| @@ -89,9 +89,12 @@ | ||
| 89 | 89 | * running alongside its replacement and silently undo #243. |
| 90 | 90 | */ |
| 91 | 91 | private const TARGETED_INVALIDATION_HOOKS = array( |
| 92 | 92 | 'save_post', |
| 93 | + 'before_delete_post', | |
| 94 | + 'trashed_post', | |
| 93 | 95 | 'comment_post', |
| 96 | + 'wp_set_comment_status', | |
| 94 | 97 | 'user_register', |
| 95 | 98 | 'profile_update', |
| 96 | 99 | ); |
| 97 | 100 | |
| @@ -155,9 +158,9 @@ | ||
| 155 | 158 | // rendered author bylines / term-archive pages. Without them, an edit |
| 156 | 159 | // left the matching endpoint (and archives) stale for the full TTL. |
| 157 | 160 | // (FBS-82408) |
| 158 | 161 | $invalidate_hooks = array( |
| 159 | - 'save_post', 'deleted_post', 'trashed_post', | |
| 162 | + 'save_post', 'before_delete_post', 'trashed_post', | |
| 160 | 163 | 'comment_post', 'wp_set_comment_status', |
| 161 | 164 | 'switch_theme', 'activated_plugin', 'deactivated_plugin', |
| 162 | 165 | // Users → /wp/v2/users + author archives. |
| 163 | 166 | 'profile_update', 'user_register', 'deleted_user', |
| @@ -187,9 +190,13 @@ | ||
| 187 | 190 | } |
| 188 | 191 | add_action( |
| 189 | 192 | $hook, |
| 190 | 193 | static function () use ( $hook ): void { |
| 191 | - self::purge_all( 'hook:' . $hook ); | |
| 194 | + self::purge_all( | |
| 195 | + 'hook:' . $hook, | |
| 196 | + null, | |
| 197 | + self::invalidation_for_hook( $hook ) | |
| 198 | + ); | |
| 192 | 199 | } |
| 193 | 200 | ); |
| 194 | 201 | add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 195 | 202 | } |
| @@ -260,12 +267,21 @@ | ||
| 260 | 267 | // and generalises to Flamingo (#229) and Tutor LMS (#231) too. |
| 261 | 268 | remove_action( 'save_post', array( __CLASS__, 'purge_all' ) ); |
| 262 | 269 | remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 263 | 270 | add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 ); |
| 271 | + add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 272 | + add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 273 | + // wp_delete_post() hands an attachment to wp_delete_attachment() and | |
| 274 | + // returns BEFORE before_delete_post fires, so deleting media reached | |
| 275 | + // neither hook above. Attachment pages are public and media appears in | |
| 276 | + // galleries, so that left cached pages showing a file that is gone. | |
| 277 | + // (dev caught this via `deleted_post`, which this branch replaced.) | |
| 278 | + add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 264 | 279 | |
| 265 | 280 | remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) ); |
| 266 | 281 | remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 267 | 282 | add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 ); |
| 283 | + add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 ); | |
| 268 | 284 | |
| 269 | 285 | remove_action( 'user_register', array( __CLASS__, 'purge_all' ) ); |
| 270 | 286 | remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 271 | 287 | add_action( 'user_register', array( __CLASS__, 'on_user_change' ) ); |
| @@ -1394,9 +1410,10 @@ | ||
| 1394 | 1410 | * exactly this blog's pages. |
| 1395 | 1411 | */ |
| 1396 | 1412 | public static function current_static_scope(): string { |
| 1397 | 1413 | // Same switch_to_blog() caveat as current_host_dir() — see current_host(). |
| 1398 | - $dir = self::host_dir( self::current_host() ); | |
| 1414 | + // Keep the port folded into the segment exactly as store_static() does. | |
| 1415 | + $dir = self::static_host_dir( self::current_host() ); | |
| 1399 | 1416 | if ( '' === $dir ) { |
| 1400 | 1417 | $dir = 'default'; |
| 1401 | 1418 | } |
| 1402 | 1419 | $path = self::site_path_raw(); |
| @@ -2624,8 +2641,101 @@ | ||
| 2624 | 2641 | * TTL — up to 30 days at the maximum lifetime. (#270 regression) |
| 2625 | 2642 | * |
| 2626 | 2643 | * @return string[] |
| 2627 | 2644 | */ |
| 2645 | + /** | |
| 2646 | + * Could this post change alter anything an anonymous visitor had cached? | |
| 2647 | + * | |
| 2648 | + * Deleting one post fired a full purge for the post AND for every stored | |
| 2649 | + * revision, because wp_delete_post() removes each revision through | |
| 2650 | + * wp_delete_post() again and every one of those fires before_delete_post | |
| 2651 | + * with post_type 'revision'. A post with six revisions cost seven whole- | |
| 2652 | + * site sweeps, each one also announcing to LiteSpeed, purging the object | |
| 2653 | + * cache network-wide on Redis, rewriting the stats option and running | |
| 2654 | + * every xspeed_after_purge_all listener -- including Pro's Cloudflare | |
| 2655 | + * purge, so seven API calls. Trashing cost two, via save_post and then | |
| 2656 | + * trashed_post. (QA #348) | |
| 2657 | + * | |
| 2658 | + * The check lives here, ahead of purge_all(), so one early return covers | |
| 2659 | + * the local sweep, the server-cache announcement and both action hooks. | |
| 2660 | + * It deliberately does NOT live inside purge_all(): a manual, CLI or | |
| 2661 | + * explicit caller asked for a purge and must get one. | |
| 2662 | + * | |
| 2663 | + * @param int $post_id Post being saved or removed. | |
| 2664 | + * @param mixed $post Post object when the hook passed one. | |
| 2665 | + * @param string $event 'save' or 'remove'. | |
| 2666 | + */ | |
| 2667 | + private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool { | |
| 2668 | + $post_id = (int) $post_id; | |
| 2669 | + | |
| 2670 | + // Only `save_post` and `before_delete_post` hand over a post object. | |
| 2671 | + // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING -- | |
| 2672 | + // so reaching for ->post_status on the second argument finds nothing | |
| 2673 | + // and the status rule below would never fire. Read the row instead. | |
| 2674 | + if ( ! is_object( $post ) && function_exists( 'get_post' ) ) { | |
| 2675 | + $post = get_post( $post_id ); | |
| 2676 | + } | |
| 2677 | + | |
| 2678 | + $type = is_object( $post ) && isset( $post->post_type ) | |
| 2679 | + ? (string) $post->post_type | |
| 2680 | + : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' ); | |
| 2681 | + if ( '' === $type ) { | |
| 2682 | + return false; | |
| 2683 | + } | |
| 2684 | + | |
| 2685 | + // A revision is a copy of content nobody can browse to. | |
| 2686 | + if ( 'revision' === $type ) { | |
| 2687 | + return false; | |
| 2688 | + } | |
| 2689 | + if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) { | |
| 2690 | + return false; | |
| 2691 | + } | |
| 2692 | + if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) { | |
| 2693 | + return false; | |
| 2694 | + } | |
| 2695 | + | |
| 2696 | + $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : ''; | |
| 2697 | + | |
| 2698 | + // Clicking "Add New" inserts an auto-draft and fires save_post. There | |
| 2699 | + // is nothing cached of a post that has never existed publicly. | |
| 2700 | + if ( 'auto-draft' === $status ) { | |
| 2701 | + return false; | |
| 2702 | + } | |
| 2703 | + | |
| 2704 | + // Unknown/!viewable → nothing anonymous can see changed, UNLESS the | |
| 2705 | + // type is itself part of how pages render (#270 regression). | |
| 2706 | + if ( function_exists( 'is_post_type_viewable' ) | |
| 2707 | + && ! is_post_type_viewable( $type ) | |
| 2708 | + && ! in_array( $type, self::presentation_post_types(), true ) | |
| 2709 | + ) { | |
| 2710 | + return false; | |
| 2711 | + } | |
| 2712 | + | |
| 2713 | + // Deleting something that was already invisible changes no cached | |
| 2714 | + // page: the transition that hid it purged at the time. This is what | |
| 2715 | + // makes emptying a trash of a hundred posts cost nothing rather than | |
| 2716 | + // a hundred full sweeps. | |
| 2717 | + // | |
| 2718 | + // It also collapses trashing to a single purge: wp_trash_post() fires | |
| 2719 | + // save_post first, where the post is genuinely disappearing from | |
| 2720 | + // listings and SHOULD purge, then trashed_post, by which point the | |
| 2721 | + // row reads 'trash' and is skipped. A status we cannot read, on a row | |
| 2722 | + // that still reports a type, means assume viewable -- erring toward | |
| 2723 | + // an extra purge, never toward serving a stale page. A row that is | |
| 2724 | + // gone entirely reports no type either and was refused above. | |
| 2725 | + // 'inherit' is an INTERNAL status in core, so is_post_status_viewable() | |
| 2726 | + // says no -- but an attachment carrying it is genuinely public. Judge | |
| 2727 | + // those on the post type alone, which is already checked above. | |
| 2728 | + if ( 'remove' === $event && '' !== $status && 'inherit' !== $status | |
| 2729 | + && function_exists( 'is_post_status_viewable' ) | |
| 2730 | + && ! is_post_status_viewable( $status ) | |
| 2731 | + ) { | |
| 2732 | + return false; | |
| 2733 | + } | |
| 2734 | + | |
| 2735 | + return true; | |
| 2736 | + } | |
| 2737 | + | |
| 2628 | 2738 | public static function presentation_post_types(): array { |
| 2629 | 2739 | $types = array( |
| 2630 | 2740 | 'wp_template', // Site Editor templates. |
| 2631 | 2741 | 'wp_template_part', // Header / footer / reusable parts. |
| @@ -2646,8 +2756,37 @@ | ||
| 2646 | 2756 | return (array) apply_filters( 'xspeed_presentation_post_types', $types ); |
| 2647 | 2757 | } |
| 2648 | 2758 | |
| 2649 | 2759 | /** |
| 2760 | + * Describe a broad hook invalidation for response-cache adapters. | |
| 2761 | + * | |
| 2762 | + * Term, menu, theme and plugin changes can alter navigation, archives or | |
| 2763 | + * markup across the site, so they require a site response-cache purge. | |
| 2764 | + * Content saves also require this scope while their local operation is a | |
| 2765 | + * complete bucket sweep. | |
| 2766 | + * | |
| 2767 | + * @return array{scope:string,intent:string,urls:array<int,string>} | |
| 2768 | + */ | |
| 2769 | + private static function invalidation_for_hook( string $hook ): array { | |
| 2770 | + $presentation = array( | |
| 2771 | + 'switch_theme', | |
| 2772 | + 'activated_plugin', | |
| 2773 | + 'deactivated_plugin', | |
| 2774 | + 'created_term', | |
| 2775 | + 'edited_term', | |
| 2776 | + 'delete_term', | |
| 2777 | + 'wp_update_nav_menu', | |
| 2778 | + ); | |
| 2779 | + | |
| 2780 | + return array( | |
| 2781 | + 'scope' => 'site', | |
| 2782 | + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content', | |
| 2783 | + 'urls' => array(), | |
| 2784 | + ); | |
| 2785 | + } | |
| 2786 | + | |
| 2787 | + | |
| 2788 | + /** | |
| 2650 | 2789 | * save_post → purge only when the saved thing can appear on a cached page. |
| 2651 | 2790 | * |
| 2652 | 2791 | * Revisions and autosaves are never rendered. Non-viewable post types — |
| 2653 | 2792 | * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund` |
| @@ -2662,35 +2801,32 @@ | ||
| 2662 | 2801 | * @param int $post_id Saved post ID. |
| 2663 | 2802 | * @param \WP_Post $post Saved post object. |
| 2664 | 2803 | */ |
| 2665 | 2804 | public static function on_save_post( $post_id, $post = null ): void { |
| 2666 | - if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) { | |
| 2805 | + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) { | |
| 2667 | 2806 | return; |
| 2668 | 2807 | } |
| 2669 | - if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) { | |
| 2670 | - return; | |
| 2671 | - } | |
| 2672 | 2808 | |
| 2673 | 2809 | $post_type = is_object( $post ) && isset( $post->post_type ) |
| 2674 | 2810 | ? (string) $post->post_type |
| 2675 | 2811 | : (string) get_post_type( $post_id ); |
| 2676 | - if ( '' === $post_type ) { | |
| 2677 | - return; | |
| 2678 | - } | |
| 2679 | 2812 | |
| 2680 | - // Unknown/!viewable → nothing anonymous can see changed, UNLESS the | |
| 2681 | - // type is itself part of how pages render (#270 regression). | |
| 2682 | - if ( function_exists( 'is_post_type_viewable' ) | |
| 2683 | - && ! is_post_type_viewable( $post_type ) | |
| 2684 | - && ! in_array( $post_type, self::presentation_post_types(), true ) | |
| 2685 | - ) { | |
| 2686 | - return; | |
| 2687 | - } | |
| 2688 | - | |
| 2689 | 2813 | // Name the trigger rather than logging a bare numeric id — the old |
| 2690 | 2814 | // wiring passed the post ID into $cause, so the log read |
| 2691 | 2815 | // "Cache purged (46)" with no indication of what caused it. (#243) |
| 2692 | - self::purge_all( 'post:' . $post_type ); | |
| 2816 | + $presentation = in_array( $post_type, self::presentation_post_types(), true ); | |
| 2817 | + self::purge_all( | |
| 2818 | + 'post:' . $post_type, | |
| 2819 | + null, | |
| 2820 | + array( | |
| 2821 | + // purge_all() sweeps every local response in this site's bucket. | |
| 2822 | + // Without dependency tracking, the server cache must match that | |
| 2823 | + // same boundary or unrelated pages can remain stale there. | |
| 2824 | + 'scope' => 'site', | |
| 2825 | + 'intent' => $presentation ? 'presentation' : 'content', | |
| 2826 | + 'urls' => array(), | |
| 2827 | + ) | |
| 2828 | + ); | |
| 2693 | 2829 | if ( class_exists( '\XSpeed\Minifier' ) ) { |
| 2694 | 2830 | Minifier::purge_minified(); |
| 2695 | 2831 | } |
| 2696 | 2832 | } |
| @@ -2695,8 +2831,61 @@ | ||
| 2695 | 2831 | } |
| 2696 | 2832 | } |
| 2697 | 2833 | |
| 2698 | 2834 | /** |
| 2835 | + * Delete/trash invalidation while the post type is still available. | |
| 2836 | + * The local and server response-cache sweeps share the same site boundary. | |
| 2837 | + * | |
| 2838 | + * @param int $post_id Removed post ID. | |
| 2839 | + * @param object|null $post Post object supplied by core when available. | |
| 2840 | + */ | |
| 2841 | + public static function on_post_removed( $post_id, $post = null ): void { | |
| 2842 | + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) { | |
| 2843 | + return; | |
| 2844 | + } | |
| 2845 | + | |
| 2846 | + $post_type = is_object( $post ) && isset( $post->post_type ) | |
| 2847 | + ? (string) $post->post_type | |
| 2848 | + : (string) get_post_type( $post_id ); | |
| 2849 | + | |
| 2850 | + self::purge_all( | |
| 2851 | + 'post-removed:' . $post_type, | |
| 2852 | + null, | |
| 2853 | + array( | |
| 2854 | + 'scope' => 'site', | |
| 2855 | + // Match on_save_post: a presentation type changes how pages | |
| 2856 | + // render rather than what they say. | |
| 2857 | + 'intent' => in_array( $post_type, self::presentation_post_types(), true ) | |
| 2858 | + ? 'presentation' | |
| 2859 | + : 'content', | |
| 2860 | + 'urls' => array(), | |
| 2861 | + ) | |
| 2862 | + ); | |
| 2863 | + } | |
| 2864 | + | |
| 2865 | + /** Purge site responses when moderation changes visible comments. */ | |
| 2866 | + public static function on_comment_status( $comment_id, $status = '' ): void { | |
| 2867 | + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null; | |
| 2868 | + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0; | |
| 2869 | + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) { | |
| 2870 | + return; | |
| 2871 | + } | |
| 2872 | + $url = get_permalink( $post_id ); | |
| 2873 | + if ( ! is_string( $url ) || '' === $url ) { | |
| 2874 | + return; | |
| 2875 | + } | |
| 2876 | + self::purge_all( | |
| 2877 | + 'comment-status:' . (string) $status, | |
| 2878 | + null, | |
| 2879 | + array( | |
| 2880 | + 'scope' => 'site', | |
| 2881 | + 'intent' => 'content', | |
| 2882 | + 'urls' => array(), | |
| 2883 | + ) | |
| 2884 | + ); | |
| 2885 | + } | |
| 2886 | + | |
| 2887 | + /** | |
| 2699 | 2888 | * comment_post → purge just the commented-on URL, and only once the |
| 2700 | 2889 | * comment is actually visible. |
| 2701 | 2890 | * |
| 2702 | 2891 | * A comment held for moderation changes nothing on the front end, and an |
| @@ -2847,13 +3036,304 @@ | ||
| 2847 | 3036 | public static function purge_product_object( $product ): void { |
| 2848 | 3037 | self::purge_product( $product ); |
| 2849 | 3038 | } |
| 2850 | 3039 | |
| 3040 | + /** | |
| 3041 | + * Re-entry guard for the purge-event contract. | |
| 3042 | + * | |
| 3043 | + * A listener on `xspeed_after_purge_url` legitimately purges its own | |
| 3044 | + * layer, and a server-cache or CDN adapter that calls back into xSpeed | |
| 3045 | + * while doing so re-enters this method — unbounded, because each pass | |
| 3046 | + * looks like a fresh purge. | |
| 3047 | + * | |
| 3048 | + * A single global flag stops too much: a nested purge of a DIFFERENT URL is | |
| 3049 | + * a real purge whose listeners must hear about it. But a per-request | |
| 3050 | + * "already published" set stops too much in the other direction — a | |
| 3051 | + * network purge loops every blog in one request, and on a subdirectory | |
| 3052 | + * network they share a host, so blogs 2..N would be silently skipped. It | |
| 3053 | + * also grows for the life of the process. | |
| 3054 | + * | |
| 3055 | + * So the guard tracks what is IN FLIGHT, not what has been published: a | |
| 3056 | + * target is marked while its own dispatch is on the stack and unmarked | |
| 3057 | + * when it returns. Re-entering the same target recurses, so it is refused; | |
| 3058 | + * purging the same URL again later is a new event and publishes. The set | |
| 3059 | + * is bounded by call depth rather than by how many URLs a request touches. | |
| 3060 | + * | |
| 3061 | + * @var array<string,bool> | |
| 3062 | + */ | |
| 3063 | + private static $purge_events_in_flight = array(); | |
| 3064 | + | |
| 3065 | + /** Monotonic count used to detect whether a delegated purge published. */ | |
| 3066 | + private static $purge_event_sequence = 0; | |
| 3067 | + | |
| 3068 | + /** | |
| 3069 | + * Publish a purge event exactly once, with bounded arguments. | |
| 3070 | + * | |
| 3071 | + * Deliberately carries only what an integration needs to invalidate its | |
| 3072 | + * own copy: the canonical URL (or null for a full purge), the site host, | |
| 3073 | + * the cause label, and how many files went. No filesystem paths, no cache | |
| 3074 | + * contents, no request headers, no user data. The URL query and caller- | |
| 3075 | + * supplied cause may nevertheless contain sensitive text, so listeners | |
| 3076 | + * must redact them in logs or unrelated destinations that do not need the | |
| 3077 | + * exact cache key. | |
| 3078 | + * | |
| 3079 | + * A listener that throws must not take the purge down with it: the files | |
| 3080 | + * are already gone by the time we get here, and an integration's bad day | |
| 3081 | + * is not a reason to report a failed purge to the caller. | |
| 3082 | + * | |
| 3083 | + * @param string $hook Hook name to emit. | |
| 3084 | + * @param array<string,mixed> $context Bounded context, see above. | |
| 3085 | + */ | |
| 3086 | + private static function dispatch_purge_event( string $hook, array $context ): void { | |
| 3087 | + if ( ! function_exists( 'do_action' ) ) { | |
| 3088 | + return; | |
| 3089 | + } | |
| 3090 | + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' ) | |
| 3091 | + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' ); | |
| 3092 | + if ( isset( self::$purge_events_in_flight[ $target ] ) ) { | |
| 3093 | + return; | |
| 3094 | + } | |
| 3095 | + self::$purge_events_in_flight[ $target ] = true; | |
| 3096 | + ++self::$purge_event_sequence; | |
| 3097 | + | |
| 3098 | + // Our own integrations get their own try. Sharing one with the public | |
| 3099 | + // action below meant a listener on the extension seam could throw and | |
| 3100 | + // take the contract event down with it — the mirror of the failure | |
| 3101 | + // this separation exists to prevent. | |
| 3102 | + try { | |
| 3103 | + // Built-in server-cache integrations run FIRST, and by a direct | |
| 3104 | + // call rather than as listeners on the action below. | |
| 3105 | + // | |
| 3106 | + // WordPress stops dispatching an action's remaining callbacks when | |
| 3107 | + // one of them throws. As a listener, our LiteSpeed forwarding | |
| 3108 | + // would then be skipped by any unrelated third-party callback that | |
| 3109 | + // happened to be registered earlier and blew up — and the visible | |
| 3110 | + // result is the worst kind: xSpeed reports a successful purge while | |
| 3111 | + // the server keeps serving stale HTML. Shipped behaviour must not | |
| 3112 | + // be hostage to a listener's bug. | |
| 3113 | + self::forward_to_server_caches( $context ); | |
| 3114 | + } catch ( \Throwable $e ) { | |
| 3115 | + self::log_purge_listener_error( $hook, $e ); | |
| 3116 | + } | |
| 3117 | + | |
| 3118 | + try { | |
| 3119 | + self::do_action_isolated( $hook, $context ); | |
| 3120 | + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch | |
| 3121 | + // Swallow: see docblock. The purge succeeded regardless. | |
| 3122 | + self::log_purge_listener_error( $hook, $e ); | |
| 3123 | + } finally { | |
| 3124 | + unset( self::$purge_events_in_flight[ $target ] ); | |
| 3125 | + } | |
| 3126 | + } | |
| 3127 | + | |
| 3128 | + /** | |
| 3129 | + * Run every listener on a purge hook, isolating each from the others. | |
| 3130 | + * | |
| 3131 | + * `do_action()` dispatches callbacks in one loop, so the first one to | |
| 3132 | + * throw takes every LATER listener down with it. On a purge that meant a | |
| 3133 | + * failing CDN integration silently cancelled the ones queued behind it — | |
| 3134 | + * and because the throw was swallowed to keep the purge itself succeeding, | |
| 3135 | + * the user was told the clear worked while two edges were never touched. | |
| 3136 | + * Invisible unless WP_DEBUG happened to be on. (QA #348) | |
| 3137 | + * | |
| 3138 | + * Each callback gets its own try/catch here, so one integration's bad day | |
| 3139 | + * costs only that integration. Priority order is preserved. Falls back to | |
| 3140 | + * a plain `do_action()` when the filter registry is not the shape we | |
| 3141 | + * expect, so an unusual environment degrades to the old behaviour rather | |
| 3142 | + * than skipping listeners entirely. | |
| 3143 | + * | |
| 3144 | + * @param string $hook Hook name to emit. | |
| 3145 | + * @param mixed $arg Single argument passed to each listener. | |
| 3146 | + */ | |
| 3147 | + public static function do_action_isolated( string $hook, $arg ): void { | |
| 3148 | + global $wp_filter; | |
| 3149 | + | |
| 3150 | + // Walking $wp_filter by hand and calling each callback directly was the | |
| 3151 | + // obvious way to do this, and it was wrong: it bypasses WordPress, so | |
| 3152 | + // `current_filter()` came back empty, `did_action()` stayed at 0, the | |
| 3153 | + // `all` hook never fired, and Query Monitor and Debug Bar could not see | |
| 3154 | + // the very contract this class publishes. A shared handler branching on | |
| 3155 | + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3) | |
| 3156 | + // | |
| 3157 | + // So let do_action() dispatch — WordPress keeps its bookkeeping — and | |
| 3158 | + // isolate one level down instead: each registered callback is swapped | |
| 3159 | + // for a wrapper that runs it inside a try/catch. One listener throwing | |
| 3160 | + // then costs only that listener, which is the whole point, without | |
| 3161 | + // costing the hook its identity. | |
| 3162 | + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) { | |
| 3163 | + do_action( $hook, $arg ); | |
| 3164 | + return; | |
| 3165 | + } | |
| 3166 | + | |
| 3167 | + $hook_object = $wp_filter[ $hook ]; | |
| 3168 | + $original = $hook_object->callbacks; | |
| 3169 | + if ( ! is_array( $original ) || array() === $original ) { | |
| 3170 | + do_action( $hook, $arg ); | |
| 3171 | + return; | |
| 3172 | + } | |
| 3173 | + | |
| 3174 | + $wrapped = array(); | |
| 3175 | + $restorations = array(); | |
| 3176 | + foreach ( $original as $priority => $group ) { | |
| 3177 | + if ( ! is_array( $group ) ) { | |
| 3178 | + $wrapped[ $priority ] = $group; | |
| 3179 | + continue; | |
| 3180 | + } | |
| 3181 | + foreach ( $group as $id => $registered ) { | |
| 3182 | + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) { | |
| 3183 | + $wrapped[ $priority ][ $id ] = $registered; | |
| 3184 | + continue; | |
| 3185 | + } | |
| 3186 | + $callback = $registered['function']; | |
| 3187 | + $wrapper = static function ( ...$args ) use ( $callback, $hook ) { | |
| 3188 | + try { | |
| 3189 | + return $callback( ...$args ); | |
| 3190 | + } catch ( \Throwable $e ) { | |
| 3191 | + self::log_purge_listener_error( $hook, $e ); | |
| 3192 | + return null; | |
| 3193 | + } | |
| 3194 | + }; | |
| 3195 | + $wrapped[ $priority ][ $id ] = array( | |
| 3196 | + // Keep accepted_args: a listener registered for 0 or 1 | |
| 3197 | + // arguments must still be called the way it asked. | |
| 3198 | + 'accepted_args' => $registered['accepted_args'] ?? 1, | |
| 3199 | + 'function' => $wrapper, | |
| 3200 | + ); | |
| 3201 | + $restorations[ $priority ][ $id ] = array( | |
| 3202 | + 'original' => $registered, | |
| 3203 | + 'wrapper' => $wrapper, | |
| 3204 | + ); | |
| 3205 | + } | |
| 3206 | + } | |
| 3207 | + | |
| 3208 | + $hook_object->callbacks = $wrapped; | |
| 3209 | + try { | |
| 3210 | + do_action( $hook, $arg ); | |
| 3211 | + } finally { | |
| 3212 | + // Restore only wrappers still present. Native add/remove operations | |
| 3213 | + // performed by listeners must survive this temporary substitution. | |
| 3214 | + foreach ( $restorations as $priority => $group ) { | |
| 3215 | + foreach ( $group as $id => $restore ) { | |
| 3216 | + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null; | |
| 3217 | + if ( $current === $restore['wrapper'] ) { | |
| 3218 | + $hook_object->callbacks[ $priority ][ $id ] = $restore['original']; | |
| 3219 | + } | |
| 3220 | + } | |
| 3221 | + } | |
| 3222 | + } | |
| 3223 | + } | |
| 3224 | + | |
| 3225 | + /** | |
| 3226 | + * Name a listener that threw, under WP_DEBUG only. | |
| 3227 | + * | |
| 3228 | + * Gated like the rest of Free's diagnostics: a third-party listener | |
| 3229 | + * throwing on every purge must not fill a production log. | |
| 3230 | + */ | |
| 3231 | + private static function log_purge_listener_error( string $hook, \Throwable $e ): void { | |
| 3232 | + // An \Error — a TypeError from one of OUR listeners, say — is a bug | |
| 3233 | + // rather than a runtime condition a third party imposed on us, and | |
| 3234 | + // swallowing it silently in production turns it into a purge that | |
| 3235 | + // quietly stops working. Those are logged whatever WP_DEBUG says; | |
| 3236 | + // third-party \Exceptions stay gated so a noisy integration cannot | |
| 3237 | + // fill a production log. | |
| 3238 | + $always = $e instanceof \Error; | |
| 3239 | + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) { | |
| 3240 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge. | |
| 3241 | + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() ); | |
| 3242 | + } | |
| 3243 | + } | |
| 3244 | + | |
| 3245 | + /** Test seam: clear the in-flight set left behind by an aborted dispatch. */ | |
| 3246 | + public static function reset_purge_events(): void { | |
| 3247 | + self::$purge_events_in_flight = array(); | |
| 3248 | + self::$purge_event_sequence = 0; | |
| 3249 | + } | |
| 3250 | + | |
| 3251 | + /** | |
| 3252 | + * Hand the purge to the caches we ship integrations for. | |
| 3253 | + * | |
| 3254 | + * Isolated from the public action on purpose — see dispatch_purge_event(). | |
| 3255 | + * Guarded so a missing class (a partial upgrade, a stripped build) cannot | |
| 3256 | + * turn a working purge into a fatal. | |
| 3257 | + * | |
| 3258 | + * @param array<string,mixed> $context Bounded purge context. | |
| 3259 | + */ | |
| 3260 | + private static function forward_to_server_caches( array $context ): void { | |
| 3261 | + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) { | |
| 3262 | + Server_Caches::forward( $context ); | |
| 3263 | + } | |
| 3264 | + } | |
| 3265 | + | |
| 3266 | + /** | |
| 3267 | + * `host[:port]` for a cache key, from a parsed URL. | |
| 3268 | + * | |
| 3269 | + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and | |
| 3270 | + * that carries `:8080` on any install not served from 80/443 — dropping it | |
| 3271 | + * computed a different md5, found no file, and reported "already cold" | |
| 3272 | + * while the page kept serving HIT. | |
| 3273 | + * | |
| 3274 | + * A port that is the DEFAULT for the scheme is dropped, though, because | |
| 3275 | + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for | |
| 3276 | + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file | |
| 3277 | + * stored under `site.com` — the same silent no-op in the other direction, | |
| 3278 | + * and the one QA hit passing a canonical URL with the port spelled out. | |
| 3279 | + * (QA #348) | |
| 3280 | + * | |
| 3281 | + * @param array<string,mixed> $parts Output of wp_parse_url(). | |
| 3282 | + */ | |
| 3283 | + private static function host_port_of( array $parts ): string { | |
| 3284 | + if ( ! isset( $parts['host'] ) ) { | |
| 3285 | + return ''; | |
| 3286 | + } | |
| 3287 | + $host = strtolower( (string) $parts['host'] ); | |
| 3288 | + if ( '' === $host || ! isset( $parts['port'] ) ) { | |
| 3289 | + return $host; | |
| 3290 | + } | |
| 3291 | + $port = (int) $parts['port']; | |
| 3292 | + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; | |
| 3293 | + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) { | |
| 3294 | + return $host; | |
| 3295 | + } | |
| 3296 | + return $host . ':' . $port; | |
| 3297 | + } | |
| 3298 | + | |
| 2851 | 3299 | public static function purge_url( string $url, string $cause = 'manual' ): int { |
| 3300 | + // A URL that names nothing is not a purge of everything. An empty or | |
| 3301 | + // blank string used to fall through to the home_url() default below | |
| 3302 | + // and clear the HOMEPAGE — so a third party calling | |
| 3303 | + // `purge_url( get_permalink( $id ) )` on a post whose permalink came | |
| 3304 | + // back empty silently purged the front page instead of nothing. The | |
| 3305 | + // CLI and the MCP tool reject empties before reaching this, so only | |
| 3306 | + // direct API callers were exposed, but they are exactly the audience | |
| 3307 | + // this public contract is for. (QA #348) | |
| 3308 | + if ( '' === trim( $url ) ) { | |
| 3309 | + return 0; | |
| 3310 | + } | |
| 2852 | 3311 | $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. |
| 2853 | 3312 | if ( ! is_array( $parts ) ) { |
| 2854 | 3313 | return 0; |
| 2855 | 3314 | } |
| 3315 | + // Absolute URLs are accepted only for HTTP response caches. Schemes such | |
| 3316 | + // as ftp:, file: and javascript: can parse cleanly but do not name a page | |
| 3317 | + // xSpeed or a server response cache can invalidate. A leading-slash path | |
| 3318 | + // remains a supported site-relative target. | |
| 3319 | + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) { | |
| 3320 | + return 0; | |
| 3321 | + } | |
| 3322 | + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) { | |
| 3323 | + return 0; | |
| 3324 | + } | |
| 3325 | + // Reject a string that parsed but is not a URL we can act on: no | |
| 3326 | + // scheme AND no host AND no leading-slash path means something like | |
| 3327 | + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a | |
| 3328 | + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)` | |
| 3329 | + // — a nonsense tag sent to LiteSpeed for every malformed call. | |
| 3330 | + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) { | |
| 3331 | + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : ''; | |
| 3332 | + if ( '' === $raw || '/' !== $raw[0] ) { | |
| 3333 | + return 0; | |
| 3334 | + } | |
| 3335 | + } | |
| 2856 | 3336 | // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which |
| 2857 | 3337 | // carries `:8080` on any install not served from 80/443 — while |
| 2858 | 3338 | // parse_url() splits the port into its own component, so a purge that |
| 2859 | 3339 | // used the bare host computed a different md5, found no file, and |
| @@ -2859,19 +3339,34 @@ | ||
| 2859 | 3339 | // used the bare host computed a different md5, found no file, and |
| 2860 | 3340 | // reported "already cold". A silent no-op: the page kept serving HIT |
| 2861 | 3341 | // until its TTL ran out. Intranet installs, panel hosts on :8443 and |
| 2862 | 3342 | // proxies that forward `Host: site.com:8080` all hit this. |
| 2863 | - $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : ''; | |
| 2864 | - if ( '' !== $host && isset( $parts['port'] ) ) { | |
| 2865 | - $host .= ':' . (int) $parts['port']; | |
| 3343 | + // A scheme-less `site.test:443/page/` is a supported explicit-host | |
| 3344 | + // target. Infer a scheme only when it names THIS site's hostname: then | |
| 3345 | + // its explicit default port is the same origin and the same local cache | |
| 3346 | + // key. Never apply this to another host or to a non-default port. | |
| 3347 | + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) { | |
| 3348 | + $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. | |
| 3349 | + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] ) | |
| 3350 | + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] ) | |
| 3351 | + ) { | |
| 3352 | + $home_scheme = strtolower( (string) $home['scheme'] ); | |
| 3353 | + $port = (int) $parts['port']; | |
| 3354 | + $home_port = isset( $home['port'] ) | |
| 3355 | + ? (int) $home['port'] | |
| 3356 | + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) ); | |
| 3357 | + if ( $home_port === $port | |
| 3358 | + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) ) | |
| 3359 | + ) { | |
| 3360 | + $parts['scheme'] = $home_scheme; | |
| 3361 | + } | |
| 3362 | + } | |
| 2866 | 3363 | } |
| 3364 | + $host = self::host_port_of( $parts ); | |
| 2867 | 3365 | if ( '' === $host && function_exists( 'home_url' ) ) { |
| 2868 | 3366 | $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. |
| 2869 | - if ( is_array( $home ) && isset( $home['host'] ) ) { | |
| 2870 | - $host = strtolower( (string) $home['host'] ); | |
| 2871 | - if ( isset( $home['port'] ) ) { | |
| 2872 | - $host .= ':' . (int) $home['port']; | |
| 2873 | - } | |
| 3367 | + if ( is_array( $home ) ) { | |
| 3368 | + $host = self::host_port_of( $home ); | |
| 2874 | 3369 | } |
| 2875 | 3370 | } |
| 2876 | 3371 | if ( '' === $host ) { |
| 2877 | 3372 | return 0; |
| @@ -2955,14 +3450,120 @@ | ||
| 2955 | 3450 | Activity_Log::INFO |
| 2956 | 3451 | ); |
| 2957 | 3452 | } |
| 2958 | 3453 | |
| 3454 | + /** | |
| 3455 | + * Fires after one URL's cached copy has been purged. | |
| 3456 | + * | |
| 3457 | + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe | |
| 3458 | + * here to invalidate a cache xSpeed does not own — a server-level | |
| 3459 | + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for | |
| 3460 | + * the same URL. | |
| 3461 | + * | |
| 3462 | + * Only fires when the purge actually ran. A malformed URL, a URL with | |
| 3463 | + * no resolvable host, or a traversal attempt returns earlier and | |
| 3464 | + * publishes nothing, so a listener can treat this as "xSpeed purged | |
| 3465 | + * this URL" rather than "xSpeed was asked to". `removed` may legitimately | |
| 3466 | + * be 0: the URL was not in xSpeed's cache, which says nothing about | |
| 3467 | + * whether it is in yours. | |
| 3468 | + * | |
| 3469 | + * Fires at most once per purge. A listener that calls back into | |
| 3470 | + * xSpeed's purge API will not re-enter this event. | |
| 3471 | + * | |
| 3472 | + * @since 1.2.3 | |
| 3473 | + * | |
| 3474 | + * @param array $context { | |
| 3475 | + * Bounded description of the purge. URL queries and caller-supplied | |
| 3476 | + * causes can contain sensitive values and are not logging fields. | |
| 3477 | + * | |
| 3478 | + * @type string $url Canonical scheme://host/path[?query] of the purged URL. | |
| 3479 | + * The query is preserved because caches in front | |
| 3480 | + * commonly key on it; xSpeed's own sweep is | |
| 3481 | + * path-based, so `removed` describes that. | |
| 3482 | + * @type string $host Host (with port when non-standard). | |
| 3483 | + * @type string $path Path component, leading slash. | |
| 3484 | + * @type string $cause Short label for who asked. See purge_all(). | |
| 3485 | + * @type int $removed Number of cache files removed. | |
| 3486 | + * @type string $scope Actionable adapter scope: `urls`. | |
| 3487 | + * @type string $intent Why responses changed: `content`. | |
| 3488 | + * @type string[] $urls Exact response URLs to invalidate. | |
| 3489 | + * } | |
| 3490 | + */ | |
| 3491 | + $canonical_url = self::canonical_purge_url( | |
| 3492 | + $host, | |
| 3493 | + $path, | |
| 3494 | + isset( $parts['query'] ) ? (string) $parts['query'] : '', | |
| 3495 | + isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '' | |
| 3496 | + ); | |
| 3497 | + self::dispatch_purge_event( | |
| 3498 | + 'xspeed_after_purge_url', | |
| 3499 | + array( | |
| 3500 | + 'url' => $canonical_url, | |
| 3501 | + 'host' => $host, | |
| 3502 | + 'path' => $path, | |
| 3503 | + 'cause' => $cause, | |
| 3504 | + 'removed' => $count, | |
| 3505 | + 'scope' => 'urls', | |
| 3506 | + 'intent' => 'content', | |
| 3507 | + 'urls' => array( $canonical_url ), | |
| 3508 | + ) | |
| 3509 | + ); | |
| 3510 | + | |
| 2959 | 3511 | return $count; |
| 2960 | 3512 | } |
| 2961 | 3513 | |
| 3514 | + /** Host this site's purge is scoped to, for the purge-event context. */ | |
| 3515 | + private static function current_purge_host(): string { | |
| 3516 | + if ( ! function_exists( 'home_url' ) ) { | |
| 3517 | + return ''; | |
| 3518 | + } | |
| 3519 | + $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. | |
| 3520 | + if ( ! is_array( $home ) || empty( $home['host'] ) ) { | |
| 3521 | + return ''; | |
| 3522 | + } | |
| 3523 | + // Same default-port normalisation as purge_url(): a site whose | |
| 3524 | + // home_url() carries `:443` (normal behind a proxy) otherwise stamps | |
| 3525 | + // every full-purge event with a host that matches none of its own | |
| 3526 | + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348) | |
| 3527 | + return self::host_port_of( $home ); | |
| 3528 | + } | |
| 3529 | + | |
| 2962 | 3530 | /** |
| 2963 | - * Purge this site's cache. | |
| 3531 | + * Rebuild the canonical URL a purge applied to. | |
| 2964 | 3532 | * |
| 3533 | + * Built from the parts the purge itself used, so a listener is told the | |
| 3534 | + * URL we acted on rather than the string the caller happened to pass — | |
| 3535 | + * those differ whenever the caller supplied a site-relative path, a | |
| 3536 | + * different scheme, or a query string the cache key ignores. | |
| 3537 | + */ | |
| 3538 | + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string { | |
| 3539 | + // The purged URL's own scheme wins. purge_url() explicitly supports | |
| 3540 | + // cross-site purges (multisite, WP-CLI, cron), where composing the | |
| 3541 | + // current site's scheme onto another site's host builds a URL that was | |
| 3542 | + // never served — and a CDN listener then purges the wrong key and | |
| 3543 | + // reports success. | |
| 3544 | + if ( '' !== $url_scheme ) { | |
| 3545 | + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' ); | |
| 3546 | + } | |
| 3547 | + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http'; | |
| 3548 | + if ( function_exists( 'home_url' ) ) { | |
| 3549 | + $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. | |
| 3550 | + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) { | |
| 3551 | + $scheme = (string) $home['scheme']; | |
| 3552 | + } | |
| 3553 | + } | |
| 3554 | + // The query is carried even though OUR sweep above is path-based. | |
| 3555 | + // Caches in front commonly key on the full request line — LiteSpeed | |
| 3556 | + // tags `/shop/?page=2` separately from `/shop/` — so publishing the | |
| 3557 | + // bare path would have a listener confidently purge the wrong entry | |
| 3558 | + // and report success. Telling it exactly what was asked for lets it | |
| 3559 | + // act correctly; `removed` still describes only what WE removed. | |
| 3560 | + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' ); | |
| 3561 | + } | |
| 3562 | + | |
| 3563 | + /** | |
| 3564 | + * Sweep this site's cache files. | |
| 3565 | + * | |
| 2965 | 3566 | * On multisite every blog shares one cache directory, so an unscoped |
| 2966 | 3567 | * sweep here took the whole network cold — one subsite's settings save |
| 2967 | 3568 | * or post publish rebuilt every other site from PHP. Entries are stored |
| 2968 | 3569 | * per host (see host_dir()), and the sweep is scoped to match, so a |
| @@ -2969,12 +3570,13 @@ | ||
| 2969 | 3570 | * purge originating on site-a leaves site-b's cache warm. (#6) |
| 2970 | 3571 | * |
| 2971 | 3572 | * Clears the files only: the flat tree, the static tree, the REST |
| 2972 | 3573 | * responses and the minified assets. The object-cache flush, the stats |
| 2973 | - * update, `xspeed_after_purge_all` and the log entry live in purge_all(), | |
| 2974 | - * which is still the entry point for every existing caller. Split out so | |
| 2975 | - * `wp xspeed purge` can report the local sweep as one line item and the | |
| 2976 | - * object cache as another, each with its own status — see Purge_Runner. | |
| 3574 | + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract | |
| 3575 | + * event and the log entry live in purge_all(), which is still the entry | |
| 3576 | + * point for every existing caller. Split out so `wp xspeed purge` can | |
| 3577 | + * report the local sweep as one line item and the object cache as | |
| 3578 | + * another, each with its own status — see Purge_Runner. | |
| 2977 | 3579 | * |
| 2978 | 3580 | * @param string|null $host Host to purge. Defaults to the current site. |
| 2979 | 3581 | * Pass '*' to sweep the ENTIRE tree — network |
| 2980 | 3582 | * admin's "purge all sites", and the migration |
| @@ -2995,9 +3597,10 @@ | ||
| 2995 | 3597 | $static_scope = $network_wide ? '' : self::current_static_scope(); |
| 2996 | 3598 | } else { |
| 2997 | 3599 | $dir = self::host_dir( $host ); |
| 2998 | 3600 | $scope = '' === $dir ? 'default' : $dir; |
| 2999 | - $static_scope = $scope; | |
| 3601 | + $static_dir = self::static_host_dir( $host ); | |
| 3602 | + $static_scope = '' === $static_dir ? 'default' : $static_dir; | |
| 3000 | 3603 | } |
| 3001 | 3604 | |
| 3002 | 3605 | $count = 0; |
| 3003 | 3606 | if ( is_dir( XSPEED_CACHE_DIR ) ) { |
| @@ -3149,15 +3752,45 @@ | ||
| 3149 | 3752 | * inventory invalidation, purge log). |
| 3150 | 3753 | * |
| 3151 | 3754 | * @param string $cause Who asked, for the purge log. |
| 3152 | 3755 | * @param string|null $host See purge_local(). |
| 3756 | + * @param array<string,mixed> $invalidation Public adapter policy. `scope` | |
| 3757 | + * is urls/site/network/none, | |
| 3758 | + * `intent` explains why, and | |
| 3759 | + * `urls` supplies exact targets. | |
| 3153 | 3760 | * @return int Page + REST entries removed. |
| 3154 | 3761 | */ |
| 3155 | - public static function purge_all( string $cause = 'manual', ?string $host = null ) { | |
| 3156 | - $network_wide = ( '*' === $host ); | |
| 3157 | - $removed = self::purge_local( $host ); | |
| 3158 | - $count = $removed['pages'] + $removed['rest']; | |
| 3762 | + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) { | |
| 3763 | + $network_wide = ( '*' === $host ); | |
| 3764 | + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] ) | |
| 3765 | + ? $invalidation['scope'] | |
| 3766 | + : ( $network_wide ? 'network' : 'site' ); | |
| 3767 | + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) { | |
| 3768 | + $adapter_scope = $network_wide ? 'network' : 'site'; | |
| 3769 | + } | |
| 3770 | + if ( $network_wide ) { | |
| 3771 | + $adapter_scope = 'network'; | |
| 3772 | + } | |
| 3773 | + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent'] | |
| 3774 | + ? $invalidation['intent'] | |
| 3775 | + : 'complete'; | |
| 3776 | + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] ) | |
| 3777 | + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) ) | |
| 3778 | + : array(); | |
| 3779 | + // This method always sweeps a complete local bucket. A narrower adapter | |
| 3780 | + // announcement would claim unrelated local pages stayed warm when they | |
| 3781 | + // did not, leaving their server copies stale. Until purge_all() gains | |
| 3782 | + // dependency-aware local deletion, its response scope cannot be `urls`. | |
| 3783 | + if ( 'urls' === $adapter_scope ) { | |
| 3784 | + $adapter_scope = $network_wide ? 'network' : 'site'; | |
| 3785 | + } | |
| 3786 | + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) { | |
| 3787 | + $urls = array(); | |
| 3788 | + } | |
| 3159 | 3789 | |
| 3790 | + $removed = self::purge_local( $host ); | |
| 3791 | + $count = $removed['pages'] + $removed['rest']; | |
| 3792 | + | |
| 3160 | 3793 | self::flush_object_cache( $network_wide ); |
| 3161 | 3794 | |
| 3162 | 3795 | self::update_stats( array( 'last_purge' => time() ) ); |
| 3163 | 3796 | |
| @@ -3165,10 +3798,64 @@ | ||
| 3165 | 3798 | // Unused CSS, Cloudflare edge purge) run — this action had three |
| 3166 | 3799 | // registered listeners but was never emitted. Treat it as additive |
| 3167 | 3800 | // (CDN / edge invalidation), not the mechanism for clearing local |
| 3168 | 3801 | // files. (FBS-83114) |
| 3169 | - do_action( 'xspeed_after_purge_all', $cause ); | |
| 3802 | + // Wrapped: this action predates the purge-event contract and has its | |
| 3803 | + // own third-party listeners. One of them throwing used to abort | |
| 3804 | + // purge_all() here, which now also means the contract event below | |
| 3805 | + // never fires and a server cache keeps serving stale HTML. The local | |
| 3806 | + // sweep is already done by this point, so swallowing is strictly safer | |
| 3807 | + // than letting a listener decide the rest of the method runs. | |
| 3808 | + try { | |
| 3809 | + // Isolated per listener: one throwing used to cancel every | |
| 3810 | + // listener queued behind it — Critical CSS, Unused CSS and the | |
| 3811 | + // Cloudflare edge purge all hang off this hook. (QA #348) | |
| 3812 | + self::do_action_isolated( 'xspeed_after_purge_all', $cause ); | |
| 3813 | + } catch ( \Throwable $e ) { | |
| 3814 | + self::log_purge_listener_error( 'xspeed_after_purge_all', $e ); | |
| 3815 | + } | |
| 3170 | 3816 | |
| 3817 | + /** | |
| 3818 | + * Fires after a full purge, with the same bounded context shape as | |
| 3819 | + * `xspeed_after_purge_url`. | |
| 3820 | + * | |
| 3821 | + * Distinct from `xspeed_after_purge_all` on purpose. That action is | |
| 3822 | + * the long-standing internal signal — it passes a bare `$cause` string | |
| 3823 | + * and Free's own modules use it for local bookkeeping. This one is the | |
| 3824 | + * documented contract for OUTSIDE integrations: same argument shape as | |
| 3825 | + * the per-URL event, so a server-cache or CDN adapter can subscribe to | |
| 3826 | + * both with one handler and branch on a null `url`. | |
| 3827 | + * | |
| 3828 | + * Fires at most once per purge, and not at all when a listener's own | |
| 3829 | + * purge re-enters xSpeed. | |
| 3830 | + * | |
| 3831 | + * @since 1.2.3 | |
| 3832 | + * | |
| 3833 | + * @param array $context { | |
| 3834 | + * @type null $url Always null — a full purge has no single URL. | |
| 3835 | + * @type string $host Host swept, or '*' for the entire tree. | |
| 3836 | + * @type null $path Always null. | |
| 3837 | + * @type string $cause Short label for who asked. | |
| 3838 | + * @type int $removed Number of cache files removed. | |
| 3839 | + * @type string $scope Adapter action: urls/site/network/none. | |
| 3840 | + * @type string $intent content/presentation/complete or a caller-defined intent. | |
| 3841 | + * @type string[] $urls Exact targets when scope is urls. | |
| 3842 | + * } | |
| 3843 | + */ | |
| 3844 | + self::dispatch_purge_event( | |
| 3845 | + 'xspeed_after_purge', | |
| 3846 | + array( | |
| 3847 | + 'url' => null, | |
| 3848 | + 'host' => null === $host ? self::current_purge_host() : (string) $host, | |
| 3849 | + 'path' => null, | |
| 3850 | + 'cause' => $cause, | |
| 3851 | + 'removed' => $count, | |
| 3852 | + 'scope' => $adapter_scope, | |
| 3853 | + 'intent' => $intent, | |
| 3854 | + 'urls' => $urls, | |
| 3855 | + ) | |
| 3856 | + ); | |
| 3857 | + | |
| 3171 | 3858 | // The list behind the "Cached pages" card is memoized for a minute; |
| 3172 | 3859 | // a purge has to drop it or the drill-down shows pages that no |
| 3173 | 3860 | // longer exist. |
| 3174 | 3861 | Cache_Inventory::invalidate(); |
| @@ -3768,8 +4455,9 @@ | ||
| 3768 | 4455 | $count = self::purge_pages(); |
| 3769 | 4456 | self::update_stats( array( 'last_purge' => time() ) ); |
| 3770 | 4457 | Cache_Inventory::invalidate(); |
| 3771 | 4458 | self::record_partial_purge( 'page', $cause, $count ); |
| 4459 | + self::announce_purge( $cause, $count ); | |
| 3772 | 4460 | return $count; |
| 3773 | 4461 | |
| 3774 | 4462 | case 'assets': |
| 3775 | 4463 | if ( class_exists( '\\XSpeed\\Minifier' ) ) { |
| @@ -3796,8 +4484,9 @@ | ||
| 3796 | 4484 | $count = self::purge_pages(); |
| 3797 | 4485 | self::update_stats( array( 'last_purge' => time() ) ); |
| 3798 | 4486 | Cache_Inventory::invalidate(); |
| 3799 | 4487 | self::record_partial_purge( 'assets', $cause, $count ); |
| 4488 | + self::announce_purge( $cause, $count ); | |
| 3800 | 4489 | return $count; |
| 3801 | 4490 | |
| 3802 | 4491 | case 'object': |
| 3803 | 4492 | if ( function_exists( 'wp_cache_flush' ) ) { |
| @@ -3808,8 +4497,9 @@ | ||
| 3808 | 4497 | |
| 3809 | 4498 | case 'rest': |
| 3810 | 4499 | $count = Rest_Cache::purge(); |
| 3811 | 4500 | self::record_partial_purge( 'REST responses', $cause, $count ); |
| 4501 | + self::announce_purge( $cause, $count ); | |
| 3812 | 4502 | return $count; |
| 3813 | 4503 | |
| 3814 | 4504 | default: |
| 3815 | 4505 | return self::purge_type_unhandled( $type, $cause ); |
| @@ -3862,15 +4552,101 @@ | ||
| 3862 | 4552 | * @param string $type Purge-type slug. |
| 3863 | 4553 | * @param string $cause Who asked. |
| 3864 | 4554 | */ |
| 3865 | 4555 | private static function purge_type_unhandled( string $type, string $cause ): int { |
| 3866 | - do_action( 'xspeed_purge_type_' . $type ); | |
| 4556 | + $event_sequence = self::$purge_event_sequence; | |
| 4557 | + $hook = 'xspeed_purge_type_' . $type; | |
| 4558 | + $has_handler = false !== has_action( $hook ); | |
| 4559 | + do_action( $hook ); | |
| 3867 | 4560 | self::record_partial_purge( $type, $cause, null ); |
| 3868 | 4561 | |
| 4562 | + // Announce, same as the types this class owns. Pro's "Purge Critical | |
| 4563 | + // CSS" and "Purge Unused CSS" arrive here, and they change what a | |
| 4564 | + // cached page CONTAINS — critical CSS is inlined into the HTML, so a | |
| 4565 | + // server cache goes on serving pages with the old styles baked in. | |
| 4566 | + // Fixing the three Free buttons and leaving these two silent left the | |
| 4567 | + // same hole for the tier most likely to be using both plugins. | |
| 4568 | + // (QA #348 round 2, issue 2) | |
| 4569 | + // | |
| 4570 | + // Unknown slugs must not turn into a site-wide purge merely because no | |
| 4571 | + // handler exists. These are the response-changing Pro types Free knows; | |
| 4572 | + // third parties can declare another through the filter. A registered | |
| 4573 | + // handler plus this explicit response scope is the handled signal. | |
| 4574 | + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none'; | |
| 4575 | + /** | |
| 4576 | + * Declare whether a handled custom purge type changes cached responses. | |
| 4577 | + * | |
| 4578 | + * @since 1.2.3 | |
| 4579 | + * @param string $scope site/network/none. | |
| 4580 | + * @param string $type Purge-type slug. | |
| 4581 | + */ | |
| 4582 | + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type ); | |
| 4583 | + if ( $has_handler | |
| 4584 | + && $event_sequence === self::$purge_event_sequence | |
| 4585 | + && in_array( $scope, array( 'site', 'network' ), true ) | |
| 4586 | + ) { | |
| 4587 | + self::announce_purge( $cause, 0, $scope, 'presentation' ); | |
| 4588 | + } | |
| 4589 | + | |
| 3869 | 4590 | return 0; |
| 3870 | 4591 | } |
| 3871 | 4592 | |
| 3872 | 4593 | /** |
| 4594 | + * Tell the server cache that a PARTIAL purge cleared cached responses. | |
| 4595 | + * | |
| 4596 | + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST | |
| 4597 | + * Cache" each delete cached RESPONSES for the whole site, so a cache in | |
| 4598 | + * front of PHP is now serving copies xSpeed has just thrown away. Only | |
| 4599 | + * "Purge All" announced itself, which left three of the four toolbar | |
| 4600 | + * buttons doing exactly what this contract exists to prevent: clearing | |
| 4601 | + * our copy while the server kept serving the stale one. The `assets` case | |
| 4602 | + * was the sharpest — it deletes the minified bundles too, so LiteSpeed | |
| 4603 | + * went on serving pages whose CSS and JS no longer exist. (QA #348) | |
| 4604 | + * | |
| 4605 | + * Sent as the full-purge shape (`url` null) because that is what happened: | |
| 4606 | + * every cached page for this site went, not one address. `object` is not | |
| 4607 | + * announced — flushing the object cache changes no rendered response a | |
| 4608 | + * server cache could be holding. | |
| 4609 | + * | |
| 4610 | + * Public because Purge_Runner sweeps the local files itself, through | |
| 4611 | + * purge_local(), rather than through purge_all() — so it has to announce | |
| 4612 | + * on its own behalf or `wp xspeed purge` and the dashboard button clear | |
| 4613 | + * our copy while LiteSpeed keeps serving the stale one. | |
| 4614 | + * | |
| 4615 | + * @param string $cause Who asked. | |
| 4616 | + * @param int $removed Entries removed locally. | |
| 4617 | + * @param string $scope Actionable adapter scope. | |
| 4618 | + * @param string $intent Reason rendered responses changed. | |
| 4619 | + */ | |
| 4620 | + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void { | |
| 4621 | + // Announcing is additive: the local sweep has already happened and | |
| 4622 | + // succeeded. Notification must never be able to turn a working purge | |
| 4623 | + // into a fatal, so anything the URL helpers do in an unusual context | |
| 4624 | + // (early boot, a drop-in, a bare test harness) is contained here | |
| 4625 | + // rather than propagating to the caller. | |
| 4626 | + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) { | |
| 4627 | + return; | |
| 4628 | + } | |
| 4629 | + try { | |
| 4630 | + self::dispatch_purge_event( | |
| 4631 | + 'xspeed_after_purge', | |
| 4632 | + array( | |
| 4633 | + 'url' => null, | |
| 4634 | + 'host' => self::current_purge_host(), | |
| 4635 | + 'path' => null, | |
| 4636 | + 'cause' => $cause, | |
| 4637 | + 'removed' => $removed, | |
| 4638 | + 'scope' => $scope, | |
| 4639 | + 'intent' => $intent, | |
| 4640 | + 'urls' => array(), | |
| 4641 | + ) | |
| 4642 | + ); | |
| 4643 | + } catch ( \Throwable $e ) { | |
| 4644 | + self::log_purge_listener_error( 'xspeed_after_purge', $e ); | |
| 4645 | + } | |
| 4646 | + } | |
| 4647 | + | |
| 4648 | + /** | |
| 3873 | 4649 | * Log a partial purge so the drill-down behind "Last purge" shows every |
| 3874 | 4650 | * clear, not only the full ones. Without this a site whose object cache |
| 3875 | 4651 | * is flushed on a schedule looks, from the log, like nothing happens. |
| 3876 | 4652 | * |
| @@ -4743,10 +5519,11 @@ | ||
| 4743 | 5519 | * they don't share a user at all. A default-umask 0644 file is then |
| 4744 | 5520 | * unwritable by nginx, the access_log write silently fails, and the |
| 4745 | 5521 | * dashboard shows a 0% hit ratio even though static HITs are serving. |
| 4746 | 5522 | * So we widen the dir to 0777 and the file to 0666 — group/other write — |
| 4747 | - * so whatever uid nginx runs as can append. (The file holds only HIT | |
| 4748 | - * request lines, no secrets.) | |
| 5523 | + * so whatever uid nginx runs as can append. The file holds HIT request | |
| 5524 | + * lines and must be protected like an access log: paths and queries can | |
| 5525 | + * contain sensitive values. | |
| 4749 | 5526 | */ |
| 4750 | 5527 | /** |
| 4751 | 5528 | * Directory holding the nginx hit log. Lives under uploads/, NOT the |
| 4752 | 5529 | * cache dir — uninstall.php and a cache purge both delete the cache |