| @@ -99,10 +99,14 @@ | ||
| 99 | 99 | |
| 100 | 100 | // Drop-in installed (only when cache is enabled — otherwise N/A) |
| 101 | 101 | $cache_enabled = (bool) Settings::get()['cache_enabled']; |
| 102 | 102 | if ( $cache_enabled ) { |
| 103 | - $dropin_path = WP_CONTENT_DIR . '/advanced-cache.php'; | |
| 104 | - $dropin_match = file_exists( $dropin_path ) && false !== strpos( (string) file_get_contents( $dropin_path ), 'xspeed' ); | |
| 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(); | |
| 105 | 109 | $out[] = array( |
| 106 | 110 | 'id' => 'dropin', |
| 107 | 111 | 'tone' => $dropin_match ? self::OK : self::FAIL, |
| 108 | 112 | 'label' => 'advanced-cache.php drop-in', |
| @@ -192,8 +196,19 @@ | ||
| 192 | 196 | $inconclusive = (bool) ( $probe['inconclusive'] ?? false ); |
| 193 | 197 | $probe_reason = (string) ( $probe['reason'] ?? '' ); |
| 194 | 198 | |
| 195 | 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 | + } | |
| 196 | 211 | $mobile_block = ( 'mobile_separate' === $block_reason ) |
| 197 | 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.' |
| 198 | 213 | : ''; |
| 199 | 214 | |
| @@ -218,8 +233,10 @@ | ||
| 218 | 233 | if ( $is_active ) { |
| 219 | 234 | $nginx_detail = 'nginx is serving cache hits directly — PHP bypassed (~5-15ms TTFB).'; |
| 220 | 235 | } elseif ( 'mobile_separate' === $block_reason ) { |
| 221 | 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 ); | |
| 222 | 239 | } elseif ( $inconclusive ) { |
| 223 | 240 | $nginx_detail = sprintf( |
| 224 | 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', |
| 225 | 242 | $probe_reason |
| @@ -273,8 +290,14 @@ | ||
| 273 | 290 | // still works via the drop-in; say so, and give the one |
| 274 | 291 | // step that actually changes the outcome. |
| 275 | 292 | $tone = self::INFO; |
| 276 | 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 ); | |
| 277 | 300 | } elseif ( ! $installed ) { |
| 278 | 301 | $tone = self::WARN; |
| 279 | 302 | $detail = 'Block missing from .htaccess. Toggle Enable Cache off and on to reinstall it.'; |
| 280 | 303 | } elseif ( $inconclusive ) { |
| @@ -299,8 +322,62 @@ | ||
| 299 | 322 | ); |
| 300 | 323 | } |
| 301 | 324 | } |
| 302 | 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 | + | |
| 303 | 380 | // Cache expiry vs preloader schedule (deterministic rule, issue #31): |
| 304 | 381 | // pages that expire faster than the preloader re-warms them leave the |
| 305 | 382 | // cache cold for most real traffic — the classic "24.8% hit ratio with |
| 306 | 383 | // everything on" misconfiguration. Pure logic in |
| @@ -309,9 +386,9 @@ | ||
| 309 | 386 | $cache_opts = Settings_Manager::get( 'cache' ); |
| 310 | 387 | $pre_opts = Settings_Manager::get( 'preloader' ); |
| 311 | 388 | $schedule = (string) ( $pre_opts['schedule'] ?? 'manual' ); |
| 312 | 389 | $mismatch = self::expiry_preload_check( |
| 313 | - (int) ( $cache_opts['cache_expiry'] ?? 24 ), | |
| 390 | + (int) ( $cache_opts['cache_expiry'] ?? \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS ), | |
| 314 | 391 | $schedule, |
| 315 | 392 | ! empty( $pre_opts['enabled'] ), |
| 316 | 393 | self::schedule_interval_hours( $schedule ) |
| 317 | 394 | ); |
| @@ -330,8 +407,16 @@ | ||
| 330 | 407 | ? 'Pretty permalinks active.' |
| 331 | 408 | : 'Set permalinks to anything other than "Plain" — page caching needs URL paths to key on.', |
| 332 | 409 | ); |
| 333 | 410 | |
| 411 | + // What is in front of the site, and what we are telling it. Extracted | |
| 412 | + // so it can be exercised without paying for every other probe in | |
| 413 | + // checks(); see edge_check(). | |
| 414 | + $edge_row = self::edge_check(); | |
| 415 | + if ( null !== $edge_row ) { | |
| 416 | + $out[] = $edge_row; | |
| 417 | + } | |
| 418 | + | |
| 334 | 419 | // Cache-poisoning Set-Cookie detection (issue #33): a plugin emitting |
| 335 | 420 | // Set-Cookie on anonymous pageviews forces CDN/edge BYPASS for all |
| 336 | 421 | // HTML (Cloudflare never caches a response carrying Set-Cookie). Probe |
| 337 | 422 | // is transient-throttled inside Cookie_Inspector, same pattern as the |
| @@ -373,13 +458,44 @@ | ||
| 373 | 458 | $out[] = array( |
| 374 | 459 | 'id' => 'conflicts', |
| 375 | 460 | 'tone' => empty( $conflicts ) ? self::OK : self::WARN, |
| 376 | 461 | 'label' => 'Caching plugin conflicts', |
| 462 | + // Not "Active:" — the list now includes a drop-in left behind by a | |
| 463 | + // plugin that is not running, which is exactly the case that made | |
| 464 | + // this row disagree with what the enable actually does. | |
| 377 | 465 | 'detail' => empty( $conflicts ) |
| 378 | 466 | ? 'No other caching plugins detected.' |
| 379 | - : sprintf( 'Active: %s. Deactivate before enabling xSpeed cache to avoid double-caching.', implode( ', ', $conflicts ) ), | |
| 467 | + : sprintf( 'Found: %s. Another page cache must be off, and its advanced-cache.php gone, before xSpeed can enable its own.', implode( ', ', $conflicts ) ), | |
| 380 | 468 | ); |
| 381 | 469 | |
| 470 | + /* | |
| 471 | + * A migration whose source is STILL RUNNING. | |
| 472 | + * | |
| 473 | + * Distinct from the generic `conflicts` check above, which only says | |
| 474 | + * "another caching plugin is active". This one knows the user imported | |
| 475 | + * from it and chose (or was refused) to leave it on, so it can name the | |
| 476 | + * plugin and the decision. | |
| 477 | + * | |
| 478 | + * The point is persistence: the import screen's warning disappears the | |
| 479 | + * moment the user navigates away, and the risk does not. Two page | |
| 480 | + * caches fighting over the drop-in is exactly what breaks caching for | |
| 481 | + * both, so the warning has to outlive the screen it was raised on. | |
| 482 | + * (#189 AC4) | |
| 483 | + */ | |
| 484 | + $pending = class_exists( '\\XSpeed\\Migration' ) ? Migration::pending_source() : null; | |
| 485 | + if ( null !== $pending ) { | |
| 486 | + $out[] = array( | |
| 487 | + 'id' => 'migration_source_active', | |
| 488 | + 'tone' => self::WARN, | |
| 489 | + 'label' => sprintf( '%s is still active after import', $pending['label'] ), | |
| 490 | + 'detail' => sprintf( | |
| 491 | + '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.', | |
| 492 | + $pending['label'], | |
| 493 | + $pending['label'] | |
| 494 | + ), | |
| 495 | + ); | |
| 496 | + } | |
| 497 | + | |
| 382 | 498 | return $out; |
| 383 | 499 | } |
| 384 | 500 | |
| 385 | 501 | /** |
| @@ -401,8 +517,38 @@ | ||
| 401 | 517 | * Falls back to `wp_get_schedules()` so custom crons registered by a |
| 402 | 518 | * theme or another plugin are covered too, rather than silently |
| 403 | 519 | * skipping the check. |
| 404 | 520 | */ |
| 521 | + /** | |
| 522 | + * Explain a static-tree refusal caused by nonces. | |
| 523 | + * | |
| 524 | + * Says four things, because leaving any of them out is what made this | |
| 525 | + * invisible: the config is FINE (so nobody re-pastes a snippet that was | |
| 526 | + * never the problem), hits are coming from PHP instead, which nonce keys | |
| 527 | + * caused it, and that the refusal is deliberate rather than a bug to work | |
| 528 | + * around. The keys are the actionable part — they name the plugin, and it | |
| 529 | + * is usually a widget the page does not use. (#372) | |
| 530 | + * | |
| 531 | + * @param array{reason?:string,url?:string,keys?:string[]} $skip Recorded refusal. | |
| 532 | + */ | |
| 533 | + private static function nonce_skip_detail( array $skip ): string { | |
| 534 | + $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). ' | |
| 535 | + . '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.'; | |
| 536 | + | |
| 537 | + $keys = array_filter( array_map( 'strval', (array) ( $skip['keys'] ?? array() ) ) ); | |
| 538 | + if ( ! empty( $keys ) ) { | |
| 539 | + $detail .= ' Nonces found: ' . implode( ', ', $keys ) . '.'; | |
| 540 | + $detail .= ' These come from plugin widgets — disabling the ones this site does not use lets its pages be served statically again.'; | |
| 541 | + } | |
| 542 | + | |
| 543 | + $url = (string) ( $skip['url'] ?? '' ); | |
| 544 | + if ( '' !== $url ) { | |
| 545 | + $detail .= sprintf( ' Last seen on %s.', $url ); | |
| 546 | + } | |
| 547 | + | |
| 548 | + return $detail; | |
| 549 | + } | |
| 550 | + | |
| 405 | 551 | public static function schedule_interval_hours( string $schedule ): ?int { |
| 406 | 552 | if ( isset( self::PRELOAD_INTERVALS[ $schedule ] ) ) { |
| 407 | 553 | return self::PRELOAD_INTERVALS[ $schedule ]; |
| 408 | 554 | } |
| @@ -432,8 +578,74 @@ | ||
| 432 | 578 | * wp_get_schedules() lookup. |
| 433 | 579 | * @return array{id:string,tone:string,label:string,detail:string}|null Check |
| 434 | 580 | * row, or null when the rule doesn't apply (preloader off/manual). |
| 435 | 581 | */ |
| 582 | + /** | |
| 583 | + * What cache is in front of the site, and what we are telling it. | |
| 584 | + * | |
| 585 | + * Reported whether or not anything is currently being held back, because | |
| 586 | + * the useful half is the caveat rather than the header. A Cloudflare | |
| 587 | + * Cache Rule set to ignore origin headers overrides everything xSpeed | |
| 588 | + * sends, and someone debugging "my cart page is still being cached" | |
| 589 | + * needs telling that rather than left to discover it. | |
| 590 | + * | |
| 591 | + * Null when nothing was detected and nothing was switched off: there is | |
| 592 | + * no news in "we looked and saw nothing", and a row saying so on every | |
| 593 | + * ordinary single-server site would be noise in a panel people scan for | |
| 594 | + * problems. | |
| 595 | + * | |
| 596 | + * @return array{id:string,tone:string,label:string,detail:string}|null | |
| 597 | + */ | |
| 598 | + public static function edge_check(): ?array { | |
| 599 | + $edge = Edge_Provider::detect(); | |
| 600 | + | |
| 601 | + if ( Edge_Provider::is_off( $edge ) ) { | |
| 602 | + return array( | |
| 603 | + 'id' => 'edge_hold', | |
| 604 | + 'tone' => self::WARN, | |
| 605 | + 'label' => 'Edge cache not being told anything', | |
| 606 | + 'detail' => 'xSpeed is set not to send cache headers to the CDN in front of this site, so first renders and bypassed pages can be stored at the edge. Set "Cache In Front Of This Site" back to automatic unless you are sending your own headers.', | |
| 607 | + ); | |
| 608 | + } | |
| 609 | + | |
| 610 | + if ( Edge_Provider::NONE === $edge['confidence'] ) { | |
| 611 | + return null; | |
| 612 | + } | |
| 613 | + | |
| 614 | + $named = '' !== $edge['provider'] ? $edge['provider'] : 'a cache we could not identify'; | |
| 615 | + | |
| 616 | + // A pin outranks detection by design, so nothing re-checks it on the | |
| 617 | + // site's behalf — and it is the one answer that also reaches the | |
| 618 | + // drop-in and the server rules. Comparing it against the request is | |
| 619 | + // the only way a site that changed CDN ever finds out. | |
| 620 | + $sniffed = Edge_Provider::sniffed(); | |
| 621 | + if ( in_array( $edge['source'], array( 'setting', 'constant', 'filter' ), true ) | |
| 622 | + && '' !== $sniffed['provider'] | |
| 623 | + && $sniffed['provider'] !== $edge['provider'] ) { | |
| 624 | + return array( | |
| 625 | + 'id' => 'edge_hold', | |
| 626 | + 'tone' => self::WARN, | |
| 627 | + 'label' => 'Edge cache setting looks out of date', | |
| 628 | + 'detail' => sprintf( | |
| 629 | + 'This request looks like %s, but the provider is pinned to %s. If the site moved, update it — the pinned answer is also baked into the drop-in and the server rules.', | |
| 630 | + $sniffed['provider'], | |
| 631 | + $named | |
| 632 | + ), | |
| 633 | + ); | |
| 634 | + } | |
| 635 | + | |
| 636 | + $caveat = 'cloudflare' === $edge['provider'] | |
| 637 | + ? ' A Cloudflare Cache Rule whose Edge TTL is "Ignore cache-control header and use this TTL" overrides this; use "Respect origin TTL" on that rule.' | |
| 638 | + : ''; | |
| 639 | + | |
| 640 | + return array( | |
| 641 | + 'id' => 'edge_hold', | |
| 642 | + 'tone' => self::OK, | |
| 643 | + 'label' => 'Edge cache being told what not to store', | |
| 644 | + 'detail' => sprintf( 'First renders, bypassed pages and mobile-split pages are marked do-not-store for %s.%s', $named, $caveat ), | |
| 645 | + ); | |
| 646 | + } | |
| 647 | + | |
| 436 | 648 | public static function expiry_preload_check( int $expiry_hours, string $schedule, bool $preloader_enabled, ?int $interval_hours = null ): ?array { |
| 437 | 649 | if ( ! $preloader_enabled ) { |
| 438 | 650 | return null; |
| 439 | 651 | } |
| @@ -493,8 +705,19 @@ | ||
| 493 | 705 | 'writable' => self::wp_config_writable(), |
| 494 | 706 | ), |
| 495 | 707 | 'permalinks_ok' => (bool) get_option( 'permalink_structure' ), |
| 496 | 708 | 'conflicts' => Server::conflicts(), |
| 709 | + /* | |
| 710 | + * The reason the enable would be refused right now, or null. | |
| 711 | + * | |
| 712 | + * `conflicts` is a list of plugins, and the wizard used it to | |
| 713 | + * decide whether to open with page caching ticked. The two are | |
| 714 | + * not the same question: an orphaned or doubly-defined WP_CACHE | |
| 715 | + * refuses the enable with no plugin to name, so the wizard | |
| 716 | + * offered a pre-ticked switch it already knew would fail. This | |
| 717 | + * is the gate's own answer, so the box and the outcome agree. | |
| 718 | + */ | |
| 719 | + 'page_cache_blocked' => Cache::acquisition_blocker(), | |
| 497 | 720 | ); |
| 498 | 721 | } |
| 499 | 722 | |
| 500 | 723 | /** |