| @@ -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 |