| 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 |
$invalidate_hooks = array( 'save_post', 'deleted_post', 'trashed_post', 'comment_post', 'wp_set_comment_status', 'switch_theme', 'activated_plugin', 'deactivated_plugin' ); |
| 27 |
foreach ( $invalidate_hooks as $hook ) { |
| 28 |
add_action( $hook, array( __CLASS__, 'purge_all' ) ); |
| 29 |
add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 30 |
} |
| 31 |
|
| 32 |
add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 ); |
| 33 |
|
| 34 |
add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 ); |
| 35 |
add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) ); |
| 36 |
} |
| 37 |
|
| 38 |
public static function on_settings_change( $old, $new ) { |
| 39 |
// gzip_enabled moved to xspeed_module_gzip — GzipModule owns the |
| 40 |
// .htaccess flip via its own update_option_xspeed_module_gzip hook. |
| 41 |
// Same migration is planned for cache_expiry + excluded_urls |
| 42 |
// (Cache module). Keep this handler around for whatever still |
| 43 |
// lives in the legacy blob (cache_enabled is special and goes |
| 44 |
// through Cache::toggle anyway). |
| 45 |
|
| 46 |
// Any settings change — purge caches so changes take effect. |
| 47 |
self::purge_all( 'settings change' ); |
| 48 |
Minifier::purge_minified(); |
| 49 |
} |
| 50 |
|
| 51 |
public function maybe_start_cache() { |
| 52 |
if ( ! self::should_cache() ) { |
| 53 |
return; |
| 54 |
} |
| 55 |
|
| 56 |
$key = self::cache_key(); |
| 57 |
$file = self::cache_file_for( $key ); |
| 58 |
|
| 59 |
if ( file_exists( $file ) && ! self::is_expired( $file ) ) { |
| 60 |
Hit_Counter::record_hit(); |
| 61 |
// 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. |
| 62 |
readfile( $file ); |
| 63 |
exit; |
| 64 |
} |
| 65 |
|
| 66 |
// Cache miss → render fresh + write cache. On LiteSpeed servers |
| 67 |
// we also signal the server-level LSCache module to cache this |
| 68 |
// response, so subsequent requests skip PHP entirely (~5-15ms |
| 69 |
// TTFB vs. our PHP drop-in's ~30ms floor). Defers if the |
| 70 |
// LiteSpeed Cache plugin is active — that plugin owns its own |
| 71 |
// header emission and conflicts with ours. |
| 72 |
self::maybe_emit_lscache_headers(); |
| 73 |
|
| 74 |
// We're about to render fresh + cache → miss for this request. |
| 75 |
Hit_Counter::record_miss(); |
| 76 |
|
| 77 |
|
| 78 |
// WP < 6.9 fallback: ob_start() with a callback, paired with an |
| 79 |
// explicit shutdown close so the buffer lifecycle is visible to |
| 80 |
// reviewers and Plugin Check, instead of relying on PHP's implicit |
| 81 |
// request-end flush. We record our nesting level so close_buffer() |
| 82 |
// flushes ONLY the buffer we opened. |
| 83 |
ob_start( array( __CLASS__, 'finalize_buffer' ) ); |
| 84 |
self::$buffer_level = ob_get_level(); |
| 85 |
|
| 86 |
add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 ); |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Close the cache buffer opened by maybe_start_cache(). |
| 91 |
* |
| 92 |
* Guarded by the recorded buffer level so we never flush a buffer that |
| 93 |
* another plugin pushed on top of (or under) ours. If something else is |
| 94 |
* currently on top, we leave the stack alone — PHP's shutdown sequence |
| 95 |
* will unwind buffers in order and our finalize_buffer() callback will |
| 96 |
* still run when our level becomes the topmost one. |
| 97 |
*/ |
| 98 |
public static function close_buffer() { |
| 99 |
if ( null === self::$buffer_level ) { |
| 100 |
return; |
| 101 |
} |
| 102 |
if ( ob_get_level() === self::$buffer_level ) { |
| 103 |
ob_end_flush(); |
| 104 |
} |
| 105 |
self::$buffer_level = null; |
| 106 |
} |
| 107 |
|
| 108 |
public static function should_cache() { |
| 109 |
$opts = Settings::get(); |
| 110 |
if ( empty( $opts['cache_enabled'] ) ) { |
| 111 |
return false; |
| 112 |
} |
| 113 |
|
| 114 |
if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { |
| 115 |
return false; |
| 116 |
} |
| 117 |
|
| 118 |
if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) { |
| 119 |
return false; |
| 120 |
} |
| 121 |
|
| 122 |
// All exclusion knobs now owned by CacheModule. |
| 123 |
$cache_opts = Settings_Manager::get( 'cache' ); |
| 124 |
|
| 125 |
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : ''; |
| 126 |
if ( 'GET' !== $method ) { |
| 127 |
return false; |
| 128 |
} |
| 129 |
|
| 130 |
// Query string handling: anything OUTSIDE the ignored-params |
| 131 |
// allow-list (utm_*, fbclid, gclid by default) means a unique |
| 132 |
// request that we don't want to share with the canonical cache |
| 133 |
// entry. Skip cache rather than poison the key. |
| 134 |
$query_raw = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : ''; |
| 135 |
if ( '' !== $query_raw ) { |
| 136 |
$ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array(); |
| 137 |
parse_str( $query_raw, $params ); |
| 138 |
foreach ( $params as $key => $_ ) { |
| 139 |
if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) { |
| 140 |
return false; |
| 141 |
} |
| 142 |
} |
| 143 |
} |
| 144 |
|
| 145 |
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; |
| 146 |
$path = (string) strtok( $request_uri, '?' ); |
| 147 |
|
| 148 |
$excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array(); |
| 149 |
if ( Glob_Matcher::any_match( $excluded_urls, $path ) ) { |
| 150 |
return false; |
| 151 |
} |
| 152 |
|
| 153 |
// Cookie-based exclusion. We only check cookie NAMES (matching |
| 154 |
// values would leak content-sensitive logic into the cache key |
| 155 |
// rules); presence of any matching cookie name skips cache. |
| 156 |
$excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array(); |
| 157 |
if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) { |
| 158 |
foreach ( array_keys( $_COOKIE ) as $cookie_name ) { |
| 159 |
if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) { |
| 160 |
return false; |
| 161 |
} |
| 162 |
} |
| 163 |
} |
| 164 |
|
| 165 |
// User-agent bypass list. Substring match (not glob) since UA |
| 166 |
// strings have so much variation that glob anchoring rarely |
| 167 |
// helps and confuses users. |
| 168 |
$bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array(); |
| 169 |
if ( ! empty( $bypass_uas ) ) { |
| 170 |
$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; |
| 171 |
foreach ( $bypass_uas as $needle ) { |
| 172 |
if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) { |
| 173 |
return false; |
| 174 |
} |
| 175 |
} |
| 176 |
} |
| 177 |
|
| 178 |
// Per-post override (Phase 3.4). Honored only on singular |
| 179 |
// post-context requests — archives / 404s / taxonomies use the |
| 180 |
// global policy above. |
| 181 |
if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) { |
| 182 |
return false; |
| 183 |
} |
| 184 |
|
| 185 |
return true; |
| 186 |
} |
| 187 |
|
| 188 |
/** |
| 189 |
* Is this query-string key on the ignored-params allow-list? Supports |
| 190 |
* trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`, |
| 191 |
* etc.) so users don't have to enumerate every UTM variant. |
| 192 |
*/ |
| 193 |
private static function query_key_is_ignored( string $key, array $ignored ): bool { |
| 194 |
return Glob_Matcher::any_match( $ignored, $key ); |
| 195 |
} |
| 196 |
|
| 197 |
public static function cache_key() { |
| 198 |
$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default'; |
| 199 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; |
| 200 |
// Strip the query string from the key so /post and /post?utm_*=… |
| 201 |
// share the same cache entry. should_cache() above already |
| 202 |
// rejected requests with non-ignored params, so by the time we |
| 203 |
// build the key the only params left are safe to drop. |
| 204 |
$uri = (string) strtok( $uri, '?' ); |
| 205 |
|
| 206 |
// Optional device bucket: when mobile_separate is on, mobile and |
| 207 |
// desktop responses live in different cache files so themes that |
| 208 |
// serve different HTML by device (AMP, WPtouch, Jetpack mobile) |
| 209 |
// can't poison each other. |
| 210 |
$device = ''; |
| 211 |
$opts = Settings_Manager::get( 'cache' ); |
| 212 |
if ( ! empty( $opts['mobile_separate'] ) ) { |
| 213 |
$device = self::is_mobile_request() ? '|m' : '|d'; |
| 214 |
} |
| 215 |
|
| 216 |
return md5( $host . $uri . $device ); |
| 217 |
} |
| 218 |
|
| 219 |
/** |
| 220 |
* Server-side mobile detection. Prefers WordPress's `wp_is_mobile()` |
| 221 |
* which uses the same UA tokens as core (so our bucket aligns with |
| 222 |
* whatever theme-side branching uses). Falls back to a tiny inline |
| 223 |
* detector if wp_is_mobile() isn't loaded (e.g. the drop-in path). |
| 224 |
*/ |
| 225 |
private static function is_mobile_request(): bool { |
| 226 |
if ( function_exists( 'wp_is_mobile' ) ) { |
| 227 |
return (bool) wp_is_mobile(); |
| 228 |
} |
| 229 |
$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; |
| 230 |
if ( '' === $ua ) { |
| 231 |
return false; |
| 232 |
} |
| 233 |
// Mirrors the token list wp_is_mobile() uses internally. |
| 234 |
return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua ); |
| 235 |
} |
| 236 |
|
| 237 |
public static function cache_file_for( $key ) { |
| 238 |
return XSPEED_CACHE_DIR . '/' . $key . '.html'; |
| 239 |
} |
| 240 |
|
| 241 |
public static function is_expired( $file ) { |
| 242 |
// cache_expiry now owned by CacheModule; per-post override |
| 243 |
// (Phase 3.4) shrinks the TTL further when the editor set one. |
| 244 |
$opts = Settings_Manager::get( 'cache' ); |
| 245 |
$max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS; |
| 246 |
$post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() ); |
| 247 |
if ( null !== $post_override ) { |
| 248 |
$max_age = $post_override; |
| 249 |
} |
| 250 |
return ( time() - filemtime( $file ) ) > $max_age; |
| 251 |
} |
| 252 |
|
| 253 |
/** |
| 254 |
* Accumulator for the full response body across all output-handler phases. |
| 255 |
* |
| 256 |
* PHP invokes an ob_start() callback once per flush, and each invocation |
| 257 |
* only receives the chunk produced *since the previous flush*. If anything |
| 258 |
* during the render calls `ob_flush()` or `flush()` (some themes, lazy- |
| 259 |
* load plugins, AMP, etc. do), the final-phase call would otherwise only |
| 260 |
* see the tail of the page — and we'd cache a truncated response that |
| 261 |
* gets served repeatedly until purge. We accumulate every chunk here so |
| 262 |
* the cache file always reflects the complete page. |
| 263 |
* |
| 264 |
* @var string |
| 265 |
*/ |
| 266 |
private static $accumulated = ''; |
| 267 |
|
| 268 |
public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) { |
| 269 |
self::$accumulated .= $buffer; |
| 270 |
|
| 271 |
// On non-final phases (mid-request flushes), pass the current chunk |
| 272 |
// through to the client unmodified and keep collecting. The WP 6.9 |
| 273 |
// filter path always passes the full body in one shot with the |
| 274 |
// default $phase, so it falls straight through to the final block. |
| 275 |
$is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0; |
| 276 |
if ( ! $is_final ) { |
| 277 |
return $buffer; |
| 278 |
} |
| 279 |
|
| 280 |
$full = self::$accumulated; |
| 281 |
self::$accumulated = ''; |
| 282 |
|
| 283 |
if ( strlen( $full ) < 255 ) { |
| 284 |
return $buffer; |
| 285 |
} |
| 286 |
|
| 287 |
if ( function_exists( 'http_response_code' ) && 200 !== http_response_code() ) { |
| 288 |
return $buffer; |
| 289 |
} |
| 290 |
|
| 291 |
// If no mid-request flush happened, $buffer === $full and we can |
| 292 |
// safely minify the on-wire bytes too. Otherwise earlier chunks have |
| 293 |
// already been sent unminified, so we minify only what goes to disk — |
| 294 |
// the first visitor sees unminified HTML, every cache hit after that |
| 295 |
// is minified. |
| 296 |
$single_chunk = ( $buffer === $full ); |
| 297 |
|
| 298 |
// minify_html now owned by the Minify module; read through the |
| 299 |
// module's storage so this stays consistent with the engine that |
| 300 |
// applies CSS/JS minification. |
| 301 |
$minify_opts = Settings_Manager::get( 'minify' ); |
| 302 |
if ( ! empty( $minify_opts['minify_html'] ) ) { |
| 303 |
$full = Minifier::minify_html( $full ); |
| 304 |
if ( $single_chunk ) { |
| 305 |
$buffer = $full; |
| 306 |
} |
| 307 |
} |
| 308 |
|
| 309 |
if ( ! file_exists( XSPEED_CACHE_DIR ) ) { |
| 310 |
wp_mkdir_p( XSPEED_CACHE_DIR ); |
| 311 |
self::write_silence( XSPEED_CACHE_DIR ); |
| 312 |
} |
| 313 |
|
| 314 |
// Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'` |
| 315 |
// where $key comes from md5() — guaranteed to be exactly 32 lowercase |
| 316 |
// hex chars, so no traversal sequence ('..', '/', null byte, etc.) |
| 317 |
// can appear. The write is therefore always inside XSPEED_CACHE_DIR. |
| 318 |
$file = self::cache_file_for( self::cache_key() ); |
| 319 |
// 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. |
| 320 |
file_put_contents( $file, $full, LOCK_EX ); |
| 321 |
|
| 322 |
// Static-cache tree (xspeed-static/{host}{path}/index.html). The |
| 323 |
// .htaccess rewrite block serves this file directly via the web |
| 324 |
// server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path. |
| 325 |
// store_static() returns silently on any path/permission issue — |
| 326 |
// the drop-in remains the safety net. |
| 327 |
self::store_static( $full ); |
| 328 |
|
| 329 |
return $buffer; |
| 330 |
} |
| 331 |
|
| 332 |
/** |
| 333 |
* Write the current response to the static-cache tree at |
| 334 |
* `xspeed-static/{host}{request_uri}/index.html`. The web-server |
| 335 |
* rewrite block points at this path so cache hits skip PHP |
| 336 |
* entirely. Caller already minified/finalized $html. |
| 337 |
* |
| 338 |
* Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist; |
| 339 |
* $uri has its query string stripped, null bytes removed, '..' |
| 340 |
* sequences collapsed, and after concatenation we verify the |
| 341 |
* resolved real path stays inside XSPEED_CACHE_STATIC_DIR before |
| 342 |
* any write. Anything off the happy path returns silently. |
| 343 |
*/ |
| 344 |
private static function store_static( string $html ): void { |
| 345 |
$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; |
| 346 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; |
| 347 |
$host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host ); |
| 348 |
$uri = str_replace( "\0", '', $uri ); |
| 349 |
$uri = (string) strtok( $uri, '?' ); |
| 350 |
if ( '' === $host || '' === $uri ) { |
| 351 |
return; |
| 352 |
} |
| 353 |
// Collapse any traversal sequences before path resolution. |
| 354 |
$uri = preg_replace( '#/+#', '/', $uri ); |
| 355 |
if ( false !== strpos( $uri, '..' ) ) { |
| 356 |
return; |
| 357 |
} |
| 358 |
|
| 359 |
$base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ); |
| 360 |
$dir = $base . '/' . $host . rtrim( $uri, '/' ); |
| 361 |
$file = $dir . '/index.html'; |
| 362 |
|
| 363 |
// Resolve the parent against the cache root to be sure the |
| 364 |
// final path is inside our tree even if the OS does anything |
| 365 |
// funny with multi-byte sequences. |
| 366 |
$base_real = realpath( WP_CONTENT_DIR ); |
| 367 |
if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) { |
| 368 |
return; |
| 369 |
} |
| 370 |
|
| 371 |
if ( ! file_exists( $dir ) ) { |
| 372 |
wp_mkdir_p( $dir ); |
| 373 |
} |
| 374 |
if ( ! is_dir( $dir ) ) { |
| 375 |
return; |
| 376 |
} |
| 377 |
// 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. |
| 378 |
file_put_contents( $file, $html, LOCK_EX ); |
| 379 |
} |
| 380 |
|
| 381 |
/** |
| 382 |
* @param string $cause Free-form human reason. Recorded in the |
| 383 |
* Activity log to give users context (e.g. |
| 384 |
* 'post saved', 'settings change', 'manual', |
| 385 |
* 'theme switch'). |
| 386 |
*/ |
| 387 |
public static function purge_all( string $cause = 'manual' ) { |
| 388 |
$count = 0; |
| 389 |
if ( is_dir( XSPEED_CACHE_DIR ) ) { |
| 390 |
$files = glob( XSPEED_CACHE_DIR . '/*.html' ); |
| 391 |
if ( $files ) { |
| 392 |
$count = count( $files ); |
| 393 |
foreach ( $files as $f ) { |
| 394 |
wp_delete_file( $f ); |
| 395 |
} |
| 396 |
} |
| 397 |
} |
| 398 |
// Static-cache tree purge — recursive because the layout is |
| 399 |
// xspeed-static/{host}/{path}/index.html, so a flat glob can't |
| 400 |
// reach everything. |
| 401 |
if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) { |
| 402 |
$count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR ); |
| 403 |
} |
| 404 |
self::update_stats( array( 'last_purge' => time() ) ); |
| 405 |
|
| 406 |
// Trigger of WP_CLI / hook / admin-bar purges all hit the same |
| 407 |
// path. Record once with the supplied cause so the dashboard |
| 408 |
// activity feed reads naturally. |
| 409 |
Activity_Log::record( |
| 410 |
'cache_purged', |
| 411 |
sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ), |
| 412 |
Activity_Log::INFO |
| 413 |
); |
| 414 |
} |
| 415 |
|
| 416 |
/** |
| 417 |
* Recursively delete every `index.html` and empty directory inside |
| 418 |
* the static-cache tree. Used by purge_all(). Returns the number of |
| 419 |
* .html files removed so purge stats stay accurate across the flat |
| 420 |
* + static caches. |
| 421 |
*/ |
| 422 |
private static function rmtree_html( string $dir ): int { |
| 423 |
if ( ! is_dir( $dir ) ) { |
| 424 |
return 0; |
| 425 |
} |
| 426 |
$removed = 0; |
| 427 |
// SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk |
| 428 |
// the whole tree regardless of order. |
| 429 |
$entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 430 |
if ( false === $entries ) { |
| 431 |
return 0; |
| 432 |
} |
| 433 |
foreach ( $entries as $entry ) { |
| 434 |
if ( '.' === $entry || '..' === $entry ) { |
| 435 |
continue; |
| 436 |
} |
| 437 |
$path = $dir . '/' . $entry; |
| 438 |
if ( is_dir( $path ) ) { |
| 439 |
$removed += self::rmtree_html( $path ); |
| 440 |
// Best-effort empty-dir cleanup; ignore failures (a |
| 441 |
// foreign file inside would block rmdir, which is fine). |
| 442 |
// 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. |
| 443 |
@rmdir( $path ); |
| 444 |
continue; |
| 445 |
} |
| 446 |
if ( substr( $entry, -5 ) === '.html' ) { |
| 447 |
wp_delete_file( $path ); |
| 448 |
++$removed; |
| 449 |
} |
| 450 |
} |
| 451 |
return $removed; |
| 452 |
} |
| 453 |
|
| 454 |
/** |
| 455 |
* Drop a "silence is golden" index.php into a directory so apaches/nginx |
| 456 |
* with directory listing enabled don't expose cache contents. |
| 457 |
*/ |
| 458 |
public static function write_silence( $dir ) { |
| 459 |
$file = trailingslashit( $dir ) . 'index.php'; |
| 460 |
if ( ! file_exists( $file ) ) { |
| 461 |
// 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. |
| 462 |
file_put_contents( $file, "<?php\n// Silence is golden.\n" ); |
| 463 |
} |
| 464 |
} |
| 465 |
|
| 466 |
/** |
| 467 |
* Persist stats with autoload disabled — stats are only read in admin |
| 468 |
* contexts, so there is no reason to inflate every frontend request's |
| 469 |
* `wp_load_alloptions()` payload. |
| 470 |
*/ |
| 471 |
private static function update_stats( array $stats ) { |
| 472 |
if ( false === get_option( 'xspeed_stats' ) ) { |
| 473 |
add_option( 'xspeed_stats', $stats, '', 'no' ); |
| 474 |
return; |
| 475 |
} |
| 476 |
update_option( 'xspeed_stats', $stats ); |
| 477 |
} |
| 478 |
|
| 479 |
public static function get_stats() { |
| 480 |
$count = 0; |
| 481 |
$size = 0; |
| 482 |
if ( is_dir( XSPEED_CACHE_DIR ) ) { |
| 483 |
$files = glob( XSPEED_CACHE_DIR . '/*.html' ); |
| 484 |
if ( $files ) { |
| 485 |
$count = count( $files ); |
| 486 |
foreach ( $files as $f ) { |
| 487 |
$size += filesize( $f ); |
| 488 |
} |
| 489 |
} |
| 490 |
} |
| 491 |
// Drain the nginx HIT-log file written by the server-level |
| 492 |
// rewrite (see nginx_snippet()) BEFORE reading totals — otherwise |
| 493 |
// nginx-served HITs that never reach PHP go uncounted and the |
| 494 |
// dashboard reports 0% hit-ratio on a perfectly working cache. |
| 495 |
Hit_Counter::collect_nginx_log_hits(); |
| 496 |
|
| 497 |
$stats = get_option( 'xspeed_stats', array() ); |
| 498 |
$totals = Hit_Counter::totals_24h(); |
| 499 |
return array( |
| 500 |
'cached_pages' => $count, |
| 501 |
'cache_size' => $size, |
| 502 |
'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0, |
| 503 |
// Rolling 24h cache performance — sourced from Hit_Counter's |
| 504 |
// hourly buckets. The frontend uses hit_ratio to drive the |
| 505 |
// CacheHero stat grid + the Health module's panel. |
| 506 |
'hits_24h' => $totals['hits'], |
| 507 |
'misses_24h' => $totals['misses'], |
| 508 |
'hit_ratio' => $totals['ratio'], |
| 509 |
); |
| 510 |
} |
| 511 |
|
| 512 |
/** |
| 513 |
* Apply the user's enable/disable choice. Called only from the REST |
| 514 |
* toggle endpoint, which is gated by current_user_can( 'manage_options' ) |
| 515 |
* and a verified REST nonce. This is the only place the drop-in and |
| 516 |
* the WP_CACHE constant are written — they MUST NOT happen on |
| 517 |
* register_activation_hook (WordPress.org review requirement). |
| 518 |
* |
| 519 |
* @param bool $enable User's choice. |
| 520 |
* @return array{ |
| 521 |
* enabled: bool, |
| 522 |
* dropin_installed: bool, |
| 523 |
* wp_cache_constant: bool, |
| 524 |
* wp_config_writable: bool, |
| 525 |
* manual_snippet: ?string |
| 526 |
* } |
| 527 |
*/ |
| 528 |
public static function toggle( $enable ) { |
| 529 |
$enable = (bool) $enable; |
| 530 |
|
| 531 |
if ( $enable ) { |
| 532 |
$dropin_ok = self::install_dropin(); |
| 533 |
$wp_config_ok = self::set_wp_cache_constant( true ); |
| 534 |
$rewrite_ok = self::install_rewrite(); |
| 535 |
self::ensure_hits_log_file(); |
| 536 |
$snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );"; |
| 537 |
|
| 538 |
Activity_Log::record( |
| 539 |
'cache_enabled_event', |
| 540 |
$wp_config_ok |
| 541 |
? 'Cache enabled. Drop-in installed, WP_CACHE constant set.' |
| 542 |
: 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.', |
| 543 |
$wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN |
| 544 |
); |
| 545 |
|
| 546 |
return array( |
| 547 |
'enabled' => true, |
| 548 |
'dropin_installed' => (bool) $dropin_ok, |
| 549 |
'wp_cache_constant' => (bool) $wp_config_ok, |
| 550 |
'rewrite_installed' => (bool) $rewrite_ok, |
| 551 |
'wp_config_writable' => self::wp_config_writable(), |
| 552 |
'manual_snippet' => $snippet, |
| 553 |
'nginx_snippet' => self::nginx_snippet(), |
| 554 |
); |
| 555 |
} |
| 556 |
|
| 557 |
self::remove_dropin(); |
| 558 |
self::set_wp_cache_constant( false ); |
| 559 |
self::remove_rewrite(); |
| 560 |
|
| 561 |
Activity_Log::record( |
| 562 |
'cache_disabled_event', |
| 563 |
'Cache disabled. Drop-in removed.', |
| 564 |
Activity_Log::INFO |
| 565 |
); |
| 566 |
|
| 567 |
return array( |
| 568 |
'enabled' => false, |
| 569 |
'dropin_installed' => false, |
| 570 |
'wp_cache_constant' => false, |
| 571 |
'rewrite_installed' => false, |
| 572 |
'wp_config_writable' => self::wp_config_writable(), |
| 573 |
'manual_snippet' => null, |
| 574 |
'nginx_snippet' => self::nginx_snippet(), |
| 575 |
); |
| 576 |
} |
| 577 |
|
| 578 |
/** |
| 579 |
* Check wp-config.php writability via WP_Filesystem. Plugin Check flags |
| 580 |
* direct is_writable() under WordPress.WP.AlternativeFunctions. |
| 581 |
*/ |
| 582 |
private static function wp_config_writable() { |
| 583 |
global $wp_filesystem; |
| 584 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 585 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 586 |
} |
| 587 |
WP_Filesystem(); |
| 588 |
|
| 589 |
return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false; |
| 590 |
} |
| 591 |
|
| 592 |
/** |
| 593 |
* Nginx server-block snippet mirroring the Apache rewrite block. |
| 594 |
* We never auto-write nginx config — it sits outside the WordPress |
| 595 |
* root and is owned by the server admin — but the dashboard |
| 596 |
* surfaces this snippet when nginx is detected so the admin can |
| 597 |
* paste it once and unlock the same PHP-bypass speedup we get on |
| 598 |
* Apache / LiteSpeed via .htaccess. |
| 599 |
* |
| 600 |
* Returns null when the server isn't nginx (no point showing it). |
| 601 |
*/ |
| 602 |
/** |
| 603 |
* Create wp-content/cache/xspeed/hits.log as an empty file so the |
| 604 |
* server-level rewrite's `access_log` directive has somewhere to |
| 605 |
* write on first request. Idempotent — touches an existing file |
| 606 |
* without disturbing accumulated lines. Called from Cache::toggle() |
| 607 |
* on enable and from auto_heal() when the file is missing. |
| 608 |
* |
| 609 |
* Permissions matter here. The file is created by PHP-FPM (often uid |
| 610 |
* www-data), but the nginx process that appends HIT lines may run as a |
| 611 |
* DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in |
| 612 |
* its own container as uid `nginx`, PHP-FPM in another as `www-data`) |
| 613 |
* they don't share a user at all. A default-umask 0644 file is then |
| 614 |
* unwritable by nginx, the access_log write silently fails, and the |
| 615 |
* dashboard shows a 0% hit ratio even though static HITs are serving. |
| 616 |
* So we widen the dir to 0777 and the file to 0666 — group/other write — |
| 617 |
* so whatever uid nginx runs as can append. (The file holds only HIT |
| 618 |
* request lines, no secrets.) |
| 619 |
*/ |
| 620 |
public static function ensure_hits_log_file(): bool { |
| 621 |
$dir = XSPEED_CACHE_DIR; |
| 622 |
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { |
| 623 |
return false; |
| 624 |
} |
| 625 |
// Ensure the dir is traversable + writable by a different-uid nginx. |
| 626 |
// 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. |
| 627 |
@chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails. |
| 628 |
$path = $dir . '/hits.log'; |
| 629 |
if ( ! file_exists( $path ) ) { |
| 630 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem. |
| 631 |
@touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check. |
| 632 |
} |
| 633 |
// World-writable so a different-uid nginx can append HIT lines. |
| 634 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock. |
| 635 |
@chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort. |
| 636 |
return file_exists( $path ); |
| 637 |
} |
| 638 |
|
| 639 |
public static function nginx_snippet(): ?string { |
| 640 |
if ( Server::NGINX !== Server::type() ) { |
| 641 |
return null; |
| 642 |
} |
| 643 |
$rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' ); |
| 644 |
$rel = rtrim( $rel, '/' ); |
| 645 |
|
| 646 |
// WP-Rocket-canonical pattern: every condition lives at |
| 647 |
// SERVER level (outside any location block). Each one appends |
| 648 |
// a tag to $xspeed_no_cache; the final check is a single |
| 649 |
// string-equality against the unmodified default "no-cache". |
| 650 |
// Only when ALL conditions pass does the rewrite fire, |
| 651 |
// jumping the request to the static file's URL. nginx then |
| 652 |
// restarts location matching against the new path, where |
| 653 |
// regular static-file serving takes over. |
| 654 |
// |
| 655 |
// Why server-level + a single rewrite (instead of try_files |
| 656 |
// inside `location /`): nginx's well-documented "if is evil" |
| 657 |
// quirk silently disables `try_files`'s last fallback when |
| 658 |
// any `if` in the same location is true. Moving the `if`s |
| 659 |
// outside any location dodges the trap completely, because |
| 660 |
// server-level rewrite is the documented stable path. |
| 661 |
// |
| 662 |
// `last` (not `break`) restarts location matching — required |
| 663 |
// so the rewritten static-file URI gets served via the normal |
| 664 |
// static-file location, not re-matched against `location /` |
| 665 |
// where our own rewrite would loop. |
| 666 |
// |
| 667 |
// The cache existence check is the LAST condition in the |
| 668 |
// chain so when the file isn't cached, $xspeed_no_cache |
| 669 |
// gets a "-nofile" tag and the rewrite is skipped — the |
| 670 |
// request falls through to whatever `location /` the user |
| 671 |
// already had (typically `try_files $uri $uri/ /index.php?$args;`). |
| 672 |
// Absolute path to the hit-log file from the nginx process's |
| 673 |
// filesystem view. Nginx's `access_log buffer=N flush=Ns` form |
| 674 |
// requires a literal path — `$document_root` variables are |
| 675 |
// rejected — so we emit `WP_CONTENT_DIR/cache/xspeed/hits.log` |
| 676 |
// computed by PHP. Works on every topology where the nginx |
| 677 |
// process shares a filesystem with PHP (container or host). |
| 678 |
$hits_abs = WP_CONTENT_DIR . '/cache/xspeed/hits.log'; |
| 679 |
|
| 680 |
$lines = array(); |
| 681 |
$lines[] = '# xSpeed static cache — paste at SERVER level (inside `server { }`,'; |
| 682 |
$lines[] = '# above your existing `location / { … }`; do NOT put it inside any'; |
| 683 |
$lines[] = '# location block).'; |
| 684 |
$lines[] = 'set $xspeed_no_cache "no-cache";'; |
| 685 |
$lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }'; |
| 686 |
$lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }'; |
| 687 |
$lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }'; |
| 688 |
$lines[] = 'if (!-f "$document_root' . $rel . '/$host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }'; |
| 689 |
// Neither `add_header` nor `access_log` is allowed inside an `if{}` |
| 690 |
// at server level (nginx rejects with "directive is not allowed |
| 691 |
// here"). The logging therefore lives in a `location` block that |
| 692 |
// matches the rewritten URI after `rewrite … last;` restarts |
| 693 |
// location matching. Every HIT lands there exactly once, every |
| 694 |
// MISS / PHP-served request never matches it. |
| 695 |
$lines[] = 'if ($xspeed_no_cache = "no-cache") {'; |
| 696 |
$lines[] = ' rewrite ^ ' . $rel . '/$host$uri/index.html last;'; |
| 697 |
$lines[] = '}'; |
| 698 |
$lines[] = ''; |
| 699 |
$lines[] = '# Serve + log the cached HIT. The `^~` modifier is REQUIRED:'; |
| 700 |
$lines[] = '# the rewrite above lands on a `…/index.html` URI, and nginx'; |
| 701 |
$lines[] = '# matches regex locations (e.g. a `~* \.html$` block a vhost'; |
| 702 |
$lines[] = '# commonly has) BEFORE plain prefix locations. Without `^~`,'; |
| 703 |
$lines[] = '# that regex block wins, this location never matches, the HIT'; |
| 704 |
$lines[] = '# is never logged, and on some vhosts the request falls through'; |
| 705 |
$lines[] = '# to PHP — i.e. the static rewrite silently does nothing.'; |
| 706 |
$lines[] = '# `^~` makes this prefix match beat any regex location, so the'; |
| 707 |
$lines[] = '# rewritten request always serves here from disk and logs once.'; |
| 708 |
$lines[] = 'location ^~ ' . $rel . '/ {'; |
| 709 |
$lines[] = ' internal;'; |
| 710 |
$lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=10s;'; |
| 711 |
$lines[] = ' # Visible HIT indicator for the fast path: this file was'; |
| 712 |
$lines[] = ' # served directly by nginx from xSpeed\'s static cache,'; |
| 713 |
$lines[] = ' # bypassing PHP entirely. The PHP drop-in sends the same'; |
| 714 |
$lines[] = ' # header with value "HIT (php)" on its slower fallback path.'; |
| 715 |
$lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;'; |
| 716 |
$lines[] = '}'; |
| 717 |
return implode( "\n", $lines ); |
| 718 |
} |
| 719 |
|
| 720 |
/** |
| 721 |
* Aggregate every enabled module's nginx_directives() into one |
| 722 |
* pasteable server-block snippet. Replaces the per-module "paste |
| 723 |
* this snippet" notices with a single consolidated paste — every |
| 724 |
* future feature toggle just regenerates this output. |
| 725 |
* |
| 726 |
* Returns null on non-nginx hosts (nothing to paste). |
| 727 |
* |
| 728 |
* Sections render in module-registration order so the layout stays |
| 729 |
* predictable; each module gets a comment header `# <slug>`. |
| 730 |
*/ |
| 731 |
public static function full_nginx_server_block(): ?string { |
| 732 |
if ( Server::NGINX !== Server::type() ) { |
| 733 |
return null; |
| 734 |
} |
| 735 |
|
| 736 |
$blocks = array(); |
| 737 |
foreach ( Module_Registry::all() as $module ) { |
| 738 |
$directives = $module->nginx_directives(); |
| 739 |
if ( ! is_string( $directives ) || '' === trim( $directives ) ) { |
| 740 |
continue; |
| 741 |
} |
| 742 |
$blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives ); |
| 743 |
} |
| 744 |
|
| 745 |
if ( empty( $blocks ) ) { |
| 746 |
return null; |
| 747 |
} |
| 748 |
|
| 749 |
$header = "# xSpeed unified nginx config — paste once into your\n" |
| 750 |
. "# nginx vhost's `server { }` block (or container nginx\n" |
| 751 |
. "# config for containerized hosts), above `location / { }`.\n" |
| 752 |
. "# Regenerated on every dashboard load — re-paste after\n" |
| 753 |
. "# toggling features so the directives reflect current state.\n"; |
| 754 |
|
| 755 |
return $header . "\n" . implode( "\n\n", $blocks ) . "\n"; |
| 756 |
} |
| 757 |
|
| 758 |
/** |
| 759 |
* Emit LiteSpeed Cache module headers on the cache-miss render |
| 760 |
* path so the server caches the response and serves subsequent |
| 761 |
* requests at edge speed without booting PHP again. |
| 762 |
* |
| 763 |
* LSCache reads two response headers: |
| 764 |
* - X-LiteSpeed-Cache-Control: public,max-age=N → "cache for N s" |
| 765 |
* - X-LiteSpeed-Tag: tag1,tag2 → tag the entry for selective |
| 766 |
* purge later via X-LiteSpeed-Purge in any later response. |
| 767 |
* |
| 768 |
* Server detection runs through Server::type() so a non-LiteSpeed |
| 769 |
* host (Apache / nginx / IIS) sees a no-op — the headers are |
| 770 |
* harmless if emitted there, but we skip them to keep response |
| 771 |
* headers tidy. The conflict check defers to the LiteSpeed Cache |
| 772 |
* plugin when present so we don't double-cache. |
| 773 |
*/ |
| 774 |
public static function maybe_emit_lscache_headers(): void { |
| 775 |
if ( headers_sent() ) { |
| 776 |
return; |
| 777 |
} |
| 778 |
if ( Server::LITESPEED !== Server::type() ) { |
| 779 |
return; |
| 780 |
} |
| 781 |
// is_plugin_active() lives in wp-admin/includes/plugin.php which |
| 782 |
// isn't auto-loaded on front-end requests. Use the option layer |
| 783 |
// directly to avoid pulling in admin code from a render path. |
| 784 |
$active = (array) get_option( 'active_plugins', array() ); |
| 785 |
if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) { |
| 786 |
return; |
| 787 |
} |
| 788 |
|
| 789 |
$opts = Settings_Manager::get( 'cache' ); |
| 790 |
$expiry = isset( $opts['cache_expiry'] ) ? (int) $opts['cache_expiry'] : DAY_IN_SECONDS; |
| 791 |
$expiry = max( 60, min( $expiry, 30 * DAY_IN_SECONDS ) ); |
| 792 |
|
| 793 |
// Tags scope the entry so a single post change can purge just |
| 794 |
// that page (or its archive) instead of the whole cache. We |
| 795 |
// always send the global `xspeed` tag plus a path-derived one. |
| 796 |
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; |
| 797 |
$path_tag = 'xspeed_' . md5( (string) strtok( $request_uri, '?' ) ); |
| 798 |
|
| 799 |
header( 'X-LiteSpeed-Cache-Control: public,max-age=' . $expiry ); |
| 800 |
header( 'X-LiteSpeed-Tag: xspeed,' . $path_tag ); |
| 801 |
} |
| 802 |
|
| 803 |
/** |
| 804 |
* Reconcile drop-in + WP_CACHE + rewrite block with the user's |
| 805 |
* saved choice. Runs on admin_init. Cheap when nothing's wrong |
| 806 |
* (one option read + a handful of file_exists / defined checks); |
| 807 |
* writes only when state has drifted (typical cause: plugin |
| 808 |
* upgrade wiped the drop-in, foreign plugin removed our WP_CACHE |
| 809 |
* define, or someone hand-edited .htaccess). |
| 810 |
* |
| 811 |
* Skipped during the WP plugin updater run so we don't race |
| 812 |
* the upgrader's own filesystem operations. |
| 813 |
*/ |
| 814 |
public static function auto_heal(): void { |
| 815 |
if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) { |
| 816 |
return; |
| 817 |
} |
| 818 |
if ( wp_doing_ajax() || wp_doing_cron() ) { |
| 819 |
return; |
| 820 |
} |
| 821 |
|
| 822 |
$opts = get_option( 'xspeed_options', array() ); |
| 823 |
if ( empty( $opts['cache_enabled'] ) ) { |
| 824 |
return; |
| 825 |
} |
| 826 |
|
| 827 |
$dropin_target = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 828 |
$dropin_ours = false; |
| 829 |
if ( file_exists( $dropin_target ) ) { |
| 830 |
$contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 831 |
$dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ); |
| 832 |
} |
| 833 |
|
| 834 |
if ( ! $dropin_ours ) { |
| 835 |
self::install_dropin(); |
| 836 |
} |
| 837 |
|
| 838 |
if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) { |
| 839 |
self::set_wp_cache_constant( true ); |
| 840 |
} |
| 841 |
|
| 842 |
// Rewrite block goes last. It's what turns the static-cache |
| 843 |
// tree into a PHP-bypass — every cache hit served by the web |
| 844 |
// server directly. Without it we still cache, just at drop-in |
| 845 |
// speed (~85ms TTFB) instead of static-file speed (~25-40ms). |
| 846 |
if ( ! self::rewrite_installed() ) { |
| 847 |
self::install_rewrite(); |
| 848 |
} |
| 849 |
|
| 850 |
// HITs log file — nginx writes one line per HIT served directly |
| 851 |
// (see nginx_snippet()), Cache::get_stats() drains the file via |
| 852 |
// Hit_Counter::collect_nginx_log_hits(). If the file vanishes |
| 853 |
// (plugin upgrade wiped wp-content/cache/), nginx errors silently |
| 854 |
// on the access_log directive and the counter stays at 0. |
| 855 |
self::ensure_hits_log_file(); |
| 856 |
} |
| 857 |
|
| 858 |
/** |
| 859 |
* Build the .htaccess rules that map cacheable requests to the |
| 860 |
* static-cache tree. Conditions are deliberately strict: GET only, |
| 861 |
* empty query string, no session/comment-author/post-password |
| 862 |
* cookie, and the static file must exist on disk. Anything that |
| 863 |
* fails one of these falls through to PHP and the drop-in / full |
| 864 |
* WordPress path. |
| 865 |
* |
| 866 |
* @return string[] Lines for insert_with_markers(). |
| 867 |
*/ |
| 868 |
public static function rewrite_block_lines(): array { |
| 869 |
// Path relative to ABSPATH so the rule lives in the site-root |
| 870 |
// .htaccess regardless of where wp-content sits. WP_CONTENT_DIR |
| 871 |
// can be moved, so we compute the document-root-relative form |
| 872 |
// at install time and bake it into the rule. |
| 873 |
$rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ); |
| 874 |
$rel = '/' . ltrim( $rel, '/' ); |
| 875 |
$rel = rtrim( $rel, '/' ); |
| 876 |
|
| 877 |
return array( |
| 878 |
'<IfModule mod_rewrite.c>', |
| 879 |
' RewriteEngine On', |
| 880 |
' RewriteCond %{REQUEST_METHOD} ^GET$', |
| 881 |
' RewriteCond %{QUERY_STRING} ^$', |
| 882 |
' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]', |
| 883 |
// Capture REQUEST_URI without its trailing slash into %1. |
| 884 |
// store_static() writes `{host}{uri-without-trailing-slash}/index.html`, |
| 885 |
// so this normalization lets `/blog/` and `/blog` both hit |
| 886 |
// the same cache file without producing the double-slash |
| 887 |
// path that would skip the -f check below. |
| 888 |
' RewriteCond %{REQUEST_URI} ^(.*?)/?$', |
| 889 |
' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f', |
| 890 |
' RewriteRule . ' . $rel . '/%{HTTP_HOST}%1/index.html [L]', |
| 891 |
'</IfModule>', |
| 892 |
); |
| 893 |
} |
| 894 |
|
| 895 |
/** |
| 896 |
* Active probe that confirms the web-server static-rewrite path is |
| 897 |
* actually serving cached files. Writes a probe file with a random |
| 898 |
* nonce, fetches it over HTTP at its public URL, and checks whether |
| 899 |
* the response was served directly by the web server (Last-Modified |
| 900 |
* + ETag headers + no X-Powered-By: PHP). |
| 901 |
* |
| 902 |
* Server-agnostic: same probe works for nginx (snippet pasted) and |
| 903 |
* Apache / LiteSpeed (.htaccess block installed). If the rewrite |
| 904 |
* isn't engaged, the request falls through to WordPress and PHP |
| 905 |
* adds its own headers, which the probe detects and reports. |
| 906 |
* |
| 907 |
* Throttled via a 5-minute transient — we never want this running |
| 908 |
* on every Health card paint. |
| 909 |
* |
| 910 |
* @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int} |
| 911 |
*/ |
| 912 |
public static function probe_static_rewrite(): array { |
| 913 |
$cached = get_transient( 'xspeed_rewrite_probe' ); |
| 914 |
if ( is_array( $cached ) ) { |
| 915 |
return $cached; |
| 916 |
} |
| 917 |
|
| 918 |
$home = home_url( '/' ); |
| 919 |
$host = (string) wp_parse_url( $home, PHP_URL_HOST ); |
| 920 |
if ( '' === $host ) { |
| 921 |
$result = array( 'active' => false, 'reason' => 'home_url has no host' ); |
| 922 |
set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 923 |
return $result; |
| 924 |
} |
| 925 |
|
| 926 |
// Use a randomised path AND nonce so a stale CDN cache entry |
| 927 |
// from a prior probe can never make a broken install look |
| 928 |
// healthy. Path is namespaced under __xspeed_probe__ so the |
| 929 |
// directory listing stays obvious if cleanup misfires. |
| 930 |
$slug = wp_generate_password( 12, false, false ); |
| 931 |
$nonce = wp_generate_password( 24, false, false ); |
| 932 |
$probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug; |
| 933 |
$probe_file = $probe_dir . '/index.html'; |
| 934 |
$probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/'; |
| 935 |
|
| 936 |
if ( ! file_exists( $probe_dir ) ) { |
| 937 |
wp_mkdir_p( $probe_dir ); |
| 938 |
} |
| 939 |
if ( ! is_dir( $probe_dir ) ) { |
| 940 |
$result = array( 'active' => false, 'reason' => 'cannot create probe dir' ); |
| 941 |
set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 942 |
return $result; |
| 943 |
} |
| 944 |
// 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. |
| 945 |
file_put_contents( $probe_file, $nonce, LOCK_EX ); |
| 946 |
|
| 947 |
$resp = wp_remote_get( |
| 948 |
$probe_url, |
| 949 |
array( |
| 950 |
'timeout' => 4, |
| 951 |
'sslverify' => false, |
| 952 |
'redirection' => 0, |
| 953 |
'headers' => array( 'Cache-Control' => 'no-cache' ), |
| 954 |
) |
| 955 |
); |
| 956 |
|
| 957 |
// Best-effort cleanup so we don't accumulate probe dirs even |
| 958 |
// if subsequent calls all hit the transient. |
| 959 |
if ( file_exists( $probe_file ) ) { |
| 960 |
wp_delete_file( $probe_file ); |
| 961 |
} |
| 962 |
if ( is_dir( $probe_dir ) ) { |
| 963 |
// 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. |
| 964 |
@rmdir( $probe_dir ); |
| 965 |
} |
| 966 |
|
| 967 |
if ( is_wp_error( $resp ) ) { |
| 968 |
$result = array( |
| 969 |
'active' => false, |
| 970 |
'reason' => 'http error: ' . $resp->get_error_message(), |
| 971 |
); |
| 972 |
set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 973 |
return $result; |
| 974 |
} |
| 975 |
|
| 976 |
$code = (int) wp_remote_retrieve_response_code( $resp ); |
| 977 |
$body = (string) wp_remote_retrieve_body( $resp ); |
| 978 |
$ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' ); |
| 979 |
$has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' ) |
| 980 |
|| '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' ); |
| 981 |
$match = trim( $body ) === $nonce; |
| 982 |
|
| 983 |
// "Active" = the web server served our raw nonce bytes back |
| 984 |
// AND emitted the static-serve markers (ETag / Last-Modified) |
| 985 |
// AND didn't add an X-Powered-By: PHP header. All three are |
| 986 |
// individually noisy; together they're conclusive. |
| 987 |
$active = $match && $has_etag && ! $ua_php && 200 === $code; |
| 988 |
|
| 989 |
if ( $active ) { |
| 990 |
$reason = 'static-served'; |
| 991 |
} elseif ( 200 === $code && $match && $ua_php ) { |
| 992 |
$reason = 'php served the file instead of nginx/Apache (rewrite block missing)'; |
| 993 |
} elseif ( 200 === $code && ! $match ) { |
| 994 |
$reason = 'unexpected body (CDN cached an older response?)'; |
| 995 |
} elseif ( 404 === $code ) { |
| 996 |
$reason = 'probe URL returned 404 (rewrite block missing or wrong path)'; |
| 997 |
} else { |
| 998 |
$reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' ); |
| 999 |
} |
| 1000 |
|
| 1001 |
$result = array( |
| 1002 |
'active' => $active, |
| 1003 |
'reason' => $reason, |
| 1004 |
'code' => $code, |
| 1005 |
'php' => $ua_php, |
| 1006 |
); |
| 1007 |
set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS ); |
| 1008 |
return $result; |
| 1009 |
} |
| 1010 |
|
| 1011 |
public static function rewrite_installed(): bool { |
| 1012 |
$htaccess = ABSPATH . '.htaccess'; |
| 1013 |
if ( ! file_exists( $htaccess ) ) { |
| 1014 |
return false; |
| 1015 |
} |
| 1016 |
$existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 1017 |
if ( ! is_string( $existing ) ) { |
| 1018 |
return false; |
| 1019 |
} |
| 1020 |
return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' ); |
| 1021 |
} |
| 1022 |
|
| 1023 |
/** |
| 1024 |
* Install the static-cache rewrite block at the TOP of .htaccess. |
| 1025 |
* |
| 1026 |
* Position matters: WordPress's own block ends with |
| 1027 |
* `RewriteRule . /index.php [L]` which routes every non-file |
| 1028 |
* request to PHP. The [L] flag stops the current rewrite pass, |
| 1029 |
* but Apache restarts the cycle; on the second pass REQUEST_URI |
| 1030 |
* is /index.php and no static-file check can match. The only |
| 1031 |
* reliable position for a "serve static if it exists" rule is |
| 1032 |
* before WordPress's block. |
| 1033 |
* |
| 1034 |
* WP's insert_with_markers() always appends, so we manage the |
| 1035 |
* block manually: strip any prior xSpeed Static Cache markers, |
| 1036 |
* then write our block followed by the rest of the file. |
| 1037 |
*/ |
| 1038 |
public static function install_rewrite(): bool { |
| 1039 |
$htaccess = ABSPATH . '.htaccess'; |
| 1040 |
$existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 1041 |
if ( false === $existing ) { |
| 1042 |
$existing = ''; |
| 1043 |
} |
| 1044 |
// Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in |
| 1045 |
// covers; we skip the write so we don't litter their root. |
| 1046 |
// 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. |
| 1047 |
if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) { |
| 1048 |
return false; |
| 1049 |
} |
| 1050 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above. |
| 1051 |
if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) { |
| 1052 |
return false; |
| 1053 |
} |
| 1054 |
|
| 1055 |
$cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' ); |
| 1056 |
$block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() ); |
| 1057 |
$next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned ); |
| 1058 |
|
| 1059 |
// 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. |
| 1060 |
return false !== file_put_contents( $htaccess, $next, LOCK_EX ); |
| 1061 |
} |
| 1062 |
|
| 1063 |
public static function remove_rewrite(): bool { |
| 1064 |
$htaccess = ABSPATH . '.htaccess'; |
| 1065 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale. |
| 1066 |
if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) { |
| 1067 |
return false; |
| 1068 |
} |
| 1069 |
$existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 1070 |
if ( false === $existing ) { |
| 1071 |
return false; |
| 1072 |
} |
| 1073 |
$cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' ); |
| 1074 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale. |
| 1075 |
return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX ); |
| 1076 |
} |
| 1077 |
|
| 1078 |
/** |
| 1079 |
* Strip a `# BEGIN <marker>` ... `# END <marker>` block from a |
| 1080 |
* .htaccess-style file, including any blank line that immediately |
| 1081 |
* follows it. Idempotent — returns the input unchanged if the |
| 1082 |
* marker isn't present. |
| 1083 |
*/ |
| 1084 |
private static function strip_marker_block( string $contents, string $marker ): string { |
| 1085 |
$pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s"; |
| 1086 |
$out = preg_replace( $pattern, '', $contents ); |
| 1087 |
return is_string( $out ) ? $out : $contents; |
| 1088 |
} |
| 1089 |
|
| 1090 |
private static function marker_block( string $marker, array $lines ): string { |
| 1091 |
$header = "# BEGIN $marker\n"; |
| 1092 |
$header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n"; |
| 1093 |
$header .= "# dynamically generated, and should only be modified via WordPress filters.\n"; |
| 1094 |
$header .= "# Any changes to the directives between these markers will be overwritten.\n"; |
| 1095 |
$footer = "# END $marker\n"; |
| 1096 |
return $header . implode( "\n", $lines ) . "\n" . $footer; |
| 1097 |
} |
| 1098 |
|
| 1099 |
public static function install_dropin() { |
| 1100 |
$source = XSPEED_DIR . 'includes/advanced-cache.php'; |
| 1101 |
$target = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 1102 |
if ( ! file_exists( $source ) ) { |
| 1103 |
return false; |
| 1104 |
} |
| 1105 |
|
| 1106 |
global $wp_filesystem; |
| 1107 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 1108 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 1109 |
} |
| 1110 |
WP_Filesystem(); |
| 1111 |
if ( ! $wp_filesystem ) { |
| 1112 |
return false; |
| 1113 |
} |
| 1114 |
|
| 1115 |
$source_contents = $wp_filesystem->get_contents( $source ); |
| 1116 |
if ( ! is_string( $source_contents ) ) { |
| 1117 |
return false; |
| 1118 |
} |
| 1119 |
|
| 1120 |
if ( file_exists( $target ) ) { |
| 1121 |
$existing = $wp_filesystem->get_contents( $target ); |
| 1122 |
$is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' ); |
| 1123 |
|
| 1124 |
if ( $is_xspeed ) { |
| 1125 |
if ( $existing === $source_contents ) { |
| 1126 |
return true; |
| 1127 |
} |
| 1128 |
return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE ); |
| 1129 |
} |
| 1130 |
|
| 1131 |
// Foreign drop-in (e.g. left over from another cache plugin) — back it up |
| 1132 |
// before overwriting so the user can recover if needed. Uploads dir |
| 1133 |
// (not wp-content root) keeps the backup out of WordPress's reserved |
| 1134 |
// drop-in location. |
| 1135 |
$upload = wp_upload_dir( null, false ); |
| 1136 |
$basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false; |
| 1137 |
if ( $basedir ) { |
| 1138 |
if ( ! file_exists( $basedir ) ) { |
| 1139 |
wp_mkdir_p( $basedir ); |
| 1140 |
self::write_silence( $basedir ); |
| 1141 |
} |
| 1142 |
$backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak'; |
| 1143 |
$wp_filesystem->move( $target, $backup, true ); |
| 1144 |
} else { |
| 1145 |
$wp_filesystem->delete( $target ); |
| 1146 |
} |
| 1147 |
} |
| 1148 |
|
| 1149 |
return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE ); |
| 1150 |
} |
| 1151 |
|
| 1152 |
public static function remove_dropin() { |
| 1153 |
$target = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 1154 |
if ( ! file_exists( $target ) ) { |
| 1155 |
return; |
| 1156 |
} |
| 1157 |
|
| 1158 |
global $wp_filesystem; |
| 1159 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 1160 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 1161 |
} |
| 1162 |
WP_Filesystem(); |
| 1163 |
if ( ! $wp_filesystem ) { |
| 1164 |
return; |
| 1165 |
} |
| 1166 |
|
| 1167 |
$contents = $wp_filesystem->get_contents( $target ); |
| 1168 |
if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) { |
| 1169 |
wp_delete_file( $target ); |
| 1170 |
} |
| 1171 |
} |
| 1172 |
|
| 1173 |
public static function set_wp_cache_constant( $enable ) { |
| 1174 |
$wp_config = ABSPATH . 'wp-config.php'; |
| 1175 |
if ( ! file_exists( $wp_config ) ) { |
| 1176 |
return false; |
| 1177 |
} |
| 1178 |
|
| 1179 |
global $wp_filesystem; |
| 1180 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 1181 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 1182 |
} |
| 1183 |
WP_Filesystem(); |
| 1184 |
if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) { |
| 1185 |
return false; |
| 1186 |
} |
| 1187 |
|
| 1188 |
$config = $wp_filesystem->get_contents( $wp_config ); |
| 1189 |
|
| 1190 |
if ( $enable ) { |
| 1191 |
if ( strpos( $config, "define( 'WP_CACHE'" ) !== false || strpos( $config, "define('WP_CACHE'" ) !== false ) { |
| 1192 |
return true; |
| 1193 |
} |
| 1194 |
$config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 ); |
| 1195 |
} else { |
| 1196 |
$config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config ); |
| 1197 |
} |
| 1198 |
|
| 1199 |
return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE ); |
| 1200 |
} |
| 1201 |
|
| 1202 |
public function admin_bar_purge( $wp_admin_bar ) { |
| 1203 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 1204 |
return; |
| 1205 |
} |
| 1206 |
$wp_admin_bar->add_node( |
| 1207 |
array( |
| 1208 |
'id' => 'xspeed-purge', |
| 1209 |
'title' => __( 'Purge xSpeed Cache', 'xspeed' ), |
| 1210 |
'href' => wp_nonce_url( admin_url( 'admin-post.php?action=xspeed_purge' ), 'xspeed_purge' ), |
| 1211 |
) |
| 1212 |
); |
| 1213 |
} |
| 1214 |
|
| 1215 |
public function handle_admin_bar_purge() { |
| 1216 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 1217 |
wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 ); |
| 1218 |
} |
| 1219 |
check_admin_referer( 'xspeed_purge' ); |
| 1220 |
self::purge_all(); |
| 1221 |
wp_safe_redirect( wp_get_referer() ?: admin_url() ); |
| 1222 |
exit; |
| 1223 |
} |
| 1224 |
} |
| 1225 |
|