PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.0 All 28 releases
← All changes | includes/class-cache.php +42 -819 trunk1.3.0 View file →
@@ -89,12 +89,9 @@
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',
95 93 'comment_post',
96 - 'wp_set_comment_status',
97 94 'user_register',
98 95 'profile_update',
99 96 );
100 97
@@ -158,9 +155,9 @@
158 155 // rendered author bylines / term-archive pages. Without them, an edit
159 156 // left the matching endpoint (and archives) stale for the full TTL.
160 157 // (FBS-82408)
161 158 $invalidate_hooks = array(
162 - 'save_post', 'before_delete_post', 'trashed_post',
159 + 'save_post', 'deleted_post', 'trashed_post',
163 160 'comment_post', 'wp_set_comment_status',
164 161 'switch_theme', 'activated_plugin', 'deactivated_plugin',
165 162 // Users → /wp/v2/users + author archives.
166 163 'profile_update', 'user_register', 'deleted_user',
@@ -190,13 +187,9 @@
190 187 }
191 188 add_action(
192 189 $hook,
193 190 static function () use ( $hook ): void {
194 - self::purge_all(
195 - 'hook:' . $hook,
196 - null,
197 - self::invalidation_for_hook( $hook )
198 - );
191 + self::purge_all( 'hook:' . $hook );
199 192 }
200 193 );
201 194 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
202 195 }
@@ -267,21 +260,12 @@
267 260 // and generalises to Flamingo (#229) and Tutor LMS (#231) too.
268 261 remove_action( 'save_post', array( __CLASS__, 'purge_all' ) );
269 262 remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
270 263 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 );
279 264
280 265 remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) );
281 266 remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
282 267 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 );
284 268
285 269 remove_action( 'user_register', array( __CLASS__, 'purge_all' ) );
286 270 remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) );
287 271 add_action( 'user_register', array( __CLASS__, 'on_user_change' ) );
@@ -1410,10 +1394,9 @@
1410 1394 * exactly this blog's pages.
1411 1395 */
1412 1396 public static function current_static_scope(): string {
1413 1397 // Same switch_to_blog() caveat as current_host_dir() — see current_host().
1414 - // Keep the port folded into the segment exactly as store_static() does.
1415 - $dir = self::static_host_dir( self::current_host() );
1398 + $dir = self::host_dir( self::current_host() );
1416 1399 if ( '' === $dir ) {
1417 1400 $dir = 'default';
1418 1401 }
1419 1402 $path = self::site_path_raw();
@@ -2641,101 +2624,8 @@
2641 2624 * TTL — up to 30 days at the maximum lifetime. (#270 regression)
2642 2625 *
2643 2626 * @return string[]
2644 2627 */
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 -
2738 2628 public static function presentation_post_types(): array {
2739 2629 $types = array(
2740 2630 'wp_template', // Site Editor templates.
2741 2631 'wp_template_part', // Header / footer / reusable parts.
@@ -2756,37 +2646,8 @@
2756 2646 return (array) apply_filters( 'xspeed_presentation_post_types', $types );
2757 2647 }
2758 2648
2759 2649 /**
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 - /**
2789 2650 * save_post → purge only when the saved thing can appear on a cached page.
2790 2651 *
2791 2652 * Revisions and autosaves are never rendered. Non-viewable post types —
2792 2653 * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
@@ -2801,32 +2662,35 @@
2801 2662 * @param int $post_id Saved post ID.
2802 2663 * @param \WP_Post $post Saved post object.
2803 2664 */
2804 2665 public static function on_save_post( $post_id, $post = null ): void {
2805 - if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
2666 + if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
2806 2667 return;
2807 2668 }
2669 + if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
2670 + return;
2671 + }
2808 2672
2809 2673 $post_type = is_object( $post ) && isset( $post->post_type )
2810 2674 ? (string) $post->post_type
2811 2675 : (string) get_post_type( $post_id );
2676 + if ( '' === $post_type ) {
2677 + return;
2678 + }
2812 2679
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 +
2813 2689 // Name the trigger rather than logging a bare numeric id — the old
2814 2690 // wiring passed the post ID into $cause, so the log read
2815 2691 // "Cache purged (46)" with no indication of what caused it. (#243)
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 - );
2692 + self::purge_all( 'post:' . $post_type );
2829 2693 if ( class_exists( '\XSpeed\Minifier' ) ) {
2830 2694 Minifier::purge_minified();
2831 2695 }
2832 2696 }
@@ -2831,61 +2695,8 @@
2831 2695 }
2832 2696 }
2833 2697
2834 2698 /**
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 - /**
2888 2699 * comment_post → purge just the commented-on URL, and only once the
2889 2700 * comment is actually visible.
2890 2701 *
2891 2702 * A comment held for moderation changes nothing on the front end, and an
@@ -3036,304 +2847,13 @@
3036 2847 public static function purge_product_object( $product ): void {
3037 2848 self::purge_product( $product );
3038 2849 }
3039 2850
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 -
3299 2851 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 - }
3311 2852 $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.
3312 2853 if ( ! is_array( $parts ) ) {
3313 2854 return 0;
3314 2855 }
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 - }
3336 2856 // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
3337 2857 // carries `:8080` on any install not served from 80/443 — while
3338 2858 // parse_url() splits the port into its own component, so a purge that
3339 2859 // used the bare host computed a different md5, found no file, and
@@ -3339,34 +2859,19 @@
3339 2859 // used the bare host computed a different md5, found no file, and
3340 2860 // reported "already cold". A silent no-op: the page kept serving HIT
3341 2861 // until its TTL ran out. Intranet installs, panel hosts on :8443 and
3342 2862 // proxies that forward `Host: site.com:8080` all hit this.
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 - }
2863 + $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
2864 + if ( '' !== $host && isset( $parts['port'] ) ) {
2865 + $host .= ':' . (int) $parts['port'];
3363 2866 }
3364 - $host = self::host_port_of( $parts );
3365 2867 if ( '' === $host && function_exists( 'home_url' ) ) {
3366 2868 $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.
3367 - if ( is_array( $home ) ) {
3368 - $host = self::host_port_of( $home );
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 + }
3369 2874 }
3370 2875 }
3371 2876 if ( '' === $host ) {
3372 2877 return 0;
@@ -3450,120 +2955,14 @@
3450 2955 Activity_Log::INFO
3451 2956 );
3452 2957 }
3453 2958
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 -
3511 2959 return $count;
3512 2960 }
3513 2961
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 -
3530 2962 /**
3531 - * Rebuild the canonical URL a purge applied to.
2963 + * Purge this site's cache.
3532 2964 *
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 - *
3566 2965 * On multisite every blog shares one cache directory, so an unscoped
3567 2966 * sweep here took the whole network cold — one subsite's settings save
3568 2967 * or post publish rebuilt every other site from PHP. Entries are stored
3569 2968 * per host (see host_dir()), and the sweep is scoped to match, so a
@@ -3570,13 +2969,12 @@
3570 2969 * purge originating on site-a leaves site-b's cache warm. (#6)
3571 2970 *
3572 2971 * Clears the files only: the flat tree, the static tree, the REST
3573 2972 * responses and the minified assets. The object-cache flush, the stats
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.
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.
3579 2977 *
3580 2978 * @param string|null $host Host to purge. Defaults to the current site.
3581 2979 * Pass '*' to sweep the ENTIRE tree — network
3582 2980 * admin's "purge all sites", and the migration
@@ -3597,10 +2995,9 @@
3597 2995 $static_scope = $network_wide ? '' : self::current_static_scope();
3598 2996 } else {
3599 2997 $dir = self::host_dir( $host );
3600 2998 $scope = '' === $dir ? 'default' : $dir;
3601 - $static_dir = self::static_host_dir( $host );
3602 - $static_scope = '' === $static_dir ? 'default' : $static_dir;
2999 + $static_scope = $scope;
3603 3000 }
3604 3001
3605 3002 $count = 0;
3606 3003 if ( is_dir( XSPEED_CACHE_DIR ) ) {
@@ -3752,45 +3149,15 @@
3752 3149 * inventory invalidation, purge log).
3753 3150 *
3754 3151 * @param string $cause Who asked, for the purge log.
3755 3152 * @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.
3760 3153 * @return int Page + REST entries removed.
3761 3154 */
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 - }
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'];
3789 3159
3790 - $removed = self::purge_local( $host );
3791 - $count = $removed['pages'] + $removed['rest'];
3792 -
3793 3160 self::flush_object_cache( $network_wide );
3794 3161
3795 3162 self::update_stats( array( 'last_purge' => time() ) );
3796 3163
@@ -3798,64 +3165,10 @@
3798 3165 // Unused CSS, Cloudflare edge purge) run — this action had three
3799 3166 // registered listeners but was never emitted. Treat it as additive
3800 3167 // (CDN / edge invalidation), not the mechanism for clearing local
3801 3168 // files. (FBS-83114)
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 - }
3169 + do_action( 'xspeed_after_purge_all', $cause );
3816 3170
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 -
3858 3171 // The list behind the "Cached pages" card is memoized for a minute;
3859 3172 // a purge has to drop it or the drill-down shows pages that no
3860 3173 // longer exist.
3861 3174 Cache_Inventory::invalidate();
@@ -4455,9 +3768,8 @@
4455 3768 $count = self::purge_pages();
4456 3769 self::update_stats( array( 'last_purge' => time() ) );
4457 3770 Cache_Inventory::invalidate();
4458 3771 self::record_partial_purge( 'page', $cause, $count );
4459 - self::announce_purge( $cause, $count );
4460 3772 return $count;
4461 3773
4462 3774 case 'assets':
4463 3775 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
@@ -4484,9 +3796,8 @@
4484 3796 $count = self::purge_pages();
4485 3797 self::update_stats( array( 'last_purge' => time() ) );
4486 3798 Cache_Inventory::invalidate();
4487 3799 self::record_partial_purge( 'assets', $cause, $count );
4488 - self::announce_purge( $cause, $count );
4489 3800 return $count;
4490 3801
4491 3802 case 'object':
4492 3803 if ( function_exists( 'wp_cache_flush' ) ) {
@@ -4497,9 +3808,8 @@
4497 3808
4498 3809 case 'rest':
4499 3810 $count = Rest_Cache::purge();
4500 3811 self::record_partial_purge( 'REST responses', $cause, $count );
4501 - self::announce_purge( $cause, $count );
4502 3812 return $count;
4503 3813
4504 3814 default:
4505 3815 return self::purge_type_unhandled( $type, $cause );
@@ -4552,101 +3862,15 @@
4552 3862 * @param string $type Purge-type slug.
4553 3863 * @param string $cause Who asked.
4554 3864 */
4555 3865 private static function purge_type_unhandled( string $type, string $cause ): int {
4556 - $event_sequence = self::$purge_event_sequence;
4557 - $hook = 'xspeed_purge_type_' . $type;
4558 - $has_handler = false !== has_action( $hook );
4559 - do_action( $hook );
3866 + do_action( 'xspeed_purge_type_' . $type );
4560 3867 self::record_partial_purge( $type, $cause, null );
4561 3868
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 -
4590 3869 return 0;
4591 3870 }
4592 3871
4593 3872 /**
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 - /**
4649 3873 * Log a partial purge so the drill-down behind "Last purge" shows every
4650 3874 * clear, not only the full ones. Without this a site whose object cache
4651 3875 * is flushed on a schedule looks, from the log, like nothing happens.
4652 3876 *
@@ -5519,11 +4743,10 @@
5519 4743 * they don't share a user at all. A default-umask 0644 file is then
5520 4744 * unwritable by nginx, the access_log write silently fails, and the
5521 4745 * dashboard shows a 0% hit ratio even though static HITs are serving.
5522 4746 * So we widen the dir to 0777 and the file to 0666 — group/other write —
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.
4747 + * so whatever uid nginx runs as can append. (The file holds only HIT
4748 + * request lines, no secrets.)
5526 4749 */
5527 4750 /**
5528 4751 * Directory holding the nginx hit log. Lives under uploads/, NOT the
5529 4752 * cache dir — uninstall.php and a cache purge both delete the cache