| 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 |
public function __construct() { |
| 24 |
add_action( 'template_redirect', array( $this, 'maybe_start_cache' ), 0 ); |
| 25 |
|
| 26 |
// Events that should invalidate cached output. Beyond posts/comments, |
| 27 |
// this covers user and term changes — the REST cache can serve |
| 28 |
// /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect |
| 29 |
// rendered author bylines / term-archive pages. Without them, an edit |
| 30 |
// left the matching endpoint (and archives) stale for the full TTL. |
| 31 |
// (FBS-82408) |
| 32 |
$invalidate_hooks = array( |
| 33 |
'save_post', 'deleted_post', 'trashed_post', |
| 34 |
'comment_post', 'wp_set_comment_status', |
| 35 |
'switch_theme', 'activated_plugin', 'deactivated_plugin', |
| 36 |
// Users → /wp/v2/users + author archives. |
| 37 |
'profile_update', 'user_register', 'deleted_user', |
| 38 |
// Terms → /wp/v2/{taxonomy} + term archives. |
| 39 |
'created_term', 'edited_term', 'delete_term', |
| 40 |
); |
| 41 |
foreach ( $invalidate_hooks as $hook ) { |
| 42 |
add_action( $hook, array( __CLASS__, 'purge_all' ) ); |
| 43 |
add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 44 |
} |
| 45 |
|
| 46 |
add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 ); |
| 47 |
|
| 48 |
add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 ); |
| 49 |
add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) ); |
| 50 |
} |
| 51 |
|
| 52 |
public static function on_settings_change( $old, $new ) { |
| 53 |
// gzip_enabled moved to xspeed_module_gzip — GzipModule owns the |
| 54 |
// .htaccess flip via its own update_option_xspeed_module_gzip hook. |
| 55 |
// Same migration is planned for cache_expiry + excluded_urls |
| 56 |
// (Cache module). Keep this handler around for whatever still |
| 57 |
// lives in the legacy blob (cache_enabled is special and goes |
| 58 |
// through Cache::toggle anyway). |
| 59 |
|
| 60 |
// Any settings change — purge caches so changes take effect. |
| 61 |
self::purge_all( 'settings change' ); |
| 62 |
Minifier::purge_minified(); |
| 63 |
} |
| 64 |
|
| 65 |
public function maybe_start_cache() { |
| 66 |
if ( ! self::should_cache() ) { |
| 67 |
return; |
| 68 |
} |
| 69 |
|
| 70 |
$key = self::cache_key(); |
| 71 |
$file = self::cache_file_for( $key ); |
| 72 |
|
| 73 |
if ( file_exists( $file ) && ! self::is_expired( $file ) ) { |
| 74 |
Hit_Counter::record_hit(); |
| 75 |
// Emit the HIT marker on THIS path too. The drop-in |
| 76 |
// (advanced-cache.php) sends "HIT (php)" and the nginx static |
| 77 |
// rewrite sends "HIT (nginx)", but this template_redirect |
| 78 |
// serve path — the one that runs when the drop-in isn't loaded |
| 79 |
// (e.g. WP_CACHE not true) — previously streamed the cached |
| 80 |
// file with NO marker, so a genuine HIT looked like a MISS in |
| 81 |
// the response headers. Same header + value as the drop-in. |
| 82 |
if ( ! headers_sent() ) { |
| 83 |
header( 'X-XSpeed-Cache: HIT (php)' ); |
| 84 |
} |
| 85 |
// Replay stored response bits so the HIT matches the original: |
| 86 |
// a non-HTML Content-Type (cached feeds, sitemaps) and a non-200 |
| 87 |
// status (a cached 404 must serve 404, not 200). No-op for |
| 88 |
// ordinary pages, which write no .meta. |
| 89 |
$meta = self::read_meta( $key ); |
| 90 |
if ( ! headers_sent() ) { |
| 91 |
if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) { |
| 92 |
http_response_code( (int) $meta['status'] ); |
| 93 |
} |
| 94 |
if ( ! empty( $meta['content_type'] ) && is_string( $meta['content_type'] ) ) { |
| 95 |
header( 'Content-Type: ' . $meta['content_type'] ); |
| 96 |
} |
| 97 |
// Conditional GET: emit Last-Modified + ETag and answer a |
| 98 |
// matching If-Modified-Since / If-None-Match with 304 so |
| 99 |
// aggregators (and browsers) skip re-downloading an unchanged |
| 100 |
// cached response — the bandwidth win feeds are about. |
| 101 |
// (FBS-82407 #5) |
| 102 |
if ( self::serve_not_modified( $file ) ) { |
| 103 |
exit; // 304 sent, no body. |
| 104 |
} |
| 105 |
} |
| 106 |
// Serve the precompressed Brotli sibling when the client accepts |
| 107 |
// it (an add-on, the Pro Brotli module, wrote <file>.br). On this |
| 108 |
// PHP serve path the web server never sees the .br, so without |
| 109 |
// this a br-capable client got the plain .html — precompression |
| 110 |
// did nothing here. Falls through to plain readfile otherwise. |
| 111 |
$br = self::maybe_serve_brotli( $file ); |
| 112 |
if ( null !== $br ) { |
| 113 |
// 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. |
| 114 |
readfile( $br ); |
| 115 |
exit; |
| 116 |
} |
| 117 |
// 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. |
| 118 |
readfile( $file ); |
| 119 |
exit; |
| 120 |
} |
| 121 |
|
| 122 |
// Cache miss → render fresh + write cache. On LiteSpeed we send an |
| 123 |
// explicit "stand down" header so the server's LSCache module does |
| 124 |
// NOT cache + shadow our response — xSpeed's own .htaccess static |
| 125 |
// rewrite owns hit serving (and hit accounting) here, exactly as on |
| 126 |
// Apache. See maybe_emit_lscache_headers() for the full rationale. |
| 127 |
self::maybe_emit_lscache_headers(); |
| 128 |
|
| 129 |
// We're about to render fresh + cache → miss for this request. |
| 130 |
Hit_Counter::record_miss(); |
| 131 |
|
| 132 |
|
| 133 |
// WP < 6.9 fallback: ob_start() with a callback, paired with an |
| 134 |
// explicit shutdown close so the buffer lifecycle is visible to |
| 135 |
// reviewers and Plugin Check, instead of relying on PHP's implicit |
| 136 |
// request-end flush. We record our nesting level so close_buffer() |
| 137 |
// flushes ONLY the buffer we opened. |
| 138 |
ob_start( array( __CLASS__, 'finalize_buffer' ) ); |
| 139 |
self::$buffer_level = ob_get_level(); |
| 140 |
|
| 141 |
add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 ); |
| 142 |
} |
| 143 |
|
| 144 |
/** |
| 145 |
* Close the cache buffer opened by maybe_start_cache(). |
| 146 |
* |
| 147 |
* Guarded by the recorded buffer level so we never flush a buffer that |
| 148 |
* another plugin pushed on top of (or under) ours. If something else is |
| 149 |
* currently on top, we leave the stack alone — PHP's shutdown sequence |
| 150 |
* will unwind buffers in order and our finalize_buffer() callback will |
| 151 |
* still run when our level becomes the topmost one. |
| 152 |
*/ |
| 153 |
public static function close_buffer() { |
| 154 |
if ( null === self::$buffer_level ) { |
| 155 |
return; |
| 156 |
} |
| 157 |
if ( ob_get_level() === self::$buffer_level ) { |
| 158 |
ob_end_flush(); |
| 159 |
} |
| 160 |
self::$buffer_level = null; |
| 161 |
} |
| 162 |
|
| 163 |
public static function should_cache() { |
| 164 |
$opts = Settings::get(); |
| 165 |
if ( empty( $opts['cache_enabled'] ) ) { |
| 166 |
return false; |
| 167 |
} |
| 168 |
|
| 169 |
if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { |
| 170 |
return false; |
| 171 |
} |
| 172 |
|
| 173 |
if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) { |
| 174 |
return false; |
| 175 |
} |
| 176 |
|
| 177 |
// All exclusion knobs now owned by CacheModule. |
| 178 |
$cache_opts = Settings_Manager::get( 'cache' ); |
| 179 |
|
| 180 |
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : ''; |
| 181 |
if ( 'GET' !== $method ) { |
| 182 |
return false; |
| 183 |
} |
| 184 |
|
| 185 |
// Search-results requests carry a `s` query param, which the |
| 186 |
// query-string gate below would normally reject as "dynamic". An |
| 187 |
// add-on (xspeed-pro search cache) can opt them in: when this is a |
| 188 |
// genuine is_search() and the filter returns true, the `s` param is |
| 189 |
// treated as cacheable (the search term goes into the cache key so |
| 190 |
// different searches stay distinct — see cache_key()). |
| 191 |
$cache_search = self::should_cache_search(); |
| 192 |
|
| 193 |
// Feed opt-in is resolved BEFORE the query-string gate so query-form |
| 194 |
// feeds (/?feed=rss2, used on plain-permalink sites) aren't rejected |
| 195 |
// as "dynamic" by that gate — the `feed` param is then allowed through |
| 196 |
// just like the search `s` param. Feeds are excluded by default (the |
| 197 |
// `/feed/` pattern in excluded_urls); an add-on (xspeed-pro feed cache) |
| 198 |
// opts them back in via the filter. (FBS-82407 #4) |
| 199 |
$is_feed_request = function_exists( 'is_feed' ) && is_feed(); |
| 200 |
/** |
| 201 |
* Whether to cache the current feed request. |
| 202 |
* |
| 203 |
* Default false → feeds fall through to the normal URL-exclusion |
| 204 |
* rules (so `/feed/` keeps them out). A listener returning true |
| 205 |
* opts this feed request into caching. |
| 206 |
* |
| 207 |
* @param bool $cache_feed Whether to cache this feed request. |
| 208 |
*/ |
| 209 |
$cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false ); |
| 210 |
|
| 211 |
// Query string handling: anything OUTSIDE the ignored-params |
| 212 |
// allow-list (utm_*, fbclid, gclid by default) means a unique |
| 213 |
// request that we don't want to share with the canonical cache |
| 214 |
// entry. Skip cache rather than poison the key. |
| 215 |
$query_raw = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : ''; |
| 216 |
if ( '' !== $query_raw ) { |
| 217 |
$ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array(); |
| 218 |
parse_str( $query_raw, $params ); |
| 219 |
foreach ( $params as $key => $_ ) { |
| 220 |
// Allow the search param through when search caching is on. |
| 221 |
if ( $cache_search && 's' === $key ) { |
| 222 |
continue; |
| 223 |
} |
| 224 |
// Allow query-form feed params through when feed caching opted |
| 225 |
// this request in (?feed=rss2 / &withcomments=1 on feeds). |
| 226 |
if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) { |
| 227 |
continue; |
| 228 |
} |
| 229 |
if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) { |
| 230 |
return false; |
| 231 |
} |
| 232 |
} |
| 233 |
} |
| 234 |
|
| 235 |
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; |
| 236 |
$path = (string) strtok( $request_uri, '?' ); |
| 237 |
|
| 238 |
$excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array(); |
| 239 |
if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) { |
| 240 |
return false; |
| 241 |
} |
| 242 |
|
| 243 |
// Cookie-based exclusion. We only check cookie NAMES (matching |
| 244 |
// values would leak content-sensitive logic into the cache key |
| 245 |
// rules); presence of any matching cookie name skips cache. |
| 246 |
$excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array(); |
| 247 |
if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) { |
| 248 |
foreach ( array_keys( $_COOKIE ) as $cookie_name ) { |
| 249 |
if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) { |
| 250 |
return false; |
| 251 |
} |
| 252 |
} |
| 253 |
} |
| 254 |
|
| 255 |
// User-agent bypass list. Substring match (not glob) since UA |
| 256 |
// strings have so much variation that glob anchoring rarely |
| 257 |
// helps and confuses users. |
| 258 |
$bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array(); |
| 259 |
if ( ! empty( $bypass_uas ) ) { |
| 260 |
$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; |
| 261 |
foreach ( $bypass_uas as $needle ) { |
| 262 |
if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) { |
| 263 |
return false; |
| 264 |
} |
| 265 |
} |
| 266 |
} |
| 267 |
|
| 268 |
// Per-post override (Phase 3.4). Honored only on singular |
| 269 |
// post-context requests — archives / 404s / taxonomies use the |
| 270 |
// global policy above. |
| 271 |
if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) { |
| 272 |
return false; |
| 273 |
} |
| 274 |
|
| 275 |
/** |
| 276 |
* Final say on whether the current request is cacheable. |
| 277 |
* |
| 278 |
* Runs at template_redirect (full WP context), so listeners may use |
| 279 |
* conditional tags (is_search(), is_feed(), is_404(), |
| 280 |
* wp_is_maintenance_mode(), …). The core engine has already applied |
| 281 |
* its own exclusion rules and reached `true`; a listener returning |
| 282 |
* false vetoes caching for this request. This is the documented |
| 283 |
* extension point add-ons (xspeed-pro) hook to add their own |
| 284 |
* request-level cache policy without forking the engine. |
| 285 |
* |
| 286 |
* Note: this gates the WRITE side. The pre-WP drop-in |
| 287 |
* (advanced-cache.php) cannot run PHP filters, so request types that |
| 288 |
* must never be *served* from a stale file are handled by not |
| 289 |
* writing them here and/or by purging — see the conflict notes in |
| 290 |
* advanced-cache.php. |
| 291 |
* |
| 292 |
* @param bool $should_cache Whether to cache the current request. |
| 293 |
*/ |
| 294 |
return (bool) apply_filters( 'xspeed_should_cache', true ); |
| 295 |
} |
| 296 |
|
| 297 |
/** |
| 298 |
* Whether the current request is a 404 we may cache. |
| 299 |
* |
| 300 |
* True only when: it's a genuine main-query is_404(), an add-on opted |
| 301 |
* in via `xspeed_should_cache_404` (default false), and the request |
| 302 |
* isn't a transient 404 we must never freeze — maintenance mode or a |
| 303 |
* 404 emitted while the DB/site is in an error state. The xspeed-pro |
| 304 |
* 404 cache flips the filter; Free never caches 404s on its own. |
| 305 |
*/ |
| 306 |
public static function should_cache_404(): bool { |
| 307 |
if ( ! function_exists( 'is_404' ) || ! is_404() ) { |
| 308 |
return false; |
| 309 |
} |
| 310 |
// Never cache a 404 served because the site is down for |
| 311 |
// maintenance — that screen disappears the moment maintenance |
| 312 |
// ends, and a cached copy would outlive it. |
| 313 |
if ( function_exists( 'wp_is_maintenance_mode' ) && wp_is_maintenance_mode() ) { |
| 314 |
return false; |
| 315 |
} |
| 316 |
|
| 317 |
/** |
| 318 |
* Whether to cache the current 404 response. |
| 319 |
* |
| 320 |
* Default false. A listener returning true opts the (genuine) |
| 321 |
* 404 into the page cache, served back for any unknown URL under |
| 322 |
* one generic key. The 404 status is preserved on the HIT. |
| 323 |
* |
| 324 |
* @param bool $cache_404 Whether to cache this 404. |
| 325 |
*/ |
| 326 |
return (bool) apply_filters( 'xspeed_should_cache_404', false ); |
| 327 |
} |
| 328 |
|
| 329 |
/** |
| 330 |
* Whether the current request is an internal search-results page we |
| 331 |
* may cache. |
| 332 |
* |
| 333 |
* True only when: it's a genuine main-query is_search() with a |
| 334 |
* non-empty term, and an add-on opted in via `xspeed_should_cache_search` |
| 335 |
* (default false). The search term is folded into the cache key (see |
| 336 |
* search_term() / cache_key()) so different searches stay distinct. |
| 337 |
* The xspeed-pro search cache flips the filter; Free never caches |
| 338 |
* search results on its own. |
| 339 |
*/ |
| 340 |
public static function should_cache_search(): bool { |
| 341 |
if ( ! function_exists( 'is_search' ) || ! is_search() ) { |
| 342 |
return false; |
| 343 |
} |
| 344 |
// Empty search (`?s=`) renders the same as a normal archive and |
| 345 |
// carries no term to key on — let it fall through to the usual |
| 346 |
// rules rather than caching an ambiguous entry. |
| 347 |
if ( '' === self::search_term() ) { |
| 348 |
return false; |
| 349 |
} |
| 350 |
|
| 351 |
/** |
| 352 |
* Whether to cache the current search-results request. |
| 353 |
* |
| 354 |
* Default false. A listener returning true opts the search page |
| 355 |
* into the cache, keyed by the normalized search term. |
| 356 |
* |
| 357 |
* @param bool $cache_search Whether to cache this search request. |
| 358 |
*/ |
| 359 |
return (bool) apply_filters( 'xspeed_should_cache_search', false ); |
| 360 |
} |
| 361 |
|
| 362 |
/** |
| 363 |
* The current request's normalized search term, or '' if none. Reads |
| 364 |
* the raw `s` query param (works on the pre-WP drop-in path too, where |
| 365 |
* get_search_query() isn't available), trims + lowercases so |
| 366 |
* "WordPress" and "wordpress" share one entry, and collapses internal |
| 367 |
* whitespace. |
| 368 |
*/ |
| 369 |
public static function search_term(): string { |
| 370 |
$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. |
| 371 |
$raw = trim( $raw ); |
| 372 |
if ( '' === $raw ) { |
| 373 |
return ''; |
| 374 |
} |
| 375 |
$raw = preg_replace( '/\s+/', ' ', $raw ); |
| 376 |
return function_exists( 'mb_strtolower' ) ? mb_strtolower( $raw ) : strtolower( $raw ); |
| 377 |
} |
| 378 |
|
| 379 |
/** |
| 380 |
* Is this query-string key on the ignored-params allow-list? Supports |
| 381 |
* trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`, |
| 382 |
* etc.) so users don't have to enumerate every UTM variant. |
| 383 |
*/ |
| 384 |
private static function query_key_is_ignored( string $key, array $ignored ): bool { |
| 385 |
return Glob_Matcher::any_match( $ignored, $key ); |
| 386 |
} |
| 387 |
|
| 388 |
public static function cache_key() { |
| 389 |
$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default'; |
| 390 |
|
| 391 |
// Cacheable 404s share ONE generic per-host entry — keying them by |
| 392 |
// URL would let a scanner flood (millions of random paths) bloat |
| 393 |
// the cache with identical 404 bodies. Both the write and the HIT |
| 394 |
// lookup run through here, so they agree on the key automatically. |
| 395 |
if ( self::should_cache_404() ) { |
| 396 |
return md5( $host . '|404' ); |
| 397 |
} |
| 398 |
|
| 399 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; |
| 400 |
// Strip the query string from the key so /post and /post?utm_*=… |
| 401 |
// share the same cache entry. should_cache() above already |
| 402 |
// rejected requests with non-ignored params, so by the time we |
| 403 |
// build the key the only params left are safe to drop. |
| 404 |
$uri = (string) strtok( $uri, '?' ); |
| 405 |
|
| 406 |
// Optional device bucket: when mobile_separate is on, mobile and |
| 407 |
// desktop responses live in different cache files so themes that |
| 408 |
// serve different HTML by device (AMP, WPtouch, Jetpack mobile) |
| 409 |
// can't poison each other. |
| 410 |
$device = ''; |
| 411 |
$opts = Settings_Manager::get( 'cache' ); |
| 412 |
if ( ! empty( $opts['mobile_separate'] ) ) { |
| 413 |
$device = self::is_mobile_request() ? '|m' : '|d'; |
| 414 |
} |
| 415 |
|
| 416 |
// Search-results requests fold the normalized term into the key so |
| 417 |
// /?s=foo and /?s=bar get distinct entries (the query string is |
| 418 |
// otherwise stripped above). Only added when search caching opted |
| 419 |
// in, so non-search URLs are unaffected. |
| 420 |
$search = self::should_cache_search() ? '|s=' . self::search_term() : ''; |
| 421 |
|
| 422 |
// Query-form feeds (/?feed=rss2 vs /?feed=atom) share the same path |
| 423 |
// once the query is stripped, so fold the feed type into the key to |
| 424 |
// keep the flavors distinct. Pretty-permalink feeds (/feed/rss/) carry |
| 425 |
// the type in $uri already and are unaffected. (FBS-82407 #4) |
| 426 |
$feed = ''; |
| 427 |
if ( function_exists( 'is_feed' ) && is_feed() && function_exists( 'get_query_var' ) ) { |
| 428 |
$feed_type = (string) get_query_var( 'feed' ); |
| 429 |
if ( '' !== $feed_type ) { |
| 430 |
$feed = '|feed=' . preg_replace( '/[^a-z0-9]/i', '', $feed_type ); |
| 431 |
} |
| 432 |
} |
| 433 |
|
| 434 |
return md5( $host . $uri . $device . $search . $feed ); |
| 435 |
} |
| 436 |
|
| 437 |
/** |
| 438 |
* Server-side mobile detection. Prefers WordPress's `wp_is_mobile()` |
| 439 |
* which uses the same UA tokens as core (so our bucket aligns with |
| 440 |
* whatever theme-side branching uses). Falls back to a tiny inline |
| 441 |
* detector if wp_is_mobile() isn't loaded (e.g. the drop-in path). |
| 442 |
*/ |
| 443 |
private static function is_mobile_request(): bool { |
| 444 |
if ( function_exists( 'wp_is_mobile' ) ) { |
| 445 |
return (bool) wp_is_mobile(); |
| 446 |
} |
| 447 |
// Fallback for the rare context where wp_is_mobile() isn't loaded. |
| 448 |
// Mirrors core's wp_is_mobile() EXACTLY — including the |
| 449 |
// Sec-CH-UA-Mobile client hint it checks *before* UA tokens — so the |
| 450 |
// bucket this picks matches whatever the engine's primary path (and |
| 451 |
// the drop-in's own copy of this logic) would pick for the same |
| 452 |
// request. Drift here re-introduces the cross-path key mismatch. |
| 453 |
if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) { |
| 454 |
return '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE']; |
| 455 |
} |
| 456 |
$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; |
| 457 |
if ( '' === $ua ) { |
| 458 |
return false; |
| 459 |
} |
| 460 |
return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua ); |
| 461 |
} |
| 462 |
|
| 463 |
public static function cache_file_for( $key ) { |
| 464 |
return XSPEED_CACHE_DIR . '/' . $key . '.html'; |
| 465 |
} |
| 466 |
|
| 467 |
/** |
| 468 |
* If a precompressed Brotli sibling (`<file>.br`) exists and the client |
| 469 |
* advertises `Accept-Encoding: br`, emit the Brotli response headers and |
| 470 |
* return the `.br` path to stream. Returns null to fall through to the |
| 471 |
* plain file. Keeps the PHP serve path in parity with the web server's |
| 472 |
* static .br serving (mod_brotli / ngx_brotli rewrite). |
| 473 |
* |
| 474 |
* Free has no Brotli logic of its own — this only fires when an add-on |
| 475 |
* (the Pro Brotli module) actually wrote the .br, so it's a safe no-op |
| 476 |
* on Free-only installs. |
| 477 |
* |
| 478 |
* @param string $file Absolute path to the cached .html file. |
| 479 |
* @return string|null The .br path to stream, or null to serve $file. |
| 480 |
*/ |
| 481 |
public static function maybe_serve_brotli( string $file ): ?string { |
| 482 |
if ( headers_sent() ) { |
| 483 |
return null; |
| 484 |
} |
| 485 |
$accept = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) |
| 486 |
? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) ) |
| 487 |
: ''; |
| 488 |
// Match `br` as a token (comma/space delimited), not a substring, so |
| 489 |
// a hypothetical "xbr" encoding can't false-positive. |
| 490 |
if ( ! preg_match( '/(^|[\s,])br([\s,;]|$)/', $accept ) ) { |
| 491 |
return null; |
| 492 |
} |
| 493 |
$br = $file . '.br'; |
| 494 |
if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) { |
| 495 |
return null; |
| 496 |
} |
| 497 |
header( 'Content-Encoding: br' ); |
| 498 |
header( 'Vary: Accept-Encoding', false ); |
| 499 |
// The byte length changes for the compressed body — drop any |
| 500 |
// Content-Length the caller may have set so the stream isn't |
| 501 |
// truncated/padded. readfile() lets the SAPI set the right length. |
| 502 |
header_remove( 'Content-Length' ); |
| 503 |
return $br; |
| 504 |
} |
| 505 |
|
| 506 |
/** |
| 507 |
* Sidecar metadata file for a cache entry. Holds response bits the HIT |
| 508 |
* path must replay — Content-Type (cached feeds → application/rss+xml, |
| 509 |
* sitemaps → text/xml) and status (a cached 404 must serve 404, not |
| 510 |
* 200). JSON, one tiny file per entry, written only when there's |
| 511 |
* something non-default to replay. |
| 512 |
*/ |
| 513 |
public static function cache_meta_for( $key ) { |
| 514 |
return XSPEED_CACHE_DIR . '/' . $key . '.meta'; |
| 515 |
} |
| 516 |
|
| 517 |
/** |
| 518 |
* Read the .meta sidecar for a cache entry as an array, or [] if none. |
| 519 |
* Keys: 'content_type' (string), 'status' (int). Used on the HIT path |
| 520 |
* to replay them before streaming the file. |
| 521 |
*/ |
| 522 |
private static function read_meta( $key ): array { |
| 523 |
$meta_file = self::cache_meta_for( $key ); |
| 524 |
if ( ! file_exists( $meta_file ) ) { |
| 525 |
return array(); |
| 526 |
} |
| 527 |
// 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. |
| 528 |
$raw = file_get_contents( $meta_file ); |
| 529 |
$data = json_decode( (string) $raw, true ); |
| 530 |
return is_array( $data ) ? $data : array(); |
| 531 |
} |
| 532 |
|
| 533 |
/** |
| 534 |
* Conditional-GET support for a cache HIT. Emits Last-Modified + ETag |
| 535 |
* derived from the cache file's mtime, and — when the request's |
| 536 |
* If-Modified-Since / If-None-Match still match — sends 304 Not Modified |
| 537 |
* and returns true (caller should exit without a body). Returns false to |
| 538 |
* proceed with a normal 200 body. Lets aggregators/browsers skip |
| 539 |
* re-downloading an unchanged cached response. (FBS-82407 #5) |
| 540 |
* |
| 541 |
* @param string $file Absolute path to the cache .html file. |
| 542 |
* @return bool True when a 304 was sent. |
| 543 |
*/ |
| 544 |
public static function serve_not_modified( string $file ): bool { |
| 545 |
$mtime = (int) filemtime( $file ); |
| 546 |
if ( $mtime <= 0 ) { |
| 547 |
return false; |
| 548 |
} |
| 549 |
$last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT'; |
| 550 |
$etag = '"' . md5( $file . '|' . $mtime ) . '"'; |
| 551 |
header( 'Last-Modified: ' . $last_modified ); |
| 552 |
header( 'ETag: ' . $etag ); |
| 553 |
|
| 554 |
$ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : ''; |
| 555 |
$inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : ''; |
| 556 |
|
| 557 |
$etag_match = '' !== $inm && false !== strpos( $inm, $etag ); |
| 558 |
$time_match = '' !== $ims && ( strtotime( $ims ) >= $mtime ); |
| 559 |
|
| 560 |
if ( $etag_match || $time_match ) { |
| 561 |
if ( function_exists( 'http_response_code' ) ) { |
| 562 |
http_response_code( 304 ); |
| 563 |
} |
| 564 |
return true; |
| 565 |
} |
| 566 |
return false; |
| 567 |
} |
| 568 |
|
| 569 |
public static function is_expired( $file ) { |
| 570 |
// cache_expiry now owned by CacheModule; per-post override |
| 571 |
// (Phase 3.4) shrinks the TTL further when the editor set one. |
| 572 |
$opts = Settings_Manager::get( 'cache' ); |
| 573 |
$max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS; |
| 574 |
$post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() ); |
| 575 |
if ( null !== $post_override ) { |
| 576 |
$max_age = $post_override; |
| 577 |
} |
| 578 |
|
| 579 |
/** |
| 580 |
* Filter the max-age (seconds) for the current cache entry. |
| 581 |
* |
| 582 |
* Lets an add-on apply a request-type-specific TTL — e.g. the |
| 583 |
* xspeed-pro feed cache gives feeds a longer expiry than pages, |
| 584 |
* since aggregators tolerate more staleness. Return seconds. |
| 585 |
* |
| 586 |
* @param int $max_age Computed max-age in seconds. |
| 587 |
*/ |
| 588 |
$max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age ); |
| 589 |
|
| 590 |
return ( time() - filemtime( $file ) ) > $max_age; |
| 591 |
} |
| 592 |
|
| 593 |
/** |
| 594 |
* Accumulator for the full response body across all output-handler phases. |
| 595 |
* |
| 596 |
* PHP invokes an ob_start() callback once per flush, and each invocation |
| 597 |
* only receives the chunk produced *since the previous flush*. If anything |
| 598 |
* during the render calls `ob_flush()` or `flush()` (some themes, lazy- |
| 599 |
* load plugins, AMP, etc. do), the final-phase call would otherwise only |
| 600 |
* see the tail of the page — and we'd cache a truncated response that |
| 601 |
* gets served repeatedly until purge. We accumulate every chunk here so |
| 602 |
* the cache file always reflects the complete page. |
| 603 |
* |
| 604 |
* @var string |
| 605 |
*/ |
| 606 |
private static $accumulated = ''; |
| 607 |
|
| 608 |
public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) { |
| 609 |
self::$accumulated .= $buffer; |
| 610 |
|
| 611 |
// On non-final phases (mid-request flushes), pass the current chunk |
| 612 |
// through to the client unmodified and keep collecting. The WP 6.9 |
| 613 |
// filter path always passes the full body in one shot with the |
| 614 |
// default $phase, so it falls straight through to the final block. |
| 615 |
$is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0; |
| 616 |
if ( ! $is_final ) { |
| 617 |
return $buffer; |
| 618 |
} |
| 619 |
|
| 620 |
$full = self::$accumulated; |
| 621 |
self::$accumulated = ''; |
| 622 |
|
| 623 |
if ( strlen( $full ) < 255 ) { |
| 624 |
return $buffer; |
| 625 |
} |
| 626 |
|
| 627 |
// Status gate. We cache 200 by default. A 404 may be cached too, |
| 628 |
// but only when an add-on (xspeed-pro 404 cache) opts in for a |
| 629 |
// genuine is_404() — never a transient 404 (maintenance screen, |
| 630 |
// DB error, or a 404 emitted outside the main query), which would |
| 631 |
// otherwise be frozen until purge. Any other status is skipped. |
| 632 |
$status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200; |
| 633 |
if ( 200 !== $status ) { |
| 634 |
if ( 404 !== $status || ! self::should_cache_404() ) { |
| 635 |
return $buffer; |
| 636 |
} |
| 637 |
} |
| 638 |
|
| 639 |
// If no mid-request flush happened, $buffer === $full and we can |
| 640 |
// safely minify the on-wire bytes too. Otherwise earlier chunks have |
| 641 |
// already been sent unminified, so we minify only what goes to disk — |
| 642 |
// the first visitor sees unminified HTML, every cache hit after that |
| 643 |
// is minified. |
| 644 |
$single_chunk = ( $buffer === $full ); |
| 645 |
|
| 646 |
// minify_html now owned by the Minify module; read through the |
| 647 |
// module's storage so this stays consistent with the engine that |
| 648 |
// applies CSS/JS minification. |
| 649 |
$minify_opts = Settings_Manager::get( 'minify' ); |
| 650 |
if ( ! empty( $minify_opts['minify_html'] ) ) { |
| 651 |
$full = Minifier::minify_html( $full ); |
| 652 |
if ( $single_chunk ) { |
| 653 |
$buffer = $full; |
| 654 |
} |
| 655 |
} |
| 656 |
|
| 657 |
if ( ! file_exists( XSPEED_CACHE_DIR ) ) { |
| 658 |
wp_mkdir_p( XSPEED_CACHE_DIR ); |
| 659 |
self::write_silence( XSPEED_CACHE_DIR ); |
| 660 |
} |
| 661 |
|
| 662 |
// Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'` |
| 663 |
// where $key comes from md5() — guaranteed to be exactly 32 lowercase |
| 664 |
// hex chars, so no traversal sequence ('..', '/', null byte, etc.) |
| 665 |
// can appear. The write is therefore always inside XSPEED_CACHE_DIR. |
| 666 |
$key = self::cache_key(); |
| 667 |
$file = self::cache_file_for( $key ); |
| 668 |
// 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. |
| 669 |
file_put_contents( $file, $full, LOCK_EX ); |
| 670 |
|
| 671 |
// Persist a non-default Content-Type so the HIT path can replay it |
| 672 |
// (cached feeds must serve application/rss+xml, not text/html). |
| 673 |
// Only written when the response set a content-type other than |
| 674 |
// the HTML default — pages don't pay for an extra file. |
| 675 |
self::write_meta( $key ); |
| 676 |
|
| 677 |
// Static-cache tree (xspeed-static/{host}{path}/index.html). The |
| 678 |
// .htaccess rewrite block serves this file directly via the web |
| 679 |
// server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path. |
| 680 |
// store_static() returns silently on any path/permission issue — |
| 681 |
// the drop-in remains the safety net. |
| 682 |
// |
| 683 |
// Skip it entirely when mobile_separate is on: the rewrite is |
| 684 |
// disabled in that mode (static_rewrite_allowed()), so a static file |
| 685 |
// would only be dead weight — and a device-blind one at that. |
| 686 |
// Skip the static-tree write for responses the web server can't replay |
| 687 |
// correctly: a non-200 status (a cached 404 would be served as a soft |
| 688 |
// 200, FBS-82406) or a non-HTML content-type (a cached feed would go |
| 689 |
// out as text/html, FBS-82407). The web server serves these .html files |
| 690 |
// directly with no PHP, so there's no .meta replay — keep them on the |
| 691 |
// drop-in / PHP path instead, which DOES replay status + content-type. |
| 692 |
if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) { |
| 693 |
self::store_static( $full ); |
| 694 |
} |
| 695 |
|
| 696 |
return $buffer; |
| 697 |
} |
| 698 |
|
| 699 |
/** |
| 700 |
* Write the current response to the static-cache tree at |
| 701 |
* `xspeed-static/{host}{request_uri}/index.html`. The web-server |
| 702 |
* rewrite block points at this path so cache hits skip PHP |
| 703 |
* entirely. Caller already minified/finalized $html. |
| 704 |
* |
| 705 |
* Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist; |
| 706 |
* $uri has its query string stripped, null bytes removed, '..' |
| 707 |
* sequences collapsed, and after concatenation we verify the |
| 708 |
* resolved real path stays inside XSPEED_CACHE_STATIC_DIR before |
| 709 |
* any write. Anything off the happy path returns silently. |
| 710 |
*/ |
| 711 |
private static function store_static( string $html ): void { |
| 712 |
$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; |
| 713 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; |
| 714 |
$host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host ); |
| 715 |
$uri = str_replace( "\0", '', $uri ); |
| 716 |
$uri = (string) strtok( $uri, '?' ); |
| 717 |
if ( '' === $host || '' === $uri ) { |
| 718 |
return; |
| 719 |
} |
| 720 |
// Collapse any traversal sequences before path resolution. |
| 721 |
$uri = preg_replace( '#/+#', '/', $uri ); |
| 722 |
if ( false !== strpos( $uri, '..' ) ) { |
| 723 |
return; |
| 724 |
} |
| 725 |
|
| 726 |
$base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ); |
| 727 |
$dir = $base . '/' . $host . rtrim( $uri, '/' ); |
| 728 |
$file = $dir . '/index.html'; |
| 729 |
|
| 730 |
// Resolve the parent against the cache root to be sure the |
| 731 |
// final path is inside our tree even if the OS does anything |
| 732 |
// funny with multi-byte sequences. |
| 733 |
$base_real = realpath( WP_CONTENT_DIR ); |
| 734 |
if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) { |
| 735 |
return; |
| 736 |
} |
| 737 |
|
| 738 |
if ( ! file_exists( $dir ) ) { |
| 739 |
wp_mkdir_p( $dir ); |
| 740 |
} |
| 741 |
if ( ! is_dir( $dir ) ) { |
| 742 |
return; |
| 743 |
} |
| 744 |
// 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. |
| 745 |
$written = file_put_contents( $file, $html, LOCK_EX ); |
| 746 |
|
| 747 |
if ( false !== $written ) { |
| 748 |
/** |
| 749 |
* Fires after a static cache file (index.html) is written. |
| 750 |
* |
| 751 |
* The extension point for serving pre-compressed siblings: |
| 752 |
* the xspeed-pro Brotli module writes `index.html.br` next to |
| 753 |
* the file here so the web server's static rewrite can serve a |
| 754 |
* Brotli copy to clients that advertise `Accept-Encoding: br`, |
| 755 |
* falling back to GZIP / the plain file otherwise. No core |
| 756 |
* behavior depends on a listener being present. |
| 757 |
* |
| 758 |
* @param string $file Absolute path to the static cache file just written. |
| 759 |
* @param string $html The HTML written to it. |
| 760 |
*/ |
| 761 |
do_action( 'xspeed_static_file_written', $file, $html ); |
| 762 |
} |
| 763 |
} |
| 764 |
|
| 765 |
/** |
| 766 |
* Write the .meta sidecar for a cache entry when the response carries |
| 767 |
* anything the HIT path must replay beyond a plain 200 text/html: |
| 768 |
* - a non-HTML Content-Type (cached feeds → application/rss+xml, |
| 769 |
* sitemaps → text/xml, …), and/or |
| 770 |
* - a non-200 status (a cached 404 must serve 404, not 200). |
| 771 |
* |
| 772 |
* Ordinary 200 text/html pages get NO .meta file, so the common path |
| 773 |
* stays a single write. |
| 774 |
* |
| 775 |
* @param string $key Cache key for the current request. |
| 776 |
*/ |
| 777 |
/** |
| 778 |
* True only for a plain 200 text/html response — the only kind the |
| 779 |
* web-server static tree can serve correctly (it streams the .html with |
| 780 |
* no PHP, so it can't replay a 404 status or a feed Content-Type). Used |
| 781 |
* to gate store_static() so cached 404s / feeds stay on the replay-capable |
| 782 |
* drop-in / PHP path. (FBS-82406, FBS-82407) |
| 783 |
*/ |
| 784 |
private static function response_is_plain_html(): bool { |
| 785 |
$status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200; |
| 786 |
if ( 200 !== $status && $status > 0 ) { |
| 787 |
return false; |
| 788 |
} |
| 789 |
foreach ( headers_list() as $header ) { |
| 790 |
if ( 0 === stripos( $header, 'content-type:' ) ) { |
| 791 |
$ct = trim( substr( $header, strlen( 'content-type:' ) ) ); |
| 792 |
if ( '' !== $ct && false === stripos( $ct, 'text/html' ) ) { |
| 793 |
return false; |
| 794 |
} |
| 795 |
} |
| 796 |
} |
| 797 |
return true; |
| 798 |
} |
| 799 |
|
| 800 |
private static function write_meta( string $key ): void { |
| 801 |
$content_type = ''; |
| 802 |
foreach ( headers_list() as $header ) { |
| 803 |
if ( 0 === stripos( $header, 'content-type:' ) ) { |
| 804 |
$content_type = trim( substr( $header, strlen( 'content-type:' ) ) ); |
| 805 |
} |
| 806 |
} |
| 807 |
$status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200; |
| 808 |
|
| 809 |
$meta = array(); |
| 810 |
$is_default_type = ( '' === $content_type || false !== stripos( $content_type, 'text/html' ) ); |
| 811 |
if ( ! $is_default_type ) { |
| 812 |
$meta['content_type'] = $content_type; |
| 813 |
} |
| 814 |
if ( 200 !== $status && $status > 0 ) { |
| 815 |
$meta['status'] = $status; |
| 816 |
} |
| 817 |
|
| 818 |
// Per-content TTL (seconds). The drop-in and static fast paths can't |
| 819 |
// call is_expired() / the xspeed_cache_max_age filter (they run before |
| 820 |
// WP), so persist the resolved max-age here whenever it differs from |
| 821 |
// the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page |
| 822 |
// default. The fast paths read this to expire correctly. (FBS-82407) |
| 823 |
$opts = Settings_Manager::get( 'cache' ); |
| 824 |
$default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS; |
| 825 |
$ttl = (int) apply_filters( 'xspeed_cache_max_age', $default_ttl ); |
| 826 |
if ( $ttl > 0 && $ttl !== $default_ttl ) { |
| 827 |
$meta['ttl'] = $ttl; |
| 828 |
} |
| 829 |
|
| 830 |
// Nothing to replay → no sidecar. |
| 831 |
if ( empty( $meta ) ) { |
| 832 |
return; |
| 833 |
} |
| 834 |
|
| 835 |
$payload = wp_json_encode( $meta ); |
| 836 |
if ( false === $payload ) { |
| 837 |
return; |
| 838 |
} |
| 839 |
// 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. |
| 840 |
file_put_contents( self::cache_meta_for( $key ), $payload, LOCK_EX ); |
| 841 |
} |
| 842 |
|
| 843 |
/** |
| 844 |
* @param string $cause Free-form human reason. Recorded in the |
| 845 |
* Activity log to give users context (e.g. |
| 846 |
* 'post saved', 'settings change', 'manual', |
| 847 |
* 'theme switch'). |
| 848 |
*/ |
| 849 |
public static function purge_all( string $cause = 'manual' ) { |
| 850 |
$count = 0; |
| 851 |
if ( is_dir( XSPEED_CACHE_DIR ) ) { |
| 852 |
$files = glob( XSPEED_CACHE_DIR . '/*.html' ); |
| 853 |
if ( $files ) { |
| 854 |
$count = count( $files ); |
| 855 |
foreach ( $files as $f ) { |
| 856 |
wp_delete_file( $f ); |
| 857 |
} |
| 858 |
} |
| 859 |
// Remove the .meta sidecars (content-type for feeds/sitemaps) |
| 860 |
// alongside their .html entries. Not counted — they're not |
| 861 |
// cache "pages", just per-entry metadata. |
| 862 |
$meta = glob( XSPEED_CACHE_DIR . '/*.meta' ); |
| 863 |
if ( $meta ) { |
| 864 |
foreach ( $meta as $m ) { |
| 865 |
wp_delete_file( $m ); |
| 866 |
} |
| 867 |
} |
| 868 |
// Remove precompressed siblings (e.g. <key>.html.br from the Pro |
| 869 |
// Brotli module). Not counted — same as .meta. Without this a |
| 870 |
// purge leaves stale .br bodies behind: disk bloat, and a |
| 871 |
// staleness window if precompression is later disabled. |
| 872 |
$br = glob( XSPEED_CACHE_DIR . '/*.br' ); |
| 873 |
if ( $br ) { |
| 874 |
foreach ( $br as $b ) { |
| 875 |
wp_delete_file( $b ); |
| 876 |
} |
| 877 |
} |
| 878 |
} |
| 879 |
// Static-cache tree purge — recursive because the layout is |
| 880 |
// xspeed-static/{host}/{path}/index.html, so a flat glob can't |
| 881 |
// reach everything. |
| 882 |
if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) { |
| 883 |
$count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR ); |
| 884 |
} |
| 885 |
// REST response cache (cache/xspeed/rest/*.json) — same purge |
| 886 |
// triggers (publish, settings change) invalidate it too. |
| 887 |
$count += Rest_Cache::purge(); |
| 888 |
self::update_stats( array( 'last_purge' => time() ) ); |
| 889 |
|
| 890 |
// Trigger of WP_CLI / hook / admin-bar purges all hit the same |
| 891 |
// path. Record once with the supplied cause so the dashboard |
| 892 |
// activity feed reads naturally. |
| 893 |
Activity_Log::record( |
| 894 |
'cache_purged', |
| 895 |
sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ), |
| 896 |
Activity_Log::INFO |
| 897 |
); |
| 898 |
} |
| 899 |
|
| 900 |
/** |
| 901 |
* Recursively delete every `index.html` (and its precompressed |
| 902 |
* `index.html.br` sibling, if the Pro Brotli module wrote one) plus |
| 903 |
* empty directories inside the static-cache tree. Used by purge_all(). |
| 904 |
* Returns the number of .html files removed so purge stats stay accurate |
| 905 |
* across the flat + static caches — .br siblings are not counted |
| 906 |
* (they're encodings of a page, not pages). |
| 907 |
*/ |
| 908 |
private static function rmtree_html( string $dir ): int { |
| 909 |
if ( ! is_dir( $dir ) ) { |
| 910 |
return 0; |
| 911 |
} |
| 912 |
$removed = 0; |
| 913 |
// SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk |
| 914 |
// the whole tree regardless of order. |
| 915 |
$entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 916 |
if ( false === $entries ) { |
| 917 |
return 0; |
| 918 |
} |
| 919 |
foreach ( $entries as $entry ) { |
| 920 |
if ( '.' === $entry || '..' === $entry ) { |
| 921 |
continue; |
| 922 |
} |
| 923 |
$path = $dir . '/' . $entry; |
| 924 |
if ( is_dir( $path ) ) { |
| 925 |
$removed += self::rmtree_html( $path ); |
| 926 |
// Best-effort empty-dir cleanup; ignore failures (a |
| 927 |
// foreign file inside would block rmdir, which is fine). |
| 928 |
// 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. |
| 929 |
@rmdir( $path ); |
| 930 |
continue; |
| 931 |
} |
| 932 |
if ( substr( $entry, -5 ) === '.html' ) { |
| 933 |
wp_delete_file( $path ); |
| 934 |
++$removed; |
| 935 |
} elseif ( substr( $entry, -3 ) === '.br' ) { |
| 936 |
// Precompressed sibling (index.html.br). Remove it too so a |
| 937 |
// purge doesn't orphan stale Brotli bodies. Not counted. |
| 938 |
wp_delete_file( $path ); |
| 939 |
} |
| 940 |
} |
| 941 |
return $removed; |
| 942 |
} |
| 943 |
|
| 944 |
/** |
| 945 |
* Drop a "silence is golden" index.php into a directory so apaches/nginx |
| 946 |
* with directory listing enabled don't expose cache contents. |
| 947 |
*/ |
| 948 |
public static function write_silence( $dir ) { |
| 949 |
$file = trailingslashit( $dir ) . 'index.php'; |
| 950 |
if ( ! file_exists( $file ) ) { |
| 951 |
// 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. |
| 952 |
file_put_contents( $file, "<?php\n// Silence is golden.\n" ); |
| 953 |
} |
| 954 |
} |
| 955 |
|
| 956 |
/** |
| 957 |
* Persist stats with autoload disabled — stats are only read in admin |
| 958 |
* contexts, so there is no reason to inflate every frontend request's |
| 959 |
* `wp_load_alloptions()` payload. |
| 960 |
*/ |
| 961 |
private static function update_stats( array $stats ) { |
| 962 |
if ( false === get_option( 'xspeed_stats' ) ) { |
| 963 |
add_option( 'xspeed_stats', $stats, '', 'no' ); |
| 964 |
return; |
| 965 |
} |
| 966 |
update_option( 'xspeed_stats', $stats ); |
| 967 |
} |
| 968 |
|
| 969 |
public static function get_stats() { |
| 970 |
$count = 0; |
| 971 |
$size = 0; |
| 972 |
if ( is_dir( XSPEED_CACHE_DIR ) ) { |
| 973 |
$files = glob( XSPEED_CACHE_DIR . '/*.html' ); |
| 974 |
if ( $files ) { |
| 975 |
$count = count( $files ); |
| 976 |
foreach ( $files as $f ) { |
| 977 |
$size += filesize( $f ); |
| 978 |
} |
| 979 |
} |
| 980 |
} |
| 981 |
// Drain the HIT-log file BEFORE reading totals. Two serve paths that |
| 982 |
// bypass the normal in-PHP record_hit() append one line per HIT here: |
| 983 |
// the nginx server-level rewrite (see nginx_snippet(), never reaches |
| 984 |
// PHP) and the advanced-cache.php drop-in (runs pre-WordPress, can't |
| 985 |
// reach Hit_Counter). Without this drain both look like a 0% hit-ratio |
| 986 |
// on a perfectly working cache. |
| 987 |
Hit_Counter::collect_nginx_log_hits(); |
| 988 |
|
| 989 |
// Apache/LiteSpeed static-rewrite HITs are served straight from disk |
| 990 |
// by .htaccess and never reach PHP either — but there's no .htaccess |
| 991 |
// equivalent of nginx's access_log directive, so we count them by |
| 992 |
// scanning the web server's own access log incrementally. No-op when |
| 993 |
// the log isn't readable (managed hosts) — see the method docblock. |
| 994 |
Hit_Counter::collect_server_log_hits(); |
| 995 |
|
| 996 |
$stats = get_option( 'xspeed_stats', array() ); |
| 997 |
$totals = Hit_Counter::totals_24h(); |
| 998 |
return array( |
| 999 |
'cached_pages' => $count, |
| 1000 |
'cache_size' => $size, |
| 1001 |
'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0, |
| 1002 |
// Rolling 24h cache performance — sourced from Hit_Counter's |
| 1003 |
// hourly buckets. The frontend uses hit_ratio to drive the |
| 1004 |
// CacheHero stat grid + the Health module's panel. |
| 1005 |
'hits_24h' => $totals['hits'], |
| 1006 |
'misses_24h' => $totals['misses'], |
| 1007 |
'hit_ratio' => $totals['ratio'], |
| 1008 |
); |
| 1009 |
} |
| 1010 |
|
| 1011 |
/** |
| 1012 |
* Apply the user's enable/disable choice. Called only from the REST |
| 1013 |
* toggle endpoint, which is gated by current_user_can( 'manage_options' ) |
| 1014 |
* and a verified REST nonce. This is the only place the drop-in and |
| 1015 |
* the WP_CACHE constant are written — they MUST NOT happen on |
| 1016 |
* register_activation_hook (WordPress.org review requirement). |
| 1017 |
* |
| 1018 |
* @param bool $enable User's choice. |
| 1019 |
* @return array{ |
| 1020 |
* enabled: bool, |
| 1021 |
* dropin_installed: bool, |
| 1022 |
* wp_cache_constant: bool, |
| 1023 |
* wp_config_writable: bool, |
| 1024 |
* manual_snippet: ?string |
| 1025 |
* } |
| 1026 |
*/ |
| 1027 |
public static function toggle( $enable ) { |
| 1028 |
$enable = (bool) $enable; |
| 1029 |
|
| 1030 |
if ( $enable ) { |
| 1031 |
$dropin_ok = self::install_dropin(); |
| 1032 |
$wp_config_ok = self::set_wp_cache_constant( true ); |
| 1033 |
$rewrite_ok = self::install_rewrite(); |
| 1034 |
self::ensure_hits_log_file(); |
| 1035 |
self::sync_mobile_flag(); |
| 1036 |
$snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );"; |
| 1037 |
|
| 1038 |
Activity_Log::record( |
| 1039 |
'cache_enabled_event', |
| 1040 |
$wp_config_ok |
| 1041 |
? 'Cache enabled. Drop-in installed, WP_CACHE constant set.' |
| 1042 |
: 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.', |
| 1043 |
$wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN |
| 1044 |
); |
| 1045 |
|
| 1046 |
return array( |
| 1047 |
'enabled' => true, |
| 1048 |
'dropin_installed' => (bool) $dropin_ok, |
| 1049 |
'wp_cache_constant' => (bool) $wp_config_ok, |
| 1050 |
'rewrite_installed' => (bool) $rewrite_ok, |
| 1051 |
'wp_config_writable' => self::wp_config_writable(), |
| 1052 |
'manual_snippet' => $snippet, |
| 1053 |
'nginx_snippet' => self::nginx_snippet(), |
| 1054 |
// Unified server-block snippet aggregating every enabled |
| 1055 |
// module's directives — the same value the dashboard and |
| 1056 |
// Health insight render. The wizard shows this so all three |
| 1057 |
// surfaces stay in lockstep. Null on non-nginx hosts. |
| 1058 |
'nginx_server_block' => self::full_nginx_server_block(), |
| 1059 |
); |
| 1060 |
} |
| 1061 |
|
| 1062 |
self::remove_dropin(); |
| 1063 |
self::set_wp_cache_constant( false ); |
| 1064 |
self::remove_rewrite(); |
| 1065 |
// Drop the device-bucket marker too — with the drop-in gone there's |
| 1066 |
// nothing left to read it, and leaving it behind would dirty a fresh |
| 1067 |
// re-enable (and leaks across test runs). |
| 1068 |
self::sync_mobile_flag( false ); |
| 1069 |
|
| 1070 |
Activity_Log::record( |
| 1071 |
'cache_disabled_event', |
| 1072 |
'Cache disabled. Drop-in removed.', |
| 1073 |
Activity_Log::INFO |
| 1074 |
); |
| 1075 |
|
| 1076 |
return array( |
| 1077 |
'enabled' => false, |
| 1078 |
'dropin_installed' => false, |
| 1079 |
'wp_cache_constant' => false, |
| 1080 |
'rewrite_installed' => false, |
| 1081 |
'wp_config_writable' => self::wp_config_writable(), |
| 1082 |
'manual_snippet' => null, |
| 1083 |
'nginx_snippet' => self::nginx_snippet(), |
| 1084 |
'nginx_server_block' => self::full_nginx_server_block(), |
| 1085 |
); |
| 1086 |
} |
| 1087 |
|
| 1088 |
/** |
| 1089 |
* Check wp-config.php writability via WP_Filesystem. Plugin Check flags |
| 1090 |
* direct is_writable() under WordPress.WP.AlternativeFunctions. |
| 1091 |
*/ |
| 1092 |
private static function wp_config_writable() { |
| 1093 |
global $wp_filesystem; |
| 1094 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 1095 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 1096 |
} |
| 1097 |
WP_Filesystem(); |
| 1098 |
|
| 1099 |
return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false; |
| 1100 |
} |
| 1101 |
|
| 1102 |
/** |
| 1103 |
* Nginx server-block snippet mirroring the Apache rewrite block. |
| 1104 |
* We never auto-write nginx config — it sits outside the WordPress |
| 1105 |
* root and is owned by the server admin — but the dashboard |
| 1106 |
* surfaces this snippet when nginx is detected so the admin can |
| 1107 |
* paste it once and unlock the same PHP-bypass speedup we get on |
| 1108 |
* Apache / LiteSpeed via .htaccess. |
| 1109 |
* |
| 1110 |
* Returns null when the server isn't nginx (no point showing it). |
| 1111 |
*/ |
| 1112 |
/** |
| 1113 |
* Create wp-content/cache/xspeed/hits.log as an empty file so the |
| 1114 |
* server-level rewrite's `access_log` directive has somewhere to |
| 1115 |
* write on first request. Idempotent — touches an existing file |
| 1116 |
* without disturbing accumulated lines. Called from Cache::toggle() |
| 1117 |
* on enable and from auto_heal() when the file is missing. |
| 1118 |
* |
| 1119 |
* Permissions matter here. The file is created by PHP-FPM (often uid |
| 1120 |
* www-data), but the nginx process that appends HIT lines may run as a |
| 1121 |
* DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in |
| 1122 |
* its own container as uid `nginx`, PHP-FPM in another as `www-data`) |
| 1123 |
* they don't share a user at all. A default-umask 0644 file is then |
| 1124 |
* unwritable by nginx, the access_log write silently fails, and the |
| 1125 |
* dashboard shows a 0% hit ratio even though static HITs are serving. |
| 1126 |
* So we widen the dir to 0777 and the file to 0666 — group/other write — |
| 1127 |
* so whatever uid nginx runs as can append. (The file holds only HIT |
| 1128 |
* request lines, no secrets.) |
| 1129 |
*/ |
| 1130 |
/** |
| 1131 |
* Directory holding the nginx hit log. Lives under uploads/, NOT the |
| 1132 |
* cache dir — uninstall.php and a cache purge both delete the cache |
| 1133 |
* dir, which would orphan the pasted nginx `access_log` directive's |
| 1134 |
* parent directory and make `nginx -t` fail [emerg], taking down every |
| 1135 |
* vhost on the host (FBS-82478). uploads/ always exists, isn't a |
| 1136 |
* plugin-managed cache dir, and is never deleted on uninstall — so the |
| 1137 |
* directive's target dir survives both, and nginx (which creates a |
| 1138 |
* missing log FILE but not a missing DIR) can always open it. |
| 1139 |
* |
| 1140 |
* Falls back to the cache dir only if uploads is somehow unavailable. |
| 1141 |
*/ |
| 1142 |
public static function hits_log_dir(): string { |
| 1143 |
if ( function_exists( 'wp_upload_dir' ) ) { |
| 1144 |
$uploads = wp_upload_dir( null, false ); |
| 1145 |
if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) { |
| 1146 |
return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed'; |
| 1147 |
} |
| 1148 |
} |
| 1149 |
return XSPEED_CACHE_DIR; |
| 1150 |
} |
| 1151 |
|
| 1152 |
/** Absolute path to the nginx hit log file. */ |
| 1153 |
public static function hits_log_path(): string { |
| 1154 |
return self::hits_log_dir() . '/hits.log'; |
| 1155 |
} |
| 1156 |
|
| 1157 |
/** |
| 1158 |
* Sync the drop-in's mobile-bucket flag file with the `mobile_separate` |
| 1159 |
* setting. The drop-in (advanced-cache.php) runs before WordPress loads, |
| 1160 |
* so it can't read the option — instead it checks for a zero-byte |
| 1161 |
* `.mobile-separate` marker next to the cache files. When the setting is |
| 1162 |
* on we touch the marker; when off we remove it. The drop-in's cache_key |
| 1163 |
* computation keys off the marker's presence so its '|m'/'|d' device |
| 1164 |
* bucket stays in lockstep with Cache::cache_key(). |
| 1165 |
* |
| 1166 |
* Without this, turning on mobile_separate made Cache::store() write keys |
| 1167 |
* with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's |
| 1168 |
* file_exists() always missed, every HIT fell through to a full WP boot, |
| 1169 |
* and the fast pre-WP path was silently dead. |
| 1170 |
* |
| 1171 |
* @param bool|null $enabled Force a state; null reads the current setting. |
| 1172 |
*/ |
| 1173 |
public static function sync_mobile_flag( $enabled = null ): void { |
| 1174 |
if ( null === $enabled ) { |
| 1175 |
$opts = Settings_Manager::get( 'cache' ); |
| 1176 |
$enabled = ! empty( $opts['mobile_separate'] ); |
| 1177 |
} |
| 1178 |
$dir = XSPEED_CACHE_DIR; |
| 1179 |
$flag = $dir . '/.mobile-separate'; |
| 1180 |
if ( $enabled ) { |
| 1181 |
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { |
| 1182 |
return; |
| 1183 |
} |
| 1184 |
if ( ! file_exists( $flag ) ) { |
| 1185 |
// 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. |
| 1186 |
@touch( $flag ); |
| 1187 |
} |
| 1188 |
return; |
| 1189 |
} |
| 1190 |
if ( file_exists( $flag ) ) { |
| 1191 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. |
| 1192 |
@unlink( $flag ); |
| 1193 |
} |
| 1194 |
} |
| 1195 |
|
| 1196 |
/** |
| 1197 |
* Write / remove the `.maintenance-active` sentinel next to the cache |
| 1198 |
* files. The pre-WP drop-in checks for this marker and bails when present, |
| 1199 |
* so a page cached while the site was live is NOT served during |
| 1200 |
* maintenance / coming-soon mode — WordPress loads and renders the |
| 1201 |
* maintenance screen instead. The Pro Maintenance-Cache module drives this |
| 1202 |
* on the maintenance on/off transition. (FBS-82409 B1) |
| 1203 |
* |
| 1204 |
* @param bool $active True to arm the sentinel (entering maintenance), |
| 1205 |
* false to clear it (site recovered). |
| 1206 |
*/ |
| 1207 |
public static function sync_maintenance_flag( bool $active ): void { |
| 1208 |
$dir = XSPEED_CACHE_DIR; |
| 1209 |
$flag = $dir . '/.maintenance-active'; |
| 1210 |
if ( $active ) { |
| 1211 |
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { |
| 1212 |
return; |
| 1213 |
} |
| 1214 |
if ( ! file_exists( $flag ) ) { |
| 1215 |
// 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. |
| 1216 |
@touch( $flag ); |
| 1217 |
} |
| 1218 |
return; |
| 1219 |
} |
| 1220 |
if ( file_exists( $flag ) ) { |
| 1221 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. |
| 1222 |
@unlink( $flag ); |
| 1223 |
} |
| 1224 |
} |
| 1225 |
|
| 1226 |
/** |
| 1227 |
* Reconcile every mobile_separate-dependent artifact to the current |
| 1228 |
* setting. Called on boot and whenever the cache settings are saved, so |
| 1229 |
* flipping mobile_separate at runtime can't leave the install in a |
| 1230 |
* half-converted state. |
| 1231 |
* |
| 1232 |
* Three things must agree with the setting: |
| 1233 |
* 1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()), |
| 1234 |
* 2. the device-blind server rewrite — present only when OFF |
| 1235 |
* (static_rewrite_allowed()), |
| 1236 |
* 3. the now-stale static-cache tree + page cache, which were keyed |
| 1237 |
* under the old scheme and would serve wrong-device HTML. |
| 1238 |
* |
| 1239 |
* No-ops when the cache is disabled — there's nothing installed to |
| 1240 |
* reconcile, and toggle() handles install/teardown itself. |
| 1241 |
*/ |
| 1242 |
public static function reconcile_mobile_separate(): void { |
| 1243 |
self::sync_mobile_flag(); |
| 1244 |
|
| 1245 |
// The rewrite/static reconciliation below needs the plugin's path |
| 1246 |
// constants. They're absent in early-boot / unit-test contexts where |
| 1247 |
// only the drop-in flag matters — bail to the flag-only behavior then. |
| 1248 |
if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) { |
| 1249 |
return; |
| 1250 |
} |
| 1251 |
|
| 1252 |
// Only touch the rewrite + caches when caching is actually on. |
| 1253 |
$opts = get_option( 'xspeed_options', array() ); |
| 1254 |
if ( empty( $opts['cache_enabled'] ) ) { |
| 1255 |
return; |
| 1256 |
} |
| 1257 |
|
| 1258 |
$rewrite_present = self::rewrite_installed(); |
| 1259 |
$rewrite_wanted = self::static_rewrite_allowed(); |
| 1260 |
|
| 1261 |
if ( $rewrite_present === $rewrite_wanted ) { |
| 1262 |
// Already consistent — nothing flipped, leave caches intact so a |
| 1263 |
// plain settings save (e.g. expiry change) doesn't blow the cache. |
| 1264 |
return; |
| 1265 |
} |
| 1266 |
|
| 1267 |
// The setting flipped. Bring the rewrite into line and purge the |
| 1268 |
// now-misbucketed cache so the next request re-primes under the new |
| 1269 |
// device scheme. |
| 1270 |
if ( $rewrite_wanted ) { |
| 1271 |
self::install_rewrite(); |
| 1272 |
} else { |
| 1273 |
self::remove_rewrite(); |
| 1274 |
} |
| 1275 |
self::purge_all( 'mobile_separate changed' ); |
| 1276 |
} |
| 1277 |
|
| 1278 |
/** |
| 1279 |
* Whether the server-level static-rewrite fast path may be used. |
| 1280 |
* |
| 1281 |
* The rewrite serves `{host}{path}/index.html` straight from the web |
| 1282 |
* server, keyed only by host + path — it has no way to run our PHP |
| 1283 |
* device detection, so it can't tell mobile from desktop. When |
| 1284 |
* `mobile_separate` is on, a single static file would be shared across |
| 1285 |
* devices and whoever primed it wins (mobile visitors could get desktop |
| 1286 |
* HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent |
| 1287 |
* UA matcher into .htaccess AND the nginx snippet (three copies that |
| 1288 |
* would inevitably drift), we simply DON'T engage the static rewrite when |
| 1289 |
* mobile_separate is on. Requests then fall through to the PHP drop-in, |
| 1290 |
* which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only |
| 1291 |
* on mobile-separate sites, in exchange for guaranteed correctness. |
| 1292 |
* |
| 1293 |
* LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in |
| 1294 |
* particular — `.htaccess` CAN run our RewriteRule to serve the static |
| 1295 |
* file, but its `.htaccess` engine ignores `mod_headers`, so we cannot |
| 1296 |
* stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no |
| 1297 |
* `.htaccess` equivalent of nginx's per-location `access_log` to record |
| 1298 |
* the hit. The result was a cache that worked but was invisible: no HIT |
| 1299 |
* header and a hit-ratio frozen near 0%. Every OTHER server gives the |
| 1300 |
* user a visible HIT header + a counted hit (nginx via add_header + |
| 1301 |
* access_log in its snippet; Apache via .htaccess mod_headers, which it |
| 1302 |
* honors). To keep LiteSpeed CONSISTENT with the rest, we route its hits |
| 1303 |
* through the PHP drop-in instead — the drop-in emits |
| 1304 |
* `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the |
| 1305 |
* observable behavior the other servers get. The cost is the drop-in's |
| 1306 |
* ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in |
| 1307 |
* exchange the dashboard hit-ratio and the response header finally tell |
| 1308 |
* the truth there. (Apache keeps the static fast path — it honors the |
| 1309 |
* header.) See maybe_emit_lscache_headers() for the paired LSCache |
| 1310 |
* stand-down that stops LiteSpeed's own module from shadowing the |
| 1311 |
* drop-in. |
| 1312 |
*/ |
| 1313 |
public static function static_rewrite_allowed(): bool { |
| 1314 |
// LiteSpeed: drop-in serves hits (visible + counted) — see docblock. |
| 1315 |
if ( Server::LITESPEED === Server::type() ) { |
| 1316 |
return false; |
| 1317 |
} |
| 1318 |
$opts = Settings_Manager::get( 'cache' ); |
| 1319 |
return empty( $opts['mobile_separate'] ); |
| 1320 |
} |
| 1321 |
|
| 1322 |
public static function ensure_hits_log_file(): bool { |
| 1323 |
$dir = self::hits_log_dir(); |
| 1324 |
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { |
| 1325 |
return false; |
| 1326 |
} |
| 1327 |
// Ensure the dir is traversable + writable by a different-uid nginx. |
| 1328 |
// 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. |
| 1329 |
@chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails. |
| 1330 |
$path = self::hits_log_path(); |
| 1331 |
if ( ! file_exists( $path ) ) { |
| 1332 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem. |
| 1333 |
@touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check. |
| 1334 |
} |
| 1335 |
// World-writable so a different-uid nginx can append HIT lines. |
| 1336 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock. |
| 1337 |
@chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort. |
| 1338 |
return file_exists( $path ); |
| 1339 |
} |
| 1340 |
|
| 1341 |
public static function nginx_snippet(): ?string { |
| 1342 |
if ( Server::NGINX !== Server::type() ) { |
| 1343 |
return null; |
| 1344 |
} |
| 1345 |
$rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' ); |
| 1346 |
$rel = rtrim( $rel, '/' ); |
| 1347 |
|
| 1348 |
// WP-Rocket-canonical pattern: every condition lives at |
| 1349 |
// SERVER level (outside any location block). Each one appends |
| 1350 |
// a tag to $xspeed_no_cache; the final check is a single |
| 1351 |
// string-equality against the unmodified default "no-cache". |
| 1352 |
// Only when ALL conditions pass does the rewrite fire, |
| 1353 |
// jumping the request to the static file's URL. nginx then |
| 1354 |
// restarts location matching against the new path, where |
| 1355 |
// regular static-file serving takes over. |
| 1356 |
// |
| 1357 |
// Why server-level + a single rewrite (instead of try_files |
| 1358 |
// inside `location /`): nginx's well-documented "if is evil" |
| 1359 |
// quirk silently disables `try_files`'s last fallback when |
| 1360 |
// any `if` in the same location is true. Moving the `if`s |
| 1361 |
// outside any location dodges the trap completely, because |
| 1362 |
// server-level rewrite is the documented stable path. |
| 1363 |
// |
| 1364 |
// `last` (not `break`) restarts location matching — required |
| 1365 |
// so the rewritten static-file URI gets served via the normal |
| 1366 |
// static-file location, not re-matched against `location /` |
| 1367 |
// where our own rewrite would loop. |
| 1368 |
// |
| 1369 |
// The cache existence check is the LAST condition in the |
| 1370 |
// chain so when the file isn't cached, $xspeed_no_cache |
| 1371 |
// gets a "-nofile" tag and the rewrite is skipped — the |
| 1372 |
// request falls through to whatever `location /` the user |
| 1373 |
// already had (typically `try_files $uri $uri/ /index.php?$args;`). |
| 1374 |
// Absolute path to the hit-log file from the nginx process's |
| 1375 |
// filesystem view. Nginx's `access_log buffer=N flush=Ns` form |
| 1376 |
// requires a literal path — `$document_root` variables are |
| 1377 |
// rejected — so PHP computes it. Lives under uploads/ (NOT the |
| 1378 |
// cache dir): a cache purge or uninstall deletes the cache dir, |
| 1379 |
// which would orphan this directive's parent directory and make |
| 1380 |
// `nginx -t` fail [emerg] for EVERY vhost on the host |
| 1381 |
// (FBS-82478). uploads/ survives both, so the directive can |
| 1382 |
// never take nginx down. Works on every topology where the nginx |
| 1383 |
// process shares a filesystem with PHP (container or host). |
| 1384 |
$hits_abs = self::hits_log_path(); |
| 1385 |
|
| 1386 |
$lines = array(); |
| 1387 |
$lines[] = '# xSpeed static cache — paste at server level, above location / { }.'; |
| 1388 |
// Cache host must match the on-disk dir PHP writes: store_static() / |
| 1389 |
// static_host() take HTTP_HOST and strip every char outside |
| 1390 |
// [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits |
| 1391 |
// (localhost:8192 → localhost8192). nginx's own $host can't reproduce |
| 1392 |
// that: $host has the port already stripped ENTIRELY (→ localhost), so |
| 1393 |
// the -f check looks for localhost/... while PHP wrote localhost8192/... |
| 1394 |
// and the rewrite never fires on a non-standard port. Derive |
| 1395 |
// $xspeed_host from $http_host (which keeps the port) and drop just the |
| 1396 |
// colon, so it equals the PHP dir on every port. On standard ports |
| 1397 |
// $http_host has no colon, so $xspeed_host == $host == the bare domain. |
| 1398 |
$lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com) |
| 1399 |
$lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host()) |
| 1400 |
$lines[] = 'set $xspeed_no_cache "no-cache";'; |
| 1401 |
$lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }'; |
| 1402 |
$lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }'; |
| 1403 |
$lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }'; |
| 1404 |
$lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }'; |
| 1405 |
// Neither `add_header` nor `access_log` is allowed inside an `if{}` |
| 1406 |
// at server level (nginx rejects with "directive is not allowed |
| 1407 |
// here"). The logging therefore lives in a `location` block that |
| 1408 |
// matches the rewritten URI after `rewrite … last;` restarts |
| 1409 |
// location matching. Every HIT lands there exactly once, every |
| 1410 |
// MISS / PHP-served request never matches it. |
| 1411 |
$lines[] = 'if ($xspeed_no_cache = "no-cache") {'; |
| 1412 |
$lines[] = ' rewrite ^ ' . $rel . '/$xspeed_host$uri/index.html last;'; |
| 1413 |
$lines[] = '}'; |
| 1414 |
$lines[] = ''; |
| 1415 |
$lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.'; |
| 1416 |
$lines[] = 'location ^~ ' . $rel . '/ {'; |
| 1417 |
$lines[] = ' internal;'; |
| 1418 |
// LITERAL log path (not `set $var; access_log $var`). The variable form |
| 1419 |
// makes nginx open the log lazily per-request and SILENTLY drop the |
| 1420 |
// line if the open fails — so on a working host hits were served |
| 1421 |
// (X-XSpeed-Cache fires regardless) but nothing was ever written and |
| 1422 |
// the hit ratio sat at 0%. A literal path makes nginx open the file at |
| 1423 |
// config load and actually log every hit. |
| 1424 |
// |
| 1425 |
// Deleting the log FILE is still safe with a literal path: nginx |
| 1426 |
// recreates it on the next write/reload and `nginx -t` stays green |
| 1427 |
// (verified). The only thing that [emerg]s `nginx -t` is a missing |
| 1428 |
// parent DIRECTORY — and the log lives under uploads/xspeed/, which |
| 1429 |
// survives cache purge + uninstall, and which ensure_hits_log_file() |
| 1430 |
// (run on every admin_init via auto_heal) recreates if it ever goes |
| 1431 |
// missing. So: hits are logged, and a user deleting the log can't take |
| 1432 |
// nginx down. |
| 1433 |
$lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;'; |
| 1434 |
$lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;'; |
| 1435 |
$lines[] = '}'; |
| 1436 |
return implode( "\n", $lines ); |
| 1437 |
} |
| 1438 |
|
| 1439 |
/** |
| 1440 |
* Aggregate every enabled module's nginx_directives() into one |
| 1441 |
* pasteable server-block snippet. Replaces the per-module "paste |
| 1442 |
* this snippet" notices with a single consolidated paste — every |
| 1443 |
* future feature toggle just regenerates this output. |
| 1444 |
* |
| 1445 |
* Returns null on non-nginx hosts (nothing to paste). |
| 1446 |
* |
| 1447 |
* Sections render in module-registration order so the layout stays |
| 1448 |
* predictable; each module gets a comment header `# <slug>`. |
| 1449 |
*/ |
| 1450 |
public static function full_nginx_server_block(): ?string { |
| 1451 |
if ( Server::NGINX !== Server::type() ) { |
| 1452 |
return null; |
| 1453 |
} |
| 1454 |
|
| 1455 |
$blocks = array(); |
| 1456 |
foreach ( Module_Registry::all() as $module ) { |
| 1457 |
$directives = $module->nginx_directives(); |
| 1458 |
if ( ! is_string( $directives ) || '' === trim( $directives ) ) { |
| 1459 |
continue; |
| 1460 |
} |
| 1461 |
$blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives ); |
| 1462 |
} |
| 1463 |
|
| 1464 |
if ( empty( $blocks ) ) { |
| 1465 |
return null; |
| 1466 |
} |
| 1467 |
|
| 1468 |
$header = "# xSpeed unified nginx config — paste into `server { }`, above `location / { }`; re-paste after toggling features.\n"; |
| 1469 |
|
| 1470 |
return $header . "\n" . implode( "\n\n", $blocks ) . "\n"; |
| 1471 |
} |
| 1472 |
|
| 1473 |
/** |
| 1474 |
* Tell LiteSpeed's LSCache module to stand down on the cache-miss |
| 1475 |
* render path. |
| 1476 |
* |
| 1477 |
* History: this method used to emit X-LiteSpeed-Cache-Control: |
| 1478 |
* public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's |
| 1479 |
* LSCache store. That delegation backfired — once LSCache cached a |
| 1480 |
* page it served every subsequent request from its OWN store and |
| 1481 |
* intercepted the request before our site-root .htaccess static |
| 1482 |
* rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache |
| 1483 |
* header, our static-cache tree never served, the HIT log never |
| 1484 |
* written (hit ratio frozen at 0%), and the Health probe reporting a |
| 1485 |
* false "cache running on PHP fallback" because it never saw an |
| 1486 |
* xSpeed-served response. |
| 1487 |
* |
| 1488 |
* xSpeed now owns the cache on LiteSpeed exactly as it does on Apache: |
| 1489 |
* our `.htaccess` mod_rewrite block serves hits straight from the |
| 1490 |
* static-cache tree (with the X-XSpeed-Cache header + access-log HIT |
| 1491 |
* accounting), and PHP/the drop-in is the fallback. To guarantee |
| 1492 |
* LSCache doesn't shadow that with its own copy — some LiteSpeed |
| 1493 |
* configs cache by default — we send an explicit `no-cache` control so |
| 1494 |
* the server defers to our rewrite. Skipped when the LiteSpeed Cache |
| 1495 |
* plugin is active (it owns its own header policy; our Conflict |
| 1496 |
* registry handles that coexistence separately). |
| 1497 |
*/ |
| 1498 |
public static function maybe_emit_lscache_headers(): void { |
| 1499 |
if ( headers_sent() ) { |
| 1500 |
return; |
| 1501 |
} |
| 1502 |
if ( Server::LITESPEED !== Server::type() ) { |
| 1503 |
return; |
| 1504 |
} |
| 1505 |
// is_plugin_active() lives in wp-admin/includes/plugin.php which |
| 1506 |
// isn't auto-loaded on front-end requests. Use the option layer |
| 1507 |
// directly to avoid pulling in admin code from a render path. |
| 1508 |
$active = (array) get_option( 'active_plugins', array() ); |
| 1509 |
if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) { |
| 1510 |
return; |
| 1511 |
} |
| 1512 |
|
| 1513 |
// Explicitly opt this response OUT of LSCache so the server can't |
| 1514 |
// shadow our static-rewrite cache with its own internal copy. |
| 1515 |
header( 'X-LiteSpeed-Cache-Control: no-cache' ); |
| 1516 |
} |
| 1517 |
|
| 1518 |
/** |
| 1519 |
* Reconcile drop-in + WP_CACHE + rewrite block with the user's |
| 1520 |
* saved choice. Runs on admin_init. Cheap when nothing's wrong |
| 1521 |
* (one option read + a handful of file_exists / defined checks); |
| 1522 |
* writes only when state has drifted (typical cause: plugin |
| 1523 |
* upgrade wiped the drop-in, foreign plugin removed our WP_CACHE |
| 1524 |
* define, or someone hand-edited .htaccess). |
| 1525 |
* |
| 1526 |
* Skipped during the WP plugin updater run so we don't race |
| 1527 |
* the upgrader's own filesystem operations. |
| 1528 |
*/ |
| 1529 |
public static function auto_heal(): void { |
| 1530 |
if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) { |
| 1531 |
return; |
| 1532 |
} |
| 1533 |
if ( wp_doing_ajax() || wp_doing_cron() ) { |
| 1534 |
return; |
| 1535 |
} |
| 1536 |
|
| 1537 |
$opts = get_option( 'xspeed_options', array() ); |
| 1538 |
if ( empty( $opts['cache_enabled'] ) ) { |
| 1539 |
return; |
| 1540 |
} |
| 1541 |
|
| 1542 |
$dropin_target = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 1543 |
$dropin_ours = false; |
| 1544 |
$dropin_stale = false; |
| 1545 |
if ( file_exists( $dropin_target ) ) { |
| 1546 |
$contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 1547 |
$dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ); |
| 1548 |
// Reinstall when OUR drop-in is an older version than the source — |
| 1549 |
// the marker alone can't distinguish an old copy from a new one, so |
| 1550 |
// a serve-logic change (e.g. the .meta read for 404s/feeds) would |
| 1551 |
// otherwise never reach existing cache-enabled sites until a manual |
| 1552 |
// cache toggle. (FBS-82406/82407) |
| 1553 |
if ( $dropin_ours ) { |
| 1554 |
$dropin_stale = self::dropin_version( (string) $contents ) < self::dropin_version( @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ) ?: '' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 1555 |
} |
| 1556 |
} |
| 1557 |
|
| 1558 |
if ( ! $dropin_ours || $dropin_stale ) { |
| 1559 |
self::install_dropin(); |
| 1560 |
} |
| 1561 |
|
| 1562 |
if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) { |
| 1563 |
self::set_wp_cache_constant( true ); |
| 1564 |
} |
| 1565 |
|
| 1566 |
// Rewrite block goes last. It's what turns the static-cache |
| 1567 |
// tree into a PHP-bypass — every cache hit served by the web |
| 1568 |
// server directly. Without it we still cache, just at drop-in |
| 1569 |
// speed (~85ms TTFB) instead of static-file speed (~25-40ms). |
| 1570 |
// |
| 1571 |
// Reconcile against mobile_separate: the rewrite is device-blind, so |
| 1572 |
// it must be ABSENT when mobile_separate is on and PRESENT otherwise. |
| 1573 |
// auto_heal() runs periodically, so it also repairs a rewrite that |
| 1574 |
// was left installed before mobile_separate was switched on. |
| 1575 |
if ( self::static_rewrite_allowed() ) { |
| 1576 |
if ( ! self::rewrite_installed() ) { |
| 1577 |
self::install_rewrite(); |
| 1578 |
} |
| 1579 |
} elseif ( self::rewrite_installed() ) { |
| 1580 |
self::remove_rewrite(); |
| 1581 |
} |
| 1582 |
|
| 1583 |
// HITs log file — nginx writes one line per HIT served directly |
| 1584 |
// (see nginx_snippet()), Cache::get_stats() drains the file via |
| 1585 |
// Hit_Counter::collect_nginx_log_hits(). If the file vanishes |
| 1586 |
// (plugin upgrade wiped wp-content/cache/), nginx errors silently |
| 1587 |
// on the access_log directive and the counter stays at 0. |
| 1588 |
self::ensure_hits_log_file(); |
| 1589 |
} |
| 1590 |
|
| 1591 |
/** |
| 1592 |
* Build the .htaccess rules that map cacheable requests to the |
| 1593 |
* static-cache tree. Conditions are deliberately strict: GET only, |
| 1594 |
* empty query string, no session/comment-author/post-password |
| 1595 |
* cookie, and the static file must exist on disk. Anything that |
| 1596 |
* fails one of these falls through to PHP and the drop-in / full |
| 1597 |
* WordPress path. |
| 1598 |
* |
| 1599 |
* @return string[] Lines for insert_with_markers(). |
| 1600 |
*/ |
| 1601 |
public static function rewrite_block_lines(): array { |
| 1602 |
// Path relative to ABSPATH so the rule lives in the site-root |
| 1603 |
// .htaccess regardless of where wp-content sits. WP_CONTENT_DIR |
| 1604 |
// can be moved, so we compute the document-root-relative form |
| 1605 |
// at install time and bake it into the rule. |
| 1606 |
$rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ); |
| 1607 |
$rel = '/' . ltrim( $rel, '/' ); |
| 1608 |
$rel = rtrim( $rel, '/' ); |
| 1609 |
|
| 1610 |
return array( |
| 1611 |
'<IfModule mod_rewrite.c>', |
| 1612 |
' RewriteEngine On', |
| 1613 |
' RewriteCond %{REQUEST_METHOD} ^GET$', |
| 1614 |
' RewriteCond %{QUERY_STRING} ^$', |
| 1615 |
' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]', |
| 1616 |
// Capture REQUEST_URI without its trailing slash into %1. |
| 1617 |
// store_static() writes `{host}{uri-without-trailing-slash}/index.html`, |
| 1618 |
// so this normalization lets `/blog/` and `/blog` both hit |
| 1619 |
// the same cache file without producing the double-slash |
| 1620 |
// path that would skip the -f check below. |
| 1621 |
' RewriteCond %{REQUEST_URI} ^(.*?)/?$', |
| 1622 |
' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f', |
| 1623 |
// Pattern is `^`, NOT `.`. The per-directory rewrite engine |
| 1624 |
// strips the leading slash before matching, so the HOMEPAGE |
| 1625 |
// request `/` arrives here as an EMPTY path. `.` requires at |
| 1626 |
// least one character and therefore never matches the homepage |
| 1627 |
// — on LiteSpeed (which honors this strictly) the front page |
| 1628 |
// fell through to PHP while every inner page rewrote fine. |
| 1629 |
// `^` matches the empty string AND any non-empty path, so it |
| 1630 |
// covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed |
| 1631 |
// 1.8: `.` → homepage served by PHP drop-in; `^` → served |
| 1632 |
// directly from the static file.) |
| 1633 |
' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]', |
| 1634 |
'</IfModule>', |
| 1635 |
); |
| 1636 |
} |
| 1637 |
|
| 1638 |
/** |
| 1639 |
* Active probe that confirms the web-server static-rewrite path is |
| 1640 |
* actually serving cached files. Writes a probe file with a random |
| 1641 |
* nonce, fetches it over HTTP at its public URL, and checks whether |
| 1642 |
* the response was served directly by the web server (Last-Modified |
| 1643 |
* + ETag headers + no X-Powered-By: PHP). |
| 1644 |
* |
| 1645 |
* Server-agnostic: same probe works for nginx (snippet pasted) and |
| 1646 |
* Apache / LiteSpeed (.htaccess block installed). If the rewrite |
| 1647 |
* isn't engaged, the request falls through to WordPress and PHP |
| 1648 |
* adds its own headers, which the probe detects and reports. |
| 1649 |
* |
| 1650 |
* Throttled via a 5-minute transient — we never want this running |
| 1651 |
* on every Health card paint. |
| 1652 |
* |
| 1653 |
* @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int} |
| 1654 |
*/ |
| 1655 |
/** |
| 1656 |
* @param bool $allow_probe When false (the default), return ONLY a cached |
| 1657 |
* result and never make an HTTP request — so admin page loads are never |
| 1658 |
* blocked by the loopback probe. The actual HTTP probe only runs when a |
| 1659 |
* caller explicitly opts in (the Health tab / cron). Previously this ran |
| 1660 |
* synchronously on every dashboard bootstrap, so a slow/timing-out |
| 1661 |
* loopback request added up to `timeout` seconds to admin page loads on |
| 1662 |
* hosts that block self-requests. (FBS-82142) |
| 1663 |
*/ |
| 1664 |
public static function probe_static_rewrite( bool $allow_probe = false ): array { |
| 1665 |
$cached = get_transient( 'xspeed_rewrite_probe' ); |
| 1666 |
if ( is_array( $cached ) ) { |
| 1667 |
return $cached; |
| 1668 |
} |
| 1669 |
// No cached result yet and the caller doesn't want to pay for a live |
| 1670 |
// HTTP probe (e.g. the admin bootstrap): report "pending" without |
| 1671 |
// blocking. The Health tab will run the real probe on demand. |
| 1672 |
if ( ! $allow_probe ) { |
| 1673 |
return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true ); |
| 1674 |
} |
| 1675 |
|
| 1676 |
$home = home_url( '/' ); |
| 1677 |
$host = (string) wp_parse_url( $home, PHP_URL_HOST ); |
| 1678 |
if ( '' === $host ) { |
| 1679 |
$result = array( 'active' => false, 'reason' => 'home_url has no host' ); |
| 1680 |
set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 1681 |
return $result; |
| 1682 |
} |
| 1683 |
|
| 1684 |
// Use a randomised path AND nonce so a stale CDN cache entry |
| 1685 |
// from a prior probe can never make a broken install look |
| 1686 |
// healthy. Path is namespaced under __xspeed_probe__ so the |
| 1687 |
// directory listing stays obvious if cleanup misfires. |
| 1688 |
$slug = wp_generate_password( 12, false, false ); |
| 1689 |
$nonce = wp_generate_password( 24, false, false ); |
| 1690 |
$probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug; |
| 1691 |
$probe_file = $probe_dir . '/index.html'; |
| 1692 |
$probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/'; |
| 1693 |
|
| 1694 |
if ( ! file_exists( $probe_dir ) ) { |
| 1695 |
wp_mkdir_p( $probe_dir ); |
| 1696 |
} |
| 1697 |
if ( ! is_dir( $probe_dir ) ) { |
| 1698 |
$result = array( 'active' => false, 'reason' => 'cannot create probe dir' ); |
| 1699 |
set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 1700 |
return $result; |
| 1701 |
} |
| 1702 |
// 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. |
| 1703 |
file_put_contents( $probe_file, $nonce, LOCK_EX ); |
| 1704 |
|
| 1705 |
// Verify TLS by default — disabling it site-wide is a needless MITM |
| 1706 |
// exposure (FBS-82142). Only relax verification in local/dev |
| 1707 |
// environments, where self-signed certs are common and there's no |
| 1708 |
// real attacker in the loop. |
| 1709 |
$is_local = function_exists( 'wp_get_environment_type' ) |
| 1710 |
&& in_array( wp_get_environment_type(), array( 'local', 'development' ), true ); |
| 1711 |
$resp = wp_remote_get( |
| 1712 |
$probe_url, |
| 1713 |
array( |
| 1714 |
// 3s cap so a host that hangs on loopback self-requests can't |
| 1715 |
// stall the caller for long; the result/error is cached so we |
| 1716 |
// don't repeat the wait every minute. |
| 1717 |
'timeout' => 3, |
| 1718 |
'sslverify' => ! $is_local, |
| 1719 |
'redirection' => 0, |
| 1720 |
'headers' => array( 'Cache-Control' => 'no-cache' ), |
| 1721 |
) |
| 1722 |
); |
| 1723 |
|
| 1724 |
// Best-effort cleanup so we don't accumulate probe dirs even |
| 1725 |
// if subsequent calls all hit the transient. |
| 1726 |
if ( file_exists( $probe_file ) ) { |
| 1727 |
wp_delete_file( $probe_file ); |
| 1728 |
} |
| 1729 |
if ( is_dir( $probe_dir ) ) { |
| 1730 |
// 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. |
| 1731 |
@rmdir( $probe_dir ); |
| 1732 |
} |
| 1733 |
|
| 1734 |
if ( is_wp_error( $resp ) ) { |
| 1735 |
$result = array( |
| 1736 |
'active' => false, |
| 1737 |
'reason' => 'http error: ' . $resp->get_error_message(), |
| 1738 |
); |
| 1739 |
// Cache the failure for the full 5 minutes (not 1) so a host that |
| 1740 |
// times out on the loopback probe isn't re-probed — and re-stalled |
| 1741 |
// — on every page load within the window. (FBS-82142) |
| 1742 |
set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS ); |
| 1743 |
return $result; |
| 1744 |
} |
| 1745 |
|
| 1746 |
$code = (int) wp_remote_retrieve_response_code( $resp ); |
| 1747 |
$body = (string) wp_remote_retrieve_body( $resp ); |
| 1748 |
$ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' ); |
| 1749 |
$has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' ) |
| 1750 |
|| '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' ); |
| 1751 |
$match = trim( $body ) === $nonce; |
| 1752 |
|
| 1753 |
// "Active" = the web server served our raw nonce bytes back |
| 1754 |
// AND emitted the static-serve markers (ETag / Last-Modified) |
| 1755 |
// AND didn't add an X-Powered-By: PHP header. All three are |
| 1756 |
// individually noisy; together they're conclusive. |
| 1757 |
$active = $match && $has_etag && ! $ua_php && 200 === $code; |
| 1758 |
|
| 1759 |
if ( $active ) { |
| 1760 |
$reason = 'static-served'; |
| 1761 |
} elseif ( 200 === $code && $match && $ua_php ) { |
| 1762 |
$reason = 'php served the file instead of nginx/Apache (rewrite block missing)'; |
| 1763 |
} elseif ( 200 === $code && ! $match ) { |
| 1764 |
$reason = 'unexpected body (CDN cached an older response?)'; |
| 1765 |
} elseif ( 404 === $code ) { |
| 1766 |
$reason = 'probe URL returned 404 (rewrite block missing or wrong path)'; |
| 1767 |
} else { |
| 1768 |
$reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' ); |
| 1769 |
} |
| 1770 |
|
| 1771 |
$result = array( |
| 1772 |
'active' => $active, |
| 1773 |
'reason' => $reason, |
| 1774 |
'code' => $code, |
| 1775 |
'php' => $ua_php, |
| 1776 |
); |
| 1777 |
set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS ); |
| 1778 |
return $result; |
| 1779 |
} |
| 1780 |
|
| 1781 |
public static function rewrite_installed(): bool { |
| 1782 |
$htaccess = ABSPATH . '.htaccess'; |
| 1783 |
if ( ! file_exists( $htaccess ) ) { |
| 1784 |
return false; |
| 1785 |
} |
| 1786 |
$existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 1787 |
if ( ! is_string( $existing ) ) { |
| 1788 |
return false; |
| 1789 |
} |
| 1790 |
return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' ); |
| 1791 |
} |
| 1792 |
|
| 1793 |
/** |
| 1794 |
* Install the static-cache rewrite block at the TOP of .htaccess. |
| 1795 |
* |
| 1796 |
* Position matters: WordPress's own block ends with |
| 1797 |
* `RewriteRule . /index.php [L]` which routes every non-file |
| 1798 |
* request to PHP. The [L] flag stops the current rewrite pass, |
| 1799 |
* but Apache restarts the cycle; on the second pass REQUEST_URI |
| 1800 |
* is /index.php and no static-file check can match. The only |
| 1801 |
* reliable position for a "serve static if it exists" rule is |
| 1802 |
* before WordPress's block. |
| 1803 |
* |
| 1804 |
* WP's insert_with_markers() always appends, so we manage the |
| 1805 |
* block manually: strip any prior xSpeed Static Cache markers, |
| 1806 |
* then write our block followed by the rest of the file. |
| 1807 |
*/ |
| 1808 |
public static function install_rewrite(): bool { |
| 1809 |
// The static rewrite is device-blind; never install it when |
| 1810 |
// mobile_separate is on (see static_rewrite_allowed()). |
| 1811 |
if ( ! self::static_rewrite_allowed() ) { |
| 1812 |
return false; |
| 1813 |
} |
| 1814 |
$htaccess = ABSPATH . '.htaccess'; |
| 1815 |
$existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 1816 |
if ( false === $existing ) { |
| 1817 |
$existing = ''; |
| 1818 |
} |
| 1819 |
// Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in |
| 1820 |
// covers; we skip the write so we don't litter their root. |
| 1821 |
// 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. |
| 1822 |
if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) { |
| 1823 |
return false; |
| 1824 |
} |
| 1825 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above. |
| 1826 |
if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) { |
| 1827 |
return false; |
| 1828 |
} |
| 1829 |
|
| 1830 |
$cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' ); |
| 1831 |
$block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() ); |
| 1832 |
$next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned ); |
| 1833 |
|
| 1834 |
// 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. |
| 1835 |
return false !== file_put_contents( $htaccess, $next, LOCK_EX ); |
| 1836 |
} |
| 1837 |
|
| 1838 |
public static function remove_rewrite(): bool { |
| 1839 |
$htaccess = ABSPATH . '.htaccess'; |
| 1840 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale. |
| 1841 |
if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) { |
| 1842 |
return false; |
| 1843 |
} |
| 1844 |
$existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 1845 |
if ( false === $existing ) { |
| 1846 |
return false; |
| 1847 |
} |
| 1848 |
$cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' ); |
| 1849 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale. |
| 1850 |
return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX ); |
| 1851 |
} |
| 1852 |
|
| 1853 |
/** |
| 1854 |
* Strip a `# BEGIN <marker>` ... `# END <marker>` block from a |
| 1855 |
* .htaccess-style file, including any blank line that immediately |
| 1856 |
* follows it. Idempotent — returns the input unchanged if the |
| 1857 |
* marker isn't present. |
| 1858 |
*/ |
| 1859 |
private static function strip_marker_block( string $contents, string $marker ): string { |
| 1860 |
$pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s"; |
| 1861 |
$out = preg_replace( $pattern, '', $contents ); |
| 1862 |
return is_string( $out ) ? $out : $contents; |
| 1863 |
} |
| 1864 |
|
| 1865 |
private static function marker_block( string $marker, array $lines ): string { |
| 1866 |
$header = "# BEGIN $marker\n"; |
| 1867 |
$header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n"; |
| 1868 |
$header .= "# dynamically generated, and should only be modified via WordPress filters.\n"; |
| 1869 |
$header .= "# Any changes to the directives between these markers will be overwritten.\n"; |
| 1870 |
$footer = "# END $marker\n"; |
| 1871 |
return $header . implode( "\n", $lines ) . "\n" . $footer; |
| 1872 |
} |
| 1873 |
|
| 1874 |
/** |
| 1875 |
* Parse the `XSPEED_DROPIN_VERSION: N` stamp out of a drop-in's source. |
| 1876 |
* Returns 0 when absent (an un-stamped older copy reinstalls). Used to |
| 1877 |
* detect a stale installed drop-in vs the bundled source. |
| 1878 |
*/ |
| 1879 |
private static function dropin_version( string $contents ): int { |
| 1880 |
if ( preg_match( '/XSPEED_DROPIN_VERSION:\s*(\d+)/', $contents, $m ) ) { |
| 1881 |
return (int) $m[1]; |
| 1882 |
} |
| 1883 |
return 0; |
| 1884 |
} |
| 1885 |
|
| 1886 |
public static function install_dropin() { |
| 1887 |
$source = XSPEED_DIR . 'includes/advanced-cache.php'; |
| 1888 |
$target = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 1889 |
if ( ! file_exists( $source ) ) { |
| 1890 |
return false; |
| 1891 |
} |
| 1892 |
|
| 1893 |
global $wp_filesystem; |
| 1894 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 1895 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 1896 |
} |
| 1897 |
WP_Filesystem(); |
| 1898 |
if ( ! $wp_filesystem ) { |
| 1899 |
return false; |
| 1900 |
} |
| 1901 |
|
| 1902 |
$source_contents = $wp_filesystem->get_contents( $source ); |
| 1903 |
if ( ! is_string( $source_contents ) ) { |
| 1904 |
return false; |
| 1905 |
} |
| 1906 |
|
| 1907 |
// Bake the absolute hit-log path into the drop-in. It runs before |
| 1908 |
// WordPress loads, so it can't resolve wp_upload_dir() itself — we |
| 1909 |
// substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path |
| 1910 |
// (never the cache dir; see hits_log_dir() / FBS-82478). Use a single |
| 1911 |
// quoted PHP string literal so the installed file stays valid PHP. |
| 1912 |
$source_contents = str_replace( |
| 1913 |
'@@XSPEED_HITS_LOG@@', |
| 1914 |
str_replace( "'", "\\'", self::hits_log_path() ), |
| 1915 |
$source_contents |
| 1916 |
); |
| 1917 |
|
| 1918 |
if ( file_exists( $target ) ) { |
| 1919 |
$existing = $wp_filesystem->get_contents( $target ); |
| 1920 |
$is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' ); |
| 1921 |
|
| 1922 |
if ( $is_xspeed ) { |
| 1923 |
if ( $existing === $source_contents ) { |
| 1924 |
return true; |
| 1925 |
} |
| 1926 |
return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE ); |
| 1927 |
} |
| 1928 |
|
| 1929 |
// Foreign drop-in (e.g. left over from another cache plugin) — back it up |
| 1930 |
// before overwriting so the user can recover if needed. Uploads dir |
| 1931 |
// (not wp-content root) keeps the backup out of WordPress's reserved |
| 1932 |
// drop-in location. |
| 1933 |
$upload = wp_upload_dir( null, false ); |
| 1934 |
$basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false; |
| 1935 |
if ( $basedir ) { |
| 1936 |
if ( ! file_exists( $basedir ) ) { |
| 1937 |
wp_mkdir_p( $basedir ); |
| 1938 |
self::write_silence( $basedir ); |
| 1939 |
} |
| 1940 |
$backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak'; |
| 1941 |
$wp_filesystem->move( $target, $backup, true ); |
| 1942 |
} else { |
| 1943 |
$wp_filesystem->delete( $target ); |
| 1944 |
} |
| 1945 |
} |
| 1946 |
|
| 1947 |
return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE ); |
| 1948 |
} |
| 1949 |
|
| 1950 |
public static function remove_dropin() { |
| 1951 |
$target = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 1952 |
if ( ! file_exists( $target ) ) { |
| 1953 |
return; |
| 1954 |
} |
| 1955 |
|
| 1956 |
global $wp_filesystem; |
| 1957 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 1958 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 1959 |
} |
| 1960 |
WP_Filesystem(); |
| 1961 |
if ( ! $wp_filesystem ) { |
| 1962 |
return; |
| 1963 |
} |
| 1964 |
|
| 1965 |
$contents = $wp_filesystem->get_contents( $target ); |
| 1966 |
if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) { |
| 1967 |
wp_delete_file( $target ); |
| 1968 |
} |
| 1969 |
} |
| 1970 |
|
| 1971 |
public static function set_wp_cache_constant( $enable ) { |
| 1972 |
$wp_config = ABSPATH . 'wp-config.php'; |
| 1973 |
if ( ! file_exists( $wp_config ) ) { |
| 1974 |
return false; |
| 1975 |
} |
| 1976 |
|
| 1977 |
global $wp_filesystem; |
| 1978 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 1979 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 1980 |
} |
| 1981 |
WP_Filesystem(); |
| 1982 |
if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) { |
| 1983 |
return false; |
| 1984 |
} |
| 1985 |
|
| 1986 |
$config = $wp_filesystem->get_contents( $wp_config ); |
| 1987 |
|
| 1988 |
if ( $enable ) { |
| 1989 |
if ( strpos( $config, "define( 'WP_CACHE'" ) !== false || strpos( $config, "define('WP_CACHE'" ) !== false ) { |
| 1990 |
return true; |
| 1991 |
} |
| 1992 |
$config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 ); |
| 1993 |
} else { |
| 1994 |
$config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config ); |
| 1995 |
} |
| 1996 |
|
| 1997 |
return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE ); |
| 1998 |
} |
| 1999 |
|
| 2000 |
public function admin_bar_purge( $wp_admin_bar ) { |
| 2001 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 2002 |
return; |
| 2003 |
} |
| 2004 |
$wp_admin_bar->add_node( |
| 2005 |
array( |
| 2006 |
'id' => 'xspeed-purge', |
| 2007 |
'title' => __( 'Purge xSpeed Cache', 'xspeed' ), |
| 2008 |
'href' => wp_nonce_url( admin_url( 'admin-post.php?action=xspeed_purge' ), 'xspeed_purge' ), |
| 2009 |
) |
| 2010 |
); |
| 2011 |
} |
| 2012 |
|
| 2013 |
public function handle_admin_bar_purge() { |
| 2014 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 2015 |
wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 ); |
| 2016 |
} |
| 2017 |
check_admin_referer( 'xspeed_purge' ); |
| 2018 |
self::purge_all(); |
| 2019 |
wp_safe_redirect( wp_get_referer() ?: admin_url() ); |
| 2020 |
exit; |
| 2021 |
} |
| 2022 |
} |
| 2023 |
|