PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-cache.php +819 -42 1.3.01.3.2 View file →
@@ -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