| @@ -20,8 +20,21 @@ | ||
| 20 | 20 | */ |
| 21 | 21 | private static $buffer_level = null; |
| 22 | 22 | |
| 23 | 23 | /** |
| 24 | + * Bytes freed by the current sweep, accumulated by sweep_delete(). | |
| 25 | + * | |
| 26 | + * A counter rather than a return value because the two sweeps that free | |
| 27 | + * the bytes — the flat glob loop and the recursive static walk — already | |
| 28 | + * report a FILE count, and `wp xspeed purge` needs both numbers from a | |
| 29 | + * single pass. Re-walking the tree to size it would double the I/O on | |
| 30 | + * exactly the caches large enough for the number to matter. | |
| 31 | + * | |
| 32 | + * @var int | |
| 33 | + */ | |
| 34 | + private static $sweep_bytes = 0; | |
| 35 | + | |
| 36 | + /** | |
| 24 | 37 | * The `X-XSpeed-Cache` value decided for this request, and — when the |
| 25 | 38 | * decision was BYPASS — the slug of the gate that made it. |
| 26 | 39 | * |
| 27 | 40 | * Recorded as well as sent so unit tests (CLI SAPI, where header() is a |
| @@ -32,8 +45,26 @@ | ||
| 32 | 45 | private static $status_header = ''; |
| 33 | 46 | private static $bypass_reason = ''; |
| 34 | 47 | |
| 35 | 48 | /** |
| 49 | + * Edge/CDN headers decided for this request, after sanitising. | |
| 50 | + * | |
| 51 | + * Same reason as $status_header: header() cannot be observed from the CLI | |
| 52 | + * SAPI, so the pairs we sent are recorded here too. | |
| 53 | + * | |
| 54 | + * @var array<string,string> | |
| 55 | + */ | |
| 56 | + private static $edge_headers = array(); | |
| 57 | + | |
| 58 | + /** | |
| 59 | + * This entry's edge headers when they differ from the site-wide bake, | |
| 60 | + * resolved once per store. Null until asked. | |
| 61 | + * | |
| 62 | + * @var array<string,string>|null | |
| 63 | + */ | |
| 64 | + private static $per_entry_edge = null; | |
| 65 | + | |
| 66 | + /** | |
| 36 | 67 | * Cache key whose write was deferred to shutdown because a render-time |
| 37 | 68 | * translation plugin's buffer wraps ours. Null on every ordinary request. |
| 38 | 69 | * |
| 39 | 70 | * @var string|null |
| @@ -76,9 +107,12 @@ | ||
| 76 | 107 | * running alongside its replacement and silently undo #243. |
| 77 | 108 | */ |
| 78 | 109 | private const TARGETED_INVALIDATION_HOOKS = array( |
| 79 | 110 | 'save_post', |
| 111 | + 'before_delete_post', | |
| 112 | + 'trashed_post', | |
| 80 | 113 | 'comment_post', |
| 114 | + 'wp_set_comment_status', | |
| 81 | 115 | 'user_register', |
| 82 | 116 | 'profile_update', |
| 83 | 117 | ); |
| 84 | 118 | |
| @@ -142,9 +176,9 @@ | ||
| 142 | 176 | // rendered author bylines / term-archive pages. Without them, an edit |
| 143 | 177 | // left the matching endpoint (and archives) stale for the full TTL. |
| 144 | 178 | // (FBS-82408) |
| 145 | 179 | $invalidate_hooks = array( |
| 146 | - 'save_post', 'deleted_post', 'trashed_post', | |
| 180 | + 'save_post', 'before_delete_post', 'trashed_post', | |
| 147 | 181 | 'comment_post', 'wp_set_comment_status', |
| 148 | 182 | 'switch_theme', 'activated_plugin', 'deactivated_plugin', |
| 149 | 183 | // Users → /wp/v2/users + author archives. |
| 150 | 184 | 'profile_update', 'user_register', 'deleted_user', |
| @@ -174,9 +208,13 @@ | ||
| 174 | 208 | } |
| 175 | 209 | add_action( |
| 176 | 210 | $hook, |
| 177 | 211 | static function () use ( $hook ): void { |
| 178 | - self::purge_all( 'hook:' . $hook ); | |
| 212 | + self::purge_all( | |
| 213 | + 'hook:' . $hook, | |
| 214 | + null, | |
| 215 | + self::invalidation_for_hook( $hook ) | |
| 216 | + ); | |
| 179 | 217 | } |
| 180 | 218 | ); |
| 181 | 219 | add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 182 | 220 | } |
| @@ -247,12 +285,21 @@ | ||
| 247 | 285 | // and generalises to Flamingo (#229) and Tutor LMS (#231) too. |
| 248 | 286 | remove_action( 'save_post', array( __CLASS__, 'purge_all' ) ); |
| 249 | 287 | remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 250 | 288 | add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 ); |
| 289 | + add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 290 | + add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 291 | + // wp_delete_post() hands an attachment to wp_delete_attachment() and | |
| 292 | + // returns BEFORE before_delete_post fires, so deleting media reached | |
| 293 | + // neither hook above. Attachment pages are public and media appears in | |
| 294 | + // galleries, so that left cached pages showing a file that is gone. | |
| 295 | + // (dev caught this via `deleted_post`, which this branch replaced.) | |
| 296 | + add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 251 | 297 | |
| 252 | 298 | remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) ); |
| 253 | 299 | remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 254 | 300 | add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 ); |
| 301 | + add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 ); | |
| 255 | 302 | |
| 256 | 303 | remove_action( 'user_register', array( __CLASS__, 'purge_all' ) ); |
| 257 | 304 | remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 258 | 305 | add_action( 'user_register', array( __CLASS__, 'on_user_change' ) ); |
| @@ -418,8 +465,17 @@ | ||
| 418 | 465 | private static function mark( string $value, string $reason = '' ): void { |
| 419 | 466 | self::$status_header = $value; |
| 420 | 467 | self::$bypass_reason = $reason; |
| 421 | 468 | |
| 469 | + // Every status, not just a HIT. A page we declined to cache is the | |
| 470 | + // one an edge most needs telling about: it goes out naked today, and | |
| 471 | + // a CDN that stores HTML by default keeps somebody's cart. | |
| 472 | + // | |
| 473 | + // Resolved before the headers_sent() guard so the decision is | |
| 474 | + // recorded (and observable in tests) even on a request that can no | |
| 475 | + // longer send headers; only the emission below is conditional. | |
| 476 | + self::$edge_headers = self::edge_headers_for( self::edge_status( $value ), 'request', $reason ); | |
| 477 | + | |
| 422 | 478 | if ( headers_sent() ) { |
| 423 | 479 | return; |
| 424 | 480 | } |
| 425 | 481 | header( 'X-XSpeed-Cache: ' . $value ); |
| @@ -425,10 +481,26 @@ | ||
| 425 | 481 | header( 'X-XSpeed-Cache: ' . $value ); |
| 426 | 482 | if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) { |
| 427 | 483 | header( 'X-XSpeed-Reason: ' . $reason ); |
| 428 | 484 | } |
| 485 | + foreach ( self::$edge_headers as $name => $val ) { | |
| 486 | + header( $name . ': ' . $val ); | |
| 487 | + } | |
| 429 | 488 | } |
| 430 | 489 | |
| 490 | + /** | |
| 491 | + * Normalize an `X-XSpeed-Cache` value to the vocabulary the edge seam | |
| 492 | + * speaks. | |
| 493 | + * | |
| 494 | + * The header value carries which layer served the page (`HIT (php)`, | |
| 495 | + * `HIT (nginx)`, `HIT (static)`); nothing deciding what to tell a CDN | |
| 496 | + * cares, and making a caller match on three spellings of one outcome is | |
| 497 | + * how a rule ends up applied on two paths out of three. | |
| 498 | + */ | |
| 499 | + private static function edge_status( string $value ): string { | |
| 500 | + return 0 === strpos( $value, 'HIT' ) ? 'HIT' : $value; | |
| 501 | + } | |
| 502 | + | |
| 431 | 503 | /** Record a bypass gate and answer "don't cache" in one statement. */ |
| 432 | 504 | private static function bypass( string $reason ): bool { |
| 433 | 505 | self::mark( 'BYPASS', $reason ); |
| 434 | 506 | return false; |
| @@ -444,8 +516,639 @@ | ||
| 444 | 516 | return self::$bypass_reason; |
| 445 | 517 | } |
| 446 | 518 | |
| 447 | 519 | /** |
| 520 | + * The edge/CDN pairs sent on this request ('' if none were). | |
| 521 | + * | |
| 522 | + * @return array<string,string> | |
| 523 | + */ | |
| 524 | + public static function edge_headers(): array { | |
| 525 | + return self::$edge_headers; | |
| 526 | + } | |
| 527 | + | |
| 528 | + /** | |
| 529 | + * Bypass gates that do NOT ask a cache in front of us to stand down. | |
| 530 | + * | |
| 531 | + * Every other slug does. The split is the reason this reads the gate | |
| 532 | + * rather than the status: a bypass usually means "this response is | |
| 533 | + * personal, or someone decided this page is never stored", and an edge | |
| 534 | + * holding one of those does precisely what we refused to do. These two | |
| 535 | + * mean something else. | |
| 536 | + * | |
| 537 | + * `cache-disabled` is the user switching OUR page cache off. Nothing | |
| 538 | + * about the page became personal. Sending `no-store` on every page of a | |
| 539 | + * site whose owner chose a different cache would make a local toggle a | |
| 540 | + * site-wide side effect on infrastructure we do not own. | |
| 541 | + * | |
| 542 | + * `non-frontend` is admin, REST, cron and AJAX. Not ours to describe: | |
| 543 | + * WordPress already nocaches admin, and a REST caller sets its own | |
| 544 | + * policy. | |
| 545 | + */ | |
| 546 | + private const HOLD_EXEMPT_BYPASS = array( 'cache-disabled', 'non-frontend' ); | |
| 547 | + | |
| 548 | + /** | |
| 549 | + * Bypass gates that describe the SHAPE of the request rather than the | |
| 550 | + * visitor or the page. | |
| 551 | + * | |
| 552 | + * These still hold, but only once we have evidence of an edge — the same | |
| 553 | + * bar a MISS has to clear. The difference matters because the default | |
| 554 | + * excluded-URL list contains `/feed/`, the sitemap and `/wp-json/`, and | |
| 555 | + * `query-param` catches `?lang=fr`, `?paged=2`, and every page of a | |
| 556 | + * plain-permalink site. | |
| 557 | + * | |
| 558 | + * xSpeed refuses those because IT cannot key on a query string, not | |
| 559 | + * because the response is private. A CDN keys on the full URL and caches | |
| 560 | + * them correctly. Holding them unconditionally would have meant every | |
| 561 | + * default install stopped its feed and sitemap being edge-cached — a | |
| 562 | + * performance regression shipped to sites that never had a CDN in the | |
| 563 | + * first place, in the name of protecting them from one. | |
| 564 | + * | |
| 565 | + * The gates left out of this list are about the visitor (`logged-in`, | |
| 566 | + * `excluded-cookie`) or are somebody stating outright that this page is | |
| 567 | + * never to be stored (`donotcachepage`, `post-excluded`, `filtered`). | |
| 568 | + * Those hold whether or not we can see an edge. | |
| 569 | + */ | |
| 570 | + private const REQUEST_SHAPE_BYPASS = array( 'query-param', 'non-get', 'user-agent' ); | |
| 571 | + | |
| 572 | + /** | |
| 573 | + * Default exclusions that are about the site's plumbing, not its content. | |
| 574 | + * | |
| 575 | + * `excluded-url` covers two unlike things. The default list carries | |
| 576 | + * `/cart`, `/checkout`, `/my-account` and `/wp-login` — personal pages, | |
| 577 | + * and the reason this feature exists. It also carries the entries below: | |
| 578 | + * feeds, sitemaps, the REST root, the front controller. Those are public, | |
| 579 | + * cacheable, and hammered by pollers; a CDN keys on the full URL and | |
| 580 | + * serves them correctly, so telling it to stop is a cost with no benefit. | |
| 581 | + * | |
| 582 | + * Matched as exact strings against the stored list, never as patterns | |
| 583 | + * against the path. Three bugs came out of doing it the other way round: | |
| 584 | + * `strpos( $uri, '/feed' )` matched `/my-account/feedback/`, reading the | |
| 585 | + * whole URI let `/cart/?utm_source=/feed/` disguise a cart as a feed, and | |
| 586 | + * a bare `index.php` — which is in this list, and which every URL contains | |
| 587 | + * on an "almost pretty" permalink site — made every page on such a site | |
| 588 | + * look personal. Comparing the LIST ENTRY rather than the path cannot make | |
| 589 | + * any of those mistakes, and it keeps a pattern the site owner added | |
| 590 | + * themselves on the personal side where it belongs. | |
| 591 | + */ | |
| 592 | + private const STRUCTURAL_EXCLUSIONS = array( | |
| 593 | + '/wp-json/', | |
| 594 | + '/xmlrpc.php', | |
| 595 | + '~wp-.*\.php', | |
| 596 | + '/feed/', | |
| 597 | + 'index.php', | |
| 598 | + '/robots.txt', | |
| 599 | + // Both spellings, and no entry here is ever retired. This is a | |
| 600 | + // RECOGNITION list, not a source of truth: it is matched against | |
| 601 | + // whatever the site has STORED, and a site that saved its settings | |
| 602 | + // before `~sitemap(_index)?\.xml` was widened to `sitemaps?` (for | |
| 603 | + // SEOPress, which ships sitemaps.xml) still has the old string in | |
| 604 | + // its option row. Dropping the old spelling when the default moved | |
| 605 | + // would read every upgraded site's sitemap exclusion as somebody's | |
| 606 | + // personal data and hold sitemaps off the CDN — the bug this whole | |
| 607 | + // predicate exists to prevent, reintroduced by a rename. | |
| 608 | + '~sitemaps?(_index)?\.xml', | |
| 609 | + '~sitemap(_index)?\.xml', | |
| 610 | + ); | |
| 611 | + /** | |
| 612 | + * Header names no edge instruction may ever carry. | |
| 613 | + * | |
| 614 | + * These describe the transfer, not the caching policy, and one wrong | |
| 615 | + * value from a settings field is a white screen rather than a missing | |
| 616 | + * optimization. | |
| 617 | + */ | |
| 618 | + private const NEVER_AN_EDGE_HEADER = array( | |
| 619 | + 'content-length', | |
| 620 | + 'content-encoding', | |
| 621 | + 'content-type', | |
| 622 | + 'transfer-encoding', | |
| 623 | + 'set-cookie', | |
| 624 | + 'location', | |
| 625 | + 'x-xspeed-cache', | |
| 626 | + 'x-xspeed-edge-hold', | |
| 627 | + ); | |
| 628 | + | |
| 629 | + /** | |
| 630 | + * Reasons that hold the edge off even when we detected nothing in front. | |
| 631 | + * | |
| 632 | + * `none` confidence means no evidence of a proxy, which is not proof | |
| 633 | + * there is none — a transparent proxy and a host page cache both leave | |
| 634 | + * the request untouched. So the question is what a wasted header costs | |
| 635 | + * against what a missed one does, and the answer differs by reason. | |
| 636 | + * | |
| 637 | + * These two are correctness failures. A cart page stored by something we | |
| 638 | + * could not see is the defect this exists to fix, and a mobile-split page | |
| 639 | + * served to the wrong device is a wrong page rather than a slow one. | |
| 640 | + * Ninety bytes on a response that was never cacheable is a cheap premium. | |
| 641 | + * | |
| 642 | + * `miss` and `pending` are performance hedges, and a hedge against a | |
| 643 | + * cache that does not exist is noise on every first render. Skipping them | |
| 644 | + * has a second benefit: because per_entry_edge_headers() compares `store` | |
| 645 | + * against `bake`, a `pending` hold that never fires leaves the two | |
| 646 | + * agreeing, which keeps the page on the static tree. | |
| 647 | + */ | |
| 648 | + private const HOLD_WITHOUT_EVIDENCE = array( 'bypass', 'mobile-split' ); | |
| 649 | + | |
| 650 | + /** | |
| 651 | + * Is a module still going to change this page after this response? | |
| 652 | + * | |
| 653 | + * Free itself never says yes — nothing in Free defers work past the | |
| 654 | + * request. Minification and combining write their file and return its URL | |
| 655 | + * inside the same render; the LCP preload is chosen by parsing the HTML | |
| 656 | + * being sent. It is the question that matters to anything caching in | |
| 657 | + * front of us, so Free asks it on their behalf and lets whoever owns the | |
| 658 | + * deferred work answer. | |
| 659 | + * | |
| 660 | + * Answer TRUE while the work is outstanding for the page being served. | |
| 661 | + * The cost of a false yes is one extra origin hit; the cost of a false no | |
| 662 | + * is an un-optimized page pinned at the edge for the full lifetime, which | |
| 663 | + * is the failure this exists to prevent — so when in doubt, say yes. | |
| 664 | + * | |
| 665 | + * Asked on a `request` only, and that boundary is the whole safety of it. | |
| 666 | + * | |
| 667 | + * A `bake` is generated once, in an admin or CLI request, and serves every | |
| 668 | + * static HIT on the site; a per-page answer frozen into it would be wrong | |
| 669 | + * for every other page. | |
| 670 | + * | |
| 671 | + * A `store` is worse, and cost a live site an afternoon. The pairs written | |
| 672 | + * at store time go into the `.meta` sidecar, which the drop-in replays on | |
| 673 | + * every later HIT — before plugins load, so nothing can re-ask this | |
| 674 | + * question. A hold written there therefore outlives the state that caused | |
| 675 | + * it, and the only thing that clears it is the page being stored again. On | |
| 676 | + * a site where the deferred work never completes, every re-store re-pins | |
| 677 | + * it, and the page is never edge-cacheable again. The symptom is a cache | |
| 678 | + * HIT carrying `no-store` and `X-XSpeed-Edge-Hold: pending` on a page | |
| 679 | + * whose deferred work finished long ago — the sidecar answering with | |
| 680 | + * state nothing can re-ask. | |
| 681 | + * | |
| 682 | + * Holding the MISS is what this is for, and it is enough: that response is | |
| 683 | + * the un-optimized one. The copy we then store is what an edge should | |
| 684 | + * mirror, and when the work does land the module purges the page, which | |
| 685 | + * reaches the edge. The purge is the correctness mechanism; this is only | |
| 686 | + * meant to cover the single render before it. | |
| 687 | + * | |
| 688 | + * @param string $context `request`, `store` or `bake`. | |
| 689 | + */ | |
| 690 | + public static function edge_optimization_pending( string $context = 'request' ): bool { | |
| 691 | + if ( 'request' !== $context ) { | |
| 692 | + return false; | |
| 693 | + } | |
| 694 | + | |
| 695 | + /** | |
| 696 | + * Filter: xspeed_edge_optimization_pending | |
| 697 | + * | |
| 698 | + * @param bool $pending Whether deferred work will still change this page. | |
| 699 | + */ | |
| 700 | + return (bool) apply_filters( 'xspeed_edge_optimization_pending', false ); | |
| 701 | + } | |
| 702 | + | |
| 703 | + /** | |
| 704 | + * Does mobile cache split this URL into two renders? | |
| 705 | + * | |
| 706 | + * With `mobile_separate` on, Free keys its cache on device and serves a | |
| 707 | + * different page to a phone than to a desktop at the SAME url. No CDN | |
| 708 | + * varies on User-Agent, so an edge holding one of those renders serves it | |
| 709 | + * to everyone: whichever device asked first decides what the other sees, | |
| 710 | + * for the whole lifetime. A wrong page, not a slow one. | |
| 711 | + * | |
| 712 | + * Read from the stored option rather than through Settings_Manager: this | |
| 713 | + * is consulted from the serve path, where the module registry may not | |
| 714 | + * have run. | |
| 715 | + */ | |
| 716 | + private static function mobile_cache_splits_html(): bool { | |
| 717 | + $stored = self::stored_cache_opts(); | |
| 718 | + return ! empty( $stored['mobile_separate'] ); | |
| 719 | + } | |
| 720 | + | |
| 721 | + /** | |
| 722 | + * Why, if at all, a cache in front of us should refuse to store this. | |
| 723 | + * | |
| 724 | + * @param string $status `HIT`, `MISS` or `BYPASS`. | |
| 725 | + * @param string $context `request`, `store` or `bake`. | |
| 726 | + * @param string $bypass_reason The gate slug, for BYPASS only. | |
| 727 | + * @return string '' or one of bypass|bypass-shape|miss|mobile-split|pending. | |
| 728 | + */ | |
| 729 | + private static function edge_hold_reason( string $status, string $context, string $bypass_reason ): string { | |
| 730 | + $reason = ''; | |
| 731 | + | |
| 732 | + // The two exempt gates are answered before anything else, or a site | |
| 733 | + // with Separate Mobile Cache on would keep holding after the page | |
| 734 | + // cache was switched off — which is exactly the "a local toggle must | |
| 735 | + // not become a site-wide side effect on infrastructure we do not own" | |
| 736 | + // rule below, defeated by the ordering rather than by the logic. | |
| 737 | + if ( 'BYPASS' === $status && in_array( $bypass_reason, self::HOLD_EXEMPT_BYPASS, true ) ) { | |
| 738 | + /** This filter is documented below. */ | |
| 739 | + return (string) apply_filters( 'xspeed_edge_hold_reason', '', $status, $context, $bypass_reason ); | |
| 740 | + } | |
| 741 | + | |
| 742 | + // First, because it is the only reason true in every context: the | |
| 743 | + // setting is a property of the site, not of one request, so it is the | |
| 744 | + // one thing a baked artifact can honestly assert. | |
| 745 | + // | |
| 746 | + // It is also the only reason that holds a HIT — a response we DID | |
| 747 | + // cache — and that is deliberate rather than an artefact of the | |
| 748 | + // ordering. With mobile_separate on we key the cache by device and | |
| 749 | + // serve different HTML to a phone than to a desktop at the same URL. | |
| 750 | + // No CDN varies on User-Agent, so an edge holding one of those | |
| 751 | + // renders serves it to everyone and whichever device asked first | |
| 752 | + // decides what the other sees. Our copy is fine; theirs would be a | |
| 753 | + // wrong page. The static path is switched off in this mode anyway | |
| 754 | + // (static_rewrite_allowed()), so these hits come from the drop-in, | |
| 755 | + // which carries the same baked answer. | |
| 756 | + if ( self::mobile_cache_splits_html() ) { | |
| 757 | + $reason = 'mobile-split'; | |
| 758 | + } elseif ( 'BYPASS' === $status ) { | |
| 759 | + $shaped = in_array( $bypass_reason, array( 'excluded-url', 'query-param' ), true ) | |
| 760 | + ? ! self::path_is_a_personal_exclusion( $bypass_reason ) | |
| 761 | + : in_array( $bypass_reason, self::REQUEST_SHAPE_BYPASS, true ); | |
| 762 | + $reason = $shaped ? 'bypass-shape' : 'bypass'; | |
| 763 | + } elseif ( self::edge_optimization_pending( $context ) ) { | |
| 764 | + $reason = 'pending'; | |
| 765 | + } elseif ( 'MISS' === $status ) { | |
| 766 | + $reason = 'miss'; | |
| 767 | + } | |
| 768 | + | |
| 769 | + /** | |
| 770 | + * Filter: xspeed_edge_hold_reason | |
| 771 | + * | |
| 772 | + * Return '' to veto a hold, or a reason string to force one. | |
| 773 | + * | |
| 774 | + * @param string $reason '' or bypass|bypass-shape|miss|mobile-split|pending. | |
| 775 | + * @param string $status `HIT`, `MISS` or `BYPASS`. | |
| 776 | + * @param string $context `request`, `store` or `bake`. | |
| 777 | + * @param string $bypass_reason The gate slug, for BYPASS only. | |
| 778 | + */ | |
| 779 | + return (string) apply_filters( 'xspeed_edge_hold_reason', $reason, $status, $context, $bypass_reason ); | |
| 780 | + } | |
| 781 | + | |
| 782 | + /** | |
| 783 | + * Was this page excluded because it is personal, or because it is | |
| 784 | + * plumbing we cannot key a cache entry on? | |
| 785 | + * | |
| 786 | + * Answers by removing the structural defaults from the site's own | |
| 787 | + * exclusion list and asking whether anything is left that matches. So a | |
| 788 | + * feed matches only `/feed/` and comes back false; `/my-account/feedback/` | |
| 789 | + * matches `/my-account` and comes back true; and on an "almost pretty" | |
| 790 | + * permalink site, where every path contains `index.php`, an ordinary page | |
| 791 | + * matches nothing else and is correctly treated as public. | |
| 792 | + * | |
| 793 | + * The path only, never the query string — a visitor writes that, and | |
| 794 | + * `/cart/?utm_source=/feed/` must not be able to talk a cart out of its | |
| 795 | + * hold. It is also what `should_cache()` matches the list against. | |
| 796 | + * | |
| 797 | + * Asked for a `query-param` bypass too, because the query gate runs | |
| 798 | + * BEFORE the URL gate, so `/cart/?add-to-cart=12` reports `query-param` | |
| 799 | + * and never reaches `excluded-url` at all. Which gate fired first says | |
| 800 | + * nothing about whose data is on the page. | |
| 801 | + */ | |
| 802 | + private static function path_is_a_personal_exclusion( string $bypass_reason ): bool { | |
| 803 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading the path of the request being served; there is no form here to nonce. | |
| 804 | + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; | |
| 805 | + $path = (string) strtok( $uri, '?' ); | |
| 806 | + if ( '' === $path ) { | |
| 807 | + return false; | |
| 808 | + } | |
| 809 | + | |
| 810 | + // Through Settings_Manager, not the raw option, because the schema's | |
| 811 | + // default IS the structural list and a fresh install has never | |
| 812 | + // written the option. Read raw, every site that has not visited the | |
| 813 | + // settings screen looks like a site with no exclusions at all, takes | |
| 814 | + // the contradiction branch below, and reports its feeds as personal. | |
| 815 | + // | |
| 816 | + // Safe here where `mobile_cache_splits_html()` is not: we are only | |
| 817 | + // ever called with a bypass reason, and those come from | |
| 818 | + // `should_cache()`, which resolved the same settings through | |
| 819 | + // `Settings_Manager::get()` to produce them. | |
| 820 | + $opts = Settings_Manager::get( 'cache' ); | |
| 821 | + $excluded = is_array( $opts['excluded_urls'] ?? null ) ? $opts['excluded_urls'] : array(); | |
| 822 | + if ( array() === $excluded ) { | |
| 823 | + // An `excluded-url` bypass with no exclusion list is a | |
| 824 | + // contradiction — something excluded the request and the list | |
| 825 | + // cannot say what — so assume personal, because a wasted header | |
| 826 | + // costs a little origin traffic while a missing one serves | |
| 827 | + // somebody's basket to a stranger. A `query-param` bypass with an | |
| 828 | + // empty list is just an ordinary page carrying a parameter, and | |
| 829 | + // says nothing about the path at all. | |
| 830 | + return 'excluded-url' === $bypass_reason; | |
| 831 | + } | |
| 832 | + | |
| 833 | + $personal = array_values( | |
| 834 | + array_filter( | |
| 835 | + $excluded, | |
| 836 | + static fn ( $pattern ) => ! in_array( (string) $pattern, self::STRUCTURAL_EXCLUSIONS, true ) | |
| 837 | + ) | |
| 838 | + ); | |
| 839 | + | |
| 840 | + return array() !== $personal && Glob_Matcher::any_match( $personal, $path ); | |
| 841 | + } | |
| 842 | + | |
| 843 | + /** | |
| 844 | + * The edge/CDN headers to send on a response with this cache status. | |
| 845 | + * | |
| 846 | + * @param string $status `HIT`, `MISS` or `BYPASS`. | |
| 847 | + * @param string $context `request` when resolved per request on the | |
| 848 | + * PHP serve path, `store` when resolved for | |
| 849 | + * one entry's sidecar, `bake` when resolved | |
| 850 | + * once and frozen into an artifact. | |
| 851 | + * @param string $bypass_reason The gate slug, for BYPASS only. | |
| 852 | + * @return array<string,string> | |
| 853 | + */ | |
| 854 | + public static function edge_headers_for( string $status, string $context = 'request', string $bypass_reason = '' ): array { | |
| 855 | + $base = array(); | |
| 856 | + if ( 'HIT' === $status ) { | |
| 857 | + /** | |
| 858 | + * Filter: xspeed_edge_cache_headers | |
| 859 | + * | |
| 860 | + * Response headers to add to a cached HTML response. A HIT-only | |
| 861 | + * contract: a lifetime is a promise that this copy is worth | |
| 862 | + * keeping, and neither a first render nor a page we refused to | |
| 863 | + * cache is one. | |
| 864 | + * | |
| 865 | + * The same filter feeds three regimes and `$context` says which. | |
| 866 | + * On the PHP serve path it runs per request (`request`); at store | |
| 867 | + * time it runs for one entry (`store`); when the drop-in or a | |
| 868 | + * server rule is generated it runs once (`bake`) and the result | |
| 869 | + * answers for every static HIT on the site. Anything per-page — a | |
| 870 | + * post id in a cache tag, say — must be skipped under `bake`. | |
| 871 | + * | |
| 872 | + * @param array<string,string> $headers Header name => value. | |
| 873 | + * @param string $status Always `HIT` here. | |
| 874 | + * @param string $context `request`, `store` or `bake`. | |
| 875 | + */ | |
| 876 | + $base = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'HIT', $context ) ); | |
| 877 | + } | |
| 878 | + | |
| 879 | + $reason = self::edge_hold_reason( $status, $context, $bypass_reason ); | |
| 880 | + if ( '' === $reason ) { | |
| 881 | + return $base; | |
| 882 | + } | |
| 883 | + $detected = Edge_Provider::detect( $context ); | |
| 884 | + if ( Edge_Provider::is_off( $detected ) ) { | |
| 885 | + return $base; | |
| 886 | + } | |
| 887 | + if ( Edge_Provider::NONE === $detected['confidence'] | |
| 888 | + && ! in_array( $reason, self::HOLD_WITHOUT_EVIDENCE, true ) ) { | |
| 889 | + return $base; | |
| 890 | + } | |
| 891 | + | |
| 892 | + $hold = Edge_Provider::hold_headers( $detected['provider'] ); | |
| 893 | + | |
| 894 | + /** | |
| 895 | + * Filter: xspeed_edge_hold_headers | |
| 896 | + * | |
| 897 | + * The last word on what a hold INSTRUCTS. Runs before sanitising, so | |
| 898 | + * a value that cannot be sent as a header is still dropped, and | |
| 899 | + * before `X-XSpeed-Edge-Hold` is added, so it cannot rewrite the | |
| 900 | + * reason xSpeed held the page for — that is a diagnosis, not an | |
| 901 | + * instruction, and a forged one sends a reader after the wrong | |
| 902 | + * module. | |
| 903 | + * | |
| 904 | + * @param array<string,string> $hold Header name => value. | |
| 905 | + * @param array<string,string> $detected Provider, confidence, source. | |
| 906 | + * @param string $reason Why the hold fired. | |
| 907 | + * @param string $context `request`, `store` or `bake`. | |
| 908 | + */ | |
| 909 | + $hold = (array) apply_filters( 'xspeed_edge_hold_headers', $hold, $detected, $reason, $context ); | |
| 910 | + | |
| 911 | + // A hold replaces the lifetime rather than sitting beside it: the two | |
| 912 | + // describe the same response and would contradict each other. The | |
| 913 | + // cache tag survives, because a later purge still has to be able to | |
| 914 | + // name whatever the edge picked up on its own terms. | |
| 915 | + if ( isset( $base['Cache-Tag'] ) ) { | |
| 916 | + $hold['Cache-Tag'] = $base['Cache-Tag']; | |
| 917 | + } | |
| 918 | + | |
| 919 | + // Never argue with a stronger answer WordPress already gave. It sends | |
| 920 | + // `no-store, private` of its own accord on a logged-in, 404 or | |
| 921 | + // password-protected response, from WP::send_headers() — which runs | |
| 922 | + // before template_redirect, so it is already on the wire by the time | |
| 923 | + // we get here. Ours is the weaker statement of the two; replacing it | |
| 924 | + // would be a downgrade dressed as a fix. Only meaningful per request: | |
| 925 | + // a bake has no response to inspect. | |
| 926 | + if ( 'request' === $context && isset( $hold['Cache-Control'] ) && self::cache_control_already_stronger() ) { | |
| 927 | + unset( $hold['Cache-Control'] ); | |
| 928 | + } | |
| 929 | + | |
| 930 | + // A page we refused to cache must not carry a validator either. A | |
| 931 | + // `Last-Modified` left on it invites a conditional request, and a | |
| 932 | + // shared cache that gets a 304 back serves the copy it should not | |
| 933 | + // have stored. Only on a bypass, and only per request: a MISS is | |
| 934 | + // about to be stored by us, so its validator is ours to keep. | |
| 935 | + if ( 'request' === $context && 'bypass' === $reason && ! headers_sent() ) { | |
| 936 | + header_remove( 'Last-Modified' ); | |
| 937 | + } | |
| 938 | + | |
| 939 | + $hold = self::sanitize_edge_headers( $hold ); | |
| 940 | + | |
| 941 | + // Name the reason in the hold set itself, rather than sending it | |
| 942 | + // separately from mark(). | |
| 943 | + // | |
| 944 | + // "Why is my page not being cached at the edge?" is the question this | |
| 945 | + // answers, and mark() could only answer it on the PHP serve path. The | |
| 946 | + // other emitters send whatever this function returns and never ran | |
| 947 | + // mark() at all — so the responses hardest to explain went out | |
| 948 | + // carrying `no-store` with nothing beside it to say why. Chiefly the | |
| 949 | + // drop-in, which serves from the `.meta` sidecar written under | |
| 950 | + // `store` and from the literal baked under `bake`, before plugins | |
| 951 | + // load and with no way to re-ask (the symptom | |
| 952 | + // edge_optimization_pending() describes above). | |
| 953 | + // | |
| 954 | + // The nginx and Apache blocks are a third path in principle and | |
| 955 | + // almost never in practice: they are only installed when | |
| 956 | + // static_rewrite_allowed() is true, and the one reason a stock site | |
| 957 | + // can hold under `bake` is `mobile-split`, which is exactly what | |
| 958 | + // makes that false. They will carry it where a site forces a hold | |
| 959 | + // through `xspeed_edge_hold_reason`, and otherwise have no hold to | |
| 960 | + // carry. | |
| 961 | + // | |
| 962 | + // Added AFTER sanitising and banned in NEVER_AN_EDGE_HEADER, so | |
| 963 | + // neither of the two filters above can forge a reason or suppress the | |
| 964 | + // real one. | |
| 965 | + // | |
| 966 | + // Reduced to the slug CHARACTER CLASS, not checked against the five | |
| 967 | + // slugs: `xspeed_edge_hold_reason` is documented as able to force a | |
| 968 | + // reason, and a site that forces its own deserves to see it. What is | |
| 969 | + // not negotiable is the shape, because this value reaches an | |
| 970 | + // .htaccess and an nginx conf as well as a response header — so no | |
| 971 | + // CR/LF, no `$`, no `%`, no `\`, and a length a config file can hold. | |
| 972 | + $slug = preg_replace( '/[^a-z0-9-]/', '', strtolower( $reason ) ); | |
| 973 | + if ( is_string( $slug ) && '' !== $slug ) { | |
| 974 | + $hold['X-XSpeed-Edge-Hold'] = substr( $slug, 0, 32 ); | |
| 975 | + } | |
| 976 | + | |
| 977 | + return $hold; | |
| 978 | + } | |
| 979 | + | |
| 980 | + /** Has something already sent a Cache-Control at least as strict as ours? */ | |
| 981 | + private static function cache_control_already_stronger(): bool { | |
| 982 | + foreach ( headers_list() as $line ) { | |
| 983 | + if ( 0 !== stripos( $line, 'cache-control:' ) ) { | |
| 984 | + continue; | |
| 985 | + } | |
| 986 | + if ( preg_match( '/\b(?:no-store|private)\b/i', $line ) ) { | |
| 987 | + return true; | |
| 988 | + } | |
| 989 | + } | |
| 990 | + | |
| 991 | + return false; | |
| 992 | + } | |
| 993 | + | |
| 994 | + /** | |
| 995 | + * Edge headers that belong to THIS page rather than to every page. | |
| 996 | + * | |
| 997 | + * `edge_headers_for('HIT','bake')` is the answer frozen into the drop-in | |
| 998 | + * and the server rules: one set, serving the whole site. But the answer | |
| 999 | + * for one URL can legitimately differ — a page whose deferred work is | |
| 1000 | + * still outstanding, say — and that answer has nowhere to live, because | |
| 1001 | + * the baked set is all the fast paths know about. | |
| 1002 | + * | |
| 1003 | + * So ask again in a `store` context, with the request still in scope, and | |
| 1004 | + * return the pairs only when they differ from the baked ones. Identical is | |
| 1005 | + * the overwhelmingly common case and writes nothing: pages do not pay a | |
| 1006 | + * sidecar for an answer the drop-in already has. | |
| 1007 | + * | |
| 1008 | + * Memoised because two callers ask within one store — the sidecar writer | |
| 1009 | + * and the static-tree guard — and the filters behind it are not required | |
| 1010 | + * to be cheap. | |
| 1011 | + * | |
| 1012 | + * @return array<string,string> Empty when this page needs no override. | |
| 1013 | + */ | |
| 1014 | + private static function per_entry_edge_headers(): array { | |
| 1015 | + if ( is_array( self::$per_entry_edge ) ) { | |
| 1016 | + return self::$per_entry_edge; | |
| 1017 | + } | |
| 1018 | + $baked = self::edge_headers_for( 'HIT', 'bake' ); | |
| 1019 | + $request = self::edge_headers_for( 'HIT', 'store' ); | |
| 1020 | + self::$per_entry_edge = ( $request === $baked ) ? array() : $request; | |
| 1021 | + | |
| 1022 | + return self::$per_entry_edge; | |
| 1023 | + } | |
| 1024 | + | |
| 1025 | + /** | |
| 1026 | + * Render baked pairs as a PHP array literal for the drop-in. | |
| 1027 | + * | |
| 1028 | + * Single-quoted literals with quotes escaped, because the result is | |
| 1029 | + * written into a PHP file that must still parse. Values reaching here | |
| 1030 | + * have already been through sanitize_edge_headers(), so neither name nor | |
| 1031 | + * value can carry a newline. | |
| 1032 | + * | |
| 1033 | + * @param array<string,string> $headers Name => value. | |
| 1034 | + */ | |
| 1035 | + private static function edge_headers_literal( array $headers ): string { | |
| 1036 | + if ( array() === $headers ) { | |
| 1037 | + return 'array()'; | |
| 1038 | + } | |
| 1039 | + // var_export(), not hand-rolled quoting. A single-quoted PHP string | |
| 1040 | + // escapes BOTH `'` and `\\`, and escaping only the first is how a | |
| 1041 | + // value ending in a backslash — `X-Foo: C:\path\` from the custom | |
| 1042 | + // headers box — leaves the literal unterminated. That file is | |
| 1043 | + // included on every request once WP_CACHE is on, so the result is a | |
| 1044 | + // parse error on the front end AND in wp-admin, with no way back | |
| 1045 | + // except deleting the file over SSH. | |
| 1046 | + $parts = array(); | |
| 1047 | + foreach ( $headers as $name => $value ) { | |
| 1048 | + $parts[] = var_export( (string) $name, true ) . ' => ' . var_export( (string) $value, true ); | |
| 1049 | + } | |
| 1050 | + | |
| 1051 | + return 'array( ' . implode( ', ', $parts ) . ' )'; | |
| 1052 | + } | |
| 1053 | + | |
| 1054 | + /** | |
| 1055 | + * Quote a header value for an nginx / Apache directive. | |
| 1056 | + * | |
| 1057 | + * Both accept a double-quoted string with backslash escapes, and both | |
| 1058 | + * refuse to load a config where the quoting is wrong — a mis-escaped | |
| 1059 | + * value takes the whole vhost down, not just this header. | |
| 1060 | + */ | |
| 1061 | + private static function quote_directive_value( string $value ): string { | |
| 1062 | + return str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value ); | |
| 1063 | + } | |
| 1064 | + | |
| 1065 | + /** | |
| 1066 | + * The same directive twice — once per name Apache can expose the | |
| 1067 | + * rewrite's environment variable under. | |
| 1068 | + * | |
| 1069 | + * `RewriteRule ... [E=XSPEED_STATIC_HIT:1]` in a per-directory context is | |
| 1070 | + * an INTERNAL REDIRECT: Apache re-enters the request with the substituted | |
| 1071 | + * path, and every variable set on the first pass is renamed with a | |
| 1072 | + * `REDIRECT_` prefix for the second. `env=XSPEED_STATIC_HIT` is evaluated | |
| 1073 | + * on that second pass, where nothing answers to that name any more, so | |
| 1074 | + * the directive never fires — dropping the headers from precisely the | |
| 1075 | + * responses they exist for. | |
| 1076 | + * | |
| 1077 | + * It cannot be written once: `env=` takes a single name with no | |
| 1078 | + * alternation, and `expr=` — which could express both — is not dependable | |
| 1079 | + * on LiteSpeed, which reads this same block. So both are emitted; the one | |
| 1080 | + * whose variable is unset on a given pass does nothing. | |
| 1081 | + * | |
| 1082 | + * @param string $directive The directive, without its `env=` clause. | |
| 1083 | + * @return string[] | |
| 1084 | + */ | |
| 1085 | + private static function static_hit_directives( string $directive ): array { | |
| 1086 | + return array( | |
| 1087 | + $directive . ' env=XSPEED_STATIC_HIT', | |
| 1088 | + $directive . ' env=REDIRECT_XSPEED_STATIC_HIT', | |
| 1089 | + ); | |
| 1090 | + } | |
| 1091 | + | |
| 1092 | + /** | |
| 1093 | + * Keep only pairs that can be sent as a header verbatim. | |
| 1094 | + * | |
| 1095 | + * These values reach three different emitters — PHP's header(), an nginx | |
| 1096 | + * `add_header` and an Apache `Header always set` — so a name with a space | |
| 1097 | + * or a value carrying CR/LF is not merely malformed, it is a | |
| 1098 | + * response-splitting vector in the first and a broken server config in | |
| 1099 | + * the other two. Names must be token-shaped; values lose CR/LF and are | |
| 1100 | + * dropped if nothing survives. | |
| 1101 | + * | |
| 1102 | + * @param array<mixed,mixed> $headers Raw pairs. | |
| 1103 | + * @return array<string,string> | |
| 1104 | + */ | |
| 1105 | + public static function sanitize_edge_headers( array $headers ): array { | |
| 1106 | + $clean = array(); | |
| 1107 | + foreach ( $headers as $name => $value ) { | |
| 1108 | + // Never let one of these through, whoever asked. They describe the | |
| 1109 | + // transfer rather than the caching policy, and getting one wrong | |
| 1110 | + // from a settings field is a white screen: `Content-Encoding: gzip` | |
| 1111 | + // on an uncompressed body, a `Content-Length` that disagrees with | |
| 1112 | + // the bytes. `X-XSpeed-Cache` is ours and a second copy would lie | |
| 1113 | + // to whoever reads it. | |
| 1114 | + if ( is_string( $name ) && in_array( strtolower( $name ), self::NEVER_AN_EDGE_HEADER, true ) ) { | |
| 1115 | + continue; | |
| 1116 | + } | |
| 1117 | + // `\z`, not `$`: PCRE's `$` also matches immediately BEFORE a | |
| 1118 | + // trailing newline, so "Cache-Tag\n" passes a `$` check and gets | |
| 1119 | + // concatenated raw into the generated .htaccess — splitting one | |
| 1120 | + // Header directive across two lines, which is a syntax error | |
| 1121 | + // Apache reports as a 500 on every request while `httpd -t` stays | |
| 1122 | + // green (.htaccess is parsed per request, not at load). | |
| 1123 | + if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9-]+\z/', $name ) ) { | |
| 1124 | + continue; | |
| 1125 | + } | |
| 1126 | + if ( ! is_string( $value ) && ! is_numeric( $value ) ) { | |
| 1127 | + continue; | |
| 1128 | + } | |
| 1129 | + $value = trim( str_replace( array( "\r", "\n" ), '', (string) $value ) ); | |
| 1130 | + if ( '' === $value ) { | |
| 1131 | + continue; | |
| 1132 | + } | |
| 1133 | + // `$` is a variable reference in an nginx string and `%` is a | |
| 1134 | + // format tag to Apache's mod_headers, which rejects an | |
| 1135 | + // unrecognised one — in .htaccess that is a 500 on every request | |
| 1136 | + // while `httpd -t` still reports OK, because .htaccess is parsed | |
| 1137 | + // per request. `\` escapes the quote in the PHP literal baked into | |
| 1138 | + // the drop-in. None of them can be escaped reliably in all three | |
| 1139 | + // places at once, and nothing a cache reads needs any of them, so | |
| 1140 | + // the value is dropped rather than mangled. | |
| 1141 | + if ( preg_match( '/[$%\\\\]/', $value ) ) { | |
| 1142 | + continue; | |
| 1143 | + } | |
| 1144 | + $clean[ $name ] = $value; | |
| 1145 | + } | |
| 1146 | + | |
| 1147 | + return $clean; | |
| 1148 | + } | |
| 1149 | + | |
| 1150 | + /** | |
| 448 | 1151 | * Bypass gates that describe THE VISITOR rather than THIS REQUEST. |
| 449 | 1152 | * |
| 450 | 1153 | * Only these may be recorded in the bypass cookie. A visitor-scoped |
| 451 | 1154 | * verdict stays true for the visitor's next request — they are still |
| @@ -752,9 +1455,16 @@ | ||
| 752 | 1455 | |
| 753 | 1456 | // Static tree too, under the same gates finalize_buffer() applies — |
| 754 | 1457 | // otherwise deferring the write would silently cost translated pages |
| 755 | 1458 | // the web-server fast path and leave them on the slower drop-in. |
| 756 | - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) { | |
| 1459 | + // The static tree cannot replay a sidecar. A file served straight by | |
| 1460 | + // the web server carries the headers baked into the rule that serves | |
| 1461 | + // the whole site — the very answer this entry exists because it | |
| 1462 | + // disagreed with. Same reasoning as the status and content-type | |
| 1463 | + // cases: what the fast path cannot replay belongs on the drop-in path. | |
| 1464 | + if ( self::static_rewrite_allowed() | |
| 1465 | + && self::response_is_plain_html() | |
| 1466 | + && array() === self::per_entry_edge_headers() ) { | |
| 757 | 1467 | self::store_static( $full ); |
| 758 | 1468 | } |
| 759 | 1469 | } |
| 760 | 1470 | |
| @@ -761,10 +1471,17 @@ | ||
| 761 | 1471 | public static function should_cache() { |
| 762 | 1472 | // Reset first: a single request only reaches this once (the sole |
| 763 | 1473 | // caller is maybe_start_cache()), but tests and any future caller |
| 764 | 1474 | // must never inherit the previous request's verdict. |
| 765 | - self::$status_header = ''; | |
| 766 | - self::$bypass_reason = ''; | |
| 1475 | + self::$status_header = ''; | |
| 1476 | + self::$bypass_reason = ''; | |
| 1477 | + self::$edge_headers = array(); | |
| 1478 | + self::$per_entry_edge = null; | |
| 1479 | + // Under PHP-FPM a process serves one request and this is moot. Under | |
| 1480 | + // a persistent worker runtime it is not: without it, an answer | |
| 1481 | + // resolved from one visitor's forgeable headers would be reused for | |
| 1482 | + // every later request the worker handles. | |
| 1483 | + Edge_Provider::forget(); | |
| 767 | 1484 | |
| 768 | 1485 | $opts = Settings::get(); |
| 769 | 1486 | if ( empty( $opts['cache_enabled'] ) ) { |
| 770 | 1487 | return self::bypass( 'cache-disabled' ); |
| @@ -815,8 +1532,20 @@ | ||
| 815 | 1532 | * @param bool $cache_feed Whether to cache this feed request. |
| 816 | 1533 | */ |
| 817 | 1534 | $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false ); |
| 818 | 1535 | |
| 1536 | + // WordPress's virtual robots.txt (and virtual favicon) are not HTML: | |
| 1537 | + // caching one runs it through the whole HTML pipeline, which stamped | |
| 1538 | + // the footer comment onto text/plain and let HTML minification | |
| 1539 | + // collapse robots.txt to a single line — a line-based format, so | |
| 1540 | + // every directive after the first was lost and crawlers read an | |
| 1541 | + // invalid file. No opt-in filter here: there is no correct way to | |
| 1542 | + // treat these as pages. (Reported live on a customer site.) | |
| 1543 | + if ( ( function_exists( 'is_robots' ) && is_robots() ) | |
| 1544 | + || ( function_exists( 'is_favicon' ) && is_favicon() ) ) { | |
| 1545 | + return self::bypass( 'non-html' ); | |
| 1546 | + } | |
| 1547 | + | |
| 819 | 1548 | // Query string handling: anything OUTSIDE the ignored-params |
| 820 | 1549 | // allow-list (utm_*, fbclid, gclid by default) means a unique |
| 821 | 1550 | // request that we don't want to share with the canonical cache |
| 822 | 1551 | // entry. Skip cache rather than poison the key. |
| @@ -1381,9 +2110,10 @@ | ||
| 1381 | 2110 | * exactly this blog's pages. |
| 1382 | 2111 | */ |
| 1383 | 2112 | public static function current_static_scope(): string { |
| 1384 | 2113 | // Same switch_to_blog() caveat as current_host_dir() — see current_host(). |
| 1385 | - $dir = self::host_dir( self::current_host() ); | |
| 2114 | + // Keep the port folded into the segment exactly as store_static() does. | |
| 2115 | + $dir = self::static_host_dir( self::current_host() ); | |
| 1386 | 2116 | if ( '' === $dir ) { |
| 1387 | 2117 | $dir = 'default'; |
| 1388 | 2118 | } |
| 1389 | 2119 | $path = self::site_path_raw(); |
| @@ -2033,9 +2763,16 @@ | ||
| 2033 | 2763 | // 200, FBS-82406) or a non-HTML content-type (a cached feed would go |
| 2034 | 2764 | // out as text/html, FBS-82407). The web server serves these .html files |
| 2035 | 2765 | // directly with no PHP, so there's no .meta replay — keep them on the |
| 2036 | 2766 | // drop-in / PHP path instead, which DOES replay status + content-type. |
| 2037 | - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) { | |
| 2767 | + // The static tree cannot replay a sidecar. A file served straight by | |
| 2768 | + // the web server carries the headers baked into the rule that serves | |
| 2769 | + // the whole site — the very answer this entry exists because it | |
| 2770 | + // disagreed with. Same reasoning as the status and content-type | |
| 2771 | + // cases: what the fast path cannot replay belongs on the drop-in path. | |
| 2772 | + if ( self::static_rewrite_allowed() | |
| 2773 | + && self::response_is_plain_html() | |
| 2774 | + && array() === self::per_entry_edge_headers() ) { | |
| 2038 | 2775 | self::store_static( $full ); |
| 2039 | 2776 | } |
| 2040 | 2777 | |
| 2041 | 2778 | return $buffer; |
| @@ -2566,8 +3303,19 @@ | ||
| 2566 | 3303 | if ( $ttl > 0 && $ttl !== $default_ttl ) { |
| 2567 | 3304 | $meta['ttl'] = $ttl; |
| 2568 | 3305 | } |
| 2569 | 3306 | |
| 3307 | + // This entry's edge headers, when they differ from the site-wide set | |
| 3308 | + // baked into the drop-in. The sidecar is the only channel that can | |
| 3309 | + // carry a per-page answer into the pre-boot fast path, and the drop-in | |
| 3310 | + // REPLACES the baked set with it rather than merging: the two describe | |
| 3311 | + // the same response, so merging would leave the baked lifetime in | |
| 3312 | + // place beside the hold meant to overrule it. | |
| 3313 | + $edge = self::per_entry_edge_headers(); | |
| 3314 | + if ( array() !== $edge ) { | |
| 3315 | + $meta['edge_headers'] = $edge; | |
| 3316 | + } | |
| 3317 | + | |
| 2570 | 3318 | // Nothing to replay → no sidecar. |
| 2571 | 3319 | if ( empty( $meta ) ) { |
| 2572 | 3320 | return; |
| 2573 | 3321 | } |
| @@ -2611,8 +3359,101 @@ | ||
| 2611 | 3359 | * TTL — up to 30 days at the maximum lifetime. (#270 regression) |
| 2612 | 3360 | * |
| 2613 | 3361 | * @return string[] |
| 2614 | 3362 | */ |
| 3363 | + /** | |
| 3364 | + * Could this post change alter anything an anonymous visitor had cached? | |
| 3365 | + * | |
| 3366 | + * Deleting one post fired a full purge for the post AND for every stored | |
| 3367 | + * revision, because wp_delete_post() removes each revision through | |
| 3368 | + * wp_delete_post() again and every one of those fires before_delete_post | |
| 3369 | + * with post_type 'revision'. A post with six revisions cost seven whole- | |
| 3370 | + * site sweeps, each one also announcing to LiteSpeed, purging the object | |
| 3371 | + * cache network-wide on Redis, rewriting the stats option and running | |
| 3372 | + * every xspeed_after_purge_all listener -- including Pro's Cloudflare | |
| 3373 | + * purge, so seven API calls. Trashing cost two, via save_post and then | |
| 3374 | + * trashed_post. (QA #348) | |
| 3375 | + * | |
| 3376 | + * The check lives here, ahead of purge_all(), so one early return covers | |
| 3377 | + * the local sweep, the server-cache announcement and both action hooks. | |
| 3378 | + * It deliberately does NOT live inside purge_all(): a manual, CLI or | |
| 3379 | + * explicit caller asked for a purge and must get one. | |
| 3380 | + * | |
| 3381 | + * @param int $post_id Post being saved or removed. | |
| 3382 | + * @param mixed $post Post object when the hook passed one. | |
| 3383 | + * @param string $event 'save' or 'remove'. | |
| 3384 | + */ | |
| 3385 | + private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool { | |
| 3386 | + $post_id = (int) $post_id; | |
| 3387 | + | |
| 3388 | + // Only `save_post` and `before_delete_post` hand over a post object. | |
| 3389 | + // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING -- | |
| 3390 | + // so reaching for ->post_status on the second argument finds nothing | |
| 3391 | + // and the status rule below would never fire. Read the row instead. | |
| 3392 | + if ( ! is_object( $post ) && function_exists( 'get_post' ) ) { | |
| 3393 | + $post = get_post( $post_id ); | |
| 3394 | + } | |
| 3395 | + | |
| 3396 | + $type = is_object( $post ) && isset( $post->post_type ) | |
| 3397 | + ? (string) $post->post_type | |
| 3398 | + : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' ); | |
| 3399 | + if ( '' === $type ) { | |
| 3400 | + return false; | |
| 3401 | + } | |
| 3402 | + | |
| 3403 | + // A revision is a copy of content nobody can browse to. | |
| 3404 | + if ( 'revision' === $type ) { | |
| 3405 | + return false; | |
| 3406 | + } | |
| 3407 | + if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) { | |
| 3408 | + return false; | |
| 3409 | + } | |
| 3410 | + if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) { | |
| 3411 | + return false; | |
| 3412 | + } | |
| 3413 | + | |
| 3414 | + $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : ''; | |
| 3415 | + | |
| 3416 | + // Clicking "Add New" inserts an auto-draft and fires save_post. There | |
| 3417 | + // is nothing cached of a post that has never existed publicly. | |
| 3418 | + if ( 'auto-draft' === $status ) { | |
| 3419 | + return false; | |
| 3420 | + } | |
| 3421 | + | |
| 3422 | + // Unknown/!viewable → nothing anonymous can see changed, UNLESS the | |
| 3423 | + // type is itself part of how pages render (#270 regression). | |
| 3424 | + if ( function_exists( 'is_post_type_viewable' ) | |
| 3425 | + && ! is_post_type_viewable( $type ) | |
| 3426 | + && ! in_array( $type, self::presentation_post_types(), true ) | |
| 3427 | + ) { | |
| 3428 | + return false; | |
| 3429 | + } | |
| 3430 | + | |
| 3431 | + // Deleting something that was already invisible changes no cached | |
| 3432 | + // page: the transition that hid it purged at the time. This is what | |
| 3433 | + // makes emptying a trash of a hundred posts cost nothing rather than | |
| 3434 | + // a hundred full sweeps. | |
| 3435 | + // | |
| 3436 | + // It also collapses trashing to a single purge: wp_trash_post() fires | |
| 3437 | + // save_post first, where the post is genuinely disappearing from | |
| 3438 | + // listings and SHOULD purge, then trashed_post, by which point the | |
| 3439 | + // row reads 'trash' and is skipped. A status we cannot read, on a row | |
| 3440 | + // that still reports a type, means assume viewable -- erring toward | |
| 3441 | + // an extra purge, never toward serving a stale page. A row that is | |
| 3442 | + // gone entirely reports no type either and was refused above. | |
| 3443 | + // 'inherit' is an INTERNAL status in core, so is_post_status_viewable() | |
| 3444 | + // says no -- but an attachment carrying it is genuinely public. Judge | |
| 3445 | + // those on the post type alone, which is already checked above. | |
| 3446 | + if ( 'remove' === $event && '' !== $status && 'inherit' !== $status | |
| 3447 | + && function_exists( 'is_post_status_viewable' ) | |
| 3448 | + && ! is_post_status_viewable( $status ) | |
| 3449 | + ) { | |
| 3450 | + return false; | |
| 3451 | + } | |
| 3452 | + | |
| 3453 | + return true; | |
| 3454 | + } | |
| 3455 | + | |
| 2615 | 3456 | public static function presentation_post_types(): array { |
| 2616 | 3457 | $types = array( |
| 2617 | 3458 | 'wp_template', // Site Editor templates. |
| 2618 | 3459 | 'wp_template_part', // Header / footer / reusable parts. |
| @@ -2633,8 +3474,44 @@ | ||
| 2633 | 3474 | return (array) apply_filters( 'xspeed_presentation_post_types', $types ); |
| 2634 | 3475 | } |
| 2635 | 3476 | |
| 2636 | 3477 | /** |
| 3478 | + * Describe a broad hook invalidation for response-cache adapters. | |
| 3479 | + * | |
| 3480 | + * Term, menu, theme and plugin changes can alter navigation, archives or | |
| 3481 | + * markup across the site, so they require a site response-cache purge. | |
| 3482 | + * Content saves also require this scope while their local operation is a | |
| 3483 | + * complete bucket sweep. | |
| 3484 | + * | |
| 3485 | + * A new term is `content`, not `presentation`. It has no posts yet, so no | |
| 3486 | + * page renders it until a post is saved with it, and that save is its own | |
| 3487 | + * content purge. Classed as presentation, it cleared the host's whole | |
| 3488 | + * nginx cache every time a post was published with a tag that did not | |
| 3489 | + * exist yet, which is most publishing. Renaming or deleting a term stays | |
| 3490 | + * presentation: the new name shows on every post in the term, and Nginx | |
| 3491 | + * Helper purges only the homepage for either. (QA #448) | |
| 3492 | + * | |
| 3493 | + * @return array{scope:string,intent:string,urls:array<int,string>} | |
| 3494 | + */ | |
| 3495 | + private static function invalidation_for_hook( string $hook ): array { | |
| 3496 | + $presentation = array( | |
| 3497 | + 'switch_theme', | |
| 3498 | + 'activated_plugin', | |
| 3499 | + 'deactivated_plugin', | |
| 3500 | + 'edited_term', | |
| 3501 | + 'delete_term', | |
| 3502 | + 'wp_update_nav_menu', | |
| 3503 | + ); | |
| 3504 | + | |
| 3505 | + return array( | |
| 3506 | + 'scope' => 'site', | |
| 3507 | + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content', | |
| 3508 | + 'urls' => array(), | |
| 3509 | + ); | |
| 3510 | + } | |
| 3511 | + | |
| 3512 | + | |
| 3513 | + /** | |
| 2637 | 3514 | * save_post → purge only when the saved thing can appear on a cached page. |
| 2638 | 3515 | * |
| 2639 | 3516 | * Revisions and autosaves are never rendered. Non-viewable post types — |
| 2640 | 3517 | * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund` |
| @@ -2649,35 +3526,32 @@ | ||
| 2649 | 3526 | * @param int $post_id Saved post ID. |
| 2650 | 3527 | * @param \WP_Post $post Saved post object. |
| 2651 | 3528 | */ |
| 2652 | 3529 | public static function on_save_post( $post_id, $post = null ): void { |
| 2653 | - if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) { | |
| 3530 | + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) { | |
| 2654 | 3531 | return; |
| 2655 | 3532 | } |
| 2656 | - if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) { | |
| 2657 | - return; | |
| 2658 | - } | |
| 2659 | 3533 | |
| 2660 | 3534 | $post_type = is_object( $post ) && isset( $post->post_type ) |
| 2661 | 3535 | ? (string) $post->post_type |
| 2662 | 3536 | : (string) get_post_type( $post_id ); |
| 2663 | - if ( '' === $post_type ) { | |
| 2664 | - return; | |
| 2665 | - } | |
| 2666 | 3537 | |
| 2667 | - // Unknown/!viewable → nothing anonymous can see changed, UNLESS the | |
| 2668 | - // type is itself part of how pages render (#270 regression). | |
| 2669 | - if ( function_exists( 'is_post_type_viewable' ) | |
| 2670 | - && ! is_post_type_viewable( $post_type ) | |
| 2671 | - && ! in_array( $post_type, self::presentation_post_types(), true ) | |
| 2672 | - ) { | |
| 2673 | - return; | |
| 2674 | - } | |
| 2675 | - | |
| 2676 | 3538 | // Name the trigger rather than logging a bare numeric id — the old |
| 2677 | 3539 | // wiring passed the post ID into $cause, so the log read |
| 2678 | 3540 | // "Cache purged (46)" with no indication of what caused it. (#243) |
| 2679 | - self::purge_all( 'post:' . $post_type ); | |
| 3541 | + $presentation = in_array( $post_type, self::presentation_post_types(), true ); | |
| 3542 | + self::purge_all( | |
| 3543 | + 'post:' . $post_type, | |
| 3544 | + null, | |
| 3545 | + array( | |
| 3546 | + // purge_all() sweeps every local response in this site's bucket. | |
| 3547 | + // Without dependency tracking, the server cache must match that | |
| 3548 | + // same boundary or unrelated pages can remain stale there. | |
| 3549 | + 'scope' => 'site', | |
| 3550 | + 'intent' => $presentation ? 'presentation' : 'content', | |
| 3551 | + 'urls' => array(), | |
| 3552 | + ) | |
| 3553 | + ); | |
| 2680 | 3554 | if ( class_exists( '\XSpeed\Minifier' ) ) { |
| 2681 | 3555 | Minifier::purge_minified(); |
| 2682 | 3556 | } |
| 2683 | 3557 | } |
| @@ -2682,8 +3556,61 @@ | ||
| 2682 | 3556 | } |
| 2683 | 3557 | } |
| 2684 | 3558 | |
| 2685 | 3559 | /** |
| 3560 | + * Delete/trash invalidation while the post type is still available. | |
| 3561 | + * The local and server response-cache sweeps share the same site boundary. | |
| 3562 | + * | |
| 3563 | + * @param int $post_id Removed post ID. | |
| 3564 | + * @param object|null $post Post object supplied by core when available. | |
| 3565 | + */ | |
| 3566 | + public static function on_post_removed( $post_id, $post = null ): void { | |
| 3567 | + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) { | |
| 3568 | + return; | |
| 3569 | + } | |
| 3570 | + | |
| 3571 | + $post_type = is_object( $post ) && isset( $post->post_type ) | |
| 3572 | + ? (string) $post->post_type | |
| 3573 | + : (string) get_post_type( $post_id ); | |
| 3574 | + | |
| 3575 | + self::purge_all( | |
| 3576 | + 'post-removed:' . $post_type, | |
| 3577 | + null, | |
| 3578 | + array( | |
| 3579 | + 'scope' => 'site', | |
| 3580 | + // Match on_save_post: a presentation type changes how pages | |
| 3581 | + // render rather than what they say. | |
| 3582 | + 'intent' => in_array( $post_type, self::presentation_post_types(), true ) | |
| 3583 | + ? 'presentation' | |
| 3584 | + : 'content', | |
| 3585 | + 'urls' => array(), | |
| 3586 | + ) | |
| 3587 | + ); | |
| 3588 | + } | |
| 3589 | + | |
| 3590 | + /** Purge site responses when moderation changes visible comments. */ | |
| 3591 | + public static function on_comment_status( $comment_id, $status = '' ): void { | |
| 3592 | + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null; | |
| 3593 | + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0; | |
| 3594 | + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) { | |
| 3595 | + return; | |
| 3596 | + } | |
| 3597 | + $url = get_permalink( $post_id ); | |
| 3598 | + if ( ! is_string( $url ) || '' === $url ) { | |
| 3599 | + return; | |
| 3600 | + } | |
| 3601 | + self::purge_all( | |
| 3602 | + 'comment-status:' . (string) $status, | |
| 3603 | + null, | |
| 3604 | + array( | |
| 3605 | + 'scope' => 'site', | |
| 3606 | + 'intent' => 'content', | |
| 3607 | + 'urls' => array(), | |
| 3608 | + ) | |
| 3609 | + ); | |
| 3610 | + } | |
| 3611 | + | |
| 3612 | + /** | |
| 2686 | 3613 | * comment_post → purge just the commented-on URL, and only once the |
| 2687 | 3614 | * comment is actually visible. |
| 2688 | 3615 | * |
| 2689 | 3616 | * A comment held for moderation changes nothing on the front end, and an |
| @@ -2834,13 +3761,304 @@ | ||
| 2834 | 3761 | public static function purge_product_object( $product ): void { |
| 2835 | 3762 | self::purge_product( $product ); |
| 2836 | 3763 | } |
| 2837 | 3764 | |
| 3765 | + /** | |
| 3766 | + * Re-entry guard for the purge-event contract. | |
| 3767 | + * | |
| 3768 | + * A listener on `xspeed_after_purge_url` legitimately purges its own | |
| 3769 | + * layer, and a server-cache or CDN adapter that calls back into xSpeed | |
| 3770 | + * while doing so re-enters this method — unbounded, because each pass | |
| 3771 | + * looks like a fresh purge. | |
| 3772 | + * | |
| 3773 | + * A single global flag stops too much: a nested purge of a DIFFERENT URL is | |
| 3774 | + * a real purge whose listeners must hear about it. But a per-request | |
| 3775 | + * "already published" set stops too much in the other direction — a | |
| 3776 | + * network purge loops every blog in one request, and on a subdirectory | |
| 3777 | + * network they share a host, so blogs 2..N would be silently skipped. It | |
| 3778 | + * also grows for the life of the process. | |
| 3779 | + * | |
| 3780 | + * So the guard tracks what is IN FLIGHT, not what has been published: a | |
| 3781 | + * target is marked while its own dispatch is on the stack and unmarked | |
| 3782 | + * when it returns. Re-entering the same target recurses, so it is refused; | |
| 3783 | + * purging the same URL again later is a new event and publishes. The set | |
| 3784 | + * is bounded by call depth rather than by how many URLs a request touches. | |
| 3785 | + * | |
| 3786 | + * @var array<string,bool> | |
| 3787 | + */ | |
| 3788 | + private static $purge_events_in_flight = array(); | |
| 3789 | + | |
| 3790 | + /** Monotonic count used to detect whether a delegated purge published. */ | |
| 3791 | + private static $purge_event_sequence = 0; | |
| 3792 | + | |
| 3793 | + /** | |
| 3794 | + * Publish a purge event exactly once, with bounded arguments. | |
| 3795 | + * | |
| 3796 | + * Deliberately carries only what an integration needs to invalidate its | |
| 3797 | + * own copy: the canonical URL (or null for a full purge), the site host, | |
| 3798 | + * the cause label, and how many files went. No filesystem paths, no cache | |
| 3799 | + * contents, no request headers, no user data. The URL query and caller- | |
| 3800 | + * supplied cause may nevertheless contain sensitive text, so listeners | |
| 3801 | + * must redact them in logs or unrelated destinations that do not need the | |
| 3802 | + * exact cache key. | |
| 3803 | + * | |
| 3804 | + * A listener that throws must not take the purge down with it: the files | |
| 3805 | + * are already gone by the time we get here, and an integration's bad day | |
| 3806 | + * is not a reason to report a failed purge to the caller. | |
| 3807 | + * | |
| 3808 | + * @param string $hook Hook name to emit. | |
| 3809 | + * @param array<string,mixed> $context Bounded context, see above. | |
| 3810 | + */ | |
| 3811 | + private static function dispatch_purge_event( string $hook, array $context ): void { | |
| 3812 | + if ( ! function_exists( 'do_action' ) ) { | |
| 3813 | + return; | |
| 3814 | + } | |
| 3815 | + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' ) | |
| 3816 | + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' ); | |
| 3817 | + if ( isset( self::$purge_events_in_flight[ $target ] ) ) { | |
| 3818 | + return; | |
| 3819 | + } | |
| 3820 | + self::$purge_events_in_flight[ $target ] = true; | |
| 3821 | + ++self::$purge_event_sequence; | |
| 3822 | + | |
| 3823 | + // Our own integrations get their own try. Sharing one with the public | |
| 3824 | + // action below meant a listener on the extension seam could throw and | |
| 3825 | + // take the contract event down with it — the mirror of the failure | |
| 3826 | + // this separation exists to prevent. | |
| 3827 | + try { | |
| 3828 | + // Built-in server-cache integrations run FIRST, and by a direct | |
| 3829 | + // call rather than as listeners on the action below. | |
| 3830 | + // | |
| 3831 | + // WordPress stops dispatching an action's remaining callbacks when | |
| 3832 | + // one of them throws. As a listener, our LiteSpeed forwarding | |
| 3833 | + // would then be skipped by any unrelated third-party callback that | |
| 3834 | + // happened to be registered earlier and blew up — and the visible | |
| 3835 | + // result is the worst kind: xSpeed reports a successful purge while | |
| 3836 | + // the server keeps serving stale HTML. Shipped behaviour must not | |
| 3837 | + // be hostage to a listener's bug. | |
| 3838 | + self::forward_to_server_caches( $context ); | |
| 3839 | + } catch ( \Throwable $e ) { | |
| 3840 | + self::log_purge_listener_error( $hook, $e ); | |
| 3841 | + } | |
| 3842 | + | |
| 3843 | + try { | |
| 3844 | + self::do_action_isolated( $hook, $context ); | |
| 3845 | + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch | |
| 3846 | + // Swallow: see docblock. The purge succeeded regardless. | |
| 3847 | + self::log_purge_listener_error( $hook, $e ); | |
| 3848 | + } finally { | |
| 3849 | + unset( self::$purge_events_in_flight[ $target ] ); | |
| 3850 | + } | |
| 3851 | + } | |
| 3852 | + | |
| 3853 | + /** | |
| 3854 | + * Run every listener on a purge hook, isolating each from the others. | |
| 3855 | + * | |
| 3856 | + * `do_action()` dispatches callbacks in one loop, so the first one to | |
| 3857 | + * throw takes every LATER listener down with it. On a purge that meant a | |
| 3858 | + * failing CDN integration silently cancelled the ones queued behind it — | |
| 3859 | + * and because the throw was swallowed to keep the purge itself succeeding, | |
| 3860 | + * the user was told the clear worked while two edges were never touched. | |
| 3861 | + * Invisible unless WP_DEBUG happened to be on. (QA #348) | |
| 3862 | + * | |
| 3863 | + * Each callback gets its own try/catch here, so one integration's bad day | |
| 3864 | + * costs only that integration. Priority order is preserved. Falls back to | |
| 3865 | + * a plain `do_action()` when the filter registry is not the shape we | |
| 3866 | + * expect, so an unusual environment degrades to the old behaviour rather | |
| 3867 | + * than skipping listeners entirely. | |
| 3868 | + * | |
| 3869 | + * @param string $hook Hook name to emit. | |
| 3870 | + * @param mixed $arg Single argument passed to each listener. | |
| 3871 | + */ | |
| 3872 | + public static function do_action_isolated( string $hook, $arg ): void { | |
| 3873 | + global $wp_filter; | |
| 3874 | + | |
| 3875 | + // Walking $wp_filter by hand and calling each callback directly was the | |
| 3876 | + // obvious way to do this, and it was wrong: it bypasses WordPress, so | |
| 3877 | + // `current_filter()` came back empty, `did_action()` stayed at 0, the | |
| 3878 | + // `all` hook never fired, and Query Monitor and Debug Bar could not see | |
| 3879 | + // the very contract this class publishes. A shared handler branching on | |
| 3880 | + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3) | |
| 3881 | + // | |
| 3882 | + // So let do_action() dispatch — WordPress keeps its bookkeeping — and | |
| 3883 | + // isolate one level down instead: each registered callback is swapped | |
| 3884 | + // for a wrapper that runs it inside a try/catch. One listener throwing | |
| 3885 | + // then costs only that listener, which is the whole point, without | |
| 3886 | + // costing the hook its identity. | |
| 3887 | + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) { | |
| 3888 | + do_action( $hook, $arg ); | |
| 3889 | + return; | |
| 3890 | + } | |
| 3891 | + | |
| 3892 | + $hook_object = $wp_filter[ $hook ]; | |
| 3893 | + $original = $hook_object->callbacks; | |
| 3894 | + if ( ! is_array( $original ) || array() === $original ) { | |
| 3895 | + do_action( $hook, $arg ); | |
| 3896 | + return; | |
| 3897 | + } | |
| 3898 | + | |
| 3899 | + $wrapped = array(); | |
| 3900 | + $restorations = array(); | |
| 3901 | + foreach ( $original as $priority => $group ) { | |
| 3902 | + if ( ! is_array( $group ) ) { | |
| 3903 | + $wrapped[ $priority ] = $group; | |
| 3904 | + continue; | |
| 3905 | + } | |
| 3906 | + foreach ( $group as $id => $registered ) { | |
| 3907 | + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) { | |
| 3908 | + $wrapped[ $priority ][ $id ] = $registered; | |
| 3909 | + continue; | |
| 3910 | + } | |
| 3911 | + $callback = $registered['function']; | |
| 3912 | + $wrapper = static function ( ...$args ) use ( $callback, $hook ) { | |
| 3913 | + try { | |
| 3914 | + return $callback( ...$args ); | |
| 3915 | + } catch ( \Throwable $e ) { | |
| 3916 | + self::log_purge_listener_error( $hook, $e ); | |
| 3917 | + return null; | |
| 3918 | + } | |
| 3919 | + }; | |
| 3920 | + $wrapped[ $priority ][ $id ] = array( | |
| 3921 | + // Keep accepted_args: a listener registered for 0 or 1 | |
| 3922 | + // arguments must still be called the way it asked. | |
| 3923 | + 'accepted_args' => $registered['accepted_args'] ?? 1, | |
| 3924 | + 'function' => $wrapper, | |
| 3925 | + ); | |
| 3926 | + $restorations[ $priority ][ $id ] = array( | |
| 3927 | + 'original' => $registered, | |
| 3928 | + 'wrapper' => $wrapper, | |
| 3929 | + ); | |
| 3930 | + } | |
| 3931 | + } | |
| 3932 | + | |
| 3933 | + $hook_object->callbacks = $wrapped; | |
| 3934 | + try { | |
| 3935 | + do_action( $hook, $arg ); | |
| 3936 | + } finally { | |
| 3937 | + // Restore only wrappers still present. Native add/remove operations | |
| 3938 | + // performed by listeners must survive this temporary substitution. | |
| 3939 | + foreach ( $restorations as $priority => $group ) { | |
| 3940 | + foreach ( $group as $id => $restore ) { | |
| 3941 | + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null; | |
| 3942 | + if ( $current === $restore['wrapper'] ) { | |
| 3943 | + $hook_object->callbacks[ $priority ][ $id ] = $restore['original']; | |
| 3944 | + } | |
| 3945 | + } | |
| 3946 | + } | |
| 3947 | + } | |
| 3948 | + } | |
| 3949 | + | |
| 3950 | + /** | |
| 3951 | + * Name a listener that threw, under WP_DEBUG only. | |
| 3952 | + * | |
| 3953 | + * Gated like the rest of Free's diagnostics: a third-party listener | |
| 3954 | + * throwing on every purge must not fill a production log. | |
| 3955 | + */ | |
| 3956 | + private static function log_purge_listener_error( string $hook, \Throwable $e ): void { | |
| 3957 | + // An \Error — a TypeError from one of OUR listeners, say — is a bug | |
| 3958 | + // rather than a runtime condition a third party imposed on us, and | |
| 3959 | + // swallowing it silently in production turns it into a purge that | |
| 3960 | + // quietly stops working. Those are logged whatever WP_DEBUG says; | |
| 3961 | + // third-party \Exceptions stay gated so a noisy integration cannot | |
| 3962 | + // fill a production log. | |
| 3963 | + $always = $e instanceof \Error; | |
| 3964 | + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) { | |
| 3965 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge. | |
| 3966 | + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() ); | |
| 3967 | + } | |
| 3968 | + } | |
| 3969 | + | |
| 3970 | + /** Test seam: clear the in-flight set left behind by an aborted dispatch. */ | |
| 3971 | + public static function reset_purge_events(): void { | |
| 3972 | + self::$purge_events_in_flight = array(); | |
| 3973 | + self::$purge_event_sequence = 0; | |
| 3974 | + } | |
| 3975 | + | |
| 3976 | + /** | |
| 3977 | + * Hand the purge to the caches we ship integrations for. | |
| 3978 | + * | |
| 3979 | + * Isolated from the public action on purpose — see dispatch_purge_event(). | |
| 3980 | + * Guarded so a missing class (a partial upgrade, a stripped build) cannot | |
| 3981 | + * turn a working purge into a fatal. | |
| 3982 | + * | |
| 3983 | + * @param array<string,mixed> $context Bounded purge context. | |
| 3984 | + */ | |
| 3985 | + private static function forward_to_server_caches( array $context ): void { | |
| 3986 | + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) { | |
| 3987 | + Server_Caches::forward( $context ); | |
| 3988 | + } | |
| 3989 | + } | |
| 3990 | + | |
| 3991 | + /** | |
| 3992 | + * `host[:port]` for a cache key, from a parsed URL. | |
| 3993 | + * | |
| 3994 | + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and | |
| 3995 | + * that carries `:8080` on any install not served from 80/443 — dropping it | |
| 3996 | + * computed a different md5, found no file, and reported "already cold" | |
| 3997 | + * while the page kept serving HIT. | |
| 3998 | + * | |
| 3999 | + * A port that is the DEFAULT for the scheme is dropped, though, because | |
| 4000 | + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for | |
| 4001 | + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file | |
| 4002 | + * stored under `site.com` — the same silent no-op in the other direction, | |
| 4003 | + * and the one QA hit passing a canonical URL with the port spelled out. | |
| 4004 | + * (QA #348) | |
| 4005 | + * | |
| 4006 | + * @param array<string,mixed> $parts Output of wp_parse_url(). | |
| 4007 | + */ | |
| 4008 | + private static function host_port_of( array $parts ): string { | |
| 4009 | + if ( ! isset( $parts['host'] ) ) { | |
| 4010 | + return ''; | |
| 4011 | + } | |
| 4012 | + $host = strtolower( (string) $parts['host'] ); | |
| 4013 | + if ( '' === $host || ! isset( $parts['port'] ) ) { | |
| 4014 | + return $host; | |
| 4015 | + } | |
| 4016 | + $port = (int) $parts['port']; | |
| 4017 | + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; | |
| 4018 | + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) { | |
| 4019 | + return $host; | |
| 4020 | + } | |
| 4021 | + return $host . ':' . $port; | |
| 4022 | + } | |
| 4023 | + | |
| 2838 | 4024 | public static function purge_url( string $url, string $cause = 'manual' ): int { |
| 4025 | + // A URL that names nothing is not a purge of everything. An empty or | |
| 4026 | + // blank string used to fall through to the home_url() default below | |
| 4027 | + // and clear the HOMEPAGE — so a third party calling | |
| 4028 | + // `purge_url( get_permalink( $id ) )` on a post whose permalink came | |
| 4029 | + // back empty silently purged the front page instead of nothing. The | |
| 4030 | + // CLI and the MCP tool reject empties before reaching this, so only | |
| 4031 | + // direct API callers were exposed, but they are exactly the audience | |
| 4032 | + // this public contract is for. (QA #348) | |
| 4033 | + if ( '' === trim( $url ) ) { | |
| 4034 | + return 0; | |
| 4035 | + } | |
| 2839 | 4036 | $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- fallback for early-boot contexts only. |
| 2840 | 4037 | if ( ! is_array( $parts ) ) { |
| 2841 | 4038 | return 0; |
| 2842 | 4039 | } |
| 4040 | + // Absolute URLs are accepted only for HTTP response caches. Schemes such | |
| 4041 | + // as ftp:, file: and javascript: can parse cleanly but do not name a page | |
| 4042 | + // xSpeed or a server response cache can invalidate. A leading-slash path | |
| 4043 | + // remains a supported site-relative target. | |
| 4044 | + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) { | |
| 4045 | + return 0; | |
| 4046 | + } | |
| 4047 | + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) { | |
| 4048 | + return 0; | |
| 4049 | + } | |
| 4050 | + // Reject a string that parsed but is not a URL we can act on: no | |
| 4051 | + // scheme AND no host AND no leading-slash path means something like | |
| 4052 | + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a | |
| 4053 | + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)` | |
| 4054 | + // — a nonsense tag sent to LiteSpeed for every malformed call. | |
| 4055 | + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) { | |
| 4056 | + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : ''; | |
| 4057 | + if ( '' === $raw || '/' !== $raw[0] ) { | |
| 4058 | + return 0; | |
| 4059 | + } | |
| 4060 | + } | |
| 2843 | 4061 | // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which |
| 2844 | 4062 | // carries `:8080` on any install not served from 80/443 — while |
| 2845 | 4063 | // parse_url() splits the port into its own component, so a purge that |
| 2846 | 4064 | // used the bare host computed a different md5, found no file, and |
| @@ -2846,19 +4064,34 @@ | ||
| 2846 | 4064 | // used the bare host computed a different md5, found no file, and |
| 2847 | 4065 | // reported "already cold". A silent no-op: the page kept serving HIT |
| 2848 | 4066 | // until its TTL ran out. Intranet installs, panel hosts on :8443 and |
| 2849 | 4067 | // proxies that forward `Host: site.com:8080` all hit this. |
| 2850 | - $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : ''; | |
| 2851 | - if ( '' !== $host && isset( $parts['port'] ) ) { | |
| 2852 | - $host .= ':' . (int) $parts['port']; | |
| 4068 | + // A scheme-less `site.test:443/page/` is a supported explicit-host | |
| 4069 | + // target. Infer a scheme only when it names THIS site's hostname: then | |
| 4070 | + // its explicit default port is the same origin and the same local cache | |
| 4071 | + // key. Never apply this to another host or to a non-default port. | |
| 4072 | + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) { | |
| 4073 | + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above. | |
| 4074 | + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] ) | |
| 4075 | + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] ) | |
| 4076 | + ) { | |
| 4077 | + $home_scheme = strtolower( (string) $home['scheme'] ); | |
| 4078 | + $port = (int) $parts['port']; | |
| 4079 | + $home_port = isset( $home['port'] ) | |
| 4080 | + ? (int) $home['port'] | |
| 4081 | + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) ); | |
| 4082 | + if ( $home_port === $port | |
| 4083 | + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) ) | |
| 4084 | + ) { | |
| 4085 | + $parts['scheme'] = $home_scheme; | |
| 4086 | + } | |
| 4087 | + } | |
| 2853 | 4088 | } |
| 4089 | + $host = self::host_port_of( $parts ); | |
| 2854 | 4090 | if ( '' === $host && function_exists( 'home_url' ) ) { |
| 2855 | 4091 | $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above. |
| 2856 | - if ( is_array( $home ) && isset( $home['host'] ) ) { | |
| 2857 | - $host = strtolower( (string) $home['host'] ); | |
| 2858 | - if ( isset( $home['port'] ) ) { | |
| 2859 | - $host .= ':' . (int) $home['port']; | |
| 2860 | - } | |
| 4092 | + if ( is_array( $home ) ) { | |
| 4093 | + $host = self::host_port_of( $home ); | |
| 2861 | 4094 | } |
| 2862 | 4095 | } |
| 2863 | 4096 | if ( '' === $host ) { |
| 2864 | 4097 | return 0; |
| @@ -2942,14 +4175,120 @@ | ||
| 2942 | 4175 | Activity_Log::INFO |
| 2943 | 4176 | ); |
| 2944 | 4177 | } |
| 2945 | 4178 | |
| 4179 | + /** | |
| 4180 | + * Fires after one URL's cached copy has been purged. | |
| 4181 | + * | |
| 4182 | + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe | |
| 4183 | + * here to invalidate a cache xSpeed does not own — a server-level | |
| 4184 | + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for | |
| 4185 | + * the same URL. | |
| 4186 | + * | |
| 4187 | + * Only fires when the purge actually ran. A malformed URL, a URL with | |
| 4188 | + * no resolvable host, or a traversal attempt returns earlier and | |
| 4189 | + * publishes nothing, so a listener can treat this as "xSpeed purged | |
| 4190 | + * this URL" rather than "xSpeed was asked to". `removed` may legitimately | |
| 4191 | + * be 0: the URL was not in xSpeed's cache, which says nothing about | |
| 4192 | + * whether it is in yours. | |
| 4193 | + * | |
| 4194 | + * Fires at most once per purge. A listener that calls back into | |
| 4195 | + * xSpeed's purge API will not re-enter this event. | |
| 4196 | + * | |
| 4197 | + * @since 1.2.3 | |
| 4198 | + * | |
| 4199 | + * @param array $context { | |
| 4200 | + * Bounded description of the purge. URL queries and caller-supplied | |
| 4201 | + * causes can contain sensitive values and are not logging fields. | |
| 4202 | + * | |
| 4203 | + * @type string $url Canonical scheme://host/path[?query] of the purged URL. | |
| 4204 | + * The query is preserved because caches in front | |
| 4205 | + * commonly key on it; xSpeed's own sweep is | |
| 4206 | + * path-based, so `removed` describes that. | |
| 4207 | + * @type string $host Host (with port when non-standard). | |
| 4208 | + * @type string $path Path component, leading slash. | |
| 4209 | + * @type string $cause Short label for who asked. See purge_all(). | |
| 4210 | + * @type int $removed Number of cache files removed. | |
| 4211 | + * @type string $scope Actionable adapter scope: `urls`. | |
| 4212 | + * @type string $intent Why responses changed: `content`. | |
| 4213 | + * @type string[] $urls Exact response URLs to invalidate. | |
| 4214 | + * } | |
| 4215 | + */ | |
| 4216 | + $canonical_url = self::canonical_purge_url( | |
| 4217 | + $host, | |
| 4218 | + $path, | |
| 4219 | + isset( $parts['query'] ) ? (string) $parts['query'] : '', | |
| 4220 | + isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '' | |
| 4221 | + ); | |
| 4222 | + self::dispatch_purge_event( | |
| 4223 | + 'xspeed_after_purge_url', | |
| 4224 | + array( | |
| 4225 | + 'url' => $canonical_url, | |
| 4226 | + 'host' => $host, | |
| 4227 | + 'path' => $path, | |
| 4228 | + 'cause' => $cause, | |
| 4229 | + 'removed' => $count, | |
| 4230 | + 'scope' => 'urls', | |
| 4231 | + 'intent' => 'content', | |
| 4232 | + 'urls' => array( $canonical_url ), | |
| 4233 | + ) | |
| 4234 | + ); | |
| 4235 | + | |
| 2946 | 4236 | return $count; |
| 2947 | 4237 | } |
| 2948 | 4238 | |
| 4239 | + /** Host this site's purge is scoped to, for the purge-event context. */ | |
| 4240 | + private static function current_purge_host(): string { | |
| 4241 | + if ( ! function_exists( 'home_url' ) ) { | |
| 4242 | + return ''; | |
| 4243 | + } | |
| 4244 | + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- host only. | |
| 4245 | + if ( ! is_array( $home ) || empty( $home['host'] ) ) { | |
| 4246 | + return ''; | |
| 4247 | + } | |
| 4248 | + // Same default-port normalisation as purge_url(): a site whose | |
| 4249 | + // home_url() carries `:443` (normal behind a proxy) otherwise stamps | |
| 4250 | + // every full-purge event with a host that matches none of its own | |
| 4251 | + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348) | |
| 4252 | + return self::host_port_of( $home ); | |
| 4253 | + } | |
| 4254 | + | |
| 2949 | 4255 | /** |
| 2950 | - * Purge this site's cache. | |
| 4256 | + * Rebuild the canonical URL a purge applied to. | |
| 2951 | 4257 | * |
| 4258 | + * Built from the parts the purge itself used, so a listener is told the | |
| 4259 | + * URL we acted on rather than the string the caller happened to pass — | |
| 4260 | + * those differ whenever the caller supplied a site-relative path, a | |
| 4261 | + * different scheme, or a query string the cache key ignores. | |
| 4262 | + */ | |
| 4263 | + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string { | |
| 4264 | + // The purged URL's own scheme wins. purge_url() explicitly supports | |
| 4265 | + // cross-site purges (multisite, WP-CLI, cron), where composing the | |
| 4266 | + // current site's scheme onto another site's host builds a URL that was | |
| 4267 | + // never served — and a CDN listener then purges the wrong key and | |
| 4268 | + // reports success. | |
| 4269 | + if ( '' !== $url_scheme ) { | |
| 4270 | + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' ); | |
| 4271 | + } | |
| 4272 | + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http'; | |
| 4273 | + if ( function_exists( 'home_url' ) ) { | |
| 4274 | + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- scheme only. | |
| 4275 | + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) { | |
| 4276 | + $scheme = (string) $home['scheme']; | |
| 4277 | + } | |
| 4278 | + } | |
| 4279 | + // The query is carried even though OUR sweep above is path-based. | |
| 4280 | + // Caches in front commonly key on the full request line — LiteSpeed | |
| 4281 | + // tags `/shop/?page=2` separately from `/shop/` — so publishing the | |
| 4282 | + // bare path would have a listener confidently purge the wrong entry | |
| 4283 | + // and report success. Telling it exactly what was asked for lets it | |
| 4284 | + // act correctly; `removed` still describes only what WE removed. | |
| 4285 | + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' ); | |
| 4286 | + } | |
| 4287 | + | |
| 4288 | + /** | |
| 4289 | + * Sweep this site's cache files. | |
| 4290 | + * | |
| 2952 | 4291 | * On multisite every blog shares one cache directory, so an unscoped |
| 2953 | 4292 | * sweep here took the whole network cold — one subsite's settings save |
| 2954 | 4293 | * or post publish rebuilt every other site from PHP. Entries are stored |
| 2955 | 4294 | * per host (see host_dir()), and the sweep is scoped to match, so a |
| @@ -2954,16 +4293,27 @@ | ||
| 2954 | 4293 | * or post publish rebuilt every other site from PHP. Entries are stored |
| 2955 | 4294 | * per host (see host_dir()), and the sweep is scoped to match, so a |
| 2956 | 4295 | * purge originating on site-a leaves site-b's cache warm. (#6) |
| 2957 | 4296 | * |
| 2958 | - * @param string $cause Who asked, for the purge log. | |
| 2959 | - * @param string|null $host Host to purge. Defaults to the current site. | |
| 2960 | - * Pass '*' to sweep the ENTIRE tree — network | |
| 2961 | - * admin's "purge all sites", and the migration | |
| 2962 | - * of pre-#6 entries that sit in the tree root. | |
| 4297 | + * Clears the files only: the flat tree, the static tree, the REST | |
| 4298 | + * responses and the minified assets. The object-cache flush, the stats | |
| 4299 | + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract | |
| 4300 | + * event and the log entry live in purge_all(), which is still the entry | |
| 4301 | + * point for every existing caller. Split out so `wp xspeed purge` can | |
| 4302 | + * report the local sweep as one line item and the object cache as | |
| 4303 | + * another, each with its own status — see Purge_Runner. | |
| 4304 | + * | |
| 4305 | + * @param string|null $host Host to purge. Defaults to the current site. | |
| 4306 | + * Pass '*' to sweep the ENTIRE tree — network | |
| 4307 | + * admin's "purge all sites", and the migration | |
| 4308 | + * of pre-#6 entries that sit in the tree root. | |
| 4309 | + * @return array{pages:int,rest:int,assets:int,bytes:int} Entries removed | |
| 4310 | + * per store, and the bytes freed by the two file sweeps | |
| 4311 | + * that measure themselves. | |
| 2963 | 4312 | */ |
| 2964 | - public static function purge_all( string $cause = 'manual', ?string $host = null ) { | |
| 2965 | - $network_wide = ( '*' === $host ); | |
| 4313 | + public static function purge_local( ?string $host = null ): array { | |
| 4314 | + $network_wide = ( '*' === $host ); | |
| 4315 | + self::$sweep_bytes = 0; | |
| 2966 | 4316 | // The flat tree buckets by a flattened segment (host/a-b) while the |
| 2967 | 4317 | // static tree mirrors the URL (host/a/b), so they need separate |
| 2968 | 4318 | // scopes — see current_host_dir() vs current_static_scope(). |
| 2969 | 4319 | $static_scope = ''; |
| @@ -2972,9 +4322,10 @@ | ||
| 2972 | 4322 | $static_scope = $network_wide ? '' : self::current_static_scope(); |
| 2973 | 4323 | } else { |
| 2974 | 4324 | $dir = self::host_dir( $host ); |
| 2975 | 4325 | $scope = '' === $dir ? 'default' : $dir; |
| 2976 | - $static_scope = $scope; | |
| 4326 | + $static_dir = self::static_host_dir( $host ); | |
| 4327 | + $static_scope = '' === $static_dir ? 'default' : $static_dir; | |
| 2977 | 4328 | } |
| 2978 | 4329 | |
| 2979 | 4330 | $count = 0; |
| 2980 | 4331 | if ( is_dir( XSPEED_CACHE_DIR ) ) { |
| @@ -3024,9 +4375,9 @@ | ||
| 3024 | 4375 | $files = glob( $root . '/*.html' ); |
| 3025 | 4376 | if ( $files ) { |
| 3026 | 4377 | $count += count( $files ); |
| 3027 | 4378 | foreach ( $files as $f ) { |
| 3028 | - wp_delete_file( $f ); | |
| 4379 | + self::sweep_delete( $f ); | |
| 3029 | 4380 | } |
| 3030 | 4381 | } |
| 3031 | 4382 | // Remove the .meta sidecars (content-type for feeds/sitemaps) |
| 3032 | 4383 | // alongside their .html entries. Not counted — they're not |
| @@ -3033,9 +4384,9 @@ | ||
| 3033 | 4384 | // cache "pages", just per-entry metadata. |
| 3034 | 4385 | $meta = glob( $root . '/*.meta' ); |
| 3035 | 4386 | if ( $meta ) { |
| 3036 | 4387 | foreach ( $meta as $m ) { |
| 3037 | - wp_delete_file( $m ); | |
| 4388 | + self::sweep_delete( $m ); | |
| 3038 | 4389 | } |
| 3039 | 4390 | } |
| 3040 | 4391 | // Remove precompressed siblings (e.g. <key>.html.br from the Pro |
| 3041 | 4392 | // Brotli module). Not counted — same as .meta. Without this a |
| @@ -3043,9 +4394,9 @@ | ||
| 3043 | 4394 | // staleness window if precompression is later disabled. |
| 3044 | 4395 | $br = glob( $root . '/*.br' ); |
| 3045 | 4396 | if ( $br ) { |
| 3046 | 4397 | foreach ( $br as $b ) { |
| 3047 | - wp_delete_file( $b ); | |
| 4398 | + self::sweep_delete( $b ); | |
| 3048 | 4399 | } |
| 3049 | 4400 | } |
| 3050 | 4401 | // `*.br` does not match `*.br.size` — same reason as the flat-root |
| 3051 | 4402 | // sweep above: a size record outliving its body would later be |
| @@ -3052,9 +4403,9 @@ | ||
| 3052 | 4403 | // read against a different sibling's bytes. |
| 3053 | 4404 | $br_size = glob( $root . '/*.br.size' ); |
| 3054 | 4405 | if ( $br_size ) { |
| 3055 | 4406 | foreach ( $br_size as $b ) { |
| 3056 | - wp_delete_file( $b ); | |
| 4407 | + self::sweep_delete( $b ); | |
| 3057 | 4408 | } |
| 3058 | 4409 | } |
| 3059 | 4410 | } |
| 3060 | 4411 | } |
| @@ -3071,9 +4422,10 @@ | ||
| 3071 | 4422 | } |
| 3072 | 4423 | } |
| 3073 | 4424 | // REST response cache (cache/xspeed/rest/*.json) — same purge |
| 3074 | 4425 | // triggers (publish, settings change) invalidate it too. |
| 3075 | - $count += Rest_Cache::purge(); | |
| 4426 | + $rest = Rest_Cache::purge(); | |
| 4427 | + $count += $rest; | |
| 3076 | 4428 | |
| 3077 | 4429 | // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/). |
| 3078 | 4430 | // purge_all is a full filesystem sweep and must clear these too, even |
| 3079 | 4431 | // when the Minify module is currently disabled — orphaned min/ files |
| @@ -3079,20 +4431,33 @@ | ||
| 3079 | 4431 | // when the Minify module is currently disabled — orphaned min/ files |
| 3080 | 4432 | // from a feature the user later turned off must still be removed, and |
| 3081 | 4433 | // a stale combined-<hash>.css that the regenerated page no longer |
| 3082 | 4434 | // references otherwise 404s and breaks the frontend. (FBS-83114/83116) |
| 3083 | - if ( class_exists( '\\XSpeed\\Minifier' ) ) { | |
| 3084 | - Minifier::purge_minified(); | |
| 3085 | - } | |
| 4435 | + $assets = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_minified() : 0; | |
| 3086 | 4436 | |
| 3087 | - // Persistent object cache (Redis / Memcached). Flush regardless of | |
| 3088 | - // whether the Object Cache module is currently enabled — a drop-in | |
| 3089 | - // installed earlier keeps serving until flushed. | |
| 3090 | - // | |
| 3091 | - // wp_cache_flush() is NETWORK-global: on multisite it would drop | |
| 3092 | - // every other site's object cache too, which is the same bug this | |
| 3093 | - // change fixes for the page cache. Prefer the blog-scoped flush | |
| 3094 | - // (WP 6.1+) unless we were explicitly asked to go network-wide. (#6) | |
| 4437 | + return array( | |
| 4438 | + 'pages' => $count - $rest, | |
| 4439 | + 'rest' => $rest, | |
| 4440 | + 'assets' => $assets, | |
| 4441 | + 'bytes' => self::$sweep_bytes, | |
| 4442 | + ); | |
| 4443 | + } | |
| 4444 | + | |
| 4445 | + /** | |
| 4446 | + * Flush the persistent object cache (Redis / Memcached). | |
| 4447 | + * | |
| 4448 | + * Runs regardless of whether the Object Cache module is currently | |
| 4449 | + * enabled — a drop-in installed earlier keeps serving until flushed. | |
| 4450 | + * | |
| 4451 | + * @param bool $network_wide Flush every blog's entries. wp_cache_flush() | |
| 4452 | + * is NETWORK-global, so on multisite the | |
| 4453 | + * default prefers the blog-scoped group flush | |
| 4454 | + * (WP 6.1+) — otherwise one site's purge drops | |
| 4455 | + * every other site's object cache, the same bug | |
| 4456 | + * #6 fixed for the page cache. | |
| 4457 | + * @return bool Whether a flush was actually performed. | |
| 4458 | + */ | |
| 4459 | + public static function flush_object_cache( bool $network_wide = false ): bool { | |
| 3095 | 4460 | if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) { |
| 3096 | 4461 | // Blog-scoped groups only; a shared/global group (site options, |
| 3097 | 4462 | // user meta) is intentionally left alone. |
| 3098 | 4463 | foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) { |
| @@ -3097,12 +4462,62 @@ | ||
| 3097 | 4462 | // user meta) is intentionally left alone. |
| 3098 | 4463 | foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) { |
| 3099 | 4464 | wp_cache_flush_group( $group ); |
| 3100 | 4465 | } |
| 3101 | - } elseif ( function_exists( 'wp_cache_flush' ) ) { | |
| 3102 | - wp_cache_flush(); | |
| 4466 | + return true; | |
| 3103 | 4467 | } |
| 4468 | + if ( function_exists( 'wp_cache_flush' ) ) { | |
| 4469 | + return (bool) wp_cache_flush(); | |
| 4470 | + } | |
| 4471 | + return false; | |
| 4472 | + } | |
| 3104 | 4473 | |
| 4474 | + /** | |
| 4475 | + * Purge this site's cache: the local sweep, then the object cache, then | |
| 4476 | + * the bookkeeping every caller expects (stats, `xspeed_after_purge_all`, | |
| 4477 | + * inventory invalidation, purge log). | |
| 4478 | + * | |
| 4479 | + * @param string $cause Who asked, for the purge log. | |
| 4480 | + * @param string|null $host See purge_local(). | |
| 4481 | + * @param array<string,mixed> $invalidation Public adapter policy. `scope` | |
| 4482 | + * is urls/site/network/none, | |
| 4483 | + * `intent` explains why, and | |
| 4484 | + * `urls` supplies exact targets. | |
| 4485 | + * @return int Page + REST entries removed. | |
| 4486 | + */ | |
| 4487 | + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) { | |
| 4488 | + $network_wide = ( '*' === $host ); | |
| 4489 | + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] ) | |
| 4490 | + ? $invalidation['scope'] | |
| 4491 | + : ( $network_wide ? 'network' : 'site' ); | |
| 4492 | + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) { | |
| 4493 | + $adapter_scope = $network_wide ? 'network' : 'site'; | |
| 4494 | + } | |
| 4495 | + if ( $network_wide ) { | |
| 4496 | + $adapter_scope = 'network'; | |
| 4497 | + } | |
| 4498 | + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent'] | |
| 4499 | + ? $invalidation['intent'] | |
| 4500 | + : 'complete'; | |
| 4501 | + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] ) | |
| 4502 | + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) ) | |
| 4503 | + : array(); | |
| 4504 | + // This method always sweeps a complete local bucket. A narrower adapter | |
| 4505 | + // announcement would claim unrelated local pages stayed warm when they | |
| 4506 | + // did not, leaving their server copies stale. Until purge_all() gains | |
| 4507 | + // dependency-aware local deletion, its response scope cannot be `urls`. | |
| 4508 | + if ( 'urls' === $adapter_scope ) { | |
| 4509 | + $adapter_scope = $network_wide ? 'network' : 'site'; | |
| 4510 | + } | |
| 4511 | + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) { | |
| 4512 | + $urls = array(); | |
| 4513 | + } | |
| 4514 | + | |
| 4515 | + $removed = self::purge_local( $host ); | |
| 4516 | + $count = $removed['pages'] + $removed['rest']; | |
| 4517 | + | |
| 4518 | + self::flush_object_cache( $network_wide ); | |
| 4519 | + | |
| 3105 | 4520 | self::update_stats( array( 'last_purge' => time() ) ); |
| 3106 | 4521 | |
| 3107 | 4522 | // Fire AFTER the local sweep so module listeners (Critical CSS, |
| 3108 | 4523 | // Unused CSS, Cloudflare edge purge) run — this action had three |
| @@ -3108,10 +4523,64 @@ | ||
| 3108 | 4523 | // Unused CSS, Cloudflare edge purge) run — this action had three |
| 3109 | 4524 | // registered listeners but was never emitted. Treat it as additive |
| 3110 | 4525 | // (CDN / edge invalidation), not the mechanism for clearing local |
| 3111 | 4526 | // files. (FBS-83114) |
| 3112 | - do_action( 'xspeed_after_purge_all', $cause ); | |
| 4527 | + // Wrapped: this action predates the purge-event contract and has its | |
| 4528 | + // own third-party listeners. One of them throwing used to abort | |
| 4529 | + // purge_all() here, which now also means the contract event below | |
| 4530 | + // never fires and a server cache keeps serving stale HTML. The local | |
| 4531 | + // sweep is already done by this point, so swallowing is strictly safer | |
| 4532 | + // than letting a listener decide the rest of the method runs. | |
| 4533 | + try { | |
| 4534 | + // Isolated per listener: one throwing used to cancel every | |
| 4535 | + // listener queued behind it — Critical CSS, Unused CSS and the | |
| 4536 | + // Cloudflare edge purge all hang off this hook. (QA #348) | |
| 4537 | + self::do_action_isolated( 'xspeed_after_purge_all', $cause ); | |
| 4538 | + } catch ( \Throwable $e ) { | |
| 4539 | + self::log_purge_listener_error( 'xspeed_after_purge_all', $e ); | |
| 4540 | + } | |
| 3113 | 4541 | |
| 4542 | + /** | |
| 4543 | + * Fires after a full purge, with the same bounded context shape as | |
| 4544 | + * `xspeed_after_purge_url`. | |
| 4545 | + * | |
| 4546 | + * Distinct from `xspeed_after_purge_all` on purpose. That action is | |
| 4547 | + * the long-standing internal signal — it passes a bare `$cause` string | |
| 4548 | + * and Free's own modules use it for local bookkeeping. This one is the | |
| 4549 | + * documented contract for OUTSIDE integrations: same argument shape as | |
| 4550 | + * the per-URL event, so a server-cache or CDN adapter can subscribe to | |
| 4551 | + * both with one handler and branch on a null `url`. | |
| 4552 | + * | |
| 4553 | + * Fires at most once per purge, and not at all when a listener's own | |
| 4554 | + * purge re-enters xSpeed. | |
| 4555 | + * | |
| 4556 | + * @since 1.2.3 | |
| 4557 | + * | |
| 4558 | + * @param array $context { | |
| 4559 | + * @type null $url Always null — a full purge has no single URL. | |
| 4560 | + * @type string $host Host swept, or '*' for the entire tree. | |
| 4561 | + * @type null $path Always null. | |
| 4562 | + * @type string $cause Short label for who asked. | |
| 4563 | + * @type int $removed Number of cache files removed. | |
| 4564 | + * @type string $scope Adapter action: urls/site/network/none. | |
| 4565 | + * @type string $intent content/presentation/complete or a caller-defined intent. | |
| 4566 | + * @type string[] $urls Exact targets when scope is urls. | |
| 4567 | + * } | |
| 4568 | + */ | |
| 4569 | + self::dispatch_purge_event( | |
| 4570 | + 'xspeed_after_purge', | |
| 4571 | + array( | |
| 4572 | + 'url' => null, | |
| 4573 | + 'host' => null === $host ? self::current_purge_host() : (string) $host, | |
| 4574 | + 'path' => null, | |
| 4575 | + 'cause' => $cause, | |
| 4576 | + 'removed' => $count, | |
| 4577 | + 'scope' => $adapter_scope, | |
| 4578 | + 'intent' => $intent, | |
| 4579 | + 'urls' => $urls, | |
| 4580 | + ) | |
| 4581 | + ); | |
| 4582 | + | |
| 3114 | 4583 | // The list behind the "Cached pages" card is memoized for a minute; |
| 3115 | 4584 | // a purge has to drop it or the drill-down shows pages that no |
| 3116 | 4585 | // longer exist. |
| 3117 | 4586 | Cache_Inventory::invalidate(); |
| @@ -3711,8 +5180,9 @@ | ||
| 3711 | 5180 | $count = self::purge_pages(); |
| 3712 | 5181 | self::update_stats( array( 'last_purge' => time() ) ); |
| 3713 | 5182 | Cache_Inventory::invalidate(); |
| 3714 | 5183 | self::record_partial_purge( 'page', $cause, $count ); |
| 5184 | + self::announce_purge( $cause, $count ); | |
| 3715 | 5185 | return $count; |
| 3716 | 5186 | |
| 3717 | 5187 | case 'assets': |
| 3718 | 5188 | if ( class_exists( '\\XSpeed\\Minifier' ) ) { |
| @@ -3739,8 +5209,9 @@ | ||
| 3739 | 5209 | $count = self::purge_pages(); |
| 3740 | 5210 | self::update_stats( array( 'last_purge' => time() ) ); |
| 3741 | 5211 | Cache_Inventory::invalidate(); |
| 3742 | 5212 | self::record_partial_purge( 'assets', $cause, $count ); |
| 5213 | + self::announce_purge( $cause, $count ); | |
| 3743 | 5214 | return $count; |
| 3744 | 5215 | |
| 3745 | 5216 | case 'object': |
| 3746 | 5217 | if ( function_exists( 'wp_cache_flush' ) ) { |
| @@ -3751,8 +5222,9 @@ | ||
| 3751 | 5222 | |
| 3752 | 5223 | case 'rest': |
| 3753 | 5224 | $count = Rest_Cache::purge(); |
| 3754 | 5225 | self::record_partial_purge( 'REST responses', $cause, $count ); |
| 5226 | + self::announce_purge( $cause, $count ); | |
| 3755 | 5227 | return $count; |
| 3756 | 5228 | |
| 3757 | 5229 | default: |
| 3758 | 5230 | return self::purge_type_unhandled( $type, $cause ); |
| @@ -3805,15 +5277,101 @@ | ||
| 3805 | 5277 | * @param string $type Purge-type slug. |
| 3806 | 5278 | * @param string $cause Who asked. |
| 3807 | 5279 | */ |
| 3808 | 5280 | private static function purge_type_unhandled( string $type, string $cause ): int { |
| 3809 | - do_action( 'xspeed_purge_type_' . $type ); | |
| 5281 | + $event_sequence = self::$purge_event_sequence; | |
| 5282 | + $hook = 'xspeed_purge_type_' . $type; | |
| 5283 | + $has_handler = false !== has_action( $hook ); | |
| 5284 | + do_action( $hook ); | |
| 3810 | 5285 | self::record_partial_purge( $type, $cause, null ); |
| 3811 | 5286 | |
| 5287 | + // Announce, same as the types this class owns. Pro's "Purge Critical | |
| 5288 | + // CSS" and "Purge Unused CSS" arrive here, and they change what a | |
| 5289 | + // cached page CONTAINS — critical CSS is inlined into the HTML, so a | |
| 5290 | + // server cache goes on serving pages with the old styles baked in. | |
| 5291 | + // Fixing the three Free buttons and leaving these two silent left the | |
| 5292 | + // same hole for the tier most likely to be using both plugins. | |
| 5293 | + // (QA #348 round 2, issue 2) | |
| 5294 | + // | |
| 5295 | + // Unknown slugs must not turn into a site-wide purge merely because no | |
| 5296 | + // handler exists. These are the response-changing Pro types Free knows; | |
| 5297 | + // third parties can declare another through the filter. A registered | |
| 5298 | + // handler plus this explicit response scope is the handled signal. | |
| 5299 | + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none'; | |
| 5300 | + /** | |
| 5301 | + * Declare whether a handled custom purge type changes cached responses. | |
| 5302 | + * | |
| 5303 | + * @since 1.2.3 | |
| 5304 | + * @param string $scope site/network/none. | |
| 5305 | + * @param string $type Purge-type slug. | |
| 5306 | + */ | |
| 5307 | + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type ); | |
| 5308 | + if ( $has_handler | |
| 5309 | + && $event_sequence === self::$purge_event_sequence | |
| 5310 | + && in_array( $scope, array( 'site', 'network' ), true ) | |
| 5311 | + ) { | |
| 5312 | + self::announce_purge( $cause, 0, $scope, 'presentation' ); | |
| 5313 | + } | |
| 5314 | + | |
| 3812 | 5315 | return 0; |
| 3813 | 5316 | } |
| 3814 | 5317 | |
| 3815 | 5318 | /** |
| 5319 | + * Tell the server cache that a PARTIAL purge cleared cached responses. | |
| 5320 | + * | |
| 5321 | + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST | |
| 5322 | + * Cache" each delete cached RESPONSES for the whole site, so a cache in | |
| 5323 | + * front of PHP is now serving copies xSpeed has just thrown away. Only | |
| 5324 | + * "Purge All" announced itself, which left three of the four toolbar | |
| 5325 | + * buttons doing exactly what this contract exists to prevent: clearing | |
| 5326 | + * our copy while the server kept serving the stale one. The `assets` case | |
| 5327 | + * was the sharpest — it deletes the minified bundles too, so LiteSpeed | |
| 5328 | + * went on serving pages whose CSS and JS no longer exist. (QA #348) | |
| 5329 | + * | |
| 5330 | + * Sent as the full-purge shape (`url` null) because that is what happened: | |
| 5331 | + * every cached page for this site went, not one address. `object` is not | |
| 5332 | + * announced — flushing the object cache changes no rendered response a | |
| 5333 | + * server cache could be holding. | |
| 5334 | + * | |
| 5335 | + * Public because Purge_Runner sweeps the local files itself, through | |
| 5336 | + * purge_local(), rather than through purge_all() — so it has to announce | |
| 5337 | + * on its own behalf or `wp xspeed purge` and the dashboard button clear | |
| 5338 | + * our copy while LiteSpeed keeps serving the stale one. | |
| 5339 | + * | |
| 5340 | + * @param string $cause Who asked. | |
| 5341 | + * @param int $removed Entries removed locally. | |
| 5342 | + * @param string $scope Actionable adapter scope. | |
| 5343 | + * @param string $intent Reason rendered responses changed. | |
| 5344 | + */ | |
| 5345 | + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void { | |
| 5346 | + // Announcing is additive: the local sweep has already happened and | |
| 5347 | + // succeeded. Notification must never be able to turn a working purge | |
| 5348 | + // into a fatal, so anything the URL helpers do in an unusual context | |
| 5349 | + // (early boot, a drop-in, a bare test harness) is contained here | |
| 5350 | + // rather than propagating to the caller. | |
| 5351 | + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) { | |
| 5352 | + return; | |
| 5353 | + } | |
| 5354 | + try { | |
| 5355 | + self::dispatch_purge_event( | |
| 5356 | + 'xspeed_after_purge', | |
| 5357 | + array( | |
| 5358 | + 'url' => null, | |
| 5359 | + 'host' => self::current_purge_host(), | |
| 5360 | + 'path' => null, | |
| 5361 | + 'cause' => $cause, | |
| 5362 | + 'removed' => $removed, | |
| 5363 | + 'scope' => $scope, | |
| 5364 | + 'intent' => $intent, | |
| 5365 | + 'urls' => array(), | |
| 5366 | + ) | |
| 5367 | + ); | |
| 5368 | + } catch ( \Throwable $e ) { | |
| 5369 | + self::log_purge_listener_error( 'xspeed_after_purge', $e ); | |
| 5370 | + } | |
| 5371 | + } | |
| 5372 | + | |
| 5373 | + /** | |
| 3816 | 5374 | * Log a partial purge so the drill-down behind "Last purge" shows every |
| 3817 | 5375 | * clear, not only the full ones. Without this a site whose object cache |
| 3818 | 5376 | * is flushed on a schedule looks, from the log, like nothing happens. |
| 3819 | 5377 | * |
| @@ -3863,8 +5421,24 @@ | ||
| 3863 | 5421 | * Returns the number of .html files removed so purge stats stay accurate |
| 3864 | 5422 | * across the flat + static caches — .br siblings are not counted |
| 3865 | 5423 | * (they're encodings of a page, not pages). |
| 3866 | 5424 | */ |
| 5425 | + /** | |
| 5426 | + * Delete a cache file, adding its size to the current sweep's byte | |
| 5427 | + * total. filesize() is silenced and re-checked because the file can | |
| 5428 | + * vanish between the glob and the unlink — a concurrent purge, or the | |
| 5429 | + * cache GC — and a warning there would be noise, not news. | |
| 5430 | + * | |
| 5431 | + * @param string $file Absolute path inside the cache tree. | |
| 5432 | + */ | |
| 5433 | + private static function sweep_delete( string $file ): void { | |
| 5434 | + $size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the file may be gone already; see docblock. | |
| 5435 | + if ( is_int( $size ) ) { | |
| 5436 | + self::$sweep_bytes += $size; | |
| 5437 | + } | |
| 5438 | + wp_delete_file( $file ); | |
| 5439 | + } | |
| 5440 | + | |
| 3867 | 5441 | private static function rmtree_html( string $dir ): int { |
| 3868 | 5442 | if ( ! is_dir( $dir ) ) { |
| 3869 | 5443 | return 0; |
| 3870 | 5444 | } |
| @@ -3888,9 +5462,9 @@ | ||
| 3888 | 5462 | @rmdir( $path ); |
| 3889 | 5463 | continue; |
| 3890 | 5464 | } |
| 3891 | 5465 | if ( substr( $entry, -5 ) === '.html' ) { |
| 3892 | - wp_delete_file( $path ); | |
| 5466 | + self::sweep_delete( $path ); | |
| 3893 | 5467 | ++$removed; |
| 3894 | 5468 | } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) { |
| 3895 | 5469 | // Precompressed sibling (index.html.br) and the record of its |
| 3896 | 5470 | // length. Remove both so a purge doesn't orphan stale Brotli |
| @@ -3895,9 +5469,9 @@ | ||
| 3895 | 5469 | // Precompressed sibling (index.html.br) and the record of its |
| 3896 | 5470 | // length. Remove both so a purge doesn't orphan stale Brotli |
| 3897 | 5471 | // bodies, or a size record that would later be read against a |
| 3898 | 5472 | // different sibling's bytes. Not counted. |
| 3899 | - wp_delete_file( $path ); | |
| 5473 | + self::sweep_delete( $path ); | |
| 3900 | 5474 | } |
| 3901 | 5475 | } |
| 3902 | 5476 | return $removed; |
| 3903 | 5477 | } |
| @@ -3994,8 +5568,25 @@ | ||
| 3994 | 5568 | // True when an edge cache (Cloudflare) fronts the origin, so hits are |
| 3995 | 5569 | // absorbed before reaching PHP. The dashboard labels the ratio |
| 3996 | 5570 | // "origin-layer only" instead of implying it's the full picture. (#118) |
| 3997 | 5571 | 'edge_cache' => self::edge_cache_detected(), |
| 5572 | + // LiteSpeed Static Fast Path (#509): the web server serves hits | |
| 5573 | + // with no PHP, no way to tag them, and no way to count them. The | |
| 5574 | + // dashboard labels the ratio as PHP-layer only so a low number | |
| 5575 | + // reads as the trade the user chose, not a fault. | |
| 5576 | + // | |
| 5577 | + // rewrite_installed() is part of the condition (QA on #513): when | |
| 5578 | + // the .htaccess write failed (read-only file), hits still take | |
| 5579 | + // the drop-in path and ARE counted — the disclosure would be the | |
| 5580 | + // opposite of the truth. Health carries the "block missing" | |
| 5581 | + // warning for that state; this flag only speaks when static | |
| 5582 | + // serving is genuinely in effect. | |
| 5583 | + 'static_hits_uncounted' => ( | |
| 5584 | + Server::LITESPEED === Server::type() | |
| 5585 | + && ! empty( Settings::get()['cache_enabled'] ) | |
| 5586 | + && self::static_rewrite_allowed() | |
| 5587 | + && self::rewrite_installed() | |
| 5588 | + ), | |
| 3998 | 5589 | /* |
| 3999 | 5590 | * Whether the page cache is actually SERVING, as opposed to |
| 4000 | 5591 | * switched on in settings. The hero read the setting alone and |
| 4001 | 5592 | * announced "Active — serving cached HTML"; a site whose |
| @@ -4011,14 +5602,46 @@ | ||
| 4011 | 5602 | * that state — the detector sweep behind it is far more work than |
| 4012 | 5603 | * a stats call should do on an ordinary healthy site. |
| 4013 | 5604 | */ |
| 4014 | 5605 | 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) ) |
| 4015 | - ? self::acquisition_blocker() | |
| 5606 | + ? ( self::acquisition_blocker() ?? self::not_serving_reason() ) | |
| 4016 | 5607 | : null, |
| 4017 | 5608 | ); |
| 4018 | 5609 | } |
| 4019 | 5610 | |
| 4020 | 5611 | /** |
| 5612 | + * Why the cache is not serving, when nothing REFUSES to enable it. | |
| 5613 | + * | |
| 5614 | + * acquisition_blocker() answers "may we take the field", and since a | |
| 5615 | + * foreign drop-in became takeable it answers null on a site where another | |
| 5616 | + * plugin is nonetheless holding that file. Intent and outcome still | |
| 5617 | + * disagree there, and the dashboard was left reporting the symptom -- not | |
| 5618 | + * serving -- with no reason under it, which is exactly the state a user | |
| 5619 | + * cannot act on. | |
| 5620 | + * | |
| 5621 | + * So this names the holder and says what to do: enabling takes it over. | |
| 5622 | + */ | |
| 5623 | + private static function not_serving_reason(): ?string { | |
| 5624 | + $owner = self::dropin_owner(); | |
| 5625 | + if ( self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner ) { | |
| 5626 | + return null; | |
| 5627 | + } | |
| 5628 | + | |
| 5629 | + if ( self::DROPIN_UNREADABLE === $owner ) { | |
| 5630 | + return __( 'advanced-cache.php cannot be read, so xSpeed cannot tell whose page cache is installed.', 'xspeed' ); | |
| 5631 | + } | |
| 5632 | + | |
| 5633 | + $label = Page_Cache_Detector::dropin_owner_label(); | |
| 5634 | + return $label | |
| 5635 | + ? sprintf( | |
| 5636 | + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */ | |
| 5637 | + __( '%s is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ), | |
| 5638 | + $label | |
| 5639 | + ) | |
| 5640 | + : __( 'Another plugin is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ); | |
| 5641 | + } | |
| 5642 | + | |
| 5643 | + /** | |
| 4021 | 5644 | * Whether the current request should be kept OUT of the cache hit/miss |
| 4022 | 5645 | * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect |
| 4023 | 5646 | * time, so is_404() is resolved. (#118) |
| 4024 | 5647 | */ |
| @@ -4032,16 +5655,20 @@ | ||
| 4032 | 5655 | return Hit_Counter::is_bot_ua( $ua ); |
| 4033 | 5656 | } |
| 4034 | 5657 | |
| 4035 | 5658 | /** |
| 4036 | - * Whether an edge cache fronts this origin. Today: the Cloudflare | |
| 4037 | - * integration is connected — so an unknown share of hits is served at the | |
| 4038 | - * edge and never counted here, making the origin ratio a partial view the | |
| 4039 | - * dashboard must label as such. (#118) | |
| 5659 | + * Whether an edge cache fronts this origin, so an unknown share of hits | |
| 5660 | + * is served there and never counted here — which makes the origin ratio a | |
| 5661 | + * partial view the dashboard has to label as such. (#118) | |
| 5662 | + * | |
| 5663 | + * This used to mean "the Cloudflare module is switched on", which answered | |
| 5664 | + * no for every site fronted by anything else, and no for a site on | |
| 5665 | + * Cloudflare that had never opened our Cloudflare panel. Both of those | |
| 5666 | + * sites had their ratio presented as the whole story. Edge_Provider knows | |
| 5667 | + * better and knows it per request, so ask it. | |
| 4040 | 5668 | */ |
| 4041 | 5669 | private static function edge_cache_detected(): bool { |
| 4042 | - $cf = get_option( 'xspeed_module_cloudflare', array() ); | |
| 4043 | - return is_array( $cf ) && ! empty( $cf['enabled'] ); | |
| 5670 | + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence']; | |
| 4044 | 5671 | } |
| 4045 | 5672 | |
| 4046 | 5673 | /** |
| 4047 | 5674 | * Apply the user's enable/disable choice. Called from the REST toggle |
| @@ -4072,9 +5699,9 @@ | ||
| 4072 | 5699 | * wp_config_writable: bool, |
| 4073 | 5700 | * manual_snippet: ?string |
| 4074 | 5701 | * } |
| 4075 | 5702 | */ |
| 4076 | - public static function toggle( $enable ) { | |
| 5703 | + public static function toggle( $enable, bool $consented = true ) { | |
| 4077 | 5704 | Page_Cache_Detector::invalidate(); |
| 4078 | 5705 | $expected = Page_Cache_Detector::inspect()['revision']; |
| 4079 | 5706 | /** Diagnostic seam; changing the expected revision can only force a safe refusal. */ |
| 4080 | 5707 | $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected ); |
| @@ -4087,9 +5714,9 @@ | ||
| 4087 | 5714 | $fresh = Page_Cache_Detector::inspect()['revision']; |
| 4088 | 5715 | if ( ! hash_equals( (string) $expected, (string) $fresh ) ) { |
| 4089 | 5716 | return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) ); |
| 4090 | 5717 | } |
| 4091 | - $state = self::toggle_unlocked( (bool) $enable ); | |
| 5718 | + $state = self::toggle_unlocked( (bool) $enable, $consented ); | |
| 4092 | 5719 | return $state; |
| 4093 | 5720 | } finally { |
| 4094 | 5721 | flock( $lock, LOCK_UN ); |
| 4095 | 5722 | fclose( $lock ); |
| @@ -4096,9 +5723,14 @@ | ||
| 4096 | 5723 | } |
| 4097 | 5724 | } |
| 4098 | 5725 | |
| 4099 | 5726 | /** Run the page-cache mutation while toggle() owns the scoped lock. */ |
| 4100 | - private static function toggle_unlocked( bool $enable ) { | |
| 5727 | + /** | |
| 5728 | + * @param bool $consented The user asked for this in the dashboard, so a | |
| 5729 | + * foreign drop-in may be taken over. False on the | |
| 5730 | + * unattended paths, which stand down instead. | |
| 5731 | + */ | |
| 5732 | + private static function toggle_unlocked( bool $enable, bool $consented = true ) { | |
| 4101 | 5733 | $enable = (bool) $enable; |
| 4102 | 5734 | |
| 4103 | 5735 | if ( $enable ) { |
| 4104 | 5736 | /* |
| @@ -4131,8 +5763,37 @@ | ||
| 4131 | 5763 | * on exactly the healthy sites this branch is about. |
| 4132 | 5764 | */ |
| 4133 | 5765 | $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner(); |
| 4134 | 5766 | $blocker = $reasserting ? null : self::acquisition_blocker(); |
| 5767 | + | |
| 5768 | + /* | |
| 5769 | + * Taking over another plugin's drop-in needs the user to have | |
| 5770 | + * asked for it. On the dashboard they did -- they clicked the | |
| 5771 | + * switch, having been told whose file it is. The UNATTENDED | |
| 5772 | + * callers have no such click: restore_dropin_if_enabled() runs | |
| 5773 | + * after a plugin update and auto_heal() on an admin page load, | |
| 5774 | + * both from nothing more than `cache_enabled` still being true. | |
| 5775 | + * | |
| 5776 | + * A competitor installed since that flag was set would have its | |
| 5777 | + * page cache seized by a background repair, which is the silent | |
| 5778 | + * acquisition this plugin refuses to perform. So those callers | |
| 5779 | + * pass $consented = false and stand down instead. | |
| 5780 | + */ | |
| 5781 | + if ( null === $blocker && ! $consented && self::DROPIN_FOREIGN === self::dropin_owner() ) { | |
| 5782 | + // Name the owner. This string is rendered by host plugins | |
| 5783 | + // through Host::enable_page_cache(), and an unnamed refusal | |
| 5784 | + // is what made every host invent its own explanation. | |
| 5785 | + $owner_label = Page_Cache_Detector::dropin_owner_label(); | |
| 5786 | + return self::blocked_toggle_state( | |
| 5787 | + $owner_label | |
| 5788 | + ? sprintf( | |
| 5789 | + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */ | |
| 5790 | + __( '%s owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ), | |
| 5791 | + $owner_label | |
| 5792 | + ) | |
| 5793 | + : __( 'Another plugin owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ) | |
| 5794 | + ); | |
| 5795 | + } | |
| 4135 | 5796 | if ( null !== $blocker ) { |
| 4136 | 5797 | Activity_Log::record( |
| 4137 | 5798 | 'cache_enable_blocked', |
| 4138 | 5799 | 'Cache not enabled — ' . $blocker, |
| @@ -4604,10 +6265,11 @@ | ||
| 4604 | 6265 | * they don't share a user at all. A default-umask 0644 file is then |
| 4605 | 6266 | * unwritable by nginx, the access_log write silently fails, and the |
| 4606 | 6267 | * dashboard shows a 0% hit ratio even though static HITs are serving. |
| 4607 | 6268 | * So we widen the dir to 0777 and the file to 0666 — group/other write — |
| 4608 | - * so whatever uid nginx runs as can append. (The file holds only HIT | |
| 4609 | - * request lines, no secrets.) | |
| 6269 | + * so whatever uid nginx runs as can append. The file holds HIT request | |
| 6270 | + * lines and must be protected like an access log: paths and queries can | |
| 6271 | + * contain sensitive values. | |
| 4610 | 6272 | */ |
| 4611 | 6273 | /** |
| 4612 | 6274 | * Directory holding the nginx hit log. Lives under uploads/, NOT the |
| 4613 | 6275 | * cache dir — uninstall.php and a cache purge both delete the cache |
| @@ -4745,10 +6407,22 @@ | ||
| 4745 | 6407 | */ |
| 4746 | 6408 | public static function sync_query_allowlist(): void { |
| 4747 | 6409 | $file = XSPEED_CACHE_DIR . '/.ignored-query-params'; |
| 4748 | 6410 | |
| 4749 | - $opts = Settings_Manager::get( 'cache' ); | |
| 4750 | - $ignored = is_array( $opts['ignored_query_params'] ?? null ) ? $opts['ignored_query_params'] : array(); | |
| 6411 | + /* | |
| 6412 | + * Stored read, not Settings_Manager::get() — this runs from boot(), | |
| 6413 | + * before translation is legal (see stored_cache_opts()). | |
| 6414 | + * | |
| 6415 | + * A raw read applies no schema defaults, and this field's default is a | |
| 6416 | + * long tracking-parameter list, NOT empty. Falling back to array() | |
| 6417 | + * would strip that whole allow-list from the drop-in on any install | |
| 6418 | + * that has never saved the Cache panel. So fall back to the schema's | |
| 6419 | + * own default, read from the module without building its labels. | |
| 6420 | + */ | |
| 6421 | + $opts = self::stored_cache_opts(); | |
| 6422 | + $ignored = is_array( $opts['ignored_query_params'] ?? null ) | |
| 6423 | + ? $opts['ignored_query_params'] | |
| 6424 | + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_IGNORED_QUERY_PARAMS; | |
| 4751 | 6425 | |
| 4752 | 6426 | $parts = array(); |
| 4753 | 6427 | foreach ( $ignored as $pattern ) { |
| 4754 | 6428 | $pattern = trim( (string) $pattern ); |
| @@ -4799,12 +6473,54 @@ | ||
| 4799 | 6473 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- read by the pre-WP drop-in; WP_Filesystem needs admin credentials unavailable here. |
| 4800 | 6474 | file_put_contents( $file, $payload, LOCK_EX ); |
| 4801 | 6475 | } |
| 4802 | 6476 | |
| 6477 | + /** | |
| 6478 | + * CacheModule's STORED settings, read straight from the option. | |
| 6479 | + * | |
| 6480 | + * `Settings_Manager::get( 'cache' )` builds CacheModule's settings schema, | |
| 6481 | + * whose labels are declared through `__()`. The reconcile chain below runs | |
| 6482 | + * from `CacheModule::boot()` on `plugins_loaded` — before | |
| 6483 | + * `after_setup_theme`, the point WordPress 6.7+ treats as safe to | |
| 6484 | + * translate — so going through the schema there fires | |
| 6485 | + * `_load_textdomain_just_in_time` on every request AND resolves the labels | |
| 6486 | + * against a domain that is not loaded yet. | |
| 6487 | + * | |
| 6488 | + * The callers here need stored values, not schema metadata, so a raw read | |
| 6489 | + * is equivalent. It applies NO defaults or coercion: read each key with a | |
| 6490 | + * fallback matching the schema's own default. | |
| 6491 | + * | |
| 6492 | + * @return array<string,mixed> | |
| 6493 | + */ | |
| 6494 | + private static function stored_cache_opts(): array { | |
| 6495 | + $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() ); | |
| 6496 | + return is_array( $stored ) ? $stored : array(); | |
| 6497 | + } | |
| 6498 | + | |
| 6499 | + /** | |
| 6500 | + * Strict truthiness for the LiteSpeed Static Fast Path opt-in. | |
| 6501 | + * | |
| 6502 | + * On non-LiteSpeed servers the key is out of the schema and carried by | |
| 6503 | + * preserved_keys(), so a REST/MCP write lands VERBATIM — QA on #513 | |
| 6504 | + * stored the string "false" on Apache and the fast path installed | |
| 6505 | + * itself the moment the site moved to LiteSpeed, because | |
| 6506 | + * empty("false") is false. Only an explicit, unambiguous "yes" may | |
| 6507 | + * enable a path that trades away hit tagging; any other value — | |
| 6508 | + * "false", "no", arbitrary junk — stays OFF, which is the default the | |
| 6509 | + * user never left. | |
| 6510 | + */ | |
| 6511 | + private static function litespeed_optin_enabled( $value ): bool { | |
| 6512 | + if ( true === $value || 1 === $value ) { | |
| 6513 | + return true; | |
| 6514 | + } | |
| 6515 | + return is_string( $value ) | |
| 6516 | + && in_array( strtolower( trim( $value ) ), array( '1', 'true', 'on', 'yes' ), true ); | |
| 6517 | + } | |
| 6518 | + | |
| 4803 | 6519 | public static function sync_mobile_flag( $enabled = null ): void { |
| 4804 | 6520 | if ( null === $enabled ) { |
| 4805 | - $opts = Settings_Manager::get( 'cache' ); | |
| 4806 | - $enabled = ! empty( $opts['mobile_separate'] ); | |
| 6521 | + $stored = self::stored_cache_opts(); | |
| 6522 | + $enabled = ! empty( $stored['mobile_separate'] ); | |
| 4807 | 6523 | } |
| 4808 | 6524 | $dir = XSPEED_CACHE_DIR; |
| 4809 | 6525 | $flag = $dir . '/.mobile-separate'; |
| 4810 | 6526 | if ( $enabled ) { |
| @@ -4908,9 +6624,10 @@ | ||
| 4908 | 6624 | // Read the setting from the SAME place static_rewrite_allowed() and |
| 4909 | 6625 | // sync_mobile_flag() do — the cache module's settings, not the |
| 4910 | 6626 | // top-level xspeed_options — or this marker would track a key that |
| 4911 | 6627 | // never changes and a real flip would go unnoticed. |
| 4912 | - $cache_opts = Settings_Manager::get( 'cache' ); | |
| 6628 | + // Stored read — this runs from boot(); see stored_cache_opts(). | |
| 6629 | + $cache_opts = self::stored_cache_opts(); | |
| 4913 | 6630 | $mobile_now = ! empty( $cache_opts['mobile_separate'] ); |
| 4914 | 6631 | $mobile_last = get_option( 'xspeed_last_mobile_separate', null ); |
| 4915 | 6632 | $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now ); |
| 4916 | 6633 | |
| @@ -4972,12 +6689,24 @@ | ||
| 4972 | 6689 | * the truth there. (Apache keeps the static fast path — it honors the |
| 4973 | 6690 | * header.) See maybe_emit_lscache_headers() for the paired LSCache |
| 4974 | 6691 | * stand-down that stops LiteSpeed's own module from shadowing the |
| 4975 | 6692 | * drop-in. |
| 6693 | + * | |
| 6694 | + * Opt-in (#509): `litespeed_static_rewrite` re-enables the fast path on | |
| 6695 | + * LiteSpeed for users who value raw TTFB over hit accounting. The trade | |
| 6696 | + * is stated in the setting's copy: statically served hits carry no | |
| 6697 | + * X-XSpeed-Cache header and are not counted (LiteSpeed logs the | |
| 6698 | + * original request line, so even the access-log scan cannot see | |
| 6699 | + * them — see Hit_Counter::collect_server_log_hits()). The drop-in | |
| 6700 | + * default above stays — nobody is surprised into an unverifiable cache. | |
| 4976 | 6701 | */ |
| 4977 | 6702 | public static function static_rewrite_allowed(): bool { |
| 4978 | - // LiteSpeed: drop-in serves hits (visible + counted) — see docblock. | |
| 4979 | - if ( Server::LITESPEED === Server::type() ) { | |
| 6703 | + // Stored read — reached from boot(); see stored_cache_opts(). | |
| 6704 | + $opts = self::stored_cache_opts(); | |
| 6705 | + // LiteSpeed: drop-in serves hits (visible + counted) unless the user | |
| 6706 | + // explicitly opted into the static fast path — see docblock. | |
| 6707 | + if ( Server::LITESPEED === Server::type() | |
| 6708 | + && ! self::litespeed_optin_enabled( $opts['litespeed_static_rewrite'] ?? false ) ) { | |
| 4980 | 6709 | return false; |
| 4981 | 6710 | } |
| 4982 | 6711 | // Apache without mod_headers is in EXACTLY the position LiteSpeed |
| 4983 | 6712 | // is in above: it can run the RewriteRule and serve the static |
| @@ -4991,9 +6720,8 @@ | ||
| 4991 | 6720 | // pinned at 0% on a working Apache cache.) |
| 4992 | 6721 | if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { |
| 4993 | 6722 | return false; |
| 4994 | 6723 | } |
| 4995 | - $opts = Settings_Manager::get( 'cache' ); | |
| 4996 | 6724 | return empty( $opts['mobile_separate'] ); |
| 4997 | 6725 | } |
| 4998 | 6726 | |
| 4999 | 6727 | /** |
| @@ -5111,8 +6839,10 @@ | ||
| 5111 | 6839 | case 'mobile_separate': |
| 5112 | 6840 | return 'Separate Mobile Cache is on, which disables the device-blind static rewrite. Cache hits are served by PHP instead. If your site serves the same HTML to every device, turn it off in Cache settings for much faster hits.'; |
| 5113 | 6841 | case 'no_mod_headers': |
| 5114 | 6842 | return "Apache's mod_headers is not loaded, so the static rewrite cannot mark its responses as cache hits. Enable mod_headers, or leave hits on the PHP path."; |
| 6843 | + case 'litespeed_dropin': | |
| 6844 | + return 'On LiteSpeed, cache hits are served by the PHP drop-in so every hit is tagged X-XSpeed-Cache and counted in the hit ratio — LiteSpeed\'s .htaccess engine cannot do either for statically served files. If raw TTFB matters more to you than hit accounting, turn on LiteSpeed Static Fast Path in Cache settings to serve hits straight from the web server.'; | |
| 5115 | 6845 | case 'skipped_nonce': |
| 5116 | 6846 | return 'The server config is correct, but pages are not reaching the static cache because they contain nonces, so hits are served by PHP instead. A static file is served with no PHP, so a nonce baked into one could never be refreshed and every anonymous form on the page would break once it expired — keeping these pages on PHP is deliberate. Nonces usually come from plugin widgets; disabling the ones the site does not use lets its pages be served statically again.'; |
| 5117 | 6847 | default: |
| 5118 | 6848 | return sprintf( 'The static rewrite is disabled (%s).', $code ); |
| @@ -5131,9 +6861,25 @@ | ||
| 5131 | 6861 | if ( empty( $opts['cache_enabled'] ) ) { |
| 5132 | 6862 | return ''; |
| 5133 | 6863 | } |
| 5134 | 6864 | if ( Server::LITESPEED === Server::type() ) { |
| 5135 | - return ''; // Intended on LiteSpeed — not a "block". | |
| 6865 | + // The opt-in is read RAW (stored_cache_opts), not through | |
| 6866 | + // Settings_Manager::get(): the schema's bool coercion is a PHP | |
| 6867 | + // cast, and (bool) "false" is true — so a junk string stored on | |
| 6868 | + // another server (where the key bypasses the schema) would come | |
| 6869 | + // back from the coercion layer as an ENABLE. Raw + the strict | |
| 6870 | + // parse below is the same read static_rewrite_allowed() makes, | |
| 6871 | + // so the two can't disagree either. (QA on #513) | |
| 6872 | + $stored = self::stored_cache_opts(); | |
| 6873 | + // The intended default — but no longer silent: with the opt-in | |
| 6874 | + // off, Health must be able to explain the PHP path and point at | |
| 6875 | + // the toggle instead of falling through to "reinstall the block" | |
| 6876 | + // advice that cannot work here. (#509) | |
| 6877 | + if ( ! self::litespeed_optin_enabled( $stored['litespeed_static_rewrite'] ?? false ) ) { | |
| 6878 | + return 'litespeed_dropin'; | |
| 6879 | + } | |
| 6880 | + $cache_opts = Settings_Manager::get( 'cache' ); | |
| 6881 | + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : ''; | |
| 5136 | 6882 | } |
| 5137 | 6883 | if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { |
| 5138 | 6884 | return 'no_mod_headers'; |
| 5139 | 6885 | } |
| @@ -5497,11 +7243,16 @@ | ||
| 5497 | 7243 | // while a page was cold — on a warm page nginx served the shared |
| 5498 | 7244 | // anonymous copy to carts, members and bypassed bots alike. The |
| 5499 | 7245 | // three historical names survive as a floor inside cookie_rule(). |
| 5500 | 7246 | // `~*` is case-insensitive, matching PHP's stripos()/glob checks. |
| 5501 | - $cache_opts = Settings_Manager::get( 'cache' ); | |
| 7247 | + // Stored read — reached from boot(); see stored_cache_opts(). The | |
| 7248 | + // fallbacks below mirror the schema's own defaults, which a raw read | |
| 7249 | + // does not apply. | |
| 7250 | + $cache_opts = self::stored_cache_opts(); | |
| 5502 | 7251 | $cookie_rule = Server_Rules::cookie_rule( |
| 5503 | - is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array() | |
| 7252 | + is_array( $cache_opts['excluded_cookies'] ?? null ) | |
| 7253 | + ? $cache_opts['excluded_cookies'] | |
| 7254 | + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXCLUDED_COOKIES | |
| 5504 | 7255 | ); |
| 5505 | 7256 | $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }'; |
| 5506 | 7257 | |
| 5507 | 7258 | $ua_rule = Server_Rules::user_agent_rule( |
| @@ -5562,8 +7313,24 @@ | ||
| 5562 | 7313 | // missing. So: hits are logged, and a user deleting the log can't take |
| 5563 | 7314 | // nginx down. |
| 5564 | 7315 | $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;'; |
| 5565 | 7316 | $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;'; |
| 7317 | + // Edge/CDN headers from the same seam the drop-in bakes. nginx serves | |
| 7318 | + // this path without ever starting PHP, so the answer cannot be | |
| 7319 | + // resolved per request — the pairs are resolved HERE, when the | |
| 7320 | + // snippet is generated, and a change of answer needs the snippet | |
| 7321 | + // regenerated and re-pasted to take effect. | |
| 7322 | + // | |
| 7323 | + // Skipped entirely when the static path is switched off. The only | |
| 7324 | + // reason that can fire under `bake` is mobile-split, and mobile-split | |
| 7325 | + // is also what switches the static path off — so the block would be | |
| 7326 | + // baked with a hold it can never serve, and would start serving it | |
| 7327 | + // the moment the setting is turned off and static files reappear, | |
| 7328 | + // until somebody regenerates and re-pastes. A rule that can only be | |
| 7329 | + // served once its premise is false is guaranteed to be stale. | |
| 7330 | + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $name => $value ) { | |
| 7331 | + $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;'; | |
| 7332 | + } | |
| 5566 | 7333 | $lines[] = '}'; |
| 5567 | 7334 | return implode( "\n", $lines ); |
| 5568 | 7335 | } |
| 5569 | 7336 | |
| @@ -5681,9 +7448,9 @@ | ||
| 5681 | 7448 | if ( empty( $opts['cache_enabled'] ) ) { |
| 5682 | 7449 | return false; |
| 5683 | 7450 | } |
| 5684 | 7451 | |
| 5685 | - $state = self::toggle( true ); | |
| 7452 | + $state = self::toggle( true, false ); | |
| 5686 | 7453 | // A refusal reports whether the cache SERVES, which on this path can |
| 5687 | 7454 | // be true for reasons that have nothing to do with this call — so a |
| 5688 | 7455 | // refusal would otherwise log "drop-in restored" for a restore that |
| 5689 | 7456 | // was declined. Restored means the transaction went through. |
| @@ -5723,9 +7490,9 @@ | ||
| 5723 | 7490 | if ( empty( $opts['cache_enabled'] ) ) { |
| 5724 | 7491 | return; |
| 5725 | 7492 | } |
| 5726 | 7493 | |
| 5727 | - $state = self::toggle( true ); | |
| 7494 | + $state = self::toggle( true, false ); | |
| 5728 | 7495 | // A refusal means something else now owns the page-cache field, or |
| 5729 | 7496 | // the write could not be verified. Either way this is not the moment |
| 5730 | 7497 | // to go on maintaining our rewrite block and log file. |
| 5731 | 7498 | if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) { |
| @@ -5856,9 +7623,9 @@ | ||
| 5856 | 7623 | // so the closing quote here cannot be escaped away. |
| 5857 | 7624 | $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]'; |
| 5858 | 7625 | } |
| 5859 | 7626 | |
| 5860 | - return array_merge( | |
| 7627 | + $block = array_merge( | |
| 5861 | 7628 | $lines, |
| 5862 | 7629 | array( |
| 5863 | 7630 | // Capture REQUEST_URI without its trailing slash into %1. |
| 5864 | 7631 | // store_static() writes `{host}{uri-without-trailing-slash}/index.html`, |
| @@ -5876,9 +7643,9 @@ | ||
| 5876 | 7643 | // `^` matches the empty string AND any non-empty path, so it |
| 5877 | 7644 | // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed |
| 5878 | 7645 | // 1.8: `.` → homepage served by PHP drop-in; `^` → served |
| 5879 | 7646 | // directly from the static file.) |
| 5880 | - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]', | |
| 7647 | + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]', | |
| 5881 | 7648 | '</IfModule>', |
| 5882 | 7649 | // Mark the statically-served response as a cache HIT. |
| 5883 | 7650 | // |
| 5884 | 7651 | // A file served by the rewrite above bypasses PHP entirely, so |
| @@ -5900,11 +7667,32 @@ | ||
| 5900 | 7667 | '<IfModule mod_headers.c>', |
| 5901 | 7668 | ' <FilesMatch "\\.html$">', |
| 5902 | 7669 | ' Header always set X-XSpeed-Cache "HIT (static)"', |
| 5903 | 7670 | ' </FilesMatch>', |
| 5904 | - '</IfModule>', | |
| 5905 | 7671 | ) |
| 5906 | 7672 | ); |
| 7673 | + | |
| 7674 | + // Edge/CDN headers from the same seam the drop-in bakes. Like the | |
| 7675 | + // nginx snippet, the static rewrite answers without PHP, so the pairs | |
| 7676 | + // are resolved when the block is GENERATED rather than per request. | |
| 7677 | + // | |
| 7678 | + // `env=` rather than the `<FilesMatch>` scoping above, because these | |
| 7679 | + // must ride only on responses the rewrite produced. The marker header | |
| 7680 | + // stays filename-scoped: it is inert, and narrowing it would change a | |
| 7681 | + // header QA reads. | |
| 7682 | + // Same reasoning as the nginx snippet: a bake hold can only come from | |
| 7683 | + // mobile-split, and mobile-split is what turns this path off. | |
| 7684 | + $edge_lines = array(); | |
| 7685 | + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $edge_name => $edge_value ) { | |
| 7686 | + $edge_lines = array_merge( | |
| 7687 | + $edge_lines, | |
| 7688 | + self::static_hit_directives( | |
| 7689 | + ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"' | |
| 7690 | + ) | |
| 7691 | + ); | |
| 7692 | + } | |
| 7693 | + | |
| 7694 | + return array_merge( $block, $edge_lines, array( '</IfModule>' ) ); | |
| 5907 | 7695 | } |
| 5908 | 7696 | |
| 5909 | 7697 | /** |
| 5910 | 7698 | * Active probe that confirms the web-server static-rewrite path is |
| @@ -5959,9 +7747,12 @@ | ||
| 5959 | 7747 | |
| 5960 | 7748 | $home = home_url( '/' ); |
| 5961 | 7749 | $host = (string) wp_parse_url( $home, PHP_URL_HOST ); |
| 5962 | 7750 | if ( '' === $host ) { |
| 5963 | - $result = array( 'active' => false, 'reason' => 'home_url has no host' ); | |
| 7751 | + // Environmental failure, not evidence the server config is wrong — | |
| 7752 | + // mark it inconclusive so Health surfaces say "could not verify" | |
| 7753 | + // instead of demanding a snippet paste. (#480) | |
| 7754 | + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'home_url has no host' ); | |
| 5964 | 7755 | set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 5965 | 7756 | return $result; |
| 5966 | 7757 | } |
| 5967 | 7758 | |
| @@ -5978,9 +7769,12 @@ | ||
| 5978 | 7769 | if ( ! file_exists( $probe_dir ) ) { |
| 5979 | 7770 | wp_mkdir_p( $probe_dir ); |
| 5980 | 7771 | } |
| 5981 | 7772 | if ( ! is_dir( $probe_dir ) ) { |
| 5982 | - $result = array( 'active' => false, 'reason' => 'cannot create probe dir' ); | |
| 7773 | + // A cache-dir permissions problem — the probe never ran, so this | |
| 7774 | + // says nothing about the nginx config. Inconclusive, not | |
| 7775 | + // "required". (#480) | |
| 7776 | + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'cannot create probe dir' ); | |
| 5983 | 7777 | set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 5984 | 7778 | return $result; |
| 5985 | 7779 | } |
| 5986 | 7780 | // 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. |
| @@ -6245,8 +8039,14 @@ | ||
| 6245 | 8039 | /** No drop-in installed. */ |
| 6246 | 8040 | public const DROPIN_NONE = 'none'; |
| 6247 | 8041 | /** A drop-in is installed and we could not read it. */ |
| 6248 | 8042 | public const DROPIN_UNREADABLE = 'unreadable'; |
| 8043 | + /** | |
| 8044 | + * Present but holding nothing -- empty, or whitespace only. WP Rocket | |
| 8045 | + * truncates advanced-cache.php to 0 bytes on deactivate, and calling that | |
| 8046 | + * FOREIGN made it a permanent blocker with no owner to ask. (#391) | |
| 8047 | + */ | |
| 8048 | + public const DROPIN_ABANDONED = 'abandoned'; | |
| 6249 | 8049 | |
| 6250 | 8050 | /** |
| 6251 | 8051 | * Who owns wp-content/advanced-cache.php right now. |
| 6252 | 8052 | * |
| @@ -6269,11 +8069,45 @@ | ||
| 6269 | 8069 | if ( null === $contents ) { |
| 6270 | 8070 | return self::DROPIN_UNREADABLE; |
| 6271 | 8071 | } |
| 6272 | 8072 | |
| 6273 | - return xspeed_has_canonical_dropin_signature( $contents ) | |
| 6274 | - ? self::DROPIN_XSPEED | |
| 6275 | - : self::DROPIN_FOREIGN; | |
| 8073 | + if ( xspeed_has_canonical_dropin_signature( $contents ) ) { | |
| 8074 | + return self::DROPIN_XSPEED; | |
| 8075 | + } | |
| 8076 | + | |
| 8077 | + // Nothing in the file means nothing owns it. Kept distinct from | |
| 8078 | + // FOREIGN so the acquisition gate can tell "someone else's cache" from | |
| 8079 | + // "a husk the last plugin left behind". (#391) | |
| 8080 | + if ( '' === trim( $contents ) ) { | |
| 8081 | + return self::DROPIN_ABANDONED; | |
| 8082 | + } | |
| 8083 | + | |
| 8084 | + /* | |
| 8085 | + * The other half of the same question, and it cannot be answered from | |
| 8086 | + * the bytes: a file we cannot attribute is a COMPETITOR only while | |
| 8087 | + * some page cache is actually running. With every candidate switched | |
| 8088 | + * off it is abandoned -- a hosting company's own cache, a hand-rolled | |
| 8089 | + * one, or a plugin that was deleted without cleaning up. | |
| 8090 | + * | |
| 8091 | + * Asking the detector rather than re-deriving it here is the point: | |
| 8092 | + * these two answers disagreeing is a split brain with a bad ending -- | |
| 8093 | + * acquisition_blocker() opens the gate, install_dropin() then refuses | |
| 8094 | + * on FOREIGN, and toggle() blames the filesystem for a write it never | |
| 8095 | + * attempted. One question, one answer. (#391, #393) | |
| 8096 | + */ | |
| 8097 | + if ( class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) { | |
| 8098 | + $owner = (string) ( Page_Cache_Detector::inspect()['dropin']['owner'] ?? '' ); | |
| 8099 | + | |
| 8100 | + // Attributable to a named plugin -> somebody's cache, whatever its | |
| 8101 | + // activation state. Only a file NOBODY can be shown to own, with | |
| 8102 | + // nothing running, is abandoned. | |
| 8103 | + if ( Page_Cache_Detector::OWNER_UNKNOWN === $owner | |
| 8104 | + && ! Page_Cache_Detector::another_page_cache_is_active() ) { | |
| 8105 | + return self::DROPIN_ABANDONED; | |
| 8106 | + } | |
| 8107 | + } | |
| 8108 | + | |
| 8109 | + return self::DROPIN_FOREIGN; | |
| 6276 | 8110 | } |
| 6277 | 8111 | |
| 6278 | 8112 | /** |
| 6279 | 8113 | * Why xSpeed must not install its page-cache artifacts right now, or null |
| @@ -6327,8 +8161,45 @@ | ||
| 6327 | 8161 | if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) { |
| 6328 | 8162 | continue; |
| 6329 | 8163 | } |
| 6330 | 8164 | /* |
| 8165 | + * Another plugin's drop-in is no longer a refusal. | |
| 8166 | + * | |
| 8167 | + * It used to be: whoever held advanced-cache.php kept it, and | |
| 8168 | + * enabling was blocked with "deactivate its page cache first". | |
| 8169 | + * That left a user who had asked for our cache with no way to get | |
| 8170 | + * it — on a live site the only exit was deleting a file over SSH, | |
| 8171 | + * and the message could not even say which of its two causes | |
| 8172 | + * applied ("is active OR owns advanced-cache.php"). | |
| 8173 | + * | |
| 8174 | + * Turning the page cache on is the instruction to serve pages | |
| 8175 | + * from cache, and that is not possible without this file. So we | |
| 8176 | + * take it, and the dashboard says whose file it is first — | |
| 8177 | + * dropin_disclosure() names the owner, the user confirms, and | |
| 8178 | + * install_dropin() writes ours over the top. | |
| 8179 | + * | |
| 8180 | + * A still-active competitor is deliberately NOT re-added as a | |
| 8181 | + * blocker below: it is caught by `active_page_cache`, which the | |
| 8182 | + * capability rule already downgrades to a note. Two page caches | |
| 8183 | + * installed at once is the user's call to make, not ours to | |
| 8184 | + * refuse — they just told us which one they want serving. | |
| 8185 | + * | |
| 8186 | + * UNREADABLE is the exception and stays a refusal: we cannot name | |
| 8187 | + * what we would destroy, and install_dropin() refuses it too, so | |
| 8188 | + * opening the gate here would only produce a failed write. | |
| 8189 | + */ | |
| 8190 | + $about_dropin_owner = in_array( | |
| 8191 | + $code, | |
| 8192 | + array( | |
| 8193 | + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN, | |
| 8194 | + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN, | |
| 8195 | + ), | |
| 8196 | + true | |
| 8197 | + ); | |
| 8198 | + if ( $about_dropin_owner && self::DROPIN_UNREADABLE !== $owner ) { | |
| 8199 | + continue; | |
| 8200 | + } | |
| 8201 | + /* | |
| 6331 | 8202 | * Capability is not possession. `active_page_cache` and |
| 6332 | 8203 | * `multiple_page_caches` both fire on a plugin that merely CAN |
| 6333 | 8204 | * cache pages — the detector cannot prove a competitor's page |
| 6334 | 8205 | * cache is off, so it counts it. As a warning that is right. As |
| @@ -6355,9 +8226,25 @@ | ||
| 6355 | 8226 | Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES, |
| 6356 | 8227 | ), |
| 6357 | 8228 | true |
| 6358 | 8229 | ); |
| 6359 | - if ( $about_capability && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE ), true ) ) { | |
| 8230 | + /* | |
| 8231 | + * FOREIGN belongs in this list now, and it is the whole point. | |
| 8232 | + * | |
| 8233 | + * The rule is still "capability is not possession": these two | |
| 8234 | + * blockers fire on any plugin that CAN cache pages, which the | |
| 8235 | + * detector cannot prove is switched off. What changed is that a | |
| 8236 | + * competitor holding the drop-in no longer stops us either — we | |
| 8237 | + * take the file, having said whose it is. So there is nothing | |
| 8238 | + * left for a merely-installed competitor to protect, and keeping | |
| 8239 | + * the refusal here would put back the dead end by another route: | |
| 8240 | + * "another page cache is active" on a site where the user has | |
| 8241 | + * just told us, by name, which cache they want serving. | |
| 8242 | + * | |
| 8243 | + * UNREADABLE is deliberately still absent — that one refuses. | |
| 8244 | + */ | |
| 8245 | + if ( $about_capability | |
| 8246 | + && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE, self::DROPIN_FOREIGN, self::DROPIN_ABANDONED ), true ) ) { | |
| 6360 | 8247 | continue; |
| 6361 | 8248 | } |
| 6362 | 8249 | if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) { |
| 6363 | 8250 | $others = self::other_page_cache_names( $blocker ); |
| @@ -6561,14 +8448,22 @@ | ||
| 6561 | 8448 | return false; |
| 6562 | 8449 | } |
| 6563 | 8450 | |
| 6564 | 8451 | /* |
| 6565 | - * Ownership first, before any of the work below. WordPress gives every | |
| 6566 | - * caching plugin the same single file, so a drop-in that is not ours is | |
| 6567 | - * another plugin's live cache — refuse rather than replace it. | |
| 8452 | + * A drop-in we cannot READ is the one thing still refused here. Not | |
| 8453 | + * because of who owns it — we no longer refuse on ownership — but | |
| 8454 | + * because an unreadable file is usually a permissions problem, and | |
| 8455 | + * writing over it would fail anyway or destroy something we were | |
| 8456 | + * never able to look at. | |
| 8457 | + * | |
| 8458 | + * Everything else is ours to take. Enabling the page cache IS the | |
| 8459 | + * user's instruction to serve the cache, and serving it means holding | |
| 8460 | + * advanced-cache.php; the dashboard says whose file it is replacing | |
| 8461 | + * before the click (Page_Cache_Detector::dropin_disclosure()), so the | |
| 8462 | + * takeover is consented rather than silent. | |
| 6568 | 8463 | */ |
| 6569 | 8464 | $owner = self::dropin_owner(); |
| 6570 | - if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) { | |
| 8465 | + if ( self::DROPIN_UNREADABLE === $owner ) { | |
| 6571 | 8466 | return false; |
| 6572 | 8467 | } |
| 6573 | 8468 | |
| 6574 | 8469 | global $wp_filesystem; |
| @@ -6653,8 +8548,22 @@ | ||
| 6653 | 8548 | (string) ( $expiry_hours * HOUR_IN_SECONDS ), |
| 6654 | 8549 | $source_contents |
| 6655 | 8550 | ); |
| 6656 | 8551 | |
| 8552 | + // Bake the site-wide edge answer in. Resolved in a `bake` context, so | |
| 8553 | + // nothing per-page and nothing a request header vouched for can reach | |
| 8554 | + // it: a bake runs once, in an admin or CLI request, and answers for | |
| 8555 | + // every page on the site. A page that disagrees gets a sidecar | |
| 8556 | + // instead — see per_entry_edge_headers(). | |
| 8557 | + // | |
| 8558 | + // Re-baked on every cache settings save (see CacheModule::boot), | |
| 8559 | + // exactly like the cookie, user-agent and lifetime rules above. | |
| 8560 | + $source_contents = str_replace( | |
| 8561 | + "'@@XSPEED_EDGE_HEADERS@@'", | |
| 8562 | + self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ), | |
| 8563 | + $source_contents | |
| 8564 | + ); | |
| 8565 | + | |
| 6657 | 8566 | if ( file_exists( $target ) ) { |
| 6658 | 8567 | $existing = $wp_filesystem->get_contents( $target ); |
| 6659 | 8568 | if ( is_string( $existing ) && $existing === $source_contents ) { |
| 6660 | 8569 | return true; |
| @@ -6896,10 +8805,59 @@ | ||
| 6896 | 8805 | 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ), |
| 6897 | 8806 | ) |
| 6898 | 8807 | ); |
| 6899 | 8808 | |
| 6900 | - foreach ( self::purge_types() as $slug => $type ) { | |
| 6901 | - if ( empty( $type['visible'] ) ) { | |
| 8809 | + // Settings first, then the two whole-errand actions (Purge All, | |
| 8810 | + // Purge this URL), then the per-type items. The order is the one WP | |
| 8811 | + // Rocket uses, and it front-loads what people open this menu for: | |
| 8812 | + // nobody reaches for "Purge Object Cache" as often as they reach for | |
| 8813 | + // the page they are looking at. | |
| 8814 | + $wp_admin_bar->add_node( | |
| 8815 | + array( | |
| 8816 | + 'id' => 'xspeed-purge-settings', | |
| 8817 | + 'parent' => 'xspeed-purge', | |
| 8818 | + 'title' => esc_html__( 'Settings', 'xspeed' ), | |
| 8819 | + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ), | |
| 8820 | + ) | |
| 8821 | + ); | |
| 8822 | + | |
| 8823 | + $types = self::purge_types(); | |
| 8824 | + | |
| 8825 | + // 'all' is rendered out of band so the single-URL item can sit | |
| 8826 | + // directly under it. A filter that reorders or drops it is honoured: | |
| 8827 | + // the loop below skips whatever was emitted here. | |
| 8828 | + $emitted = array(); | |
| 8829 | + if ( ! empty( $types['all']['visible'] ) ) { | |
| 8830 | + $wp_admin_bar->add_node( | |
| 8831 | + array( | |
| 8832 | + 'id' => 'xspeed-purge-all', | |
| 8833 | + 'parent' => 'xspeed-purge', | |
| 8834 | + 'title' => esc_html( $types['all']['label'] ), | |
| 8835 | + 'href' => self::purge_type_url( 'all' ), | |
| 8836 | + ) | |
| 8837 | + ); | |
| 8838 | + $emitted['all'] = true; | |
| 8839 | + } | |
| 8840 | + | |
| 8841 | + // Only when the current screen is about one thing — a front-end view, | |
| 8842 | + // or a published post's edit screen. On a list table or a settings | |
| 8843 | + // page there is nothing for "this" to mean, so the item stays hidden | |
| 8844 | + // rather than silently targeting the dashboard. Purge_Ui decides both | |
| 8845 | + // the label and the scope, which differ between the two contexts. | |
| 8846 | + $context = Purge_Ui::context_node(); | |
| 8847 | + if ( null !== $context ) { | |
| 8848 | + $wp_admin_bar->add_node( | |
| 8849 | + array( | |
| 8850 | + 'id' => 'xspeed-purge-this-url', | |
| 8851 | + 'parent' => 'xspeed-purge', | |
| 8852 | + 'title' => esc_html( $context['title'] ), | |
| 8853 | + 'href' => $context['href'], | |
| 8854 | + ) | |
| 8855 | + ); | |
| 8856 | + } | |
| 8857 | + | |
| 8858 | + foreach ( $types as $slug => $type ) { | |
| 8859 | + if ( empty( $type['visible'] ) || isset( $emitted[ $slug ] ) ) { | |
| 6902 | 8860 | continue; |
| 6903 | 8861 | } |
| 6904 | 8862 | $wp_admin_bar->add_node( |
| 6905 | 8863 | array( |
| @@ -6934,11 +8892,34 @@ | ||
| 6934 | 8892 | // Only honour known types; anything else falls back to a full purge. |
| 6935 | 8893 | if ( ! array_key_exists( $type, self::purge_types() ) ) { |
| 6936 | 8894 | $type = 'all'; |
| 6937 | 8895 | } |
| 8896 | + | |
| 8897 | + // Answer the browser BEFORE purging. "Purge All" fans out to the local | |
| 8898 | + // sweep, the object cache, CSS/edge listeners (outbound HTTP) and | |
| 8899 | + // third-party render caches, all in this one request — on a large site | |
| 8900 | + // that can outlive PHP-FPM's request_terminate_timeout, FPM kills the | |
| 8901 | + // worker mid-purge, and nginx answers the admin's click with a 502. | |
| 8902 | + // fastcgi_finish_request() exists on exactly those FPM setups: send | |
| 8903 | + // the redirect, close the connection, then keep purging in the same | |
| 8904 | + // process. Elsewhere (mod_php, CLI tests) fall back to purge-then- | |
| 8905 | + // redirect as before. | |
| 8906 | + $redirect = self::safe_purge_redirect( wp_get_referer() ); | |
| 8907 | + if ( function_exists( 'ignore_user_abort' ) ) { | |
| 8908 | + ignore_user_abort( true ); | |
| 8909 | + } | |
| 8910 | + if ( function_exists( 'set_time_limit' ) ) { | |
| 8911 | + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort under safe-mode-like restrictions. | |
| 8912 | + } | |
| 8913 | + if ( function_exists( 'fastcgi_finish_request' ) ) { | |
| 8914 | + wp_safe_redirect( $redirect ); | |
| 8915 | + fastcgi_finish_request(); | |
| 8916 | + self::purge_type( $type ); | |
| 8917 | + exit; | |
| 8918 | + } | |
| 8919 | + | |
| 6938 | 8920 | self::purge_type( $type ); |
| 6939 | - | |
| 6940 | - wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) ); | |
| 8921 | + wp_safe_redirect( $redirect ); | |
| 6941 | 8922 | exit; |
| 6942 | 8923 | } |
| 6943 | 8924 | |
| 6944 | 8925 | /** |