| 1 |
<?php |
| 2 |
/** |
| 3 |
* Page cache engine. |
| 4 |
* |
| 5 |
* @package XSpeed |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace XSpeed; |
| 9 |
|
| 10 |
defined( 'ABSPATH' ) || exit; |
| 11 |
|
| 12 |
class Cache { |
| 13 |
|
| 14 |
/** |
| 15 |
* Output-buffer nesting level at which we opened our cache buffer, so |
| 16 |
* `close_buffer()` can flush ONLY our buffer and never disturb a buffer |
| 17 |
* another plugin pushed on top of (or below) ours. |
| 18 |
* |
| 19 |
* @var int|null |
| 20 |
*/ |
| 21 |
private static $buffer_level = null; |
| 22 |
|
| 23 |
/** |
| 24 |
* The `X-XSpeed-Cache` value decided for this request, and — when the |
| 25 |
* decision was BYPASS — the slug of the gate that made it. |
| 26 |
* |
| 27 |
* Recorded as well as sent so unit tests (CLI SAPI, where header() is a |
| 28 |
* no-op and headers_sent() is meaningless) can assert on the decision. |
| 29 |
* |
| 30 |
* @var string |
| 31 |
*/ |
| 32 |
private static $status_header = ''; |
| 33 |
private static $bypass_reason = ''; |
| 34 |
|
| 35 |
/** |
| 36 |
* Cache key whose write was deferred to shutdown because a render-time |
| 37 |
* translation plugin's buffer wraps ours. Null on every ordinary request. |
| 38 |
* |
| 39 |
* @var string|null |
| 40 |
*/ |
| 41 |
private static $deferred_key = null; |
| 42 |
|
| 43 |
/** |
| 44 |
* Translated page HTML captured by the outer buffer, for the deferred |
| 45 |
* write. Only populated when a translation plugin is active. |
| 46 |
* |
| 47 |
* @var string |
| 48 |
*/ |
| 49 |
private static $translated_output = ''; |
| 50 |
|
| 51 |
/** |
| 52 |
* Did finalize_buffer() run to completion on this request? |
| 53 |
* |
| 54 |
* The deferred translated write runs as a PHP shutdown function, which |
| 55 |
* fires after a `wp_die()` or a bare `exit()` exactly as it does after a |
| 56 |
* clean render. Only finalize_buffer() sets this, and only at the point |
| 57 |
* where it has the full buffer in hand — so an aborted render leaves it |
| 58 |
* false and the writer declines rather than caching a truncated page |
| 59 |
* under the real key. |
| 60 |
* |
| 61 |
* @var bool |
| 62 |
*/ |
| 63 |
private static $render_completed = false; |
| 64 |
|
| 65 |
/** |
| 66 |
* Hooks that get an argument-aware handler instead of a blanket purge. |
| 67 |
* |
| 68 |
* Each fires on an ordinary visitor action — an order, a review, a |
| 69 |
* registration — where purge_all() cannot see WHAT changed and so wiped |
| 70 |
* the whole cache on every one. They are re-bound further down to |
| 71 |
* handlers that inspect the payload first. |
| 72 |
* |
| 73 |
* Listed here so the generic invalidation loop skips them. It binds a |
| 74 |
* closure (to name the cause), and a closure cannot be unbound by the |
| 75 |
* remove_action() pairs below — binding one would leave the coarse purge |
| 76 |
* running alongside its replacement and silently undo #243. |
| 77 |
*/ |
| 78 |
private const TARGETED_INVALIDATION_HOOKS = array( |
| 79 |
'save_post', |
| 80 |
'comment_post', |
| 81 |
'user_register', |
| 82 |
'profile_update', |
| 83 |
); |
| 84 |
|
| 85 |
public function __construct() { |
| 86 |
/** |
| 87 |
* When the page-cache output buffer opens. |
| 88 |
* |
| 89 |
* Filterable because buffer ORDER decides what gets cached. PHP's |
| 90 |
* output buffers are LIFO: the last one opened is innermost, and its |
| 91 |
* callback runs first. A render-time translation plugin that opens |
| 92 |
* an outer buffer therefore translates AFTER we have already captured |
| 93 |
* and cached the raw HTML — see translation_buffer_compat(). |
| 94 |
* |
| 95 |
* @param string $hook Hook to open the buffer on. |
| 96 |
* @param int $priority Priority for that hook. |
| 97 |
*/ |
| 98 |
$hook = (string) apply_filters( 'xspeed_cache_buffer_hook', 'template_redirect' ); |
| 99 |
$priority = (int) apply_filters( 'xspeed_cache_buffer_priority', 0 ); |
| 100 |
add_action( $hook, array( $this, 'maybe_start_cache' ), $priority ); |
| 101 |
|
| 102 |
// When a render-time translation plugin is present, open one extra |
| 103 |
// buffer OUTSIDE its own so we can capture post-translation HTML. |
| 104 |
// TranslatePress opens on `init` priority 0, so we take a negative |
| 105 |
// priority to land outside it. This buffer only collects bytes for |
| 106 |
// the deferred cache write — it never modifies the response. |
| 107 |
add_action( |
| 108 |
'init', |
| 109 |
static function () { |
| 110 |
if ( ! self::translation_plugin_active() ) { |
| 111 |
return; |
| 112 |
} |
| 113 |
// `init` fires on EVERY request type, and |
| 114 |
// translation_plugin_active() is a class_exists() check that |
| 115 |
// is true site-wide — so without this guard the buffer opened |
| 116 |
// on REST, admin-ajax, cron and WP-CLI too. None of those |
| 117 |
// reach template_redirect, so $deferred_key stays null and |
| 118 |
// the collected bytes are never released: a long-running |
| 119 |
// WP-CLI command copied every byte of its output into a |
| 120 |
// string that grew for the life of the process. |
| 121 |
if ( is_admin() |
| 122 |
|| wp_doing_ajax() |
| 123 |
|| wp_doing_cron() |
| 124 |
|| ( defined( 'REST_REQUEST' ) && REST_REQUEST ) |
| 125 |
|| ( defined( 'WP_CLI' ) && WP_CLI ) |
| 126 |
|| ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) { |
| 127 |
return; |
| 128 |
} |
| 129 |
ob_start( |
| 130 |
static function ( $chunk ) { |
| 131 |
self::$translated_output .= $chunk; |
| 132 |
return $chunk; |
| 133 |
} |
| 134 |
); |
| 135 |
}, |
| 136 |
(int) apply_filters( 'xspeed_translation_outer_buffer_priority', -100 ) |
| 137 |
); |
| 138 |
|
| 139 |
// Events that should invalidate cached output. Beyond posts/comments, |
| 140 |
// this covers user and term changes — the REST cache can serve |
| 141 |
// /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect |
| 142 |
// rendered author bylines / term-archive pages. Without them, an edit |
| 143 |
// left the matching endpoint (and archives) stale for the full TTL. |
| 144 |
// (FBS-82408) |
| 145 |
$invalidate_hooks = array( |
| 146 |
'save_post', 'deleted_post', 'trashed_post', |
| 147 |
'comment_post', 'wp_set_comment_status', |
| 148 |
'switch_theme', 'activated_plugin', 'deactivated_plugin', |
| 149 |
// Users → /wp/v2/users + author archives. |
| 150 |
'profile_update', 'user_register', 'deleted_user', |
| 151 |
// Terms → /wp/v2/{taxonomy} + term archives. |
| 152 |
'created_term', 'edited_term', 'delete_term', |
| 153 |
// Menu structure changes (reorder, rename, assign to a location) |
| 154 |
// fire only here — the per-item `nav_menu_item` save_post does |
| 155 |
// not cover them. (#270 regression) |
| 156 |
'wp_update_nav_menu', |
| 157 |
); |
| 158 |
foreach ( $invalidate_hooks as $hook ) { |
| 159 |
// Name the hook in the cause rather than binding purge_all bare. |
| 160 |
// Bound bare, WordPress passes the action's own first argument |
| 161 |
// into $cause — a term id, a user id, a menu id — so the activity |
| 162 |
// feed read "Cache purged (12)" and told the user nothing about |
| 163 |
// what happened. (#270 QA round 2) |
| 164 |
// |
| 165 |
// The four hooks that get an argument-aware handler below |
| 166 |
// (save_post, comment_post, user_register, profile_update) are |
| 167 |
// deliberately NOT wired here: a closure cannot be unbound by |
| 168 |
// remove_action(), so binding one would leave the coarse purge in |
| 169 |
// place alongside its replacement and silently undo #243. Skipping |
| 170 |
// them is equivalent — each is re-added with its own handler, and |
| 171 |
// each of those names its own cause. |
| 172 |
if ( in_array( $hook, self::TARGETED_INVALIDATION_HOOKS, true ) ) { |
| 173 |
continue; |
| 174 |
} |
| 175 |
add_action( |
| 176 |
$hook, |
| 177 |
static function () use ( $hook ): void { |
| 178 |
self::purge_all( 'hook:' . $hook ); |
| 179 |
} |
| 180 |
); |
| 181 |
add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 182 |
} |
| 183 |
|
| 184 |
// Updating a plugin, theme or core changes the markup and the assets |
| 185 |
// a page is built from, but fires NONE of the hooks above: WordPress |
| 186 |
// does not deactivate and reactivate a plugin to update it, so |
| 187 |
// `activated_plugin` never runs and the cached HTML survives the |
| 188 |
// update untouched for the whole TTL — up to 7 days on the Aggressive |
| 189 |
// preset, 30 at the maximum. |
| 190 |
// |
| 191 |
// The stale copy is not merely old, it is wrong in a way the user |
| 192 |
// cannot see the cause of: they update a plugin to get a fix, the |
| 193 |
// cache keeps serving the pre-fix HTML, and the update looks like it |
| 194 |
// did nothing. Minified assets do regenerate on their own (their key |
| 195 |
// includes the source filemtime), which makes it worse rather than |
| 196 |
// better — the cached pages still link the PREVIOUS hashes. |
| 197 |
// |
| 198 |
// Purge unconditionally on any completed update. Scoping it to |
| 199 |
// "plugins that enqueue front-end assets" is not knowable here, and a |
| 200 |
// cold cache after an update is the cheaper mistake. (#269) |
| 201 |
add_action( 'upgrader_process_complete', array( __CLASS__, 'purge_after_upgrade' ), 10, 2 ); |
| 202 |
// The replacement signal has to outlive OUR listener: add-ons read it |
| 203 |
// through upgrade_replaced_code() from their own priority-10 callbacks, |
| 204 |
// and consuming it inside purge_after_upgrade() meant whoever |
| 205 |
// registered second saw false. Cleared at the END of the dispatch |
| 206 |
// instead, once every listener has had its turn. |
| 207 |
// |
| 208 |
// Depth-counted, because this action NESTS. Core hangs |
| 209 |
// Language_Pack_Upgrader::async_upgrade() on it at priority 20 |
| 210 |
// (wp-admin/includes/admin-filters.php), and that runs a whole |
| 211 |
// upgrader of its own, which fires this same action again. A flat |
| 212 |
// reset therefore fired while the OUTER dispatch was still running — |
| 213 |
// on any site with pending translations — and every listener after |
| 214 |
// priority 20 read the cleared signal as false. Which is the bug this |
| 215 |
// pair exists to fix, back again and harder to see. (#303) |
| 216 |
add_action( 'upgrader_process_complete', array( __CLASS__, 'note_upgrade_dispatch' ), PHP_INT_MIN ); |
| 217 |
add_action( 'upgrader_process_complete', array( __CLASS__, 'forget_cleared_destination' ), PHP_INT_MAX ); |
| 218 |
// WordPress labels an upload-and-replace as an INSTALL, so the action |
| 219 |
// alone cannot tell "added beside nothing" from "replaced live code". |
| 220 |
// This filter fires only when the upgrader removed an existing copy, |
| 221 |
// which is exactly the difference. Registered as a filter listener |
| 222 |
// that returns its input untouched. (#303) |
| 223 |
add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 1 ); |
| 224 |
// Unattended auto-updates are the case that matters most here: they |
| 225 |
// land overnight with nobody around to purge by hand, which is the |
| 226 |
// exact scenario the stale cache goes undiagnosed in. WordPress fires |
| 227 |
// this INSTEAD of a per-item upgrader_process_complete for some |
| 228 |
// background runs. Its payload is a results array keyed by type |
| 229 |
// rather than a hook_extra, so it needs its own handler — passing it |
| 230 |
// to purge_after_upgrade() landed it in the unused $upgrader slot and |
| 231 |
// left $type empty, which read as "invalidating" and purged the whole |
| 232 |
// cache for a language-pack-only run. Matches what LiteSpeed binds. |
| 233 |
// (#298) |
| 234 |
add_action( 'automatic_updates_complete', array( __CLASS__, 'purge_after_auto_updates' ), 10, 1 ); |
| 235 |
// …except the four hooks above that fire on ordinary visitor actions. |
| 236 |
// Attached bare, purge_all() can't see WHAT changed, so on a store |
| 237 |
// every order, every product review and every checkout |
| 238 |
// account-creation wiped 100% of the cache — all anonymous happy-path |
| 239 |
// actions, so the cache never reached steady state (#243). Measured: |
| 240 |
// 3 orders across 36 pageviews took the hit rate from 83% to 50% and |
| 241 |
// the average response from 23ms to 57ms. |
| 242 |
// |
| 243 |
// HPOS does NOT help: WooCommerce still writes a |
| 244 |
// `shop_order_placehold` row into wp_posts to reserve the order ID, |
| 245 |
// so save_post fires either way. The gate therefore keys on POST-TYPE |
| 246 |
// VIEWABILITY, not on storage mode — which fixes both modes at once, |
| 247 |
// and generalises to Flamingo (#229) and Tutor LMS (#231) too. |
| 248 |
remove_action( 'save_post', array( __CLASS__, 'purge_all' ) ); |
| 249 |
remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 250 |
add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 ); |
| 251 |
|
| 252 |
remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) ); |
| 253 |
remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 254 |
add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 ); |
| 255 |
|
| 256 |
remove_action( 'user_register', array( __CLASS__, 'purge_all' ) ); |
| 257 |
remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 258 |
add_action( 'user_register', array( __CLASS__, 'on_user_change' ) ); |
| 259 |
|
| 260 |
remove_action( 'profile_update', array( __CLASS__, 'purge_all' ) ); |
| 261 |
remove_action( 'profile_update', array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 262 |
add_action( 'profile_update', array( __CLASS__, 'on_user_change' ) ); |
| 263 |
|
| 264 |
// Product data lives in post meta and lookup tables, NOT in wp_posts, |
| 265 |
// so WC_Product_Data_Store_CPT::update() takes a direct $wpdb->update() |
| 266 |
// branch and save_post never fires. Anchoring invalidation on |
| 267 |
// save_post therefore missed 100% of commerce-relevant mutations: a |
| 268 |
// REST price change, wc_update_product_stock(), a CLI ->save(), and |
| 269 |
// every scheduled sale start/end left the product page, the shop and |
| 270 |
// the category archives serving the old price and stock for the full |
| 271 |
// lifetime — the store quoting one price and charging another (#242). |
| 272 |
// |
| 273 |
// This MUST ship with the gate above: once orders stop purging |
| 274 |
// everything, the accidental invalidation that was masking this |
| 275 |
// disappears, and an order that reduces stock would leave the product |
| 276 |
// page stale. |
| 277 |
if ( class_exists( 'WooCommerce' ) ) { |
| 278 |
foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) { |
| 279 |
add_action( $wc_hook, array( __CLASS__, 'purge_product' ) ); |
| 280 |
} |
| 281 |
// Direct stock writes bypass the CRUD entirely. |
| 282 |
add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'purge_product_object' ) ); |
| 283 |
add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'purge_product_object' ) ); |
| 284 |
add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) ); |
| 285 |
add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) ); |
| 286 |
} |
| 287 |
|
| 288 |
add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 ); |
| 289 |
|
| 290 |
// …and the same for every PER-MODULE option. The handler above only |
| 291 |
// ever watched the legacy `xspeed_options` blob, but every module has |
| 292 |
// since migrated to its own `xspeed_module_<slug>` option and no hook |
| 293 |
// followed — so changing Minify HTML, Lazy Load, Remove Query Strings |
| 294 |
// etc. left the cached HTML untouched until the TTL expired (24h by |
| 295 |
// default) and the feature read as broken. (#205) |
| 296 |
// |
| 297 |
// One central listener rather than a hook per module: it covers Pro |
| 298 |
// modules with no cross-repo change, and a new module can't forget to |
| 299 |
// wire it up. |
| 300 |
add_action( 'updated_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 ); |
| 301 |
// `added_option` matters as much as `updated_option`: on a fresh install |
| 302 |
// a module's option doesn't exist yet, so the FIRST save of every panel |
| 303 |
// goes through add_option() and would otherwise skip the purge — the |
| 304 |
// original bug surviving one save per module. `deleted_option` covers a |
| 305 |
// reset-to-defaults, which changes rendered HTML just as much. (#205) |
| 306 |
add_action( 'added_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 ); |
| 307 |
add_action( 'deleted_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 ); |
| 308 |
|
| 309 |
add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 ); |
| 310 |
add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) ); |
| 311 |
} |
| 312 |
|
| 313 |
public static function on_settings_change( $old, $new ) { |
| 314 |
// gzip_enabled moved to xspeed_module_gzip — GzipModule owns the |
| 315 |
// .htaccess flip via its own update_option_xspeed_module_gzip hook. |
| 316 |
// Same migration is planned for cache_expiry + excluded_urls |
| 317 |
// (Cache module). Keep this handler around for whatever still |
| 318 |
// lives in the legacy blob (cache_enabled is special and goes |
| 319 |
// through Cache::toggle anyway). |
| 320 |
|
| 321 |
// Any settings change — purge caches so changes take effect. |
| 322 |
self::purge_all( 'settings change' ); |
| 323 |
Minifier::purge_minified(); |
| 324 |
} |
| 325 |
|
| 326 |
/** |
| 327 |
* Modules whose settings cannot change rendered HTML, so a write to them |
| 328 |
* doesn't warrant throwing away the page cache. |
| 329 |
* |
| 330 |
* The safe default is to purge: a module is listed here only when it is |
| 331 |
* clearly incapable of altering front-end output (diagnostics, the MCP |
| 332 |
* server, licensing/telemetry surfaces). When in doubt, leave it off the |
| 333 |
* list — a needless purge costs a re-render, a missed one makes the |
| 334 |
* feature look broken. (#205) |
| 335 |
* |
| 336 |
* @return string[] Module slugs. |
| 337 |
*/ |
| 338 |
public static function non_rendering_modules(): array { |
| 339 |
return (array) apply_filters( |
| 340 |
'xspeed_non_rendering_modules', |
| 341 |
array( |
| 342 |
'mcp', // AI endpoint — no front-end output. |
| 343 |
'health', // diagnostics only. |
| 344 |
'support', // support snapshot. |
| 345 |
'score', // PageSpeed/GTmetrix runner. |
| 346 |
'migration', // one-shot importer. |
| 347 |
'settings', // import/export surface. |
| 348 |
'cache-coverage', // read-only reporting. |
| 349 |
'ai-privacy', // consent flags for AI surfaces. |
| 350 |
'database', // DB cleanup schedule — no HTML impact. |
| 351 |
// Pro slugs — listed by name rather than by asking Pro, so |
| 352 |
// Free stays unaware of it. A Pro module absent here simply |
| 353 |
// purges, which is the safe default. |
| 354 |
'license', |
| 355 |
'pro_status', |
| 356 |
'analytics', |
| 357 |
'performance-health', |
| 358 |
'recommendations', |
| 359 |
'ai-provider', |
| 360 |
'migration-pro', |
| 361 |
) |
| 362 |
); |
| 363 |
} |
| 364 |
|
| 365 |
/** |
| 366 |
* Purge when ANY module's settings option is written. (#205) |
| 367 |
* |
| 368 |
* Bound to `updated_option`, `added_option` and `deleted_option` — all three |
| 369 |
* fire for every option on the site, so the prefix test comes first and is |
| 370 |
* the cheap path for the ~99% of writes that aren't ours. All three pass the |
| 371 |
* option name first, which is why this can't hook purge_all() directly: |
| 372 |
* that takes $cause first, so every purge would be filed under a cause |
| 373 |
* literally named "xspeed_module_minify". |
| 374 |
* |
| 375 |
* @param string $option Option name that was just written or removed. |
| 376 |
*/ |
| 377 |
public static function on_module_settings_change( $option ): void { |
| 378 |
$option = (string) $option; |
| 379 |
$prefix = Settings_Manager::OPTION_PREFIX; |
| 380 |
if ( 0 !== strpos( $option, $prefix ) ) { |
| 381 |
return; |
| 382 |
} |
| 383 |
|
| 384 |
$slug = substr( $option, strlen( $prefix ) ); |
| 385 |
if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) { |
| 386 |
return; |
| 387 |
} |
| 388 |
|
| 389 |
// Guard against re-entry: purge_all() and purge_minified() can write |
| 390 |
// options of their own (stats, timestamps), and a nested purge would |
| 391 |
// both waste work and risk recursing through this same hook. |
| 392 |
static $purging = false; |
| 393 |
if ( $purging ) { |
| 394 |
return; |
| 395 |
} |
| 396 |
$purging = true; |
| 397 |
|
| 398 |
self::purge_all( 'settings change' ); |
| 399 |
Minifier::purge_minified(); |
| 400 |
|
| 401 |
$purging = false; |
| 402 |
} |
| 403 |
|
| 404 |
/** |
| 405 |
* Stamp the request's cache decision on the response. |
| 406 |
* |
| 407 |
* `X-XSpeed-Cache` was only ever written on the serve-from-cache paths, |
| 408 |
* so a miss and a deliberate bypass both came back with no header at all |
| 409 |
* — indistinguishable from a `curl -I`, the first thing anyone reaches |
| 410 |
* for when a site "isn't caching" (issue #10). The reason slug rides |
| 411 |
* along on `X-XSpeed-Reason`, but only under WP_DEBUG so production |
| 412 |
* responses stay clean. Slugs are fixed per gate — never the matched |
| 413 |
* pattern, cookie or user-agent, which would echo request input back. |
| 414 |
* |
| 415 |
* @param string $value HIT (php) | MISS | BYPASS. |
| 416 |
* @param string $reason Fixed slug naming the gate, for BYPASS only. |
| 417 |
*/ |
| 418 |
private static function mark( string $value, string $reason = '' ): void { |
| 419 |
self::$status_header = $value; |
| 420 |
self::$bypass_reason = $reason; |
| 421 |
|
| 422 |
if ( headers_sent() ) { |
| 423 |
return; |
| 424 |
} |
| 425 |
header( 'X-XSpeed-Cache: ' . $value ); |
| 426 |
if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) { |
| 427 |
header( 'X-XSpeed-Reason: ' . $reason ); |
| 428 |
} |
| 429 |
} |
| 430 |
|
| 431 |
/** Record a bypass gate and answer "don't cache" in one statement. */ |
| 432 |
private static function bypass( string $reason ): bool { |
| 433 |
self::mark( 'BYPASS', $reason ); |
| 434 |
return false; |
| 435 |
} |
| 436 |
|
| 437 |
/** The X-XSpeed-Cache value decided for this request ('' if none yet). */ |
| 438 |
public static function status_header(): string { |
| 439 |
return self::$status_header; |
| 440 |
} |
| 441 |
|
| 442 |
/** The bypass gate slug for this request ('' unless BYPASS). */ |
| 443 |
public static function bypass_reason(): string { |
| 444 |
return self::$bypass_reason; |
| 445 |
} |
| 446 |
|
| 447 |
/** |
| 448 |
* Bypass gates that describe THE VISITOR rather than THIS REQUEST. |
| 449 |
* |
| 450 |
* Only these may be recorded in the bypass cookie. A visitor-scoped |
| 451 |
* verdict stays true for the visitor's next request — they are still |
| 452 |
* logged in, still hold a cart cookie — so the web server can act on |
| 453 |
* it without booting PHP. |
| 454 |
* |
| 455 |
* Every other gate describes the request in front of us: its method, |
| 456 |
* its URL, its query string, the client's user agent. Persisting one |
| 457 |
* of those pins a visitor to the uncached path over a property that |
| 458 |
* was never theirs to begin with. (#218) |
| 459 |
*/ |
| 460 |
private const VISITOR_SCOPED_BYPASS = array( 'logged-in', 'excluded-cookie' ); |
| 461 |
|
| 462 |
/** |
| 463 |
* Whether $reason describes the visitor (persist it) or merely this |
| 464 |
* request (don't). |
| 465 |
* |
| 466 |
* Split out as a pure function because it is the whole decision behind |
| 467 |
* the bypass cookie, and the cookie write itself (setcookie()) can't be |
| 468 |
* asserted in a unit test. |
| 469 |
*/ |
| 470 |
public static function bypass_is_visitor_scoped( string $reason ): bool { |
| 471 |
return in_array( $reason, self::VISITOR_SCOPED_BYPASS, true ); |
| 472 |
} |
| 473 |
|
| 474 |
public function maybe_start_cache() { |
| 475 |
if ( ! self::should_cache() ) { |
| 476 |
// PHP has just evaluated the FULL exclusion rule list — including |
| 477 |
// the `~regex` patterns the server config can't express — and |
| 478 |
// decided this response must not be served from cache. Record that |
| 479 |
// verdict in the conventional bypass cookie so the web server can |
| 480 |
// enforce it on subsequent requests without starting PHP. |
| 481 |
// |
| 482 |
// This is what stops most settings changes from needing an nginx |
| 483 |
// reload: the config tests one fixed cookie name forever, and the |
| 484 |
// rule list behind it can change freely. |
| 485 |
// |
| 486 |
// But ONLY when the verdict is about the visitor. A request-shape |
| 487 |
// gate — `non-get` above all — says nothing about who is asking, |
| 488 |
// and persisting it pinned that visitor to the uncached path for |
| 489 |
// the rest of their session: one search-form POST, one comment, |
| 490 |
// one `curl -I` from an uptime monitor, and every later GET |
| 491 |
// bypassed. It could not self-heal either, because the bypass |
| 492 |
// cookie is itself in excluded_cookies, so the next GET bypassed |
| 493 |
// with `excluded-cookie` and landed right back here, where |
| 494 |
// sync_bypass_cookie()'s no-change short-circuit left the cookie |
| 495 |
// exactly where it was. (#218) |
| 496 |
if ( self::bypass_is_visitor_scoped( self::bypass_reason() ) ) { |
| 497 |
self::sync_bypass_cookie( true ); |
| 498 |
} |
| 499 |
return; |
| 500 |
} |
| 501 |
|
| 502 |
// Cacheable: clear any stale bypass cookie, or a visitor who once |
| 503 |
// had a cart would keep skipping the fast path long after checkout. |
| 504 |
self::sync_bypass_cookie( false ); |
| 505 |
|
| 506 |
$key = self::cache_key(); |
| 507 |
$file = self::cache_file_for( $key ); |
| 508 |
|
| 509 |
if ( file_exists( $file ) && ! self::is_expired( $file ) ) { |
| 510 |
Hit_Counter::record_hit(); |
| 511 |
// Emit the HIT marker on THIS path too. The drop-in |
| 512 |
// (advanced-cache.php) sends "HIT (php)" and the nginx static |
| 513 |
// rewrite sends "HIT (nginx)", but this template_redirect |
| 514 |
// serve path — the one that runs when the drop-in isn't loaded |
| 515 |
// (e.g. WP_CACHE not true) — previously streamed the cached |
| 516 |
// file with NO marker, so a genuine HIT looked like a MISS in |
| 517 |
// the response headers. Same header + value as the drop-in. |
| 518 |
self::mark( 'HIT (php)' ); |
| 519 |
// Replay stored response bits so the HIT matches the original: |
| 520 |
// a non-HTML Content-Type (cached feeds, sitemaps) and a non-200 |
| 521 |
// status (a cached 404 must serve 404, not 200). No-op for |
| 522 |
// ordinary pages, which write no .meta. |
| 523 |
$meta = self::read_meta( $key ); |
| 524 |
if ( ! headers_sent() ) { |
| 525 |
if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) { |
| 526 |
http_response_code( (int) $meta['status'] ); |
| 527 |
} |
| 528 |
if ( ! empty( $meta['content_type'] ) && is_string( $meta['content_type'] ) ) { |
| 529 |
header( 'Content-Type: ' . $meta['content_type'] ); |
| 530 |
} |
| 531 |
// Conditional GET: emit Last-Modified + ETag and answer a |
| 532 |
// matching If-Modified-Since / If-None-Match with 304 so |
| 533 |
// aggregators (and browsers) skip re-downloading an unchanged |
| 534 |
// cached response — the bandwidth win feeds are about. |
| 535 |
// (FBS-82407 #5) |
| 536 |
if ( self::serve_not_modified( $file ) ) { |
| 537 |
exit; // 304 sent, no body. |
| 538 |
} |
| 539 |
} |
| 540 |
// Serve the precompressed Brotli sibling when the client accepts |
| 541 |
// it (an add-on, the Pro Brotli module, wrote <file>.br). On this |
| 542 |
// PHP serve path the web server never sees the .br, so without |
| 543 |
// this a br-capable client got the plain .html — precompression |
| 544 |
// did nothing here. Falls through to plain readfile otherwise. |
| 545 |
$br = self::maybe_serve_brotli( $file ); |
| 546 |
if ( null !== $br ) { |
| 547 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a static cache file directly; WP_Filesystem would buffer through PHP memory and is not appropriate for response streaming. |
| 548 |
readfile( $br ); |
| 549 |
exit; |
| 550 |
} |
| 551 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- readfile is optimal for streaming a static cache file directly to the visitor; WP_Filesystem would buffer through PHP memory and is not appropriate for response streaming. |
| 552 |
readfile( $file ); |
| 553 |
exit; |
| 554 |
} |
| 555 |
|
| 556 |
// Cache miss → render fresh + write cache. On LiteSpeed we send an |
| 557 |
// explicit "stand down" header so the server's LSCache module does |
| 558 |
// NOT cache + shadow our response — xSpeed's own .htaccess static |
| 559 |
// rewrite owns hit serving (and hit accounting) here, exactly as on |
| 560 |
// Apache. See maybe_emit_lscache_headers() for the full rationale. |
| 561 |
self::maybe_emit_lscache_headers(); |
| 562 |
|
| 563 |
// We're about to render fresh + cache → miss for this request. |
| 564 |
// …UNLESS this request is a 404 or a known bot/scanner. Those reach the |
| 565 |
// render path too, but counting them as cache misses makes the ratio |
| 566 |
// meaningless — a wave of `/wp-x7.php` scanner 404s reads as a collapsing |
| 567 |
// cache when nothing is wrong. Runs at template_redirect (priority 0), so |
| 568 |
// is_404() is already resolved. Excluded requests are tallied separately |
| 569 |
// for the "you absorbed N scanner hits" line, not dropped. (#118) |
| 570 |
if ( self::miss_is_excluded() ) { |
| 571 |
Hit_Counter::record_excluded(); |
| 572 |
} else { |
| 573 |
Hit_Counter::record_miss(); |
| 574 |
} |
| 575 |
|
| 576 |
// Stamp it, so "eligible but not cached yet" is visibly different |
| 577 |
// from "deliberately bypassed" (issue #10). Headers can't be sent |
| 578 |
// after the body starts, so this has to happen here, not in |
| 579 |
// finalize_buffer() — nothing has been output at template_redirect. |
| 580 |
self::mark( 'MISS' ); |
| 581 |
|
| 582 |
|
| 583 |
// WP < 6.9 fallback: ob_start() with a callback, paired with an |
| 584 |
// explicit shutdown close so the buffer lifecycle is visible to |
| 585 |
// reviewers and Plugin Check, instead of relying on PHP's implicit |
| 586 |
// request-end flush. We record our nesting level so close_buffer() |
| 587 |
// flushes ONLY the buffer we opened. |
| 588 |
ob_start( array( __CLASS__, 'finalize_buffer' ) ); |
| 589 |
self::$buffer_level = ob_get_level(); |
| 590 |
|
| 591 |
add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 ); |
| 592 |
} |
| 593 |
|
| 594 |
/** |
| 595 |
* Close the cache buffer opened by maybe_start_cache(). |
| 596 |
* |
| 597 |
* Guarded by the recorded buffer level so we never flush a buffer that |
| 598 |
* another plugin pushed on top of (or under) ours. If something else is |
| 599 |
* currently on top, we leave the stack alone — PHP's shutdown sequence |
| 600 |
* will unwind buffers in order and our finalize_buffer() callback will |
| 601 |
* still run when our level becomes the topmost one. |
| 602 |
*/ |
| 603 |
public static function close_buffer() { |
| 604 |
if ( null === self::$buffer_level ) { |
| 605 |
return; |
| 606 |
} |
| 607 |
if ( ob_get_level() === self::$buffer_level ) { |
| 608 |
ob_end_flush(); |
| 609 |
} |
| 610 |
self::$buffer_level = null; |
| 611 |
} |
| 612 |
|
| 613 |
/** |
| 614 |
* Are we buffering this request? |
| 615 |
* |
| 616 |
* Asked by Css_Combine_Buffer, which needs the finished HTML but must not |
| 617 |
* open a second buffer when this one is already going to hand it the page |
| 618 |
* through `xspeed_cache_final_html`. False here means the request is not |
| 619 |
* cacheable — cache off, excluded URL, logged in — and the combiner has to |
| 620 |
* provide its own buffer or it silently stops working. (#195) |
| 621 |
*/ |
| 622 |
public static function is_buffering(): bool { |
| 623 |
return null !== self::$buffer_level; |
| 624 |
} |
| 625 |
|
| 626 |
/** |
| 627 |
* Is a render-time translation plugin going to wrap our output buffer? |
| 628 |
* |
| 629 |
* TranslatePress opens its translation buffer on `init` priority 0. We |
| 630 |
* open ours on `template_redirect`, which runs much later, so ours nests |
| 631 |
* INSIDE theirs. PHP unwinds output buffers LIFO — innermost callback |
| 632 |
* first — so `finalize_buffer()` saw the raw, pre-translation HTML and |
| 633 |
* cached that, while the live visitor still got the translated bytes from |
| 634 |
* TRP's outer buffer. |
| 635 |
* |
| 636 |
* Result: the first (MISS) visitor to /fr/some-page/ got correct French; |
| 637 |
* every visitor after got English body text under a `lang="fr-FR"` |
| 638 |
* document, plus TRP's internal `#TRPLINKPROCESSED` link markers, which |
| 639 |
* TRP strips at the very end of its own buffer and which therefore leak |
| 640 |
* into anything captured from inside it. |
| 641 |
* |
| 642 |
* Note the ordering cannot be fixed from TRP's side: its |
| 643 |
* `trp_start_output_buffer_priority` filter only moves the PRIORITY on |
| 644 |
* `init`, and `init` always fires before `template_redirect` whatever the |
| 645 |
* priority. The buffer that has to move is ours. |
| 646 |
* |
| 647 |
* Detected by main class rather than plugin path, so a renamed directory |
| 648 |
* or a bundled copy still matches. |
| 649 |
*/ |
| 650 |
public static function translation_plugin_active(): bool { |
| 651 |
$active = class_exists( 'TRP_Translate_Press' ); |
| 652 |
|
| 653 |
/** |
| 654 |
* Whether to treat this request as wrapped by a translation buffer. |
| 655 |
* |
| 656 |
* Lets a site add another render-time translation plugin (or opt out) |
| 657 |
* without patching the engine. |
| 658 |
* |
| 659 |
* @param bool $active |
| 660 |
*/ |
| 661 |
return (bool) apply_filters( 'xspeed_translation_plugin_active', $active ); |
| 662 |
} |
| 663 |
|
| 664 |
/** |
| 665 |
* Write the cache file for a request whose output was wrapped by a |
| 666 |
* render-time translation plugin. |
| 667 |
* |
| 668 |
* Registered as a PHP shutdown function (not a WP `shutdown` action) so |
| 669 |
* it runs after PHP has unwound the output-buffer stack — by which point |
| 670 |
* the translation plugin's callback has transformed the bytes and its |
| 671 |
* internal markers are gone. |
| 672 |
* |
| 673 |
* finalize_buffer() has already applied the status gate, the |
| 674 |
* xspeed_cache_final_html filter and HTML minification to the |
| 675 |
* untranslated copy and then declined to write it. Here we re-run only |
| 676 |
* what's needed on the translated bytes: minify, write, and fire the |
| 677 |
* same downstream hooks so Brotli / static-tree listeners behave |
| 678 |
* identically to the ordinary path. |
| 679 |
*/ |
| 680 |
public static function write_deferred_translated_cache(): void { |
| 681 |
$key = self::$deferred_key; |
| 682 |
self::$deferred_key = null; |
| 683 |
|
| 684 |
// Release the collected bytes BEFORE the early return, so the static |
| 685 |
// is cleared on every path rather than only when a key survived. |
| 686 |
$full = self::$translated_output; |
| 687 |
self::$translated_output = ''; |
| 688 |
|
| 689 |
$completed = self::$render_completed; |
| 690 |
self::$render_completed = false; |
| 691 |
|
| 692 |
if ( null === $key ) { |
| 693 |
return; |
| 694 |
} |
| 695 |
|
| 696 |
// Did the render actually finish? |
| 697 |
// |
| 698 |
// This runs as a PHP shutdown function, which fires after a wp_die() |
| 699 |
// or a bare exit() just as readily as after a clean render — but in |
| 700 |
// those cases finalize_buffer() never returned, so the bytes we hold |
| 701 |
// are a page that was cut off partway through. The length and |
| 702 |
// TRPLINKPROCESSED checks below don't catch that: a fatal after the |
| 703 |
// footer's translated markup is both over 255 bytes and free of TRP |
| 704 |
// markers, i.e. truncated but entirely plausible. Caching it would |
| 705 |
// freeze a half-rendered page under the real key for the full TTL. |
| 706 |
// |
| 707 |
// Serving this one URL uncached is the cheap failure; the corrupt |
| 708 |
// cache entry is the expensive one. |
| 709 |
if ( ! $completed ) { |
| 710 |
return; |
| 711 |
} |
| 712 |
|
| 713 |
if ( strlen( $full ) < 255 ) { |
| 714 |
return; |
| 715 |
} |
| 716 |
|
| 717 |
// Refuse to cache a copy still carrying the translation plugin's |
| 718 |
// internal link markers. TRP strips these at the very end of its own |
| 719 |
// buffer, so their presence means we captured too early — and a |
| 720 |
// cached page containing them is SEO-visible damage. Better to serve |
| 721 |
// this URL uncached than to freeze broken markup for the full TTL. |
| 722 |
if ( false !== strpos( $full, 'TRPLINKPROCESSED' ) ) { |
| 723 |
return; |
| 724 |
} |
| 725 |
|
| 726 |
$minify_opts = Settings_Manager::get( 'minify' ); |
| 727 |
if ( ! empty( $minify_opts['minify_html'] ) ) { |
| 728 |
$full = Minifier::minify_html( $full ); |
| 729 |
} |
| 730 |
$full = self::signed( $full ); |
| 731 |
|
| 732 |
// Per-site directory: on multisite every blog shares this tree, so |
| 733 |
// entries are bucketed by host to keep one site's purge from |
| 734 |
// sweeping the whole network. (#6) |
| 735 |
self::ensure_host_dir(); |
| 736 |
|
| 737 |
// Never author a cache entry from a request that carried a query |
| 738 |
// string: cache_key() files it under the BARE url, so the params' |
| 739 |
// render would be served to every clean-URL visitor (#241). |
| 740 |
if ( self::query_string_blocks_write() ) { |
| 741 |
return; |
| 742 |
} |
| 743 |
|
| 744 |
$file = self::cache_file_for( $key ); |
| 745 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; this runs on a frontend shutdown where it's unavailable. |
| 746 |
file_put_contents( $file, $full, LOCK_EX ); |
| 747 |
|
| 748 |
/** This action is documented in includes/class-cache.php */ |
| 749 |
do_action( 'xspeed_flat_file_written', $file, $full ); |
| 750 |
|
| 751 |
self::write_meta( $key, $full ); |
| 752 |
|
| 753 |
// Static tree too, under the same gates finalize_buffer() applies — |
| 754 |
// otherwise deferring the write would silently cost translated pages |
| 755 |
// the web-server fast path and leave them on the slower drop-in. |
| 756 |
if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) { |
| 757 |
self::store_static( $full ); |
| 758 |
} |
| 759 |
} |
| 760 |
|
| 761 |
public static function should_cache() { |
| 762 |
// Reset first: a single request only reaches this once (the sole |
| 763 |
// caller is maybe_start_cache()), but tests and any future caller |
| 764 |
// must never inherit the previous request's verdict. |
| 765 |
self::$status_header = ''; |
| 766 |
self::$bypass_reason = ''; |
| 767 |
|
| 768 |
$opts = Settings::get(); |
| 769 |
if ( empty( $opts['cache_enabled'] ) ) { |
| 770 |
return self::bypass( 'cache-disabled' ); |
| 771 |
} |
| 772 |
|
| 773 |
if ( is_user_logged_in() ) { |
| 774 |
return self::bypass( 'logged-in' ); |
| 775 |
} |
| 776 |
|
| 777 |
if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { |
| 778 |
return self::bypass( 'non-frontend' ); |
| 779 |
} |
| 780 |
|
| 781 |
if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) { |
| 782 |
return self::bypass( 'donotcachepage' ); |
| 783 |
} |
| 784 |
|
| 785 |
// All exclusion knobs now owned by CacheModule. |
| 786 |
$cache_opts = Settings_Manager::get( 'cache' ); |
| 787 |
|
| 788 |
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : ''; |
| 789 |
if ( 'GET' !== $method ) { |
| 790 |
return self::bypass( 'non-get' ); |
| 791 |
} |
| 792 |
|
| 793 |
// Search-results requests carry a `s` query param, which the |
| 794 |
// query-string gate below would normally reject as "dynamic". An |
| 795 |
// add-on (xspeed-pro search cache) can opt them in: when this is a |
| 796 |
// genuine is_search() and the filter returns true, the `s` param is |
| 797 |
// treated as cacheable (the search term goes into the cache key so |
| 798 |
// different searches stay distinct — see cache_key()). |
| 799 |
$cache_search = self::should_cache_search(); |
| 800 |
|
| 801 |
// Feed opt-in is resolved BEFORE the query-string gate so query-form |
| 802 |
// feeds (/?feed=rss2, used on plain-permalink sites) aren't rejected |
| 803 |
// as "dynamic" by that gate — the `feed` param is then allowed through |
| 804 |
// just like the search `s` param. Feeds are excluded by default (the |
| 805 |
// `/feed/` pattern in excluded_urls); an add-on (xspeed-pro feed cache) |
| 806 |
// opts them back in via the filter. (FBS-82407 #4) |
| 807 |
$is_feed_request = function_exists( 'is_feed' ) && is_feed(); |
| 808 |
/** |
| 809 |
* Whether to cache the current feed request. |
| 810 |
* |
| 811 |
* Default false → feeds fall through to the normal URL-exclusion |
| 812 |
* rules (so `/feed/` keeps them out). A listener returning true |
| 813 |
* opts this feed request into caching. |
| 814 |
* |
| 815 |
* @param bool $cache_feed Whether to cache this feed request. |
| 816 |
*/ |
| 817 |
$cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false ); |
| 818 |
|
| 819 |
// Query string handling: anything OUTSIDE the ignored-params |
| 820 |
// allow-list (utm_*, fbclid, gclid by default) means a unique |
| 821 |
// request that we don't want to share with the canonical cache |
| 822 |
// entry. Skip cache rather than poison the key. |
| 823 |
// |
| 824 |
// Parse the RAW query string, NOT a sanitize_text_field() copy: |
| 825 |
// that filter strips percent-encoded octets (%XX), so `?%73=…` |
| 826 |
// would lose its `s` key here while WordPress still decodes it to |
| 827 |
// a search request — the gate would wave the request through and |
| 828 |
// cache_key() would file the search page under the bare URL, |
| 829 |
// letting an attacker poison the homepage cache with `/?%73=<spam>`. |
| 830 |
// parse_str() does its own urldecoding, matching WP's own parse, and |
| 831 |
// only the KEYS are used below (fed to Glob_Matcher → preg_match, |
| 832 |
// never echoed or executed), so no sanitization is needed here. |
| 833 |
$query_raw = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- see note above: parse_str() urldecodes to match WP; only keys are consumed, via preg_match, never output. |
| 834 |
if ( '' !== $query_raw ) { |
| 835 |
$ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array(); |
| 836 |
parse_str( $query_raw, $params ); |
| 837 |
foreach ( $params as $key => $_ ) { |
| 838 |
// Allow the search param through when search caching is on. |
| 839 |
if ( $cache_search && 's' === $key ) { |
| 840 |
continue; |
| 841 |
} |
| 842 |
// Allow query-form feed params through when feed caching opted |
| 843 |
// this request in (?feed=rss2 / &withcomments=1 on feeds). |
| 844 |
if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) { |
| 845 |
continue; |
| 846 |
} |
| 847 |
if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) { |
| 848 |
// Slug only — never the param name, which is attacker- |
| 849 |
// controlled and would be reflected into a header. |
| 850 |
return self::bypass( 'query-param' ); |
| 851 |
} |
| 852 |
} |
| 853 |
} |
| 854 |
|
| 855 |
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; |
| 856 |
$path = (string) strtok( $request_uri, '?' ); |
| 857 |
|
| 858 |
$excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array(); |
| 859 |
if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) { |
| 860 |
return self::bypass( 'excluded-url' ); |
| 861 |
} |
| 862 |
|
| 863 |
// Cookie-based exclusion. We only check cookie NAMES (matching |
| 864 |
// values would leak content-sensitive logic into the cache key |
| 865 |
// rules); presence of any matching cookie name skips cache. |
| 866 |
$excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array(); |
| 867 |
if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) { |
| 868 |
foreach ( array_keys( $_COOKIE ) as $cookie_name ) { |
| 869 |
// Our own bypass cookie is a RECORD of a previous verdict, not |
| 870 |
// evidence about this visitor, so it never gets a vote here. |
| 871 |
// Letting it match made the verdict self-confirming: once set, |
| 872 |
// it produced `excluded-cookie` forever, which re-set it, and |
| 873 |
// no later request could ever re-evaluate the visitor on the |
| 874 |
// rules that actually describe them. The web server still acts |
| 875 |
// on the cookie without booting PHP; when PHP does boot it is |
| 876 |
// authoritative and re-decides from scratch. (#218) |
| 877 |
if ( Server_Rules::BYPASS_COOKIE === $cookie_name ) { |
| 878 |
continue; |
| 879 |
} |
| 880 |
if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) { |
| 881 |
return self::bypass( 'excluded-cookie' ); |
| 882 |
} |
| 883 |
} |
| 884 |
} |
| 885 |
|
| 886 |
// User-agent bypass list. Substring match (not glob) since UA |
| 887 |
// strings have so much variation that glob anchoring rarely |
| 888 |
// helps and confuses users. |
| 889 |
$bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array(); |
| 890 |
if ( ! empty( $bypass_uas ) ) { |
| 891 |
$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; |
| 892 |
foreach ( $bypass_uas as $needle ) { |
| 893 |
if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) { |
| 894 |
return self::bypass( 'user-agent' ); |
| 895 |
} |
| 896 |
} |
| 897 |
} |
| 898 |
|
| 899 |
// Per-post override (Phase 3.4). Honored only on singular |
| 900 |
// post-context requests — archives / 404s / taxonomies use the |
| 901 |
// global policy above. |
| 902 |
if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) { |
| 903 |
return self::bypass( 'post-excluded' ); |
| 904 |
} |
| 905 |
|
| 906 |
/** |
| 907 |
* Final say on whether the current request is cacheable. |
| 908 |
* |
| 909 |
* Runs at template_redirect (full WP context), so listeners may use |
| 910 |
* conditional tags (is_search(), is_feed(), is_404(), |
| 911 |
* wp_is_maintenance_mode(), …). The core engine has already applied |
| 912 |
* its own exclusion rules and reached `true`; a listener returning |
| 913 |
* false vetoes caching for this request. This is the documented |
| 914 |
* extension point add-ons (xspeed-pro) hook to add their own |
| 915 |
* request-level cache policy without forking the engine. |
| 916 |
* |
| 917 |
* Note: this gates the WRITE side. The pre-WP drop-in |
| 918 |
* (advanced-cache.php) cannot run PHP filters, so request types that |
| 919 |
* must never be *served* from a stale file are handled by not |
| 920 |
* writing them here and/or by purging — see the conflict notes in |
| 921 |
* advanced-cache.php. |
| 922 |
* |
| 923 |
* @param bool $should_cache Whether to cache the current request. |
| 924 |
*/ |
| 925 |
if ( ! apply_filters( 'xspeed_should_cache', true ) ) { |
| 926 |
// One slug for every listener — a third-party callback name is |
| 927 |
// not ours to put in a response header. Which listener vetoed is |
| 928 |
// a WP_DEBUG-level question the filter itself can answer. |
| 929 |
return self::bypass( 'filtered' ); |
| 930 |
} |
| 931 |
|
| 932 |
return true; |
| 933 |
} |
| 934 |
|
| 935 |
/** |
| 936 |
* Whether the current request is a 404 we may cache. |
| 937 |
* |
| 938 |
* True only when: it's a genuine main-query is_404(), an add-on opted |
| 939 |
* in via `xspeed_should_cache_404` (default false), and the request |
| 940 |
* isn't a transient 404 we must never freeze — maintenance mode or a |
| 941 |
* 404 emitted while the DB/site is in an error state. The xspeed-pro |
| 942 |
* 404 cache flips the filter; Free never caches 404s on its own. |
| 943 |
*/ |
| 944 |
public static function should_cache_404(): bool { |
| 945 |
if ( ! function_exists( 'is_404' ) || ! is_404() ) { |
| 946 |
return false; |
| 947 |
} |
| 948 |
// Never cache a 404 served because the site is down for |
| 949 |
// maintenance — that screen disappears the moment maintenance |
| 950 |
// ends, and a cached copy would outlive it. |
| 951 |
if ( function_exists( 'wp_is_maintenance_mode' ) && wp_is_maintenance_mode() ) { |
| 952 |
return false; |
| 953 |
} |
| 954 |
|
| 955 |
/** |
| 956 |
* Whether to cache the current 404 response. |
| 957 |
* |
| 958 |
* Default false. A listener returning true opts the (genuine) |
| 959 |
* 404 into the page cache, served back for any unknown URL under |
| 960 |
* one generic key. The 404 status is preserved on the HIT. |
| 961 |
* |
| 962 |
* @param bool $cache_404 Whether to cache this 404. |
| 963 |
*/ |
| 964 |
return (bool) apply_filters( 'xspeed_should_cache_404', false ); |
| 965 |
} |
| 966 |
|
| 967 |
/** |
| 968 |
* Whether the current request is an internal search-results page we |
| 969 |
* may cache. |
| 970 |
* |
| 971 |
* True only when: it's a genuine main-query is_search() with a |
| 972 |
* non-empty term, and an add-on opted in via `xspeed_should_cache_search` |
| 973 |
* (default false). The search term is folded into the cache key (see |
| 974 |
* search_term() / cache_key()) so different searches stay distinct. |
| 975 |
* The xspeed-pro search cache flips the filter; Free never caches |
| 976 |
* search results on its own. |
| 977 |
*/ |
| 978 |
/** |
| 979 |
* Whether this response was rendered for a query string and therefore |
| 980 |
* must not be STORED under the bare-URL key. |
| 981 |
* |
| 982 |
* should_cache() lets a request through when every key is on the |
| 983 |
* `ignored_query_params` allow-list, and cache_key() then drops the |
| 984 |
* query string so `/post` and `/post?utm_source=x` share one entry. |
| 985 |
* Sharing on READ is the point of the allow-list and stays. Sharing on |
| 986 |
* WRITE is a cache-poisoning vector: the response was rendered *with* |
| 987 |
* those params, and WordPress reflects REQUEST_URI into form actions, |
| 988 |
* share links, canonical helpers and plugin smart tags. One anonymous |
| 989 |
* GET to a cold URL therefore freezes an attacker-chosen variant under |
| 990 |
* the clean URL's key, served for the whole TTL by the drop-in and by |
| 991 |
* the web server — neither of which runs these checks (issue #241). |
| 992 |
* |
| 993 |
* The allow-list keeps its benefit: a visitor arriving on |
| 994 |
* `?utm_source=…` is still SERVED the canonical cached entry. Only the |
| 995 |
* write is skipped, so the entry is authored by a clean request. |
| 996 |
* |
| 997 |
* This is the same reasoning as the `should_cache_search()` guard in |
| 998 |
* store_static() (#191), generalised to the allow-listed params. |
| 999 |
*/ |
| 1000 |
public static function request_has_query_string(): bool { |
| 1001 |
$query = isset( $_SERVER['QUERY_STRING'] ) |
| 1002 |
? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only tested for emptiness; never echoed, stored or used as a path. |
| 1003 |
: ''; |
| 1004 |
|
| 1005 |
return '' !== trim( $query ); |
| 1006 |
} |
| 1007 |
|
| 1008 |
/** |
| 1009 |
* Would authoring a cache entry from THIS request file a query-string |
| 1010 |
* render under the bare URL? |
| 1011 |
* |
| 1012 |
* The one predicate both write sites ask, so they cannot drift. |
| 1013 |
* |
| 1014 |
* Two shapes are exempt because cache_key() does NOT drop their query — |
| 1015 |
* it folds the distinguishing part into the key, so each variant gets |
| 1016 |
* its own entry and none is filed under the bare URL: |
| 1017 |
* |
| 1018 |
* - searches, keyed by `|s=<term>` (#191) |
| 1019 |
* - feeds, keyed by `|feed=<type>` — `/?feed=rss2` is the ONLY feed URL |
| 1020 |
* core generates on plain permalinks, so treating it as poisonable |
| 1021 |
* made feed caching a no-op on exactly the sites that need it |
| 1022 |
* |
| 1023 |
* @return bool True when the write must be skipped. |
| 1024 |
*/ |
| 1025 |
public static function query_string_blocks_write(): bool { |
| 1026 |
if ( ! self::request_has_query_string() ) { |
| 1027 |
return false; |
| 1028 |
} |
| 1029 |
|
| 1030 |
if ( self::should_cache_search() ) { |
| 1031 |
return false; |
| 1032 |
} |
| 1033 |
|
| 1034 |
// Feed caching is opt-in, via the same filter should_cache() reads |
| 1035 |
// to admit the feed params in the first place. |
| 1036 |
if ( function_exists( 'is_feed' ) && is_feed() |
| 1037 |
&& (bool) apply_filters( 'xspeed_should_cache_feed', false ) |
| 1038 |
) { |
| 1039 |
return false; |
| 1040 |
} |
| 1041 |
|
| 1042 |
return true; |
| 1043 |
} |
| 1044 |
|
| 1045 |
public static function should_cache_search(): bool { |
| 1046 |
if ( ! function_exists( 'is_search' ) || ! is_search() ) { |
| 1047 |
return false; |
| 1048 |
} |
| 1049 |
// Empty search (`?s=`) renders the same as a normal archive and |
| 1050 |
// carries no term to key on — let it fall through to the usual |
| 1051 |
// rules rather than caching an ambiguous entry. |
| 1052 |
if ( '' === self::search_term() ) { |
| 1053 |
return false; |
| 1054 |
} |
| 1055 |
|
| 1056 |
/** |
| 1057 |
* Whether to cache the current search-results request. |
| 1058 |
* |
| 1059 |
* Default false. A listener returning true opts the search page |
| 1060 |
* into the cache, keyed by the normalized search term. |
| 1061 |
* |
| 1062 |
* @param bool $cache_search Whether to cache this search request. |
| 1063 |
*/ |
| 1064 |
return (bool) apply_filters( 'xspeed_should_cache_search', false ); |
| 1065 |
} |
| 1066 |
|
| 1067 |
/** |
| 1068 |
* The current request's normalized search term, or '' if none. Reads |
| 1069 |
* the raw `s` query param (works on the pre-WP drop-in path too, where |
| 1070 |
* get_search_query() isn't available), trims + lowercases so |
| 1071 |
* "WordPress" and "wordpress" share one entry, and collapses internal |
| 1072 |
* whitespace. |
| 1073 |
*/ |
| 1074 |
public static function search_term(): string { |
| 1075 |
$raw = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only cache-key derivation from a public search param; no state change. |
| 1076 |
$raw = trim( $raw ); |
| 1077 |
if ( '' === $raw ) { |
| 1078 |
return ''; |
| 1079 |
} |
| 1080 |
$raw = preg_replace( '/\s+/', ' ', $raw ); |
| 1081 |
return function_exists( 'mb_strtolower' ) ? mb_strtolower( $raw ) : strtolower( $raw ); |
| 1082 |
} |
| 1083 |
|
| 1084 |
/** |
| 1085 |
* Is this query-string key on the ignored-params allow-list? Supports |
| 1086 |
* globs (`utm_*` matches `utm_source`, `utm_medium`, etc.) so users |
| 1087 |
* don't have to enumerate every UTM variant, and `~regex`. |
| 1088 |
* |
| 1089 |
* Matching is whole-name, not "contains" — a param name is an |
| 1090 |
* identifier, not a path. Under the old contains match the shipped |
| 1091 |
* default `ref` also swallowed `preference`, `product_ref` and |
| 1092 |
* `referrer`: those params were dropped from the cache key, so |
| 1093 |
* `/shop?preference=1` was served — and, on a cold entry, WRITTEN as — |
| 1094 |
* `/shop`. Same for `_ga` vs `_gallery`, and for the unanchored |
| 1095 |
* `~utm_…` default vs `my_utm_source`. A param name that is genuinely |
| 1096 |
* unknown now bypasses the cache, which is the safe direction. |
| 1097 |
*/ |
| 1098 |
private static function query_key_is_ignored( string $key, array $ignored ): bool { |
| 1099 |
return Glob_Matcher::any_match_name( $ignored, $key ); |
| 1100 |
} |
| 1101 |
|
| 1102 |
public static function cache_key() { |
| 1103 |
$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default'; |
| 1104 |
|
| 1105 |
// Cacheable 404s share ONE generic per-host entry — keying them by |
| 1106 |
// URL would let a scanner flood (millions of random paths) bloat |
| 1107 |
// the cache with identical 404 bodies. Both the write and the HIT |
| 1108 |
// lookup run through here, so they agree on the key automatically. |
| 1109 |
if ( self::should_cache_404() ) { |
| 1110 |
return md5( $host . '|404' ); |
| 1111 |
} |
| 1112 |
|
| 1113 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; |
| 1114 |
// Strip the query string from the key so /post and /post?utm_*=… |
| 1115 |
// share the same cache entry. should_cache() above already |
| 1116 |
// rejected requests with non-ignored params, so by the time we |
| 1117 |
// build the key the only params left are safe to drop. |
| 1118 |
$uri = (string) strtok( $uri, '?' ); |
| 1119 |
|
| 1120 |
// Optional device bucket: when mobile_separate is on, mobile and |
| 1121 |
// desktop responses live in different cache files so themes that |
| 1122 |
// serve different HTML by device (AMP, WPtouch, Jetpack mobile) |
| 1123 |
// can't poison each other. |
| 1124 |
$device = ''; |
| 1125 |
$opts = Settings_Manager::get( 'cache' ); |
| 1126 |
if ( ! empty( $opts['mobile_separate'] ) ) { |
| 1127 |
$device = self::is_mobile_request() ? '|m' : '|d'; |
| 1128 |
} |
| 1129 |
|
| 1130 |
// Search-results requests fold the normalized term into the key so |
| 1131 |
// /?s=foo and /?s=bar get distinct entries (the query string is |
| 1132 |
// otherwise stripped above). Only added when search caching opted |
| 1133 |
// in, so non-search URLs are unaffected. |
| 1134 |
$search = self::should_cache_search() ? '|s=' . self::search_term() : ''; |
| 1135 |
|
| 1136 |
// Query-form feeds (/?feed=rss2 vs /?feed=atom) share the same path |
| 1137 |
// once the query is stripped, so fold the feed type into the key to |
| 1138 |
// keep the flavors distinct. Pretty-permalink feeds (/feed/rss/) carry |
| 1139 |
// the type in $uri already and are unaffected. (FBS-82407 #4) |
| 1140 |
$feed = ''; |
| 1141 |
if ( function_exists( 'is_feed' ) && is_feed() && function_exists( 'get_query_var' ) ) { |
| 1142 |
$feed_type = (string) get_query_var( 'feed' ); |
| 1143 |
if ( '' !== $feed_type ) { |
| 1144 |
$feed = '|feed=' . preg_replace( '/[^a-z0-9]/i', '', $feed_type ); |
| 1145 |
} |
| 1146 |
} |
| 1147 |
|
| 1148 |
return md5( $host . $uri . $device . $search . $feed ); |
| 1149 |
} |
| 1150 |
|
| 1151 |
/** |
| 1152 |
* Server-side mobile detection. Prefers WordPress's `wp_is_mobile()` |
| 1153 |
* which uses the same UA tokens as core (so our bucket aligns with |
| 1154 |
* whatever theme-side branching uses). Falls back to a tiny inline |
| 1155 |
* detector if wp_is_mobile() isn't loaded (e.g. the drop-in path). |
| 1156 |
*/ |
| 1157 |
private static function is_mobile_request(): bool { |
| 1158 |
if ( function_exists( 'wp_is_mobile' ) ) { |
| 1159 |
return (bool) wp_is_mobile(); |
| 1160 |
} |
| 1161 |
// Fallback for the rare context where wp_is_mobile() isn't loaded. |
| 1162 |
// Mirrors core's wp_is_mobile() EXACTLY — including the |
| 1163 |
// Sec-CH-UA-Mobile client hint it checks *before* UA tokens — so the |
| 1164 |
// bucket this picks matches whatever the engine's primary path (and |
| 1165 |
// the drop-in's own copy of this logic) would pick for the same |
| 1166 |
// request. Drift here re-introduces the cross-path key mismatch. |
| 1167 |
if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) { |
| 1168 |
return '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE']; |
| 1169 |
} |
| 1170 |
$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; |
| 1171 |
if ( '' === $ua ) { |
| 1172 |
return false; |
| 1173 |
} |
| 1174 |
return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua ); |
| 1175 |
} |
| 1176 |
|
| 1177 |
/** |
| 1178 |
* Filesystem-safe directory name for a host, or '' when unusable. |
| 1179 |
* |
| 1180 |
* The charset MUST match the static tree (store_static()) and the |
| 1181 |
* drop-in's own copy, or the paths disagree about where an entry lives. |
| 1182 |
* The colon of `host:port` is stripped: it is legal in a Host header but |
| 1183 |
* not portable in a path. |
| 1184 |
* |
| 1185 |
* @param string $host Raw host, e.g. from HTTP_HOST. |
| 1186 |
* @return string Safe directory segment, or '' if nothing usable remains. |
| 1187 |
*/ |
| 1188 |
/** |
| 1189 |
* The host segment of the STATIC tree — `xspeed-static/<host>/…`, which |
| 1190 |
* the web server resolves without PHP. |
| 1191 |
* |
| 1192 |
* Different from host_dir(): here the port is folded INTO the segment |
| 1193 |
* (`localhost:8080` → `localhost8080`) rather than dropped, because the |
| 1194 |
* generated server rules have to reproduce this from their own variables |
| 1195 |
* and nginx's `$host` has no port to drop — see the `$xspeed_host` |
| 1196 |
* derivation in nginx_snippet(). Shared by the write and the purge so the |
| 1197 |
* two can't drift; when they did, purging a page on a ported host deleted |
| 1198 |
* nothing and the stale copy kept being served by the rewrite. |
| 1199 |
*/ |
| 1200 |
public static function static_host_dir( string $host ): string { |
| 1201 |
return (string) preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host ); |
| 1202 |
} |
| 1203 |
|
| 1204 |
public static function host_dir( string $host ): string { |
| 1205 |
$host = str_replace( "\0", '', $host ); |
| 1206 |
// Drop the port BEFORE filtering, or `example.com:8080` collapses to |
| 1207 |
// `example.com8080` — which both loses the boundary and could collide |
| 1208 |
// with a real host of that name. |
| 1209 |
$colon = strpos( $host, ':' ); |
| 1210 |
if ( false !== $colon ) { |
| 1211 |
$host = substr( $host, 0, $colon ); |
| 1212 |
} |
| 1213 |
$host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host ); |
| 1214 |
// Collapse any run of dots so no traversal sequence can survive the |
| 1215 |
// charset filter (`a/../b` would otherwise reduce to `a..b`). |
| 1216 |
$host = preg_replace( '/\.{2,}/', '.', (string) $host ); |
| 1217 |
$host = trim( (string) $host, '.-' ); |
| 1218 |
return '' === $host ? '' : $host; |
| 1219 |
} |
| 1220 |
|
| 1221 |
/** |
| 1222 |
* The per-site bucket a cache entry belongs to: `<host>` on a single |
| 1223 |
* site, `<host>/<path-prefix>` for a subdirectory multisite blog. |
| 1224 |
* |
| 1225 |
* On multisite every blog shares one cache directory, and a flat md5 |
| 1226 |
* filename carries no clue which site wrote it — so purging one subsite |
| 1227 |
* swept the whole network cold. (#6) |
| 1228 |
* |
| 1229 |
* Host alone is NOT enough: a subdirectory network (the common layout) |
| 1230 |
* puts every blog on the same host, so `example.com/` and |
| 1231 |
* `example.com/siteb/` would share a bucket and keep purging each other. |
| 1232 |
* The path prefix is what separates them, and it is derivable from the |
| 1233 |
* REQUEST_URI alone — which matters because the drop-in must compute |
| 1234 |
* this identical value before WordPress (and get_blog_details()) exist. |
| 1235 |
* |
| 1236 |
* Subdomain and domain-mapped networks differ by host already, so they |
| 1237 |
* get a bare host bucket and are unaffected. |
| 1238 |
* |
| 1239 |
* @param string $host Raw host. |
| 1240 |
* @param string $uri Raw REQUEST_URI (query string is ignored). |
| 1241 |
* @return string Bucket path, always non-empty. |
| 1242 |
*/ |
| 1243 |
public static function site_bucket( string $host, string $uri ): string { |
| 1244 |
$dir = self::host_dir( $host ); |
| 1245 |
if ( '' === $dir ) { |
| 1246 |
$dir = 'default'; |
| 1247 |
} |
| 1248 |
|
| 1249 |
$prefix = self::site_path_prefix(); |
| 1250 |
return '' === $prefix ? $dir : $dir . '/' . $prefix; |
| 1251 |
} |
| 1252 |
|
| 1253 |
/** |
| 1254 |
* The current blog's path prefix as a single safe segment ('' for the |
| 1255 |
* root blog or a non-multisite install). `/siteb/` becomes `siteb`; |
| 1256 |
* a nested `/a/b/` becomes `a-b` so the bucket stays one level deep. |
| 1257 |
* |
| 1258 |
* Written to a sidecar for the drop-in by sync_site_paths(). |
| 1259 |
*/ |
| 1260 |
public static function site_path_prefix(): string { |
| 1261 |
if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) { |
| 1262 |
return ''; |
| 1263 |
} |
| 1264 |
if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) { |
| 1265 |
return ''; // Hosts already differ; no prefix needed. |
| 1266 |
} |
| 1267 |
$path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/'; |
| 1268 |
return self::path_prefix_segment( $path ); |
| 1269 |
} |
| 1270 |
|
| 1271 |
/** |
| 1272 |
* The bucket an arbitrary URL's cache entry lives in. |
| 1273 |
* |
| 1274 |
* `site_bucket()` answers for the CURRENT request; this answers for a URL |
| 1275 |
* that may belong to another blog entirely — which is what a per-URL purge |
| 1276 |
* is usually doing (WP-CLI, cron, the MCP tool, a network-admin action). |
| 1277 |
* |
| 1278 |
* The blog is resolved from the URL itself: on a subdirectory network |
| 1279 |
* `get_blog_details()` is asked which blog owns `<host><path>`, and its |
| 1280 |
* registered path becomes the prefix. Deriving the prefix from the URL's |
| 1281 |
* first path segment directly would be wrong — `/shop/` on the main blog |
| 1282 |
* is a page, not a subsite, and would send the purge into a bucket that |
| 1283 |
* does not exist. (QA B2 on #166) |
| 1284 |
* |
| 1285 |
* @param string $host Host of the URL being purged. |
| 1286 |
* @param string $path Path of the URL being purged. |
| 1287 |
* @return string Bucket path, always non-empty. |
| 1288 |
*/ |
| 1289 |
public static function bucket_for_url( string $host, string $path ): string { |
| 1290 |
$dir = self::host_dir( $host ); |
| 1291 |
if ( '' === $dir ) { |
| 1292 |
$dir = 'default'; |
| 1293 |
} |
| 1294 |
|
| 1295 |
if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) { |
| 1296 |
return $dir; |
| 1297 |
} |
| 1298 |
if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) { |
| 1299 |
return $dir; // Hosts already differ; no prefix. |
| 1300 |
} |
| 1301 |
if ( ! function_exists( 'get_blog_details' ) ) { |
| 1302 |
return $dir; |
| 1303 |
} |
| 1304 |
|
| 1305 |
// Longest registered blog path that prefixes this URL wins, so |
| 1306 |
// `/one/2026/post/` resolves to blog `/one/` and not to the root blog. |
| 1307 |
$blog = self::blog_for_path( $host, $path ); |
| 1308 |
if ( null === $blog ) { |
| 1309 |
return $dir; |
| 1310 |
} |
| 1311 |
$prefix = self::path_prefix_segment( (string) $blog ); |
| 1312 |
return '' === $prefix ? $dir : $dir . '/' . $prefix; |
| 1313 |
} |
| 1314 |
|
| 1315 |
/** |
| 1316 |
* The registered path of the blog that owns `<host><path>`, or null. |
| 1317 |
* |
| 1318 |
* Uses get_blog_details() with a domain/path pair rather than scanning |
| 1319 |
* every blog, so a large network costs one lookup per candidate segment |
| 1320 |
* instead of a full table read. |
| 1321 |
*/ |
| 1322 |
private static function blog_for_path( string $host, string $path ): ?string { |
| 1323 |
$segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) ); |
| 1324 |
|
| 1325 |
// Try the longest candidate first: /a/b/ before /a/ before /. |
| 1326 |
for ( $take = min( count( $segments ), 2 ); $take >= 1; $take-- ) { |
| 1327 |
$candidate = '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/'; |
| 1328 |
$details = get_blog_details( |
| 1329 |
array( |
| 1330 |
'domain' => $host, |
| 1331 |
'path' => $candidate, |
| 1332 |
), |
| 1333 |
false |
| 1334 |
); |
| 1335 |
if ( $details && ! empty( $details->path ) ) { |
| 1336 |
return (string) $details->path; |
| 1337 |
} |
| 1338 |
} |
| 1339 |
return null; |
| 1340 |
} |
| 1341 |
|
| 1342 |
/** |
| 1343 |
* Normalise a blog path ('/', '/siteb/', '/a/b/') into a single |
| 1344 |
* filesystem-safe segment. Shared with the drop-in's copy. |
| 1345 |
*/ |
| 1346 |
public static function path_prefix_segment( string $path ): string { |
| 1347 |
$path = trim( str_replace( "\0", '', $path ), '/' ); |
| 1348 |
if ( '' === $path ) { |
| 1349 |
return ''; |
| 1350 |
} |
| 1351 |
$path = preg_replace( '/[^a-zA-Z0-9._\-\/]/', '', $path ); |
| 1352 |
$path = str_replace( '/', '-', (string) $path ); |
| 1353 |
return trim( (string) $path, '.-' ); |
| 1354 |
} |
| 1355 |
|
| 1356 |
/** |
| 1357 |
* The current blog's path as the static tree stores it — real slashes |
| 1358 |
* preserved, because that tree mirrors the URL |
| 1359 |
* (`xspeed-static/{host}{request_uri}/index.html`) rather than using a |
| 1360 |
* single flattened segment. '' for a root blog / single site. |
| 1361 |
*/ |
| 1362 |
public static function site_path_raw(): string { |
| 1363 |
if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) { |
| 1364 |
return ''; |
| 1365 |
} |
| 1366 |
if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) { |
| 1367 |
return ''; |
| 1368 |
} |
| 1369 |
$path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/'; |
| 1370 |
$path = trim( str_replace( "\0", '', $path ), '/' ); |
| 1371 |
if ( '' === $path ) { |
| 1372 |
return ''; |
| 1373 |
} |
| 1374 |
$path = preg_replace( '#[^a-zA-Z0-9._\-/]#', '', $path ); |
| 1375 |
return trim( (string) $path, '/' ); |
| 1376 |
} |
| 1377 |
|
| 1378 |
/** |
| 1379 |
* Static-tree root for the current site: `<host>` plus the blog's real |
| 1380 |
* path. Mirrors store_static()'s layout so a scoped purge deletes |
| 1381 |
* exactly this blog's pages. |
| 1382 |
*/ |
| 1383 |
public static function current_static_scope(): string { |
| 1384 |
// Same switch_to_blog() caveat as current_host_dir() — see current_host(). |
| 1385 |
$dir = self::host_dir( self::current_host() ); |
| 1386 |
if ( '' === $dir ) { |
| 1387 |
$dir = 'default'; |
| 1388 |
} |
| 1389 |
$path = self::site_path_raw(); |
| 1390 |
return '' === $path ? $dir : $dir . '/' . $path; |
| 1391 |
} |
| 1392 |
|
| 1393 |
/** |
| 1394 |
* The bucket for the CURRENT request. Never empty, so an entry is never |
| 1395 |
* written to the tree root (which is what the unscoped sweeps used to |
| 1396 |
* delete indiscriminately). |
| 1397 |
*/ |
| 1398 |
public static function current_host_dir(): string { |
| 1399 |
$host = self::current_host(); |
| 1400 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; |
| 1401 |
return self::site_bucket( $host, $uri ); |
| 1402 |
} |
| 1403 |
|
| 1404 |
/** |
| 1405 |
* The host the CURRENT blog is served from. |
| 1406 |
* |
| 1407 |
* Deliberately NOT just $_SERVER['HTTP_HOST']: inside a |
| 1408 |
* switch_to_blog() the request header still names whichever site is |
| 1409 |
* serving the admin screen, while the cache entries we want belong to |
| 1410 |
* the switched-to blog. On a subdomain network the host IS the bucket, |
| 1411 |
* so reading the header there would make Pro's per-site "purge this |
| 1412 |
* site" button clear the network admin's own cache instead — the very |
| 1413 |
* bug this scoping exists to fix, surviving in one topology. |
| 1414 |
* |
| 1415 |
* get_blog_details() follows the switch, so prefer it whenever we are |
| 1416 |
* on multisite, and fall back to the request header otherwise. |
| 1417 |
*/ |
| 1418 |
public static function current_host(): string { |
| 1419 |
if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_blog_details' ) ) { |
| 1420 |
$details = get_blog_details(); |
| 1421 |
if ( $details && ! empty( $details->domain ) ) { |
| 1422 |
return (string) $details->domain; |
| 1423 |
} |
| 1424 |
} |
| 1425 |
|
| 1426 |
if ( isset( $_SERVER['HTTP_HOST'] ) ) { |
| 1427 |
return sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ); |
| 1428 |
} |
| 1429 |
|
| 1430 |
/* |
| 1431 |
* No request header — WP-CLI, or WP-Cron driven by system cron. |
| 1432 |
* |
| 1433 |
* Returning '' here made the bucket resolve to the literal `default` |
| 1434 |
* while HTTP requests were writing to `<host>/`, so a scheduled purge |
| 1435 |
* swept an empty directory and reported success, and get_stats() |
| 1436 |
* reported 0 cached pages on a site with a full cache. That is the |
| 1437 |
* normal setup on any host running DISABLE_WP_CRON, which is most of |
| 1438 |
* them. Fall back to the site's own registered host. (QA D4 on #166) |
| 1439 |
*/ |
| 1440 |
if ( function_exists( 'home_url' ) ) { |
| 1441 |
$parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- early-boot fallback only. |
| 1442 |
if ( is_array( $parts ) && ! empty( $parts['host'] ) ) { |
| 1443 |
return (string) $parts['host']; |
| 1444 |
} |
| 1445 |
} |
| 1446 |
|
| 1447 |
return ''; |
| 1448 |
} |
| 1449 |
|
| 1450 |
/** |
| 1451 |
* Ensure the current site's cache directory exists, with the silence |
| 1452 |
* index in both it and the shared root. Returns the directory. |
| 1453 |
*/ |
| 1454 |
public static function ensure_host_dir(): string { |
| 1455 |
$dir = XSPEED_CACHE_DIR . '/' . self::current_host_dir(); |
| 1456 |
if ( ! file_exists( XSPEED_CACHE_DIR ) ) { |
| 1457 |
wp_mkdir_p( XSPEED_CACHE_DIR ); |
| 1458 |
self::write_silence( XSPEED_CACHE_DIR ); |
| 1459 |
} |
| 1460 |
if ( ! file_exists( $dir ) ) { |
| 1461 |
wp_mkdir_p( $dir ); |
| 1462 |
self::write_silence( $dir ); |
| 1463 |
} |
| 1464 |
return $dir; |
| 1465 |
} |
| 1466 |
|
| 1467 |
public static function cache_file_for( $key ) { |
| 1468 |
return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.html'; |
| 1469 |
} |
| 1470 |
|
| 1471 |
/** |
| 1472 |
* If a precompressed Brotli sibling (`<file>.br`) exists and the client |
| 1473 |
* advertises `Accept-Encoding: br`, emit the Brotli response headers and |
| 1474 |
* return the `.br` path to stream. Returns null to fall through to the |
| 1475 |
* plain file. Keeps the PHP serve path in parity with the web server's |
| 1476 |
* static .br serving (mod_brotli / ngx_brotli rewrite). |
| 1477 |
* |
| 1478 |
* Free has no Brotli logic of its own — this only fires when an add-on |
| 1479 |
* (the Pro Brotli module) actually wrote the .br, so it's a safe no-op |
| 1480 |
* on Free-only installs. |
| 1481 |
* |
| 1482 |
* @param string $file Absolute path to the cached .html file. |
| 1483 |
* @return string|null The .br path to stream, or null to serve $file. |
| 1484 |
*/ |
| 1485 |
public static function maybe_serve_brotli( string $file ): ?string { |
| 1486 |
if ( headers_sent() ) { |
| 1487 |
return null; |
| 1488 |
} |
| 1489 |
$accept = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) |
| 1490 |
? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) ) |
| 1491 |
: ''; |
| 1492 |
// Match `br` as a token (comma/space delimited), not a substring, so |
| 1493 |
// a hypothetical "xbr" encoding can't false-positive. |
| 1494 |
if ( ! preg_match( '/(^|[\s,])br([\s,;]|$)/', $accept ) ) { |
| 1495 |
return null; |
| 1496 |
} |
| 1497 |
$br = $file . '.br'; |
| 1498 |
if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) { |
| 1499 |
return null; |
| 1500 |
} |
| 1501 |
if ( ! self::brotli_sibling_is_usable( $file, $br ) ) { |
| 1502 |
return null; // fall through to the plain .html |
| 1503 |
} |
| 1504 |
header( 'Content-Encoding: br' ); |
| 1505 |
header( 'Vary: Accept-Encoding', false ); |
| 1506 |
// The byte length changes for the compressed body — drop any |
| 1507 |
// Content-Length the caller may have set so the stream isn't |
| 1508 |
// truncated/padded. readfile() lets the SAPI set the right length. |
| 1509 |
header_remove( 'Content-Length' ); |
| 1510 |
return $br; |
| 1511 |
} |
| 1512 |
|
| 1513 |
/** |
| 1514 |
* Is a precompressed `.br` sibling safe to serve? |
| 1515 |
* |
| 1516 |
* Existence is not enough. The sibling is written with a plain |
| 1517 |
* file_put_contents() — no atomic rename — so a crash, a full disk, or a |
| 1518 |
* read that races the write leaves a TRUNCATED file behind. Serving that |
| 1519 |
* with `Content-Encoding: br` hands the browser a stream it cannot |
| 1520 |
* inflate: it renders nothing at all (document.body is null) and the |
| 1521 |
* navigation can hang. A 16-byte .br for a 172KB page reproduces it |
| 1522 |
* exactly. (#286) |
| 1523 |
* |
| 1524 |
* Brotli has no magic number, and no byte-level marker distinguishes a |
| 1525 |
* truncated stream from a short valid one (the ISLAST bit is bit-packed, |
| 1526 |
* not byte-aligned). So this checks only what CAN be known by stat: |
| 1527 |
* |
| 1528 |
* - Not empty. A zero-byte sibling is unambiguously broken. |
| 1529 |
* - Not older than the HTML. A stale sibling would serve the PREVIOUS |
| 1530 |
* revision of the page under the current entry's ETag. |
| 1531 |
* |
| 1532 |
* A size-RATIO floor was tried here and removed. Brotli's ratio is |
| 1533 |
* unbounded on repetitive input: a ~1 MB page of table rows or a product |
| 1534 |
* grid — the ordinary shape of a big generated page — compresses to |
| 1535 |
* about 0.04%, so a 2% floor rejected a perfectly good sibling and sent |
| 1536 |
* visitors the uncompressed page instead, silently. Measured: 963 KB of |
| 1537 |
* repeated markup → 89 bytes at q5 (0.009%). No floor can separate |
| 1538 |
* "impossibly small" from "extremely compressible" for arbitrary HTML. |
| 1539 |
* |
| 1540 |
* Truncation is prevented at the WRITE side instead — see |
| 1541 |
* write_atomic(), which the Brotli writer uses so a partial file is |
| 1542 |
* never visible under the final name. Detection at read time cannot be |
| 1543 |
* made correct; not creating the bad file can. |
| 1544 |
* |
| 1545 |
* Anything suspicious returns false and the caller streams the plain |
| 1546 |
* .html — slower, always correct. Serving an uninflatable body is worse |
| 1547 |
* than serving no compression at all. |
| 1548 |
* |
| 1549 |
* @param string $file Absolute path to the .html cache file. |
| 1550 |
* @param string $br Absolute path to its .br sibling. |
| 1551 |
* @return bool True when the sibling may be served. |
| 1552 |
*/ |
| 1553 |
/** |
| 1554 |
* Write a cache sidecar so a partial file is never visible. |
| 1555 |
* |
| 1556 |
* `file_put_contents()` truncates the target and then fills it, so any |
| 1557 |
* reader arriving mid-write — or any crash, full disk, or killed worker |
| 1558 |
* — leaves a SHORT file under the real name. For HTML that degrades to a |
| 1559 |
* clipped page; for a `.br` sibling it is worse, because a truncated |
| 1560 |
* brotli stream is not a short page but an UNINFLATABLE one: the browser |
| 1561 |
* renders nothing at all and the navigation can hang. |
| 1562 |
* |
| 1563 |
* Writing to a unique temp file in the same directory and renaming is |
| 1564 |
* atomic on POSIX, so readers see either the previous complete file or |
| 1565 |
* the new complete file, never a partial one. This is the half of #286 |
| 1566 |
* that is actually fixable — a read-time heuristic cannot tell a |
| 1567 |
* truncated brotli stream from a very small valid one, but a truncated |
| 1568 |
* file that never becomes visible needs no detection. |
| 1569 |
* |
| 1570 |
* @param string $path Absolute destination path. |
| 1571 |
* @param string $contents Bytes to write. |
| 1572 |
* @return bool True when the destination now holds exactly $contents. |
| 1573 |
*/ |
| 1574 |
public static function write_atomic( string $path, string $contents ): bool { |
| 1575 |
$dir = dirname( $path ); |
| 1576 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem needs admin creds unavailable on a frontend cache write; this is our own cache dir. |
| 1577 |
if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) { |
| 1578 |
return false; |
| 1579 |
} |
| 1580 |
|
| 1581 |
// Same directory, so the rename stays on one filesystem — a rename |
| 1582 |
// across devices is a copy and loses atomicity. |
| 1583 |
$tmp = @tempnam( $dir, '.xspeed-tmp-' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a failure returns false and the caller skips the write. |
| 1584 |
if ( ! is_string( $tmp ) || '' === $tmp ) { |
| 1585 |
return false; |
| 1586 |
} |
| 1587 |
|
| 1588 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem needs admin creds unavailable on a frontend cache write; target is our own cache dir. |
| 1589 |
$written = @file_put_contents( $tmp, $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- handled by the length check below. |
| 1590 |
|
| 1591 |
// A short write is exactly the failure this function exists to |
| 1592 |
// prevent, so verify the byte count before publishing the file. |
| 1593 |
if ( false === $written || $written !== strlen( $contents ) ) { |
| 1594 |
@unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own temp file; non-fatal. |
| 1595 |
return false; |
| 1596 |
} |
| 1597 |
|
| 1598 |
// tempnam() creates the file 0600; cache files must stay readable by |
| 1599 |
// the web server, which may run as a different user. |
| 1600 |
@chmod( $tmp, defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod, WordPress.PHP.NoSilencedErrors.Discouraged -- the web server may run as another uid and must be able to read the published file; a chmod failure is not fatal. |
| 1601 |
|
| 1602 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged -- the atomic publish this function exists for; WP_Filesystem offers no atomic rename and needs admin creds. |
| 1603 |
if ( ! @rename( $tmp, $path ) ) { |
| 1604 |
@unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup; non-fatal. |
| 1605 |
return false; |
| 1606 |
} |
| 1607 |
|
| 1608 |
return true; |
| 1609 |
} |
| 1610 |
|
| 1611 |
public static function brotli_sibling_is_usable( string $file, string $br ): bool { |
| 1612 |
$br_size = (int) @filesize( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a stat failure means "don't serve it", handled by the <= 0 check. |
| 1613 |
if ( $br_size <= 0 ) { |
| 1614 |
return false; |
| 1615 |
} |
| 1616 |
|
| 1617 |
$html_size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above. |
| 1618 |
if ( $html_size <= 0 ) { |
| 1619 |
return false; |
| 1620 |
} |
| 1621 |
|
| 1622 |
// A sibling older than the page it compresses is stale. |
| 1623 |
$br_mtime = (int) @filemtime( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above. |
| 1624 |
$html_mtime = (int) @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above. |
| 1625 |
if ( $br_mtime > 0 && $html_mtime > 0 && $br_mtime < $html_mtime ) { |
| 1626 |
return false; |
| 1627 |
} |
| 1628 |
|
| 1629 |
// The writer recorded how many bytes it produced. Where that record |
| 1630 |
// exists, truncation is a certainty rather than an inference: a |
| 1631 |
// stream shorter than its own declared length cannot inflate, and |
| 1632 |
// one that matches was published whole. This is what a size ratio |
| 1633 |
// could never be — brotli's ratio is unbounded on repetitive input, |
| 1634 |
// so a 0.01% sibling of a generated page is genuinely valid. |
| 1635 |
// |
| 1636 |
// Absent for a sibling written before this version, or by an add-on |
| 1637 |
// that writes the file directly. That case keeps the checks above |
| 1638 |
// and no more, which is where a pre-existing truncated file on a |
| 1639 |
// live site still slips through — write_atomic() stops NEW ones, |
| 1640 |
// but it cannot retroactively vouch for what is already on disk. |
| 1641 |
$expected = self::brotli_expected_size( $br ); |
| 1642 |
if ( $expected > 0 && $br_size !== $expected ) { |
| 1643 |
return false; |
| 1644 |
} |
| 1645 |
|
| 1646 |
return true; |
| 1647 |
} |
| 1648 |
|
| 1649 |
/** |
| 1650 |
* Path of the sidecar recording a `.br` sibling's complete byte count. |
| 1651 |
* |
| 1652 |
* Kept beside the sibling as `<file>.html.br.size` rather than folded |
| 1653 |
* into the entry's `.meta`: the static tree the web server serves has no |
| 1654 |
* `.meta` at all, and the two trees must answer this question the same |
| 1655 |
* way. Every path that deletes a `.br` deletes this with it. |
| 1656 |
* |
| 1657 |
* @param string $br Absolute path to the `.br` sibling. |
| 1658 |
* @return string Absolute path to its size sidecar. |
| 1659 |
*/ |
| 1660 |
public static function brotli_size_sidecar( string $br ): string { |
| 1661 |
return $br . '.size'; |
| 1662 |
} |
| 1663 |
|
| 1664 |
/** |
| 1665 |
* The byte count the writer recorded for a `.br` sibling, or 0 when no |
| 1666 |
* record exists (a sibling predating this version, or written by an |
| 1667 |
* add-on that bypassed write_brotli_sibling()). |
| 1668 |
* |
| 1669 |
* @param string $br Absolute path to the `.br` sibling. |
| 1670 |
* @return int Expected size in bytes, or 0 when unknown. |
| 1671 |
*/ |
| 1672 |
public static function brotli_expected_size( string $br ): int { |
| 1673 |
$sidecar = self::brotli_size_sidecar( $br ); |
| 1674 |
if ( ! is_file( $sidecar ) ) { |
| 1675 |
return 0; |
| 1676 |
} |
| 1677 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend HIT. |
| 1678 |
$raw = @file_get_contents( $sidecar ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable sidecar means "unknown", handled by the cast below. |
| 1679 |
return max( 0, (int) trim( (string) $raw ) ); |
| 1680 |
} |
| 1681 |
|
| 1682 |
/** |
| 1683 |
* Publish a `.br` sibling together with the record of its own length. |
| 1684 |
* |
| 1685 |
* The single writer every producer of a `.br` should route through — the |
| 1686 |
* Pro Brotli module included. Publishing the body atomically stops a |
| 1687 |
* truncated file from ever becoming visible; recording the byte count |
| 1688 |
* lets the serve path prove wholeness for the files that already exist |
| 1689 |
* on disk when this ships. |
| 1690 |
* |
| 1691 |
* Order matters: the size sidecar is removed first and written last, so |
| 1692 |
* a reader arriving mid-update sees "no record" (checks above still |
| 1693 |
* apply) rather than the previous body's length against the new body. |
| 1694 |
* |
| 1695 |
* @param string $br Absolute path to the `.br` sibling to write. |
| 1696 |
* @param string $contents Compressed bytes. |
| 1697 |
* @return bool True when both the sibling and its size record are in place. |
| 1698 |
*/ |
| 1699 |
public static function write_brotli_sibling( string $br, string $contents ): bool { |
| 1700 |
$sidecar = self::brotli_size_sidecar( $br ); |
| 1701 |
if ( is_file( $sidecar ) ) { |
| 1702 |
wp_delete_file( $sidecar ); |
| 1703 |
} |
| 1704 |
|
| 1705 |
if ( ! self::write_atomic( $br, $contents ) ) { |
| 1706 |
return false; |
| 1707 |
} |
| 1708 |
|
| 1709 |
if ( self::write_atomic( $sidecar, (string) strlen( $contents ) ) ) { |
| 1710 |
return true; |
| 1711 |
} |
| 1712 |
|
| 1713 |
// The body landed but its length did not. That sibling is servable |
| 1714 |
// and unguarded — exactly the file this function exists to prevent — |
| 1715 |
// and the caller has no way to know. Withdraw it: a MISS costs one |
| 1716 |
// uncompressed response, where an unguarded sibling can cost a blank |
| 1717 |
// page for as long as the entry lives. |
| 1718 |
wp_delete_file( $br ); |
| 1719 |
return false; |
| 1720 |
} |
| 1721 |
|
| 1722 |
/** |
| 1723 |
* Sidecar metadata file for a cache entry. Holds response bits the HIT |
| 1724 |
* path must replay — Content-Type (cached feeds → application/rss+xml, |
| 1725 |
* sitemaps → text/xml) and status (a cached 404 must serve 404, not |
| 1726 |
* 200). JSON, one tiny file per entry, written only when there's |
| 1727 |
* something non-default to replay. |
| 1728 |
*/ |
| 1729 |
public static function cache_meta_for( $key ) { |
| 1730 |
return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.meta'; |
| 1731 |
} |
| 1732 |
|
| 1733 |
/** |
| 1734 |
* Read the .meta sidecar for a cache entry as an array, or [] if none. |
| 1735 |
* Keys: 'content_type' (string), 'status' (int), 'ttl' (int seconds). |
| 1736 |
* Used on the HIT path to replay content-type/status before streaming |
| 1737 |
* the file, and by Cache_GC to age an entry by its own TTL rather than |
| 1738 |
* the global one — hence public. |
| 1739 |
*/ |
| 1740 |
public static function read_meta( $key ): array { |
| 1741 |
$meta_file = self::cache_meta_for( $key ); |
| 1742 |
if ( ! file_exists( $meta_file ) ) { |
| 1743 |
return array(); |
| 1744 |
} |
| 1745 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend HIT. |
| 1746 |
$raw = file_get_contents( $meta_file ); |
| 1747 |
$data = json_decode( (string) $raw, true ); |
| 1748 |
return is_array( $data ) ? $data : array(); |
| 1749 |
} |
| 1750 |
|
| 1751 |
/** |
| 1752 |
* Conditional-GET support for a cache HIT. Emits Last-Modified + ETag |
| 1753 |
* derived from the cache file's mtime, and — when the request's |
| 1754 |
* If-Modified-Since / If-None-Match still match — sends 304 Not Modified |
| 1755 |
* and returns true (caller should exit without a body). Returns false to |
| 1756 |
* proceed with a normal 200 body. Lets aggregators/browsers skip |
| 1757 |
* re-downloading an unchanged cached response. (FBS-82407 #5) |
| 1758 |
* |
| 1759 |
* @param string $file Absolute path to the cache .html file. |
| 1760 |
* @return bool True when a 304 was sent. |
| 1761 |
*/ |
| 1762 |
public static function serve_not_modified( string $file ): bool { |
| 1763 |
$mtime = (int) filemtime( $file ); |
| 1764 |
if ( $mtime <= 0 ) { |
| 1765 |
return false; |
| 1766 |
} |
| 1767 |
$last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT'; |
| 1768 |
$etag = '"' . md5( $file . '|' . $mtime ) . '"'; |
| 1769 |
header( 'Last-Modified: ' . $last_modified ); |
| 1770 |
header( 'ETag: ' . $etag ); |
| 1771 |
|
| 1772 |
$ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : ''; |
| 1773 |
$inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : ''; |
| 1774 |
|
| 1775 |
$etag_match = '' !== $inm && false !== strpos( $inm, $etag ); |
| 1776 |
$time_match = '' !== $ims && ( strtotime( $ims ) >= $mtime ); |
| 1777 |
|
| 1778 |
if ( $etag_match || $time_match ) { |
| 1779 |
if ( function_exists( 'http_response_code' ) ) { |
| 1780 |
http_response_code( 304 ); |
| 1781 |
} |
| 1782 |
return true; |
| 1783 |
} |
| 1784 |
return false; |
| 1785 |
} |
| 1786 |
|
| 1787 |
public static function is_expired( $file ) { |
| 1788 |
// cache_expiry now owned by CacheModule; per-post override |
| 1789 |
// (Phase 3.4) shrinks the TTL further when the editor set one. |
| 1790 |
$opts = Settings_Manager::get( 'cache' ); |
| 1791 |
$max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS; |
| 1792 |
$post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() ); |
| 1793 |
if ( null !== $post_override ) { |
| 1794 |
$max_age = $post_override; |
| 1795 |
} |
| 1796 |
|
| 1797 |
/** |
| 1798 |
* Filter the max-age (seconds) for the current cache entry. |
| 1799 |
* |
| 1800 |
* Lets an add-on apply a request-type-specific TTL — e.g. the |
| 1801 |
* xspeed-pro feed cache gives feeds a longer expiry than pages, |
| 1802 |
* since aggregators tolerate more staleness. Return seconds. |
| 1803 |
* |
| 1804 |
* @param int $max_age Computed max-age in seconds. |
| 1805 |
*/ |
| 1806 |
$max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age ); |
| 1807 |
|
| 1808 |
// Honour the per-entry TTL the .meta sidecar carries, when it is |
| 1809 |
// SHORTER than what we just resolved. The sidecar records the TTL |
| 1810 |
// this specific entry was written under — a nonce cap (#236), a Pro |
| 1811 |
// feed/404 expiry — and the drop-in already reads it. is_expired() |
| 1812 |
// did not, so on the engine path a capped entry was still served for |
| 1813 |
// the full configured lifetime: exactly the stale nonce the cap |
| 1814 |
// exists to prevent. Only ever shortens, so an entry can never be |
| 1815 |
// kept alive past the configured maximum by a stale sidecar. |
| 1816 |
// Derive the sidecar from the FILE we were handed rather than |
| 1817 |
// recomputing cache_key(): callers legitimately ask about an entry |
| 1818 |
// that isn't the current request's (Cache_GC sweeps, Pro's warmer), |
| 1819 |
// and cache_key() would answer for the wrong one — besides needing a |
| 1820 |
// request context this function has no business requiring. |
| 1821 |
$meta_file = preg_replace( '/\.html$/', '.meta', (string) $file ); |
| 1822 |
if ( is_string( $meta_file ) && $meta_file !== $file && is_readable( $meta_file ) ) { |
| 1823 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend read. |
| 1824 |
$raw = file_get_contents( $meta_file ); |
| 1825 |
$decoded = is_string( $raw ) ? json_decode( $raw, true ) : null; |
| 1826 |
if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) { |
| 1827 |
$entry_ttl = (int) $decoded['ttl']; |
| 1828 |
if ( $entry_ttl > 0 && ( $max_age < 1 || $entry_ttl < $max_age ) ) { |
| 1829 |
$max_age = $entry_ttl; |
| 1830 |
} |
| 1831 |
} |
| 1832 |
} |
| 1833 |
|
| 1834 |
// A missing file is "expired" — the caller should re-render. Guard |
| 1835 |
// filemtime() rather than letting it warn: callers legitimately ask |
| 1836 |
// about a file that isn't there (Pro's predictive warmer probes for |
| 1837 |
// freshness, and Cache_GC can collect an entry between the check and |
| 1838 |
// the read), and on a site with WP_DEBUG the warning is noise. |
| 1839 |
$mtime = file_exists( $file ) ? filemtime( $file ) : false; |
| 1840 |
if ( false === $mtime ) { |
| 1841 |
return true; |
| 1842 |
} |
| 1843 |
|
| 1844 |
return ( time() - (int) $mtime ) > $max_age; |
| 1845 |
} |
| 1846 |
|
| 1847 |
/** |
| 1848 |
* Accumulator for the full response body across all output-handler phases. |
| 1849 |
* |
| 1850 |
* PHP invokes an ob_start() callback once per flush, and each invocation |
| 1851 |
* only receives the chunk produced *since the previous flush*. If anything |
| 1852 |
* during the render calls `ob_flush()` or `flush()` (some themes, lazy- |
| 1853 |
* load plugins, AMP, etc. do), the final-phase call would otherwise only |
| 1854 |
* see the tail of the page — and we'd cache a truncated response that |
| 1855 |
* gets served repeatedly until purge. We accumulate every chunk here so |
| 1856 |
* the cache file always reflects the complete page. |
| 1857 |
* |
| 1858 |
* @var string |
| 1859 |
*/ |
| 1860 |
private static $accumulated = ''; |
| 1861 |
|
| 1862 |
public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) { |
| 1863 |
self::$accumulated .= $buffer; |
| 1864 |
|
| 1865 |
// On non-final phases (mid-request flushes), pass the current chunk |
| 1866 |
// through to the client unmodified and keep collecting. The WP 6.9 |
| 1867 |
// filter path always passes the full body in one shot with the |
| 1868 |
// default $phase, so it falls straight through to the final block. |
| 1869 |
$is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0; |
| 1870 |
if ( ! $is_final ) { |
| 1871 |
return $buffer; |
| 1872 |
} |
| 1873 |
|
| 1874 |
$full = self::$accumulated; |
| 1875 |
self::$accumulated = ''; |
| 1876 |
|
| 1877 |
if ( strlen( $full ) < 255 ) { |
| 1878 |
return $buffer; |
| 1879 |
} |
| 1880 |
|
| 1881 |
// Status gate. We cache 200 by default. A 404 may be cached too, |
| 1882 |
// but only when an add-on (xspeed-pro 404 cache) opts in for a |
| 1883 |
// genuine is_404() — never a transient 404 (maintenance screen, |
| 1884 |
// DB error, or a 404 emitted outside the main query), which would |
| 1885 |
// otherwise be frozen until purge. Any other status is skipped. |
| 1886 |
$status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200; |
| 1887 |
if ( 200 !== $status ) { |
| 1888 |
if ( 404 !== $status || ! self::should_cache_404() ) { |
| 1889 |
return $buffer; |
| 1890 |
} |
| 1891 |
} |
| 1892 |
|
| 1893 |
// If no mid-request flush happened, $buffer === $full and we can |
| 1894 |
// safely minify the on-wire bytes too. Otherwise earlier chunks have |
| 1895 |
// already been sent unminified, so we minify only what goes to disk — |
| 1896 |
// the first visitor sees unminified HTML, every cache hit after that |
| 1897 |
// is minified. |
| 1898 |
$single_chunk = ( $buffer === $full ); |
| 1899 |
|
| 1900 |
/** |
| 1901 |
* Filter: xspeed_cache_final_html |
| 1902 |
* |
| 1903 |
* Last chance to transform the fully-rendered page HTML before it is |
| 1904 |
* minified and written to the cache file. Runs on cache MISS only, so |
| 1905 |
* whatever a listener injects here is baked into the cached HTML and |
| 1906 |
* replayed on every subsequent HIT (the drop-in short-circuits before |
| 1907 |
* PHP on a HIT — a wp_head hook would never fire there). |
| 1908 |
* |
| 1909 |
* The Preload module uses this to inject the LCP-image <link rel=preload> |
| 1910 |
* + preconnect hints and add fetchpriority="high" to the hero <img>. |
| 1911 |
* Keep listeners fast and idempotent; this is the on-wire body. |
| 1912 |
* |
| 1913 |
* @param string $full Complete page HTML. |
| 1914 |
*/ |
| 1915 |
$full = (string) apply_filters( 'xspeed_cache_final_html', $full ); |
| 1916 |
if ( $single_chunk ) { |
| 1917 |
$buffer = $full; |
| 1918 |
} |
| 1919 |
|
| 1920 |
// minify_html now owned by the Minify module; read through the |
| 1921 |
// module's storage so this stays consistent with the engine that |
| 1922 |
// applies CSS/JS minification. |
| 1923 |
$minify_opts = Settings_Manager::get( 'minify' ); |
| 1924 |
if ( ! empty( $minify_opts['minify_html'] ) ) { |
| 1925 |
$full = Minifier::minify_html( $full ); |
| 1926 |
if ( $single_chunk ) { |
| 1927 |
$buffer = $full; |
| 1928 |
} |
| 1929 |
} |
| 1930 |
|
| 1931 |
// AFTER minification on purpose — the HTML minifier strips comments, |
| 1932 |
// so signing earlier would erase the signature from every minified |
| 1933 |
// page. Baked into the cached bytes so all three serve paths (nginx |
| 1934 |
// static rewrite, .htaccess, the PHP drop-in) carry it identically. |
| 1935 |
$full = self::signed( $full ); |
| 1936 |
if ( $single_chunk ) { |
| 1937 |
$buffer = $full; |
| 1938 |
} |
| 1939 |
|
| 1940 |
// Per-site directory — see ensure_host_dir(). (#6) |
| 1941 |
self::ensure_host_dir(); |
| 1942 |
|
| 1943 |
// Path safety: cache_file_for() builds |
| 1944 |
// `XSPEED_CACHE_DIR . '/' . <host> . '/' . $key . '.html'` where $key |
| 1945 |
// comes from md5() — guaranteed to be exactly 32 lowercase hex chars — |
| 1946 |
// and <host> is filtered by host_dir() to [A-Za-z0-9.-] with leading |
| 1947 |
// dots trimmed, so no traversal sequence ('..', '/', null byte, etc.) |
| 1948 |
// can appear in either segment. The write is therefore always inside |
| 1949 |
// XSPEED_CACHE_DIR. |
| 1950 |
$key = self::cache_key(); |
| 1951 |
|
| 1952 |
// Query-string gate. should_cache() waved this request through |
| 1953 |
// because every param is on the ignored_query_params allow-list, and |
| 1954 |
// cache_key() drops the query so reads share the canonical entry. |
| 1955 |
// That sharing is safe on READ but not on WRITE: this response was |
| 1956 |
// rendered WITH the params, and WordPress reflects REQUEST_URI into |
| 1957 |
// form actions, share links and plugin smart tags — so storing it |
| 1958 |
// would serve an attacker-chosen variant under the clean URL for the |
| 1959 |
// whole TTL (#241). |
| 1960 |
// |
| 1961 |
// This sits BELOW the transforms deliberately. Returning above them |
| 1962 |
// also skipped xspeed_cache_final_html, and every listener disables |
| 1963 |
// its own fallback ob_start() when the page cache is on precisely |
| 1964 |
// because that filter is the shared transport — so a visitor |
| 1965 |
// arriving on ?utm_source=… was served HTML with no LCP preload, no |
| 1966 |
// preconnect, no CDN rewrite, no CSS combine and no HTML minify. |
| 1967 |
// That is the ad-click and newsletter cohort getting the least |
| 1968 |
// optimised page on the site. Only the WRITE is skipped, which is |
| 1969 |
// what this fix was always meant to do — and it is where the |
| 1970 |
// deferred writer has always placed its own copy of the guard. |
| 1971 |
if ( self::query_string_blocks_write() ) { |
| 1972 |
return $buffer; |
| 1973 |
} |
| 1974 |
$file = self::cache_file_for( $key ); |
| 1975 |
|
| 1976 |
// A render-time translation plugin (TranslatePress) wraps our buffer, |
| 1977 |
// so the bytes we hold here are still UNTRANSLATED — its callback has |
| 1978 |
// not run yet, and writing now would cache English under a French URL |
| 1979 |
// and bake in its internal #TRPLINKPROCESSED markers. Hand off to |
| 1980 |
// shutdown, where the outer buffer has already translated, and let |
| 1981 |
// the pass-through below deliver this request untouched. |
| 1982 |
if ( self::translation_plugin_active() ) { |
| 1983 |
self::$deferred_key = $key; |
| 1984 |
// Reaching here means finalize_buffer() ran to completion: the |
| 1985 |
// status gate passed, should_cache() said yes, and PHP handed us |
| 1986 |
// the whole buffer. A wp_die() or exit() mid-render unwinds the |
| 1987 |
// buffer stack WITHOUT calling this callback, so the flag stays |
| 1988 |
// false and the shutdown writer declines — see the guard there. |
| 1989 |
self::$render_completed = true; |
| 1990 |
// A PHP shutdown function, not a WP `shutdown` action: this must |
| 1991 |
// run after the output-buffer stack has unwound, and WP's |
| 1992 |
// shutdown action fires while our outer buffer is still open. |
| 1993 |
register_shutdown_function( array( __CLASS__, 'write_deferred_translated_cache' ) ); |
| 1994 |
return $buffer; |
| 1995 |
} |
| 1996 |
|
| 1997 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache writes happen on frontend requests where it's unavailable. |
| 1998 |
file_put_contents( $file, $full, LOCK_EX ); |
| 1999 |
|
| 2000 |
/** |
| 2001 |
* Fires after the flat hash cache file ({md5}.html) is written. |
| 2002 |
* |
| 2003 |
* Mirror of `xspeed_static_file_written` for the flat cache. The PHP |
| 2004 |
* serve path (Cache::maybe_serve_brotli / the drop-in) serves THIS |
| 2005 |
* file and looks for a `{md5}.html.br` sibling — which only the Pro |
| 2006 |
* Brotli listener on this hook writes. Without it the .br sibling was |
| 2007 |
* never created and the PHP path could never serve Brotli (FBS-83039, |
| 2008 |
* Blocker 2): the static-tree .br (written on xspeed_static_file_written) |
| 2009 |
* lives in a different cache layout the PHP path never reads. |
| 2010 |
* |
| 2011 |
* @param string $file Absolute path to the flat cache file just written. |
| 2012 |
* @param string $full The HTML written to it. |
| 2013 |
*/ |
| 2014 |
do_action( 'xspeed_flat_file_written', $file, $full ); |
| 2015 |
|
| 2016 |
// Persist a non-default Content-Type so the HIT path can replay it |
| 2017 |
// (cached feeds must serve application/rss+xml, not text/html). |
| 2018 |
// Only written when the response set a content-type other than |
| 2019 |
// the HTML default — pages don't pay for an extra file. |
| 2020 |
self::write_meta( $key, $full ); |
| 2021 |
|
| 2022 |
// Static-cache tree (xspeed-static/{host}{path}/index.html). The |
| 2023 |
// .htaccess rewrite block serves this file directly via the web |
| 2024 |
// server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path. |
| 2025 |
// store_static() returns silently on any path/permission issue — |
| 2026 |
// the drop-in remains the safety net. |
| 2027 |
// |
| 2028 |
// Skip it entirely when mobile_separate is on: the rewrite is |
| 2029 |
// disabled in that mode (static_rewrite_allowed()), so a static file |
| 2030 |
// would only be dead weight — and a device-blind one at that. |
| 2031 |
// Skip the static-tree write for responses the web server can't replay |
| 2032 |
// correctly: a non-200 status (a cached 404 would be served as a soft |
| 2033 |
// 200, FBS-82406) or a non-HTML content-type (a cached feed would go |
| 2034 |
// out as text/html, FBS-82407). The web server serves these .html files |
| 2035 |
// directly with no PHP, so there's no .meta replay — keep them on the |
| 2036 |
// drop-in / PHP path instead, which DOES replay status + content-type. |
| 2037 |
if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) { |
| 2038 |
self::store_static( $full ); |
| 2039 |
} |
| 2040 |
|
| 2041 |
return $buffer; |
| 2042 |
} |
| 2043 |
|
| 2044 |
/** |
| 2045 |
* Write the current response to the static-cache tree at |
| 2046 |
* `xspeed-static/{host}{request_uri}/index.html`. The web-server |
| 2047 |
* rewrite block points at this path so cache hits skip PHP |
| 2048 |
* entirely. Caller already minified/finalized $html. |
| 2049 |
* |
| 2050 |
* Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist; |
| 2051 |
* $uri has its query string stripped, null bytes removed, '..' |
| 2052 |
* sequences collapsed, and after concatenation we verify the |
| 2053 |
* resolved real path stays inside XSPEED_CACHE_STATIC_DIR before |
| 2054 |
* any write. Anything off the happy path returns silently. |
| 2055 |
* |
| 2056 |
* INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING |
| 2057 |
* else, and both generated rewrites refuse any request that carries a |
| 2058 |
* query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache, |
| 2059 |
* `if ($args)` in nginx_snippet()). So a response may only be stored |
| 2060 |
* here when cache_key() adds no discriminator beyond `{host}{path}`: |
| 2061 |
* a query-keyed entry can never be *served* from here, only mis-served |
| 2062 |
* as the bare path. Any future opt-in that folds a query param into the |
| 2063 |
* key needs a guard below, exactly like the search one. |
| 2064 |
*/ |
| 2065 |
/** |
| 2066 |
* Transient holding the most recent static-tree refusal. |
| 2067 |
* |
| 2068 |
* Short-lived on purpose: it describes what the last cacheable render |
| 2069 |
* actually did, so a stale entry would keep warning about a page whose |
| 2070 |
* nonces have since been removed. A site that still refuses simply |
| 2071 |
* rewrites it on the next render. (#372) |
| 2072 |
*/ |
| 2073 |
private const STATIC_SKIP_TRANSIENT = 'xspeed_static_skip'; |
| 2074 |
|
| 2075 |
/** |
| 2076 |
* Remember why a page was kept out of the static tree, for Health. |
| 2077 |
* |
| 2078 |
* Records the URL, the reason, and — for the nonce case — the distinct |
| 2079 |
* nonce KEYS found, which is what makes the finding actionable: the names |
| 2080 |
* (`eael_login_nonce`, `post_grid_pagination_nonce`, …) trace straight back |
| 2081 |
* to the plugin emitting them, and it is usually a widget the site does not |
| 2082 |
* use on that page. Only key names are kept, never the nonce values. |
| 2083 |
* |
| 2084 |
* @param string $reason Machine-readable refusal reason. |
| 2085 |
* @param string $html The response, for extracting the nonce keys. |
| 2086 |
*/ |
| 2087 |
private static function note_static_skip( string $reason, string $html = '' ): void { |
| 2088 |
if ( ! function_exists( 'set_transient' ) ) { |
| 2089 |
return; |
| 2090 |
} |
| 2091 |
|
| 2092 |
$keys = array(); |
| 2093 |
if ( 'nonce' === $reason && '' !== $html ) { |
| 2094 |
// Must recognise the SAME shapes response_has_nonce() refuses on, |
| 2095 |
// or a page is skipped and reported with no keys at all — which is |
| 2096 |
// most of them, since the plain `name="_wpnonce"` form field is the |
| 2097 |
// commonest shape by far and only the JSON one was handled here. |
| 2098 |
// The keys are the actionable half of the message, so a mismatch |
| 2099 |
// leaves the admin with bad news and nothing to act on. |
| 2100 |
// |
| 2101 |
// Both alternations capture the KEY only: each value pattern sits |
| 2102 |
// outside the capture group, so a nonce secret can never be stored. |
| 2103 |
$found = array(); |
| 2104 |
if ( preg_match_all( '/name=["\']([a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*)["\']/i', $html, $m ) ) { |
| 2105 |
$found = array_merge( $found, $m[1] ); |
| 2106 |
} |
| 2107 |
if ( preg_match_all( '/["\']([a-z0-9_\-]*nonce[a-z0-9_\-]*)["\']\s*:\s*["\'][a-f0-9]{8,}["\']/i', $html, $m ) ) { |
| 2108 |
$found = array_merge( $found, $m[1] ); |
| 2109 |
} |
| 2110 |
// The query-arg shape (`?_wpnonce=…`) has no key name to report |
| 2111 |
// beyond the literal, so name it explicitly rather than reporting |
| 2112 |
// nothing for a page that was genuinely refused. |
| 2113 |
if ( preg_match( '/[?&]_wpnonce=/i', $html ) ) { |
| 2114 |
$found[] = '_wpnonce'; |
| 2115 |
} |
| 2116 |
$keys = array_slice( array_values( array_unique( $found ) ), 0, 10 ); |
| 2117 |
} |
| 2118 |
|
| 2119 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; |
| 2120 |
|
| 2121 |
set_transient( |
| 2122 |
self::STATIC_SKIP_TRANSIENT, |
| 2123 |
array( |
| 2124 |
'reason' => $reason, |
| 2125 |
'url' => (string) strtok( $uri, '?' ), |
| 2126 |
'keys' => $keys, |
| 2127 |
'at' => time(), |
| 2128 |
), |
| 2129 |
HOUR_IN_SECONDS |
| 2130 |
); |
| 2131 |
} |
| 2132 |
|
| 2133 |
/** |
| 2134 |
* The most recent static-tree refusal, or an empty array when there is none. |
| 2135 |
* |
| 2136 |
* @return array{reason:string,url:string,keys:string[],at:int}|array{} |
| 2137 |
*/ |
| 2138 |
public static function last_static_skip(): array { |
| 2139 |
$stored = function_exists( 'get_transient' ) ? get_transient( self::STATIC_SKIP_TRANSIENT ) : false; |
| 2140 |
return is_array( $stored ) && ! empty( $stored['reason'] ) ? $stored : array(); |
| 2141 |
} |
| 2142 |
|
| 2143 |
private static function store_static( string $html ): void { |
| 2144 |
// Search results are keyed by term in cache_key() (`|s=<term>`) but |
| 2145 |
// carry the *path* of whatever URL was searched from — for the usual |
| 2146 |
// `/?s=<term>` that path is `/`. Writing them here would file the |
| 2147 |
// results page as `{host}/index.html` and the web server would serve |
| 2148 |
// it to every visitor as the homepage: an unauthenticated visitor |
| 2149 |
// poisons the front page with one request. Searches stay on the |
| 2150 |
// drop-in, which replays the term-keyed entry correctly. (#191) |
| 2151 |
// |
| 2152 |
// This is a superset of the query-string check the exclusion gate |
| 2153 |
// does: it also covers `/?%73=<term>`, which decodes to the same |
| 2154 |
// search (the shape #109 fixed on the gate side). |
| 2155 |
if ( self::should_cache_search() ) { |
| 2156 |
return; |
| 2157 |
} |
| 2158 |
|
| 2159 |
// Same hazard for the allow-listed query params: store_static() |
| 2160 |
// strips the query and files the response under the bare path, which |
| 2161 |
// the web server then serves to every visitor of the clean URL with |
| 2162 |
// no PHP involved at all — so none of the engine's checks can catch |
| 2163 |
// it later (#241). The callers already gate on this, but the guard |
| 2164 |
// is repeated here because this tree is the most dangerous of the |
| 2165 |
// three write sites and must not depend on its callers. |
| 2166 |
if ( self::request_has_query_string() ) { |
| 2167 |
return; |
| 2168 |
} |
| 2169 |
|
| 2170 |
// A nonce-bearing page is served here with NO PHP: no TTL check and |
| 2171 |
// no .meta replay, so the per-entry cap that keeps the drop-in honest |
| 2172 |
// (#236) cannot reach a file once it is written. Only Cache_GC removes |
| 2173 |
// it, and until it does the page hands every visitor the same nonce — |
| 2174 |
// which, once that nonce dies, breaks every anonymous form on it. |
| 2175 |
// |
| 2176 |
// Refusing outright was the safe answer, and it cost every |
| 2177 |
// nonce-bearing page the static tree entirely: a site whose homepage |
| 2178 |
// carries one unused login nonce ran PHP on every request forever. |
| 2179 |
// The nonce's own remaining life is the better gate — the page is |
| 2180 |
// written and its deadline recorded below for GC to enforce. |
| 2181 |
// |
| 2182 |
// A nonce we cannot put a clock on is still refused, and that refusal |
| 2183 |
// is still recorded: it stays completely silent otherwise, because the |
| 2184 |
// drop-in answers HIT while Health reports the fast path active from a |
| 2185 |
// probe that writes its OWN file and never proves real pages reach the |
| 2186 |
// tree. (#372) |
| 2187 |
$nonce_ttl = self::response_has_nonce( $html ) ? self::nonce_capped_ttl( $html, 0 ) : 0; |
| 2188 |
if ( self::response_has_nonce( $html ) && $nonce_ttl < 1 ) { |
| 2189 |
self::note_static_skip( 'nonce', $html ); |
| 2190 |
return; |
| 2191 |
} |
| 2192 |
|
| 2193 |
$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; |
| 2194 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; |
| 2195 |
$host = self::static_host_dir( $host ); |
| 2196 |
$uri = str_replace( "\0", '', $uri ); |
| 2197 |
$uri = (string) strtok( $uri, '?' ); |
| 2198 |
if ( '' === $host || '' === $uri ) { |
| 2199 |
return; |
| 2200 |
} |
| 2201 |
// Collapse any traversal sequences before path resolution. |
| 2202 |
$uri = preg_replace( '#/+#', '/', $uri ); |
| 2203 |
if ( false !== strpos( $uri, '..' ) ) { |
| 2204 |
return; |
| 2205 |
} |
| 2206 |
|
| 2207 |
$base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ); |
| 2208 |
$dir = $base . '/' . $host . rtrim( $uri, '/' ); |
| 2209 |
$file = $dir . '/index.html'; |
| 2210 |
|
| 2211 |
// Resolve the parent against the cache root to be sure the |
| 2212 |
// final path is inside our tree even if the OS does anything |
| 2213 |
// funny with multi-byte sequences. |
| 2214 |
$base_real = realpath( WP_CONTENT_DIR ); |
| 2215 |
if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) { |
| 2216 |
return; |
| 2217 |
} |
| 2218 |
|
| 2219 |
if ( ! file_exists( $dir ) ) { |
| 2220 |
wp_mkdir_p( $dir ); |
| 2221 |
} |
| 2222 |
if ( ! is_dir( $dir ) ) { |
| 2223 |
return; |
| 2224 |
} |
| 2225 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Same rationale as the flat-hash cache write above: WP_Filesystem isn't available on frontend requests, and the cache write must happen during shutdown. |
| 2226 |
$written = file_put_contents( $file, $html, LOCK_EX ); |
| 2227 |
|
| 2228 |
// A nonce-bearing page expires on the nonce's schedule, not the site's. |
| 2229 |
// Nothing reads this file at serve time — the web server hands over |
| 2230 |
// index.html without PHP — so the deadline is recorded beside it for |
| 2231 |
// GC, which is the only thing that can enforce it. Written before the |
| 2232 |
// action below so a listener that shells out cannot race the sweep. |
| 2233 |
if ( false !== $written && $nonce_ttl > 0 ) { |
| 2234 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- same rationale as the write above. |
| 2235 |
file_put_contents( $dir . '/.xspeed-expires', (string) ( time() + $nonce_ttl ), LOCK_EX ); |
| 2236 |
} |
| 2237 |
|
| 2238 |
if ( false !== $written ) { |
| 2239 |
/** |
| 2240 |
* Fires after a static cache file (index.html) is written. |
| 2241 |
* |
| 2242 |
* The extension point for serving pre-compressed siblings: |
| 2243 |
* the xspeed-pro Brotli module writes `index.html.br` next to |
| 2244 |
* the file here so the web server's static rewrite can serve a |
| 2245 |
* Brotli copy to clients that advertise `Accept-Encoding: br`, |
| 2246 |
* falling back to GZIP / the plain file otherwise. No core |
| 2247 |
* behavior depends on a listener being present. |
| 2248 |
* |
| 2249 |
* @param string $file Absolute path to the static cache file just written. |
| 2250 |
* @param string $html The HTML written to it. |
| 2251 |
*/ |
| 2252 |
do_action( 'xspeed_static_file_written', $file, $html ); |
| 2253 |
} |
| 2254 |
} |
| 2255 |
|
| 2256 |
/** |
| 2257 |
* Write the .meta sidecar for a cache entry when the response carries |
| 2258 |
* anything the HIT path must replay beyond a plain 200 text/html: |
| 2259 |
* - a non-HTML Content-Type (cached feeds → application/rss+xml, |
| 2260 |
* sitemaps → text/xml, …), and/or |
| 2261 |
* - a non-200 status (a cached 404 must serve 404, not 200). |
| 2262 |
* |
| 2263 |
* Ordinary 200 text/html pages get NO .meta file, so the common path |
| 2264 |
* stays a single write. |
| 2265 |
* |
| 2266 |
* @param string $key Cache key for the current request. |
| 2267 |
*/ |
| 2268 |
/** |
| 2269 |
* True only for a plain 200 text/html response — the only kind the |
| 2270 |
* web-server static tree can serve correctly (it streams the .html with |
| 2271 |
* no PHP, so it can't replay a 404 status or a feed Content-Type). Used |
| 2272 |
* to gate store_static() so cached 404s / feeds stay on the replay-capable |
| 2273 |
* drop-in / PHP path. (FBS-82406, FBS-82407) |
| 2274 |
*/ |
| 2275 |
private static function response_is_plain_html(): bool { |
| 2276 |
$status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200; |
| 2277 |
if ( 200 !== $status && $status > 0 ) { |
| 2278 |
return false; |
| 2279 |
} |
| 2280 |
foreach ( headers_list() as $header ) { |
| 2281 |
if ( 0 === stripos( $header, 'content-type:' ) ) { |
| 2282 |
$ct = trim( substr( $header, strlen( 'content-type:' ) ) ); |
| 2283 |
if ( '' !== $ct && false === stripos( $ct, 'text/html' ) ) { |
| 2284 |
return false; |
| 2285 |
} |
| 2286 |
} |
| 2287 |
} |
| 2288 |
return true; |
| 2289 |
} |
| 2290 |
|
| 2291 |
/** |
| 2292 |
* Append the cache signature comment to a finished page. |
| 2293 |
* |
| 2294 |
* The plugin's one outward version signal: external scanners (the |
| 2295 |
* xspeedcache.com speed test among them) read it to detect xSpeed and |
| 2296 |
* its version on a cached page, the way other cache plugins sign their |
| 2297 |
* output. Callers apply it AFTER HTML minification — the minifier strips |
| 2298 |
* comments — and before every cache write, so all serve paths carry the |
| 2299 |
* same bytes. |
| 2300 |
* |
| 2301 |
* The generation time is baked in here, at write time, in UTC. It is the |
| 2302 |
* moment the cached bytes were produced — NOT the moment they were served |
| 2303 |
* — because all three serve paths replay the same stored file, and two of |
| 2304 |
* them (the nginx/`.htaccess` static rewrite) run no PHP at all and so |
| 2305 |
* could never stamp a serve-time value. Reading the age of a page is the |
| 2306 |
* point: `generated` plus the current clock tells you how stale it is. |
| 2307 |
* `gmdate()` (not `current_time()`) keeps the value comparable across |
| 2308 |
* sites regardless of the configured timezone. |
| 2309 |
* |
| 2310 |
* @param string $html Finished page HTML. |
| 2311 |
* @return string HTML with the signature appended (or unchanged when a |
| 2312 |
* filter removed it). |
| 2313 |
*/ |
| 2314 |
private static function signed( string $html ): string { |
| 2315 |
$version = defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : ''; |
| 2316 |
$generated = gmdate( 'Y-m-d H:i:s' ) . ' UTC'; |
| 2317 |
// The literal ' | xspeedcache.com' must survive intact, and what |
| 2318 |
// precedes it is where an edition suffix lands: Pro appends itself by |
| 2319 |
// str_replace()-ing on that exact token |
| 2320 |
// (Pro_Plugin::sign_cache_signature). So the stamp goes AFTER it — |
| 2321 |
// placed before, it sits between the version and the anchor and |
| 2322 |
// composes as "generated <date> + Pro v1.1.3". |
| 2323 |
$signature = sprintf( |
| 2324 |
'<!-- Page cached by xSpeed Cache v%s | xspeedcache.com | generated %s -->', |
| 2325 |
$version, |
| 2326 |
$generated |
| 2327 |
); |
| 2328 |
|
| 2329 |
/** |
| 2330 |
* Filter: xspeed_cache_signature |
| 2331 |
* |
| 2332 |
* The HTML comment appended to every cached page. Add-ons append |
| 2333 |
* their own edition/version here; white-label setups return '' to |
| 2334 |
* remove the comment entirely. Must remain a valid HTML comment (or |
| 2335 |
* an empty string) — it ships inside the cached body. |
| 2336 |
* |
| 2337 |
* @param string $signature The signature comment. |
| 2338 |
* @param string $version The plugin version baked into it. |
| 2339 |
* @param string $generated The write-time timestamp baked into it, |
| 2340 |
* formatted `Y-m-d H:i:s UTC`. |
| 2341 |
*/ |
| 2342 |
$signature = (string) apply_filters( 'xspeed_cache_signature', $signature, $version, $generated ); |
| 2343 |
if ( '' === trim( $signature ) ) { |
| 2344 |
return $html; |
| 2345 |
} |
| 2346 |
return $html . "\n" . $signature; |
| 2347 |
} |
| 2348 |
|
| 2349 |
/** |
| 2350 |
* Does this response carry a WordPress nonce? |
| 2351 |
* |
| 2352 |
* Anonymous nonces depend only on the tick (user 0, empty session |
| 2353 |
* token), so they are identical for every visitor — which is exactly why |
| 2354 |
* they cache "successfully" and then fail silently once the tick moves. |
| 2355 |
* |
| 2356 |
* Matches any form field whose NAME contains "nonce" — `_wpnonce`, |
| 2357 |
* `_wpnonce_<action>`, Tutor's `_tutor_nonce`, CF7's `_wpcf7_nonce` and |
| 2358 |
* WooCommerce's `woocommerce-add-to-cart-nonce` (which does NOT start |
| 2359 |
* with an underscore, so a `_`-anchored pattern misses it) — plus the |
| 2360 |
* `_wpnonce=` form used in nonce-bearing URLs. Deliberately keyed on |
| 2361 |
* `name=` so prose, CSS classes and data attributes don't false-positive. |
| 2362 |
* |
| 2363 |
* @param string $html Rendered response body. |
| 2364 |
*/ |
| 2365 |
public static function response_has_nonce( string $html ): bool { |
| 2366 |
if ( '' === $html ) { |
| 2367 |
return false; |
| 2368 |
} |
| 2369 |
|
| 2370 |
/* |
| 2371 |
* Three shapes, because a nonce reaches the page in three ways: |
| 2372 |
* |
| 2373 |
* 1. A form field name — `_wpnonce`, `woocommerce-login-nonce`, and |
| 2374 |
* the GROUPED names form builders emit (`data[_wpnonce]`, |
| 2375 |
* `frm[nonce]`). The character class deliberately allows `[` and |
| 2376 |
* `]` so grouping does not hide the field: form builders are |
| 2377 |
* exactly the kind of plugin #236 is about, and a missed page |
| 2378 |
* keeps the old broken behaviour silently. |
| 2379 |
* 2. A query argument (`?_wpnonce=`) on a link. |
| 2380 |
* 3. A nonce handed to the page's own scripts rather than placed in |
| 2381 |
* a visible form — `wp_localize_script()` output and inline JSON |
| 2382 |
* both land as a `"nonce":"…"`-shaped pair. |
| 2383 |
*/ |
| 2384 |
return 1 === preg_match( |
| 2385 |
'/(name=["\'][a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*["\']' |
| 2386 |
. '|[?&]_wpnonce=' |
| 2387 |
. '|["\'][a-z0-9_\-]*nonce[a-z0-9_\-]*["\']\s*:\s*["\'][a-f0-9]{8,}["\'])/i', |
| 2388 |
$html |
| 2389 |
); |
| 2390 |
} |
| 2391 |
|
| 2392 |
/** |
| 2393 |
* The TTL (seconds) a response may be cached for, capped to the nonce |
| 2394 |
* lifetime when it carries one. |
| 2395 |
* |
| 2396 |
* WordPress nonces are valid for at most `nonce_life` — 24h by default — |
| 2397 |
* because wp_verify_nonce() accepts the current tick and the previous |
| 2398 |
* one. Our own lifetime maximum is 720h and the shipped Aggressive |
| 2399 |
* preset is 168h, so on any site configured above 24h every anonymous |
| 2400 |
* front-end form carried a DEAD nonce for the majority of the cache's |
| 2401 |
* life and every submission was rejected — with the other plugin's error |
| 2402 |
* string ("Nonce not matched"), so the report never reached us (#236). |
| 2403 |
* |
| 2404 |
* `nonce_life` is the MAXIMUM a nonce can live, not the minimum, so it |
| 2405 |
* is the wrong number to cap with. wp_nonce_tick() buckets time into |
| 2406 |
* `nonce_life / 2` slices; a nonce minted x seconds into its bucket is |
| 2407 |
* valid for `nonce_life - x`, where x can be as large as a full bucket. |
| 2408 |
* Capping the entry at `nonce_life` therefore still served a dead nonce |
| 2409 |
* for up to half of every entry's life — 0-12h of each 24h entry, |
| 2410 |
* averaging 6h, re-rolled by every purge so it reads as intermittent. |
| 2411 |
* Capping at the guaranteed-valid remainder closes the window at every |
| 2412 |
* tick phase, at the cost of caching nonce-bearing pages for 12h rather |
| 2413 |
* than 24h. |
| 2414 |
* |
| 2415 |
* Capping is per-entry, so only nonce-bearing pages pay for it; the rest |
| 2416 |
* of the site keeps the configured lifetime. |
| 2417 |
* |
| 2418 |
* @param string $html Rendered response body. |
| 2419 |
* @param int $ttl Otherwise-resolved TTL in seconds. |
| 2420 |
* @return int TTL to actually use. |
| 2421 |
*/ |
| 2422 |
/** |
| 2423 |
* The nonce lifetime to cap against, in seconds. |
| 2424 |
* |
| 2425 |
* `nonce_life` is a TWO-argument filter in core: |
| 2426 |
* |
| 2427 |
* $nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action ); |
| 2428 |
* |
| 2429 |
* Applying it with one argument is not merely incomplete — a callback |
| 2430 |
* that declares both parameters as required (the documented shape, and |
| 2431 |
* what a site branching per action must write) raises ArgumentCountError |
| 2432 |
* the moment we call it. That fatal lands in the shutdown cache write, |
| 2433 |
* so the visitor still sees a perfectly normal page while the sidecar is |
| 2434 |
* never written: the entry then keeps the FULL configured lifetime |
| 2435 |
* carrying a dead nonce, which is precisely the bug #236 set out to fix. |
| 2436 |
* Worse, the entry stays that way until a purge, even after the site |
| 2437 |
* removes whatever customised the lifetime. |
| 2438 |
* |
| 2439 |
* We are inspecting rendered markup, so we cannot know which action |
| 2440 |
* minted the nonce we found. Two consequences: |
| 2441 |
* |
| 2442 |
* 1. We pass `''` as the action. A per-action callback therefore sees |
| 2443 |
* the same "unknown action" value core itself passes when a nonce is |
| 2444 |
* created with no action, and can branch on it deliberately. |
| 2445 |
* 2. A page may carry nonces from SEVERAL actions with different |
| 2446 |
* lifetimes. The entry can only have one TTL, so the safe choice is |
| 2447 |
* the SHORTEST lifetime any action on the site resolves to — capping |
| 2448 |
* to a longer one would serve a dead nonce for the shorter action. |
| 2449 |
* Sites can narrow this with `xspeed_cache_nonce_life_actions`. |
| 2450 |
* |
| 2451 |
* @param string $html Response body being cached. |
| 2452 |
* @return int Nonce lifetime in seconds (0 = do not cap). |
| 2453 |
*/ |
| 2454 |
private static function nonce_life_seconds( string $html ): int { |
| 2455 |
/** |
| 2456 |
* Filter the nonce actions whose lifetimes are consulted when |
| 2457 |
* capping a cache entry. |
| 2458 |
* |
| 2459 |
* The default `''` is the "action unknown" case — we are reading |
| 2460 |
* rendered HTML, not minting a nonce. A site whose `nonce_life` |
| 2461 |
* callback shortens specific actions can list them here so the cap |
| 2462 |
* accounts for the shortest one that could appear on the page. |
| 2463 |
* |
| 2464 |
* @since 1.1.8 |
| 2465 |
* @param string[] $actions Nonce actions to resolve. |
| 2466 |
* @param string $html The response body being cached. |
| 2467 |
*/ |
| 2468 |
$actions = (array) apply_filters( 'xspeed_cache_nonce_life_actions', array( '' ), $html ); |
| 2469 |
if ( empty( $actions ) ) { |
| 2470 |
$actions = array( '' ); |
| 2471 |
} |
| 2472 |
|
| 2473 |
$shortest = 0; |
| 2474 |
foreach ( $actions as $action ) { |
| 2475 |
// Both arguments, exactly as core passes them. |
| 2476 |
$life = (int) apply_filters( 'nonce_life', DAY_IN_SECONDS, (string) $action ); |
| 2477 |
if ( $life < 1 ) { |
| 2478 |
continue; |
| 2479 |
} |
| 2480 |
if ( 0 === $shortest || $life < $shortest ) { |
| 2481 |
$shortest = $life; |
| 2482 |
} |
| 2483 |
} |
| 2484 |
|
| 2485 |
return $shortest; |
| 2486 |
} |
| 2487 |
|
| 2488 |
public static function nonce_capped_ttl( string $html, int $ttl ): int { |
| 2489 |
if ( ! self::response_has_nonce( $html ) ) { |
| 2490 |
return $ttl; |
| 2491 |
} |
| 2492 |
|
| 2493 |
$nonce_life = self::nonce_life_seconds( $html ); |
| 2494 |
if ( $nonce_life < 1 ) { |
| 2495 |
return $ttl; |
| 2496 |
} |
| 2497 |
|
| 2498 |
// Half of nonce_life is the GUARANTEED-valid remainder — see above. |
| 2499 |
$guaranteed = max( 1, intdiv( $nonce_life, 2 ) ); |
| 2500 |
$capped = ( $ttl > 0 ) ? min( $ttl, $guaranteed ) : $guaranteed; |
| 2501 |
|
| 2502 |
/** |
| 2503 |
* Filter the nonce-capped TTL for a cache entry. |
| 2504 |
* |
| 2505 |
* Escape hatch for a site whose nonce-shaped markup is decorative — |
| 2506 |
* return the uncapped $ttl to keep the configured lifetime. Most |
| 2507 |
* sites should leave this alone: serving a dead nonce breaks every |
| 2508 |
* anonymous form on the page. |
| 2509 |
* |
| 2510 |
* @param int $capped TTL after the nonce cap (seconds). |
| 2511 |
* @param int $ttl TTL before the cap (seconds). |
| 2512 |
* @param int $nonce_life Current nonce lifetime (seconds). |
| 2513 |
* @param string $html The response body being cached. |
| 2514 |
*/ |
| 2515 |
return (int) apply_filters( 'xspeed_cache_nonce_ttl_cap', $capped, $ttl, $nonce_life, $html ); |
| 2516 |
} |
| 2517 |
|
| 2518 |
private static function write_meta( string $key, string $html = '' ): void { |
| 2519 |
$content_type = ''; |
| 2520 |
foreach ( headers_list() as $header ) { |
| 2521 |
if ( 0 === stripos( $header, 'content-type:' ) ) { |
| 2522 |
$content_type = trim( substr( $header, strlen( 'content-type:' ) ) ); |
| 2523 |
} |
| 2524 |
} |
| 2525 |
$status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200; |
| 2526 |
|
| 2527 |
$meta = array(); |
| 2528 |
$is_default_type = ( '' === $content_type || false !== stripos( $content_type, 'text/html' ) ); |
| 2529 |
if ( ! $is_default_type ) { |
| 2530 |
$meta['content_type'] = $content_type; |
| 2531 |
} |
| 2532 |
if ( 200 !== $status && $status > 0 ) { |
| 2533 |
$meta['status'] = $status; |
| 2534 |
} |
| 2535 |
|
| 2536 |
// Per-content TTL (seconds). The drop-in and static fast paths can't |
| 2537 |
// call is_expired() / the xspeed_cache_max_age filter (they run before |
| 2538 |
// WP), so persist the resolved max-age here whenever it differs from |
| 2539 |
// the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page |
| 2540 |
// default. The fast paths read this to expire correctly. (FBS-82407) |
| 2541 |
// This MUST resolve the TTL the same way is_expired() does, including |
| 2542 |
// the per-post override — the sidecar is the only channel that can |
| 2543 |
// carry a per-entry TTL into the pre-boot fast paths. Omitting the |
| 2544 |
// override here left an editor's "expire this post after 1h" visible |
| 2545 |
// to the engine but invisible to the drop-in, which kept serving the |
| 2546 |
// entry until the global lifetime elapsed (#240 AC#3). Handing the |
| 2547 |
// filter the same base as is_expired() also keeps a filter that |
| 2548 |
// SCALES its input (e.g. $max_age * 2) consistent between the two. |
| 2549 |
$opts = Settings_Manager::get( 'cache' ); |
| 2550 |
$default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS; |
| 2551 |
$max_age = $default_ttl; |
| 2552 |
$post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() ); |
| 2553 |
if ( null !== $post_override ) { |
| 2554 |
$max_age = $post_override; |
| 2555 |
} |
| 2556 |
/** This filter is documented in includes/class-cache.php */ |
| 2557 |
$ttl = (int) apply_filters( 'xspeed_cache_max_age', $max_age ); |
| 2558 |
|
| 2559 |
// A response carrying a nonce may not outlive that nonce, however |
| 2560 |
// long the site's configured lifetime is (#236). This runs AFTER the |
| 2561 |
// max-age filter so it caps whatever the filter resolved rather than |
| 2562 |
// being overridden by it — a Pro module lengthening the TTL must not |
| 2563 |
// be able to reintroduce a dead nonce. |
| 2564 |
$ttl = self::nonce_capped_ttl( $html, $ttl ); |
| 2565 |
|
| 2566 |
if ( $ttl > 0 && $ttl !== $default_ttl ) { |
| 2567 |
$meta['ttl'] = $ttl; |
| 2568 |
} |
| 2569 |
|
| 2570 |
// Nothing to replay → no sidecar. |
| 2571 |
if ( empty( $meta ) ) { |
| 2572 |
return; |
| 2573 |
} |
| 2574 |
|
| 2575 |
$payload = wp_json_encode( $meta ); |
| 2576 |
if ( false === $payload ) { |
| 2577 |
return; |
| 2578 |
} |
| 2579 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend shutdown write. |
| 2580 |
file_put_contents( self::cache_meta_for( $key ), $payload, LOCK_EX ); |
| 2581 |
} |
| 2582 |
|
| 2583 |
/** |
| 2584 |
* @param string $cause Free-form human reason. Recorded in the |
| 2585 |
* Activity log to give users context (e.g. |
| 2586 |
* 'post saved', 'settings change', 'manual', |
| 2587 |
* 'theme switch'). |
| 2588 |
*/ |
| 2589 |
/** |
| 2590 |
* Purge the cache entries for ONE URL — every variant of it: the |
| 2591 |
* flat-hash entry (+ .meta / .html.br siblings), both device buckets |
| 2592 |
* (mobile_separate keys them separately), both trailing-slash forms, |
| 2593 |
* and the static-tree index.html (+ .br) the server rewrite serves. |
| 2594 |
* The rest of the cache is untouched — this is the surgical |
| 2595 |
* alternative to purge_all for "I just edited this one page". |
| 2596 |
* |
| 2597 |
* @param string $url Absolute URL, or site-relative path ("/about/"). |
| 2598 |
* @param string $cause Who asked, for the purge log. See purge_all(). |
| 2599 |
* @return int Number of cache files removed. |
| 2600 |
*/ |
| 2601 |
/** |
| 2602 |
* Post types that are not "viewable" but ARE the presentation layer. |
| 2603 |
* |
| 2604 |
* `is_post_type_viewable()` answers "does this type have a front end of |
| 2605 |
* its own?" — which is the right question for `shop_order`, but the |
| 2606 |
* wrong one for the types core uses to render every OTHER page. A |
| 2607 |
* template part, a global-styles record, a navigation or a synced |
| 2608 |
* pattern has no permalink, yet editing one changes how the whole site |
| 2609 |
* looks. Gating purges on viewability alone meant a Site Editor save |
| 2610 |
* invalidated nothing and visitors kept the old design for the full |
| 2611 |
* TTL — up to 30 days at the maximum lifetime. (#270 regression) |
| 2612 |
* |
| 2613 |
* @return string[] |
| 2614 |
*/ |
| 2615 |
public static function presentation_post_types(): array { |
| 2616 |
$types = array( |
| 2617 |
'wp_template', // Site Editor templates. |
| 2618 |
'wp_template_part', // Header / footer / reusable parts. |
| 2619 |
'wp_global_styles', // Colours, typography, spacing. |
| 2620 |
'wp_navigation', // Navigation block menus. |
| 2621 |
'nav_menu_item', // Classic menus. |
| 2622 |
'wp_block', // Synced patterns / reusable blocks. |
| 2623 |
); |
| 2624 |
|
| 2625 |
/** |
| 2626 |
* Filter the non-viewable post types that still invalidate the cache. |
| 2627 |
* |
| 2628 |
* Add a type here when it has no front end of its own but changes |
| 2629 |
* how other pages render (a theme's own layout CPT, for example). |
| 2630 |
* |
| 2631 |
* @param string[] $types Post type slugs. |
| 2632 |
*/ |
| 2633 |
return (array) apply_filters( 'xspeed_presentation_post_types', $types ); |
| 2634 |
} |
| 2635 |
|
| 2636 |
/** |
| 2637 |
* save_post → purge only when the saved thing can appear on a cached page. |
| 2638 |
* |
| 2639 |
* Revisions and autosaves are never rendered. Non-viewable post types — |
| 2640 |
* WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund` |
| 2641 |
* / `shop_coupon`, Flamingo's `flamingo_inbound` (#229), Tutor's |
| 2642 |
* `tutor_enrolled` (#231) — are invisible to anonymous visitors, so |
| 2643 |
* writing one changes nothing that is cached. (#243) |
| 2644 |
* |
| 2645 |
* The exception is the presentation types above, which are non-viewable |
| 2646 |
* yet render every page — they are allow-listed BEFORE the viewability |
| 2647 |
* test. (#270 regression) |
| 2648 |
* |
| 2649 |
* @param int $post_id Saved post ID. |
| 2650 |
* @param \WP_Post $post Saved post object. |
| 2651 |
*/ |
| 2652 |
public static function on_save_post( $post_id, $post = null ): void { |
| 2653 |
if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) { |
| 2654 |
return; |
| 2655 |
} |
| 2656 |
if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) { |
| 2657 |
return; |
| 2658 |
} |
| 2659 |
|
| 2660 |
$post_type = is_object( $post ) && isset( $post->post_type ) |
| 2661 |
? (string) $post->post_type |
| 2662 |
: (string) get_post_type( $post_id ); |
| 2663 |
if ( '' === $post_type ) { |
| 2664 |
return; |
| 2665 |
} |
| 2666 |
|
| 2667 |
// Unknown/!viewable → nothing anonymous can see changed, UNLESS the |
| 2668 |
// type is itself part of how pages render (#270 regression). |
| 2669 |
if ( function_exists( 'is_post_type_viewable' ) |
| 2670 |
&& ! is_post_type_viewable( $post_type ) |
| 2671 |
&& ! in_array( $post_type, self::presentation_post_types(), true ) |
| 2672 |
) { |
| 2673 |
return; |
| 2674 |
} |
| 2675 |
|
| 2676 |
// Name the trigger rather than logging a bare numeric id — the old |
| 2677 |
// wiring passed the post ID into $cause, so the log read |
| 2678 |
// "Cache purged (46)" with no indication of what caused it. (#243) |
| 2679 |
self::purge_all( 'post:' . $post_type ); |
| 2680 |
if ( class_exists( '\XSpeed\Minifier' ) ) { |
| 2681 |
Minifier::purge_minified(); |
| 2682 |
} |
| 2683 |
} |
| 2684 |
|
| 2685 |
/** |
| 2686 |
* comment_post → purge just the commented-on URL, and only once the |
| 2687 |
* comment is actually visible. |
| 2688 |
* |
| 2689 |
* A comment held for moderation changes nothing on the front end, and an |
| 2690 |
* approved one changes exactly one page — not the whole site. Product |
| 2691 |
* reviews are comments and guest reviews are on by default, so under the |
| 2692 |
* old wiring any visitor could flush a store's entire cache, repeatedly, |
| 2693 |
* with no account. (#243) |
| 2694 |
* |
| 2695 |
* @param int $comment_id New comment ID. |
| 2696 |
* @param int|string $approved 1 when approved, 0 when held, 'spam'. |
| 2697 |
* @param array $data Comment data. |
| 2698 |
*/ |
| 2699 |
public static function on_comment_post( $comment_id, $approved = 0, $data = array() ): void { |
| 2700 |
if ( 1 !== (int) $approved ) { |
| 2701 |
return; |
| 2702 |
} |
| 2703 |
$post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0; |
| 2704 |
if ( $post_id < 1 ) { |
| 2705 |
return; |
| 2706 |
} |
| 2707 |
$url = get_permalink( $post_id ); |
| 2708 |
if ( is_string( $url ) && '' !== $url ) { |
| 2709 |
self::purge_url( $url, 'comment' ); |
| 2710 |
} |
| 2711 |
} |
| 2712 |
|
| 2713 |
/** |
| 2714 |
* user_register / profile_update → purge only when the user can author |
| 2715 |
* content that appears on the front end. |
| 2716 |
* |
| 2717 |
* A customer registering at checkout changes no rendered page, and cannot |
| 2718 |
* change an enqueued asset — so it must not purge the cache, and must not |
| 2719 |
* rebuild the minified bundles. Checkout account-creation fired FOUR |
| 2720 |
* full-site purges plus four purge_minified() runs in a single request |
| 2721 |
* before this gate. (#243) |
| 2722 |
* |
| 2723 |
* @param int $user_id Affected user. |
| 2724 |
*/ |
| 2725 |
public static function on_user_change( $user_id ): void { |
| 2726 |
$user = function_exists( 'get_userdata' ) ? get_userdata( (int) $user_id ) : null; |
| 2727 |
if ( ! $user ) { |
| 2728 |
return; |
| 2729 |
} |
| 2730 |
|
| 2731 |
// Only roles that can publish can change a rendered page. WooCommerce |
| 2732 |
// customers and WordPress subscribers cannot. |
| 2733 |
if ( ! user_can( $user, 'edit_posts' ) ) { |
| 2734 |
return; |
| 2735 |
} |
| 2736 |
|
| 2737 |
$url = get_author_posts_url( (int) $user_id ); |
| 2738 |
if ( is_string( $url ) && '' !== $url ) { |
| 2739 |
self::purge_url( $url, 'user' ); |
| 2740 |
} |
| 2741 |
} |
| 2742 |
|
| 2743 |
/** |
| 2744 |
* Purge everything a product's price / stock / sale state is rendered on. |
| 2745 |
* |
| 2746 |
* The product permalink is not enough: the shop archive and the product's |
| 2747 |
* category and tag archives render the same price and Sale! badge, and |
| 2748 |
* #242 reproduces all three going stale together. |
| 2749 |
* |
| 2750 |
* Accepts a product ID or a WC_Product. A variation resolves to its |
| 2751 |
* parent, which is the page that actually renders. |
| 2752 |
* |
| 2753 |
* @param int|object $product Product ID or WC_Product. |
| 2754 |
*/ |
| 2755 |
public static function purge_product( $product ): void { |
| 2756 |
$product_id = is_object( $product ) && method_exists( $product, 'get_id' ) |
| 2757 |
? (int) $product->get_id() |
| 2758 |
: (int) $product; |
| 2759 |
if ( $product_id < 1 ) { |
| 2760 |
return; |
| 2761 |
} |
| 2762 |
|
| 2763 |
// Variations are never rendered on their own URL. |
| 2764 |
$parent = (int) wp_get_post_parent_id( $product_id ); |
| 2765 |
if ( $parent > 0 ) { |
| 2766 |
$product_id = $parent; |
| 2767 |
} |
| 2768 |
|
| 2769 |
$urls = array(); |
| 2770 |
|
| 2771 |
$permalink = get_permalink( $product_id ); |
| 2772 |
if ( is_string( $permalink ) && '' !== $permalink ) { |
| 2773 |
$urls[] = $permalink; |
| 2774 |
} |
| 2775 |
|
| 2776 |
// The shop archive. |
| 2777 |
if ( function_exists( 'wc_get_page_id' ) ) { |
| 2778 |
$shop_id = (int) wc_get_page_id( 'shop' ); |
| 2779 |
if ( $shop_id > 0 ) { |
| 2780 |
$shop_url = get_permalink( $shop_id ); |
| 2781 |
if ( is_string( $shop_url ) && '' !== $shop_url ) { |
| 2782 |
$urls[] = $shop_url; |
| 2783 |
} |
| 2784 |
} |
| 2785 |
} |
| 2786 |
|
| 2787 |
// Every category / tag archive this product appears on. |
| 2788 |
foreach ( array( 'product_cat', 'product_tag' ) as $taxonomy ) { |
| 2789 |
$terms = get_the_terms( $product_id, $taxonomy ); |
| 2790 |
if ( ! is_array( $terms ) ) { |
| 2791 |
continue; |
| 2792 |
} |
| 2793 |
foreach ( $terms as $term ) { |
| 2794 |
$term_url = get_term_link( $term ); |
| 2795 |
if ( is_string( $term_url ) && '' !== $term_url ) { |
| 2796 |
$urls[] = $term_url; |
| 2797 |
} |
| 2798 |
} |
| 2799 |
} |
| 2800 |
|
| 2801 |
// The front page, when it is not the shop page but still lists |
| 2802 |
// products (a block/shortcode storefront). |
| 2803 |
$front_id = (int) get_option( 'page_on_front' ); |
| 2804 |
if ( $front_id > 0 ) { |
| 2805 |
$front_url = get_permalink( $front_id ); |
| 2806 |
if ( is_string( $front_url ) && '' !== $front_url ) { |
| 2807 |
$urls[] = $front_url; |
| 2808 |
} |
| 2809 |
} |
| 2810 |
|
| 2811 |
/** |
| 2812 |
* Filter the URLs purged when a product changes. |
| 2813 |
* |
| 2814 |
* A storefront that renders products somewhere else — a landing page, |
| 2815 |
* a custom archive — can add its URLs here rather than falling back |
| 2816 |
* to purging the whole site. |
| 2817 |
* |
| 2818 |
* @param string[] $urls URLs about to be purged. |
| 2819 |
* @param int $product_id The product that changed. |
| 2820 |
*/ |
| 2821 |
$urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id ); |
| 2822 |
|
| 2823 |
foreach ( array_unique( array_filter( $urls ) ) as $url ) { |
| 2824 |
self::purge_url( (string) $url, 'product' ); |
| 2825 |
} |
| 2826 |
} |
| 2827 |
|
| 2828 |
/** |
| 2829 |
* Adapter for the WooCommerce stock actions that pass a product OBJECT |
| 2830 |
* where the status actions pass an ID. |
| 2831 |
* |
| 2832 |
* @param object $product WC_Product (or variation). |
| 2833 |
*/ |
| 2834 |
public static function purge_product_object( $product ): void { |
| 2835 |
self::purge_product( $product ); |
| 2836 |
} |
| 2837 |
|
| 2838 |
public static function purge_url( string $url, string $cause = 'manual' ): int { |
| 2839 |
$parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- fallback for early-boot contexts only. |
| 2840 |
if ( ! is_array( $parts ) ) { |
| 2841 |
return 0; |
| 2842 |
} |
| 2843 |
// Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which |
| 2844 |
// carries `:8080` on any install not served from 80/443 — while |
| 2845 |
// parse_url() splits the port into its own component, so a purge that |
| 2846 |
// used the bare host computed a different md5, found no file, and |
| 2847 |
// reported "already cold". A silent no-op: the page kept serving HIT |
| 2848 |
// until its TTL ran out. Intranet installs, panel hosts on :8443 and |
| 2849 |
// proxies that forward `Host: site.com:8080` all hit this. |
| 2850 |
$host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : ''; |
| 2851 |
if ( '' !== $host && isset( $parts['port'] ) ) { |
| 2852 |
$host .= ':' . (int) $parts['port']; |
| 2853 |
} |
| 2854 |
if ( '' === $host && function_exists( 'home_url' ) ) { |
| 2855 |
$home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above. |
| 2856 |
if ( is_array( $home ) && isset( $home['host'] ) ) { |
| 2857 |
$host = strtolower( (string) $home['host'] ); |
| 2858 |
if ( isset( $home['port'] ) ) { |
| 2859 |
$host .= ':' . (int) $home['port']; |
| 2860 |
} |
| 2861 |
} |
| 2862 |
} |
| 2863 |
if ( '' === $host ) { |
| 2864 |
return 0; |
| 2865 |
} |
| 2866 |
$path = isset( $parts['path'] ) ? (string) $parts['path'] : '/'; |
| 2867 |
$path = '/' . ltrim( $path, '/' ); |
| 2868 |
if ( false !== strpos( $path, '..' ) ) { |
| 2869 |
return 0; |
| 2870 |
} |
| 2871 |
|
| 2872 |
// The cache key preserves REQUEST_URI's trailing-slash form, so |
| 2873 |
// purge both. Root stays a single '/'. |
| 2874 |
$forms = array( $path ); |
| 2875 |
if ( '/' !== $path ) { |
| 2876 |
$forms[] = rtrim( $path, '/' ); |
| 2877 |
$forms[] = rtrim( $path, '/' ) . '/'; |
| 2878 |
} |
| 2879 |
$forms = array_unique( $forms ); |
| 2880 |
|
| 2881 |
/* |
| 2882 |
* Entries live under the bucket they were written for, and this URL's |
| 2883 |
* site may not be the one serving THIS request (a cross-site purge on |
| 2884 |
* multisite, WP-CLI, or cron). Build the directory from the URL's own |
| 2885 |
* host AND path. (#6) |
| 2886 |
* |
| 2887 |
* Host alone is wrong on a subdirectory network: `store()` wrote to |
| 2888 |
* `<host>/<prefix>/`, so looking in `<host>/` found nothing and the |
| 2889 |
* call reported "already cold" while the page kept serving HIT — a |
| 2890 |
* false success, which is worse than an error. The prefix has to come |
| 2891 |
* from the URL being purged rather than from the current blog, because |
| 2892 |
* the caller is usually purging some OTHER site. (QA B2 on #166) |
| 2893 |
*/ |
| 2894 |
$base = XSPEED_CACHE_DIR . '/' . self::bucket_for_url( $host, $path ); |
| 2895 |
|
| 2896 |
$count = 0; |
| 2897 |
foreach ( $forms as $uri ) { |
| 2898 |
// '' = mobile_separate off; '|m' / '|d' = the device buckets. |
| 2899 |
foreach ( array( '', '|m', '|d' ) as $device ) { |
| 2900 |
$key = md5( $host . $uri . $device ); |
| 2901 |
$file = $base . '/' . $key . '.html'; |
| 2902 |
if ( is_file( $file ) ) { |
| 2903 |
wp_delete_file( $file ); |
| 2904 |
++$count; |
| 2905 |
} |
| 2906 |
foreach ( array( $base . '/' . $key . '.meta', $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) { |
| 2907 |
if ( is_file( $sidecar ) ) { |
| 2908 |
wp_delete_file( $sidecar ); |
| 2909 |
} |
| 2910 |
} |
| 2911 |
} |
| 2912 |
} |
| 2913 |
|
| 2914 |
// Static tree (served directly by the nginx/.htaccess rewrite). |
| 2915 |
if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) { |
| 2916 |
// Same transform the write used — `localhost:8080` files under |
| 2917 |
// `localhost8080`, so the bare host found nothing here either. |
| 2918 |
$dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . ( '/' === $path ? '' : rtrim( $path, '/' ) ); |
| 2919 |
$file = $dir . '/index.html'; |
| 2920 |
if ( is_file( $file ) ) { |
| 2921 |
wp_delete_file( $file ); |
| 2922 |
++$count; |
| 2923 |
} |
| 2924 |
foreach ( array( $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) { |
| 2925 |
if ( is_file( $sidecar ) ) { |
| 2926 |
wp_delete_file( $sidecar ); |
| 2927 |
} |
| 2928 |
} |
| 2929 |
} |
| 2930 |
|
| 2931 |
if ( $count > 0 ) { |
| 2932 |
Cache_Inventory::invalidate(); |
| 2933 |
Activity_Log::record( |
| 2934 |
'cache_purge_url', |
| 2935 |
sprintf( |
| 2936 |
/* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */ |
| 2937 |
__( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ), |
| 2938 |
$cause, |
| 2939 |
$host . $path, |
| 2940 |
$count |
| 2941 |
), |
| 2942 |
Activity_Log::INFO |
| 2943 |
); |
| 2944 |
} |
| 2945 |
|
| 2946 |
return $count; |
| 2947 |
} |
| 2948 |
|
| 2949 |
/** |
| 2950 |
* Purge this site's cache. |
| 2951 |
* |
| 2952 |
* On multisite every blog shares one cache directory, so an unscoped |
| 2953 |
* sweep here took the whole network cold — one subsite's settings save |
| 2954 |
* or post publish rebuilt every other site from PHP. Entries are stored |
| 2955 |
* per host (see host_dir()), and the sweep is scoped to match, so a |
| 2956 |
* purge originating on site-a leaves site-b's cache warm. (#6) |
| 2957 |
* |
| 2958 |
* @param string $cause Who asked, for the purge log. |
| 2959 |
* @param string|null $host Host to purge. Defaults to the current site. |
| 2960 |
* Pass '*' to sweep the ENTIRE tree — network |
| 2961 |
* admin's "purge all sites", and the migration |
| 2962 |
* of pre-#6 entries that sit in the tree root. |
| 2963 |
*/ |
| 2964 |
public static function purge_all( string $cause = 'manual', ?string $host = null ) { |
| 2965 |
$network_wide = ( '*' === $host ); |
| 2966 |
// The flat tree buckets by a flattened segment (host/a-b) while the |
| 2967 |
// static tree mirrors the URL (host/a/b), so they need separate |
| 2968 |
// scopes — see current_host_dir() vs current_static_scope(). |
| 2969 |
$static_scope = ''; |
| 2970 |
if ( null === $host || $network_wide ) { |
| 2971 |
$scope = $network_wide ? '' : self::current_host_dir(); |
| 2972 |
$static_scope = $network_wide ? '' : self::current_static_scope(); |
| 2973 |
} else { |
| 2974 |
$dir = self::host_dir( $host ); |
| 2975 |
$scope = '' === $dir ? 'default' : $dir; |
| 2976 |
$static_scope = $scope; |
| 2977 |
} |
| 2978 |
|
| 2979 |
$count = 0; |
| 2980 |
if ( is_dir( XSPEED_CACHE_DIR ) ) { |
| 2981 |
// Scoped to one host directory, or the whole tree (including the |
| 2982 |
// legacy top-level entries written before #6) when network-wide. |
| 2983 |
/* |
| 2984 |
* Network-wide sweeps go TWO levels deep, not one. A subdirectory |
| 2985 |
* subsite's bucket is `<host>/<prefix>/`, so globbing only |
| 2986 |
* `<cache>/*` reached the main site and left every subsite's |
| 2987 |
* entries in place. (QA D5 on #166) |
| 2988 |
* |
| 2989 |
* A scoped purge also has to cover its own nested buckets: when |
| 2990 |
* the main blog of a subdirectory network purges, `<host>/` is its |
| 2991 |
* bucket and `<host>/one/` belongs to another blog — so the scoped |
| 2992 |
* branch deliberately does NOT descend, which is what keeps |
| 2993 |
* site-level purges isolated. |
| 2994 |
*/ |
| 2995 |
$roots = $network_wide |
| 2996 |
? array_merge( |
| 2997 |
array( XSPEED_CACHE_DIR ), |
| 2998 |
array_filter( (array) glob( XSPEED_CACHE_DIR . '/*', GLOB_ONLYDIR ) ), |
| 2999 |
array_filter( (array) glob( XSPEED_CACHE_DIR . '/*/*', GLOB_ONLYDIR ) ) |
| 3000 |
) |
| 3001 |
: array( XSPEED_CACHE_DIR . '/' . $scope ); |
| 3002 |
|
| 3003 |
foreach ( $roots as $root ) { |
| 3004 |
/* |
| 3005 |
* min/ and rest/ are swept by their own purgers below; never |
| 3006 |
* treat them as host buckets. |
| 3007 |
* |
| 3008 |
* Checked on every path SEGMENT, not just the basename: now |
| 3009 |
* that the network-wide glob descends two levels it can reach |
| 3010 |
* `min/combined`, whose basename is `combined` and would sail |
| 3011 |
* past a basename-only test — deleting the combined |
| 3012 |
* stylesheets out from under the pages that link them. |
| 3013 |
*/ |
| 3014 |
if ( ! $network_wide || XSPEED_CACHE_DIR !== $root ) { |
| 3015 |
$relative = trim( str_replace( XSPEED_CACHE_DIR, '', (string) $root ), '/' ); |
| 3016 |
$segments = '' === $relative ? array() : explode( '/', $relative ); |
| 3017 |
if ( array_intersect( $segments, array( 'min', 'rest' ) ) ) { |
| 3018 |
continue; |
| 3019 |
} |
| 3020 |
} |
| 3021 |
if ( ! is_dir( $root ) ) { |
| 3022 |
continue; |
| 3023 |
} |
| 3024 |
$files = glob( $root . '/*.html' ); |
| 3025 |
if ( $files ) { |
| 3026 |
$count += count( $files ); |
| 3027 |
foreach ( $files as $f ) { |
| 3028 |
wp_delete_file( $f ); |
| 3029 |
} |
| 3030 |
} |
| 3031 |
// Remove the .meta sidecars (content-type for feeds/sitemaps) |
| 3032 |
// alongside their .html entries. Not counted — they're not |
| 3033 |
// cache "pages", just per-entry metadata. |
| 3034 |
$meta = glob( $root . '/*.meta' ); |
| 3035 |
if ( $meta ) { |
| 3036 |
foreach ( $meta as $m ) { |
| 3037 |
wp_delete_file( $m ); |
| 3038 |
} |
| 3039 |
} |
| 3040 |
// Remove precompressed siblings (e.g. <key>.html.br from the Pro |
| 3041 |
// Brotli module). Not counted — same as .meta. Without this a |
| 3042 |
// purge leaves stale .br bodies behind: disk bloat, and a |
| 3043 |
// staleness window if precompression is later disabled. |
| 3044 |
$br = glob( $root . '/*.br' ); |
| 3045 |
if ( $br ) { |
| 3046 |
foreach ( $br as $b ) { |
| 3047 |
wp_delete_file( $b ); |
| 3048 |
} |
| 3049 |
} |
| 3050 |
// `*.br` does not match `*.br.size` — same reason as the flat-root |
| 3051 |
// sweep above: a size record outliving its body would later be |
| 3052 |
// read against a different sibling's bytes. |
| 3053 |
$br_size = glob( $root . '/*.br.size' ); |
| 3054 |
if ( $br_size ) { |
| 3055 |
foreach ( $br_size as $b ) { |
| 3056 |
wp_delete_file( $b ); |
| 3057 |
} |
| 3058 |
} |
| 3059 |
} |
| 3060 |
} |
| 3061 |
// Static-cache tree purge — recursive because the layout is |
| 3062 |
// xspeed-static/{host}/{path}/index.html, so a flat glob can't |
| 3063 |
// reach everything. Already host-segmented, so scoping is just a |
| 3064 |
// matter of starting one level down. |
| 3065 |
if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) { |
| 3066 |
$static_root = $network_wide |
| 3067 |
? XSPEED_CACHE_STATIC_DIR |
| 3068 |
: XSPEED_CACHE_STATIC_DIR . '/' . $static_scope; |
| 3069 |
if ( is_dir( $static_root ) ) { |
| 3070 |
$count += self::rmtree_html( $static_root ); |
| 3071 |
} |
| 3072 |
} |
| 3073 |
// REST response cache (cache/xspeed/rest/*.json) — same purge |
| 3074 |
// triggers (publish, settings change) invalidate it too. |
| 3075 |
$count += Rest_Cache::purge(); |
| 3076 |
|
| 3077 |
// Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/). |
| 3078 |
// purge_all is a full filesystem sweep and must clear these too, even |
| 3079 |
// when the Minify module is currently disabled — orphaned min/ files |
| 3080 |
// from a feature the user later turned off must still be removed, and |
| 3081 |
// a stale combined-<hash>.css that the regenerated page no longer |
| 3082 |
// references otherwise 404s and breaks the frontend. (FBS-83114/83116) |
| 3083 |
if ( class_exists( '\\XSpeed\\Minifier' ) ) { |
| 3084 |
Minifier::purge_minified(); |
| 3085 |
} |
| 3086 |
|
| 3087 |
// Persistent object cache (Redis / Memcached). Flush regardless of |
| 3088 |
// whether the Object Cache module is currently enabled — a drop-in |
| 3089 |
// installed earlier keeps serving until flushed. |
| 3090 |
// |
| 3091 |
// wp_cache_flush() is NETWORK-global: on multisite it would drop |
| 3092 |
// every other site's object cache too, which is the same bug this |
| 3093 |
// change fixes for the page cache. Prefer the blog-scoped flush |
| 3094 |
// (WP 6.1+) unless we were explicitly asked to go network-wide. (#6) |
| 3095 |
if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) { |
| 3096 |
// Blog-scoped groups only; a shared/global group (site options, |
| 3097 |
// user meta) is intentionally left alone. |
| 3098 |
foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) { |
| 3099 |
wp_cache_flush_group( $group ); |
| 3100 |
} |
| 3101 |
} elseif ( function_exists( 'wp_cache_flush' ) ) { |
| 3102 |
wp_cache_flush(); |
| 3103 |
} |
| 3104 |
|
| 3105 |
self::update_stats( array( 'last_purge' => time() ) ); |
| 3106 |
|
| 3107 |
// Fire AFTER the local sweep so module listeners (Critical CSS, |
| 3108 |
// Unused CSS, Cloudflare edge purge) run — this action had three |
| 3109 |
// registered listeners but was never emitted. Treat it as additive |
| 3110 |
// (CDN / edge invalidation), not the mechanism for clearing local |
| 3111 |
// files. (FBS-83114) |
| 3112 |
do_action( 'xspeed_after_purge_all', $cause ); |
| 3113 |
|
| 3114 |
// The list behind the "Cached pages" card is memoized for a minute; |
| 3115 |
// a purge has to drop it or the drill-down shows pages that no |
| 3116 |
// longer exist. |
| 3117 |
Cache_Inventory::invalidate(); |
| 3118 |
|
| 3119 |
// Trigger of WP_CLI / hook / admin-bar purges all hit the same |
| 3120 |
// path. Record once with the supplied cause so the dashboard |
| 3121 |
// activity feed reads naturally. |
| 3122 |
Activity_Log::record( |
| 3123 |
'cache_purged', |
| 3124 |
sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ), |
| 3125 |
Activity_Log::INFO |
| 3126 |
); |
| 3127 |
|
| 3128 |
return $count; |
| 3129 |
} |
| 3130 |
|
| 3131 |
/** |
| 3132 |
* Purge everything after a plugin / theme / core update completes. |
| 3133 |
* |
| 3134 |
* Bound to `upgrader_process_complete`, which is the only hook an update |
| 3135 |
* fires — no activation hook runs, so without this the cached HTML (and |
| 3136 |
* the asset URLs baked into it) outlives the code that produced it. |
| 3137 |
* |
| 3138 |
* Runs for plugin, theme and core updates alike, including bulk runs and |
| 3139 |
* auto-updates, and purges the WHOLE network rather than the current |
| 3140 |
* site — see the call below. Translation updates are skipped: they |
| 3141 |
* change no markup a cached page depends on, and language packs update |
| 3142 |
* often enough that purging on them would keep a multilingual site |
| 3143 |
* permanently cold. |
| 3144 |
* |
| 3145 |
* Note this cannot be folded into the `$invalidate_hooks` loop above: |
| 3146 |
* that binds `purge_all` directly, and `purge_all( string $cause )` would |
| 3147 |
* then receive the WP_Upgrader instance as its cause. |
| 3148 |
* |
| 3149 |
* @param mixed $upgrader WP_Upgrader instance (unused). |
| 3150 |
* @param array $hook_extra Context for the completed operation. |
| 3151 |
* @return void |
| 3152 |
*/ |
| 3153 |
public static function purge_after_upgrade( $upgrader = null, $hook_extra = array() ) { |
| 3154 |
$cleared = self::$upgrade_cleared_destination; |
| 3155 |
|
| 3156 |
if ( ! self::upgrade_produced_something( $upgrader ) ) { |
| 3157 |
return; |
| 3158 |
} |
| 3159 |
|
| 3160 |
if ( ! self::upgrade_should_purge( is_array( $hook_extra ) ? $hook_extra : array(), $cleared ) ) { |
| 3161 |
return; |
| 3162 |
} |
| 3163 |
|
| 3164 |
self::purge_for_upgrade(); |
| 3165 |
} |
| 3166 |
|
| 3167 |
/** |
| 3168 |
* Whether this request's upgrader removed an existing copy. |
| 3169 |
* |
| 3170 |
* @var bool |
| 3171 |
*/ |
| 3172 |
private static $upgrade_cleared_destination = false; |
| 3173 |
|
| 3174 |
/** |
| 3175 |
* How many `upgrader_process_complete` dispatches are on the stack. |
| 3176 |
* |
| 3177 |
* @var int |
| 3178 |
*/ |
| 3179 |
private static $upgrade_dispatch_depth = 0; |
| 3180 |
|
| 3181 |
/** |
| 3182 |
* Enter an `upgrader_process_complete` dispatch. |
| 3183 |
* |
| 3184 |
* Bound at PHP_INT_MIN, so it runs before any listener that might read |
| 3185 |
* the replacement signal. Public because it is a hook target. |
| 3186 |
* |
| 3187 |
* @return void |
| 3188 |
*/ |
| 3189 |
public static function note_upgrade_dispatch(): void { |
| 3190 |
++self::$upgrade_dispatch_depth; |
| 3191 |
} |
| 3192 |
|
| 3193 |
/** |
| 3194 |
* Drop the replacement signal once every listener has read it. |
| 3195 |
* |
| 3196 |
* Bound at PHP_INT_MAX so a second upgrade in the same request starts |
| 3197 |
* clean, without taking the answer away from the add-on callbacks that |
| 3198 |
* run at the same priority as ours. |
| 3199 |
* |
| 3200 |
* Only the OUTERMOST dispatch clears it. A nested run — core's language |
| 3201 |
* pack upgrader, or any add-on that installs something from this hook — |
| 3202 |
* fires the action again, and clearing there would answer for a run that |
| 3203 |
* has not finished. Called directly (no dispatch on the stack) it still |
| 3204 |
* clears, which is what a test wants. |
| 3205 |
* |
| 3206 |
* Known limit: a nested run INHERITS the outer run's signal, because the |
| 3207 |
* only evidence we get is a filter that fires before the nested dispatch |
| 3208 |
* begins and carries no upgrader identity. So a fresh install performed |
| 3209 |
* from inside a replacement run reads as a replacement and purges once |
| 3210 |
* more than it needs to. A cold cache is the cheap direction, and the |
| 3211 |
* alternative — scoping the signal per upgrader — is not knowable from |
| 3212 |
* `upgrader_clear_destination`. |
| 3213 |
* |
| 3214 |
* @return void |
| 3215 |
*/ |
| 3216 |
public static function forget_cleared_destination(): void { |
| 3217 |
if ( self::$upgrade_dispatch_depth > 0 ) { |
| 3218 |
--self::$upgrade_dispatch_depth; |
| 3219 |
} |
| 3220 |
|
| 3221 |
if ( 0 === self::$upgrade_dispatch_depth ) { |
| 3222 |
self::$upgrade_cleared_destination = false; |
| 3223 |
} |
| 3224 |
} |
| 3225 |
|
| 3226 |
/** |
| 3227 |
* Record that the upgrader cleared an existing destination. |
| 3228 |
* |
| 3229 |
* A pass-through listener on `upgrader_clear_destination`: WordPress only |
| 3230 |
* fires it when `clear_destination` was set AND something was there to |
| 3231 |
* remove, which is the one signal that separates an upload-and-replace |
| 3232 |
* from a first-time install. The filtered value is returned untouched. |
| 3233 |
* |
| 3234 |
* @param true|\WP_Error $removed Whether the destination was cleared. |
| 3235 |
* @return true|\WP_Error |
| 3236 |
*/ |
| 3237 |
public static function note_cleared_destination( $removed ) { |
| 3238 |
if ( ! is_wp_error( $removed ) ) { |
| 3239 |
self::$upgrade_cleared_destination = true; |
| 3240 |
} |
| 3241 |
|
| 3242 |
return $removed; |
| 3243 |
} |
| 3244 |
|
| 3245 |
/** |
| 3246 |
* Did the completed run actually replace anything? |
| 3247 |
* |
| 3248 |
* `upgrader_process_complete` fires whether the run succeeded or failed — |
| 3249 |
* the failure branch in WP_Upgrader::run() only feeds the skin before the |
| 3250 |
* action fires. A run that installed nothing changed no markup, so purging |
| 3251 |
* for it is a cold cache bought for nothing. |
| 3252 |
* |
| 3253 |
* Deliberately conservative: this returns false ONLY when every result we |
| 3254 |
* can see is an error. An upgrader we cannot read, a mixed bulk run, or a |
| 3255 |
* missing result all fall through to purging, which is the safe direction |
| 3256 |
* everywhere else in this handler. |
| 3257 |
* |
| 3258 |
* @param mixed $upgrader WP_Upgrader instance, or anything else. |
| 3259 |
* @return bool |
| 3260 |
*/ |
| 3261 |
public static function upgrade_produced_something( $upgrader ): bool { |
| 3262 |
if ( ! is_object( $upgrader ) ) { |
| 3263 |
return true; |
| 3264 |
} |
| 3265 |
|
| 3266 |
// A bulk run collects one entry per item; `result` alone would only |
| 3267 |
// describe the last of them. |
| 3268 |
if ( isset( $upgrader->results ) && is_array( $upgrader->results ) && ! empty( $upgrader->results ) ) { |
| 3269 |
foreach ( $upgrader->results as $result ) { |
| 3270 |
if ( ! is_wp_error( $result ) && ! empty( $result ) ) { |
| 3271 |
return true; |
| 3272 |
} |
| 3273 |
} |
| 3274 |
return false; |
| 3275 |
} |
| 3276 |
|
| 3277 |
if ( ! property_exists( $upgrader, 'result' ) ) { |
| 3278 |
return true; |
| 3279 |
} |
| 3280 |
|
| 3281 |
return ! is_wp_error( $upgrader->result ) && ! empty( $upgrader->result ); |
| 3282 |
} |
| 3283 |
|
| 3284 |
/** |
| 3285 |
* Decide whether a completed operation invalidates the cache. |
| 3286 |
* |
| 3287 |
* Split out from the handler so the decision is testable on its own: |
| 3288 |
* purge_all() reaches straight for glob() and unlink(), which a unit test |
| 3289 |
* cannot observe honestly, while every rule that matters lives here. |
| 3290 |
* |
| 3291 |
* @param array $hook_extra Context for the completed operation. |
| 3292 |
* @return bool |
| 3293 |
*/ |
| 3294 |
public static function upgrade_should_purge( array $hook_extra, bool $destination_cleared = false ): bool { |
| 3295 |
if ( ! self::upgrade_replaced_code( $hook_extra, $destination_cleared ) ) { |
| 3296 |
return false; |
| 3297 |
} |
| 3298 |
|
| 3299 |
// An update to xSpeed ITSELF always purges, whatever the setting says. |
| 3300 |
// This plugin's own code is what rendered every cached page — the |
| 3301 |
// minifier, lazy-loader, resource hints and CDN rewriter all changed |
| 3302 |
// underneath it — so serving that HTML after an update means serving |
| 3303 |
// output from a version that no longer exists. Minified assets make it |
| 3304 |
// concrete rather than theoretical: their filenames are keyed on the |
| 3305 |
// source filemtime, so they regenerate under NEW hashes while the |
| 3306 |
// cached pages still link the old ones, and the page requests files |
| 3307 |
// that are no longer on disk. Offering an opt-out for that would be |
| 3308 |
// offering a broken site. |
| 3309 |
return self::upgrade_touches_xspeed( $hook_extra ) || self::purge_on_upgrade_enabled(); |
| 3310 |
} |
| 3311 |
|
| 3312 |
/** |
| 3313 |
* Did this completed run replace code that renders pages? |
| 3314 |
* |
| 3315 |
* The half of the decision that has nothing to do with our settings: it |
| 3316 |
* asks only whether live code changed underneath the output we cached. |
| 3317 |
* Add-ons that keep their own derived artifacts — generated CSS, captured |
| 3318 |
* selectors, fingerprints — need the same answer and must not have to |
| 3319 |
* rebuild these rules, or they drift apart. Call it with the hook's own |
| 3320 |
* `$hook_extra`; the upload-and-replace signal is read from this request. |
| 3321 |
* |
| 3322 |
* Deliberately independent of the "Purge After Updates" setting. That |
| 3323 |
* setting governs the page cache, not whether an add-on's derived data is |
| 3324 |
* still valid. |
| 3325 |
* |
| 3326 |
* @param array $hook_extra Context for the completed operation. |
| 3327 |
* @param bool|null $destination_cleared Override the recorded signal; null reads this request's. |
| 3328 |
* @return bool |
| 3329 |
*/ |
| 3330 |
public static function upgrade_replaced_code( array $hook_extra, ?bool $destination_cleared = null ): bool { |
| 3331 |
$cleared = null === $destination_cleared ? self::$upgrade_cleared_destination : $destination_cleared; |
| 3332 |
|
| 3333 |
$type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : ''; |
| 3334 |
$action = isset( $hook_extra['action'] ) ? (string) $hook_extra['action'] : ''; |
| 3335 |
|
| 3336 |
// `upgrader_process_complete` fires for INSTALLS as well as updates. |
| 3337 |
// A freshly installed plugin is inactive and a freshly installed theme |
| 3338 |
// is not the active one, so neither can change a single rendered page |
| 3339 |
// — but the first cut of this handler purged the whole tree anyway, so |
| 3340 |
// evaluating three plugins in a row emptied the cache three times. |
| 3341 |
// |
| 3342 |
// 'install' alone is NOT enough to skip on, because WordPress reports |
| 3343 |
// an upload-and-replace as an install: `Plugin_Upgrader::install()` |
| 3344 |
// hardcodes `action => install` and `overwrite_package` does not change |
| 3345 |
// it, so "Replace current with uploaded" and `wp plugin install <zip> |
| 3346 |
// --force` both arrive here labelled install while genuinely replacing |
| 3347 |
// live code. That is how a plugin distributed as a zip is updated, and |
| 3348 |
// skipping it put back the stale markup this handler exists to clear. |
| 3349 |
// |
| 3350 |
// The distinguishing signal is whether the destination was cleared: |
| 3351 |
// WP_Upgrader only fires `upgrader_clear_destination` when it removed |
| 3352 |
// something that was already there. Installing beside nothing does not. |
| 3353 |
if ( 'install' === $action && ! $cleared ) { |
| 3354 |
return false; |
| 3355 |
} |
| 3356 |
|
| 3357 |
// 'translation' is the one update type that cannot change rendered |
| 3358 |
// markup. Anything else — including an empty type from a custom |
| 3359 |
// updater — is treated as cache-invalidating, because guessing wrong |
| 3360 |
// in that direction only costs a cold cache. |
| 3361 |
if ( 'translation' === $type ) { |
| 3362 |
return false; |
| 3363 |
} |
| 3364 |
|
| 3365 |
return true; |
| 3366 |
} |
| 3367 |
|
| 3368 |
/** |
| 3369 |
* Purge everything an update can invalidate. |
| 3370 |
* |
| 3371 |
* Network-wide ('*'), not the calling site's bucket. A plugin, theme or |
| 3372 |
* core update replaces code shared by EVERY site on the network, so a |
| 3373 |
* scoped purge would clear the site that happened to run the updater and |
| 3374 |
* leave every other subsite serving pre-update HTML for the whole TTL — |
| 3375 |
* the very bug this handler exists to fix, one level down. On single-site |
| 3376 |
* this is identical to the scoped call, since there is only ever one |
| 3377 |
* bucket. |
| 3378 |
* |
| 3379 |
* @return void |
| 3380 |
*/ |
| 3381 |
private static function purge_for_upgrade(): void { |
| 3382 |
self::purge_all( 'upgrade', '*' ); |
| 3383 |
Minifier::purge_minified(); |
| 3384 |
} |
| 3385 |
|
| 3386 |
/** |
| 3387 |
* Purge after an unattended background update run. |
| 3388 |
* |
| 3389 |
* `automatic_updates_complete` passes ONE argument, and it is not a |
| 3390 |
* hook_extra: it is WordPress's results array, keyed by what was updated |
| 3391 |
* ('core', 'plugin', 'theme', 'translation'). Handing it to |
| 3392 |
* purge_after_upgrade() put it in the unused $upgrader slot and left the |
| 3393 |
* type empty, so a night on which only a language pack updated purged |
| 3394 |
* every cached page — the exact case the translation exemption exists to |
| 3395 |
* prevent, and WordPress auto-updates language packs by default. |
| 3396 |
* |
| 3397 |
* @param array $results Update results, keyed by type. |
| 3398 |
* @return void |
| 3399 |
*/ |
| 3400 |
public static function purge_after_auto_updates( $results = array() ): void { |
| 3401 |
if ( ! self::auto_updates_should_purge( is_array( $results ) ? $results : array() ) ) { |
| 3402 |
return; |
| 3403 |
} |
| 3404 |
|
| 3405 |
self::purge_for_upgrade(); |
| 3406 |
} |
| 3407 |
|
| 3408 |
/** |
| 3409 |
* Decide whether a background update run invalidates the cache. |
| 3410 |
* |
| 3411 |
* @param array $results Update results, keyed by type. |
| 3412 |
* @return bool |
| 3413 |
*/ |
| 3414 |
public static function auto_updates_should_purge( array $results ): bool { |
| 3415 |
// An unrecognisable payload is treated as invalidating, the same |
| 3416 |
// direction every other unknown takes here. |
| 3417 |
if ( empty( $results ) ) { |
| 3418 |
return true; |
| 3419 |
} |
| 3420 |
|
| 3421 |
// Failed items are listed alongside successful ones — WP_Automatic_Updater |
| 3422 |
// appends an entry whatever the outcome — and a night on which every |
| 3423 |
// update failed replaced no code, so it invalidates nothing. |
| 3424 |
$updated = array(); |
| 3425 |
foreach ( $results as $type => $items ) { |
| 3426 |
if ( ! is_array( $items ) ) { |
| 3427 |
continue; |
| 3428 |
} |
| 3429 |
foreach ( $items as $item ) { |
| 3430 |
$result = is_object( $item ) && isset( $item->result ) ? $item->result : true; |
| 3431 |
if ( ! is_wp_error( $result ) && ! empty( $result ) ) { |
| 3432 |
$updated[] = (string) $type; |
| 3433 |
break; |
| 3434 |
} |
| 3435 |
} |
| 3436 |
} |
| 3437 |
|
| 3438 |
if ( empty( $updated ) ) { |
| 3439 |
return false; |
| 3440 |
} |
| 3441 |
|
| 3442 |
// Nothing but language packs: a language pack changes no markup a |
| 3443 |
// cached page depends on, and purging on one would keep a multilingual |
| 3444 |
// site permanently cold. |
| 3445 |
if ( array( 'translation' ) === array_values( array_unique( $updated ) ) ) { |
| 3446 |
return false; |
| 3447 |
} |
| 3448 |
|
| 3449 |
return self::auto_updates_touch_xspeed( $results ) || self::purge_on_upgrade_enabled(); |
| 3450 |
} |
| 3451 |
|
| 3452 |
/** |
| 3453 |
* Does a background run include one of our own plugins? |
| 3454 |
* |
| 3455 |
* Same rule as a foreground self-update, read out of the results array's |
| 3456 |
* shape instead of a hook_extra: each plugin entry carries the update |
| 3457 |
* object on `->item->plugin`. |
| 3458 |
* |
| 3459 |
* @param array $results Update results, keyed by type. |
| 3460 |
* @return bool |
| 3461 |
*/ |
| 3462 |
private static function auto_updates_touch_xspeed( array $results ): bool { |
| 3463 |
if ( empty( $results['plugin'] ) || ! is_array( $results['plugin'] ) ) { |
| 3464 |
return false; |
| 3465 |
} |
| 3466 |
|
| 3467 |
$ours = self::self_update_plugins(); |
| 3468 |
foreach ( $results['plugin'] as $entry ) { |
| 3469 |
$item = is_object( $entry ) && isset( $entry->item ) ? $entry->item : null; |
| 3470 |
$file = is_object( $item ) && isset( $item->plugin ) ? (string) $item->plugin : ''; |
| 3471 |
if ( '' !== $file && in_array( $file, $ours, true ) ) { |
| 3472 |
return true; |
| 3473 |
} |
| 3474 |
} |
| 3475 |
|
| 3476 |
return false; |
| 3477 |
} |
| 3478 |
|
| 3479 |
/** |
| 3480 |
* Is the "Purge After Updates" setting on? |
| 3481 |
* |
| 3482 |
* Gates THIRD-PARTY updates only — an xSpeed self-update ignores it, see |
| 3483 |
* purge_after_upgrade(). Defaults to true when the option has never been |
| 3484 |
* written, matching the schema default in CacheModule: an unset value on |
| 3485 |
* an existing install must not read as "the user turned this off". |
| 3486 |
* |
| 3487 |
* Unlike LiteSpeed, which ships the equivalent toggle OFF, this defaults |
| 3488 |
* ON — a cold cache costs one slow request, whereas stale HTML is a wrong |
| 3489 |
* page for up to the full TTL and the site owner has no way to tell why. |
| 3490 |
* |
| 3491 |
* @return bool |
| 3492 |
*/ |
| 3493 |
private static function purge_on_upgrade_enabled(): bool { |
| 3494 |
$opts = Settings_Manager::get( 'cache' ); |
| 3495 |
return ! array_key_exists( 'purge_on_upgrade', $opts ) || ! empty( $opts['purge_on_upgrade'] ); |
| 3496 |
} |
| 3497 |
|
| 3498 |
/** |
| 3499 |
* Does this completed update include xSpeed itself? |
| 3500 |
* |
| 3501 |
* Mirrors the payload shapes Plugin::maybe_restore_after_update() reads: |
| 3502 |
* a single update carries 'plugin', a bulk run carries 'plugins'. |
| 3503 |
* |
| 3504 |
* @param array $hook_extra Context for the completed operation. |
| 3505 |
* @return bool |
| 3506 |
*/ |
| 3507 |
private static function upgrade_touches_xspeed( array $hook_extra ): bool { |
| 3508 |
if ( ! isset( $hook_extra['type'] ) || 'plugin' !== $hook_extra['type'] ) { |
| 3509 |
return false; |
| 3510 |
} |
| 3511 |
|
| 3512 |
$updated = array(); |
| 3513 |
if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) { |
| 3514 |
// Strings only: array_intersect() stringifies what it is given, so |
| 3515 |
// an object without __toString in a custom updater's payload would |
| 3516 |
// be a fatal rather than a miss. |
| 3517 |
$updated = array_filter( $hook_extra['plugins'], 'is_string' ); |
| 3518 |
} elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) { |
| 3519 |
$updated = array( $hook_extra['plugin'] ); |
| 3520 |
} |
| 3521 |
|
| 3522 |
return (bool) array_intersect( self::self_update_plugins(), $updated ); |
| 3523 |
} |
| 3524 |
|
| 3525 |
/** |
| 3526 |
* Plugin files whose update counts as an update to us. |
| 3527 |
* |
| 3528 |
* The self-update rule is "our own code rendered this cached HTML, so it |
| 3529 |
* must not survive the code being replaced". That is true of any add-on |
| 3530 |
* that writes into the same page: an add-on inlines critical CSS, rewrites |
| 3531 |
* stylesheet links and image URLs, and produces the compressed and static |
| 3532 |
* copies, so its update leaves exactly the stale markup this rule exists |
| 3533 |
* to clear. Free cannot name an add-on, so it asks instead. |
| 3534 |
* |
| 3535 |
* Filter: xspeed_self_update_plugins |
| 3536 |
* |
| 3537 |
* Add-ons add their own `plugin_basename( __FILE__ )`. Entries are matched |
| 3538 |
* against the plugin files WordPress reports for the completed update, so |
| 3539 |
* a value that is not a `dir/file.php` basename simply never matches. |
| 3540 |
* |
| 3541 |
* @param string[] $plugins Plugin basenames treated as our own. |
| 3542 |
* @return string[] |
| 3543 |
*/ |
| 3544 |
private static function self_update_plugins(): array { |
| 3545 |
$ours = array( plugin_basename( XSPEED_FILE ) ); |
| 3546 |
|
| 3547 |
/** This filter is documented above. */ |
| 3548 |
$filtered = apply_filters( 'xspeed_self_update_plugins', $ours ); |
| 3549 |
|
| 3550 |
// Our own file is merged back afterwards rather than trusted to survive |
| 3551 |
// the round trip. A listener that returns null, a bare string, or a |
| 3552 |
// list it built from scratch would otherwise drop it, and the plugin |
| 3553 |
// would quietly stop exempting its OWN update from the setting — a |
| 3554 |
// failure no add-on author would think to test for. |
| 3555 |
$claimed = array_filter( is_array( $filtered ) ? $filtered : array(), 'is_string' ); |
| 3556 |
|
| 3557 |
return array_values( array_unique( array_merge( $ours, array_filter( $claimed ) ) ) ); |
| 3558 |
} |
| 3559 |
|
| 3560 |
/** |
| 3561 |
* Invalidate caches of RENDERED output owned by other plugins. |
| 3562 |
* |
| 3563 |
* purge_all() sweeps only what xSpeed wrote. A page builder that stores |
| 3564 |
* rendered HTML or generated CSS of its own — Elementor's element cache |
| 3565 |
* and `uploads/elementor/css/`, and the equivalents in Beaver / Divi / |
| 3566 |
* Bricks / Oxygen — keeps whatever asset URLs were current when it was |
| 3567 |
* written, and no xSpeed purge has ever reached it. |
| 3568 |
* |
| 3569 |
* That only matters for rewrites that happen DURING render rather than on |
| 3570 |
* the finished page. Minify, combine, lazy-load and resource hints all run |
| 3571 |
* on `xspeed_cache_final_html` or a `template_redirect` buffer — after the |
| 3572 |
* builder has already stored its copy — so nothing they emit can leak. |
| 3573 |
* The CDN module's `wp_get_attachment_url` filter is the one that can. |
| 3574 |
* |
| 3575 |
* Called ONLY from purges where asset URLs themselves can have changed |
| 3576 |
* (a CDN settings write, an explicit Purge All). NOT from purge_all(), |
| 3577 |
* which also runs on every post publish — regenerating every builder CSS |
| 3578 |
* file that often would cost more than it saves, and the builder already |
| 3579 |
* invalidates its own copy for the post being saved. |
| 3580 |
* |
| 3581 |
* @param string $cause Who asked. Threaded through to the listeners and |
| 3582 |
* the activity log. |
| 3583 |
* @return string[] Labels of the caches that were actually cleared. |
| 3584 |
*/ |
| 3585 |
public static function purge_render_caches( string $cause = 'manual' ): array { |
| 3586 |
/** |
| 3587 |
* Clear render caches belonging to other plugins. |
| 3588 |
* |
| 3589 |
* A listener does its own work and appends a human-readable label for |
| 3590 |
* what it cleared, so the activity log can name it. Returning |
| 3591 |
* `$cleared` unchanged means "nothing of mine is installed" and is the |
| 3592 |
* correct no-op — never a failure. |
| 3593 |
* |
| 3594 |
* Detect the owning plugin by class or constant, not by an |
| 3595 |
* `is_plugin_active()` path check: a renamed plugin folder must not |
| 3596 |
* silently disable the integration. |
| 3597 |
* |
| 3598 |
* @param string[] $cleared Labels of caches cleared so far. |
| 3599 |
* @param string $cause Why the purge is happening. |
| 3600 |
*/ |
| 3601 |
$cleared = (array) apply_filters( 'xspeed_purge_third_party_render_caches', array(), $cause ); |
| 3602 |
|
| 3603 |
// Labels are strings destined for the activity feed. Anything else a |
| 3604 |
// third-party listener returns is dropped rather than coerced — a |
| 3605 |
// stray `0` or `null` in the log reads as a cache we cleared. |
| 3606 |
$cleared = array_values( |
| 3607 |
array_filter( |
| 3608 |
$cleared, |
| 3609 |
static function ( $label ) { |
| 3610 |
return is_string( $label ) && '' !== trim( $label ); |
| 3611 |
} |
| 3612 |
) |
| 3613 |
); |
| 3614 |
|
| 3615 |
if ( ! $cleared ) { |
| 3616 |
return $cleared; |
| 3617 |
} |
| 3618 |
|
| 3619 |
// Logged separately from the page-cache purge above it. "I turned the |
| 3620 |
// CDN off and the images are still wrong" is only diagnosable if the |
| 3621 |
// feed says which OTHER plugin's cache was regenerated and when. |
| 3622 |
Activity_Log::record( |
| 3623 |
'cache_purged', |
| 3624 |
sprintf( 'Render caches cleared (%s) — %s', $cause, implode( ', ', $cleared ) ), |
| 3625 |
Activity_Log::INFO |
| 3626 |
); |
| 3627 |
|
| 3628 |
return $cleared; |
| 3629 |
} |
| 3630 |
|
| 3631 |
/** |
| 3632 |
* The per-type purge menu, LiteSpeed-style. Each entry is a cache type |
| 3633 |
* the user can purge individually from the admin-bar dropdown. `visible` |
| 3634 |
* controls whether the item shows (active + licensed module only) — it |
| 3635 |
* NEVER limits Purge All, which always sweeps everything on disk. |
| 3636 |
* |
| 3637 |
* Pro registers its own types (Critical CSS, Unused CSS, …) by filtering |
| 3638 |
* `xspeed_purge_types`, so Free degrades gracefully when Pro is absent. |
| 3639 |
* |
| 3640 |
* @return array<string,array{label:string,visible:bool}> |
| 3641 |
*/ |
| 3642 |
public static function purge_types(): array { |
| 3643 |
$minify_on = false; |
| 3644 |
if ( class_exists( '\\XSpeed\\Settings_Manager' ) ) { |
| 3645 |
$min = Settings_Manager::get( 'minify' ); |
| 3646 |
$minify_on = ! empty( $min['minify_css'] ) || ! empty( $min['minify_js'] ) || ! empty( $min['combine_css'] ) || ! empty( $min['combine_js'] ); |
| 3647 |
} |
| 3648 |
// Object cache is "active" when an external object-cache drop-in is in |
| 3649 |
// use — the canonical WP signal, independent of our settings option. |
| 3650 |
$oc_on = function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache(); |
| 3651 |
|
| 3652 |
$types = array( |
| 3653 |
'all' => array( |
| 3654 |
'label' => __( 'Purge All', 'xspeed' ), |
| 3655 |
'visible' => true, |
| 3656 |
), |
| 3657 |
'page' => array( |
| 3658 |
'label' => __( 'Purge Page / Static Cache', 'xspeed' ), |
| 3659 |
'visible' => true, |
| 3660 |
), |
| 3661 |
'assets' => array( |
| 3662 |
'label' => __( 'Purge CSS / JS Cache', 'xspeed' ), |
| 3663 |
'visible' => $minify_on, |
| 3664 |
), |
| 3665 |
'object' => array( |
| 3666 |
'label' => __( 'Purge Object Cache', 'xspeed' ), |
| 3667 |
'visible' => $oc_on, |
| 3668 |
), |
| 3669 |
'rest' => array( |
| 3670 |
'label' => __( 'Purge REST Cache', 'xspeed' ), |
| 3671 |
'visible' => true, |
| 3672 |
), |
| 3673 |
); |
| 3674 |
|
| 3675 |
/** |
| 3676 |
* Filter the admin-bar purge-type menu. Pro modules add their own |
| 3677 |
* (Critical CSS, Unused CSS, CDN). Adding a type here only adds a |
| 3678 |
* MENU item — purge_type() must know how to handle the same slug. |
| 3679 |
* |
| 3680 |
* @param array $types Map of slug => [label, visible]. |
| 3681 |
*/ |
| 3682 |
return (array) apply_filters( 'xspeed_purge_types', $types ); |
| 3683 |
} |
| 3684 |
|
| 3685 |
/** |
| 3686 |
* Purge a single cache type by slug. 'all' delegates to purge_all(); |
| 3687 |
* every other slug clears just its own artifacts. Unknown slugs (e.g. a |
| 3688 |
* Pro type) fan out via the `xspeed_purge_type_{slug}` action so the |
| 3689 |
* owning module can handle it. Returns the number of items removed where |
| 3690 |
* countable. |
| 3691 |
* |
| 3692 |
* @param string $type Cache type slug. |
| 3693 |
* @param string $cause Who asked. Threaded through so the purge log can |
| 3694 |
* tell an AI assistant's purge apart from a click — |
| 3695 |
* "the cache cleared four times today" is only |
| 3696 |
* actionable once you know what kept clearing it. |
| 3697 |
*/ |
| 3698 |
public static function purge_type( string $type, string $cause = 'manual' ): int { |
| 3699 |
switch ( $type ) { |
| 3700 |
case 'all': |
| 3701 |
$count = self::purge_all( $cause ); |
| 3702 |
// "Purge All" is the user saying they don't trust anything |
| 3703 |
// stored anywhere — the one purge that should also reach |
| 3704 |
// caches of rendered output we don't own. purge_all() itself |
| 3705 |
// deliberately does NOT, because it also runs on every post |
| 3706 |
// publish. (See Render_Caches.) |
| 3707 |
self::purge_render_caches( $cause ); |
| 3708 |
return $count; |
| 3709 |
|
| 3710 |
case 'page': |
| 3711 |
$count = self::purge_pages(); |
| 3712 |
self::update_stats( array( 'last_purge' => time() ) ); |
| 3713 |
Cache_Inventory::invalidate(); |
| 3714 |
self::record_partial_purge( 'page', $cause, $count ); |
| 3715 |
return $count; |
| 3716 |
|
| 3717 |
case 'assets': |
| 3718 |
if ( class_exists( '\\XSpeed\\Minifier' ) ) { |
| 3719 |
Minifier::purge_minified(); |
| 3720 |
} |
| 3721 |
// Deleting min/ without clearing the pages that link it left |
| 3722 |
// every cached page pointing at files that no longer exist. |
| 3723 |
// WordPress answers the missing asset by 301-ing to its |
| 3724 |
// pretty-permalink form and serving the 404 TEMPLATE as |
| 3725 |
// `HTTP 200 text/html`, which the browser accepts as a |
| 3726 |
// stylesheet and parses to zero rules — no console error, no |
| 3727 |
// network failure, no 4xx anywhere in devtools. The pages |
| 3728 |
// stayed broken for the rest of the TTL (7 days on |
| 3729 |
// Aggressive, up to 30), and the admin who clicked could not |
| 3730 |
// see it: they are logged in, so their own requests bypass |
| 3731 |
// the page cache and re-render, regenerating the assets as a |
| 3732 |
// side effect. Only anonymous visitors were served the stale |
| 3733 |
// HTML. (#244) |
| 3734 |
// |
| 3735 |
// The assets are the pages' dependency, so invalidating them |
| 3736 |
// invalidates the pages. Same invariant Cache_GC enforces |
| 3737 |
// with is_referenced(): never leave a cached page pointing at |
| 3738 |
// an asset that is gone. |
| 3739 |
$count = self::purge_pages(); |
| 3740 |
self::update_stats( array( 'last_purge' => time() ) ); |
| 3741 |
Cache_Inventory::invalidate(); |
| 3742 |
self::record_partial_purge( 'assets', $cause, $count ); |
| 3743 |
return $count; |
| 3744 |
|
| 3745 |
case 'object': |
| 3746 |
if ( function_exists( 'wp_cache_flush' ) ) { |
| 3747 |
wp_cache_flush(); |
| 3748 |
} |
| 3749 |
self::record_partial_purge( 'object cache', $cause, null ); |
| 3750 |
return 0; |
| 3751 |
|
| 3752 |
case 'rest': |
| 3753 |
$count = Rest_Cache::purge(); |
| 3754 |
self::record_partial_purge( 'REST responses', $cause, $count ); |
| 3755 |
return $count; |
| 3756 |
|
| 3757 |
default: |
| 3758 |
return self::purge_type_unhandled( $type, $cause ); |
| 3759 |
} |
| 3760 |
} |
| 3761 |
|
| 3762 |
/** |
| 3763 |
* Delete this site's cached pages from both the flat and static trees. |
| 3764 |
* |
| 3765 |
* Extracted so the `assets` purge can reuse it: minified assets are a |
| 3766 |
* dependency of the cached HTML, so clearing them must clear the pages |
| 3767 |
* too or the pages are left referencing deleted files (#244). |
| 3768 |
* |
| 3769 |
* @return int Number of page entries removed. |
| 3770 |
*/ |
| 3771 |
private static function purge_pages(): int { |
| 3772 |
$count = 0; |
| 3773 |
// Scoped to this site — see purge_all(). (#6) |
| 3774 |
$scope = self::current_host_dir(); |
| 3775 |
$flat_root = XSPEED_CACHE_DIR . '/' . $scope; |
| 3776 |
if ( is_dir( $flat_root ) ) { |
| 3777 |
foreach ( (array) glob( $flat_root . '/*.html' ) as $f ) { |
| 3778 |
wp_delete_file( $f ); |
| 3779 |
++$count; |
| 3780 |
} |
| 3781 |
foreach ( (array) glob( $flat_root . '/*.meta' ) as $m ) { |
| 3782 |
wp_delete_file( $m ); |
| 3783 |
} |
| 3784 |
foreach ( (array) glob( $flat_root . '/*.br' ) as $b ) { |
| 3785 |
wp_delete_file( $b ); |
| 3786 |
} |
| 3787 |
// `*.br` does not match `*.br.size`; a size record outliving its |
| 3788 |
// body would later be read against a DIFFERENT sibling's bytes. |
| 3789 |
foreach ( (array) glob( $flat_root . '/*.br.size' ) as $b ) { |
| 3790 |
wp_delete_file( $b ); |
| 3791 |
} |
| 3792 |
} |
| 3793 |
$static_root = XSPEED_CACHE_STATIC_DIR . '/' . self::current_static_scope(); |
| 3794 |
if ( is_dir( $static_root ) ) { |
| 3795 |
$count += self::rmtree_html( $static_root ); |
| 3796 |
} |
| 3797 |
|
| 3798 |
return $count; |
| 3799 |
} |
| 3800 |
|
| 3801 |
/** |
| 3802 |
* A purge type this class does not own — a Pro or third-party module |
| 3803 |
* registered it via the `xspeed_purge_types` filter, so hand it off. |
| 3804 |
* |
| 3805 |
* @param string $type Purge-type slug. |
| 3806 |
* @param string $cause Who asked. |
| 3807 |
*/ |
| 3808 |
private static function purge_type_unhandled( string $type, string $cause ): int { |
| 3809 |
do_action( 'xspeed_purge_type_' . $type ); |
| 3810 |
self::record_partial_purge( $type, $cause, null ); |
| 3811 |
|
| 3812 |
return 0; |
| 3813 |
} |
| 3814 |
|
| 3815 |
/** |
| 3816 |
* Log a partial purge so the drill-down behind "Last purge" shows every |
| 3817 |
* clear, not only the full ones. Without this a site whose object cache |
| 3818 |
* is flushed on a schedule looks, from the log, like nothing happens. |
| 3819 |
* |
| 3820 |
* @param string $what Human label for the slice purged. |
| 3821 |
* @param string $cause Who asked. |
| 3822 |
* @param int|null $count Items removed, when countable. |
| 3823 |
*/ |
| 3824 |
private static function record_partial_purge( string $what, string $cause, ?int $count ): void { |
| 3825 |
$message = null === $count |
| 3826 |
? sprintf( |
| 3827 |
/* translators: 1: what was purged, 2: cause of the purge. */ |
| 3828 |
__( 'Purged %1$s (%2$s)', 'xspeed' ), |
| 3829 |
$what, |
| 3830 |
$cause |
| 3831 |
) |
| 3832 |
: sprintf( |
| 3833 |
/* translators: 1: what was purged, 2: cause of the purge, 3: number of files removed. */ |
| 3834 |
__( 'Purged %1$s (%2$s) — %3$d file(s) removed', 'xspeed' ), |
| 3835 |
$what, |
| 3836 |
$cause, |
| 3837 |
$count |
| 3838 |
); |
| 3839 |
|
| 3840 |
Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO ); |
| 3841 |
} |
| 3842 |
|
| 3843 |
/** |
| 3844 |
* Clear the static tree only, leaving the flat cache in place. |
| 3845 |
* |
| 3846 |
* A narrower purge_all() for the case where only the web-server tree can |
| 3847 |
* be wrong: its files are keyed by `{host}{path}` and nothing else, so a |
| 3848 |
* response filed under the wrong path poisons it while the flat cache — |
| 3849 |
* keyed by cache_key(), discriminators included — stays correct. Avoids |
| 3850 |
* throwing away Critical CSS, minified bundles and the object cache to |
| 3851 |
* fix a static-only problem. |
| 3852 |
* |
| 3853 |
* @return int Number of index.html files removed. |
| 3854 |
*/ |
| 3855 |
public static function purge_static_tree(): int { |
| 3856 |
return self::rmtree_html( XSPEED_CACHE_STATIC_DIR ); |
| 3857 |
} |
| 3858 |
|
| 3859 |
/** |
| 3860 |
* Recursively delete every `index.html` (and its precompressed |
| 3861 |
* `index.html.br` sibling, if the Pro Brotli module wrote one) plus |
| 3862 |
* empty directories inside the static-cache tree. Used by purge_all(). |
| 3863 |
* Returns the number of .html files removed so purge stats stay accurate |
| 3864 |
* across the flat + static caches — .br siblings are not counted |
| 3865 |
* (they're encodings of a page, not pages). |
| 3866 |
*/ |
| 3867 |
private static function rmtree_html( string $dir ): int { |
| 3868 |
if ( ! is_dir( $dir ) ) { |
| 3869 |
return 0; |
| 3870 |
} |
| 3871 |
$removed = 0; |
| 3872 |
// SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk |
| 3873 |
// the whole tree regardless of order. |
| 3874 |
$entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 3875 |
if ( false === $entries ) { |
| 3876 |
return 0; |
| 3877 |
} |
| 3878 |
foreach ( $entries as $entry ) { |
| 3879 |
if ( '.' === $entry || '..' === $entry ) { |
| 3880 |
continue; |
| 3881 |
} |
| 3882 |
$path = $dir . '/' . $entry; |
| 3883 |
if ( is_dir( $path ) ) { |
| 3884 |
$removed += self::rmtree_html( $path ); |
| 3885 |
// Best-effort empty-dir cleanup; ignore failures (a |
| 3886 |
// foreign file inside would block rmdir, which is fine). |
| 3887 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Best-effort empty-dir cleanup; WP_Filesystem needs admin credentials we don't have during a normal purge. |
| 3888 |
@rmdir( $path ); |
| 3889 |
continue; |
| 3890 |
} |
| 3891 |
if ( substr( $entry, -5 ) === '.html' ) { |
| 3892 |
wp_delete_file( $path ); |
| 3893 |
++$removed; |
| 3894 |
} elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) { |
| 3895 |
// Precompressed sibling (index.html.br) and the record of its |
| 3896 |
// length. Remove both so a purge doesn't orphan stale Brotli |
| 3897 |
// bodies, or a size record that would later be read against a |
| 3898 |
// different sibling's bytes. Not counted. |
| 3899 |
wp_delete_file( $path ); |
| 3900 |
} |
| 3901 |
} |
| 3902 |
return $removed; |
| 3903 |
} |
| 3904 |
|
| 3905 |
/** |
| 3906 |
* Drop a "silence is golden" index.php into a directory so apaches/nginx |
| 3907 |
* with directory listing enabled don't expose cache contents. |
| 3908 |
*/ |
| 3909 |
public static function write_silence( $dir ) { |
| 3910 |
$file = trailingslashit( $dir ) . 'index.php'; |
| 3911 |
if ( ! file_exists( $file ) ) { |
| 3912 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache dir setup may run during a frontend page render. |
| 3913 |
file_put_contents( $file, "<?php\n// Silence is golden.\n" ); |
| 3914 |
} |
| 3915 |
} |
| 3916 |
|
| 3917 |
/** |
| 3918 |
* The raw xspeed_stats option as an array. Keys currently in use: |
| 3919 |
* 'last_purge', 'last_gc', 'gc_removed', 'gc_removed_total'. |
| 3920 |
*/ |
| 3921 |
public static function get_stats_option(): array { |
| 3922 |
$stats = get_option( 'xspeed_stats', array() ); |
| 3923 |
return is_array( $stats ) ? $stats : array(); |
| 3924 |
} |
| 3925 |
|
| 3926 |
/** |
| 3927 |
* Persist stats with autoload disabled — stats are only read in admin |
| 3928 |
* contexts, so there is no reason to inflate every frontend request's |
| 3929 |
* `wp_load_alloptions()` payload. |
| 3930 |
* |
| 3931 |
* MERGES into whatever is already stored. It used to overwrite, which |
| 3932 |
* was harmless while `last_purge` was the only key — with the GC keys |
| 3933 |
* alongside it, a purge would have wiped the GC history and vice versa. |
| 3934 |
*/ |
| 3935 |
public static function update_stats( array $stats ) { |
| 3936 |
if ( false === get_option( 'xspeed_stats', false ) ) { |
| 3937 |
add_option( 'xspeed_stats', $stats, '', 'no' ); |
| 3938 |
return; |
| 3939 |
} |
| 3940 |
update_option( 'xspeed_stats', array_merge( self::get_stats_option(), $stats ) ); |
| 3941 |
} |
| 3942 |
|
| 3943 |
public static function get_stats() { |
| 3944 |
$count = 0; |
| 3945 |
$size = 0; |
| 3946 |
// This site's entries only — on multisite the tree is shared, so an |
| 3947 |
// unscoped count reported the whole network's pages on every |
| 3948 |
// subsite's dashboard. (#6) |
| 3949 |
$flat_root = XSPEED_CACHE_DIR . '/' . self::current_host_dir(); |
| 3950 |
if ( is_dir( $flat_root ) ) { |
| 3951 |
$files = glob( $flat_root . '/*.html' ); |
| 3952 |
if ( $files ) { |
| 3953 |
$count = count( $files ); |
| 3954 |
foreach ( $files as $f ) { |
| 3955 |
$size += filesize( $f ); |
| 3956 |
} |
| 3957 |
} |
| 3958 |
} |
| 3959 |
// Drain the HIT-log file BEFORE reading totals. Two serve paths that |
| 3960 |
// bypass the normal in-PHP record_hit() append one line per HIT here: |
| 3961 |
// the nginx server-level rewrite (see nginx_snippet(), never reaches |
| 3962 |
// PHP) and the advanced-cache.php drop-in (runs pre-WordPress, can't |
| 3963 |
// reach Hit_Counter). Without this drain both look like a 0% hit-ratio |
| 3964 |
// on a perfectly working cache. |
| 3965 |
Hit_Counter::collect_nginx_log_hits(); |
| 3966 |
|
| 3967 |
// Apache/LiteSpeed static-rewrite HITs are served straight from disk |
| 3968 |
// by .htaccess and never reach PHP either — but there's no .htaccess |
| 3969 |
// equivalent of nginx's access_log directive, so we count them by |
| 3970 |
// scanning the web server's own access log incrementally. No-op when |
| 3971 |
// the log isn't readable (managed hosts) — see the method docblock. |
| 3972 |
Hit_Counter::collect_server_log_hits(); |
| 3973 |
|
| 3974 |
$stats = get_option( 'xspeed_stats', array() ); |
| 3975 |
$totals = Hit_Counter::totals_24h(); |
| 3976 |
// One read of the ground truth for both fields below: it costs a |
| 3977 |
// stat of advanced-cache.php and a tokenize of wp-config.php, and |
| 3978 |
// this runs on every dashboard poll. |
| 3979 |
$serving = self::page_cache_operational(); |
| 3980 |
return array( |
| 3981 |
'cached_pages' => $count, |
| 3982 |
'cache_size' => $size, |
| 3983 |
'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0, |
| 3984 |
// Rolling 24h cache performance — sourced from Hit_Counter's |
| 3985 |
// hourly buckets. The frontend uses hit_ratio to drive the |
| 3986 |
// CacheHero stat grid + the Health module's panel. |
| 3987 |
'hits_24h' => $totals['hits'], |
| 3988 |
'misses_24h' => $totals['misses'], |
| 3989 |
'hit_ratio' => $totals['ratio'], |
| 3990 |
// Requests kept OUT of the ratio (404s + bots) — surfaced as its own |
| 3991 |
// "absorbed N scanner/bot requests" line rather than distorting the |
| 3992 |
// cache-performance number. (#118) |
| 3993 |
'excluded_24h' => $totals['excluded'], |
| 3994 |
// True when an edge cache (Cloudflare) fronts the origin, so hits are |
| 3995 |
// absorbed before reaching PHP. The dashboard labels the ratio |
| 3996 |
// "origin-layer only" instead of implying it's the full picture. (#118) |
| 3997 |
'edge_cache' => self::edge_cache_detected(), |
| 3998 |
/* |
| 3999 |
* Whether the page cache is actually SERVING, as opposed to |
| 4000 |
* switched on in settings. The hero read the setting alone and |
| 4001 |
* announced "Active — serving cached HTML"; a site whose |
| 4002 |
* advanced-cache.php had been taken over by another cache plugin |
| 4003 |
* got that line while every response carried |
| 4004 |
* `X-XSpeed-Cache: BYPASS`. The setting is the user's intent; |
| 4005 |
* this is the outcome, and the dashboard needs both to explain |
| 4006 |
* the difference. |
| 4007 |
*/ |
| 4008 |
'page_cache_serving' => $serving, |
| 4009 |
/* |
| 4010 |
* Why not, when intent and outcome disagree. Only computed in |
| 4011 |
* that state — the detector sweep behind it is far more work than |
| 4012 |
* a stats call should do on an ordinary healthy site. |
| 4013 |
*/ |
| 4014 |
'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) ) |
| 4015 |
? self::acquisition_blocker() |
| 4016 |
: null, |
| 4017 |
); |
| 4018 |
} |
| 4019 |
|
| 4020 |
/** |
| 4021 |
* Whether the current request should be kept OUT of the cache hit/miss |
| 4022 |
* ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect |
| 4023 |
* time, so is_404() is resolved. (#118) |
| 4024 |
*/ |
| 4025 |
private static function miss_is_excluded(): bool { |
| 4026 |
if ( function_exists( 'is_404' ) && is_404() ) { |
| 4027 |
return true; |
| 4028 |
} |
| 4029 |
$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) |
| 4030 |
? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) ) |
| 4031 |
: ''; |
| 4032 |
return Hit_Counter::is_bot_ua( $ua ); |
| 4033 |
} |
| 4034 |
|
| 4035 |
/** |
| 4036 |
* Whether an edge cache fronts this origin. Today: the Cloudflare |
| 4037 |
* integration is connected — so an unknown share of hits is served at the |
| 4038 |
* edge and never counted here, making the origin ratio a partial view the |
| 4039 |
* dashboard must label as such. (#118) |
| 4040 |
*/ |
| 4041 |
private static function edge_cache_detected(): bool { |
| 4042 |
$cf = get_option( 'xspeed_module_cloudflare', array() ); |
| 4043 |
return is_array( $cf ) && ! empty( $cf['enabled'] ); |
| 4044 |
} |
| 4045 |
|
| 4046 |
/** |
| 4047 |
* Apply the user's enable/disable choice. Called from the REST toggle |
| 4048 |
* endpoint, which is gated by current_user_can( 'manage_options' ) and |
| 4049 |
* a verified REST nonce. |
| 4050 |
* |
| 4051 |
* This is the only path that ENABLES caching — a drop-in is never |
| 4052 |
* created for a user who hasn't opted in, which is the guideline that |
| 4053 |
* matters (a plugin must not install drop-ins or edit wp-config.php |
| 4054 |
* on a fresh activation). RESTORING the drop-in for a site that |
| 4055 |
* already has cache_enabled = true is a different act and is handled |
| 4056 |
* by restore_dropin_if_enabled() on activation and auto_heal() at |
| 4057 |
* runtime; without it every plugin update silently un-caches the site. |
| 4058 |
* |
| 4059 |
* Enabling is gated on acquisition_blocker(): if another plugin owns the |
| 4060 |
* drop-in, or WP_CACHE is written in a form we must not rewrite, nothing |
| 4061 |
* is written and the returned state carries `blocked` + a reason the |
| 4062 |
* caller can show. Callers must persist `cache_enabled` from the returned |
| 4063 |
* `enabled`, never from what they asked for. |
| 4064 |
* |
| 4065 |
* @param bool $enable User's choice. |
| 4066 |
* @return array{ |
| 4067 |
* enabled: bool, |
| 4068 |
* blocked: bool, |
| 4069 |
* blocked_reason: ?string, |
| 4070 |
* dropin_installed: bool, |
| 4071 |
* wp_cache_constant: bool, |
| 4072 |
* wp_config_writable: bool, |
| 4073 |
* manual_snippet: ?string |
| 4074 |
* } |
| 4075 |
*/ |
| 4076 |
public static function toggle( $enable ) { |
| 4077 |
Page_Cache_Detector::invalidate(); |
| 4078 |
$expected = Page_Cache_Detector::inspect()['revision']; |
| 4079 |
/** Diagnostic seam; changing the expected revision can only force a safe refusal. */ |
| 4080 |
$expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected ); |
| 4081 |
$lock = self::page_cache_lock(); |
| 4082 |
if ( ! is_resource( $lock ) ) { |
| 4083 |
return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) ); |
| 4084 |
} |
| 4085 |
try { |
| 4086 |
Page_Cache_Detector::invalidate(); |
| 4087 |
$fresh = Page_Cache_Detector::inspect()['revision']; |
| 4088 |
if ( ! hash_equals( (string) $expected, (string) $fresh ) ) { |
| 4089 |
return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) ); |
| 4090 |
} |
| 4091 |
$state = self::toggle_unlocked( (bool) $enable ); |
| 4092 |
return $state; |
| 4093 |
} finally { |
| 4094 |
flock( $lock, LOCK_UN ); |
| 4095 |
fclose( $lock ); |
| 4096 |
} |
| 4097 |
} |
| 4098 |
|
| 4099 |
/** Run the page-cache mutation while toggle() owns the scoped lock. */ |
| 4100 |
private static function toggle_unlocked( bool $enable ) { |
| 4101 |
$enable = (bool) $enable; |
| 4102 |
|
| 4103 |
if ( $enable ) { |
| 4104 |
/* |
| 4105 |
* Preflight. The drop-in and the WP_CACHE define are shared, |
| 4106 |
* single-occupancy state; if we do not own them, no part of this |
| 4107 |
* runs — not the drop-in, not wp-config.php, not the rewrite |
| 4108 |
* block. Refusing whole is the point: a partial enable leaves the |
| 4109 |
* site claiming a cache it cannot serve. |
| 4110 |
* |
| 4111 |
* Every caller routes through here (REST, onboarding, MCP, CLI, |
| 4112 |
* the optimize runner, Pro's migration), so the gate lives here |
| 4113 |
* rather than being re-implemented at each entry point. |
| 4114 |
* |
| 4115 |
* Except when there is nothing to acquire. A site where we |
| 4116 |
* already own the drop-in and are already serving is being asked |
| 4117 |
* to stay as it is, and the gate answers a different question — |
| 4118 |
* "is the field free to take" — which a merely ACTIVE competitor |
| 4119 |
* makes false. So "make sure caching is on", from an AI agent, |
| 4120 |
* the optimize runner or Pro's migration, came back as a refusal |
| 4121 |
* telling the user to deactivate a plugin on a site that was |
| 4122 |
* caching perfectly. The dashboard never saw it, because nobody |
| 4123 |
* presses Enable on a cache that is already enabled. |
| 4124 |
* |
| 4125 |
* Only the GATE is skipped. The writes below still run, and every |
| 4126 |
* one of them is individually idempotent — which matters, because |
| 4127 |
* this is the path CacheModule re-bakes the drop-in through when |
| 4128 |
* an exclusion rule or the TTL changes (#240, #251), and the path |
| 4129 |
* auto_heal() restores a stripped WP_CACHE through. Returning |
| 4130 |
* early here left both of those doing nothing at all, silently, |
| 4131 |
* on exactly the healthy sites this branch is about. |
| 4132 |
*/ |
| 4133 |
$reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner(); |
| 4134 |
$blocker = $reasserting ? null : self::acquisition_blocker(); |
| 4135 |
if ( null !== $blocker ) { |
| 4136 |
Activity_Log::record( |
| 4137 |
'cache_enable_blocked', |
| 4138 |
'Cache not enabled — ' . $blocker, |
| 4139 |
Activity_Log::WARN |
| 4140 |
); |
| 4141 |
|
| 4142 |
return self::blocked_toggle_state( $blocker ); |
| 4143 |
} |
| 4144 |
|
| 4145 |
$dropin_path = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 4146 |
$config_path = self::wp_config_path(); |
| 4147 |
$dropin_before = file_exists( $dropin_path ) ? self::read_file( $dropin_path ) : null; |
| 4148 |
$config_before = '' !== $config_path ? self::read_file( $config_path ) : null; |
| 4149 |
$dropin_ok = self::install_dropin(); |
| 4150 |
if ( ! $dropin_ok ) { |
| 4151 |
$partial = self::read_file( $dropin_path ); |
| 4152 |
if ( is_string( $partial ) && xspeed_has_canonical_dropin_signature( $partial ) ) { |
| 4153 |
self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $partial, $config_path, $config_before, null ); |
| 4154 |
} |
| 4155 |
/* |
| 4156 |
* Preflight said the field was clear, so this is a filesystem |
| 4157 |
* failure (or a drop-in that appeared in between). Without the |
| 4158 |
* drop-in there is no cache to enable, and persisting |
| 4159 |
* cache_enabled anyway is what produced sites reporting a |
| 4160 |
* healthy cache while serving every request uncached. |
| 4161 |
*/ |
| 4162 |
$reason = __( 'Could not write wp-content/advanced-cache.php. Check filesystem permissions.', 'xspeed' ); |
| 4163 |
Activity_Log::record( |
| 4164 |
'cache_enable_blocked', |
| 4165 |
'Cache not enabled — ' . $reason, |
| 4166 |
Activity_Log::WARN |
| 4167 |
); |
| 4168 |
|
| 4169 |
return array( |
| 4170 |
'enabled' => false, |
| 4171 |
'blocked' => true, |
| 4172 |
'blocked_reason' => $reason, |
| 4173 |
'dropin_installed' => false, |
| 4174 |
'wp_cache_constant' => false, |
| 4175 |
'rewrite_installed' => false, |
| 4176 |
'wp_config_writable' => self::wp_config_writable(), |
| 4177 |
'manual_snippet' => null, |
| 4178 |
'nginx_snippet' => self::nginx_snippet(), |
| 4179 |
'nginx_server_block' => self::full_nginx_server_block(), |
| 4180 |
); |
| 4181 |
} |
| 4182 |
|
| 4183 |
$dropin_written = self::read_file( $dropin_path ); |
| 4184 |
self::set_wp_cache_constant( true ); |
| 4185 |
$config_written = '' !== $config_path ? self::read_file( $config_path ) : null; |
| 4186 |
Page_Cache_Detector::invalidate(); |
| 4187 |
$dropin_ours = self::DROPIN_XSPEED === self::dropin_owner(); |
| 4188 |
$constant_state = self::wp_cache_define_state(); |
| 4189 |
$constant_ok = 'true' === $constant_state; |
| 4190 |
|
| 4191 |
/* |
| 4192 |
* A wp-config.php we cannot write at all is a supported state, not |
| 4193 |
* a failed transaction. Plenty of managed hosts ship the file |
| 4194 |
* read-only; there the drop-in is ours and installed, the cache |
| 4195 |
* works the moment WP_CACHE exists, and the one line to paste |
| 4196 |
* comes back as `manual_snippet`. Rolling back instead left those |
| 4197 |
* hosts unable to turn the page cache on by any route — including |
| 4198 |
* when the user had already pasted the define, since the write |
| 4199 |
* fails on an unwritable file whatever value is already there. |
| 4200 |
* |
| 4201 |
* `undefined` ONLY. `false` looks eligible — this method would |
| 4202 |
* have rewritten it — but the snippet we hand back cannot work |
| 4203 |
* there: the file already says `define( 'WP_CACHE', false )`, the |
| 4204 |
* first define() call wins, and a user who pastes our line via |
| 4205 |
* FTP ends up with a cache that never serves AND a `duplicate` |
| 4206 |
* wp-config that blocks every future toggle in both directions. |
| 4207 |
* They have to edit the existing line, which means refusing here |
| 4208 |
* and saying so. `duplicate` and `dynamic` are refused by |
| 4209 |
* acquisition_blocker() before we get here, and if one appears in |
| 4210 |
* the race window it must still fail closed. |
| 4211 |
*/ |
| 4212 |
$manual_mode = ! $constant_ok |
| 4213 |
&& 'undefined' === $constant_state |
| 4214 |
&& ! self::can_write_wp_config(); |
| 4215 |
|
| 4216 |
if ( ! $dropin_ours || ( ! $constant_ok && ! $manual_mode ) ) { |
| 4217 |
if ( ! self::can_write_wp_config() ) { |
| 4218 |
$reason = 'false' === $constant_state |
| 4219 |
? __( "wp-config.php is not writable and already contains define( 'WP_CACHE', false ). Change that line to true — adding a second one would leave the cache off and block xSpeed from changing it again.", 'xspeed' ) |
| 4220 |
: __( 'xSpeed could not verify the complete page-cache write, and wp-config.php is not writable. Its changes were rolled back.', 'xspeed' ); |
| 4221 |
} else { |
| 4222 |
$reason = __( 'xSpeed could not verify the complete page-cache write. Its changes were rolled back.', 'xspeed' ); |
| 4223 |
} |
| 4224 |
self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written ); |
| 4225 |
return self::blocked_toggle_state( $reason ); |
| 4226 |
} |
| 4227 |
$wp_config_ok = $constant_ok; |
| 4228 |
$rewrite_ok = self::install_rewrite(); |
| 4229 |
self::ensure_hits_log_file(); |
| 4230 |
self::sync_mobile_flag(); |
| 4231 |
$snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );"; |
| 4232 |
Settings::update( array( 'cache_enabled' => true ) ); |
| 4233 |
if ( empty( Settings::get()['cache_enabled'] ) ) { |
| 4234 |
self::remove_rewrite(); |
| 4235 |
self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written ); |
| 4236 |
delete_option( 'xspeed_page_cache_ownership_receipt' ); |
| 4237 |
return self::blocked_toggle_state( __( 'xSpeed could not save the page-cache setting. Its file changes were rolled back.', 'xspeed' ) ); |
| 4238 |
} |
| 4239 |
|
| 4240 |
/* |
| 4241 |
* Only when this call actually changed something. auto_heal() runs |
| 4242 |
* the enable transaction on every admin_init, and an unconditional |
| 4243 |
* entry filled the 50-slot log with identical "Cache enabled" lines |
| 4244 |
* within 50 wp-admin page loads, evicting every real event — plus |
| 4245 |
* an option write per admin request. The sentence is also false |
| 4246 |
* when nothing was installed. |
| 4247 |
*/ |
| 4248 |
if ( $dropin_written !== $dropin_before || $config_written !== $config_before ) { |
| 4249 |
Activity_Log::record( |
| 4250 |
'cache_enabled_event', |
| 4251 |
$wp_config_ok |
| 4252 |
? 'Cache enabled. Drop-in installed, WP_CACHE constant set.' |
| 4253 |
: 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.', |
| 4254 |
$wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN |
| 4255 |
); |
| 4256 |
} |
| 4257 |
|
| 4258 |
return array( |
| 4259 |
'enabled' => true, |
| 4260 |
'blocked' => false, |
| 4261 |
'blocked_reason' => null, |
| 4262 |
'dropin_installed' => (bool) $dropin_ok, |
| 4263 |
'wp_cache_constant' => (bool) $wp_config_ok, |
| 4264 |
'rewrite_installed' => (bool) $rewrite_ok, |
| 4265 |
'wp_config_writable' => self::wp_config_writable(), |
| 4266 |
'manual_snippet' => $snippet, |
| 4267 |
'nginx_snippet' => self::nginx_snippet(), |
| 4268 |
// Unified server-block snippet aggregating every enabled |
| 4269 |
// module's directives — the same value the dashboard and |
| 4270 |
// Health insight render. The wizard shows this so all three |
| 4271 |
// surfaces stay in lockstep. Null on non-nginx hosts. |
| 4272 |
'nginx_server_block' => self::full_nginx_server_block(), |
| 4273 |
); |
| 4274 |
} |
| 4275 |
|
| 4276 |
/* |
| 4277 |
* Whose advanced-cache.php is on disk decides how much of the disable |
| 4278 |
* below may run. Read it once, before anything is touched. |
| 4279 |
*/ |
| 4280 |
$owner = self::dropin_owner(); |
| 4281 |
$not_ours = self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner; |
| 4282 |
if ( ! self::set_wp_cache_constant( false ) ) { |
| 4283 |
/* |
| 4284 |
* The mirror of the enable path. A wp-config.php nobody can write |
| 4285 |
* does not trap the user in a cache they turned off: WP_CACHE on |
| 4286 |
* its own does nothing once advanced-cache.php is gone, and core |
| 4287 |
* simply skips the missing drop-in. Refusing here left the |
| 4288 |
* read-only managed hosts able to enable the page cache and never |
| 4289 |
* able to disable it again. |
| 4290 |
* |
| 4291 |
* A drop-in that is not ours reaches the same conclusion by a |
| 4292 |
* different road. WP_CACHE is then the switch for THEIR cache, so |
| 4293 |
* set_wp_cache_constant() refuses it — correctly, and permanently, |
| 4294 |
* because nothing the user does to xSpeed will make that file ours |
| 4295 |
* again. Treating that refusal as a failed disable was a trap with |
| 4296 |
* no exit: install any competing cache plugin while xSpeed's cache |
| 4297 |
* was on, and xSpeed's toggle could never be turned off again, |
| 4298 |
* while the dashboard went on claiming a cache that was serving |
| 4299 |
* nothing. Turning xSpeed off is entirely within our own state — |
| 4300 |
* our setting, our rewrite block — so it proceeds, and their |
| 4301 |
* constant and their file are left exactly as they are. |
| 4302 |
*/ |
| 4303 |
/* |
| 4304 |
* Every reason set_wp_cache_constant() refuses is structural |
| 4305 |
* except one, and the exception is the only one worth blocking |
| 4306 |
* on. It will not touch a constant it cannot prove is ours; it |
| 4307 |
* will not rewrite a define it cannot read as a literal — |
| 4308 |
* duplicate, dynamic, or inside a conditional; and it cannot |
| 4309 |
* write a file the filesystem will not let it write. None of |
| 4310 |
* those improve on a retry, and all of them leave a WP_CACHE |
| 4311 |
* that does nothing once our drop-in is gone. What is left — our |
| 4312 |
* own constant, in a shape we can rewrite, in a file we can |
| 4313 |
* write, and the write still failed — is a real I/O failure, and |
| 4314 |
* that one still refuses so the user is not told a cache was |
| 4315 |
* turned off while it goes on serving. |
| 4316 |
* |
| 4317 |
* The proof, not the drop-in, is the test. A user who pasted our |
| 4318 |
* manual snippet on a locked-down host has a WP_CACHE line with |
| 4319 |
* no receipt on it; if their drop-in later goes missing, we can |
| 4320 |
* never prove that line is ours, so refusing left the toggle |
| 4321 |
* stuck on with no way out but enabling first and disabling |
| 4322 |
* again. Nothing loads a drop-in that is not there, so the line |
| 4323 |
* is inert either way and the disable proceeds without it. |
| 4324 |
*/ |
| 4325 |
$leave_it = ! self::wp_cache_define_is_ours_to_remove( $owner ) |
| 4326 |
|| ! in_array( self::wp_cache_define_state(), array( 'true', 'false', 'undefined' ), true ) |
| 4327 |
|| ! self::can_write_wp_config(); |
| 4328 |
if ( ! $leave_it ) { |
| 4329 |
return self::blocked_toggle_state( __( 'xSpeed could not safely remove its WP_CACHE setting. The cache remains enabled.', 'xspeed' ) ); |
| 4330 |
} |
| 4331 |
} |
| 4332 |
self::remove_dropin(); |
| 4333 |
if ( self::DROPIN_XSPEED === self::dropin_owner() ) { |
| 4334 |
// Put WP_CACHE back, and say so if we could not. Reporting a |
| 4335 |
// hardcoded `enabled: true` here claimed a working cache on a |
| 4336 |
// site whose constant we had just failed to restore. |
| 4337 |
// Put WP_CACHE back, then read the outcome off disk rather than |
| 4338 |
// trusting the write's return value — a write can report failure |
| 4339 |
// for a value that was already correct, and the question the |
| 4340 |
// caller needs answered is whether the cache serves. |
| 4341 |
self::set_wp_cache_constant( true ); |
| 4342 |
return self::blocked_toggle_state( |
| 4343 |
self::page_cache_operational() |
| 4344 |
? __( 'xSpeed could not remove its page-cache drop-in. The cache remains enabled.', 'xspeed' ) |
| 4345 |
: __( 'xSpeed could not remove its page-cache drop-in, and could not put WP_CACHE back. The cache is not serving; check wp-config.php before changing the page cache again.', 'xspeed' ) |
| 4346 |
); |
| 4347 |
} |
| 4348 |
self::remove_rewrite(); |
| 4349 |
/* |
| 4350 |
* The .htaccess block serves cached HTML straight off disk without |
| 4351 |
* ever reaching PHP, so a block we failed to remove keeps answering |
| 4352 |
* requests from a cache the user just turned off — and nothing else |
| 4353 |
* in this method can stop it. remove_rewrite() also returns false |
| 4354 |
* when there is no .htaccess to clean, which is the ordinary case, |
| 4355 |
* so ask the file rather than trust the return value. |
| 4356 |
*/ |
| 4357 |
if ( self::rewrite_installed() ) { |
| 4358 |
if ( $not_ours ) { |
| 4359 |
// Nothing to roll back — under a foreign drop-in this method |
| 4360 |
// removed no drop-in and wrote no constant, and it could not |
| 4361 |
// put either back if it wanted to. Say what is actually left. |
| 4362 |
return self::blocked_toggle_state( __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. Remove the xSpeed block from .htaccess by hand before turning the page cache off.', 'xspeed' ) ); |
| 4363 |
} |
| 4364 |
/* |
| 4365 |
* Roll the disable back. Both calls can fail — a filesystem that |
| 4366 |
* would not let us remove the block may not let us write the |
| 4367 |
* drop-in either — and discarding their results reported an |
| 4368 |
* enabled cache over a site left with no drop-in and no |
| 4369 |
* constant. Fall through to the default state so the artifact |
| 4370 |
* fields are read from disk rather than asserted. |
| 4371 |
*/ |
| 4372 |
self::install_dropin(); |
| 4373 |
self::set_wp_cache_constant( true ); |
| 4374 |
// Both of those can fail — a filesystem that would not let us |
| 4375 |
// remove the block may not let us write the drop-in either — so |
| 4376 |
// the message follows what is on disk afterwards, not what the |
| 4377 |
// calls returned. |
| 4378 |
return self::blocked_toggle_state( |
| 4379 |
self::page_cache_operational() |
| 4380 |
? __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. The cache remains enabled.', 'xspeed' ) |
| 4381 |
: __( 'xSpeed could not remove its rewrite rules from .htaccess, and could not restore the drop-in it had just removed. The cache is not serving, and the site may still return stale cached pages until the xSpeed block is removed from .htaccess by hand.', 'xspeed' ) |
| 4382 |
); |
| 4383 |
} |
| 4384 |
// Drop the device-bucket marker too — with the drop-in gone there's |
| 4385 |
// nothing left to read it, and leaving it behind would dirty a fresh |
| 4386 |
// re-enable (and leaks across test runs). |
| 4387 |
self::sync_mobile_flag( false ); |
| 4388 |
Settings::update( array( 'cache_enabled' => false ) ); |
| 4389 |
if ( ! empty( Settings::get()['cache_enabled'] ) ) { |
| 4390 |
if ( $not_ours ) { |
| 4391 |
// Same as above: there is nothing of ours on disk to restore. |
| 4392 |
return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state.', 'xspeed' ) ); |
| 4393 |
} |
| 4394 |
self::install_dropin(); |
| 4395 |
self::set_wp_cache_constant( true ); |
| 4396 |
return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state. The page cache was restored.', 'xspeed' ) ); |
| 4397 |
} |
| 4398 |
|
| 4399 |
// A WP_CACHE we could not remove because wp-config.php is read-only |
| 4400 |
// is left behind deliberately (see above) — say so rather than |
| 4401 |
// reporting a constant that is still in the file as gone. |
| 4402 |
$constant_left = 'true' === self::wp_cache_define_state(); |
| 4403 |
/* |
| 4404 |
* Say why the constant is still there, because there are now three |
| 4405 |
* different reasons and they call for different advice. Keyed off the |
| 4406 |
* same facts $leave_it was, so the log cannot drift from the decision |
| 4407 |
* it is describing — it did, briefly, and reported a wp-config.php as |
| 4408 |
* unwritable when the real reason was that we could not prove the |
| 4409 |
* line was ours. |
| 4410 |
*/ |
| 4411 |
if ( self::DROPIN_UNREADABLE === $owner ) { |
| 4412 |
$log_message = 'Cache disabled. advanced-cache.php could not be read, so it and the WP_CACHE setting were left untouched.'; |
| 4413 |
} elseif ( $not_ours ) { |
| 4414 |
$log_message = 'Cache disabled. Another plugin owns advanced-cache.php, so its drop-in and its WP_CACHE setting were left untouched.'; |
| 4415 |
} elseif ( ! $constant_left ) { |
| 4416 |
$log_message = 'Cache disabled. Drop-in removed.'; |
| 4417 |
} elseif ( ! self::wp_cache_define_is_ours_to_remove( $owner ) ) { |
| 4418 |
$log_message = 'Cache disabled. WP_CACHE was left in place — it carries no proof xSpeed wrote it, and it does nothing without a drop-in.'; |
| 4419 |
} elseif ( ! self::can_write_wp_config() ) { |
| 4420 |
$log_message = 'Cache disabled. Drop-in removed; wp-config.php not writable, so WP_CACHE was left in place (harmless without the drop-in).'; |
| 4421 |
} else { |
| 4422 |
$log_message = 'Cache disabled. Drop-in removed; WP_CACHE was left in place (harmless without the drop-in).'; |
| 4423 |
} |
| 4424 |
Activity_Log::record( |
| 4425 |
'cache_disabled_event', |
| 4426 |
$log_message, |
| 4427 |
$constant_left ? Activity_Log::WARN : Activity_Log::INFO |
| 4428 |
); |
| 4429 |
|
| 4430 |
return array( |
| 4431 |
'enabled' => false, |
| 4432 |
'blocked' => false, |
| 4433 |
'blocked_reason' => null, |
| 4434 |
'dropin_installed' => false, |
| 4435 |
'wp_cache_constant' => $constant_left, |
| 4436 |
'rewrite_installed' => false, |
| 4437 |
'wp_config_writable' => self::wp_config_writable(), |
| 4438 |
'manual_snippet' => null, |
| 4439 |
'nginx_snippet' => self::nginx_snippet(), |
| 4440 |
'nginx_server_block' => self::full_nginx_server_block(), |
| 4441 |
); |
| 4442 |
} |
| 4443 |
|
| 4444 |
/** Acquire the local lock that serializes page-cache ownership changes. */ |
| 4445 |
private static function page_cache_lock() { |
| 4446 |
$path = WP_CONTENT_DIR . '/.xspeed-page-cache.lock'; |
| 4447 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.PHP.NoSilencedErrors.Discouraged -- flock requires a local handle; failure is a safe blocked result. |
| 4448 |
$lock = @fopen( $path, 'c+' ); |
| 4449 |
if ( ! is_resource( $lock ) || ! flock( $lock, LOCK_EX ) ) { |
| 4450 |
return false; |
| 4451 |
} |
| 4452 |
return $lock; |
| 4453 |
} |
| 4454 |
|
| 4455 |
/** |
| 4456 |
* Build the stable response shape for a refused transaction. |
| 4457 |
* |
| 4458 |
* The artifact fields report what is ON DISK, not zeros. A refusal means |
| 4459 |
* xSpeed changed nothing — on a site already running our cache that is |
| 4460 |
* exactly the state where the drop-in and WP_CACHE are both still in |
| 4461 |
* place and still serving hits. Hardcoding false told the dashboard the |
| 4462 |
* cache had been dismantled every time a refusal was returned. |
| 4463 |
*/ |
| 4464 |
private static function blocked_toggle_state( string $reason ): array { |
| 4465 |
/* |
| 4466 |
* `enabled` answers ONE question: is the page cache operational right |
| 4467 |
* now. Not what was asked for, and not what the option says. |
| 4468 |
* |
| 4469 |
* WordPress loads advanced-cache.php only when WP_CACHE is truthy, so |
| 4470 |
* those two files together are the whole answer, and reading them is |
| 4471 |
* the only source that cannot go stale. Both of the alternatives were |
| 4472 |
* tried here and both produced wrong answers on real paths: a |
| 4473 |
* hardcoded false told a caller the cache had gone away on a site |
| 4474 |
* still serving hits, and the persisted setting told a caller the |
| 4475 |
* cache was healthy after a rollback had just removed the artifacts |
| 4476 |
* — the option is not written until the end of the transaction, so |
| 4477 |
* mid-transaction it is stale by construction. |
| 4478 |
* |
| 4479 |
* Deliberately not a parameter. Every branch that got to choose its |
| 4480 |
* own answer eventually chose wrong. |
| 4481 |
*/ |
| 4482 |
return array( |
| 4483 |
'enabled' => self::page_cache_operational(), |
| 4484 |
'blocked' => true, |
| 4485 |
'blocked_reason' => $reason, |
| 4486 |
'dropin_installed' => self::DROPIN_XSPEED === self::dropin_owner(), |
| 4487 |
'wp_cache_constant' => 'true' === self::wp_cache_define_state(), |
| 4488 |
'rewrite_installed' => self::rewrite_installed(), |
| 4489 |
'wp_config_writable' => self::wp_config_writable(), |
| 4490 |
'manual_snippet' => null, |
| 4491 |
'nginx_snippet' => self::nginx_snippet(), |
| 4492 |
'nginx_server_block' => self::full_nginx_server_block(), |
| 4493 |
); |
| 4494 |
} |
| 4495 |
|
| 4496 |
/** |
| 4497 |
* The wp-config.php line a user must paste, or null when none is needed. |
| 4498 |
* |
| 4499 |
* Non-null only where the drop-in is ours and WP_CACHE is not set to true |
| 4500 |
* in a file we can write — the read-only managed host. Everywhere else the |
| 4501 |
* constant is ours to manage and there is nothing to ask for. |
| 4502 |
*/ |
| 4503 |
public static function manual_wp_cache_snippet(): ?string { |
| 4504 |
if ( self::DROPIN_XSPEED !== self::dropin_owner() ) { |
| 4505 |
return null; |
| 4506 |
} |
| 4507 |
if ( 'true' === self::wp_cache_define_state() ) { |
| 4508 |
return null; |
| 4509 |
} |
| 4510 |
return self::wp_config_writable() ? null : "define( 'WP_CACHE', true );"; |
| 4511 |
} |
| 4512 |
|
| 4513 |
/** |
| 4514 |
* Is the page cache serving right now? |
| 4515 |
* |
| 4516 |
* Two things decide it, and `WP_CACHE` is not one of them. |
| 4517 |
* |
| 4518 |
* xSpeed serves a cached page from `template_redirect` whenever the |
| 4519 |
* setting is on — see the `HIT (php)` mark on that path, which exists |
| 4520 |
* precisely for "the drop-in isn't loaded". `advanced-cache.php` and the |
| 4521 |
* `WP_CACHE` constant that loads it are the FAST path: they answer before |
| 4522 |
* WordPress boots, which is worth a lot of milliseconds and nothing at |
| 4523 |
* all to the question of whether pages are being served from cache. |
| 4524 |
* |
| 4525 |
* Conflating the two reported a dead cache over a live one. On a managed |
| 4526 |
* host with an unwritable wp-config.php — the exact case the manual |
| 4527 |
* snippet exists for — one card said "Your cache works on every request", |
| 4528 |
* "On, but not serving", "nothing will be cached until you add this line" |
| 4529 |
* and "hit ratio 67%", all at once, and told the user to edit a file they |
| 4530 |
* have no permission to write. The released 1.2.1 reported that site as |
| 4531 |
* active, correctly. |
| 4532 |
* |
| 4533 |
* So: the setting, and whether anyone else holds the drop-in. A foreign |
| 4534 |
* drop-in answers before WordPress loads us, so ours never runs and we |
| 4535 |
* genuinely are not serving. An unreadable one we must assume the same of. |
| 4536 |
* Everything else — our drop-in, or none at all — serves. |
| 4537 |
* |
| 4538 |
* Public because it is part of the host-plugin contract — see Host. A |
| 4539 |
* plugin that installed xSpeed needs to be able to say whether the cache |
| 4540 |
* it asked for is actually serving, and no combination of settings reads |
| 4541 |
* answers that. |
| 4542 |
*/ |
| 4543 |
public static function page_cache_operational(): bool { |
| 4544 |
$settings = Settings::get(); |
| 4545 |
if ( empty( $settings['cache_enabled'] ) ) { |
| 4546 |
return false; |
| 4547 |
} |
| 4548 |
$owner = self::dropin_owner(); |
| 4549 |
return self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner; |
| 4550 |
} |
| 4551 |
|
| 4552 |
/** Restore exact snapshots only while disk still matches our own write. */ |
| 4553 |
private static function rollback_page_cache_artifacts( string $dropin_path, ?string $dropin_before, ?string $dropin_written, string $config_path, ?string $config_before, ?string $config_written ): void { |
| 4554 |
// Roll back only files that still carry xSpeed's just-written state. |
| 4555 |
if ( null !== $dropin_written && hash_equals( $dropin_written, (string) self::read_file( $dropin_path ) ) ) { |
| 4556 |
if ( null === $dropin_before ) { |
| 4557 |
wp_delete_file( $dropin_path ); |
| 4558 |
} else { |
| 4559 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock. |
| 4560 |
file_put_contents( $dropin_path, $dropin_before ); |
| 4561 |
} |
| 4562 |
} |
| 4563 |
if ( '' !== $config_path && null !== $config_before && null !== $config_written && hash_equals( $config_written, (string) self::read_file( $config_path ) ) && self::wp_cache_receipt_matches_source( $config_written ) ) { |
| 4564 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock. |
| 4565 |
file_put_contents( $config_path, $config_before ); |
| 4566 |
} |
| 4567 |
} |
| 4568 |
|
| 4569 |
/** |
| 4570 |
* Check wp-config.php writability via WP_Filesystem. Plugin Check flags |
| 4571 |
* direct is_writable() under WordPress.WP.AlternativeFunctions. |
| 4572 |
*/ |
| 4573 |
private static function wp_config_writable() { |
| 4574 |
global $wp_filesystem; |
| 4575 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 4576 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 4577 |
} |
| 4578 |
WP_Filesystem(); |
| 4579 |
|
| 4580 |
return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false; |
| 4581 |
} |
| 4582 |
|
| 4583 |
/** |
| 4584 |
* Nginx server-block snippet mirroring the Apache rewrite block. |
| 4585 |
* We never auto-write nginx config — it sits outside the WordPress |
| 4586 |
* root and is owned by the server admin — but the dashboard |
| 4587 |
* surfaces this snippet when nginx is detected so the admin can |
| 4588 |
* paste it once and unlock the same PHP-bypass speedup we get on |
| 4589 |
* Apache / LiteSpeed via .htaccess. |
| 4590 |
* |
| 4591 |
* Returns null when the server isn't nginx (no point showing it). |
| 4592 |
*/ |
| 4593 |
/** |
| 4594 |
* Create wp-content/cache/xspeed/hits.log as an empty file so the |
| 4595 |
* server-level rewrite's `access_log` directive has somewhere to |
| 4596 |
* write on first request. Idempotent — touches an existing file |
| 4597 |
* without disturbing accumulated lines. Called from Cache::toggle() |
| 4598 |
* on enable and from auto_heal() when the file is missing. |
| 4599 |
* |
| 4600 |
* Permissions matter here. The file is created by PHP-FPM (often uid |
| 4601 |
* www-data), but the nginx process that appends HIT lines may run as a |
| 4602 |
* DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in |
| 4603 |
* its own container as uid `nginx`, PHP-FPM in another as `www-data`) |
| 4604 |
* they don't share a user at all. A default-umask 0644 file is then |
| 4605 |
* unwritable by nginx, the access_log write silently fails, and the |
| 4606 |
* dashboard shows a 0% hit ratio even though static HITs are serving. |
| 4607 |
* So we widen the dir to 0777 and the file to 0666 — group/other write — |
| 4608 |
* so whatever uid nginx runs as can append. (The file holds only HIT |
| 4609 |
* request lines, no secrets.) |
| 4610 |
*/ |
| 4611 |
/** |
| 4612 |
* Directory holding the nginx hit log. Lives under uploads/, NOT the |
| 4613 |
* cache dir — uninstall.php and a cache purge both delete the cache |
| 4614 |
* dir, which would orphan the pasted nginx `access_log` directive's |
| 4615 |
* parent directory and make `nginx -t` fail [emerg], taking down every |
| 4616 |
* vhost on the host (FBS-82478). uploads/ always exists, isn't a |
| 4617 |
* plugin-managed cache dir, and is never deleted on uninstall — so the |
| 4618 |
* directive's target dir survives both, and nginx (which creates a |
| 4619 |
* missing log FILE but not a missing DIR) can always open it. |
| 4620 |
* |
| 4621 |
* Falls back to the cache dir only if uploads is somehow unavailable. |
| 4622 |
*/ |
| 4623 |
public static function hits_log_dir(): string { |
| 4624 |
if ( function_exists( 'wp_upload_dir' ) ) { |
| 4625 |
$uploads = wp_upload_dir( null, false ); |
| 4626 |
if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) { |
| 4627 |
return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed'; |
| 4628 |
} |
| 4629 |
} |
| 4630 |
return XSPEED_CACHE_DIR; |
| 4631 |
} |
| 4632 |
|
| 4633 |
/** Absolute path to the nginx hit log file. */ |
| 4634 |
public static function hits_log_path(): string { |
| 4635 |
return self::hits_log_dir() . '/hits.log'; |
| 4636 |
} |
| 4637 |
|
| 4638 |
/** |
| 4639 |
* Sync the drop-in's mobile-bucket flag file with the `mobile_separate` |
| 4640 |
* setting. The drop-in (advanced-cache.php) runs before WordPress loads, |
| 4641 |
* so it can't read the option — instead it checks for a zero-byte |
| 4642 |
* `.mobile-separate` marker next to the cache files. When the setting is |
| 4643 |
* on we touch the marker; when off we remove it. The drop-in's cache_key |
| 4644 |
* computation keys off the marker's presence so its '|m'/'|d' device |
| 4645 |
* bucket stays in lockstep with Cache::cache_key(). |
| 4646 |
* |
| 4647 |
* Without this, turning on mobile_separate made Cache::store() write keys |
| 4648 |
* with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's |
| 4649 |
* file_exists() always missed, every HIT fell through to a full WP boot, |
| 4650 |
* and the fast pre-WP path was silently dead. |
| 4651 |
* |
| 4652 |
* @param bool|null $enabled Force a state; null reads the current setting. |
| 4653 |
*/ |
| 4654 |
/** |
| 4655 |
* Write the subdirectory-multisite path list the drop-in needs to work |
| 4656 |
* out which blog a request belongs to. |
| 4657 |
* |
| 4658 |
* The drop-in runs before WordPress, so it cannot call is_multisite() |
| 4659 |
* or get_blog_details(). It can only see REQUEST_URI — so we persist the |
| 4660 |
* network's blog paths (one per line, longest first) next to the cache |
| 4661 |
* files, exactly as sync_mobile_flag() persists the device flag. The |
| 4662 |
* drop-in prefix-matches the URI against that list to pick the same |
| 4663 |
* bucket Cache::current_host_dir() picks. (#6) |
| 4664 |
* |
| 4665 |
* No file is written for a single site or a subdomain network — there |
| 4666 |
* the host alone identifies the blog and the bucket carries no prefix. |
| 4667 |
*/ |
| 4668 |
public static function sync_site_paths(): void { |
| 4669 |
$file = XSPEED_CACHE_DIR . '/.site-paths'; |
| 4670 |
|
| 4671 |
$needed = function_exists( 'is_multisite' ) && is_multisite() |
| 4672 |
&& ( ! function_exists( 'is_subdomain_install' ) || ! is_subdomain_install() ); |
| 4673 |
|
| 4674 |
if ( ! $needed ) { |
| 4675 |
if ( file_exists( $file ) ) { |
| 4676 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. |
| 4677 |
@unlink( $file ); |
| 4678 |
} |
| 4679 |
return; |
| 4680 |
} |
| 4681 |
|
| 4682 |
if ( ! function_exists( 'get_sites' ) ) { |
| 4683 |
return; |
| 4684 |
} |
| 4685 |
|
| 4686 |
$paths = array(); |
| 4687 |
foreach ( get_sites( array( 'number' => 0 ) ) as $site ) { |
| 4688 |
$prefix = self::path_prefix_segment( (string) $site->path ); |
| 4689 |
if ( '' !== $prefix ) { |
| 4690 |
// Store the raw path so the drop-in can prefix-match a URI, |
| 4691 |
// alongside the segment it maps to. |
| 4692 |
$paths[ trim( (string) $site->path, '/' ) ] = $prefix; |
| 4693 |
} |
| 4694 |
} |
| 4695 |
|
| 4696 |
if ( empty( $paths ) ) { |
| 4697 |
if ( file_exists( $file ) ) { |
| 4698 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- see above. |
| 4699 |
@unlink( $file ); |
| 4700 |
} |
| 4701 |
return; |
| 4702 |
} |
| 4703 |
|
| 4704 |
// Longest path first so /a/b wins over /a. |
| 4705 |
uksort( |
| 4706 |
$paths, |
| 4707 |
static function ( $x, $y ) { |
| 4708 |
return strlen( (string) $y ) <=> strlen( (string) $x ); |
| 4709 |
} |
| 4710 |
); |
| 4711 |
|
| 4712 |
$lines = array(); |
| 4713 |
foreach ( $paths as $raw => $segment ) { |
| 4714 |
$lines[] = $raw . '|' . $segment; |
| 4715 |
} |
| 4716 |
|
| 4717 |
if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) { |
| 4718 |
return; |
| 4719 |
} |
| 4720 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- read by the pre-WP drop-in; WP_Filesystem needs admin credentials unavailable here. |
| 4721 |
file_put_contents( $file, implode( "\n", $lines ), LOCK_EX ); |
| 4722 |
} |
| 4723 |
|
| 4724 |
/** |
| 4725 |
* Compile `ignored_query_params` into a regex the DROP-IN can use. |
| 4726 |
* |
| 4727 |
* Tracking traffic was cached but never served fast. should_cache() |
| 4728 |
* learned to allow `?utm_source=…` through and cache_key() strips the |
| 4729 |
* query, so `/post` and `/post?utm_source=x` share one entry — but the |
| 4730 |
* drop-in still bailed on ANY query string, so every visitor from an |
| 4731 |
* email or ad campaign paid a full WordPress boot to be handed a file |
| 4732 |
* that was already on disk. On a marketing site that is most of the |
| 4733 |
* paid traffic taking the slowest path. (#13) |
| 4734 |
* |
| 4735 |
* The drop-in runs before WordPress, so it cannot read the option or |
| 4736 |
* call Glob_Matcher. It gets a precompiled alternation instead, written |
| 4737 |
* next to the cache files exactly as sync_mobile_flag() writes the |
| 4738 |
* device flag. Regenerated whenever cache settings are saved. |
| 4739 |
* |
| 4740 |
* Only the KEYS matter: a param whose name is on the list contributes |
| 4741 |
* nothing to the response, so the entry keyed without it is correct. |
| 4742 |
* Anything not on the list means the drop-in must stand down and let |
| 4743 |
* PHP decide — the file is deleted rather than left stale when the |
| 4744 |
* list is empty, so a missing sidecar always fails safe. |
| 4745 |
*/ |
| 4746 |
public static function sync_query_allowlist(): void { |
| 4747 |
$file = XSPEED_CACHE_DIR . '/.ignored-query-params'; |
| 4748 |
|
| 4749 |
$opts = Settings_Manager::get( 'cache' ); |
| 4750 |
$ignored = is_array( $opts['ignored_query_params'] ?? null ) ? $opts['ignored_query_params'] : array(); |
| 4751 |
|
| 4752 |
$parts = array(); |
| 4753 |
foreach ( $ignored as $pattern ) { |
| 4754 |
$pattern = trim( (string) $pattern ); |
| 4755 |
if ( '' === $pattern ) { |
| 4756 |
continue; |
| 4757 |
} |
| 4758 |
if ( '~' === $pattern[0] ) { |
| 4759 |
// Raw regex, PHP-side dialect. Keep it — unlike a server |
| 4760 |
// config, the drop-in runs the same PCRE engine, so the |
| 4761 |
// pattern behaves identically. Anchored below with the rest. |
| 4762 |
$body = substr( $pattern, 1 ); |
| 4763 |
if ( '' !== $body && false !== @preg_match( '#^(?:' . $body . ')$#', '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed user pattern must be dropped, not fatal. |
| 4764 |
$parts[] = $body; |
| 4765 |
} |
| 4766 |
continue; |
| 4767 |
} |
| 4768 |
// Glob semantics, same as Glob_Matcher: * is any run, ? is one. |
| 4769 |
$esc = preg_quote( $pattern, '#' ); |
| 4770 |
$esc = str_replace( array( '\*', '\?' ), array( '.*', '.' ), $esc ); |
| 4771 |
$parts[] = $esc; |
| 4772 |
} |
| 4773 |
|
| 4774 |
if ( empty( $parts ) ) { |
| 4775 |
if ( file_exists( $file ) ) { |
| 4776 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. |
| 4777 |
@unlink( $file ); |
| 4778 |
} |
| 4779 |
return; |
| 4780 |
} |
| 4781 |
|
| 4782 |
if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) { |
| 4783 |
return; |
| 4784 |
} |
| 4785 |
|
| 4786 |
$payload = '(?:' . implode( '|', array_unique( $parts ) ) . ')'; |
| 4787 |
|
| 4788 |
// Only write when the value actually changed. This runs from |
| 4789 |
// reconcile_mobile_separate() on CacheModule::boot(), so an |
| 4790 |
// unconditional write cost a file write and an exclusive lock on every |
| 4791 |
// request that boots WordPress — every MISS, every BYPASS, every admin |
| 4792 |
// screen, every REST call. sync_mobile_flag() below is the model: it |
| 4793 |
// touches the marker only when the setting flips. |
| 4794 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- our own sidecar; an unreadable file falls through to the write below. |
| 4795 |
if ( is_readable( $file ) && (string) @file_get_contents( $file ) === $payload ) { |
| 4796 |
return; |
| 4797 |
} |
| 4798 |
|
| 4799 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- read by the pre-WP drop-in; WP_Filesystem needs admin credentials unavailable here. |
| 4800 |
file_put_contents( $file, $payload, LOCK_EX ); |
| 4801 |
} |
| 4802 |
|
| 4803 |
public static function sync_mobile_flag( $enabled = null ): void { |
| 4804 |
if ( null === $enabled ) { |
| 4805 |
$opts = Settings_Manager::get( 'cache' ); |
| 4806 |
$enabled = ! empty( $opts['mobile_separate'] ); |
| 4807 |
} |
| 4808 |
$dir = XSPEED_CACHE_DIR; |
| 4809 |
$flag = $dir . '/.mobile-separate'; |
| 4810 |
if ( $enabled ) { |
| 4811 |
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { |
| 4812 |
return; |
| 4813 |
} |
| 4814 |
if ( ! file_exists( $flag ) ) { |
| 4815 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged -- read by the pre-WP drop-in via file_exists(); must be a plain marker, not WP_Filesystem. |
| 4816 |
@touch( $flag ); |
| 4817 |
} |
| 4818 |
return; |
| 4819 |
} |
| 4820 |
if ( file_exists( $flag ) ) { |
| 4821 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. |
| 4822 |
@unlink( $flag ); |
| 4823 |
} |
| 4824 |
} |
| 4825 |
|
| 4826 |
/** |
| 4827 |
* Write / remove the `.maintenance-active` sentinel next to the cache |
| 4828 |
* files. The pre-WP drop-in checks for this marker and bails when present, |
| 4829 |
* so a page cached while the site was live is NOT served during |
| 4830 |
* maintenance / coming-soon mode — WordPress loads and renders the |
| 4831 |
* maintenance screen instead. The Pro Maintenance-Cache module drives this |
| 4832 |
* on the maintenance on/off transition. (FBS-82409 B1) |
| 4833 |
* |
| 4834 |
* @param bool $active True to arm the sentinel (entering maintenance), |
| 4835 |
* false to clear it (site recovered). |
| 4836 |
*/ |
| 4837 |
public static function sync_maintenance_flag( bool $active ): void { |
| 4838 |
$dir = XSPEED_CACHE_DIR; |
| 4839 |
$flag = $dir . '/.maintenance-active'; |
| 4840 |
if ( $active ) { |
| 4841 |
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { |
| 4842 |
return; |
| 4843 |
} |
| 4844 |
if ( ! file_exists( $flag ) ) { |
| 4845 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged -- read by the pre-WP drop-in via file_exists(); must be a plain marker, not WP_Filesystem. |
| 4846 |
@touch( $flag ); |
| 4847 |
} |
| 4848 |
return; |
| 4849 |
} |
| 4850 |
if ( file_exists( $flag ) ) { |
| 4851 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. |
| 4852 |
@unlink( $flag ); |
| 4853 |
} |
| 4854 |
} |
| 4855 |
|
| 4856 |
/** |
| 4857 |
* Reconcile every mobile_separate-dependent artifact to the current |
| 4858 |
* setting. Called on boot and whenever the cache settings are saved, so |
| 4859 |
* flipping mobile_separate at runtime can't leave the install in a |
| 4860 |
* half-converted state. |
| 4861 |
* |
| 4862 |
* Three things must agree with the setting: |
| 4863 |
* 1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()), |
| 4864 |
* 2. the device-blind server rewrite — present only when OFF |
| 4865 |
* (static_rewrite_allowed()), |
| 4866 |
* 3. the now-stale static-cache tree + page cache, which were keyed |
| 4867 |
* under the old scheme and would serve wrong-device HTML. |
| 4868 |
* |
| 4869 |
* No-ops when the cache is disabled — there's nothing installed to |
| 4870 |
* reconcile, and toggle() handles install/teardown itself. |
| 4871 |
*/ |
| 4872 |
public static function reconcile_mobile_separate(): void { |
| 4873 |
self::sync_mobile_flag(); |
| 4874 |
if ( defined( 'XSPEED_CACHE_DIR' ) ) { |
| 4875 |
// Keep the drop-in's view of the network's blog paths current — a |
| 4876 |
// site added or removed changes which bucket its URLs belong to. (#6) |
| 4877 |
self::sync_site_paths(); |
| 4878 |
// Keep the drop-in's copy of the query allow-list current — a param |
| 4879 |
// added in settings must reach the fast path too. (#13) |
| 4880 |
self::sync_query_allowlist(); |
| 4881 |
} |
| 4882 |
|
| 4883 |
// The rewrite/static reconciliation below needs the plugin's path |
| 4884 |
// constants. They're absent in early-boot / unit-test contexts where |
| 4885 |
// only the drop-in flag matters — bail to the flag-only behavior then. |
| 4886 |
if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) { |
| 4887 |
return; |
| 4888 |
} |
| 4889 |
|
| 4890 |
// Only touch the rewrite + caches when caching is actually on. |
| 4891 |
$opts = get_option( 'xspeed_options', array() ); |
| 4892 |
if ( empty( $opts['cache_enabled'] ) ) { |
| 4893 |
return; |
| 4894 |
} |
| 4895 |
|
| 4896 |
$rewrite_present = self::rewrite_installed(); |
| 4897 |
$rewrite_wanted = self::static_rewrite_allowed(); |
| 4898 |
|
| 4899 |
// Did the thing that actually invalidates cache KEYS change? |
| 4900 |
// mobile_separate buckets entries as |d / |m, so flipping it makes |
| 4901 |
// stored entries mis-bucketed and they must go. A rewrite-state |
| 4902 |
// mismatch from anything else (e.g. mod_headers detection, a hand- |
| 4903 |
// edited .htaccess) changes no key at all — the same files are still |
| 4904 |
// valid, they're just served by PHP instead of by the web server. |
| 4905 |
// Purging there is what let one WP-CLI call wipe the whole cache on |
| 4906 |
// every bootstrap. (#138) |
| 4907 |
// |
| 4908 |
// Read the setting from the SAME place static_rewrite_allowed() and |
| 4909 |
// sync_mobile_flag() do — the cache module's settings, not the |
| 4910 |
// top-level xspeed_options — or this marker would track a key that |
| 4911 |
// never changes and a real flip would go unnoticed. |
| 4912 |
$cache_opts = Settings_Manager::get( 'cache' ); |
| 4913 |
$mobile_now = ! empty( $cache_opts['mobile_separate'] ); |
| 4914 |
$mobile_last = get_option( 'xspeed_last_mobile_separate', null ); |
| 4915 |
$mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now ); |
| 4916 |
|
| 4917 |
if ( (string) (int) $mobile_now !== (string) $mobile_last ) { |
| 4918 |
update_option( 'xspeed_last_mobile_separate', $mobile_now ? '1' : '0', false ); |
| 4919 |
} |
| 4920 |
|
| 4921 |
if ( $rewrite_present === $rewrite_wanted ) { |
| 4922 |
// Already consistent — nothing flipped, leave caches intact so a |
| 4923 |
// plain settings save (e.g. expiry change) doesn't blow the cache. |
| 4924 |
return; |
| 4925 |
} |
| 4926 |
|
| 4927 |
// Bring the rewrite into line with what this server actually supports. |
| 4928 |
if ( $rewrite_wanted ) { |
| 4929 |
self::install_rewrite(); |
| 4930 |
} else { |
| 4931 |
self::remove_rewrite(); |
| 4932 |
} |
| 4933 |
|
| 4934 |
// Only discard cache contents when the device bucketing changed. |
| 4935 |
if ( $mobile_flipped ) { |
| 4936 |
self::purge_all( 'mobile_separate changed' ); |
| 4937 |
} |
| 4938 |
} |
| 4939 |
|
| 4940 |
/** |
| 4941 |
* Whether the server-level static-rewrite fast path may be used. |
| 4942 |
* |
| 4943 |
* The rewrite serves `{host}{path}/index.html` straight from the web |
| 4944 |
* server, keyed only by host + path — it has no way to run our PHP |
| 4945 |
* device detection, so it can't tell mobile from desktop. When |
| 4946 |
* `mobile_separate` is on, a single static file would be shared across |
| 4947 |
* devices and whoever primed it wins (mobile visitors could get desktop |
| 4948 |
* HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent |
| 4949 |
* UA matcher into .htaccess AND the nginx snippet (three copies that |
| 4950 |
* would inevitably drift), we simply DON'T engage the static rewrite when |
| 4951 |
* mobile_separate is on. Requests then fall through to the PHP drop-in, |
| 4952 |
* which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only |
| 4953 |
* on mobile-separate sites, in exchange for guaranteed correctness. |
| 4954 |
* |
| 4955 |
* LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in |
| 4956 |
* particular — `.htaccess` CAN run our RewriteRule to serve the static |
| 4957 |
* file, but its `.htaccess` engine ignores `mod_headers`, so we cannot |
| 4958 |
* stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no |
| 4959 |
* `.htaccess` equivalent of nginx's per-location `access_log` to record |
| 4960 |
* the hit. The result was a cache that worked but was invisible: no HIT |
| 4961 |
* header and a hit-ratio frozen near 0%. Every OTHER server gives the |
| 4962 |
* user a visible HIT header + a counted hit (nginx via add_header + |
| 4963 |
* access_log in its snippet; Apache via the `<IfModule mod_headers.c>` |
| 4964 |
* block in rewrite_block_lines(), WHEN that module is loaded — when it is |
| 4965 |
* not, Apache takes this same drop-in fallback). To keep LiteSpeed |
| 4966 |
* CONSISTENT with the rest, we route its hits |
| 4967 |
* through the PHP drop-in instead — the drop-in emits |
| 4968 |
* `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the |
| 4969 |
* observable behavior the other servers get. The cost is the drop-in's |
| 4970 |
* ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in |
| 4971 |
* exchange the dashboard hit-ratio and the response header finally tell |
| 4972 |
* the truth there. (Apache keeps the static fast path — it honors the |
| 4973 |
* header.) See maybe_emit_lscache_headers() for the paired LSCache |
| 4974 |
* stand-down that stops LiteSpeed's own module from shadowing the |
| 4975 |
* drop-in. |
| 4976 |
*/ |
| 4977 |
public static function static_rewrite_allowed(): bool { |
| 4978 |
// LiteSpeed: drop-in serves hits (visible + counted) — see docblock. |
| 4979 |
if ( Server::LITESPEED === Server::type() ) { |
| 4980 |
return false; |
| 4981 |
} |
| 4982 |
// Apache without mod_headers is in EXACTLY the position LiteSpeed |
| 4983 |
// is in above: it can run the RewriteRule and serve the static |
| 4984 |
// file, but it cannot stamp `X-XSpeed-Cache` on the response, so |
| 4985 |
// the hit is invisible to the user and uncountable by |
| 4986 |
// Hit_Counter. The docblock above used to assert Apache "honors |
| 4987 |
// mod_headers" and left it on the fast path unconditionally — |
| 4988 |
// true only when the module is actually loaded. Fall back to the |
| 4989 |
// drop-in when it isn't, trading ~10ms of TTFB for a hit that |
| 4990 |
// shows up in the header and the ratio. (Field report: hit ratio |
| 4991 |
// pinned at 0% on a working Apache cache.) |
| 4992 |
if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { |
| 4993 |
return false; |
| 4994 |
} |
| 4995 |
$opts = Settings_Manager::get( 'cache' ); |
| 4996 |
return empty( $opts['mobile_separate'] ); |
| 4997 |
} |
| 4998 |
|
| 4999 |
/** |
| 5000 |
* Why the device-blind static rewrite is NOT installed, when it isn't. |
| 5001 |
* Returns 'mobile_separate' when Separate Mobile Cache is the blocker |
| 5002 |
* (the static file is one-per-URL, so it can't coexist with per-device |
| 5003 |
* buckets), 'no_mod_headers' when Apache can't stamp the HIT header, |
| 5004 |
* '' otherwise. Lets the dashboard explain the slow path instead of |
| 5005 |
* silently falling back to PHP serving. (FBS-83145) |
| 5006 |
* |
| 5007 |
* Every refusal in static_rewrite_allowed() that is NOT self-explanatory |
| 5008 |
* must have a branch here. Otherwise the Health card falls through to |
| 5009 |
* "Block missing — toggle Enable Cache off and on to reinstall it", |
| 5010 |
* advice that cannot work: the same condition that suppressed the write |
| 5011 |
* suppresses the reinstall, and auto_heal() strips the block again on |
| 5012 |
* the next admin page load. (Field report: Apache host with mod_headers |
| 5013 |
* unloaded sat on the slow path with no way to find out why.) |
| 5014 |
*/ |
| 5015 |
/** |
| 5016 |
* Qualify a raw probe result with what we already KNOW about config. |
| 5017 |
* |
| 5018 |
* probe_static_rewrite() writes its own file under the static-cache tree |
| 5019 |
* and fetches that, which succeeds whenever the web server can serve a |
| 5020 |
* static file at all — including when static_rewrite_allowed() is false |
| 5021 |
* and no real page is on the static path. So `active: true` on its own is |
| 5022 |
* not evidence that pages are being served statically. |
| 5023 |
* |
| 5024 |
* The reachable case is nginx with Separate Mobile Cache on: the snippet |
| 5025 |
* lives in the server block and we cannot remove it, pages are |
| 5026 |
* deliberately routed to the PHP drop-in, but the probe file is still |
| 5027 |
* served directly. |
| 5028 |
* |
| 5029 |
* The Health panel learned this in 88b4b50; the CLI, REST and MCP paths |
| 5030 |
* did not, so they kept reporting "active" in exactly that configuration. |
| 5031 |
* Rather than repeat the reasoning at each call site, they now all come |
| 5032 |
* through here. |
| 5033 |
* |
| 5034 |
* Deliberately does NOT consult rewrite_installed(): on nginx the fast |
| 5035 |
* path is the pasted snippet and there is no .htaccess marker to find, so |
| 5036 |
* requiring one would report every correctly-configured nginx site as |
| 5037 |
* broken. |
| 5038 |
* |
| 5039 |
* @param array $probe Raw result from probe_static_rewrite(). |
| 5040 |
* @return array{active:bool,inconclusive:bool,reason:string,block_reason:string} |
| 5041 |
*/ |
| 5042 |
public static function qualify_rewrite_probe( array $probe ): array { |
| 5043 |
$active = (bool) ( $probe['active'] ?? false ); |
| 5044 |
$inconclusive = (bool) ( $probe['inconclusive'] ?? false ); |
| 5045 |
$reason = (string) ( $probe['reason'] ?? '' ); |
| 5046 |
$block_reason = self::static_rewrite_block_reason(); |
| 5047 |
|
| 5048 |
// Same observed-refusal check Health makes. This is the shared path for |
| 5049 |
// `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite — |
| 5050 |
// and, because a CLI command is automatically an MCP tool, for the |
| 5051 |
// AI-facing surface too. Leaving it out would have fixed the dashboard |
| 5052 |
// while the CLI kept answering that the fast path was active. (#372) |
| 5053 |
if ( '' === $block_reason ) { |
| 5054 |
$skip = self::last_static_skip(); |
| 5055 |
if ( ! empty( $skip['reason'] ) ) { |
| 5056 |
$block_reason = 'skipped_' . (string) $skip['reason']; |
| 5057 |
} |
| 5058 |
} |
| 5059 |
|
| 5060 |
// With page caching off there is nothing to serve, so `active` can |
| 5061 |
// never be true here whatever the raw probe says. probe_static_rewrite() |
| 5062 |
// writes its OWN file under the static tree and fetches that, which |
| 5063 |
// succeeds whenever the server can serve a static file at all — and on |
| 5064 |
// nginx the snippet is server-level, so it keeps succeeding after the |
| 5065 |
// cache is switched off. |
| 5066 |
// |
| 5067 |
// block_reason() used to carry this meaning by accident: it returned |
| 5068 |
// 'mobile_separate' with caching off, and the refusal branch below |
| 5069 |
// forced active=false. Now that it correctly reports '' (nothing can |
| 5070 |
// block a fast path that isn't in use), this consumer has to state the |
| 5071 |
// condition itself — otherwise `wp xspeed cache recheck-rewrite` and |
| 5072 |
// POST /cache/recheck-rewrite claim "the web server is serving cache |
| 5073 |
// hits directly" on a site with no cache. That is a positive false |
| 5074 |
// claim rather than a nag, i.e. worse than the bug being fixed. |
| 5075 |
$cache_opts = Settings::get(); |
| 5076 |
if ( empty( $cache_opts['cache_enabled'] ) ) { |
| 5077 |
return array( |
| 5078 |
'active' => false, |
| 5079 |
'inconclusive' => false, |
| 5080 |
'reason' => 'Page caching is off, so there is no cache for the web server to serve.', |
| 5081 |
'block_reason' => '', |
| 5082 |
); |
| 5083 |
} |
| 5084 |
|
| 5085 |
// A known refusal outranks the probe, and also outranks |
| 5086 |
// "inconclusive" — a blocked rewrite whose probe merely failed to |
| 5087 |
// complete is still definitely blocked. |
| 5088 |
if ( '' !== $block_reason ) { |
| 5089 |
$active = false; |
| 5090 |
$inconclusive = false; |
| 5091 |
$reason = self::block_reason_text( $block_reason ); |
| 5092 |
} |
| 5093 |
|
| 5094 |
return array( |
| 5095 |
'active' => $active, |
| 5096 |
'inconclusive' => $inconclusive, |
| 5097 |
'reason' => $reason, |
| 5098 |
'block_reason' => $block_reason, |
| 5099 |
); |
| 5100 |
} |
| 5101 |
|
| 5102 |
/** |
| 5103 |
* Human-readable explanation for a static_rewrite_block_reason() code. |
| 5104 |
* |
| 5105 |
* Each one has to say what to DO about it: "mobile_separate" alone tells |
| 5106 |
* a user nothing, and the whole point of surfacing a refusal instead of |
| 5107 |
* the probe verdict is that it is actionable. |
| 5108 |
*/ |
| 5109 |
public static function block_reason_text( string $code ): string { |
| 5110 |
switch ( $code ) { |
| 5111 |
case 'mobile_separate': |
| 5112 |
return 'Separate Mobile Cache is on, which disables the device-blind static rewrite. Cache hits are served by PHP instead. If your site serves the same HTML to every device, turn it off in Cache settings for much faster hits.'; |
| 5113 |
case 'no_mod_headers': |
| 5114 |
return "Apache's mod_headers is not loaded, so the static rewrite cannot mark its responses as cache hits. Enable mod_headers, or leave hits on the PHP path."; |
| 5115 |
case 'skipped_nonce': |
| 5116 |
return 'The server config is correct, but pages are not reaching the static cache because they contain nonces, so hits are served by PHP instead. A static file is served with no PHP, so a nonce baked into one could never be refreshed and every anonymous form on the page would break once it expired — keeping these pages on PHP is deliberate. Nonces usually come from plugin widgets; disabling the ones the site does not use lets its pages be served statically again.'; |
| 5117 |
default: |
| 5118 |
return sprintf( 'The static rewrite is disabled (%s).', $code ); |
| 5119 |
} |
| 5120 |
} |
| 5121 |
|
| 5122 |
public static function static_rewrite_block_reason(): string { |
| 5123 |
// Nothing can be blocking the fast path when there is no cache to |
| 5124 |
// serve from it. Without this the dashboard told users with page |
| 5125 |
// caching switched OFF that Separate Mobile Cache "is disabling |
| 5126 |
// faster static serving" — a fast path they were not using, about a |
| 5127 |
// cache that did not exist. Every caller of this is a user-facing |
| 5128 |
// explanation of why the rewrite is off, so "the cache is off" is |
| 5129 |
// the honest answer, and it is silence. (#108) |
| 5130 |
$opts = Settings::get(); |
| 5131 |
if ( empty( $opts['cache_enabled'] ) ) { |
| 5132 |
return ''; |
| 5133 |
} |
| 5134 |
if ( Server::LITESPEED === Server::type() ) { |
| 5135 |
return ''; // Intended on LiteSpeed — not a "block". |
| 5136 |
} |
| 5137 |
if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { |
| 5138 |
return 'no_mod_headers'; |
| 5139 |
} |
| 5140 |
$cache_opts = Settings_Manager::get( 'cache' ); |
| 5141 |
return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : ''; |
| 5142 |
} |
| 5143 |
|
| 5144 |
/** |
| 5145 |
* Whether migration flagged Separate Mobile Cache for user review. Set by |
| 5146 |
* Migration::map_mobile_separate() when a source plugin (WP Rocket / WP |
| 5147 |
* Super Cache / LiteSpeed) had its "separate mobile cache" option on: we |
| 5148 |
* import it as OFF (to keep the device-blind static fast path) but record |
| 5149 |
* this flag so the dashboard can invite the user to turn it back on only |
| 5150 |
* if their site genuinely serves different HTML per device. (FBS-83145) |
| 5151 |
*/ |
| 5152 |
public static function mobile_separate_needs_review(): bool { |
| 5153 |
// Same reasoning as static_rewrite_block_reason(): the invitation is |
| 5154 |
// "turn this back on if your site needs it, to regain the fast path", |
| 5155 |
// which is meaningless with page caching off — there is no fast path |
| 5156 |
// to regain, and the equality probe behind the prompt would fetch |
| 5157 |
// pages that aren't being cached. Gated here rather than at the two |
| 5158 |
// payload call sites (Admin + Rest_Api) so `enabled`, `blocking` and |
| 5159 |
// `needs_review` are consistently gated on the same condition. (#108) |
| 5160 |
$opts = Settings::get(); |
| 5161 |
if ( empty( $opts['cache_enabled'] ) ) { |
| 5162 |
return false; |
| 5163 |
} |
| 5164 |
$cache_opts = Settings_Manager::get( 'cache' ); |
| 5165 |
return ! empty( $cache_opts['mobile_separate_review'] ); |
| 5166 |
} |
| 5167 |
|
| 5168 |
/** |
| 5169 |
* Clear the review flag — called when the user has acted on the prompt |
| 5170 |
* (dismissed it, or turned Separate Mobile Cache on/off deliberately) so |
| 5171 |
* the dashboard callout doesn't nag forever. Writes the option directly |
| 5172 |
* (bypassing Settings_Manager) so it never touches schema fields. |
| 5173 |
*/ |
| 5174 |
public static function clear_mobile_separate_review(): void { |
| 5175 |
$stored = get_option( 'xspeed_module_cache', array() ); |
| 5176 |
if ( ! is_array( $stored ) || empty( $stored['mobile_separate_review'] ) ) { |
| 5177 |
return; |
| 5178 |
} |
| 5179 |
unset( $stored['mobile_separate_review'] ); |
| 5180 |
update_option( 'xspeed_module_cache', $stored ); |
| 5181 |
} |
| 5182 |
|
| 5183 |
/** |
| 5184 |
* On-demand probe: does the homepage serve materially the same HTML to a |
| 5185 |
* desktop and a mobile browser? Fetches home_url() twice over loopback — |
| 5186 |
* once with a desktop User-Agent, once with a mobile one — strips |
| 5187 |
* per-request noise (nonces, CSRF tokens, session ids, inline timestamps), |
| 5188 |
* and compares. When identical, Separate Mobile Cache is almost certainly |
| 5189 |
* unnecessary and the user can turn it off to regain the static fast path. |
| 5190 |
* |
| 5191 |
* NEVER run automatically (no page-load cost) — only from the dashboard |
| 5192 |
* "Check now" button. Result is cached for 10 minutes so a double-click or |
| 5193 |
* a re-render doesn't fire two more self-requests. (FBS-83145) |
| 5194 |
* |
| 5195 |
* @return array{ identical:bool, checked:bool, reason?:string, desktop_bytes?:int, mobile_bytes?:int } |
| 5196 |
*/ |
| 5197 |
public static function probe_mobile_equality(): array { |
| 5198 |
$cached = get_transient( 'xspeed_mobile_equality_probe' ); |
| 5199 |
if ( is_array( $cached ) ) { |
| 5200 |
return $cached; |
| 5201 |
} |
| 5202 |
|
| 5203 |
$home = home_url( '/' ); |
| 5204 |
$host = (string) wp_parse_url( $home, PHP_URL_HOST ); |
| 5205 |
if ( '' === $host ) { |
| 5206 |
$result = array( 'identical' => false, 'checked' => false, 'reason' => 'home_url has no host' ); |
| 5207 |
set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS ); |
| 5208 |
return $result; |
| 5209 |
} |
| 5210 |
|
| 5211 |
// Match WP core's own mobile detection (wp_is_mobile) so the probe |
| 5212 |
// reflects what the site would actually branch on. iPhone Safari for |
| 5213 |
// mobile; a current desktop Chrome UA for desktop. |
| 5214 |
$desktop_ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; |
| 5215 |
$mobile_ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; |
| 5216 |
|
| 5217 |
$is_local = function_exists( 'wp_get_environment_type' ) |
| 5218 |
&& in_array( wp_get_environment_type(), array( 'local', 'development' ), true ); |
| 5219 |
|
| 5220 |
$fetch = static function ( string $ua ) use ( $home, $is_local ) { |
| 5221 |
$resp = wp_remote_get( |
| 5222 |
$home, |
| 5223 |
array( |
| 5224 |
'timeout' => 5, |
| 5225 |
'sslverify' => ! $is_local, |
| 5226 |
'redirection' => 2, |
| 5227 |
// Bust any per-device cache so we compare freshly-rendered |
| 5228 |
// HTML, and pass the device UA the site would branch on. |
| 5229 |
'user-agent' => $ua, |
| 5230 |
'headers' => array( 'Cache-Control' => 'no-cache' ), |
| 5231 |
) |
| 5232 |
); |
| 5233 |
if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) { |
| 5234 |
return null; |
| 5235 |
} |
| 5236 |
return (string) wp_remote_retrieve_body( $resp ); |
| 5237 |
}; |
| 5238 |
|
| 5239 |
$desktop = $fetch( $desktop_ua ); |
| 5240 |
$mobile = $fetch( $mobile_ua ); |
| 5241 |
|
| 5242 |
if ( null === $desktop || null === $mobile ) { |
| 5243 |
$result = array( 'identical' => false, 'checked' => false, 'reason' => 'could not fetch homepage twice' ); |
| 5244 |
set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS ); |
| 5245 |
return $result; |
| 5246 |
} |
| 5247 |
|
| 5248 |
$identical = self::normalize_html_for_diff( $desktop ) === self::normalize_html_for_diff( $mobile ); |
| 5249 |
|
| 5250 |
$result = array( |
| 5251 |
'identical' => $identical, |
| 5252 |
'checked' => true, |
| 5253 |
'desktop_bytes' => strlen( $desktop ), |
| 5254 |
'mobile_bytes' => strlen( $mobile ), |
| 5255 |
); |
| 5256 |
set_transient( 'xspeed_mobile_equality_probe', $result, 10 * MINUTE_IN_SECONDS ); |
| 5257 |
return $result; |
| 5258 |
} |
| 5259 |
|
| 5260 |
/** |
| 5261 |
* Strip per-request noise from HTML so a desktop-vs-mobile diff reflects |
| 5262 |
* real structural differences, not nonces / session ids / timestamps that |
| 5263 |
* change on every render. Deliberately conservative: it normalizes the |
| 5264 |
* handful of well-known noise sources and collapses whitespace, so a site |
| 5265 |
* that truly serves different markup per device still compares as different. |
| 5266 |
*/ |
| 5267 |
private static function normalize_html_for_diff( string $html ): string { |
| 5268 |
// Every rule here errs toward "they differ" being WRONG rather than |
| 5269 |
// "they match" being wrong: this check only ever tells a user it is |
| 5270 |
// SAFE to turn Separate Mobile Cache off, so a false "identical" |
| 5271 |
// would cost them device-specific output. The risk of being too |
| 5272 |
// conservative is milder but real — the useful answer never appears, |
| 5273 |
// and the feature's whole pitch ("we'll prove it's safe to turn |
| 5274 |
// off") silently never pays out. These close the gaps that made a |
| 5275 |
// mismatch effectively guaranteed on an ordinary WordPress site. (#108) |
| 5276 |
$patterns = array( |
| 5277 |
// WP nonces in attribute or JSON form: data-nonce="…", |
| 5278 |
// _wpnonce=…, "nonce":"…". The `[:=]` adjacency below misses |
| 5279 |
// wp_nonce_field()'s own markup — `name="_wpnonce" value="ab…"` |
| 5280 |
// puts `value=` between the key and the token — which is the |
| 5281 |
// single most common nonce shape in WordPress, so that form is |
| 5282 |
// matched explicitly first. |
| 5283 |
'/name=["\']?(_wpnonce|_ajax_nonce)["\']?\s+value=["\']?[a-z0-9]{8,}/i', |
| 5284 |
// CSP nonces on script/style tags. Base64, so uppercase and |
| 5285 |
// +/= appear — the hex-only rules below can never match one, |
| 5286 |
// and a CSP-enabled site therefore differed on every fetch. |
| 5287 |
// MUST precede the generic nonce rule: that one stops at the |
| 5288 |
// first non-alphanumeric, leaving the rest of the token behind |
| 5289 |
// and the two responses still unequal. |
| 5290 |
// The quotes are optional so HTML5's legal unquoted attribute |
| 5291 |
// form (`<script nonce=AbCd+q/r=>`) is covered too — without |
| 5292 |
// that it fell through to the generic rule, which is the exact |
| 5293 |
// failure this rule exists to remove. |
| 5294 |
'/\bnonce=(["\'])?[A-Za-z0-9+\/=_-]{8,}(?(1)\1)/', |
| 5295 |
'/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-z0-9]{8,}/i', |
| 5296 |
// Generic hex tokens: cache busters, session ids, md5/sha |
| 5297 |
// digests. Was 16+, which left an 11-15 char gap above the |
| 5298 |
// 10-char nonce rule. |
| 5299 |
// |
| 5300 |
// The token MUST contain at least one a-f letter. `[a-f0-9]` |
| 5301 |
// also matches every decimal digit, so a bare `{10,}` erased |
| 5302 |
// every 10+ digit INTEGER anywhere in the document — including |
| 5303 |
// visible body text. A page whose desktop and mobile HTML |
| 5304 |
// differed only by a per-device numeric id (an AdSense slot, an |
| 5305 |
// A/B bucket, an analytics property) then compared as identical, |
| 5306 |
// and the check told the user it was safe to switch off the very |
| 5307 |
// setting keeping that output correct — the one direction this |
| 5308 |
// function must never fail in. Decimal-only runs are left to the |
| 5309 |
// bounded epoch rule below, which is deliberately narrower. |
| 5310 |
// |
| 5311 |
// Known, accepted (QA R2): a token whose letters all fall in a-f |
| 5312 |
// reads as a digest, so a per-device `ABC1234567890` strips even |
| 5313 |
// though it is an id, not a hash. Deliberately left open — the |
| 5314 |
// alternatives all cost more than the bug: |
| 5315 |
// |
| 5316 |
// Token shape (lowercase-only, case-uniformity, a trailing |
| 5317 |
// letter) cannot separate it. `ABC1234567890` and |
| 5318 |
// `ABCDEF012345` — an uppercase digest this rule SHOULD strip — |
| 5319 |
// are both all-hex, uniformly cased, letters-then-digits. |
| 5320 |
// Each variant fixed the id only by sparing the digest. |
| 5321 |
// |
| 5322 |
// Letter density does separate them (23% letters vs 50%), but |
| 5323 |
// measured over 2000 md5/sha1/sha256 samples, requiring letters |
| 5324 |
// spread through the token leaves 21-67% of REAL digests |
| 5325 |
// unmatched depending on the window. Digest noise is most of |
| 5326 |
// what this function exists to remove, so that trade guts it. |
| 5327 |
// |
| 5328 |
// Context (protecting data-* attribute values from this rule) |
| 5329 |
// works for ids and still strips digests in URLs, classes and |
| 5330 |
// query strings — but regresses a CHANGING digest inside a |
| 5331 |
// non-nonce data-* attribute, and needs a two-pass |
| 5332 |
// hold/restore. Viable if R2 is ever worth pressing; its |
| 5333 |
// failure at least errs toward "differ". |
| 5334 |
// |
| 5335 |
// An A-F-only prefix on a per-device id is rare, and the earlier |
| 5336 |
// nonce rules already claim the data-nonce/_wpnonce shapes. |
| 5337 |
'/\b(?=[a-f0-9]{10,}\b)[0-9]*[a-f][a-f0-9]*\b/i', |
| 5338 |
// wp-generated unique ids (e.g. wp-block ids, aria ids). |
| 5339 |
'/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i', |
| 5340 |
// ISO-ish timestamps + epoch-looking numbers in query strings. |
| 5341 |
'/\?ver=[0-9.]+/', |
| 5342 |
'/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/', |
| 5343 |
// Our own signature's generation stamp. The two fetches are |
| 5344 |
// sequential and each writes its own entry, so this differs on |
| 5345 |
// essentially every comparison — and it is space-separated, so |
| 5346 |
// the ISO rule above (which requires a literal `T`) never |
| 5347 |
// touches it. Without this the probe reports "differ" for every |
| 5348 |
// site and the "safe to turn Separate Mobile Cache off" verdict |
| 5349 |
// can never appear. |
| 5350 |
'/generated [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} UTC/', |
| 5351 |
// Raw epoch seconds. The two fetches are sequential, so any |
| 5352 |
// template printing time() guaranteed a mismatch. |
| 5353 |
// |
| 5354 |
// This is the ONLY rule that may strip a decimal-only run, so |
| 5355 |
// its bound is load-bearing rather than decorative — every digit |
| 5356 |
// it gives away is a class of per-device id it silently erases. |
| 5357 |
// `1[0-9]{9}` was too loose: it claimed the whole |
| 5358 |
// 1000000000-1999999999 range (2001-2033) to cover timestamps |
| 5359 |
// nobody serves, and took every 10-digit AdSense slot, order id |
| 5360 |
// and SKU beginning with 1 along with it — reproducing the exact |
| 5361 |
// false-"identical" verdict the hex rule above was tightened to |
| 5362 |
// stop. `1[6-9]` covers 2020-2033, which is the only span a live |
| 5363 |
// site can actually print, and collides with roughly a tenth as |
| 5364 |
// many ids. |
| 5365 |
// |
| 5366 |
// Not airtight — an id beginning 16-19 still collides. Closing |
| 5367 |
// that properly means scoping this to places a timestamp really |
| 5368 |
// appears (an attribute value, a query parameter, a JSON value) |
| 5369 |
// rather than bare body text; the bound is the cheap 90% of it. |
| 5370 |
'/\b1[6-9][0-9]{8}\b/', |
| 5371 |
); |
| 5372 |
$html = (string) preg_replace( $patterns, 'X', $html ); |
| 5373 |
// Collapse all whitespace so trivial formatting differences don't count. |
| 5374 |
return trim( (string) preg_replace( '/\s+/', ' ', $html ) ); |
| 5375 |
} |
| 5376 |
|
| 5377 |
public static function ensure_hits_log_file(): bool { |
| 5378 |
// TWO writers append to this log, and an earlier fix conflated them: |
| 5379 |
// |
| 5380 |
// 1. nginx, via the server-level `access_log` directive in |
| 5381 |
// nginx_snippet() — a DIFFERENT uid, which is why the file needs |
| 5382 |
// to be world-writable there. |
| 5383 |
// 2. the PHP drop-in (advanced-cache.php), on EVERY server. A hit it |
| 5384 |
// serves bypasses WordPress entirely, so it can't call |
| 5385 |
// Hit_Counter::record_hit() — appending here is the only way that |
| 5386 |
// hit is ever counted. |
| 5387 |
// |
| 5388 |
// The nginx-only early return that used to sit at the top of this |
| 5389 |
// method was fixing something real: chmod() on a file PHP doesn't own |
| 5390 |
// raises "Operation not permitted", and off nginx that chmod buys |
| 5391 |
// nothing. But it took directory creation with it, so on LiteSpeed |
| 5392 |
// (which always serves via the drop-in), on Apache without mod_headers, |
| 5393 |
// and anywhere mobile_separate forces the drop-in path, writer 2 was |
| 5394 |
// appending to a file whose parent directory did not exist. The append |
| 5395 |
// is @-suppressed and documented as non-fatal, so every one of those |
| 5396 |
// hits vanished and the dashboard ratio sat at 0% forever. |
| 5397 |
// |
| 5398 |
// So: create the dir + file everywhere, and keep only the chmod gated |
| 5399 |
// to nginx. |
| 5400 |
$dir = self::hits_log_dir(); |
| 5401 |
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { |
| 5402 |
return false; |
| 5403 |
} |
| 5404 |
|
| 5405 |
$is_nginx = ( Server::NGINX === Server::type() ); |
| 5406 |
|
| 5407 |
if ( $is_nginx ) { |
| 5408 |
// Ensure the dir is traversable + writable by a different-uid nginx. |
| 5409 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- nginx (a separate uid in multi-container setups) must be able to create/append the log; WP_Filesystem layers ownership overrides that defeat that intent. |
| 5410 |
@chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails. |
| 5411 |
} |
| 5412 |
|
| 5413 |
$path = self::hits_log_path(); |
| 5414 |
if ( ! file_exists( $path ) ) { |
| 5415 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem. |
| 5416 |
@touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check. |
| 5417 |
} |
| 5418 |
|
| 5419 |
if ( $is_nginx ) { |
| 5420 |
// World-writable so a different-uid nginx can append HIT lines. |
| 5421 |
// Off nginx the drop-in appends as the same uid that owns the file, |
| 5422 |
// so this is unnecessary — and would emit the "Operation not |
| 5423 |
// permitted" warnings the old early return was added to silence. |
| 5424 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock. |
| 5425 |
@chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort. |
| 5426 |
} |
| 5427 |
|
| 5428 |
return file_exists( $path ); |
| 5429 |
} |
| 5430 |
|
| 5431 |
public static function nginx_snippet(): ?string { |
| 5432 |
if ( Server::NGINX !== Server::type() ) { |
| 5433 |
return null; |
| 5434 |
} |
| 5435 |
$rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' ); |
| 5436 |
$rel = rtrim( $rel, '/' ); |
| 5437 |
|
| 5438 |
// WP-Rocket-canonical pattern: every condition lives at |
| 5439 |
// SERVER level (outside any location block). Each one appends |
| 5440 |
// a tag to $xspeed_no_cache; the final check is a single |
| 5441 |
// string-equality against the unmodified default "no-cache". |
| 5442 |
// Only when ALL conditions pass does the rewrite fire, |
| 5443 |
// jumping the request to the static file's URL. nginx then |
| 5444 |
// restarts location matching against the new path, where |
| 5445 |
// regular static-file serving takes over. |
| 5446 |
// |
| 5447 |
// Why server-level + a single rewrite (instead of try_files |
| 5448 |
// inside `location /`): nginx's well-documented "if is evil" |
| 5449 |
// quirk silently disables `try_files`'s last fallback when |
| 5450 |
// any `if` in the same location is true. Moving the `if`s |
| 5451 |
// outside any location dodges the trap completely, because |
| 5452 |
// server-level rewrite is the documented stable path. |
| 5453 |
// |
| 5454 |
// `last` (not `break`) restarts location matching — required |
| 5455 |
// so the rewritten static-file URI gets served via the normal |
| 5456 |
// static-file location, not re-matched against `location /` |
| 5457 |
// where our own rewrite would loop. |
| 5458 |
// |
| 5459 |
// The cache existence check is the LAST condition in the |
| 5460 |
// chain so when the file isn't cached, $xspeed_no_cache |
| 5461 |
// gets a "-nofile" tag and the rewrite is skipped — the |
| 5462 |
// request falls through to whatever `location /` the user |
| 5463 |
// already had (typically `try_files $uri $uri/ /index.php?$args;`). |
| 5464 |
// Absolute path to the hit-log file from the nginx process's |
| 5465 |
// filesystem view. Nginx's `access_log buffer=N flush=Ns` form |
| 5466 |
// requires a literal path — `$document_root` variables are |
| 5467 |
// rejected — so PHP computes it. Lives under uploads/ (NOT the |
| 5468 |
// cache dir): a cache purge or uninstall deletes the cache dir, |
| 5469 |
// which would orphan this directive's parent directory and make |
| 5470 |
// `nginx -t` fail [emerg] for EVERY vhost on the host |
| 5471 |
// (FBS-82478). uploads/ survives both, so the directive can |
| 5472 |
// never take nginx down. Works on every topology where the nginx |
| 5473 |
// process shares a filesystem with PHP (container or host). |
| 5474 |
$hits_abs = self::hits_log_path(); |
| 5475 |
|
| 5476 |
$lines = array(); |
| 5477 |
$lines[] = '# xSpeed static cache — paste at server level, above location / { }.'; |
| 5478 |
// Cache host must match the on-disk dir PHP writes: store_static() / |
| 5479 |
// static_host() take HTTP_HOST and strip every char outside |
| 5480 |
// [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits |
| 5481 |
// (localhost:8192 → localhost8192). nginx's own $host can't reproduce |
| 5482 |
// that: $host has the port already stripped ENTIRELY (→ localhost), so |
| 5483 |
// the -f check looks for localhost/... while PHP wrote localhost8192/... |
| 5484 |
// and the rewrite never fires on a non-standard port. Derive |
| 5485 |
// $xspeed_host from $http_host (which keeps the port) and drop just the |
| 5486 |
// colon, so it equals the PHP dir on every port. On standard ports |
| 5487 |
// $http_host has no colon, so $xspeed_host == $host == the bare domain. |
| 5488 |
$lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com) |
| 5489 |
$lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host()) |
| 5490 |
$lines[] = 'set $xspeed_no_cache "no-cache";'; |
| 5491 |
$lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }'; |
| 5492 |
$lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }'; |
| 5493 |
// Cookie + user-agent exclusions, generated from the user's actual |
| 5494 |
// settings rather than a hardcoded list. Before this, the rule |
| 5495 |
// tested three fixed cookie names and no user agent at all, so |
| 5496 |
// every excluded_cookies / bypass_user_agents entry applied only |
| 5497 |
// while a page was cold — on a warm page nginx served the shared |
| 5498 |
// anonymous copy to carts, members and bypassed bots alike. The |
| 5499 |
// three historical names survive as a floor inside cookie_rule(). |
| 5500 |
// `~*` is case-insensitive, matching PHP's stripos()/glob checks. |
| 5501 |
$cache_opts = Settings_Manager::get( 'cache' ); |
| 5502 |
$cookie_rule = Server_Rules::cookie_rule( |
| 5503 |
is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array() |
| 5504 |
); |
| 5505 |
$lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }'; |
| 5506 |
|
| 5507 |
$ua_rule = Server_Rules::user_agent_rule( |
| 5508 |
is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array() |
| 5509 |
); |
| 5510 |
// Emitted only when the list is non-empty — an empty alternation |
| 5511 |
// would compile to `(...)` matching every request and disable the |
| 5512 |
// fast path entirely. |
| 5513 |
if ( '' !== $ua_rule['regex'] ) { |
| 5514 |
$lines[] = 'if ($http_user_agent ~* "(' . $ua_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-ua"; }'; |
| 5515 |
} |
| 5516 |
|
| 5517 |
// URL exclusions. Without this an excluded URL was only excluded |
| 5518 |
// while its page was cold: PHP won't write a static file for one, so |
| 5519 |
// there is usually nothing to serve — but a page cached BEFORE the |
| 5520 |
// rule was added still has its file on disk, and nginx serves it |
| 5521 |
// without ever asking PHP. The exclusion then does nothing until the |
| 5522 |
// next purge. (#169) |
| 5523 |
// |
| 5524 |
// Matched against $uri, not $request_uri: $uri is the decoded path |
| 5525 |
// without the query string, which is what Cache::should_cache() |
| 5526 |
// tests. Using $request_uri would make `/cart` fail to match |
| 5527 |
// `/cart?x=1` inconsistently with PHP. Same empty-regex guard as the |
| 5528 |
// UA rule above — an empty alternation matches everything. |
| 5529 |
$url_rule = Server_Rules::url_rule( |
| 5530 |
is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array() |
| 5531 |
); |
| 5532 |
if ( '' !== $url_rule['regex'] ) { |
| 5533 |
$lines[] = 'if ($uri ~* "(' . $url_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-url"; }'; |
| 5534 |
} |
| 5535 |
$lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }'; |
| 5536 |
// Neither `add_header` nor `access_log` is allowed inside an `if{}` |
| 5537 |
// at server level (nginx rejects with "directive is not allowed |
| 5538 |
// here"). The logging therefore lives in a `location` block that |
| 5539 |
// matches the rewritten URI after `rewrite … last;` restarts |
| 5540 |
// location matching. Every HIT lands there exactly once, every |
| 5541 |
// MISS / PHP-served request never matches it. |
| 5542 |
$lines[] = 'if ($xspeed_no_cache = "no-cache") {'; |
| 5543 |
$lines[] = ' rewrite ^ ' . $rel . '/$xspeed_host$uri/index.html last;'; |
| 5544 |
$lines[] = '}'; |
| 5545 |
$lines[] = ''; |
| 5546 |
$lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.'; |
| 5547 |
$lines[] = 'location ^~ ' . $rel . '/ {'; |
| 5548 |
$lines[] = ' internal;'; |
| 5549 |
// LITERAL log path (not `set $var; access_log $var`). The variable form |
| 5550 |
// makes nginx open the log lazily per-request and SILENTLY drop the |
| 5551 |
// line if the open fails — so on a working host hits were served |
| 5552 |
// (X-XSpeed-Cache fires regardless) but nothing was ever written and |
| 5553 |
// the hit ratio sat at 0%. A literal path makes nginx open the file at |
| 5554 |
// config load and actually log every hit. |
| 5555 |
// |
| 5556 |
// Deleting the log FILE is still safe with a literal path: nginx |
| 5557 |
// recreates it on the next write/reload and `nginx -t` stays green |
| 5558 |
// (verified). The only thing that [emerg]s `nginx -t` is a missing |
| 5559 |
// parent DIRECTORY — and the log lives under uploads/xspeed/, which |
| 5560 |
// survives cache purge + uninstall, and which ensure_hits_log_file() |
| 5561 |
// (run on every admin_init via auto_heal) recreates if it ever goes |
| 5562 |
// missing. So: hits are logged, and a user deleting the log can't take |
| 5563 |
// nginx down. |
| 5564 |
$lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;'; |
| 5565 |
$lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;'; |
| 5566 |
$lines[] = '}'; |
| 5567 |
return implode( "\n", $lines ); |
| 5568 |
} |
| 5569 |
|
| 5570 |
/** |
| 5571 |
* Aggregate every enabled module's nginx_directives() into one |
| 5572 |
* pasteable server-block snippet. Replaces the per-module "paste |
| 5573 |
* this snippet" notices with a single consolidated paste — every |
| 5574 |
* future feature toggle just regenerates this output. |
| 5575 |
* |
| 5576 |
* Returns null on non-nginx hosts (nothing to paste). |
| 5577 |
* |
| 5578 |
* Sections render in module-registration order so the layout stays |
| 5579 |
* predictable; each module gets a comment header `# <slug>`. |
| 5580 |
*/ |
| 5581 |
public static function full_nginx_server_block(): ?string { |
| 5582 |
if ( Server::NGINX !== Server::type() ) { |
| 5583 |
return null; |
| 5584 |
} |
| 5585 |
|
| 5586 |
$blocks = array(); |
| 5587 |
foreach ( Module_Registry::all() as $module ) { |
| 5588 |
$directives = $module->nginx_directives(); |
| 5589 |
if ( ! is_string( $directives ) || '' === trim( $directives ) ) { |
| 5590 |
continue; |
| 5591 |
} |
| 5592 |
$blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives ); |
| 5593 |
} |
| 5594 |
|
| 5595 |
if ( empty( $blocks ) ) { |
| 5596 |
return null; |
| 5597 |
} |
| 5598 |
|
| 5599 |
$header = "# xSpeed unified nginx config — paste into `server { }`, above `location / { }`; re-paste after toggling features.\n"; |
| 5600 |
|
| 5601 |
return $header . "\n" . implode( "\n\n", $blocks ) . "\n"; |
| 5602 |
} |
| 5603 |
|
| 5604 |
/** |
| 5605 |
* Tell LiteSpeed's LSCache module to stand down on the cache-miss |
| 5606 |
* render path. |
| 5607 |
* |
| 5608 |
* History: this method used to emit X-LiteSpeed-Cache-Control: |
| 5609 |
* public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's |
| 5610 |
* LSCache store. That delegation backfired — once LSCache cached a |
| 5611 |
* page it served every subsequent request from its OWN store and |
| 5612 |
* intercepted the request before our site-root .htaccess static |
| 5613 |
* rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache |
| 5614 |
* header, our static-cache tree never served, the HIT log never |
| 5615 |
* written (hit ratio frozen at 0%), and the Health probe reporting a |
| 5616 |
* false "cache running on PHP fallback" because it never saw an |
| 5617 |
* xSpeed-served response. |
| 5618 |
* |
| 5619 |
* xSpeed now owns the cache on LiteSpeed exactly as it does on Apache: |
| 5620 |
* our `.htaccess` mod_rewrite block serves hits straight from the |
| 5621 |
* static-cache tree (with the X-XSpeed-Cache header + access-log HIT |
| 5622 |
* accounting), and PHP/the drop-in is the fallback. To guarantee |
| 5623 |
* LSCache doesn't shadow that with its own copy — some LiteSpeed |
| 5624 |
* configs cache by default — we send an explicit `no-cache` control so |
| 5625 |
* the server defers to our rewrite. Skipped when the LiteSpeed Cache |
| 5626 |
* plugin is active (it owns its own header policy; our Conflict |
| 5627 |
* registry handles that coexistence separately). |
| 5628 |
*/ |
| 5629 |
public static function maybe_emit_lscache_headers(): void { |
| 5630 |
if ( headers_sent() ) { |
| 5631 |
return; |
| 5632 |
} |
| 5633 |
if ( Server::LITESPEED !== Server::type() ) { |
| 5634 |
return; |
| 5635 |
} |
| 5636 |
// is_plugin_active() lives in wp-admin/includes/plugin.php which |
| 5637 |
// isn't auto-loaded on front-end requests. Use the option layer |
| 5638 |
// directly to avoid pulling in admin code from a render path. |
| 5639 |
$active = (array) get_option( 'active_plugins', array() ); |
| 5640 |
if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) { |
| 5641 |
return; |
| 5642 |
} |
| 5643 |
|
| 5644 |
// Explicitly opt this response OUT of LSCache so the server can't |
| 5645 |
// shadow our static-rewrite cache with its own internal copy. |
| 5646 |
header( 'X-LiteSpeed-Cache-Control: no-cache' ); |
| 5647 |
} |
| 5648 |
|
| 5649 |
/** |
| 5650 |
* Restore the drop-in + WP_CACHE constant for a site that had caching |
| 5651 |
* ON before this activation — and ONLY for such a site. |
| 5652 |
* |
| 5653 |
* WordPress runs an upgrade as deactivate → wipe plugin files → |
| 5654 |
* install → activate. The wipe takes advanced-cache.php with it, so |
| 5655 |
* without this the site serves 100% uncached from the moment the |
| 5656 |
* update finishes until the next authenticated wp-admin page load |
| 5657 |
* (auto_heal() is on admin_init). On a site whose admin logs in |
| 5658 |
* rarely that window is hours or days of silent cache loss, while |
| 5659 |
* the dashboard still reports cache_enabled = true. (FBS field |
| 5660 |
* report against 1.1.2 / Pro 1.0.5.) |
| 5661 |
* |
| 5662 |
* The `cache_enabled` guard is the whole contract: a FRESH install |
| 5663 |
* has the option unset, so activation writes nothing and the user |
| 5664 |
* still opts in explicitly through Cache::toggle() via the |
| 5665 |
* /cache/toggle REST endpoint. We only ever put back state the user |
| 5666 |
* already chose — repair, never a new install path. This is what |
| 5667 |
* keeps us on the right side of the "don't create drop-ins the user |
| 5668 |
* didn't ask for" guideline while matching what WP Rocket, W3 Total |
| 5669 |
* Cache and WP Super Cache all do on activation. |
| 5670 |
* |
| 5671 |
* @return bool True when a restore was performed. |
| 5672 |
*/ |
| 5673 |
public static function restore_dropin_if_enabled(): bool { |
| 5674 |
if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) { |
| 5675 |
return false; |
| 5676 |
} |
| 5677 |
|
| 5678 |
// The user's saved choice. Absent/false on a fresh install => no |
| 5679 |
// drop-in is written and nothing touches wp-config.php. |
| 5680 |
$opts = get_option( 'xspeed_options', array() ); |
| 5681 |
if ( empty( $opts['cache_enabled'] ) ) { |
| 5682 |
return false; |
| 5683 |
} |
| 5684 |
|
| 5685 |
$state = self::toggle( true ); |
| 5686 |
// A refusal reports whether the cache SERVES, which on this path can |
| 5687 |
// be true for reasons that have nothing to do with this call — so a |
| 5688 |
// refusal would otherwise log "drop-in restored" for a restore that |
| 5689 |
// was declined. Restored means the transaction went through. |
| 5690 |
$restored = empty( $state['blocked'] ) && ! empty( $state['enabled'] ); |
| 5691 |
|
| 5692 |
if ( $restored ) { |
| 5693 |
Activity_Log::record( |
| 5694 |
'cache_dropin_restored', |
| 5695 |
'Cache drop-in restored after a plugin update — caching was already enabled.', |
| 5696 |
Activity_Log::SUCCESS |
| 5697 |
); |
| 5698 |
} |
| 5699 |
|
| 5700 |
return $restored; |
| 5701 |
} |
| 5702 |
|
| 5703 |
/** |
| 5704 |
* Reconcile drop-in + WP_CACHE + rewrite block with the user's |
| 5705 |
* saved choice. Runs on admin_init. Cheap when nothing's wrong |
| 5706 |
* (one option read + a handful of file_exists / defined checks); |
| 5707 |
* writes only when state has drifted (typical cause: plugin |
| 5708 |
* upgrade wiped the drop-in, foreign plugin removed our WP_CACHE |
| 5709 |
* define, or someone hand-edited .htaccess). |
| 5710 |
* |
| 5711 |
* Skipped during the WP plugin updater run so we don't race |
| 5712 |
* the upgrader's own filesystem operations. |
| 5713 |
*/ |
| 5714 |
public static function auto_heal(): void { |
| 5715 |
if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) { |
| 5716 |
return; |
| 5717 |
} |
| 5718 |
if ( wp_doing_ajax() || wp_doing_cron() ) { |
| 5719 |
return; |
| 5720 |
} |
| 5721 |
|
| 5722 |
$opts = get_option( 'xspeed_options', array() ); |
| 5723 |
if ( empty( $opts['cache_enabled'] ) ) { |
| 5724 |
return; |
| 5725 |
} |
| 5726 |
|
| 5727 |
$state = self::toggle( true ); |
| 5728 |
// A refusal means something else now owns the page-cache field, or |
| 5729 |
// the write could not be verified. Either way this is not the moment |
| 5730 |
// to go on maintaining our rewrite block and log file. |
| 5731 |
if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) { |
| 5732 |
return; |
| 5733 |
} |
| 5734 |
|
| 5735 |
// Rewrite block goes last. It's what turns the static-cache |
| 5736 |
// tree into a PHP-bypass — every cache hit served by the web |
| 5737 |
// server directly. Without it we still cache, just at drop-in |
| 5738 |
// speed (~85ms TTFB) instead of static-file speed (~25-40ms). |
| 5739 |
// |
| 5740 |
// Reconcile against mobile_separate: the rewrite is device-blind, so |
| 5741 |
// it must be ABSENT when mobile_separate is on and PRESENT otherwise. |
| 5742 |
// auto_heal() runs periodically, so it also repairs a rewrite that |
| 5743 |
// was left installed before mobile_separate was switched on. |
| 5744 |
if ( self::static_rewrite_allowed() ) { |
| 5745 |
if ( ! self::rewrite_installed() ) { |
| 5746 |
self::install_rewrite(); |
| 5747 |
} |
| 5748 |
} elseif ( self::rewrite_installed() ) { |
| 5749 |
self::remove_rewrite(); |
| 5750 |
} |
| 5751 |
|
| 5752 |
// HITs log file — nginx writes one line per HIT served directly |
| 5753 |
// (see nginx_snippet()), Cache::get_stats() drains the file via |
| 5754 |
// Hit_Counter::collect_nginx_log_hits(). If the file vanishes |
| 5755 |
// (plugin upgrade wiped wp-content/cache/), nginx errors silently |
| 5756 |
// on the access_log directive and the counter stays at 0. |
| 5757 |
self::ensure_hits_log_file(); |
| 5758 |
} |
| 5759 |
|
| 5760 |
/** |
| 5761 |
* Keep the generic bypass cookie in sync with PHP's caching verdict. |
| 5762 |
* |
| 5763 |
* The server config tests exactly one cookie name (Server_Rules:: |
| 5764 |
* BYPASS_COOKIE) forever, and PHP decides what that name means. Adding |
| 5765 |
* a new excluded cookie therefore needs no config change and no nginx |
| 5766 |
* reload — the reason this exists. |
| 5767 |
* |
| 5768 |
* Session cookie (expiry 0) so it dies with the browser session, and |
| 5769 |
* deliberately NOT HttpOnly-sensitive: it carries no identity, only the |
| 5770 |
* boolean "don't serve this visitor a shared cached page". |
| 5771 |
* |
| 5772 |
* Honest limit: this can only ever help a visitor PHP has already seen |
| 5773 |
* once. A bot's first request to a warm page never reaches PHP, which |
| 5774 |
* is why user-agent rules are still written into the server config |
| 5775 |
* rather than relying on this. |
| 5776 |
* |
| 5777 |
* @param bool $bypass Whether this visitor must skip the cache. |
| 5778 |
*/ |
| 5779 |
private static function sync_bypass_cookie( bool $bypass ): void { |
| 5780 |
if ( headers_sent() ) { |
| 5781 |
return; |
| 5782 |
} |
| 5783 |
|
| 5784 |
$name = Server_Rules::BYPASS_COOKIE; |
| 5785 |
$has = isset( $_COOKIE[ $name ] ); |
| 5786 |
|
| 5787 |
// Only touch the header when the state actually changes — a |
| 5788 |
// Set-Cookie on every request would make the response uncacheable |
| 5789 |
// for intermediary caches and add noise to every hit. |
| 5790 |
if ( $bypass === $has ) { |
| 5791 |
return; |
| 5792 |
} |
| 5793 |
|
| 5794 |
$path = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/'; |
| 5795 |
$domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : ''; |
| 5796 |
|
| 5797 |
if ( $bypass ) { |
| 5798 |
setcookie( $name, '1', 0, $path, (string) $domain, is_ssl(), false ); |
| 5799 |
$_COOKIE[ $name ] = '1'; |
| 5800 |
} else { |
| 5801 |
setcookie( $name, '', time() - 3600, $path, (string) $domain, is_ssl(), false ); |
| 5802 |
unset( $_COOKIE[ $name ] ); |
| 5803 |
} |
| 5804 |
} |
| 5805 |
|
| 5806 |
/** |
| 5807 |
* Build the .htaccess rules that map cacheable requests to the |
| 5808 |
* static-cache tree. Conditions are deliberately strict: GET only, |
| 5809 |
* empty query string, no session/comment-author/post-password |
| 5810 |
* cookie, and the static file must exist on disk. Anything that |
| 5811 |
* fails one of these falls through to PHP and the drop-in / full |
| 5812 |
* WordPress path. |
| 5813 |
* |
| 5814 |
* @return string[] Lines for insert_with_markers(). |
| 5815 |
*/ |
| 5816 |
public static function rewrite_block_lines(): array { |
| 5817 |
// Path relative to ABSPATH so the rule lives in the site-root |
| 5818 |
// .htaccess regardless of where wp-content sits. WP_CONTENT_DIR |
| 5819 |
// can be moved, so we compute the document-root-relative form |
| 5820 |
// at install time and bake it into the rule. |
| 5821 |
$rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ); |
| 5822 |
$rel = '/' . ltrim( $rel, '/' ); |
| 5823 |
$rel = rtrim( $rel, '/' ); |
| 5824 |
|
| 5825 |
// Cookie + user-agent exclusions generated from the live settings. |
| 5826 |
// See the matching block in nginx_snippet() — same generator, same |
| 5827 |
// floor, so both servers enforce an identical policy. Apache reads |
| 5828 |
// .htaccess on every request and we already self-heal this file, so |
| 5829 |
// Apache/LiteSpeed users get the fix on upgrade with no action. |
| 5830 |
$cache_opts = Settings_Manager::get( 'cache' ); |
| 5831 |
$cookie_rule = Server_Rules::cookie_rule( |
| 5832 |
is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array() |
| 5833 |
); |
| 5834 |
$ua_rule = Server_Rules::user_agent_rule( |
| 5835 |
is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array() |
| 5836 |
); |
| 5837 |
|
| 5838 |
$lines = array( |
| 5839 |
'<IfModule mod_rewrite.c>', |
| 5840 |
' RewriteEngine On', |
| 5841 |
' RewriteCond %{REQUEST_METHOD} ^GET$', |
| 5842 |
' RewriteCond %{QUERY_STRING} ^$', |
| 5843 |
' RewriteCond %{HTTP_COOKIE} !(' . $cookie_rule['regex'] . ') [NC]', |
| 5844 |
); |
| 5845 |
|
| 5846 |
// Only emit the UA condition when there's something to match — |
| 5847 |
// `!()` would negate an always-true empty match and refuse every |
| 5848 |
// request, silently disabling the static path. |
| 5849 |
if ( '' !== $ua_rule['regex'] ) { |
| 5850 |
// Quoted, because RewriteCond is whitespace-delimited and real |
| 5851 |
// user-agent fragments contain spaces ("Mozilla/5.0 (compatible"). |
| 5852 |
// Unquoted, a space adds an argument and Apache answers every |
| 5853 |
// request with a 500 — and because .htaccess is parsed per |
| 5854 |
// request, `httpd -t` still reports Syntax OK. Server_Rules has |
| 5855 |
// already excluded quotes and backslashes from the alternation, |
| 5856 |
// so the closing quote here cannot be escaped away. |
| 5857 |
$lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]'; |
| 5858 |
} |
| 5859 |
|
| 5860 |
return array_merge( |
| 5861 |
$lines, |
| 5862 |
array( |
| 5863 |
// Capture REQUEST_URI without its trailing slash into %1. |
| 5864 |
// store_static() writes `{host}{uri-without-trailing-slash}/index.html`, |
| 5865 |
// so this normalization lets `/blog/` and `/blog` both hit |
| 5866 |
// the same cache file without producing the double-slash |
| 5867 |
// path that would skip the -f check below. |
| 5868 |
' RewriteCond %{REQUEST_URI} ^(.*?)/?$', |
| 5869 |
' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f', |
| 5870 |
// Pattern is `^`, NOT `.`. The per-directory rewrite engine |
| 5871 |
// strips the leading slash before matching, so the HOMEPAGE |
| 5872 |
// request `/` arrives here as an EMPTY path. `.` requires at |
| 5873 |
// least one character and therefore never matches the homepage |
| 5874 |
// — on LiteSpeed (which honors this strictly) the front page |
| 5875 |
// fell through to PHP while every inner page rewrote fine. |
| 5876 |
// `^` matches the empty string AND any non-empty path, so it |
| 5877 |
// covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed |
| 5878 |
// 1.8: `.` → homepage served by PHP drop-in; `^` → served |
| 5879 |
// directly from the static file.) |
| 5880 |
' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]', |
| 5881 |
'</IfModule>', |
| 5882 |
// Mark the statically-served response as a cache HIT. |
| 5883 |
// |
| 5884 |
// A file served by the rewrite above bypasses PHP entirely, so |
| 5885 |
// this directive is the ONLY thing that can identify it as |
| 5886 |
// cached — both for the user reading response headers and for |
| 5887 |
// Hit_Counter, which reconciles static hits from the access |
| 5888 |
// log. Without it the cache works perfectly and reports a 0% |
| 5889 |
// hit ratio, which reads as "the plugin is broken". (Field |
| 5890 |
// report against 1.1.2: homepage served byte-identical from |
| 5891 |
// the static tree, no X-XSpeed-Cache header on any response.) |
| 5892 |
// |
| 5893 |
// `always` so the header is set on the 200 from the rewritten |
| 5894 |
// file, not only on the successful-response table. The |
| 5895 |
// <IfModule> guard keeps a server without mod_headers from |
| 5896 |
// 500ing on an unknown directive — on such a host the header |
| 5897 |
// is silently dropped, which is exactly why |
| 5898 |
// static_rewrite_allowed() refuses the static path there and |
| 5899 |
// routes hits through the drop-in instead. |
| 5900 |
'<IfModule mod_headers.c>', |
| 5901 |
' <FilesMatch "\\.html$">', |
| 5902 |
' Header always set X-XSpeed-Cache "HIT (static)"', |
| 5903 |
' </FilesMatch>', |
| 5904 |
'</IfModule>', |
| 5905 |
) |
| 5906 |
); |
| 5907 |
} |
| 5908 |
|
| 5909 |
/** |
| 5910 |
* Active probe that confirms the web-server static-rewrite path is |
| 5911 |
* actually serving cached files. Writes a probe file with a random |
| 5912 |
* nonce, fetches it over HTTP at its public URL, and checks whether |
| 5913 |
* the response was served directly by the web server (Last-Modified |
| 5914 |
* + ETag headers + no X-Powered-By: PHP). |
| 5915 |
* |
| 5916 |
* Server-agnostic: same probe works for nginx (snippet pasted) and |
| 5917 |
* Apache / LiteSpeed (.htaccess block installed). If the rewrite |
| 5918 |
* isn't engaged, the request falls through to WordPress and PHP |
| 5919 |
* adds its own headers, which the probe detects and reports. |
| 5920 |
* |
| 5921 |
* Throttled via a 5-minute transient — we never want this running |
| 5922 |
* on every Health card paint. |
| 5923 |
* |
| 5924 |
* @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int} |
| 5925 |
*/ |
| 5926 |
/** |
| 5927 |
* @param bool $allow_probe When false (the default), return ONLY a cached |
| 5928 |
* result and never make an HTTP request — so admin page loads are never |
| 5929 |
* blocked by the loopback probe. The actual HTTP probe only runs when a |
| 5930 |
* caller explicitly opts in (the Health tab / cron). Previously this ran |
| 5931 |
* synchronously on every dashboard bootstrap, so a slow/timing-out |
| 5932 |
* loopback request added up to `timeout` seconds to admin page loads on |
| 5933 |
* hosts that block self-requests. (FBS-82142) |
| 5934 |
*/ |
| 5935 |
/** |
| 5936 |
* Discard the cached probe result and run a fresh one. |
| 5937 |
* |
| 5938 |
* Without this there was no way to re-check: the result sat in a transient |
| 5939 |
* for five minutes and nothing ever deleted it, so a user who fixed their |
| 5940 |
* nginx config kept seeing "nginx detected — configure for max cache speed" |
| 5941 |
* with no means of confirming the fix worked. (FBS-84012) |
| 5942 |
*/ |
| 5943 |
public static function recheck_static_rewrite(): array { |
| 5944 |
delete_transient( 'xspeed_rewrite_probe' ); |
| 5945 |
return self::probe_static_rewrite( true ); |
| 5946 |
} |
| 5947 |
|
| 5948 |
public static function probe_static_rewrite( bool $allow_probe = false ): array { |
| 5949 |
$cached = get_transient( 'xspeed_rewrite_probe' ); |
| 5950 |
if ( is_array( $cached ) ) { |
| 5951 |
return $cached; |
| 5952 |
} |
| 5953 |
// No cached result yet and the caller doesn't want to pay for a live |
| 5954 |
// HTTP probe (e.g. the admin bootstrap): report "pending" without |
| 5955 |
// blocking. The Health tab will run the real probe on demand. |
| 5956 |
if ( ! $allow_probe ) { |
| 5957 |
return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true ); |
| 5958 |
} |
| 5959 |
|
| 5960 |
$home = home_url( '/' ); |
| 5961 |
$host = (string) wp_parse_url( $home, PHP_URL_HOST ); |
| 5962 |
if ( '' === $host ) { |
| 5963 |
$result = array( 'active' => false, 'reason' => 'home_url has no host' ); |
| 5964 |
set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 5965 |
return $result; |
| 5966 |
} |
| 5967 |
|
| 5968 |
// Use a randomised path AND nonce so a stale CDN cache entry |
| 5969 |
// from a prior probe can never make a broken install look |
| 5970 |
// healthy. Path is namespaced under __xspeed_probe__ so the |
| 5971 |
// directory listing stays obvious if cleanup misfires. |
| 5972 |
$slug = wp_generate_password( 12, false, false ); |
| 5973 |
$nonce = wp_generate_password( 24, false, false ); |
| 5974 |
$probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug; |
| 5975 |
$probe_file = $probe_dir . '/index.html'; |
| 5976 |
$probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/'; |
| 5977 |
|
| 5978 |
if ( ! file_exists( $probe_dir ) ) { |
| 5979 |
wp_mkdir_p( $probe_dir ); |
| 5980 |
} |
| 5981 |
if ( ! is_dir( $probe_dir ) ) { |
| 5982 |
$result = array( 'active' => false, 'reason' => 'cannot create probe dir' ); |
| 5983 |
set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 5984 |
return $result; |
| 5985 |
} |
| 5986 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir. |
| 5987 |
file_put_contents( $probe_file, $nonce, LOCK_EX ); |
| 5988 |
|
| 5989 |
// Verify TLS by default — disabling it site-wide is a needless MITM |
| 5990 |
// exposure (FBS-82142). Only relax verification in local/dev |
| 5991 |
// environments, where self-signed certs are common and there's no |
| 5992 |
// real attacker in the loop. |
| 5993 |
$is_local = function_exists( 'wp_get_environment_type' ) |
| 5994 |
&& in_array( wp_get_environment_type(), array( 'local', 'development' ), true ); |
| 5995 |
$resp = wp_remote_get( |
| 5996 |
$probe_url, |
| 5997 |
array( |
| 5998 |
// 3s cap so a host that hangs on loopback self-requests can't |
| 5999 |
// stall the caller for long; the result/error is cached so we |
| 6000 |
// don't repeat the wait every minute. |
| 6001 |
'timeout' => 3, |
| 6002 |
'sslverify' => ! $is_local, |
| 6003 |
'redirection' => 0, |
| 6004 |
'headers' => array( 'Cache-Control' => 'no-cache' ), |
| 6005 |
) |
| 6006 |
); |
| 6007 |
|
| 6008 |
// Best-effort cleanup so we don't accumulate probe dirs even |
| 6009 |
// if subsequent calls all hit the transient. |
| 6010 |
if ( file_exists( $probe_file ) ) { |
| 6011 |
wp_delete_file( $probe_file ); |
| 6012 |
} |
| 6013 |
if ( is_dir( $probe_dir ) ) { |
| 6014 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Best-effort probe-dir cleanup; WP_Filesystem needs admin credentials we don't have here. |
| 6015 |
@rmdir( $probe_dir ); |
| 6016 |
} |
| 6017 |
|
| 6018 |
if ( is_wp_error( $resp ) ) { |
| 6019 |
$result = array( |
| 6020 |
'active' => false, |
| 6021 |
// The request never completed, so we learned NOTHING about the |
| 6022 |
// rewrite. Flagged inconclusive so the UI doesn't tell the user |
| 6023 |
// to configure a server that may already be configured — a |
| 6024 |
// blocked loopback, a self-signed cert, or a timeout is a probe |
| 6025 |
// failure, not a missing rewrite. (FBS-84012) |
| 6026 |
'inconclusive' => true, |
| 6027 |
'reason' => 'http error: ' . $resp->get_error_message(), |
| 6028 |
); |
| 6029 |
// Cache the failure for the full 5 minutes (not 1) so a host that |
| 6030 |
// times out on the loopback probe isn't re-probed — and re-stalled |
| 6031 |
// — on every page load within the window. (FBS-82142) |
| 6032 |
set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS ); |
| 6033 |
return $result; |
| 6034 |
} |
| 6035 |
|
| 6036 |
$code = (int) wp_remote_retrieve_response_code( $resp ); |
| 6037 |
$body = (string) wp_remote_retrieve_body( $resp ); |
| 6038 |
$ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' ); |
| 6039 |
$has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' ) |
| 6040 |
|| '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' ); |
| 6041 |
$match = trim( $body ) === $nonce; |
| 6042 |
|
| 6043 |
// "Active" = the web server served our raw nonce bytes back |
| 6044 |
// AND emitted the static-serve markers (ETag / Last-Modified) |
| 6045 |
// AND didn't add an X-Powered-By: PHP header. All three are |
| 6046 |
// individually noisy; together they're conclusive. |
| 6047 |
$active = $match && $has_etag && ! $ua_php && 200 === $code; |
| 6048 |
|
| 6049 |
/* |
| 6050 |
* `inconclusive` separates "we proved the rewrite isn't serving" from |
| 6051 |
* "the probe couldn't tell". Only the former should drive a |
| 6052 |
* configure-your-server banner; the latter previously rendered the |
| 6053 |
* same alarming copy at a user who had already configured nginx |
| 6054 |
* correctly, and there was no way to clear it. (FBS-84012) |
| 6055 |
*/ |
| 6056 |
$inconclusive = false; |
| 6057 |
if ( $active ) { |
| 6058 |
$reason = 'static-served'; |
| 6059 |
} elseif ( 200 === $code && $match && $ua_php ) { |
| 6060 |
$reason = 'php served the file instead of nginx/Apache (rewrite block missing)'; |
| 6061 |
} elseif ( 200 === $code && ! $match ) { |
| 6062 |
// Something answered 200 with content that isn't our nonce — a CDN, |
| 6063 |
// a proxy, a security plugin. That tells us nothing about the |
| 6064 |
// origin's rewrite. |
| 6065 |
$reason = 'unexpected body (CDN cached an older response?)'; |
| 6066 |
$inconclusive = true; |
| 6067 |
} elseif ( 404 === $code ) { |
| 6068 |
$reason = 'probe URL returned 404 (rewrite block missing or wrong path)'; |
| 6069 |
} else { |
| 6070 |
// Redirects, 403s from a WAF, 5xx — the probe never reached a |
| 6071 |
// verdict about the rewrite itself. |
| 6072 |
$reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' ); |
| 6073 |
$inconclusive = true; |
| 6074 |
} |
| 6075 |
|
| 6076 |
$result = array( |
| 6077 |
'active' => $active, |
| 6078 |
'inconclusive' => $inconclusive, |
| 6079 |
'reason' => $reason, |
| 6080 |
'code' => $code, |
| 6081 |
'php' => $ua_php, |
| 6082 |
); |
| 6083 |
set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS ); |
| 6084 |
return $result; |
| 6085 |
} |
| 6086 |
|
| 6087 |
public static function rewrite_installed(): bool { |
| 6088 |
$htaccess = ABSPATH . '.htaccess'; |
| 6089 |
if ( ! file_exists( $htaccess ) ) { |
| 6090 |
return false; |
| 6091 |
} |
| 6092 |
$existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 6093 |
if ( ! is_string( $existing ) ) { |
| 6094 |
return false; |
| 6095 |
} |
| 6096 |
return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' ); |
| 6097 |
} |
| 6098 |
|
| 6099 |
/** |
| 6100 |
* Install the static-cache rewrite block at the TOP of .htaccess. |
| 6101 |
* |
| 6102 |
* Position matters: WordPress's own block ends with |
| 6103 |
* `RewriteRule . /index.php [L]` which routes every non-file |
| 6104 |
* request to PHP. The [L] flag stops the current rewrite pass, |
| 6105 |
* but Apache restarts the cycle; on the second pass REQUEST_URI |
| 6106 |
* is /index.php and no static-file check can match. The only |
| 6107 |
* reliable position for a "serve static if it exists" rule is |
| 6108 |
* before WordPress's block. |
| 6109 |
* |
| 6110 |
* WP's insert_with_markers() always appends, so we manage the |
| 6111 |
* block manually: strip any prior xSpeed Static Cache markers, |
| 6112 |
* then write our block followed by the rest of the file. |
| 6113 |
*/ |
| 6114 |
public static function install_rewrite(): bool { |
| 6115 |
// The static rewrite is device-blind; never install it when |
| 6116 |
// mobile_separate is on (see static_rewrite_allowed()). |
| 6117 |
if ( ! self::static_rewrite_allowed() ) { |
| 6118 |
return false; |
| 6119 |
} |
| 6120 |
$htaccess = ABSPATH . '.htaccess'; |
| 6121 |
$existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 6122 |
if ( false === $existing ) { |
| 6123 |
$existing = ''; |
| 6124 |
} |
| 6125 |
// Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in |
| 6126 |
// covers; we skip the write so we don't litter their root. |
| 6127 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Pre-flight check before file_put_contents; WP_Filesystem requires admin credentials we don't have inside a manage_options REST request. |
| 6128 |
if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) { |
| 6129 |
return false; |
| 6130 |
} |
| 6131 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above. |
| 6132 |
if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) { |
| 6133 |
return false; |
| 6134 |
} |
| 6135 |
|
| 6136 |
$cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' ); |
| 6137 |
$block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() ); |
| 6138 |
$next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned ); |
| 6139 |
|
| 6140 |
/* |
| 6141 |
* Nothing to change. auto_heal() runs the whole enable transaction on |
| 6142 |
* every admin_init and this is called unconditionally from it, so |
| 6143 |
* without this every wp-admin request truncated and rewrote .htaccess |
| 6144 |
* with byte-identical content. Apache reads that file without a lock, |
| 6145 |
* so the truncate window is a real 500 on a busy admin, and the churn |
| 6146 |
* trips host file-integrity monitors. |
| 6147 |
*/ |
| 6148 |
if ( $next === $existing ) { |
| 6149 |
return true; |
| 6150 |
} |
| 6151 |
|
| 6152 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- WP_Filesystem requires admin credentials we don't have here; toggle() runs in a REST request authorized by manage_options nonce. The target is the site's .htaccess (configuration file managed by WP core itself), not user data — wp_upload_dir() doesn't apply. |
| 6153 |
return false !== file_put_contents( $htaccess, $next, LOCK_EX ); |
| 6154 |
} |
| 6155 |
|
| 6156 |
/** |
| 6157 |
* Rewrite the .htaccess block in place when — and only when — one is |
| 6158 |
* already installed. |
| 6159 |
* |
| 6160 |
* The block embeds the generated cookie / user-agent exclusion rules, |
| 6161 |
* so it goes stale the moment those settings change. install_rewrite() |
| 6162 |
* regenerates it from the live settings, but calling that unconditionally |
| 6163 |
* on every save would CREATE a block on sites that never enabled the |
| 6164 |
* static path — silently turning on server-level serving nobody asked |
| 6165 |
* for. So we refresh only what's already there. |
| 6166 |
* |
| 6167 |
* @return bool True when a block was present and rewritten. |
| 6168 |
*/ |
| 6169 |
public static function refresh_rewrite_if_installed(): bool { |
| 6170 |
$htaccess = ABSPATH . '.htaccess'; |
| 6171 |
if ( ! file_exists( $htaccess ) ) { |
| 6172 |
return false; |
| 6173 |
} |
| 6174 |
$existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; an unreadable file simply means nothing to refresh. |
| 6175 |
if ( ! is_string( $existing ) || false === strpos( $existing, '# BEGIN xSpeed Static Cache' ) ) { |
| 6176 |
return false; |
| 6177 |
} |
| 6178 |
return self::install_rewrite(); |
| 6179 |
} |
| 6180 |
|
| 6181 |
public static function remove_rewrite(): bool { |
| 6182 |
$htaccess = ABSPATH . '.htaccess'; |
| 6183 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale. |
| 6184 |
if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) { |
| 6185 |
return false; |
| 6186 |
} |
| 6187 |
$existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 6188 |
if ( false === $existing ) { |
| 6189 |
return false; |
| 6190 |
} |
| 6191 |
$cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' ); |
| 6192 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale. |
| 6193 |
return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX ); |
| 6194 |
} |
| 6195 |
|
| 6196 |
/** |
| 6197 |
* Strip a `# BEGIN <marker>` ... `# END <marker>` block from a |
| 6198 |
* .htaccess-style file, including any blank line that immediately |
| 6199 |
* follows it. Idempotent — returns the input unchanged if the |
| 6200 |
* marker isn't present. |
| 6201 |
*/ |
| 6202 |
private static function strip_marker_block( string $contents, string $marker ): string { |
| 6203 |
/* |
| 6204 |
* The body may not contain another BEGIN for this marker. |
| 6205 |
* |
| 6206 |
* `.*?` is non-greedy but still spans anything, so an ORPHANED |
| 6207 |
* `# BEGIN xSpeed Static Cache` — an END line lost to a hand edit or |
| 6208 |
* a partial write — paired with the END of the NEXT block and deleted |
| 6209 |
* everything between them. On a site where the orphan sits above |
| 6210 |
* `# BEGIN WordPress`, that takes WordPress's own rewrite rules with |
| 6211 |
* it and every permalink 404s. Refusing to cross a second BEGIN makes |
| 6212 |
* the orphan a no-op instead of a site-wide outage. |
| 6213 |
*/ |
| 6214 |
$begin = '# BEGIN ' . preg_quote( $marker, '/' ) . '\b'; |
| 6215 |
$pattern = '/' . $begin . '(?:(?!' . $begin . ').)*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s"; |
| 6216 |
$out = preg_replace( $pattern, '', $contents ); |
| 6217 |
return is_string( $out ) ? $out : $contents; |
| 6218 |
} |
| 6219 |
|
| 6220 |
private static function marker_block( string $marker, array $lines ): string { |
| 6221 |
$header = "# BEGIN $marker\n"; |
| 6222 |
$header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n"; |
| 6223 |
$header .= "# dynamically generated, and should only be modified via WordPress filters.\n"; |
| 6224 |
$header .= "# Any changes to the directives between these markers will be overwritten.\n"; |
| 6225 |
$footer = "# END $marker\n"; |
| 6226 |
return $header . implode( "\n", $lines ) . "\n" . $footer; |
| 6227 |
} |
| 6228 |
|
| 6229 |
/** |
| 6230 |
* Parse the `XSPEED_DROPIN_VERSION: N` stamp out of a drop-in's source. |
| 6231 |
* Returns 0 when absent (an un-stamped older copy reinstalls). Used to |
| 6232 |
* detect a stale installed drop-in vs the bundled source. |
| 6233 |
*/ |
| 6234 |
private static function dropin_version( string $contents ): int { |
| 6235 |
if ( preg_match( '/XSPEED_DROPIN_VERSION:\s*(\d+)/', $contents, $m ) ) { |
| 6236 |
return (int) $m[1]; |
| 6237 |
} |
| 6238 |
return 0; |
| 6239 |
} |
| 6240 |
|
| 6241 |
/** The advanced-cache.php drop-in is ours. */ |
| 6242 |
public const DROPIN_XSPEED = 'xspeed'; |
| 6243 |
/** Someone else's drop-in is installed. */ |
| 6244 |
public const DROPIN_FOREIGN = 'foreign'; |
| 6245 |
/** No drop-in installed. */ |
| 6246 |
public const DROPIN_NONE = 'none'; |
| 6247 |
/** A drop-in is installed and we could not read it. */ |
| 6248 |
public const DROPIN_UNREADABLE = 'unreadable'; |
| 6249 |
|
| 6250 |
/** |
| 6251 |
* Who owns wp-content/advanced-cache.php right now. |
| 6252 |
* |
| 6253 |
* WordPress gives every caching plugin the same single file to live in, |
| 6254 |
* so "is there a drop-in" and "is it ours" are completely different |
| 6255 |
* questions, and only the second one licenses a write. An unreadable |
| 6256 |
* drop-in is deliberately its own answer rather than folding into |
| 6257 |
* "foreign": we cannot even name what we would be destroying. |
| 6258 |
* |
| 6259 |
* @return string One of the DROPIN_* constants. |
| 6260 |
*/ |
| 6261 |
public static function dropin_owner(): string { |
| 6262 |
require_once XSPEED_DIR . 'includes/wp-cache-constant.php'; |
| 6263 |
$target = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 6264 |
if ( ! file_exists( $target ) ) { |
| 6265 |
return self::DROPIN_NONE; |
| 6266 |
} |
| 6267 |
|
| 6268 |
$contents = self::read_file( $target ); |
| 6269 |
if ( null === $contents ) { |
| 6270 |
return self::DROPIN_UNREADABLE; |
| 6271 |
} |
| 6272 |
|
| 6273 |
return xspeed_has_canonical_dropin_signature( $contents ) |
| 6274 |
? self::DROPIN_XSPEED |
| 6275 |
: self::DROPIN_FOREIGN; |
| 6276 |
} |
| 6277 |
|
| 6278 |
/** |
| 6279 |
* Why xSpeed must not install its page-cache artifacts right now, or null |
| 6280 |
* when it may. |
| 6281 |
* |
| 6282 |
* This is the single gate in front of every write that touches shared |
| 6283 |
* state — the drop-in and the WP_CACHE define. Both are single-occupancy: |
| 6284 |
* whatever is there belongs to exactly one plugin, and taking it silently |
| 6285 |
* breaks that plugin's caching with no way back. |
| 6286 |
* |
| 6287 |
* Returns a user-facing string, so a REST caller can hand it straight to |
| 6288 |
* the dashboard instead of reporting a bare failure. |
| 6289 |
*/ |
| 6290 |
public static function acquisition_blocker(): ?string { |
| 6291 |
Page_Cache_Detector::invalidate(); |
| 6292 |
$verdict = Page_Cache_Detector::classify(); |
| 6293 |
$owner = self::dropin_owner(); |
| 6294 |
// The reason we refuse, whether that reason already names a plugin, |
| 6295 |
// and every other page cache the detector counted anywhere in the |
| 6296 |
// verdict. See the tail of this method for why all three are needed. |
| 6297 |
$primary = null; |
| 6298 |
$primary_names = false; |
| 6299 |
$named = array(); |
| 6300 |
foreach ( $verdict['blockers'] as $blocker ) { |
| 6301 |
$code = (string) ( $blocker['code'] ?? '' ); |
| 6302 |
// The shared detector quite correctly reports xSpeed itself as a |
| 6303 |
// page-cache owner. That is not a competitor to this transaction. |
| 6304 |
// |
| 6305 |
// Except when the two disagree about the DROP-IN. The detector |
| 6306 |
// accepts our marker anywhere in a file's header; this plugin's |
| 6307 |
// own check requires it to open the header, because only this |
| 6308 |
// side authorizes overwriting and deleting. A foreign drop-in |
| 6309 |
// that merely carries our marker further down its header is |
| 6310 |
// attributed to us by the detector, and skipping it here dropped |
| 6311 |
// the refusal entirely — the write then failed on the stricter |
| 6312 |
// check and the user was told to go and fix file permissions. |
| 6313 |
// Where they disagree, believe the stricter one. |
| 6314 |
if ( self::PLUGIN_FILE === ( $blocker['plugin'] ?? null ) ) { |
| 6315 |
$about_dropin = in_array( |
| 6316 |
$code, |
| 6317 |
array( |
| 6318 |
Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN, |
| 6319 |
Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN, |
| 6320 |
), |
| 6321 |
true |
| 6322 |
); |
| 6323 |
if ( ! $about_dropin || self::DROPIN_XSPEED === $owner ) { |
| 6324 |
continue; |
| 6325 |
} |
| 6326 |
} |
| 6327 |
if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) { |
| 6328 |
continue; |
| 6329 |
} |
| 6330 |
/* |
| 6331 |
* Capability is not possession. `active_page_cache` and |
| 6332 |
* `multiple_page_caches` both fire on a plugin that merely CAN |
| 6333 |
* cache pages — the detector cannot prove a competitor's page |
| 6334 |
* cache is off, so it counts it. As a warning that is right. As |
| 6335 |
* a gate it refuses a write that takes nothing from anyone. |
| 6336 |
* |
| 6337 |
* This gate guards exactly two files: advanced-cache.php and the |
| 6338 |
* WP_CACHE define that loads it. A plugin that does not hold the |
| 6339 |
* drop-in has nothing here for us to overwrite, and one that does |
| 6340 |
* is already refused by `foreign_dropin` / `unknown_dropin` a few |
| 6341 |
* lines up. So when the field is ours or empty, an active |
| 6342 |
* competitor is a note, not a refusal. |
| 6343 |
* |
| 6344 |
* QA found this on a live OpenLiteSpeed site keeping LiteSpeed |
| 6345 |
* Cache for images and CDN with its page cache off, while xSpeed |
| 6346 |
* served the pages. One click of the off switch and it could not |
| 6347 |
* be turned back on: the only way out was deactivating LiteSpeed |
| 6348 |
* entirely, and the message told them to "deactivate its page |
| 6349 |
* cache" — which they already had. |
| 6350 |
*/ |
| 6351 |
$about_capability = in_array( |
| 6352 |
$code, |
| 6353 |
array( |
| 6354 |
Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE, |
| 6355 |
Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES, |
| 6356 |
), |
| 6357 |
true |
| 6358 |
); |
| 6359 |
if ( $about_capability && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE ), true ) ) { |
| 6360 |
continue; |
| 6361 |
} |
| 6362 |
if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) { |
| 6363 |
$others = self::other_page_cache_names( $blocker ); |
| 6364 |
if ( array() === $others ) { |
| 6365 |
// We were the only owner counted — nothing to refuse — |
| 6366 |
// unless the list is missing entirely, which is an older |
| 6367 |
// detector copy we still must not talk past. |
| 6368 |
if ( null === $primary && array() === (array) ( $blocker['plugins'] ?? array() ) ) { |
| 6369 |
$primary = self::ownership_blocker_message( '', '' ); |
| 6370 |
} |
| 6371 |
continue; |
| 6372 |
} |
| 6373 |
$named = array_values( array_unique( array_merge( $named, $others ) ) ); |
| 6374 |
if ( null === $primary ) { |
| 6375 |
$primary = self::multiple_page_caches_message( $others ); |
| 6376 |
$primary_names = true; |
| 6377 |
} |
| 6378 |
continue; |
| 6379 |
} |
| 6380 |
if ( null === $primary ) { |
| 6381 |
$label = (string) ( $blocker['label'] ?? '' ); |
| 6382 |
$primary = self::ownership_blocker_message( $code, $label ); |
| 6383 |
$primary_names = '' !== $label; |
| 6384 |
} |
| 6385 |
} |
| 6386 |
|
| 6387 |
if ( null === $primary ) { |
| 6388 |
return null; |
| 6389 |
} |
| 6390 |
/* |
| 6391 |
* The first blocker decides WHY we refuse; it does not always know |
| 6392 |
* WHO. The detector can only attribute a drop-in it recognises, and |
| 6393 |
* an unrecognised one produces "its owner cannot be proved" — the |
| 6394 |
* sentence a W3 Total Cache site used to get while a later blocker in |
| 6395 |
* the same verdict was holding the name "W3 Total Cache". |
| 6396 |
* |
| 6397 |
* So keep the reason and add the names, rather than swapping one for |
| 6398 |
* the other: the plugin the user must deal with is not necessarily |
| 6399 |
* the owner of the file we could not identify, and promoting the |
| 6400 |
* named blocker would have told them to deactivate a plugin that is |
| 6401 |
* not what is in their way. |
| 6402 |
*/ |
| 6403 |
if ( $primary_names || array() === $named ) { |
| 6404 |
return $primary; |
| 6405 |
} |
| 6406 |
if ( 1 === count( $named ) ) { |
| 6407 |
return sprintf( |
| 6408 |
/* translators: 1: the refusal reason, 2: a page-caching plugin's name. */ |
| 6409 |
__( '%1$s %2$s is also active on this site — deactivate its page cache before enabling xSpeed.', 'xspeed' ), |
| 6410 |
$primary, |
| 6411 |
$named[0] |
| 6412 |
); |
| 6413 |
} |
| 6414 |
return sprintf( |
| 6415 |
/* translators: 1: the refusal reason, 2: comma-separated page-caching plugin names. */ |
| 6416 |
__( '%1$s These page caches are also active on this site: %2$s. Deactivate them before enabling xSpeed.', 'xspeed' ), |
| 6417 |
$primary, |
| 6418 |
implode( ', ', $named ) |
| 6419 |
); |
| 6420 |
} |
| 6421 |
|
| 6422 |
/** How xSpeed's own plugin file appears in the detector's catalog. */ |
| 6423 |
private const PLUGIN_FILE = 'xspeed/xspeed.php'; |
| 6424 |
|
| 6425 |
/** |
| 6426 |
* Name the OTHER page caches behind a `multiple_page_caches` refusal. |
| 6427 |
* |
| 6428 |
* This blocker has no single owner, so the detector leaves `plugin` and |
| 6429 |
* `label` null and hands over the full list instead. Left unhandled it |
| 6430 |
* fell through to the anonymous fallback sentence — and it is the blocker |
| 6431 |
* an ordinary site hits most: xSpeed counts toward "multiple", so the |
| 6432 |
* count reaches two the moment one other page-cache plugin is activated, |
| 6433 |
* even one that has not written a drop-in. A site running our cache that |
| 6434 |
* activated LiteSpeed could not re-enable it and was told only that "the |
| 6435 |
* page-cache field is occupied". |
| 6436 |
* |
| 6437 |
* Returns an empty list when xSpeed was the only owner counted, or when |
| 6438 |
* an older detector copy sent no list at all — the caller distinguishes |
| 6439 |
* the two by looking at `plugins`. |
| 6440 |
* |
| 6441 |
* @param array<string,mixed> $blocker One entry from Detector::classify(). |
| 6442 |
* @return string[] |
| 6443 |
*/ |
| 6444 |
private static function other_page_cache_names( array $blocker ): array { |
| 6445 |
$plugins = array_values( (array) ( $blocker['plugins'] ?? array() ) ); |
| 6446 |
$labels = array_values( (array) ( $blocker['labels'] ?? array() ) ); |
| 6447 |
|
| 6448 |
$others = array(); |
| 6449 |
foreach ( $plugins as $i => $plugin ) { |
| 6450 |
if ( self::PLUGIN_FILE === $plugin ) { |
| 6451 |
continue; |
| 6452 |
} |
| 6453 |
$others[] = isset( $labels[ $i ] ) && '' !== (string) $labels[ $i ] |
| 6454 |
? (string) $labels[ $i ] |
| 6455 |
: (string) $plugin; |
| 6456 |
} |
| 6457 |
return array_values( array_unique( $others ) ); |
| 6458 |
} |
| 6459 |
|
| 6460 |
/** |
| 6461 |
* The refusal sentence for a `multiple_page_caches` blocker. |
| 6462 |
* |
| 6463 |
* @param string[] $others Page caches other than xSpeed. Never empty. |
| 6464 |
*/ |
| 6465 |
private static function multiple_page_caches_message( array $others ): string { |
| 6466 |
if ( 1 === count( $others ) ) { |
| 6467 |
return self::ownership_blocker_message( Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE, $others[0] ); |
| 6468 |
} |
| 6469 |
return sprintf( |
| 6470 |
/* translators: %s: comma-separated list of page-caching plugin names. */ |
| 6471 |
__( 'More than one page cache is active on this site (%s). Turn off the other page caches before enabling xSpeed.', 'xspeed' ), |
| 6472 |
implode( ', ', $others ) |
| 6473 |
); |
| 6474 |
} |
| 6475 |
|
| 6476 |
private static function ownership_blocker_message( string $code, string $label ): string { |
| 6477 |
if ( '' !== $label ) { |
| 6478 |
return sprintf( __( '%s is active or owns advanced-cache.php. Deactivate its page cache before enabling xSpeed.', 'xspeed' ), $label ); |
| 6479 |
} |
| 6480 |
$messages = array( |
| 6481 |
'wp_cache_orphaned' => __( 'WP_CACHE is true but no page-cache drop-in owner can be proved. xSpeed will not claim it.', 'xspeed' ), |
| 6482 |
'wp_cache_duplicate' => __( 'wp-config.php defines WP_CACHE more than once. Remove the duplicate before enabling the cache.', 'xspeed' ), |
| 6483 |
'wp_cache_dynamic' => __( 'WP_CACHE is set from an expression in wp-config.php. xSpeed will not rewrite it.', 'xspeed' ), |
| 6484 |
'wp_cache_conditional' => __( 'WP_CACHE is defined inside a conditional in wp-config.php, so xSpeed cannot tell what it will be. Move it to a plain define before enabling the cache.', 'xspeed' ), |
| 6485 |
'wp_config_unreadable' => __( 'wp-config.php cannot be read, so xSpeed cannot safely change page-cache ownership.', 'xspeed' ), |
| 6486 |
'unknown_dropin' => __( 'advanced-cache.php is occupied but its owner cannot be proved. xSpeed will not replace it.', 'xspeed' ), |
| 6487 |
'unreadable_dropin' => __( 'advanced-cache.php cannot be read, so xSpeed cannot prove its owner.', 'xspeed' ), |
| 6488 |
); |
| 6489 |
return $messages[ $code ] ?? __( 'The page-cache field is occupied or cannot be verified. xSpeed will not change it.', 'xspeed' ); |
| 6490 |
} |
| 6491 |
|
| 6492 |
/** |
| 6493 |
* How WP_CACHE is written in wp-config.php, as opposed to what it |
| 6494 |
* evaluates to at runtime. |
| 6495 |
* |
| 6496 |
* The literal is what matters to a writer: a value behind an expression, |
| 6497 |
* or two competing defines, cannot be rewritten by a regex without |
| 6498 |
* guessing — and a wrong guess silently disables page caching (ours or |
| 6499 |
* someone else's) with no error anywhere. |
| 6500 |
* |
| 6501 |
* @return string undefined | true | false | duplicate | dynamic | conditional | unreadable |
| 6502 |
*/ |
| 6503 |
public static function wp_cache_define_state(): string { |
| 6504 |
$path = self::wp_config_path(); |
| 6505 |
if ( '' === $path ) { |
| 6506 |
return 'unreadable'; |
| 6507 |
} |
| 6508 |
|
| 6509 |
$config = self::read_file( $path ); |
| 6510 |
if ( null === $config ) { |
| 6511 |
return 'unreadable'; |
| 6512 |
} |
| 6513 |
|
| 6514 |
require_once XSPEED_DIR . 'includes/wp-cache-constant.php'; |
| 6515 |
$parsed = \xspeed_parse_wp_cache_defines( $config ); |
| 6516 |
return $parsed['state']; |
| 6517 |
} |
| 6518 |
|
| 6519 |
/** |
| 6520 |
* Classify the captured right-hand side of a WP_CACHE define. |
| 6521 |
* |
| 6522 |
* Hosts and older tutorials write the value several ways — |
| 6523 |
* `1`, `'1'`, `TRUE` — and all of them are literals a rewrite can safely |
| 6524 |
* replace. Only a value we cannot evaluate by looking at it (a variable, a |
| 6525 |
* function call, a ternary) counts as dynamic, because that is the case |
| 6526 |
* where rewriting means guessing. |
| 6527 |
* |
| 6528 |
* @return string true | false | dynamic |
| 6529 |
*/ |
| 6530 |
private static function classify_wp_cache_literal( string $raw ): string { |
| 6531 |
$literal = strtolower( trim( $raw ) ); |
| 6532 |
$literal = trim( $literal, "'\"" ); |
| 6533 |
|
| 6534 |
if ( in_array( $literal, array( 'true', '1' ), true ) ) { |
| 6535 |
return 'true'; |
| 6536 |
} |
| 6537 |
if ( in_array( $literal, array( 'false', '0', '', 'null' ), true ) ) { |
| 6538 |
return 'false'; |
| 6539 |
} |
| 6540 |
return 'dynamic'; |
| 6541 |
} |
| 6542 |
|
| 6543 |
/** |
| 6544 |
* Read a file for an ownership decision. Null on any failure — callers |
| 6545 |
* treat null as "unknown", never as "empty", because an empty string |
| 6546 |
* would read as "no marker found" and license an overwrite. |
| 6547 |
*/ |
| 6548 |
private static function read_file( string $path ): ?string { |
| 6549 |
if ( ! is_readable( $path ) ) { |
| 6550 |
return null; |
| 6551 |
} |
| 6552 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Ownership check on a local file; WP_Filesystem would need credentials we must not prompt for here. |
| 6553 |
$contents = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed read is a valid answer ("unknown"), not an error to surface. |
| 6554 |
return is_string( $contents ) ? $contents : null; |
| 6555 |
} |
| 6556 |
|
| 6557 |
public static function install_dropin() { |
| 6558 |
$source = XSPEED_DIR . 'includes/advanced-cache.php'; |
| 6559 |
$target = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 6560 |
if ( ! file_exists( $source ) ) { |
| 6561 |
return false; |
| 6562 |
} |
| 6563 |
|
| 6564 |
/* |
| 6565 |
* Ownership first, before any of the work below. WordPress gives every |
| 6566 |
* caching plugin the same single file, so a drop-in that is not ours is |
| 6567 |
* another plugin's live cache — refuse rather than replace it. |
| 6568 |
*/ |
| 6569 |
$owner = self::dropin_owner(); |
| 6570 |
if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) { |
| 6571 |
return false; |
| 6572 |
} |
| 6573 |
|
| 6574 |
global $wp_filesystem; |
| 6575 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 6576 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 6577 |
} |
| 6578 |
WP_Filesystem(); |
| 6579 |
if ( ! $wp_filesystem ) { |
| 6580 |
return false; |
| 6581 |
} |
| 6582 |
|
| 6583 |
$source_contents = $wp_filesystem->get_contents( $source ); |
| 6584 |
if ( ! is_string( $source_contents ) ) { |
| 6585 |
return false; |
| 6586 |
} |
| 6587 |
|
| 6588 |
// Bake the absolute hit-log path into the drop-in. It runs before |
| 6589 |
// WordPress loads, so it can't resolve wp_upload_dir() itself — we |
| 6590 |
// substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path |
| 6591 |
// (never the cache dir; see hits_log_dir() / FBS-82478). Use a single |
| 6592 |
// quoted PHP string literal so the installed file stays valid PHP. |
| 6593 |
$source_contents = str_replace( |
| 6594 |
'@@XSPEED_HITS_LOG@@', |
| 6595 |
str_replace( "'", "\\'", self::hits_log_path() ), |
| 6596 |
$source_contents |
| 6597 |
); |
| 6598 |
|
| 6599 |
// Bake the cookie + user-agent exclusion rules in too. The drop-in |
| 6600 |
// runs before WordPress loads, so it cannot read the settings — and |
| 6601 |
// without them it served the shared anonymous page to any visitor |
| 6602 |
// PHP had not yet seen (a first-time cart visitor, a bypassed bot). |
| 6603 |
// The generic bypass cookie only covers repeat visitors; these two |
| 6604 |
// regexes are what make the FIRST request correct. |
| 6605 |
// |
| 6606 |
// Both are already fully escaped by Server_Rules, and each is |
| 6607 |
// embedded as a single-quoted PHP literal, so a settings value can |
| 6608 |
// neither break the drop-in's syntax nor execute. |
| 6609 |
$cache_opts = Settings_Manager::get( 'cache' ); |
| 6610 |
$cookie_rule = Server_Rules::cookie_rule( |
| 6611 |
is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array() |
| 6612 |
); |
| 6613 |
$ua_rule = Server_Rules::user_agent_rule( |
| 6614 |
is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array() |
| 6615 |
); |
| 6616 |
|
| 6617 |
$source_contents = str_replace( |
| 6618 |
'@@XSPEED_COOKIE_RE@@', |
| 6619 |
str_replace( "'", "\\'", $cookie_rule['regex'] ), |
| 6620 |
$source_contents |
| 6621 |
); |
| 6622 |
$source_contents = str_replace( |
| 6623 |
'@@XSPEED_UA_RE@@', |
| 6624 |
str_replace( "'", "\\'", $ua_rule['regex'] ), |
| 6625 |
$source_contents |
| 6626 |
); |
| 6627 |
|
| 6628 |
/* |
| 6629 |
* Ours or absent — the ownership gate at the top of this method ruled |
| 6630 |
* out everything else. The old code path that moved a foreign drop-in |
| 6631 |
* into uploads/xspeed-backups and wrote ours on top is gone: it |
| 6632 |
* disabled the other plugin's page cache the moment an xSpeed install |
| 6633 |
* ran, with nothing in its own UI to explain why. |
| 6634 |
*/ |
| 6635 |
|
| 6636 |
// Bake the configured cache lifetime in. The drop-in runs before |
| 6637 |
// WordPress loads, so it cannot read the option — it previously fell |
| 6638 |
// back to a hardcoded 86400 for every ordinary page, because |
| 6639 |
// write_meta() only emits a `ttl` sidecar when the value DIFFERS from |
| 6640 |
// the page default. That made the admin's "1 to 720 hours" control a |
| 6641 |
// no-op at the layer that actually answers the request: 12h served |
| 6642 |
// stale for up to 2x the configured lifetime, and 168h lost the fast |
| 6643 |
// path for 6 of every 7 days (issue #240). |
| 6644 |
// |
| 6645 |
// This is re-baked on every cache settings save (see CacheModule::boot), |
| 6646 |
// exactly like the cookie / user-agent rules above. |
| 6647 |
$expiry_hours = isset( $cache_opts['cache_expiry'] ) ? (int) $cache_opts['cache_expiry'] : 24; |
| 6648 |
if ( $expiry_hours < 1 || $expiry_hours > 720 ) { |
| 6649 |
$expiry_hours = 24; |
| 6650 |
} |
| 6651 |
$source_contents = str_replace( |
| 6652 |
'@@XSPEED_DEFAULT_TTL@@', |
| 6653 |
(string) ( $expiry_hours * HOUR_IN_SECONDS ), |
| 6654 |
$source_contents |
| 6655 |
); |
| 6656 |
|
| 6657 |
if ( file_exists( $target ) ) { |
| 6658 |
$existing = $wp_filesystem->get_contents( $target ); |
| 6659 |
if ( is_string( $existing ) && $existing === $source_contents ) { |
| 6660 |
return true; |
| 6661 |
} |
| 6662 |
} |
| 6663 |
|
| 6664 |
return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE ); |
| 6665 |
} |
| 6666 |
|
| 6667 |
public static function remove_dropin() { |
| 6668 |
$target = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 6669 |
if ( ! file_exists( $target ) ) { |
| 6670 |
return; |
| 6671 |
} |
| 6672 |
|
| 6673 |
global $wp_filesystem; |
| 6674 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 6675 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 6676 |
} |
| 6677 |
WP_Filesystem(); |
| 6678 |
if ( ! $wp_filesystem ) { |
| 6679 |
return; |
| 6680 |
} |
| 6681 |
|
| 6682 |
$contents = $wp_filesystem->get_contents( $target ); |
| 6683 |
if ( is_string( $contents ) && xspeed_has_canonical_dropin_signature( $contents ) ) { |
| 6684 |
wp_delete_file( $target ); |
| 6685 |
} |
| 6686 |
} |
| 6687 |
|
| 6688 |
/** |
| 6689 |
* Where wp-config.php actually is. |
| 6690 |
* |
| 6691 |
* WordPress core supports the file one directory ABOVE ABSPATH, and |
| 6692 |
* plenty of installs use that layout. This used to look only in ABSPATH |
| 6693 |
* and bail, so on those sites the constant could never be written — while |
| 6694 |
* Health, which did fall back to the parent, reported the file writable |
| 6695 |
* and told the user to toggle the cache off and on. The advice could |
| 6696 |
* never work, and its fallback hint ("another plugin left WP_CACHE false |
| 6697 |
* behind") was wrong too: there was no define at all. (#19, QA on #174) |
| 6698 |
* |
| 6699 |
* Returns '' when no wp-config.php can be found in either location. |
| 6700 |
*/ |
| 6701 |
public static function wp_config_path(): string { |
| 6702 |
$candidates = array( ABSPATH . 'wp-config.php', dirname( ABSPATH ) . '/wp-config.php' ); |
| 6703 |
foreach ( $candidates as $path ) { |
| 6704 |
if ( file_exists( $path ) ) { |
| 6705 |
return $path; |
| 6706 |
} |
| 6707 |
} |
| 6708 |
return ''; |
| 6709 |
} |
| 6710 |
|
| 6711 |
/** |
| 6712 |
* Can we actually write the constant right now? |
| 6713 |
* |
| 6714 |
* This is the single oracle for that question — Health asks THIS rather |
| 6715 |
* than running its own `wp_is_writable()` test, so the message a user |
| 6716 |
* reads can never disagree with what the plugin will do. The two differed |
| 6717 |
* in both directions: on the path (above) and on the test itself, since |
| 6718 |
* an FTP/SSH WP_Filesystem transport can refuse a file that |
| 6719 |
* `wp_is_writable()` reports as writable. (#19, QA on #174) |
| 6720 |
*/ |
| 6721 |
public static function can_write_wp_config(): bool { |
| 6722 |
$wp_config = self::wp_config_path(); |
| 6723 |
if ( '' === $wp_config ) { |
| 6724 |
return false; |
| 6725 |
} |
| 6726 |
|
| 6727 |
global $wp_filesystem; |
| 6728 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 6729 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 6730 |
} |
| 6731 |
WP_Filesystem(); |
| 6732 |
return (bool) ( $wp_filesystem && $wp_filesystem->is_writable( $wp_config ) ); |
| 6733 |
} |
| 6734 |
|
| 6735 |
public static function set_wp_cache_constant( $enable ) { |
| 6736 |
$wp_config = self::wp_config_path(); |
| 6737 |
if ( '' === $wp_config ) { |
| 6738 |
return false; |
| 6739 |
} |
| 6740 |
|
| 6741 |
/* |
| 6742 |
* WP_CACHE belongs to whoever owns the drop-in — it is the switch that |
| 6743 |
* makes core load that one file. Editing it while someone else's |
| 6744 |
* drop-in is installed either turns THEIR cache on or off; either way |
| 6745 |
* it is a write to another plugin's state. So: no ownership, no edit. |
| 6746 |
*/ |
| 6747 |
$owner = self::dropin_owner(); |
| 6748 |
if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) { |
| 6749 |
return false; |
| 6750 |
} |
| 6751 |
|
| 6752 |
$state = self::wp_cache_define_state(); |
| 6753 |
if ( 'duplicate' === $state || 'dynamic' === $state ) { |
| 6754 |
// Two competing defines, or a value behind an expression. A regex |
| 6755 |
// rewrite here is a guess, and a wrong guess silently kills page |
| 6756 |
// caching with no error anywhere. |
| 6757 |
return false; |
| 6758 |
} |
| 6759 |
global $wp_filesystem; |
| 6760 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 6761 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 6762 |
} |
| 6763 |
WP_Filesystem(); |
| 6764 |
if ( ! $wp_filesystem ) { |
| 6765 |
return false; |
| 6766 |
} |
| 6767 |
|
| 6768 |
$config = $wp_filesystem->get_contents( $wp_config ); |
| 6769 |
if ( ! is_string( $config ) ) { |
| 6770 |
return false; |
| 6771 |
} |
| 6772 |
require_once XSPEED_DIR . 'includes/wp-cache-constant.php'; |
| 6773 |
$marker = $enable ? self::wp_cache_receipt() : ''; |
| 6774 |
$updated = xspeed_rewrite_wp_cache_define( $config, (bool) $enable, $marker ); |
| 6775 |
if ( ! is_string( $updated ) ) { |
| 6776 |
return false; |
| 6777 |
} |
| 6778 |
|
| 6779 |
/* |
| 6780 |
* Removing a WP_CACHE line we cannot prove we wrote is somebody else's |
| 6781 |
* configuration, so a disable needs either our drop-in or our receipt. |
| 6782 |
* |
| 6783 |
* The test is on the REWRITE, not on the request: it used to run |
| 6784 |
* before the rewrite and refuse a disable that had nothing to remove. |
| 6785 |
* An ordinary site with no drop-in and no define — every fresh |
| 6786 |
* install — therefore failed to turn page caching off, so the |
| 6787 |
* onboarding wizard reported "setup needs attention" to every user who |
| 6788 |
* declined it and Migration reported the cache import as failed. |
| 6789 |
*/ |
| 6790 |
if ( ! $enable && $updated !== $config |
| 6791 |
&& self::DROPIN_XSPEED !== $owner |
| 6792 |
&& ! self::wp_cache_receipt_matches_source( $config ) ) { |
| 6793 |
return false; |
| 6794 |
} |
| 6795 |
|
| 6796 |
/* |
| 6797 |
* Nothing to write. auto_heal() runs the whole enable transaction on |
| 6798 |
* every admin_init, so without this every wp-admin request rewrote |
| 6799 |
* wp-config.php with byte-identical content: pointless disk churn |
| 6800 |
* that trips host file-integrity monitors and widens the window for |
| 6801 |
* a concurrent write on a busy admin. |
| 6802 |
* |
| 6803 |
* It is also what makes a correct WP_CACHE on a read-only |
| 6804 |
* wp-config.php succeed. A managed host that ships the file |
| 6805 |
* unwritable, on a site where the user already pasted the define, |
| 6806 |
* is in the state we wanted — the writability test below is about |
| 6807 |
* whether we can CHANGE the file, and there is nothing to change. |
| 6808 |
*/ |
| 6809 |
if ( $updated === $config ) { |
| 6810 |
if ( ! $enable ) { |
| 6811 |
// Our line is not in the file, so the receipt that proved we |
| 6812 |
// wrote it is stale — drop it on the same terms as a real |
| 6813 |
// removal, or uninstall keeps a claim on nothing. |
| 6814 |
delete_option( 'xspeed_page_cache_ownership_receipt' ); |
| 6815 |
} |
| 6816 |
return true; |
| 6817 |
} |
| 6818 |
|
| 6819 |
if ( ! $wp_filesystem->is_writable( $wp_config ) ) { |
| 6820 |
return false; |
| 6821 |
} |
| 6822 |
$written = (bool) $wp_filesystem->put_contents( $wp_config, $updated, FS_CHMOD_FILE ); |
| 6823 |
if ( $written && ! $enable ) { |
| 6824 |
delete_option( 'xspeed_page_cache_ownership_receipt' ); |
| 6825 |
} |
| 6826 |
return $written; |
| 6827 |
} |
| 6828 |
|
| 6829 |
private static function wp_cache_receipt(): string { |
| 6830 |
$receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' ); |
| 6831 |
if ( is_string( $receipt ) && preg_match( '/^[a-f0-9]{32}$/', $receipt ) ) { |
| 6832 |
return $receipt; |
| 6833 |
} |
| 6834 |
$receipt = substr( hash( 'sha256', XSPEED_DIR . microtime( true ) . mt_rand() ), 0, 32 ); |
| 6835 |
update_option( 'xspeed_page_cache_ownership_receipt', $receipt, false ); |
| 6836 |
return $receipt; |
| 6837 |
} |
| 6838 |
|
| 6839 |
/** |
| 6840 |
* Is the WP_CACHE line in wp-config.php ours to REMOVE? |
| 6841 |
* |
| 6842 |
* Two different questions live here and only one of them matters. "Did we |
| 6843 |
* write it" is answered by our drop-in on disk or by our receipt comment |
| 6844 |
* beside the define. "Is it ours to remove" also asks what the line does |
| 6845 |
* NOW — and once a competitor owns advanced-cache.php, a line we wrote |
| 6846 |
* ourselves is the switch that loads THEIR drop-in. They had no reason to |
| 6847 |
* touch an already-true define, so our receipt is still sitting on it. |
| 6848 |
* Removing it there would stop their live page cache. |
| 6849 |
* |
| 6850 |
* So a foreign or unreadable owner is never ours to remove, whatever the |
| 6851 |
* receipt says, and the caller treats that as a reason to leave the line |
| 6852 |
* and get on with disabling our own cache — not as a reason to refuse. |
| 6853 |
*/ |
| 6854 |
private static function wp_cache_define_is_ours_to_remove( string $owner ): bool { |
| 6855 |
if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) { |
| 6856 |
return false; |
| 6857 |
} |
| 6858 |
if ( self::DROPIN_XSPEED === $owner ) { |
| 6859 |
return true; |
| 6860 |
} |
| 6861 |
$path = self::wp_config_path(); |
| 6862 |
if ( '' === $path ) { |
| 6863 |
return false; |
| 6864 |
} |
| 6865 |
$config = self::read_file( $path ); |
| 6866 |
return is_string( $config ) && self::wp_cache_receipt_matches_source( $config ); |
| 6867 |
} |
| 6868 |
|
| 6869 |
private static function wp_cache_receipt_matches_source( string $source ): bool { |
| 6870 |
$receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' ); |
| 6871 |
require_once XSPEED_DIR . 'includes/wp-cache-constant.php'; |
| 6872 |
return xspeed_wp_cache_receipt_matches( $source, $receipt ); |
| 6873 |
} |
| 6874 |
|
| 6875 |
/** |
| 6876 |
* Admin-bar purge menu — a parent node plus one child per visible cache |
| 6877 |
* type (LiteSpeed-style), instead of a single "Purge All" link. Each |
| 6878 |
* child posts to the same admin-post handler with its type slug. The |
| 6879 |
* per-type items only appear for active/licensed modules; "Purge All" |
| 6880 |
* always shows and always sweeps everything. (FBS-83114) |
| 6881 |
* |
| 6882 |
* The parent node links to the settings page rather than a purge URL — |
| 6883 |
* clicking the top-level item used to wipe the whole cache instantly with |
| 6884 |
* no confirmation, which is far too destructive for a stray click. Purging |
| 6885 |
* stays available (and explicit) through the child items. (FBS-84068) |
| 6886 |
*/ |
| 6887 |
public function admin_bar_purge( $wp_admin_bar ) { |
| 6888 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 6889 |
return; |
| 6890 |
} |
| 6891 |
|
| 6892 |
$wp_admin_bar->add_node( |
| 6893 |
array( |
| 6894 |
'id' => 'xspeed-purge', |
| 6895 |
'title' => __( 'xSpeed Cache', 'xspeed' ), |
| 6896 |
'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ), |
| 6897 |
) |
| 6898 |
); |
| 6899 |
|
| 6900 |
foreach ( self::purge_types() as $slug => $type ) { |
| 6901 |
if ( empty( $type['visible'] ) ) { |
| 6902 |
continue; |
| 6903 |
} |
| 6904 |
$wp_admin_bar->add_node( |
| 6905 |
array( |
| 6906 |
'id' => 'xspeed-purge-' . $slug, |
| 6907 |
'parent' => 'xspeed-purge', |
| 6908 |
'title' => esc_html( $type['label'] ), |
| 6909 |
'href' => self::purge_type_url( $slug ), |
| 6910 |
) |
| 6911 |
); |
| 6912 |
} |
| 6913 |
} |
| 6914 |
|
| 6915 |
/** |
| 6916 |
* Nonce-protected admin-post URL for purging a single type. The nonce |
| 6917 |
* action is per-type so a leaked URL can't be replayed for a different |
| 6918 |
* scope. |
| 6919 |
*/ |
| 6920 |
private static function purge_type_url( string $type ): string { |
| 6921 |
return wp_nonce_url( |
| 6922 |
admin_url( 'admin-post.php?action=xspeed_purge&type=' . rawurlencode( $type ) ), |
| 6923 |
'xspeed_purge_' . $type |
| 6924 |
); |
| 6925 |
} |
| 6926 |
|
| 6927 |
public function handle_admin_bar_purge() { |
| 6928 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 6929 |
wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 ); |
| 6930 |
} |
| 6931 |
$type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all'; |
| 6932 |
check_admin_referer( 'xspeed_purge_' . $type ); |
| 6933 |
|
| 6934 |
// Only honour known types; anything else falls back to a full purge. |
| 6935 |
if ( ! array_key_exists( $type, self::purge_types() ) ) { |
| 6936 |
$type = 'all'; |
| 6937 |
} |
| 6938 |
self::purge_type( $type ); |
| 6939 |
|
| 6940 |
wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) ); |
| 6941 |
exit; |
| 6942 |
} |
| 6943 |
|
| 6944 |
/** |
| 6945 |
* Resolve a safe redirect target for an admin-bar purge. |
| 6946 |
* |
| 6947 |
* The purge sends the admin back where they came from — but the referer |
| 6948 |
* can be a ONE-SHOT action URL (e.g. update.php?action=upload-plugin from |
| 6949 |
* installing a plugin zip, or any *.php?action=… that consumed a POST / |
| 6950 |
* temp upload). Redirecting there re-runs the action with nothing to act |
| 6951 |
* on, so WordPress dies — the classic "Please select a file" from |
| 6952 |
* File_Upload_Upgrader. Strip the transient action args so we return to a |
| 6953 |
* safe, re-GET-able view of the same page; fall back to the dashboard when |
| 6954 |
* there is no usable referer. |
| 6955 |
* |
| 6956 |
* @param string|false $referer Raw wp_get_referer() value. |
| 6957 |
* @return string Safe URL to redirect to. |
| 6958 |
*/ |
| 6959 |
public static function safe_purge_redirect( $referer ): string { |
| 6960 |
$referer = is_string( $referer ) ? $referer : ''; |
| 6961 |
if ( '' === $referer ) { |
| 6962 |
return admin_url(); |
| 6963 |
} |
| 6964 |
|
| 6965 |
// A referer that lands on an action-processing endpoint (update.php, |
| 6966 |
// update-core.php, plugin/theme install/upload flows) can't be safely |
| 6967 |
// re-requested — send them to the dashboard instead of replaying it. |
| 6968 |
$path = (string) wp_parse_url( $referer, PHP_URL_PATH ); |
| 6969 |
if ( preg_match( '#/wp-admin/(update|update-core)\.php$#', $path ) ) { |
| 6970 |
return admin_url(); |
| 6971 |
} |
| 6972 |
|
| 6973 |
// Otherwise keep them on the same page but drop the query args that |
| 6974 |
// would re-trigger a form action or upload on load. |
| 6975 |
return remove_query_arg( |
| 6976 |
array( 'action', 'action2', 'package', 'overwrite', 'plugin', 'theme', 'file', '_wpnonce', '_ajax_nonce' ), |
| 6977 |
$referer |
| 6978 |
); |
| 6979 |
} |
| 6980 |
} |
| 6981 |
|