$_ ) { if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) { return false; } } } $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $path = (string) strtok( $request_uri, '?' ); $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array(); if ( Glob_Matcher::any_match( $excluded_urls, $path ) ) { return false; } // Cookie-based exclusion. We only check cookie NAMES (matching // values would leak content-sensitive logic into the cache key // rules); presence of any matching cookie name skips cache. $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array(); if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) { foreach ( array_keys( $_COOKIE ) as $cookie_name ) { if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) { return false; } } } // User-agent bypass list. Substring match (not glob) since UA // strings have so much variation that glob anchoring rarely // helps and confuses users. $bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array(); if ( ! empty( $bypass_uas ) ) { $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; foreach ( $bypass_uas as $needle ) { if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) { return false; } } } // Per-post override (Phase 3.4). Honored only on singular // post-context requests — archives / 404s / taxonomies use the // global policy above. if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) { return false; } return true; } /** * Is this query-string key on the ignored-params allow-list? Supports * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`, * etc.) so users don't have to enumerate every UTM variant. */ private static function query_key_is_ignored( string $key, array $ignored ): bool { return Glob_Matcher::any_match( $ignored, $key ); } public static function cache_key() { $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default'; $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; // Strip the query string from the key so /post and /post?utm_*=… // share the same cache entry. should_cache() above already // rejected requests with non-ignored params, so by the time we // build the key the only params left are safe to drop. $uri = (string) strtok( $uri, '?' ); // Optional device bucket: when mobile_separate is on, mobile and // desktop responses live in different cache files so themes that // serve different HTML by device (AMP, WPtouch, Jetpack mobile) // can't poison each other. $device = ''; $opts = Settings_Manager::get( 'cache' ); if ( ! empty( $opts['mobile_separate'] ) ) { $device = self::is_mobile_request() ? '|m' : '|d'; } return md5( $host . $uri . $device ); } /** * Server-side mobile detection. Prefers WordPress's `wp_is_mobile()` * which uses the same UA tokens as core (so our bucket aligns with * whatever theme-side branching uses). Falls back to a tiny inline * detector if wp_is_mobile() isn't loaded (e.g. the drop-in path). */ private static function is_mobile_request(): bool { if ( function_exists( 'wp_is_mobile' ) ) { return (bool) wp_is_mobile(); } // Fallback for the rare context where wp_is_mobile() isn't loaded. // Mirrors core's wp_is_mobile() EXACTLY — including the // Sec-CH-UA-Mobile client hint it checks *before* UA tokens — so the // bucket this picks matches whatever the engine's primary path (and // the drop-in's own copy of this logic) would pick for the same // request. Drift here re-introduces the cross-path key mismatch. if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) { return '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE']; } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( '' === $ua ) { return false; } return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua ); } public static function cache_file_for( $key ) { return XSPEED_CACHE_DIR . '/' . $key . '.html'; } public static function is_expired( $file ) { // cache_expiry now owned by CacheModule; per-post override // (Phase 3.4) shrinks the TTL further when the editor set one. $opts = Settings_Manager::get( 'cache' ); $max_age = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS; $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() ); if ( null !== $post_override ) { $max_age = $post_override; } return ( time() - filemtime( $file ) ) > $max_age; } /** * Accumulator for the full response body across all output-handler phases. * * PHP invokes an ob_start() callback once per flush, and each invocation * only receives the chunk produced *since the previous flush*. If anything * during the render calls `ob_flush()` or `flush()` (some themes, lazy- * load plugins, AMP, etc. do), the final-phase call would otherwise only * see the tail of the page — and we'd cache a truncated response that * gets served repeatedly until purge. We accumulate every chunk here so * the cache file always reflects the complete page. * * @var string */ private static $accumulated = ''; public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) { self::$accumulated .= $buffer; // On non-final phases (mid-request flushes), pass the current chunk // through to the client unmodified and keep collecting. The WP 6.9 // filter path always passes the full body in one shot with the // default $phase, so it falls straight through to the final block. $is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0; if ( ! $is_final ) { return $buffer; } $full = self::$accumulated; self::$accumulated = ''; if ( strlen( $full ) < 255 ) { return $buffer; } if ( function_exists( 'http_response_code' ) && 200 !== http_response_code() ) { return $buffer; } // If no mid-request flush happened, $buffer === $full and we can // safely minify the on-wire bytes too. Otherwise earlier chunks have // already been sent unminified, so we minify only what goes to disk — // the first visitor sees unminified HTML, every cache hit after that // is minified. $single_chunk = ( $buffer === $full ); // minify_html now owned by the Minify module; read through the // module's storage so this stays consistent with the engine that // applies CSS/JS minification. $minify_opts = Settings_Manager::get( 'minify' ); if ( ! empty( $minify_opts['minify_html'] ) ) { $full = Minifier::minify_html( $full ); if ( $single_chunk ) { $buffer = $full; } } if ( ! file_exists( XSPEED_CACHE_DIR ) ) { wp_mkdir_p( XSPEED_CACHE_DIR ); self::write_silence( XSPEED_CACHE_DIR ); } // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'` // where $key comes from md5() — guaranteed to be exactly 32 lowercase // hex chars, so no traversal sequence ('..', '/', null byte, etc.) // can appear. The write is therefore always inside XSPEED_CACHE_DIR. $file = self::cache_file_for( self::cache_key() ); // 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. file_put_contents( $file, $full, LOCK_EX ); // Static-cache tree (xspeed-static/{host}{path}/index.html). The // .htaccess rewrite block serves this file directly via the web // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path. // store_static() returns silently on any path/permission issue — // the drop-in remains the safety net. // // Skip it entirely when mobile_separate is on: the rewrite is // disabled in that mode (static_rewrite_allowed()), so a static file // would only be dead weight — and a device-blind one at that. if ( self::static_rewrite_allowed() ) { self::store_static( $full ); } return $buffer; } /** * Write the current response to the static-cache tree at * `xspeed-static/{host}{request_uri}/index.html`. The web-server * rewrite block points at this path so cache hits skip PHP * entirely. Caller already minified/finalized $html. * * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist; * $uri has its query string stripped, null bytes removed, '..' * sequences collapsed, and after concatenation we verify the * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before * any write. Anything off the happy path returns silently. */ private static function store_static( string $html ): void { $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host ); $uri = str_replace( "\0", '', $uri ); $uri = (string) strtok( $uri, '?' ); if ( '' === $host || '' === $uri ) { return; } // Collapse any traversal sequences before path resolution. $uri = preg_replace( '#/+#', '/', $uri ); if ( false !== strpos( $uri, '..' ) ) { return; } $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ); $dir = $base . '/' . $host . rtrim( $uri, '/' ); $file = $dir . '/index.html'; // Resolve the parent against the cache root to be sure the // final path is inside our tree even if the OS does anything // funny with multi-byte sequences. $base_real = realpath( WP_CONTENT_DIR ); if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) { return; } if ( ! file_exists( $dir ) ) { wp_mkdir_p( $dir ); } if ( ! is_dir( $dir ) ) { return; } // 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. file_put_contents( $file, $html, LOCK_EX ); } /** * @param string $cause Free-form human reason. Recorded in the * Activity log to give users context (e.g. * 'post saved', 'settings change', 'manual', * 'theme switch'). */ public static function purge_all( string $cause = 'manual' ) { $count = 0; if ( is_dir( XSPEED_CACHE_DIR ) ) { $files = glob( XSPEED_CACHE_DIR . '/*.html' ); if ( $files ) { $count = count( $files ); foreach ( $files as $f ) { wp_delete_file( $f ); } } } // Static-cache tree purge — recursive because the layout is // xspeed-static/{host}/{path}/index.html, so a flat glob can't // reach everything. if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) { $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR ); } self::update_stats( array( 'last_purge' => time() ) ); // Trigger of WP_CLI / hook / admin-bar purges all hit the same // path. Record once with the supplied cause so the dashboard // activity feed reads naturally. Activity_Log::record( 'cache_purged', sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ), Activity_Log::INFO ); } /** * Recursively delete every `index.html` and empty directory inside * the static-cache tree. Used by purge_all(). Returns the number of * .html files removed so purge stats stay accurate across the flat * + static caches. */ private static function rmtree_html( string $dir ): int { if ( ! is_dir( $dir ) ) { return 0; } $removed = 0; // SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk // the whole tree regardless of order. $entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $entries ) { return 0; } foreach ( $entries as $entry ) { if ( '.' === $entry || '..' === $entry ) { continue; } $path = $dir . '/' . $entry; if ( is_dir( $path ) ) { $removed += self::rmtree_html( $path ); // Best-effort empty-dir cleanup; ignore failures (a // foreign file inside would block rmdir, which is fine). // 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. @rmdir( $path ); continue; } if ( substr( $entry, -5 ) === '.html' ) { wp_delete_file( $path ); ++$removed; } } return $removed; } /** * Drop a "silence is golden" index.php into a directory so apaches/nginx * with directory listing enabled don't expose cache contents. */ public static function write_silence( $dir ) { $file = trailingslashit( $dir ) . 'index.php'; if ( ! file_exists( $file ) ) { // 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. file_put_contents( $file, " $count, 'cache_size' => $size, 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0, // Rolling 24h cache performance — sourced from Hit_Counter's // hourly buckets. The frontend uses hit_ratio to drive the // CacheHero stat grid + the Health module's panel. 'hits_24h' => $totals['hits'], 'misses_24h' => $totals['misses'], 'hit_ratio' => $totals['ratio'], ); } /** * Apply the user's enable/disable choice. Called only from the REST * toggle endpoint, which is gated by current_user_can( 'manage_options' ) * and a verified REST nonce. This is the only place the drop-in and * the WP_CACHE constant are written — they MUST NOT happen on * register_activation_hook (WordPress.org review requirement). * * @param bool $enable User's choice. * @return array{ * enabled: bool, * dropin_installed: bool, * wp_cache_constant: bool, * wp_config_writable: bool, * manual_snippet: ?string * } */ public static function toggle( $enable ) { $enable = (bool) $enable; if ( $enable ) { $dropin_ok = self::install_dropin(); $wp_config_ok = self::set_wp_cache_constant( true ); $rewrite_ok = self::install_rewrite(); self::ensure_hits_log_file(); self::sync_mobile_flag(); $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );"; Activity_Log::record( 'cache_enabled_event', $wp_config_ok ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.' : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.', $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN ); return array( 'enabled' => true, 'dropin_installed' => (bool) $dropin_ok, 'wp_cache_constant' => (bool) $wp_config_ok, 'rewrite_installed' => (bool) $rewrite_ok, 'wp_config_writable' => self::wp_config_writable(), 'manual_snippet' => $snippet, 'nginx_snippet' => self::nginx_snippet(), // Unified server-block snippet aggregating every enabled // module's directives — the same value the dashboard and // Health insight render. The wizard shows this so all three // surfaces stay in lockstep. Null on non-nginx hosts. 'nginx_server_block' => self::full_nginx_server_block(), ); } self::remove_dropin(); self::set_wp_cache_constant( false ); self::remove_rewrite(); // Drop the device-bucket marker too — with the drop-in gone there's // nothing left to read it, and leaving it behind would dirty a fresh // re-enable (and leaks across test runs). self::sync_mobile_flag( false ); Activity_Log::record( 'cache_disabled_event', 'Cache disabled. Drop-in removed.', Activity_Log::INFO ); return array( 'enabled' => false, 'dropin_installed' => false, 'wp_cache_constant' => false, 'rewrite_installed' => false, 'wp_config_writable' => self::wp_config_writable(), 'manual_snippet' => null, 'nginx_snippet' => self::nginx_snippet(), 'nginx_server_block' => self::full_nginx_server_block(), ); } /** * Check wp-config.php writability via WP_Filesystem. Plugin Check flags * direct is_writable() under WordPress.WP.AlternativeFunctions. */ private static function wp_config_writable() { global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false; } /** * Nginx server-block snippet mirroring the Apache rewrite block. * We never auto-write nginx config — it sits outside the WordPress * root and is owned by the server admin — but the dashboard * surfaces this snippet when nginx is detected so the admin can * paste it once and unlock the same PHP-bypass speedup we get on * Apache / LiteSpeed via .htaccess. * * Returns null when the server isn't nginx (no point showing it). */ /** * Create wp-content/cache/xspeed/hits.log as an empty file so the * server-level rewrite's `access_log` directive has somewhere to * write on first request. Idempotent — touches an existing file * without disturbing accumulated lines. Called from Cache::toggle() * on enable and from auto_heal() when the file is missing. * * Permissions matter here. The file is created by PHP-FPM (often uid * www-data), but the nginx process that appends HIT lines may run as a * DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in * its own container as uid `nginx`, PHP-FPM in another as `www-data`) * they don't share a user at all. A default-umask 0644 file is then * unwritable by nginx, the access_log write silently fails, and the * dashboard shows a 0% hit ratio even though static HITs are serving. * So we widen the dir to 0777 and the file to 0666 — group/other write — * so whatever uid nginx runs as can append. (The file holds only HIT * request lines, no secrets.) */ /** * Directory holding the nginx hit log. Lives under uploads/, NOT the * cache dir — uninstall.php and a cache purge both delete the cache * dir, which would orphan the pasted nginx `access_log` directive's * parent directory and make `nginx -t` fail [emerg], taking down every * vhost on the host (FBS-82478). uploads/ always exists, isn't a * plugin-managed cache dir, and is never deleted on uninstall — so the * directive's target dir survives both, and nginx (which creates a * missing log FILE but not a missing DIR) can always open it. * * Falls back to the cache dir only if uploads is somehow unavailable. */ public static function hits_log_dir(): string { if ( function_exists( 'wp_upload_dir' ) ) { $uploads = wp_upload_dir( null, false ); if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) { return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed'; } } return XSPEED_CACHE_DIR; } /** Absolute path to the nginx hit log file. */ public static function hits_log_path(): string { return self::hits_log_dir() . '/hits.log'; } /** * Sync the drop-in's mobile-bucket flag file with the `mobile_separate` * setting. The drop-in (advanced-cache.php) runs before WordPress loads, * so it can't read the option — instead it checks for a zero-byte * `.mobile-separate` marker next to the cache files. When the setting is * on we touch the marker; when off we remove it. The drop-in's cache_key * computation keys off the marker's presence so its '|m'/'|d' device * bucket stays in lockstep with Cache::cache_key(). * * Without this, turning on mobile_separate made Cache::store() write keys * with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's * file_exists() always missed, every HIT fell through to a full WP boot, * and the fast pre-WP path was silently dead. * * @param bool|null $enabled Force a state; null reads the current setting. */ public static function sync_mobile_flag( $enabled = null ): void { if ( null === $enabled ) { $opts = Settings_Manager::get( 'cache' ); $enabled = ! empty( $opts['mobile_separate'] ); } $dir = XSPEED_CACHE_DIR; $flag = $dir . '/.mobile-separate'; if ( $enabled ) { if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { return; } if ( ! file_exists( $flag ) ) { // 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. @touch( $flag ); } return; } if ( file_exists( $flag ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. @unlink( $flag ); } } /** * Reconcile every mobile_separate-dependent artifact to the current * setting. Called on boot and whenever the cache settings are saved, so * flipping mobile_separate at runtime can't leave the install in a * half-converted state. * * Three things must agree with the setting: * 1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()), * 2. the device-blind server rewrite — present only when OFF * (static_rewrite_allowed()), * 3. the now-stale static-cache tree + page cache, which were keyed * under the old scheme and would serve wrong-device HTML. * * No-ops when the cache is disabled — there's nothing installed to * reconcile, and toggle() handles install/teardown itself. */ public static function reconcile_mobile_separate(): void { self::sync_mobile_flag(); // The rewrite/static reconciliation below needs the plugin's path // constants. They're absent in early-boot / unit-test contexts where // only the drop-in flag matters — bail to the flag-only behavior then. if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) { return; } // Only touch the rewrite + caches when caching is actually on. $opts = get_option( 'xspeed_options', array() ); if ( empty( $opts['cache_enabled'] ) ) { return; } $rewrite_present = self::rewrite_installed(); $rewrite_wanted = self::static_rewrite_allowed(); if ( $rewrite_present === $rewrite_wanted ) { // Already consistent — nothing flipped, leave caches intact so a // plain settings save (e.g. expiry change) doesn't blow the cache. return; } // The setting flipped. Bring the rewrite into line and purge the // now-misbucketed cache so the next request re-primes under the new // device scheme. if ( $rewrite_wanted ) { self::install_rewrite(); } else { self::remove_rewrite(); } self::purge_all( 'mobile_separate changed' ); } /** * Whether the server-level static-rewrite fast path may be used. * * The rewrite serves `{host}{path}/index.html` straight from the web * server, keyed only by host + path — it has no way to run our PHP * device detection, so it can't tell mobile from desktop. When * `mobile_separate` is on, a single static file would be shared across * devices and whoever primed it wins (mobile visitors could get desktop * HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent * UA matcher into .htaccess AND the nginx snippet (three copies that * would inevitably drift), we simply DON'T engage the static rewrite when * mobile_separate is on. Requests then fall through to the PHP drop-in, * which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only * on mobile-separate sites, in exchange for guaranteed correctness. * * LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in * particular — `.htaccess` CAN run our RewriteRule to serve the static * file, but its `.htaccess` engine ignores `mod_headers`, so we cannot * stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no * `.htaccess` equivalent of nginx's per-location `access_log` to record * the hit. The result was a cache that worked but was invisible: no HIT * header and a hit-ratio frozen near 0%. Every OTHER server gives the * user a visible HIT header + a counted hit (nginx via add_header + * access_log in its snippet; Apache via .htaccess mod_headers, which it * honors). To keep LiteSpeed CONSISTENT with the rest, we route its hits * through the PHP drop-in instead — the drop-in emits * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the * observable behavior the other servers get. The cost is the drop-in's * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in * exchange the dashboard hit-ratio and the response header finally tell * the truth there. (Apache keeps the static fast path — it honors the * header.) See maybe_emit_lscache_headers() for the paired LSCache * stand-down that stops LiteSpeed's own module from shadowing the * drop-in. */ public static function static_rewrite_allowed(): bool { // LiteSpeed: drop-in serves hits (visible + counted) — see docblock. if ( Server::LITESPEED === Server::type() ) { return false; } $opts = Settings_Manager::get( 'cache' ); return empty( $opts['mobile_separate'] ); } public static function ensure_hits_log_file(): bool { $dir = self::hits_log_dir(); if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { return false; } // Ensure the dir is traversable + writable by a different-uid nginx. // 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. @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails. $path = self::hits_log_path(); if ( ! file_exists( $path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem. @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check. } // World-writable so a different-uid nginx can append HIT lines. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock. @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort. return file_exists( $path ); } public static function nginx_snippet(): ?string { if ( Server::NGINX !== Server::type() ) { return null; } $rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' ); $rel = rtrim( $rel, '/' ); // WP-Rocket-canonical pattern: every condition lives at // SERVER level (outside any location block). Each one appends // a tag to $xspeed_no_cache; the final check is a single // string-equality against the unmodified default "no-cache". // Only when ALL conditions pass does the rewrite fire, // jumping the request to the static file's URL. nginx then // restarts location matching against the new path, where // regular static-file serving takes over. // // Why server-level + a single rewrite (instead of try_files // inside `location /`): nginx's well-documented "if is evil" // quirk silently disables `try_files`'s last fallback when // any `if` in the same location is true. Moving the `if`s // outside any location dodges the trap completely, because // server-level rewrite is the documented stable path. // // `last` (not `break`) restarts location matching — required // so the rewritten static-file URI gets served via the normal // static-file location, not re-matched against `location /` // where our own rewrite would loop. // // The cache existence check is the LAST condition in the // chain so when the file isn't cached, $xspeed_no_cache // gets a "-nofile" tag and the rewrite is skipped — the // request falls through to whatever `location /` the user // already had (typically `try_files $uri $uri/ /index.php?$args;`). // Absolute path to the hit-log file from the nginx process's // filesystem view. Nginx's `access_log buffer=N flush=Ns` form // requires a literal path — `$document_root` variables are // rejected — so PHP computes it. Lives under uploads/ (NOT the // cache dir): a cache purge or uninstall deletes the cache dir, // which would orphan this directive's parent directory and make // `nginx -t` fail [emerg] for EVERY vhost on the host // (FBS-82478). uploads/ survives both, so the directive can // never take nginx down. Works on every topology where the nginx // process shares a filesystem with PHP (container or host). $hits_abs = self::hits_log_path(); $lines = array(); $lines[] = '# xSpeed static cache — paste at server level, above location / { }.'; // Cache host must match the on-disk dir PHP writes: store_static() / // static_host() take HTTP_HOST and strip every char outside // [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits // (localhost:8192 → localhost8192). nginx's own $host can't reproduce // that: $host has the port already stripped ENTIRELY (→ localhost), so // the -f check looks for localhost/... while PHP wrote localhost8192/... // and the rewrite never fires on a non-standard port. Derive // $xspeed_host from $http_host (which keeps the port) and drop just the // colon, so it equals the PHP dir on every port. On standard ports // $http_host has no colon, so $xspeed_host == $host == the bare domain. $lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com) $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host()) $lines[] = 'set $xspeed_no_cache "no-cache";'; $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }'; $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }'; $lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }'; $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }'; // Neither `add_header` nor `access_log` is allowed inside an `if{}` // at server level (nginx rejects with "directive is not allowed // here"). The logging therefore lives in a `location` block that // matches the rewritten URI after `rewrite … last;` restarts // location matching. Every HIT lands there exactly once, every // MISS / PHP-served request never matches it. $lines[] = 'if ($xspeed_no_cache = "no-cache") {'; $lines[] = ' rewrite ^ ' . $rel . '/$xspeed_host$uri/index.html last;'; $lines[] = '}'; $lines[] = ''; $lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.'; $lines[] = 'location ^~ ' . $rel . '/ {'; $lines[] = ' internal;'; // LITERAL log path (not `set $var; access_log $var`). The variable form // makes nginx open the log lazily per-request and SILENTLY drop the // line if the open fails — so on a working host hits were served // (X-XSpeed-Cache fires regardless) but nothing was ever written and // the hit ratio sat at 0%. A literal path makes nginx open the file at // config load and actually log every hit. // // Deleting the log FILE is still safe with a literal path: nginx // recreates it on the next write/reload and `nginx -t` stays green // (verified). The only thing that [emerg]s `nginx -t` is a missing // parent DIRECTORY — and the log lives under uploads/xspeed/, which // survives cache purge + uninstall, and which ensure_hits_log_file() // (run on every admin_init via auto_heal) recreates if it ever goes // missing. So: hits are logged, and a user deleting the log can't take // nginx down. $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;'; $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;'; $lines[] = '}'; return implode( "\n", $lines ); } /** * Aggregate every enabled module's nginx_directives() into one * pasteable server-block snippet. Replaces the per-module "paste * this snippet" notices with a single consolidated paste — every * future feature toggle just regenerates this output. * * Returns null on non-nginx hosts (nothing to paste). * * Sections render in module-registration order so the layout stays * predictable; each module gets a comment header `# `. */ public static function full_nginx_server_block(): ?string { if ( Server::NGINX !== Server::type() ) { return null; } $blocks = array(); foreach ( Module_Registry::all() as $module ) { $directives = $module->nginx_directives(); if ( ! is_string( $directives ) || '' === trim( $directives ) ) { continue; } $blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives ); } if ( empty( $blocks ) ) { return null; } $header = "# xSpeed unified nginx config — paste into `server { }`, above `location / { }`; re-paste after toggling features.\n"; return $header . "\n" . implode( "\n\n", $blocks ) . "\n"; } /** * Tell LiteSpeed's LSCache module to stand down on the cache-miss * render path. * * History: this method used to emit X-LiteSpeed-Cache-Control: * public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's * LSCache store. That delegation backfired — once LSCache cached a * page it served every subsequent request from its OWN store and * intercepted the request before our site-root .htaccess static * rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache * header, our static-cache tree never served, the HIT log never * written (hit ratio frozen at 0%), and the Health probe reporting a * false "cache running on PHP fallback" because it never saw an * xSpeed-served response. * * xSpeed now owns the cache on LiteSpeed exactly as it does on Apache: * our `.htaccess` mod_rewrite block serves hits straight from the * static-cache tree (with the X-XSpeed-Cache header + access-log HIT * accounting), and PHP/the drop-in is the fallback. To guarantee * LSCache doesn't shadow that with its own copy — some LiteSpeed * configs cache by default — we send an explicit `no-cache` control so * the server defers to our rewrite. Skipped when the LiteSpeed Cache * plugin is active (it owns its own header policy; our Conflict * registry handles that coexistence separately). */ public static function maybe_emit_lscache_headers(): void { if ( headers_sent() ) { return; } if ( Server::LITESPEED !== Server::type() ) { return; } // is_plugin_active() lives in wp-admin/includes/plugin.php which // isn't auto-loaded on front-end requests. Use the option layer // directly to avoid pulling in admin code from a render path. $active = (array) get_option( 'active_plugins', array() ); if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) { return; } // Explicitly opt this response OUT of LSCache so the server can't // shadow our static-rewrite cache with its own internal copy. header( 'X-LiteSpeed-Cache-Control: no-cache' ); } /** * Reconcile drop-in + WP_CACHE + rewrite block with the user's * saved choice. Runs on admin_init. Cheap when nothing's wrong * (one option read + a handful of file_exists / defined checks); * writes only when state has drifted (typical cause: plugin * upgrade wiped the drop-in, foreign plugin removed our WP_CACHE * define, or someone hand-edited .htaccess). * * Skipped during the WP plugin updater run so we don't race * the upgrader's own filesystem operations. */ public static function auto_heal(): void { if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) { return; } if ( wp_doing_ajax() || wp_doing_cron() ) { return; } $opts = get_option( 'xspeed_options', array() ); if ( empty( $opts['cache_enabled'] ) ) { return; } $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php'; $dropin_ours = false; if ( file_exists( $dropin_target ) ) { $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ); } if ( ! $dropin_ours ) { self::install_dropin(); } if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) { self::set_wp_cache_constant( true ); } // Rewrite block goes last. It's what turns the static-cache // tree into a PHP-bypass — every cache hit served by the web // server directly. Without it we still cache, just at drop-in // speed (~85ms TTFB) instead of static-file speed (~25-40ms). // // Reconcile against mobile_separate: the rewrite is device-blind, so // it must be ABSENT when mobile_separate is on and PRESENT otherwise. // auto_heal() runs periodically, so it also repairs a rewrite that // was left installed before mobile_separate was switched on. if ( self::static_rewrite_allowed() ) { if ( ! self::rewrite_installed() ) { self::install_rewrite(); } } elseif ( self::rewrite_installed() ) { self::remove_rewrite(); } // HITs log file — nginx writes one line per HIT served directly // (see nginx_snippet()), Cache::get_stats() drains the file via // Hit_Counter::collect_nginx_log_hits(). If the file vanishes // (plugin upgrade wiped wp-content/cache/), nginx errors silently // on the access_log directive and the counter stays at 0. self::ensure_hits_log_file(); } /** * Build the .htaccess rules that map cacheable requests to the * static-cache tree. Conditions are deliberately strict: GET only, * empty query string, no session/comment-author/post-password * cookie, and the static file must exist on disk. Anything that * fails one of these falls through to PHP and the drop-in / full * WordPress path. * * @return string[] Lines for insert_with_markers(). */ public static function rewrite_block_lines(): array { // Path relative to ABSPATH so the rule lives in the site-root // .htaccess regardless of where wp-content sits. WP_CONTENT_DIR // can be moved, so we compute the document-root-relative form // at install time and bake it into the rule. $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ); $rel = '/' . ltrim( $rel, '/' ); $rel = rtrim( $rel, '/' ); return array( '', ' RewriteEngine On', ' RewriteCond %{REQUEST_METHOD} ^GET$', ' RewriteCond %{QUERY_STRING} ^$', ' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]', // Capture REQUEST_URI without its trailing slash into %1. // store_static() writes `{host}{uri-without-trailing-slash}/index.html`, // so this normalization lets `/blog/` and `/blog` both hit // the same cache file without producing the double-slash // path that would skip the -f check below. ' RewriteCond %{REQUEST_URI} ^(.*?)/?$', ' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f', // Pattern is `^`, NOT `.`. The per-directory rewrite engine // strips the leading slash before matching, so the HOMEPAGE // request `/` arrives here as an EMPTY path. `.` requires at // least one character and therefore never matches the homepage // — on LiteSpeed (which honors this strictly) the front page // fell through to PHP while every inner page rewrote fine. // `^` matches the empty string AND any non-empty path, so it // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed // 1.8: `.` → homepage served by PHP drop-in; `^` → served // directly from the static file.) ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]', '', ); } /** * Active probe that confirms the web-server static-rewrite path is * actually serving cached files. Writes a probe file with a random * nonce, fetches it over HTTP at its public URL, and checks whether * the response was served directly by the web server (Last-Modified * + ETag headers + no X-Powered-By: PHP). * * Server-agnostic: same probe works for nginx (snippet pasted) and * Apache / LiteSpeed (.htaccess block installed). If the rewrite * isn't engaged, the request falls through to WordPress and PHP * adds its own headers, which the probe detects and reports. * * Throttled via a 5-minute transient — we never want this running * on every Health card paint. * * @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int} */ /** * @param bool $allow_probe When false (the default), return ONLY a cached * result and never make an HTTP request — so admin page loads are never * blocked by the loopback probe. The actual HTTP probe only runs when a * caller explicitly opts in (the Health tab / cron). Previously this ran * synchronously on every dashboard bootstrap, so a slow/timing-out * loopback request added up to `timeout` seconds to admin page loads on * hosts that block self-requests. (FBS-82142) */ public static function probe_static_rewrite( bool $allow_probe = false ): array { $cached = get_transient( 'xspeed_rewrite_probe' ); if ( is_array( $cached ) ) { return $cached; } // No cached result yet and the caller doesn't want to pay for a live // HTTP probe (e.g. the admin bootstrap): report "pending" without // blocking. The Health tab will run the real probe on demand. if ( ! $allow_probe ) { return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true ); } $home = home_url( '/' ); $host = (string) wp_parse_url( $home, PHP_URL_HOST ); if ( '' === $host ) { $result = array( 'active' => false, 'reason' => 'home_url has no host' ); set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); return $result; } // Use a randomised path AND nonce so a stale CDN cache entry // from a prior probe can never make a broken install look // healthy. Path is namespaced under __xspeed_probe__ so the // directory listing stays obvious if cleanup misfires. $slug = wp_generate_password( 12, false, false ); $nonce = wp_generate_password( 24, false, false ); $probe_dir = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug; $probe_file = $probe_dir . '/index.html'; $probe_url = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/'; if ( ! file_exists( $probe_dir ) ) { wp_mkdir_p( $probe_dir ); } if ( ! is_dir( $probe_dir ) ) { $result = array( 'active' => false, 'reason' => 'cannot create probe dir' ); set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); return $result; } // 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. file_put_contents( $probe_file, $nonce, LOCK_EX ); // Verify TLS by default — disabling it site-wide is a needless MITM // exposure (FBS-82142). Only relax verification in local/dev // environments, where self-signed certs are common and there's no // real attacker in the loop. $is_local = function_exists( 'wp_get_environment_type' ) && in_array( wp_get_environment_type(), array( 'local', 'development' ), true ); $resp = wp_remote_get( $probe_url, array( // 3s cap so a host that hangs on loopback self-requests can't // stall the caller for long; the result/error is cached so we // don't repeat the wait every minute. 'timeout' => 3, 'sslverify' => ! $is_local, 'redirection' => 0, 'headers' => array( 'Cache-Control' => 'no-cache' ), ) ); // Best-effort cleanup so we don't accumulate probe dirs even // if subsequent calls all hit the transient. if ( file_exists( $probe_file ) ) { wp_delete_file( $probe_file ); } if ( is_dir( $probe_dir ) ) { // 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. @rmdir( $probe_dir ); } if ( is_wp_error( $resp ) ) { $result = array( 'active' => false, 'reason' => 'http error: ' . $resp->get_error_message(), ); // Cache the failure for the full 5 minutes (not 1) so a host that // times out on the loopback probe isn't re-probed — and re-stalled // — on every page load within the window. (FBS-82142) set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS ); return $result; } $code = (int) wp_remote_retrieve_response_code( $resp ); $body = (string) wp_remote_retrieve_body( $resp ); $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' ); $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' ) || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' ); $match = trim( $body ) === $nonce; // "Active" = the web server served our raw nonce bytes back // AND emitted the static-serve markers (ETag / Last-Modified) // AND didn't add an X-Powered-By: PHP header. All three are // individually noisy; together they're conclusive. $active = $match && $has_etag && ! $ua_php && 200 === $code; if ( $active ) { $reason = 'static-served'; } elseif ( 200 === $code && $match && $ua_php ) { $reason = 'php served the file instead of nginx/Apache (rewrite block missing)'; } elseif ( 200 === $code && ! $match ) { $reason = 'unexpected body (CDN cached an older response?)'; } elseif ( 404 === $code ) { $reason = 'probe URL returned 404 (rewrite block missing or wrong path)'; } else { $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' ); } $result = array( 'active' => $active, 'reason' => $reason, 'code' => $code, 'php' => $ua_php, ); set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS ); return $result; } public static function rewrite_installed(): bool { $htaccess = ABSPATH . '.htaccess'; if ( ! file_exists( $htaccess ) ) { return false; } $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( ! is_string( $existing ) ) { return false; } return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' ); } /** * Install the static-cache rewrite block at the TOP of .htaccess. * * Position matters: WordPress's own block ends with * `RewriteRule . /index.php [L]` which routes every non-file * request to PHP. The [L] flag stops the current rewrite pass, * but Apache restarts the cycle; on the second pass REQUEST_URI * is /index.php and no static-file check can match. The only * reliable position for a "serve static if it exists" rule is * before WordPress's block. * * WP's insert_with_markers() always appends, so we manage the * block manually: strip any prior xSpeed Static Cache markers, * then write our block followed by the rest of the file. */ public static function install_rewrite(): bool { // The static rewrite is device-blind; never install it when // mobile_separate is on (see static_rewrite_allowed()). if ( ! self::static_rewrite_allowed() ) { return false; } $htaccess = ABSPATH . '.htaccess'; $existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $existing ) { $existing = ''; } // Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in // covers; we skip the write so we don't litter their root. // 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. if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) { return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above. if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) { return false; } $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' ); $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() ); $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned ); // 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. return false !== file_put_contents( $htaccess, $next, LOCK_EX ); } public static function remove_rewrite(): bool { $htaccess = ABSPATH . '.htaccess'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale. if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) { return false; } $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $existing ) { return false; } $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale. return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX ); } /** * Strip a `# BEGIN ` ... `# END ` block from a * .htaccess-style file, including any blank line that immediately * follows it. Idempotent — returns the input unchanged if the * marker isn't present. */ private static function strip_marker_block( string $contents, string $marker ): string { $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s"; $out = preg_replace( $pattern, '', $contents ); return is_string( $out ) ? $out : $contents; } private static function marker_block( string $marker, array $lines ): string { $header = "# BEGIN $marker\n"; $header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n"; $header .= "# dynamically generated, and should only be modified via WordPress filters.\n"; $header .= "# Any changes to the directives between these markers will be overwritten.\n"; $footer = "# END $marker\n"; return $header . implode( "\n", $lines ) . "\n" . $footer; } public static function install_dropin() { $source = XSPEED_DIR . 'includes/advanced-cache.php'; $target = WP_CONTENT_DIR . '/advanced-cache.php'; if ( ! file_exists( $source ) ) { return false; } global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); if ( ! $wp_filesystem ) { return false; } $source_contents = $wp_filesystem->get_contents( $source ); if ( ! is_string( $source_contents ) ) { return false; } // Bake the absolute hit-log path into the drop-in. It runs before // WordPress loads, so it can't resolve wp_upload_dir() itself — we // substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path // (never the cache dir; see hits_log_dir() / FBS-82478). Use a single // quoted PHP string literal so the installed file stays valid PHP. $source_contents = str_replace( '@@XSPEED_HITS_LOG@@', str_replace( "'", "\\'", self::hits_log_path() ), $source_contents ); if ( file_exists( $target ) ) { $existing = $wp_filesystem->get_contents( $target ); $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' ); if ( $is_xspeed ) { if ( $existing === $source_contents ) { return true; } return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE ); } // Foreign drop-in (e.g. left over from another cache plugin) — back it up // before overwriting so the user can recover if needed. Uploads dir // (not wp-content root) keeps the backup out of WordPress's reserved // drop-in location. $upload = wp_upload_dir( null, false ); $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false; if ( $basedir ) { if ( ! file_exists( $basedir ) ) { wp_mkdir_p( $basedir ); self::write_silence( $basedir ); } $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak'; $wp_filesystem->move( $target, $backup, true ); } else { $wp_filesystem->delete( $target ); } } return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE ); } public static function remove_dropin() { $target = WP_CONTENT_DIR . '/advanced-cache.php'; if ( ! file_exists( $target ) ) { return; } global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); if ( ! $wp_filesystem ) { return; } $contents = $wp_filesystem->get_contents( $target ); if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) { wp_delete_file( $target ); } } public static function set_wp_cache_constant( $enable ) { $wp_config = ABSPATH . 'wp-config.php'; if ( ! file_exists( $wp_config ) ) { return false; } global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) { return false; } $config = $wp_filesystem->get_contents( $wp_config ); if ( $enable ) { if ( strpos( $config, "define( 'WP_CACHE'" ) !== false || strpos( $config, "define('WP_CACHE'" ) !== false ) { return true; } $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 ); } else { $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config ); } return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE ); } public function admin_bar_purge( $wp_admin_bar ) { if ( ! current_user_can( 'manage_options' ) ) { return; } $wp_admin_bar->add_node( array( 'id' => 'xspeed-purge', 'title' => __( 'Purge xSpeed Cache', 'xspeed' ), 'href' => wp_nonce_url( admin_url( 'admin-post.php?action=xspeed_purge' ), 'xspeed_purge' ), ) ); } public function handle_admin_bar_purge() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 ); } check_admin_referer( 'xspeed_purge' ); self::purge_all(); wp_safe_redirect( wp_get_referer() ?: admin_url() ); exit; } }