| 1 |
<?php |
| 2 |
/** |
| 3 |
* Health — shared diagnostic checks consumed by both Onboarding's |
| 4 |
* environment surface and the Health module's dashboard panel. |
| 5 |
* |
| 6 |
* Each check returns: |
| 7 |
* [ |
| 8 |
* 'id' => 'wp_version', |
| 9 |
* 'tone' => 'ok' | 'warn' | 'fail' | 'info', |
| 10 |
* 'label' => 'WordPress 6.6', |
| 11 |
* 'detail' => 'Meets the 6.0+ minimum.', |
| 12 |
* ] |
| 13 |
* |
| 14 |
* Pure reads — never writes to disk, never makes outbound HTTP calls. |
| 15 |
* Safe to call from any request including the loading dashboard. |
| 16 |
* |
| 17 |
* @package XSpeed |
| 18 |
*/ |
| 19 |
|
| 20 |
declare(strict_types=1); |
| 21 |
|
| 22 |
namespace XSpeed; |
| 23 |
|
| 24 |
defined( 'ABSPATH' ) || exit; |
| 25 |
|
| 26 |
final class Health { |
| 27 |
|
| 28 |
public const OK = 'ok'; |
| 29 |
public const WARN = 'warn'; |
| 30 |
public const FAIL = 'fail'; |
| 31 |
public const INFO = 'info'; |
| 32 |
|
| 33 |
private const SERVER_LABELS = array( |
| 34 |
'apache' => 'Apache', |
| 35 |
'litespeed' => 'LiteSpeed', |
| 36 |
'nginx' => 'nginx', |
| 37 |
'iis' => 'IIS', |
| 38 |
'unknown' => 'Unknown', |
| 39 |
); |
| 40 |
|
| 41 |
/** |
| 42 |
* Full check list used by the Health module's dashboard panel. |
| 43 |
* |
| 44 |
* @return array<int,array{id:string,tone:string,label:string,detail:string}> |
| 45 |
*/ |
| 46 |
public static function checks(): array { |
| 47 |
global $wp_version; |
| 48 |
|
| 49 |
$server_type = Server::type(); |
| 50 |
$gzip_mode = Server::gzip_mode(); |
| 51 |
$conflicts = Server::conflicts(); |
| 52 |
$cache_dir = defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR : ( WP_CONTENT_DIR . '/cache/xspeed' ); |
| 53 |
|
| 54 |
$out = array(); |
| 55 |
|
| 56 |
// WordPress version |
| 57 |
$wp_ok = version_compare( (string) $wp_version, '6.0', '>=' ); |
| 58 |
$out[] = array( |
| 59 |
'id' => 'wp_version', |
| 60 |
'tone' => $wp_ok ? self::OK : self::FAIL, |
| 61 |
'label' => sprintf( 'WordPress %s', (string) $wp_version ), |
| 62 |
'detail' => $wp_ok ? 'Meets the 6.0+ minimum.' : 'Upgrade to WordPress 6.0 or higher.', |
| 63 |
); |
| 64 |
|
| 65 |
// PHP version |
| 66 |
$php_ok = version_compare( PHP_VERSION, '7.4', '>=' ); |
| 67 |
$php_modern = version_compare( PHP_VERSION, '8.1', '>=' ); |
| 68 |
$out[] = array( |
| 69 |
'id' => 'php_version', |
| 70 |
'tone' => $php_modern ? self::OK : ( $php_ok ? self::WARN : self::FAIL ), |
| 71 |
'label' => sprintf( 'PHP %s', PHP_VERSION ), |
| 72 |
'detail' => $php_modern |
| 73 |
? 'Modern PHP — full speed.' |
| 74 |
: ( $php_ok |
| 75 |
? 'Works, but 8.1+ is recommended for best performance.' |
| 76 |
: 'Upgrade to PHP 7.4 or higher.' ), |
| 77 |
); |
| 78 |
|
| 79 |
// Server |
| 80 |
$out[] = array( |
| 81 |
'id' => 'server', |
| 82 |
'tone' => self::INFO, |
| 83 |
'label' => sprintf( 'Server: %s', self::SERVER_LABELS[ $server_type ] ?? 'Unknown' ), |
| 84 |
'detail' => 'auto' === $gzip_mode |
| 85 |
? 'GZIP can be auto-configured via .htaccess.' |
| 86 |
: 'GZIP requires a manual server-config snippet (shown in the GZIP module).', |
| 87 |
); |
| 88 |
|
| 89 |
// Cache directory writable |
| 90 |
$dir_writable = wp_mkdir_p( $cache_dir ) && wp_is_writable( $cache_dir ); |
| 91 |
$out[] = array( |
| 92 |
'id' => 'cache_dir', |
| 93 |
'tone' => $dir_writable ? self::OK : self::FAIL, |
| 94 |
'label' => 'Cache directory writable', |
| 95 |
'detail' => $dir_writable |
| 96 |
? $cache_dir |
| 97 |
: sprintf( 'Cannot write to %s. Adjust file permissions before enabling cache.', $cache_dir ), |
| 98 |
); |
| 99 |
|
| 100 |
// Drop-in installed (only when cache is enabled — otherwise N/A) |
| 101 |
$cache_enabled = (bool) Settings::get()['cache_enabled']; |
| 102 |
if ( $cache_enabled ) { |
| 103 |
// Ask the ownership oracle, not the bytes. A loose "xspeed" |
| 104 |
// substring matched any foreign drop-in that so much as mentions |
| 105 |
// us in a compatibility note, and reported it as "owned by |
| 106 |
// xSpeed" while another plugin served every hit — the exact |
| 107 |
// loose-substring test the drop-in contract forbids. |
| 108 |
$dropin_match = Cache::DROPIN_XSPEED === Cache::dropin_owner(); |
| 109 |
$out[] = array( |
| 110 |
'id' => 'dropin', |
| 111 |
'tone' => $dropin_match ? self::OK : self::FAIL, |
| 112 |
'label' => 'advanced-cache.php drop-in', |
| 113 |
'detail' => $dropin_match |
| 114 |
? 'Installed and owned by xSpeed.' |
| 115 |
: 'Drop-in missing or owned by another plugin. Toggle Enable Cache off and on to reinstall.', |
| 116 |
); |
| 117 |
} |
| 118 |
|
| 119 |
// WP_CACHE constant |
| 120 |
$wp_cache_const = defined( 'WP_CACHE' ) && WP_CACHE; |
| 121 |
if ( $cache_enabled ) { |
| 122 |
// Why the constant is missing decides what we tell the user to |
| 123 |
// do, so the two cases can't share one sentence. An unwritable |
| 124 |
// wp-config.php (managed hosts make it read-only by design) is |
| 125 |
// the common cause and the user must paste the line by hand. |
| 126 |
// But it is NOT the only way to land here: a leftover |
| 127 |
// `define( 'WP_CACHE', false );` from a previous cache plugin, |
| 128 |
// a missing wp-config.php, or a WP_Filesystem that wants FTP |
| 129 |
// credentials all fail set_wp_cache_constant() on a perfectly |
| 130 |
// writable file. Asserting "not writable" unconditionally told |
| 131 |
// those users something demonstrably false about their own |
| 132 |
// server and sent them hand-editing a file the plugin could |
| 133 |
// have fixed by toggling the cache off and on. (#19) |
| 134 |
// The cost is real, but it is NOT "nothing is being cached": with |
| 135 |
// the constant absent, Cache::maybe_start_cache() still serves on |
| 136 |
// template_redirect and tags the response `HIT (php)`. What the |
| 137 |
// constant buys is answering from the drop-in BEFORE WordPress |
| 138 |
// boots — worth roughly an order of magnitude on TTFB, which is |
| 139 |
// what makes this a WARN worth acting on. Claiming the cache was |
| 140 |
// idle was simply false, and it is the sentence a worried user |
| 141 |
// reads first. (#19, QA on #174) |
| 142 |
// |
| 143 |
// Ask the WRITER which branch to show, not a second oracle: Health |
| 144 |
// used to run its own wp_is_writable() against its own path |
| 145 |
// resolution, and the two disagreed with set_wp_cache_constant() |
| 146 |
// in both directions — see Cache::can_write_wp_config(). |
| 147 |
$wp_config_writable = Cache::can_write_wp_config(); |
| 148 |
$check = array( |
| 149 |
'id' => 'wp_cache_constant', |
| 150 |
'tone' => $wp_cache_const ? self::OK : self::WARN, |
| 151 |
'label' => 'WP_CACHE constant', |
| 152 |
'detail' => $wp_cache_const |
| 153 |
? 'Defined and truthy in wp-config.php.' |
| 154 |
: 'Not set — cached pages are being served the slow way. Without this constant WordPress boots fully before xSpeed can answer from cache, costing roughly 10ms per hit. ' . ( $wp_config_writable |
| 155 |
? 'wp-config.php is writable, so toggling Enable Cache off and on should set it for you; if it comes back, another plugin may have left define( \'WP_CACHE\', false ) behind — add the line below by hand instead.' |
| 156 |
: 'wp-config.php is not writable here (managed hosts often make it read-only), so add the line below by hand, above the "That\'s all, stop editing!" comment.' ), |
| 157 |
); |
| 158 |
if ( ! $wp_cache_const ) { |
| 159 |
// The line to paste, carried on the check itself so it |
| 160 |
// survives on a persistent surface. Enabling the cache |
| 161 |
// offered this only inside a toast that cleared after two |
| 162 |
// seconds with no copy button, so a user who looked away |
| 163 |
// had no way back to it anywhere in the dashboard — while |
| 164 |
// the toggle read green and the site cached nothing. |
| 165 |
// HealthCard already renders `snippet` through CopySnippet |
| 166 |
// (the nginx check proves the wiring). (#19) |
| 167 |
$check['snippet'] = "define( 'WP_CACHE', true );"; |
| 168 |
} |
| 169 |
$out[] = $check; |
| 170 |
} |
| 171 |
|
| 172 |
// Static-rewrite probe. Active end-to-end check: writes a probe |
| 173 |
// file under the static-cache dir, fetches it over HTTP, and |
| 174 |
// confirms the web server (nginx OR Apache/LiteSpeed) served |
| 175 |
// the raw file. Result is throttled to a 5-minute transient |
| 176 |
// inside Cache::probe_static_rewrite so we never hit the |
| 177 |
// network per-paint. |
| 178 |
if ( $cache_enabled ) { |
| 179 |
$server_type = Server::type(); |
| 180 |
// The live loopback probe only matters where a server-level static |
| 181 |
// rewrite is actually used (nginx snippet / Apache .htaccess). |
| 182 |
// LiteSpeed serves hits via the drop-in (see below), so skip the |
| 183 |
// probe there entirely — no needless self-request. Health is the |
| 184 |
// right place to pay for the probe when we DO run it (the admin |
| 185 |
// bootstrap reads cache-only so it never blocks); the 5-minute |
| 186 |
// transient still throttles repeat runs. (FBS-82142) |
| 187 |
$probe = ( Server::LITESPEED === $server_type ) |
| 188 |
? array( 'active' => false ) |
| 189 |
: Cache::probe_static_rewrite( true ); |
| 190 |
$is_active = (bool) ( $probe['active'] ?? false ); |
| 191 |
// An inconclusive probe (blocked loopback, TLS failure, timeout, a |
| 192 |
// CDN/WAF answering instead of the origin) proves nothing about the |
| 193 |
// rewrite. Reporting it as "not yet routing to the cache" told users |
| 194 |
// with a correct nginx config that their config was broken, and |
| 195 |
// pointed them at a snippet they had already pasted. (FBS-84012) |
| 196 |
$inconclusive = (bool) ( $probe['inconclusive'] ?? false ); |
| 197 |
$probe_reason = (string) ( $probe['reason'] ?? '' ); |
| 198 |
|
| 199 |
$block_reason = Cache::static_rewrite_block_reason(); |
| 200 |
|
| 201 |
// An OBSERVED refusal, from the last cacheable render. The settings |
| 202 |
// above say whether the rewrite is allowed; this says whether pages |
| 203 |
// are actually reaching the tree. They disagree whenever a page is |
| 204 |
// refused per-response — a nonce being the common one — and in that |
| 205 |
// case the settings are right and irrelevant: nginx is configured |
| 206 |
// correctly, and every hit still comes from PHP. (#372) |
| 207 |
$skip = Cache::last_static_skip(); |
| 208 |
if ( '' === $block_reason && ! empty( $skip['reason'] ) ) { |
| 209 |
$block_reason = 'skipped_' . (string) $skip['reason']; |
| 210 |
} |
| 211 |
$mobile_block = ( 'mobile_separate' === $block_reason ) |
| 212 |
? ' Note: Separate Mobile Cache is on, which disables the device-blind static rewrite — if your site serves the same HTML to all devices, turn it off (Cache settings) for much faster cache hits.' |
| 213 |
: ''; |
| 214 |
|
| 215 |
// A known refusal OUTRANKS the probe. probe_static_rewrite() writes |
| 216 |
// its own file under the static-cache dir and fetches that — which |
| 217 |
// succeeds whenever the server can serve a static file at all, even |
| 218 |
// when static_rewrite_allowed() is false and no real page is being |
| 219 |
// served that way. Checking $is_active first therefore reported |
| 220 |
// "PHP bypassed" on a site whose pages were all returning |
| 221 |
// HIT (php): the panel answered "why isn't static serving active?" |
| 222 |
// with the opposite of the truth. When we already know why the |
| 223 |
// rewrite is off, say that and ignore the probe. (FBS-83145) |
| 224 |
$refused = ( '' !== $block_reason ); |
| 225 |
$is_active = $is_active && ! $refused; |
| 226 |
// A refusal is a definite finding, so it also outranks |
| 227 |
// "inconclusive" — otherwise a blocked rewrite whose probe merely |
| 228 |
// failed to complete would be reported as INFO ("nothing to warn |
| 229 |
// about") instead of the WARN the block deserves. |
| 230 |
$inconclusive = $inconclusive && ! $refused; |
| 231 |
|
| 232 |
if ( Server::NGINX === $server_type ) { |
| 233 |
if ( $is_active ) { |
| 234 |
$nginx_detail = 'nginx is serving cache hits directly — PHP bypassed (~5-15ms TTFB).'; |
| 235 |
} elseif ( 'mobile_separate' === $block_reason ) { |
| 236 |
$nginx_detail = 'nginx detected, but the static rewrite is disabled because Separate Mobile Cache is on.' . $mobile_block; |
| 237 |
} elseif ( 'skipped_nonce' === $block_reason ) { |
| 238 |
$nginx_detail = self::nonce_skip_detail( $skip ); |
| 239 |
} elseif ( $inconclusive ) { |
| 240 |
$nginx_detail = sprintf( |
| 241 |
'Could not verify the static rewrite — the check itself did not complete, so this is not evidence that your config is wrong. If you have already pasted the snippet, it may well be working. Reason: %s', |
| 242 |
$probe_reason |
| 243 |
); |
| 244 |
} else { |
| 245 |
$nginx_detail = 'nginx detected but not yet routing to the cache. Paste the snippet below into your site\'s server { } block, then reload nginx.'; |
| 246 |
} |
| 247 |
|
| 248 |
$out[] = array( |
| 249 |
'id' => 'static_rewrite_nginx', |
| 250 |
// Inconclusive is INFO, not WARN — we have no finding to warn about. |
| 251 |
'tone' => $is_active ? self::OK : ( $inconclusive ? self::INFO : self::WARN ), |
| 252 |
'label' => 'Static-file rewrite (nginx server config)', |
| 253 |
'detail' => $nginx_detail, |
| 254 |
// Always ship the snippet — even when active, so the |
| 255 |
// admin has it handy for re-pasting after a server |
| 256 |
// rebuild without having to find it elsewhere. Mirror |
| 257 |
// the SAME unified block the "Server config" panel |
| 258 |
// renders (cache + gzip + browser-cache directives), |
| 259 |
// not the cache-only snippet — otherwise Health and |
| 260 |
// the Cache panel disagree on what to paste. |
| 261 |
'snippet' => Cache::full_nginx_server_block(), |
| 262 |
); |
| 263 |
} elseif ( Server::LITESPEED === $server_type ) { |
| 264 |
// LiteSpeed intentionally does NOT use the .htaccess static |
| 265 |
// rewrite: OpenLiteSpeed's .htaccess engine ignores |
| 266 |
// mod_headers (so we can't stamp X-XSpeed-Cache: HIT) and has |
| 267 |
// no per-rule access_log (so a static hit can't be counted). |
| 268 |
// We route LiteSpeed hits through the PHP drop-in instead, so |
| 269 |
// every hit is both visible (X-XSpeed-Cache: HIT) and counted |
| 270 |
// in the hit-ratio — see Cache::static_rewrite_allowed(). This |
| 271 |
// is the healthy, expected state on LiteSpeed, not a fallback. |
| 272 |
$out[] = array( |
| 273 |
'id' => 'static_rewrite_litespeed', |
| 274 |
'tone' => self::OK, |
| 275 |
'label' => 'Cache serving (LiteSpeed)', |
| 276 |
'detail' => 'Cache hits are served by xSpeed\'s drop-in and tagged X-XSpeed-Cache: HIT — so every hit is visible and counted in your hit-ratio. (LiteSpeed\'s .htaccess can\'t add that header or log static hits, so xSpeed serves them itself for accurate reporting.)', |
| 277 |
); |
| 278 |
} elseif ( Server::APACHE === $server_type ) { |
| 279 |
$installed = Cache::rewrite_installed(); |
| 280 |
if ( $is_active ) { |
| 281 |
$tone = self::OK; |
| 282 |
$detail = 'Block installed and serving cache hits directly — PHP bypassed.'; |
| 283 |
} elseif ( 'mobile_separate' === $block_reason ) { |
| 284 |
$tone = self::WARN; |
| 285 |
$detail = 'Static rewrite disabled because Separate Mobile Cache is on.' . $mobile_block; |
| 286 |
} elseif ( 'no_mod_headers' === $block_reason ) { |
| 287 |
// Not "missing" — deliberately not installed, because |
| 288 |
// Apache can't stamp X-XSpeed-Cache without mod_headers |
| 289 |
// and the hit would be invisible and uncountable. Caching |
| 290 |
// still works via the drop-in; say so, and give the one |
| 291 |
// step that actually changes the outcome. |
| 292 |
$tone = self::INFO; |
| 293 |
$detail = 'Cache hits are served by xSpeed\'s drop-in and tagged X-XSpeed-Cache: HIT (php), so every hit is visible and counted. The faster .htaccess fast path is off because Apache\'s mod_headers module is not loaded — without it a static hit could not be tagged or counted. Enable mod_headers (`a2enmod headers` on Debian/Ubuntu, then restart Apache) to shave roughly 20-30ms off each cache hit.'; |
| 294 |
} elseif ( 'skipped_nonce' === $block_reason ) { |
| 295 |
// Before this, Apache fell through to "probe failed — |
| 296 |
// check AllowOverride", sending the admin to audit a |
| 297 |
// config that was never the problem. |
| 298 |
$tone = self::WARN; |
| 299 |
$detail = self::nonce_skip_detail( $skip ); |
| 300 |
} elseif ( ! $installed ) { |
| 301 |
$tone = self::WARN; |
| 302 |
$detail = 'Block missing from .htaccess. Toggle Enable Cache off and on to reinstall it.'; |
| 303 |
} elseif ( $inconclusive ) { |
| 304 |
// Same distinction as the nginx branch: the probe never |
| 305 |
// reached a verdict, so telling the user to go re-check |
| 306 |
// AllowOverride blames a config that may be perfectly |
| 307 |
// fine. Report the failure honestly instead. (FBS-84012) |
| 308 |
$tone = self::INFO; |
| 309 |
$detail = sprintf( |
| 310 |
'Block is installed, but the check could not complete (%s), so this is not evidence that anything is misconfigured. Re-run it with `wp xspeed cache recheck-rewrite` once the site can reach itself over HTTP.', |
| 311 |
$probe_reason |
| 312 |
); |
| 313 |
} else { |
| 314 |
$tone = self::WARN; |
| 315 |
$detail = sprintf( 'Block installed but probe failed (%s). Confirm the .htaccess block is at the TOP of the file, and that AllowOverride is enabled for your site so mod_rewrite reads it.', $probe_reason ); |
| 316 |
} |
| 317 |
$out[] = array( |
| 318 |
'id' => 'static_rewrite', |
| 319 |
'tone' => $tone, |
| 320 |
'label' => 'Static-file rewrite (.htaccess)', |
| 321 |
'detail' => $detail, |
| 322 |
); |
| 323 |
} |
| 324 |
} |
| 325 |
|
| 326 |
/* |
| 327 |
* A full-page cache owned by the WEB SERVER, in front of PHP. |
| 328 |
* |
| 329 |
* Reported only when it is actually there, because it is a fact about |
| 330 |
* the host rather than a setting the admin can act on from here — an |
| 331 |
* "absent" row would be noise on the ~99% of sites that have no such |
| 332 |
* layer. When it IS there it outranks almost everything else on this |
| 333 |
* panel: nginx answers before WordPress runs, so what a visitor sees |
| 334 |
* is decided by that cache and not by anything xSpeed reports about |
| 335 |
* its own. |
| 336 |
* |
| 337 |
* The severity is about DOUBLE full-page caching, not about the layer |
| 338 |
* existing. Two independent full-page caches stacked in front of one |
| 339 |
* site have independent TTLs, and the outer one can re-serve HTML the |
| 340 |
* inner one has already regenerated — the classic "I purged and it is |
| 341 |
* still stale" report. With xSpeed's own page cache off there is only |
| 342 |
* one layer and nothing to warn about, so that case is INFO. |
| 343 |
*/ |
| 344 |
$host_cache_path = Host_Page_Caches::nginx_helper_cache_path(); |
| 345 |
if ( null !== $host_cache_path ) { |
| 346 |
$detail = $cache_enabled |
| 347 |
? 'Your server is running its own full-page cache in nginx (FastCGI), managed by the Nginx Helper plugin your host installed — so this site has TWO full-page caches stacked in front of it. xSpeed forwards every Purge All to the server layer, but the two expire on their own schedules (the server side is typically an hour), so a page can still be served from nginx after xSpeed has regenerated it. If edits keep looking stale, purge from your host\'s dashboard too, or turn xSpeed\'s page cache off and let the server layer do the work — it is the faster of the two, because it answers before PHP starts.' |
| 348 |
: 'Your server is running a full-page cache in nginx (FastCGI), managed by the Nginx Helper plugin your host installed. xSpeed\'s own page cache is off, so this is the only full-page cache in front of the site — and it is the fastest kind, answering before PHP starts. Purge All in xSpeed still clears it.'; |
| 349 |
|
| 350 |
// Path prefix as a fingerprint for the WORDING only — never as a |
| 351 |
// gate. Nginx Helper is not xCloud-only; other hosts and manual |
| 352 |
// installs use it with a cache directory somewhere else entirely. |
| 353 |
if ( 0 === strpos( $host_cache_path, '/etc/nginx/cache/' ) ) { |
| 354 |
$detail .= sprintf( ' Cache directory: %s (the layout xCloud provisions).', $host_cache_path ); |
| 355 |
} else { |
| 356 |
$detail .= sprintf( ' Cache directory: %s.', $host_cache_path ); |
| 357 |
} |
| 358 |
|
| 359 |
// The purge is a direct unlink by the PHP-FPM user against a |
| 360 |
// directory nginx owns. Whether that user can write there is a |
| 361 |
// property of the host we cannot test from here without deleting |
| 362 |
// someone's cache to find out, so say what to check rather than |
| 363 |
// claiming an outcome either way. |
| 364 |
if ( 'unlink_files' === Host_Page_Caches::nginx_helper_purge_method() ) { |
| 365 |
$detail .= ' The server cache is purged by deleting its files directly, which needs PHP to have write access to that directory — if a purge here never changes what nginx serves, that permission is the thing to check with your host.'; |
| 366 |
} |
| 367 |
|
| 368 |
if ( is_multisite() ) { |
| 369 |
$detail .= ' On multisite, nginx keys one cache per install rather than per site, so this purge clears every site on the network.'; |
| 370 |
} |
| 371 |
|
| 372 |
$out[] = array( |
| 373 |
'id' => 'host_page_cache', |
| 374 |
'tone' => $cache_enabled ? self::WARN : self::INFO, |
| 375 |
'label' => 'Server-level page cache (nginx FastCGI)', |
| 376 |
'detail' => $detail, |
| 377 |
); |
| 378 |
} |
| 379 |
|
| 380 |
// Cache expiry vs preloader schedule (deterministic rule, issue #31): |
| 381 |
// pages that expire faster than the preloader re-warms them leave the |
| 382 |
// cache cold for most real traffic — the classic "24.8% hit ratio with |
| 383 |
// everything on" misconfiguration. Pure logic in |
| 384 |
// expiry_preload_check() so it's unit-testable. |
| 385 |
if ( $cache_enabled ) { |
| 386 |
$cache_opts = Settings_Manager::get( 'cache' ); |
| 387 |
$pre_opts = Settings_Manager::get( 'preloader' ); |
| 388 |
$schedule = (string) ( $pre_opts['schedule'] ?? 'manual' ); |
| 389 |
$mismatch = self::expiry_preload_check( |
| 390 |
(int) ( $cache_opts['cache_expiry'] ?? \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS ), |
| 391 |
$schedule, |
| 392 |
! empty( $pre_opts['enabled'] ), |
| 393 |
self::schedule_interval_hours( $schedule ) |
| 394 |
); |
| 395 |
if ( null !== $mismatch ) { |
| 396 |
$out[] = $mismatch; |
| 397 |
} |
| 398 |
} |
| 399 |
|
| 400 |
// Permalinks |
| 401 |
$permalinks_ok = (bool) get_option( 'permalink_structure' ); |
| 402 |
$out[] = array( |
| 403 |
'id' => 'permalinks', |
| 404 |
'tone' => $permalinks_ok ? self::OK : self::WARN, |
| 405 |
'label' => 'Permalinks', |
| 406 |
'detail' => $permalinks_ok |
| 407 |
? 'Pretty permalinks active.' |
| 408 |
: 'Set permalinks to anything other than "Plain" — page caching needs URL paths to key on.', |
| 409 |
); |
| 410 |
|
| 411 |
// Cache-poisoning Set-Cookie detection (issue #33): a plugin emitting |
| 412 |
// Set-Cookie on anonymous pageviews forces CDN/edge BYPASS for all |
| 413 |
// HTML (Cloudflare never caches a response carrying Set-Cookie). Probe |
| 414 |
// is transient-throttled inside Cookie_Inspector, same pattern as the |
| 415 |
// static-rewrite probe above — Health is the right place to pay for it. |
| 416 |
// Cached-only: Health runs inside the dashboard REST request and the |
| 417 |
// MCP get_health tool, so this must never block on an HTTP call. |
| 418 |
// A cold verdict schedules a background refresh and reports nothing |
| 419 |
// this paint. See Cookie_Inspector::probe_cached(). |
| 420 |
$cookie_probe = Cookie_Inspector::probe_cached(); |
| 421 |
if ( $cookie_probe['checked'] ) { |
| 422 |
$offenders = $cookie_probe['cookies']; |
| 423 |
if ( empty( $offenders ) ) { |
| 424 |
$out[] = array( |
| 425 |
'id' => 'set_cookie_poisoning', |
| 426 |
'tone' => self::OK, |
| 427 |
'label' => 'No cache-poisoning cookies', |
| 428 |
'detail' => 'Anonymous pages are served without Set-Cookie, so CDN/edge caches can store them.', |
| 429 |
); |
| 430 |
} else { |
| 431 |
$named = array(); |
| 432 |
foreach ( $offenders as $c ) { |
| 433 |
$named[] = null !== $c['plugin'] |
| 434 |
? sprintf( '%s is setting %s', $c['plugin'], $c['name'] ) |
| 435 |
: sprintf( 'an unidentified plugin is setting %s', $c['name'] ); |
| 436 |
} |
| 437 |
$out[] = array( |
| 438 |
'id' => 'set_cookie_poisoning', |
| 439 |
'tone' => self::WARN, |
| 440 |
'label' => 'Set-Cookie on cacheable pages', |
| 441 |
'detail' => sprintf( |
| 442 |
'%s — this prevents CDN edge caching (Cloudflare returns BYPASS for any response with Set-Cookie). Configure the plugin to set its cookie via JavaScript instead, or exclude it from anonymous pageviews.', |
| 443 |
implode( '; ', $named ) |
| 444 |
), |
| 445 |
); |
| 446 |
} |
| 447 |
} |
| 448 |
|
| 449 |
// Conflicting plugins |
| 450 |
$out[] = array( |
| 451 |
'id' => 'conflicts', |
| 452 |
'tone' => empty( $conflicts ) ? self::OK : self::WARN, |
| 453 |
'label' => 'Caching plugin conflicts', |
| 454 |
// Not "Active:" — the list now includes a drop-in left behind by a |
| 455 |
// plugin that is not running, which is exactly the case that made |
| 456 |
// this row disagree with what the enable actually does. |
| 457 |
'detail' => empty( $conflicts ) |
| 458 |
? 'No other caching plugins detected.' |
| 459 |
: sprintf( 'Found: %s. Another page cache must be off, and its advanced-cache.php gone, before xSpeed can enable its own.', implode( ', ', $conflicts ) ), |
| 460 |
); |
| 461 |
|
| 462 |
/* |
| 463 |
* A migration whose source is STILL RUNNING. |
| 464 |
* |
| 465 |
* Distinct from the generic `conflicts` check above, which only says |
| 466 |
* "another caching plugin is active". This one knows the user imported |
| 467 |
* from it and chose (or was refused) to leave it on, so it can name the |
| 468 |
* plugin and the decision. |
| 469 |
* |
| 470 |
* The point is persistence: the import screen's warning disappears the |
| 471 |
* moment the user navigates away, and the risk does not. Two page |
| 472 |
* caches fighting over the drop-in is exactly what breaks caching for |
| 473 |
* both, so the warning has to outlive the screen it was raised on. |
| 474 |
* (#189 AC4) |
| 475 |
*/ |
| 476 |
$pending = class_exists( '\\XSpeed\\Migration' ) ? Migration::pending_source() : null; |
| 477 |
if ( null !== $pending ) { |
| 478 |
$out[] = array( |
| 479 |
'id' => 'migration_source_active', |
| 480 |
'tone' => self::WARN, |
| 481 |
'label' => sprintf( '%s is still active after import', $pending['label'] ), |
| 482 |
'detail' => sprintf( |
| 483 |
'You imported settings from %s but left it running. Two page caches fight over the cache drop-in and can break caching for both — deactivate %s on the Plugins screen once you have checked the imported settings.', |
| 484 |
$pending['label'], |
| 485 |
$pending['label'] |
| 486 |
), |
| 487 |
); |
| 488 |
} |
| 489 |
|
| 490 |
return $out; |
| 491 |
} |
| 492 |
|
| 493 |
/** |
| 494 |
* Hours between recurring preloader crawls, per schedule option. |
| 495 |
* `twicedaily` is a WordPress core schedule — omitting it meant a site |
| 496 |
* using it got no check at all, not even a pass. |
| 497 |
*/ |
| 498 |
public const PRELOAD_INTERVALS = array( |
| 499 |
'hourly' => 1, |
| 500 |
'twicedaily' => 12, |
| 501 |
'daily' => 24, |
| 502 |
'weekly' => 168, |
| 503 |
); |
| 504 |
|
| 505 |
/** |
| 506 |
* Interval in hours for a cron schedule slug, or null when it isn't a |
| 507 |
* recurring schedule (`manual`) or can't be resolved. |
| 508 |
* |
| 509 |
* Falls back to `wp_get_schedules()` so custom crons registered by a |
| 510 |
* theme or another plugin are covered too, rather than silently |
| 511 |
* skipping the check. |
| 512 |
*/ |
| 513 |
/** |
| 514 |
* Explain a static-tree refusal caused by nonces. |
| 515 |
* |
| 516 |
* Says four things, because leaving any of them out is what made this |
| 517 |
* invisible: the config is FINE (so nobody re-pastes a snippet that was |
| 518 |
* never the problem), hits are coming from PHP instead, which nonce keys |
| 519 |
* caused it, and that the refusal is deliberate rather than a bug to work |
| 520 |
* around. The keys are the actionable part — they name the plugin, and it |
| 521 |
* is usually a widget the page does not use. (#372) |
| 522 |
* |
| 523 |
* @param array{reason?:string,url?:string,keys?:string[]} $skip Recorded refusal. |
| 524 |
*/ |
| 525 |
private static function nonce_skip_detail( array $skip ): string { |
| 526 |
$detail = 'Your nginx config is correct, but pages are not reaching the static cache, so hits are served by PHP (typically ~1s instead of ~5-15ms). ' |
| 527 |
. 'They contain nonces, and a static file is served with no PHP — nothing could ever refresh them, so every anonymous form on the page would break once they expire. Keeping these pages on PHP is deliberate.'; |
| 528 |
|
| 529 |
$keys = array_filter( array_map( 'strval', (array) ( $skip['keys'] ?? array() ) ) ); |
| 530 |
if ( ! empty( $keys ) ) { |
| 531 |
$detail .= ' Nonces found: ' . implode( ', ', $keys ) . '.'; |
| 532 |
$detail .= ' These come from plugin widgets — disabling the ones this site does not use lets its pages be served statically again.'; |
| 533 |
} |
| 534 |
|
| 535 |
$url = (string) ( $skip['url'] ?? '' ); |
| 536 |
if ( '' !== $url ) { |
| 537 |
$detail .= sprintf( ' Last seen on %s.', $url ); |
| 538 |
} |
| 539 |
|
| 540 |
return $detail; |
| 541 |
} |
| 542 |
|
| 543 |
public static function schedule_interval_hours( string $schedule ): ?int { |
| 544 |
if ( isset( self::PRELOAD_INTERVALS[ $schedule ] ) ) { |
| 545 |
return self::PRELOAD_INTERVALS[ $schedule ]; |
| 546 |
} |
| 547 |
if ( '' === $schedule || 'manual' === $schedule || ! function_exists( 'wp_get_schedules' ) ) { |
| 548 |
return null; |
| 549 |
} |
| 550 |
$schedules = wp_get_schedules(); |
| 551 |
if ( ! isset( $schedules[ $schedule ]['interval'] ) ) { |
| 552 |
return null; |
| 553 |
} |
| 554 |
$hours = (int) round( (int) $schedules[ $schedule ]['interval'] / HOUR_IN_SECONDS ); |
| 555 |
return $hours > 0 ? $hours : 1; |
| 556 |
} |
| 557 |
|
| 558 |
/** |
| 559 |
* Deterministic rule: warn when cache_expiry is shorter than the |
| 560 |
* preloader's recurring interval (pages go cold between crawls). |
| 561 |
* |
| 562 |
* Pure — no WP calls — so it can be unit-tested directly. |
| 563 |
* |
| 564 |
* @param int $expiry_hours Cache expiry in hours. |
| 565 |
* @param string $schedule Preloader schedule (manual|hourly|twicedaily|daily|weekly|custom). |
| 566 |
* @param bool $preloader_enabled Whether the preloader module is on. |
| 567 |
* @param int|null $interval_hours Pre-resolved interval, for schedules |
| 568 |
* outside PRELOAD_INTERVALS. Keeps this |
| 569 |
* function pure — the caller does the |
| 570 |
* wp_get_schedules() lookup. |
| 571 |
* @return array{id:string,tone:string,label:string,detail:string}|null Check |
| 572 |
* row, or null when the rule doesn't apply (preloader off/manual). |
| 573 |
*/ |
| 574 |
public static function expiry_preload_check( int $expiry_hours, string $schedule, bool $preloader_enabled, ?int $interval_hours = null ): ?array { |
| 575 |
if ( ! $preloader_enabled ) { |
| 576 |
return null; |
| 577 |
} |
| 578 |
$interval = $interval_hours ?? ( self::PRELOAD_INTERVALS[ $schedule ] ?? null ); |
| 579 |
if ( null === $interval || $interval < 1 ) { |
| 580 |
return null; |
| 581 |
} |
| 582 |
if ( $expiry_hours < $interval ) { |
| 583 |
return array( |
| 584 |
'id' => 'expiry_preload_mismatch', |
| 585 |
'tone' => self::WARN, |
| 586 |
'label' => 'Cache expiry shorter than the preload schedule', |
| 587 |
'detail' => sprintf( |
| 588 |
'Pages expire after %dh but the preloader only re-warms them every %dh (%s), so most visits hit a cold cache. Raise Cache Expiry to at least %dh (Cache settings), or preload more often (Preloader settings).', |
| 589 |
$expiry_hours, |
| 590 |
$interval, |
| 591 |
$schedule, |
| 592 |
$interval |
| 593 |
), |
| 594 |
); |
| 595 |
} |
| 596 |
return array( |
| 597 |
'id' => 'expiry_preload_mismatch', |
| 598 |
'tone' => self::OK, |
| 599 |
'label' => 'Cache expiry covers the preload schedule', |
| 600 |
'detail' => sprintf( 'Expiry %dh ≥ preload interval %dh (%s) — preloaded pages stay warm between crawls.', $expiry_hours, $interval, $schedule ), |
| 601 |
); |
| 602 |
} |
| 603 |
|
| 604 |
/** |
| 605 |
* Lightweight environment payload for the onboarding wizard. Keeps |
| 606 |
* the legacy shape Onboarding::env_payload returned so the Welcome |
| 607 |
* step's HealthRow rendering doesn't change. |
| 608 |
*/ |
| 609 |
public static function env_payload(): array { |
| 610 |
global $wp_version; |
| 611 |
$cache_dir = defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR : ( WP_CONTENT_DIR . '/cache/xspeed' ); |
| 612 |
return array( |
| 613 |
'wp' => array( |
| 614 |
'version' => (string) $wp_version, |
| 615 |
'ok' => version_compare( (string) $wp_version, '6.0', '>=' ), |
| 616 |
), |
| 617 |
'php' => array( |
| 618 |
'version' => PHP_VERSION, |
| 619 |
'ok' => version_compare( PHP_VERSION, '7.4', '>=' ), |
| 620 |
'modern' => version_compare( PHP_VERSION, '8.1', '>=' ), |
| 621 |
), |
| 622 |
'server' => array( |
| 623 |
'type' => Server::type(), |
| 624 |
'gzip_mode' => Server::gzip_mode(), |
| 625 |
), |
| 626 |
'cache_dir' => array( |
| 627 |
'path' => $cache_dir, |
| 628 |
'writable' => wp_mkdir_p( $cache_dir ) && wp_is_writable( $cache_dir ), |
| 629 |
), |
| 630 |
'wp_config' => array( |
| 631 |
'writable' => self::wp_config_writable(), |
| 632 |
), |
| 633 |
'permalinks_ok' => (bool) get_option( 'permalink_structure' ), |
| 634 |
'conflicts' => Server::conflicts(), |
| 635 |
/* |
| 636 |
* The reason the enable would be refused right now, or null. |
| 637 |
* |
| 638 |
* `conflicts` is a list of plugins, and the wizard used it to |
| 639 |
* decide whether to open with page caching ticked. The two are |
| 640 |
* not the same question: an orphaned or doubly-defined WP_CACHE |
| 641 |
* refuses the enable with no plugin to name, so the wizard |
| 642 |
* offered a pre-ticked switch it already knew would fail. This |
| 643 |
* is the gate's own answer, so the box and the outcome agree. |
| 644 |
*/ |
| 645 |
'page_cache_blocked' => Cache::acquisition_blocker(), |
| 646 |
); |
| 647 |
} |
| 648 |
|
| 649 |
/** |
| 650 |
* Cheap writability probe for the onboarding env payload only. |
| 651 |
* |
| 652 |
* Deliberately NOT the oracle behind the WP_CACHE check — that asks |
| 653 |
* Cache::can_write_wp_config(), which runs the same WP_Filesystem test |
| 654 |
* the writer runs, so advice can never contradict behaviour. This one |
| 655 |
* stays a plain filesystem read because env_payload() is documented as |
| 656 |
* making no outbound calls, and WP_Filesystem() can try to open an |
| 657 |
* FTP/SSH connection. It shares the writer's path resolution so the two |
| 658 |
* at least agree on WHICH file they are describing. (#19, QA on #174) |
| 659 |
*/ |
| 660 |
private static function wp_config_writable(): bool { |
| 661 |
$path = Cache::wp_config_path(); |
| 662 |
return '' !== $path && wp_is_writable( $path ); |
| 663 |
} |
| 664 |
} |
| 665 |
|