| @@ -19,11 +19,158 @@ | ||
| 19 | 19 | * @var int|null |
| 20 | 20 | */ |
| 21 | 21 | private static $buffer_level = null; |
| 22 | 22 | |
| 23 | + /** | |
| 24 | + * Bytes freed by the current sweep, accumulated by sweep_delete(). | |
| 25 | + * | |
| 26 | + * A counter rather than a return value because the two sweeps that free | |
| 27 | + * the bytes — the flat glob loop and the recursive static walk — already | |
| 28 | + * report a FILE count, and `wp xspeed purge` needs both numbers from a | |
| 29 | + * single pass. Re-walking the tree to size it would double the I/O on | |
| 30 | + * exactly the caches large enough for the number to matter. | |
| 31 | + * | |
| 32 | + * @var int | |
| 33 | + */ | |
| 34 | + private static $sweep_bytes = 0; | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * The `X-XSpeed-Cache` value decided for this request, and — when the | |
| 38 | + * decision was BYPASS — the slug of the gate that made it. | |
| 39 | + * | |
| 40 | + * Recorded as well as sent so unit tests (CLI SAPI, where header() is a | |
| 41 | + * no-op and headers_sent() is meaningless) can assert on the decision. | |
| 42 | + * | |
| 43 | + * @var string | |
| 44 | + */ | |
| 45 | + private static $status_header = ''; | |
| 46 | + private static $bypass_reason = ''; | |
| 47 | + | |
| 48 | + /** | |
| 49 | + * Edge/CDN headers decided for this request, after sanitising. | |
| 50 | + * | |
| 51 | + * Same reason as $status_header: header() cannot be observed from the CLI | |
| 52 | + * SAPI, so the pairs we sent are recorded here too. | |
| 53 | + * | |
| 54 | + * @var array<string,string> | |
| 55 | + */ | |
| 56 | + private static $edge_headers = array(); | |
| 57 | + | |
| 58 | + /** | |
| 59 | + * This entry's edge headers when they differ from the site-wide bake, | |
| 60 | + * resolved once per store. Null until asked. | |
| 61 | + * | |
| 62 | + * @var array<string,string>|null | |
| 63 | + */ | |
| 64 | + private static $per_entry_edge = null; | |
| 65 | + | |
| 66 | + /** | |
| 67 | + * Cache key whose write was deferred to shutdown because a render-time | |
| 68 | + * translation plugin's buffer wraps ours. Null on every ordinary request. | |
| 69 | + * | |
| 70 | + * @var string|null | |
| 71 | + */ | |
| 72 | + private static $deferred_key = null; | |
| 73 | + | |
| 74 | + /** | |
| 75 | + * Translated page HTML captured by the outer buffer, for the deferred | |
| 76 | + * write. Only populated when a translation plugin is active. | |
| 77 | + * | |
| 78 | + * @var string | |
| 79 | + */ | |
| 80 | + private static $translated_output = ''; | |
| 81 | + | |
| 82 | + /** | |
| 83 | + * Did finalize_buffer() run to completion on this request? | |
| 84 | + * | |
| 85 | + * The deferred translated write runs as a PHP shutdown function, which | |
| 86 | + * fires after a `wp_die()` or a bare `exit()` exactly as it does after a | |
| 87 | + * clean render. Only finalize_buffer() sets this, and only at the point | |
| 88 | + * where it has the full buffer in hand — so an aborted render leaves it | |
| 89 | + * false and the writer declines rather than caching a truncated page | |
| 90 | + * under the real key. | |
| 91 | + * | |
| 92 | + * @var bool | |
| 93 | + */ | |
| 94 | + private static $render_completed = false; | |
| 95 | + | |
| 96 | + /** | |
| 97 | + * Hooks that get an argument-aware handler instead of a blanket purge. | |
| 98 | + * | |
| 99 | + * Each fires on an ordinary visitor action — an order, a review, a | |
| 100 | + * registration — where purge_all() cannot see WHAT changed and so wiped | |
| 101 | + * the whole cache on every one. They are re-bound further down to | |
| 102 | + * handlers that inspect the payload first. | |
| 103 | + * | |
| 104 | + * Listed here so the generic invalidation loop skips them. It binds a | |
| 105 | + * closure (to name the cause), and a closure cannot be unbound by the | |
| 106 | + * remove_action() pairs below — binding one would leave the coarse purge | |
| 107 | + * running alongside its replacement and silently undo #243. | |
| 108 | + */ | |
| 109 | + private const TARGETED_INVALIDATION_HOOKS = array( | |
| 110 | + 'save_post', | |
| 111 | + 'before_delete_post', | |
| 112 | + 'trashed_post', | |
| 113 | + 'comment_post', | |
| 114 | + 'wp_set_comment_status', | |
| 115 | + 'user_register', | |
| 116 | + 'profile_update', | |
| 117 | + ); | |
| 118 | + | |
| 23 | 119 | public function __construct() { |
| 24 | - add_action( 'template_redirect', array( $this, 'maybe_start_cache' ), 0 ); | |
| 120 | + /** | |
| 121 | + * When the page-cache output buffer opens. | |
| 122 | + * | |
| 123 | + * Filterable because buffer ORDER decides what gets cached. PHP's | |
| 124 | + * output buffers are LIFO: the last one opened is innermost, and its | |
| 125 | + * callback runs first. A render-time translation plugin that opens | |
| 126 | + * an outer buffer therefore translates AFTER we have already captured | |
| 127 | + * and cached the raw HTML — see translation_buffer_compat(). | |
| 128 | + * | |
| 129 | + * @param string $hook Hook to open the buffer on. | |
| 130 | + * @param int $priority Priority for that hook. | |
| 131 | + */ | |
| 132 | + $hook = (string) apply_filters( 'xspeed_cache_buffer_hook', 'template_redirect' ); | |
| 133 | + $priority = (int) apply_filters( 'xspeed_cache_buffer_priority', 0 ); | |
| 134 | + add_action( $hook, array( $this, 'maybe_start_cache' ), $priority ); | |
| 25 | 135 | |
| 136 | + // When a render-time translation plugin is present, open one extra | |
| 137 | + // buffer OUTSIDE its own so we can capture post-translation HTML. | |
| 138 | + // TranslatePress opens on `init` priority 0, so we take a negative | |
| 139 | + // priority to land outside it. This buffer only collects bytes for | |
| 140 | + // the deferred cache write — it never modifies the response. | |
| 141 | + add_action( | |
| 142 | + 'init', | |
| 143 | + static function () { | |
| 144 | + if ( ! self::translation_plugin_active() ) { | |
| 145 | + return; | |
| 146 | + } | |
| 147 | + // `init` fires on EVERY request type, and | |
| 148 | + // translation_plugin_active() is a class_exists() check that | |
| 149 | + // is true site-wide — so without this guard the buffer opened | |
| 150 | + // on REST, admin-ajax, cron and WP-CLI too. None of those | |
| 151 | + // reach template_redirect, so $deferred_key stays null and | |
| 152 | + // the collected bytes are never released: a long-running | |
| 153 | + // WP-CLI command copied every byte of its output into a | |
| 154 | + // string that grew for the life of the process. | |
| 155 | + if ( is_admin() | |
| 156 | + || wp_doing_ajax() | |
| 157 | + || wp_doing_cron() | |
| 158 | + || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) | |
| 159 | + || ( defined( 'WP_CLI' ) && WP_CLI ) | |
| 160 | + || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) { | |
| 161 | + return; | |
| 162 | + } | |
| 163 | + ob_start( | |
| 164 | + static function ( $chunk ) { | |
| 165 | + self::$translated_output .= $chunk; | |
| 166 | + return $chunk; | |
| 167 | + } | |
| 168 | + ); | |
| 169 | + }, | |
| 170 | + (int) apply_filters( 'xspeed_translation_outer_buffer_priority', -100 ) | |
| 171 | + ); | |
| 172 | + | |
| 26 | 173 | // Events that should invalidate cached output. Beyond posts/comments, |
| 27 | 174 | // this covers user and term changes — the REST cache can serve |
| 28 | 175 | // /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect |
| 29 | 176 | // rendered author bylines / term-archive pages. Without them, an edit |
| @@ -29,9 +176,9 @@ | ||
| 29 | 176 | // rendered author bylines / term-archive pages. Without them, an edit |
| 30 | 177 | // left the matching endpoint (and archives) stale for the full TTL. |
| 31 | 178 | // (FBS-82408) |
| 32 | 179 | $invalidate_hooks = array( |
| 33 | - 'save_post', 'deleted_post', 'trashed_post', | |
| 180 | + 'save_post', 'before_delete_post', 'trashed_post', | |
| 34 | 181 | 'comment_post', 'wp_set_comment_status', |
| 35 | 182 | 'switch_theme', 'activated_plugin', 'deactivated_plugin', |
| 36 | 183 | // Users → /wp/v2/users + author archives. |
| 37 | 184 | 'profile_update', 'user_register', 'deleted_user', |
| @@ -36,16 +183,177 @@ | ||
| 36 | 183 | // Users → /wp/v2/users + author archives. |
| 37 | 184 | 'profile_update', 'user_register', 'deleted_user', |
| 38 | 185 | // Terms → /wp/v2/{taxonomy} + term archives. |
| 39 | 186 | 'created_term', 'edited_term', 'delete_term', |
| 187 | + // Menu structure changes (reorder, rename, assign to a location) | |
| 188 | + // fire only here — the per-item `nav_menu_item` save_post does | |
| 189 | + // not cover them. (#270 regression) | |
| 190 | + 'wp_update_nav_menu', | |
| 40 | 191 | ); |
| 41 | 192 | foreach ( $invalidate_hooks as $hook ) { |
| 42 | - add_action( $hook, array( __CLASS__, 'purge_all' ) ); | |
| 193 | + // Name the hook in the cause rather than binding purge_all bare. | |
| 194 | + // Bound bare, WordPress passes the action's own first argument | |
| 195 | + // into $cause — a term id, a user id, a menu id — so the activity | |
| 196 | + // feed read "Cache purged (12)" and told the user nothing about | |
| 197 | + // what happened. (#270 QA round 2) | |
| 198 | + // | |
| 199 | + // The four hooks that get an argument-aware handler below | |
| 200 | + // (save_post, comment_post, user_register, profile_update) are | |
| 201 | + // deliberately NOT wired here: a closure cannot be unbound by | |
| 202 | + // remove_action(), so binding one would leave the coarse purge in | |
| 203 | + // place alongside its replacement and silently undo #243. Skipping | |
| 204 | + // them is equivalent — each is re-added with its own handler, and | |
| 205 | + // each of those names its own cause. | |
| 206 | + if ( in_array( $hook, self::TARGETED_INVALIDATION_HOOKS, true ) ) { | |
| 207 | + continue; | |
| 208 | + } | |
| 209 | + add_action( | |
| 210 | + $hook, | |
| 211 | + static function () use ( $hook ): void { | |
| 212 | + self::purge_all( | |
| 213 | + 'hook:' . $hook, | |
| 214 | + null, | |
| 215 | + self::invalidation_for_hook( $hook ) | |
| 216 | + ); | |
| 217 | + } | |
| 218 | + ); | |
| 43 | 219 | add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) ); |
| 44 | 220 | } |
| 45 | 221 | |
| 222 | + // Updating a plugin, theme or core changes the markup and the assets | |
| 223 | + // a page is built from, but fires NONE of the hooks above: WordPress | |
| 224 | + // does not deactivate and reactivate a plugin to update it, so | |
| 225 | + // `activated_plugin` never runs and the cached HTML survives the | |
| 226 | + // update untouched for the whole TTL — up to 7 days on the Aggressive | |
| 227 | + // preset, 30 at the maximum. | |
| 228 | + // | |
| 229 | + // The stale copy is not merely old, it is wrong in a way the user | |
| 230 | + // cannot see the cause of: they update a plugin to get a fix, the | |
| 231 | + // cache keeps serving the pre-fix HTML, and the update looks like it | |
| 232 | + // did nothing. Minified assets do regenerate on their own (their key | |
| 233 | + // includes the source filemtime), which makes it worse rather than | |
| 234 | + // better — the cached pages still link the PREVIOUS hashes. | |
| 235 | + // | |
| 236 | + // Purge unconditionally on any completed update. Scoping it to | |
| 237 | + // "plugins that enqueue front-end assets" is not knowable here, and a | |
| 238 | + // cold cache after an update is the cheaper mistake. (#269) | |
| 239 | + add_action( 'upgrader_process_complete', array( __CLASS__, 'purge_after_upgrade' ), 10, 2 ); | |
| 240 | + // The replacement signal has to outlive OUR listener: add-ons read it | |
| 241 | + // through upgrade_replaced_code() from their own priority-10 callbacks, | |
| 242 | + // and consuming it inside purge_after_upgrade() meant whoever | |
| 243 | + // registered second saw false. Cleared at the END of the dispatch | |
| 244 | + // instead, once every listener has had its turn. | |
| 245 | + // | |
| 246 | + // Depth-counted, because this action NESTS. Core hangs | |
| 247 | + // Language_Pack_Upgrader::async_upgrade() on it at priority 20 | |
| 248 | + // (wp-admin/includes/admin-filters.php), and that runs a whole | |
| 249 | + // upgrader of its own, which fires this same action again. A flat | |
| 250 | + // reset therefore fired while the OUTER dispatch was still running — | |
| 251 | + // on any site with pending translations — and every listener after | |
| 252 | + // priority 20 read the cleared signal as false. Which is the bug this | |
| 253 | + // pair exists to fix, back again and harder to see. (#303) | |
| 254 | + add_action( 'upgrader_process_complete', array( __CLASS__, 'note_upgrade_dispatch' ), PHP_INT_MIN ); | |
| 255 | + add_action( 'upgrader_process_complete', array( __CLASS__, 'forget_cleared_destination' ), PHP_INT_MAX ); | |
| 256 | + // WordPress labels an upload-and-replace as an INSTALL, so the action | |
| 257 | + // alone cannot tell "added beside nothing" from "replaced live code". | |
| 258 | + // This filter fires only when the upgrader removed an existing copy, | |
| 259 | + // which is exactly the difference. Registered as a filter listener | |
| 260 | + // that returns its input untouched. (#303) | |
| 261 | + add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 1 ); | |
| 262 | + // Unattended auto-updates are the case that matters most here: they | |
| 263 | + // land overnight with nobody around to purge by hand, which is the | |
| 264 | + // exact scenario the stale cache goes undiagnosed in. WordPress fires | |
| 265 | + // this INSTEAD of a per-item upgrader_process_complete for some | |
| 266 | + // background runs. Its payload is a results array keyed by type | |
| 267 | + // rather than a hook_extra, so it needs its own handler — passing it | |
| 268 | + // to purge_after_upgrade() landed it in the unused $upgrader slot and | |
| 269 | + // left $type empty, which read as "invalidating" and purged the whole | |
| 270 | + // cache for a language-pack-only run. Matches what LiteSpeed binds. | |
| 271 | + // (#298) | |
| 272 | + add_action( 'automatic_updates_complete', array( __CLASS__, 'purge_after_auto_updates' ), 10, 1 ); | |
| 273 | + // …except the four hooks above that fire on ordinary visitor actions. | |
| 274 | + // Attached bare, purge_all() can't see WHAT changed, so on a store | |
| 275 | + // every order, every product review and every checkout | |
| 276 | + // account-creation wiped 100% of the cache — all anonymous happy-path | |
| 277 | + // actions, so the cache never reached steady state (#243). Measured: | |
| 278 | + // 3 orders across 36 pageviews took the hit rate from 83% to 50% and | |
| 279 | + // the average response from 23ms to 57ms. | |
| 280 | + // | |
| 281 | + // HPOS does NOT help: WooCommerce still writes a | |
| 282 | + // `shop_order_placehold` row into wp_posts to reserve the order ID, | |
| 283 | + // so save_post fires either way. The gate therefore keys on POST-TYPE | |
| 284 | + // VIEWABILITY, not on storage mode — which fixes both modes at once, | |
| 285 | + // and generalises to Flamingo (#229) and Tutor LMS (#231) too. | |
| 286 | + remove_action( 'save_post', array( __CLASS__, 'purge_all' ) ); | |
| 287 | + remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) ); | |
| 288 | + add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 ); | |
| 289 | + add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 290 | + add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 291 | + // wp_delete_post() hands an attachment to wp_delete_attachment() and | |
| 292 | + // returns BEFORE before_delete_post fires, so deleting media reached | |
| 293 | + // neither hook above. Attachment pages are public and media appears in | |
| 294 | + // galleries, so that left cached pages showing a file that is gone. | |
| 295 | + // (dev caught this via `deleted_post`, which this branch replaced.) | |
| 296 | + add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 297 | + | |
| 298 | + remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) ); | |
| 299 | + remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) ); | |
| 300 | + add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 ); | |
| 301 | + add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 ); | |
| 302 | + | |
| 303 | + remove_action( 'user_register', array( __CLASS__, 'purge_all' ) ); | |
| 304 | + remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) ); | |
| 305 | + add_action( 'user_register', array( __CLASS__, 'on_user_change' ) ); | |
| 306 | + | |
| 307 | + remove_action( 'profile_update', array( __CLASS__, 'purge_all' ) ); | |
| 308 | + remove_action( 'profile_update', array( 'XSpeed\\Minifier', 'purge_minified' ) ); | |
| 309 | + add_action( 'profile_update', array( __CLASS__, 'on_user_change' ) ); | |
| 310 | + | |
| 311 | + // Product data lives in post meta and lookup tables, NOT in wp_posts, | |
| 312 | + // so WC_Product_Data_Store_CPT::update() takes a direct $wpdb->update() | |
| 313 | + // branch and save_post never fires. Anchoring invalidation on | |
| 314 | + // save_post therefore missed 100% of commerce-relevant mutations: a | |
| 315 | + // REST price change, wc_update_product_stock(), a CLI ->save(), and | |
| 316 | + // every scheduled sale start/end left the product page, the shop and | |
| 317 | + // the category archives serving the old price and stock for the full | |
| 318 | + // lifetime — the store quoting one price and charging another (#242). | |
| 319 | + // | |
| 320 | + // This MUST ship with the gate above: once orders stop purging | |
| 321 | + // everything, the accidental invalidation that was masking this | |
| 322 | + // disappears, and an order that reduces stock would leave the product | |
| 323 | + // page stale. | |
| 324 | + if ( class_exists( 'WooCommerce' ) ) { | |
| 325 | + foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) { | |
| 326 | + add_action( $wc_hook, array( __CLASS__, 'purge_product' ) ); | |
| 327 | + } | |
| 328 | + // Direct stock writes bypass the CRUD entirely. | |
| 329 | + add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'purge_product_object' ) ); | |
| 330 | + add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'purge_product_object' ) ); | |
| 331 | + add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) ); | |
| 332 | + add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) ); | |
| 333 | + } | |
| 334 | + | |
| 46 | 335 | add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 ); |
| 47 | 336 | |
| 337 | + // …and the same for every PER-MODULE option. The handler above only | |
| 338 | + // ever watched the legacy `xspeed_options` blob, but every module has | |
| 339 | + // since migrated to its own `xspeed_module_<slug>` option and no hook | |
| 340 | + // followed — so changing Minify HTML, Lazy Load, Remove Query Strings | |
| 341 | + // etc. left the cached HTML untouched until the TTL expired (24h by | |
| 342 | + // default) and the feature read as broken. (#205) | |
| 343 | + // | |
| 344 | + // One central listener rather than a hook per module: it covers Pro | |
| 345 | + // modules with no cross-repo change, and a new module can't forget to | |
| 346 | + // wire it up. | |
| 347 | + add_action( 'updated_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 ); | |
| 348 | + // `added_option` matters as much as `updated_option`: on a fresh install | |
| 349 | + // a module's option doesn't exist yet, so the FIRST save of every panel | |
| 350 | + // goes through add_option() and would otherwise skip the purge — the | |
| 351 | + // original bug surviving one save per module. `deleted_option` covers a | |
| 352 | + // reset-to-defaults, which changes rendered HTML just as much. (#205) | |
| 353 | + add_action( 'added_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 ); | |
| 354 | + add_action( 'deleted_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 ); | |
| 355 | + | |
| 48 | 356 | add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 ); |
| 49 | 357 | add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) ); |
| 50 | 358 | } |
| 51 | 359 | |
| @@ -61,13 +369,844 @@ | ||
| 61 | 369 | self::purge_all( 'settings change' ); |
| 62 | 370 | Minifier::purge_minified(); |
| 63 | 371 | } |
| 64 | 372 | |
| 373 | + /** | |
| 374 | + * Modules whose settings cannot change rendered HTML, so a write to them | |
| 375 | + * doesn't warrant throwing away the page cache. | |
| 376 | + * | |
| 377 | + * The safe default is to purge: a module is listed here only when it is | |
| 378 | + * clearly incapable of altering front-end output (diagnostics, the MCP | |
| 379 | + * server, licensing/telemetry surfaces). When in doubt, leave it off the | |
| 380 | + * list — a needless purge costs a re-render, a missed one makes the | |
| 381 | + * feature look broken. (#205) | |
| 382 | + * | |
| 383 | + * @return string[] Module slugs. | |
| 384 | + */ | |
| 385 | + public static function non_rendering_modules(): array { | |
| 386 | + return (array) apply_filters( | |
| 387 | + 'xspeed_non_rendering_modules', | |
| 388 | + array( | |
| 389 | + 'mcp', // AI endpoint — no front-end output. | |
| 390 | + 'health', // diagnostics only. | |
| 391 | + 'support', // support snapshot. | |
| 392 | + 'score', // PageSpeed/GTmetrix runner. | |
| 393 | + 'migration', // one-shot importer. | |
| 394 | + 'settings', // import/export surface. | |
| 395 | + 'cache-coverage', // read-only reporting. | |
| 396 | + 'ai-privacy', // consent flags for AI surfaces. | |
| 397 | + 'database', // DB cleanup schedule — no HTML impact. | |
| 398 | + // Pro slugs — listed by name rather than by asking Pro, so | |
| 399 | + // Free stays unaware of it. A Pro module absent here simply | |
| 400 | + // purges, which is the safe default. | |
| 401 | + 'license', | |
| 402 | + 'pro_status', | |
| 403 | + 'analytics', | |
| 404 | + 'performance-health', | |
| 405 | + 'recommendations', | |
| 406 | + 'ai-provider', | |
| 407 | + 'migration-pro', | |
| 408 | + ) | |
| 409 | + ); | |
| 410 | + } | |
| 411 | + | |
| 412 | + /** | |
| 413 | + * Purge when ANY module's settings option is written. (#205) | |
| 414 | + * | |
| 415 | + * Bound to `updated_option`, `added_option` and `deleted_option` — all three | |
| 416 | + * fire for every option on the site, so the prefix test comes first and is | |
| 417 | + * the cheap path for the ~99% of writes that aren't ours. All three pass the | |
| 418 | + * option name first, which is why this can't hook purge_all() directly: | |
| 419 | + * that takes $cause first, so every purge would be filed under a cause | |
| 420 | + * literally named "xspeed_module_minify". | |
| 421 | + * | |
| 422 | + * @param string $option Option name that was just written or removed. | |
| 423 | + */ | |
| 424 | + public static function on_module_settings_change( $option ): void { | |
| 425 | + $option = (string) $option; | |
| 426 | + $prefix = Settings_Manager::OPTION_PREFIX; | |
| 427 | + if ( 0 !== strpos( $option, $prefix ) ) { | |
| 428 | + return; | |
| 429 | + } | |
| 430 | + | |
| 431 | + $slug = substr( $option, strlen( $prefix ) ); | |
| 432 | + if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) { | |
| 433 | + return; | |
| 434 | + } | |
| 435 | + | |
| 436 | + // Guard against re-entry: purge_all() and purge_minified() can write | |
| 437 | + // options of their own (stats, timestamps), and a nested purge would | |
| 438 | + // both waste work and risk recursing through this same hook. | |
| 439 | + static $purging = false; | |
| 440 | + if ( $purging ) { | |
| 441 | + return; | |
| 442 | + } | |
| 443 | + $purging = true; | |
| 444 | + | |
| 445 | + self::purge_all( 'settings change' ); | |
| 446 | + Minifier::purge_minified(); | |
| 447 | + | |
| 448 | + $purging = false; | |
| 449 | + } | |
| 450 | + | |
| 451 | + /** | |
| 452 | + * Stamp the request's cache decision on the response. | |
| 453 | + * | |
| 454 | + * `X-XSpeed-Cache` was only ever written on the serve-from-cache paths, | |
| 455 | + * so a miss and a deliberate bypass both came back with no header at all | |
| 456 | + * — indistinguishable from a `curl -I`, the first thing anyone reaches | |
| 457 | + * for when a site "isn't caching" (issue #10). The reason slug rides | |
| 458 | + * along on `X-XSpeed-Reason`, but only under WP_DEBUG so production | |
| 459 | + * responses stay clean. Slugs are fixed per gate — never the matched | |
| 460 | + * pattern, cookie or user-agent, which would echo request input back. | |
| 461 | + * | |
| 462 | + * @param string $value HIT (php) | MISS | BYPASS. | |
| 463 | + * @param string $reason Fixed slug naming the gate, for BYPASS only. | |
| 464 | + */ | |
| 465 | + private static function mark( string $value, string $reason = '' ): void { | |
| 466 | + self::$status_header = $value; | |
| 467 | + self::$bypass_reason = $reason; | |
| 468 | + | |
| 469 | + // Every status, not just a HIT. A page we declined to cache is the | |
| 470 | + // one an edge most needs telling about: it goes out naked today, and | |
| 471 | + // a CDN that stores HTML by default keeps somebody's cart. | |
| 472 | + // | |
| 473 | + // Resolved before the headers_sent() guard so the decision is | |
| 474 | + // recorded (and observable in tests) even on a request that can no | |
| 475 | + // longer send headers; only the emission below is conditional. | |
| 476 | + self::$edge_headers = self::edge_headers_for( self::edge_status( $value ), 'request', $reason ); | |
| 477 | + | |
| 478 | + if ( headers_sent() ) { | |
| 479 | + return; | |
| 480 | + } | |
| 481 | + header( 'X-XSpeed-Cache: ' . $value ); | |
| 482 | + if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) { | |
| 483 | + header( 'X-XSpeed-Reason: ' . $reason ); | |
| 484 | + } | |
| 485 | + foreach ( self::$edge_headers as $name => $val ) { | |
| 486 | + header( $name . ': ' . $val ); | |
| 487 | + } | |
| 488 | + } | |
| 489 | + | |
| 490 | + /** | |
| 491 | + * Normalize an `X-XSpeed-Cache` value to the vocabulary the edge seam | |
| 492 | + * speaks. | |
| 493 | + * | |
| 494 | + * The header value carries which layer served the page (`HIT (php)`, | |
| 495 | + * `HIT (nginx)`, `HIT (static)`); nothing deciding what to tell a CDN | |
| 496 | + * cares, and making a caller match on three spellings of one outcome is | |
| 497 | + * how a rule ends up applied on two paths out of three. | |
| 498 | + */ | |
| 499 | + private static function edge_status( string $value ): string { | |
| 500 | + return 0 === strpos( $value, 'HIT' ) ? 'HIT' : $value; | |
| 501 | + } | |
| 502 | + | |
| 503 | + /** Record a bypass gate and answer "don't cache" in one statement. */ | |
| 504 | + private static function bypass( string $reason ): bool { | |
| 505 | + self::mark( 'BYPASS', $reason ); | |
| 506 | + return false; | |
| 507 | + } | |
| 508 | + | |
| 509 | + /** The X-XSpeed-Cache value decided for this request ('' if none yet). */ | |
| 510 | + public static function status_header(): string { | |
| 511 | + return self::$status_header; | |
| 512 | + } | |
| 513 | + | |
| 514 | + /** The bypass gate slug for this request ('' unless BYPASS). */ | |
| 515 | + public static function bypass_reason(): string { | |
| 516 | + return self::$bypass_reason; | |
| 517 | + } | |
| 518 | + | |
| 519 | + /** | |
| 520 | + * The edge/CDN pairs sent on this request ('' if none were). | |
| 521 | + * | |
| 522 | + * @return array<string,string> | |
| 523 | + */ | |
| 524 | + public static function edge_headers(): array { | |
| 525 | + return self::$edge_headers; | |
| 526 | + } | |
| 527 | + | |
| 528 | + /** | |
| 529 | + * Bypass gates that do NOT ask a cache in front of us to stand down. | |
| 530 | + * | |
| 531 | + * Every other slug does. The split is the reason this reads the gate | |
| 532 | + * rather than the status: a bypass usually means "this response is | |
| 533 | + * personal, or someone decided this page is never stored", and an edge | |
| 534 | + * holding one of those does precisely what we refused to do. These two | |
| 535 | + * mean something else. | |
| 536 | + * | |
| 537 | + * `cache-disabled` is the user switching OUR page cache off. Nothing | |
| 538 | + * about the page became personal. Sending `no-store` on every page of a | |
| 539 | + * site whose owner chose a different cache would make a local toggle a | |
| 540 | + * site-wide side effect on infrastructure we do not own. | |
| 541 | + * | |
| 542 | + * `non-frontend` is admin, REST, cron and AJAX. Not ours to describe: | |
| 543 | + * WordPress already nocaches admin, and a REST caller sets its own | |
| 544 | + * policy. | |
| 545 | + */ | |
| 546 | + private const HOLD_EXEMPT_BYPASS = array( 'cache-disabled', 'non-frontend' ); | |
| 547 | + | |
| 548 | + /** | |
| 549 | + * Bypass gates that describe the SHAPE of the request rather than the | |
| 550 | + * visitor or the page. | |
| 551 | + * | |
| 552 | + * These still hold, but only once we have evidence of an edge — the same | |
| 553 | + * bar a MISS has to clear. The difference matters because the default | |
| 554 | + * excluded-URL list contains `/feed/`, the sitemap and `/wp-json/`, and | |
| 555 | + * `query-param` catches `?lang=fr`, `?paged=2`, and every page of a | |
| 556 | + * plain-permalink site. | |
| 557 | + * | |
| 558 | + * xSpeed refuses those because IT cannot key on a query string, not | |
| 559 | + * because the response is private. A CDN keys on the full URL and caches | |
| 560 | + * them correctly. Holding them unconditionally would have meant every | |
| 561 | + * default install stopped its feed and sitemap being edge-cached — a | |
| 562 | + * performance regression shipped to sites that never had a CDN in the | |
| 563 | + * first place, in the name of protecting them from one. | |
| 564 | + * | |
| 565 | + * The gates left out of this list are about the visitor (`logged-in`, | |
| 566 | + * `excluded-cookie`) or are somebody stating outright that this page is | |
| 567 | + * never to be stored (`donotcachepage`, `post-excluded`, `filtered`). | |
| 568 | + * Those hold whether or not we can see an edge. | |
| 569 | + */ | |
| 570 | + private const REQUEST_SHAPE_BYPASS = array( 'query-param', 'non-get', 'user-agent' ); | |
| 571 | + | |
| 572 | + /** | |
| 573 | + * Default exclusions that are about the site's plumbing, not its content. | |
| 574 | + * | |
| 575 | + * `excluded-url` covers two unlike things. The default list carries | |
| 576 | + * `/cart`, `/checkout`, `/my-account` and `/wp-login` — personal pages, | |
| 577 | + * and the reason this feature exists. It also carries the entries below: | |
| 578 | + * feeds, sitemaps, the REST root, the front controller. Those are public, | |
| 579 | + * cacheable, and hammered by pollers; a CDN keys on the full URL and | |
| 580 | + * serves them correctly, so telling it to stop is a cost with no benefit. | |
| 581 | + * | |
| 582 | + * Matched as exact strings against the stored list, never as patterns | |
| 583 | + * against the path. Three bugs came out of doing it the other way round: | |
| 584 | + * `strpos( $uri, '/feed' )` matched `/my-account/feedback/`, reading the | |
| 585 | + * whole URI let `/cart/?utm_source=/feed/` disguise a cart as a feed, and | |
| 586 | + * a bare `index.php` — which is in this list, and which every URL contains | |
| 587 | + * on an "almost pretty" permalink site — made every page on such a site | |
| 588 | + * look personal. Comparing the LIST ENTRY rather than the path cannot make | |
| 589 | + * any of those mistakes, and it keeps a pattern the site owner added | |
| 590 | + * themselves on the personal side where it belongs. | |
| 591 | + */ | |
| 592 | + private const STRUCTURAL_EXCLUSIONS = array( | |
| 593 | + '/wp-json/', | |
| 594 | + '/xmlrpc.php', | |
| 595 | + '~wp-.*\.php', | |
| 596 | + '/feed/', | |
| 597 | + 'index.php', | |
| 598 | + '/robots.txt', | |
| 599 | + // Both spellings, and no entry here is ever retired. This is a | |
| 600 | + // RECOGNITION list, not a source of truth: it is matched against | |
| 601 | + // whatever the site has STORED, and a site that saved its settings | |
| 602 | + // before `~sitemap(_index)?\.xml` was widened to `sitemaps?` (for | |
| 603 | + // SEOPress, which ships sitemaps.xml) still has the old string in | |
| 604 | + // its option row. Dropping the old spelling when the default moved | |
| 605 | + // would read every upgraded site's sitemap exclusion as somebody's | |
| 606 | + // personal data and hold sitemaps off the CDN — the bug this whole | |
| 607 | + // predicate exists to prevent, reintroduced by a rename. | |
| 608 | + '~sitemaps?(_index)?\.xml', | |
| 609 | + '~sitemap(_index)?\.xml', | |
| 610 | + ); | |
| 611 | + /** | |
| 612 | + * Header names no edge instruction may ever carry. | |
| 613 | + * | |
| 614 | + * These describe the transfer, not the caching policy, and one wrong | |
| 615 | + * value from a settings field is a white screen rather than a missing | |
| 616 | + * optimization. | |
| 617 | + */ | |
| 618 | + private const NEVER_AN_EDGE_HEADER = array( | |
| 619 | + 'content-length', | |
| 620 | + 'content-encoding', | |
| 621 | + 'content-type', | |
| 622 | + 'transfer-encoding', | |
| 623 | + 'set-cookie', | |
| 624 | + 'location', | |
| 625 | + 'x-xspeed-cache', | |
| 626 | + 'x-xspeed-edge-hold', | |
| 627 | + ); | |
| 628 | + | |
| 629 | + /** | |
| 630 | + * Reasons that hold the edge off even when we detected nothing in front. | |
| 631 | + * | |
| 632 | + * `none` confidence means no evidence of a proxy, which is not proof | |
| 633 | + * there is none — a transparent proxy and a host page cache both leave | |
| 634 | + * the request untouched. So the question is what a wasted header costs | |
| 635 | + * against what a missed one does, and the answer differs by reason. | |
| 636 | + * | |
| 637 | + * These two are correctness failures. A cart page stored by something we | |
| 638 | + * could not see is the defect this exists to fix, and a mobile-split page | |
| 639 | + * served to the wrong device is a wrong page rather than a slow one. | |
| 640 | + * Ninety bytes on a response that was never cacheable is a cheap premium. | |
| 641 | + * | |
| 642 | + * `miss` and `pending` are performance hedges, and a hedge against a | |
| 643 | + * cache that does not exist is noise on every first render. Skipping them | |
| 644 | + * has a second benefit: because per_entry_edge_headers() compares `store` | |
| 645 | + * against `bake`, a `pending` hold that never fires leaves the two | |
| 646 | + * agreeing, which keeps the page on the static tree. | |
| 647 | + */ | |
| 648 | + private const HOLD_WITHOUT_EVIDENCE = array( 'bypass', 'mobile-split' ); | |
| 649 | + | |
| 650 | + /** | |
| 651 | + * Is a module still going to change this page after this response? | |
| 652 | + * | |
| 653 | + * Free itself never says yes — nothing in Free defers work past the | |
| 654 | + * request. Minification and combining write their file and return its URL | |
| 655 | + * inside the same render; the LCP preload is chosen by parsing the HTML | |
| 656 | + * being sent. It is the question that matters to anything caching in | |
| 657 | + * front of us, so Free asks it on their behalf and lets whoever owns the | |
| 658 | + * deferred work answer. | |
| 659 | + * | |
| 660 | + * Answer TRUE while the work is outstanding for the page being served. | |
| 661 | + * The cost of a false yes is one extra origin hit; the cost of a false no | |
| 662 | + * is an un-optimized page pinned at the edge for the full lifetime, which | |
| 663 | + * is the failure this exists to prevent — so when in doubt, say yes. | |
| 664 | + * | |
| 665 | + * Asked on a `request` only, and that boundary is the whole safety of it. | |
| 666 | + * | |
| 667 | + * A `bake` is generated once, in an admin or CLI request, and serves every | |
| 668 | + * static HIT on the site; a per-page answer frozen into it would be wrong | |
| 669 | + * for every other page. | |
| 670 | + * | |
| 671 | + * A `store` is worse, and cost a live site an afternoon. The pairs written | |
| 672 | + * at store time go into the `.meta` sidecar, which the drop-in replays on | |
| 673 | + * every later HIT — before plugins load, so nothing can re-ask this | |
| 674 | + * question. A hold written there therefore outlives the state that caused | |
| 675 | + * it, and the only thing that clears it is the page being stored again. On | |
| 676 | + * a site where the deferred work never completes, every re-store re-pins | |
| 677 | + * it, and the page is never edge-cacheable again. The symptom is a cache | |
| 678 | + * HIT carrying `no-store` and `X-XSpeed-Edge-Hold: pending` on a page | |
| 679 | + * whose deferred work finished long ago — the sidecar answering with | |
| 680 | + * state nothing can re-ask. | |
| 681 | + * | |
| 682 | + * Holding the MISS is what this is for, and it is enough: that response is | |
| 683 | + * the un-optimized one. The copy we then store is what an edge should | |
| 684 | + * mirror, and when the work does land the module purges the page, which | |
| 685 | + * reaches the edge. The purge is the correctness mechanism; this is only | |
| 686 | + * meant to cover the single render before it. | |
| 687 | + * | |
| 688 | + * @param string $context `request`, `store` or `bake`. | |
| 689 | + */ | |
| 690 | + public static function edge_optimization_pending( string $context = 'request' ): bool { | |
| 691 | + if ( 'request' !== $context ) { | |
| 692 | + return false; | |
| 693 | + } | |
| 694 | + | |
| 695 | + /** | |
| 696 | + * Filter: xspeed_edge_optimization_pending | |
| 697 | + * | |
| 698 | + * @param bool $pending Whether deferred work will still change this page. | |
| 699 | + */ | |
| 700 | + return (bool) apply_filters( 'xspeed_edge_optimization_pending', false ); | |
| 701 | + } | |
| 702 | + | |
| 703 | + /** | |
| 704 | + * Does mobile cache split this URL into two renders? | |
| 705 | + * | |
| 706 | + * With `mobile_separate` on, Free keys its cache on device and serves a | |
| 707 | + * different page to a phone than to a desktop at the SAME url. No CDN | |
| 708 | + * varies on User-Agent, so an edge holding one of those renders serves it | |
| 709 | + * to everyone: whichever device asked first decides what the other sees, | |
| 710 | + * for the whole lifetime. A wrong page, not a slow one. | |
| 711 | + * | |
| 712 | + * Read from the stored option rather than through Settings_Manager: this | |
| 713 | + * is consulted from the serve path, where the module registry may not | |
| 714 | + * have run. | |
| 715 | + */ | |
| 716 | + private static function mobile_cache_splits_html(): bool { | |
| 717 | + $stored = self::stored_cache_opts(); | |
| 718 | + return ! empty( $stored['mobile_separate'] ); | |
| 719 | + } | |
| 720 | + | |
| 721 | + /** | |
| 722 | + * Why, if at all, a cache in front of us should refuse to store this. | |
| 723 | + * | |
| 724 | + * @param string $status `HIT`, `MISS` or `BYPASS`. | |
| 725 | + * @param string $context `request`, `store` or `bake`. | |
| 726 | + * @param string $bypass_reason The gate slug, for BYPASS only. | |
| 727 | + * @return string '' or one of bypass|bypass-shape|miss|mobile-split|pending. | |
| 728 | + */ | |
| 729 | + private static function edge_hold_reason( string $status, string $context, string $bypass_reason ): string { | |
| 730 | + $reason = ''; | |
| 731 | + | |
| 732 | + // The two exempt gates are answered before anything else, or a site | |
| 733 | + // with Separate Mobile Cache on would keep holding after the page | |
| 734 | + // cache was switched off — which is exactly the "a local toggle must | |
| 735 | + // not become a site-wide side effect on infrastructure we do not own" | |
| 736 | + // rule below, defeated by the ordering rather than by the logic. | |
| 737 | + if ( 'BYPASS' === $status && in_array( $bypass_reason, self::HOLD_EXEMPT_BYPASS, true ) ) { | |
| 738 | + /** This filter is documented below. */ | |
| 739 | + return (string) apply_filters( 'xspeed_edge_hold_reason', '', $status, $context, $bypass_reason ); | |
| 740 | + } | |
| 741 | + | |
| 742 | + // First, because it is the only reason true in every context: the | |
| 743 | + // setting is a property of the site, not of one request, so it is the | |
| 744 | + // one thing a baked artifact can honestly assert. | |
| 745 | + // | |
| 746 | + // It is also the only reason that holds a HIT — a response we DID | |
| 747 | + // cache — and that is deliberate rather than an artefact of the | |
| 748 | + // ordering. With mobile_separate on we key the cache by device and | |
| 749 | + // serve different HTML to a phone than to a desktop at the same URL. | |
| 750 | + // No CDN varies on User-Agent, so an edge holding one of those | |
| 751 | + // renders serves it to everyone and whichever device asked first | |
| 752 | + // decides what the other sees. Our copy is fine; theirs would be a | |
| 753 | + // wrong page. The static path is switched off in this mode anyway | |
| 754 | + // (static_rewrite_allowed()), so these hits come from the drop-in, | |
| 755 | + // which carries the same baked answer. | |
| 756 | + if ( self::mobile_cache_splits_html() ) { | |
| 757 | + $reason = 'mobile-split'; | |
| 758 | + } elseif ( 'BYPASS' === $status ) { | |
| 759 | + $shaped = in_array( $bypass_reason, array( 'excluded-url', 'query-param' ), true ) | |
| 760 | + ? ! self::path_is_a_personal_exclusion( $bypass_reason ) | |
| 761 | + : in_array( $bypass_reason, self::REQUEST_SHAPE_BYPASS, true ); | |
| 762 | + $reason = $shaped ? 'bypass-shape' : 'bypass'; | |
| 763 | + } elseif ( self::edge_optimization_pending( $context ) ) { | |
| 764 | + $reason = 'pending'; | |
| 765 | + } elseif ( 'MISS' === $status ) { | |
| 766 | + $reason = 'miss'; | |
| 767 | + } | |
| 768 | + | |
| 769 | + /** | |
| 770 | + * Filter: xspeed_edge_hold_reason | |
| 771 | + * | |
| 772 | + * Return '' to veto a hold, or a reason string to force one. | |
| 773 | + * | |
| 774 | + * @param string $reason '' or bypass|bypass-shape|miss|mobile-split|pending. | |
| 775 | + * @param string $status `HIT`, `MISS` or `BYPASS`. | |
| 776 | + * @param string $context `request`, `store` or `bake`. | |
| 777 | + * @param string $bypass_reason The gate slug, for BYPASS only. | |
| 778 | + */ | |
| 779 | + return (string) apply_filters( 'xspeed_edge_hold_reason', $reason, $status, $context, $bypass_reason ); | |
| 780 | + } | |
| 781 | + | |
| 782 | + /** | |
| 783 | + * Was this page excluded because it is personal, or because it is | |
| 784 | + * plumbing we cannot key a cache entry on? | |
| 785 | + * | |
| 786 | + * Answers by removing the structural defaults from the site's own | |
| 787 | + * exclusion list and asking whether anything is left that matches. So a | |
| 788 | + * feed matches only `/feed/` and comes back false; `/my-account/feedback/` | |
| 789 | + * matches `/my-account` and comes back true; and on an "almost pretty" | |
| 790 | + * permalink site, where every path contains `index.php`, an ordinary page | |
| 791 | + * matches nothing else and is correctly treated as public. | |
| 792 | + * | |
| 793 | + * The path only, never the query string — a visitor writes that, and | |
| 794 | + * `/cart/?utm_source=/feed/` must not be able to talk a cart out of its | |
| 795 | + * hold. It is also what `should_cache()` matches the list against. | |
| 796 | + * | |
| 797 | + * Asked for a `query-param` bypass too, because the query gate runs | |
| 798 | + * BEFORE the URL gate, so `/cart/?add-to-cart=12` reports `query-param` | |
| 799 | + * and never reaches `excluded-url` at all. Which gate fired first says | |
| 800 | + * nothing about whose data is on the page. | |
| 801 | + */ | |
| 802 | + private static function path_is_a_personal_exclusion( string $bypass_reason ): bool { | |
| 803 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading the path of the request being served; there is no form here to nonce. | |
| 804 | + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; | |
| 805 | + $path = (string) strtok( $uri, '?' ); | |
| 806 | + if ( '' === $path ) { | |
| 807 | + return false; | |
| 808 | + } | |
| 809 | + | |
| 810 | + // Through Settings_Manager, not the raw option, because the schema's | |
| 811 | + // default IS the structural list and a fresh install has never | |
| 812 | + // written the option. Read raw, every site that has not visited the | |
| 813 | + // settings screen looks like a site with no exclusions at all, takes | |
| 814 | + // the contradiction branch below, and reports its feeds as personal. | |
| 815 | + // | |
| 816 | + // Safe here where `mobile_cache_splits_html()` is not: we are only | |
| 817 | + // ever called with a bypass reason, and those come from | |
| 818 | + // `should_cache()`, which resolved the same settings through | |
| 819 | + // `Settings_Manager::get()` to produce them. | |
| 820 | + $opts = Settings_Manager::get( 'cache' ); | |
| 821 | + $excluded = is_array( $opts['excluded_urls'] ?? null ) ? $opts['excluded_urls'] : array(); | |
| 822 | + if ( array() === $excluded ) { | |
| 823 | + // An `excluded-url` bypass with no exclusion list is a | |
| 824 | + // contradiction — something excluded the request and the list | |
| 825 | + // cannot say what — so assume personal, because a wasted header | |
| 826 | + // costs a little origin traffic while a missing one serves | |
| 827 | + // somebody's basket to a stranger. A `query-param` bypass with an | |
| 828 | + // empty list is just an ordinary page carrying a parameter, and | |
| 829 | + // says nothing about the path at all. | |
| 830 | + return 'excluded-url' === $bypass_reason; | |
| 831 | + } | |
| 832 | + | |
| 833 | + $personal = array_values( | |
| 834 | + array_filter( | |
| 835 | + $excluded, | |
| 836 | + static fn ( $pattern ) => ! in_array( (string) $pattern, self::STRUCTURAL_EXCLUSIONS, true ) | |
| 837 | + ) | |
| 838 | + ); | |
| 839 | + | |
| 840 | + return array() !== $personal && Glob_Matcher::any_match( $personal, $path ); | |
| 841 | + } | |
| 842 | + | |
| 843 | + /** | |
| 844 | + * The edge/CDN headers to send on a response with this cache status. | |
| 845 | + * | |
| 846 | + * @param string $status `HIT`, `MISS` or `BYPASS`. | |
| 847 | + * @param string $context `request` when resolved per request on the | |
| 848 | + * PHP serve path, `store` when resolved for | |
| 849 | + * one entry's sidecar, `bake` when resolved | |
| 850 | + * once and frozen into an artifact. | |
| 851 | + * @param string $bypass_reason The gate slug, for BYPASS only. | |
| 852 | + * @return array<string,string> | |
| 853 | + */ | |
| 854 | + public static function edge_headers_for( string $status, string $context = 'request', string $bypass_reason = '' ): array { | |
| 855 | + $base = array(); | |
| 856 | + if ( 'HIT' === $status ) { | |
| 857 | + /** | |
| 858 | + * Filter: xspeed_edge_cache_headers | |
| 859 | + * | |
| 860 | + * Response headers to add to a cached HTML response. A HIT-only | |
| 861 | + * contract: a lifetime is a promise that this copy is worth | |
| 862 | + * keeping, and neither a first render nor a page we refused to | |
| 863 | + * cache is one. | |
| 864 | + * | |
| 865 | + * The same filter feeds three regimes and `$context` says which. | |
| 866 | + * On the PHP serve path it runs per request (`request`); at store | |
| 867 | + * time it runs for one entry (`store`); when the drop-in or a | |
| 868 | + * server rule is generated it runs once (`bake`) and the result | |
| 869 | + * answers for every static HIT on the site. Anything per-page — a | |
| 870 | + * post id in a cache tag, say — must be skipped under `bake`. | |
| 871 | + * | |
| 872 | + * @param array<string,string> $headers Header name => value. | |
| 873 | + * @param string $status Always `HIT` here. | |
| 874 | + * @param string $context `request`, `store` or `bake`. | |
| 875 | + */ | |
| 876 | + $base = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'HIT', $context ) ); | |
| 877 | + } | |
| 878 | + | |
| 879 | + $reason = self::edge_hold_reason( $status, $context, $bypass_reason ); | |
| 880 | + if ( '' === $reason ) { | |
| 881 | + return $base; | |
| 882 | + } | |
| 883 | + $detected = Edge_Provider::detect( $context ); | |
| 884 | + if ( Edge_Provider::is_off( $detected ) ) { | |
| 885 | + return $base; | |
| 886 | + } | |
| 887 | + if ( Edge_Provider::NONE === $detected['confidence'] | |
| 888 | + && ! in_array( $reason, self::HOLD_WITHOUT_EVIDENCE, true ) ) { | |
| 889 | + return $base; | |
| 890 | + } | |
| 891 | + | |
| 892 | + $hold = Edge_Provider::hold_headers( $detected['provider'] ); | |
| 893 | + | |
| 894 | + /** | |
| 895 | + * Filter: xspeed_edge_hold_headers | |
| 896 | + * | |
| 897 | + * The last word on what a hold INSTRUCTS. Runs before sanitising, so | |
| 898 | + * a value that cannot be sent as a header is still dropped, and | |
| 899 | + * before `X-XSpeed-Edge-Hold` is added, so it cannot rewrite the | |
| 900 | + * reason xSpeed held the page for — that is a diagnosis, not an | |
| 901 | + * instruction, and a forged one sends a reader after the wrong | |
| 902 | + * module. | |
| 903 | + * | |
| 904 | + * @param array<string,string> $hold Header name => value. | |
| 905 | + * @param array<string,string> $detected Provider, confidence, source. | |
| 906 | + * @param string $reason Why the hold fired. | |
| 907 | + * @param string $context `request`, `store` or `bake`. | |
| 908 | + */ | |
| 909 | + $hold = (array) apply_filters( 'xspeed_edge_hold_headers', $hold, $detected, $reason, $context ); | |
| 910 | + | |
| 911 | + // A hold replaces the lifetime rather than sitting beside it: the two | |
| 912 | + // describe the same response and would contradict each other. The | |
| 913 | + // cache tag survives, because a later purge still has to be able to | |
| 914 | + // name whatever the edge picked up on its own terms. | |
| 915 | + if ( isset( $base['Cache-Tag'] ) ) { | |
| 916 | + $hold['Cache-Tag'] = $base['Cache-Tag']; | |
| 917 | + } | |
| 918 | + | |
| 919 | + // Never argue with a stronger answer WordPress already gave. It sends | |
| 920 | + // `no-store, private` of its own accord on a logged-in, 404 or | |
| 921 | + // password-protected response, from WP::send_headers() — which runs | |
| 922 | + // before template_redirect, so it is already on the wire by the time | |
| 923 | + // we get here. Ours is the weaker statement of the two; replacing it | |
| 924 | + // would be a downgrade dressed as a fix. Only meaningful per request: | |
| 925 | + // a bake has no response to inspect. | |
| 926 | + if ( 'request' === $context && isset( $hold['Cache-Control'] ) && self::cache_control_already_stronger() ) { | |
| 927 | + unset( $hold['Cache-Control'] ); | |
| 928 | + } | |
| 929 | + | |
| 930 | + // A page we refused to cache must not carry a validator either. A | |
| 931 | + // `Last-Modified` left on it invites a conditional request, and a | |
| 932 | + // shared cache that gets a 304 back serves the copy it should not | |
| 933 | + // have stored. Only on a bypass, and only per request: a MISS is | |
| 934 | + // about to be stored by us, so its validator is ours to keep. | |
| 935 | + if ( 'request' === $context && 'bypass' === $reason && ! headers_sent() ) { | |
| 936 | + header_remove( 'Last-Modified' ); | |
| 937 | + } | |
| 938 | + | |
| 939 | + $hold = self::sanitize_edge_headers( $hold ); | |
| 940 | + | |
| 941 | + // Name the reason in the hold set itself, rather than sending it | |
| 942 | + // separately from mark(). | |
| 943 | + // | |
| 944 | + // "Why is my page not being cached at the edge?" is the question this | |
| 945 | + // answers, and mark() could only answer it on the PHP serve path. The | |
| 946 | + // other emitters send whatever this function returns and never ran | |
| 947 | + // mark() at all — so the responses hardest to explain went out | |
| 948 | + // carrying `no-store` with nothing beside it to say why. Chiefly the | |
| 949 | + // drop-in, which serves from the `.meta` sidecar written under | |
| 950 | + // `store` and from the literal baked under `bake`, before plugins | |
| 951 | + // load and with no way to re-ask (the symptom | |
| 952 | + // edge_optimization_pending() describes above). | |
| 953 | + // | |
| 954 | + // The nginx and Apache blocks are a third path in principle and | |
| 955 | + // almost never in practice: they are only installed when | |
| 956 | + // static_rewrite_allowed() is true, and the one reason a stock site | |
| 957 | + // can hold under `bake` is `mobile-split`, which is exactly what | |
| 958 | + // makes that false. They will carry it where a site forces a hold | |
| 959 | + // through `xspeed_edge_hold_reason`, and otherwise have no hold to | |
| 960 | + // carry. | |
| 961 | + // | |
| 962 | + // Added AFTER sanitising and banned in NEVER_AN_EDGE_HEADER, so | |
| 963 | + // neither of the two filters above can forge a reason or suppress the | |
| 964 | + // real one. | |
| 965 | + // | |
| 966 | + // Reduced to the slug CHARACTER CLASS, not checked against the five | |
| 967 | + // slugs: `xspeed_edge_hold_reason` is documented as able to force a | |
| 968 | + // reason, and a site that forces its own deserves to see it. What is | |
| 969 | + // not negotiable is the shape, because this value reaches an | |
| 970 | + // .htaccess and an nginx conf as well as a response header — so no | |
| 971 | + // CR/LF, no `$`, no `%`, no `\`, and a length a config file can hold. | |
| 972 | + $slug = preg_replace( '/[^a-z0-9-]/', '', strtolower( $reason ) ); | |
| 973 | + if ( is_string( $slug ) && '' !== $slug ) { | |
| 974 | + $hold['X-XSpeed-Edge-Hold'] = substr( $slug, 0, 32 ); | |
| 975 | + } | |
| 976 | + | |
| 977 | + return $hold; | |
| 978 | + } | |
| 979 | + | |
| 980 | + /** Has something already sent a Cache-Control at least as strict as ours? */ | |
| 981 | + private static function cache_control_already_stronger(): bool { | |
| 982 | + foreach ( headers_list() as $line ) { | |
| 983 | + if ( 0 !== stripos( $line, 'cache-control:' ) ) { | |
| 984 | + continue; | |
| 985 | + } | |
| 986 | + if ( preg_match( '/\b(?:no-store|private)\b/i', $line ) ) { | |
| 987 | + return true; | |
| 988 | + } | |
| 989 | + } | |
| 990 | + | |
| 991 | + return false; | |
| 992 | + } | |
| 993 | + | |
| 994 | + /** | |
| 995 | + * Edge headers that belong to THIS page rather than to every page. | |
| 996 | + * | |
| 997 | + * `edge_headers_for('HIT','bake')` is the answer frozen into the drop-in | |
| 998 | + * and the server rules: one set, serving the whole site. But the answer | |
| 999 | + * for one URL can legitimately differ — a page whose deferred work is | |
| 1000 | + * still outstanding, say — and that answer has nowhere to live, because | |
| 1001 | + * the baked set is all the fast paths know about. | |
| 1002 | + * | |
| 1003 | + * So ask again in a `store` context, with the request still in scope, and | |
| 1004 | + * return the pairs only when they differ from the baked ones. Identical is | |
| 1005 | + * the overwhelmingly common case and writes nothing: pages do not pay a | |
| 1006 | + * sidecar for an answer the drop-in already has. | |
| 1007 | + * | |
| 1008 | + * Memoised because two callers ask within one store — the sidecar writer | |
| 1009 | + * and the static-tree guard — and the filters behind it are not required | |
| 1010 | + * to be cheap. | |
| 1011 | + * | |
| 1012 | + * @return array<string,string> Empty when this page needs no override. | |
| 1013 | + */ | |
| 1014 | + private static function per_entry_edge_headers(): array { | |
| 1015 | + if ( is_array( self::$per_entry_edge ) ) { | |
| 1016 | + return self::$per_entry_edge; | |
| 1017 | + } | |
| 1018 | + $baked = self::edge_headers_for( 'HIT', 'bake' ); | |
| 1019 | + $request = self::edge_headers_for( 'HIT', 'store' ); | |
| 1020 | + self::$per_entry_edge = ( $request === $baked ) ? array() : $request; | |
| 1021 | + | |
| 1022 | + return self::$per_entry_edge; | |
| 1023 | + } | |
| 1024 | + | |
| 1025 | + /** | |
| 1026 | + * Render baked pairs as a PHP array literal for the drop-in. | |
| 1027 | + * | |
| 1028 | + * Single-quoted literals with quotes escaped, because the result is | |
| 1029 | + * written into a PHP file that must still parse. Values reaching here | |
| 1030 | + * have already been through sanitize_edge_headers(), so neither name nor | |
| 1031 | + * value can carry a newline. | |
| 1032 | + * | |
| 1033 | + * @param array<string,string> $headers Name => value. | |
| 1034 | + */ | |
| 1035 | + private static function edge_headers_literal( array $headers ): string { | |
| 1036 | + if ( array() === $headers ) { | |
| 1037 | + return 'array()'; | |
| 1038 | + } | |
| 1039 | + // var_export(), not hand-rolled quoting. A single-quoted PHP string | |
| 1040 | + // escapes BOTH `'` and `\\`, and escaping only the first is how a | |
| 1041 | + // value ending in a backslash — `X-Foo: C:\path\` from the custom | |
| 1042 | + // headers box — leaves the literal unterminated. That file is | |
| 1043 | + // included on every request once WP_CACHE is on, so the result is a | |
| 1044 | + // parse error on the front end AND in wp-admin, with no way back | |
| 1045 | + // except deleting the file over SSH. | |
| 1046 | + $parts = array(); | |
| 1047 | + foreach ( $headers as $name => $value ) { | |
| 1048 | + $parts[] = var_export( (string) $name, true ) . ' => ' . var_export( (string) $value, true ); | |
| 1049 | + } | |
| 1050 | + | |
| 1051 | + return 'array( ' . implode( ', ', $parts ) . ' )'; | |
| 1052 | + } | |
| 1053 | + | |
| 1054 | + /** | |
| 1055 | + * Quote a header value for an nginx / Apache directive. | |
| 1056 | + * | |
| 1057 | + * Both accept a double-quoted string with backslash escapes, and both | |
| 1058 | + * refuse to load a config where the quoting is wrong — a mis-escaped | |
| 1059 | + * value takes the whole vhost down, not just this header. | |
| 1060 | + */ | |
| 1061 | + private static function quote_directive_value( string $value ): string { | |
| 1062 | + return str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value ); | |
| 1063 | + } | |
| 1064 | + | |
| 1065 | + /** | |
| 1066 | + * The same directive twice — once per name Apache can expose the | |
| 1067 | + * rewrite's environment variable under. | |
| 1068 | + * | |
| 1069 | + * `RewriteRule ... [E=XSPEED_STATIC_HIT:1]` in a per-directory context is | |
| 1070 | + * an INTERNAL REDIRECT: Apache re-enters the request with the substituted | |
| 1071 | + * path, and every variable set on the first pass is renamed with a | |
| 1072 | + * `REDIRECT_` prefix for the second. `env=XSPEED_STATIC_HIT` is evaluated | |
| 1073 | + * on that second pass, where nothing answers to that name any more, so | |
| 1074 | + * the directive never fires — dropping the headers from precisely the | |
| 1075 | + * responses they exist for. | |
| 1076 | + * | |
| 1077 | + * It cannot be written once: `env=` takes a single name with no | |
| 1078 | + * alternation, and `expr=` — which could express both — is not dependable | |
| 1079 | + * on LiteSpeed, which reads this same block. So both are emitted; the one | |
| 1080 | + * whose variable is unset on a given pass does nothing. | |
| 1081 | + * | |
| 1082 | + * @param string $directive The directive, without its `env=` clause. | |
| 1083 | + * @return string[] | |
| 1084 | + */ | |
| 1085 | + private static function static_hit_directives( string $directive ): array { | |
| 1086 | + return array( | |
| 1087 | + $directive . ' env=XSPEED_STATIC_HIT', | |
| 1088 | + $directive . ' env=REDIRECT_XSPEED_STATIC_HIT', | |
| 1089 | + ); | |
| 1090 | + } | |
| 1091 | + | |
| 1092 | + /** | |
| 1093 | + * Keep only pairs that can be sent as a header verbatim. | |
| 1094 | + * | |
| 1095 | + * These values reach three different emitters — PHP's header(), an nginx | |
| 1096 | + * `add_header` and an Apache `Header always set` — so a name with a space | |
| 1097 | + * or a value carrying CR/LF is not merely malformed, it is a | |
| 1098 | + * response-splitting vector in the first and a broken server config in | |
| 1099 | + * the other two. Names must be token-shaped; values lose CR/LF and are | |
| 1100 | + * dropped if nothing survives. | |
| 1101 | + * | |
| 1102 | + * @param array<mixed,mixed> $headers Raw pairs. | |
| 1103 | + * @return array<string,string> | |
| 1104 | + */ | |
| 1105 | + public static function sanitize_edge_headers( array $headers ): array { | |
| 1106 | + $clean = array(); | |
| 1107 | + foreach ( $headers as $name => $value ) { | |
| 1108 | + // Never let one of these through, whoever asked. They describe the | |
| 1109 | + // transfer rather than the caching policy, and getting one wrong | |
| 1110 | + // from a settings field is a white screen: `Content-Encoding: gzip` | |
| 1111 | + // on an uncompressed body, a `Content-Length` that disagrees with | |
| 1112 | + // the bytes. `X-XSpeed-Cache` is ours and a second copy would lie | |
| 1113 | + // to whoever reads it. | |
| 1114 | + if ( is_string( $name ) && in_array( strtolower( $name ), self::NEVER_AN_EDGE_HEADER, true ) ) { | |
| 1115 | + continue; | |
| 1116 | + } | |
| 1117 | + // `\z`, not `$`: PCRE's `$` also matches immediately BEFORE a | |
| 1118 | + // trailing newline, so "Cache-Tag\n" passes a `$` check and gets | |
| 1119 | + // concatenated raw into the generated .htaccess — splitting one | |
| 1120 | + // Header directive across two lines, which is a syntax error | |
| 1121 | + // Apache reports as a 500 on every request while `httpd -t` stays | |
| 1122 | + // green (.htaccess is parsed per request, not at load). | |
| 1123 | + if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9-]+\z/', $name ) ) { | |
| 1124 | + continue; | |
| 1125 | + } | |
| 1126 | + if ( ! is_string( $value ) && ! is_numeric( $value ) ) { | |
| 1127 | + continue; | |
| 1128 | + } | |
| 1129 | + $value = trim( str_replace( array( "\r", "\n" ), '', (string) $value ) ); | |
| 1130 | + if ( '' === $value ) { | |
| 1131 | + continue; | |
| 1132 | + } | |
| 1133 | + // `$` is a variable reference in an nginx string and `%` is a | |
| 1134 | + // format tag to Apache's mod_headers, which rejects an | |
| 1135 | + // unrecognised one — in .htaccess that is a 500 on every request | |
| 1136 | + // while `httpd -t` still reports OK, because .htaccess is parsed | |
| 1137 | + // per request. `\` escapes the quote in the PHP literal baked into | |
| 1138 | + // the drop-in. None of them can be escaped reliably in all three | |
| 1139 | + // places at once, and nothing a cache reads needs any of them, so | |
| 1140 | + // the value is dropped rather than mangled. | |
| 1141 | + if ( preg_match( '/[$%\\\\]/', $value ) ) { | |
| 1142 | + continue; | |
| 1143 | + } | |
| 1144 | + $clean[ $name ] = $value; | |
| 1145 | + } | |
| 1146 | + | |
| 1147 | + return $clean; | |
| 1148 | + } | |
| 1149 | + | |
| 1150 | + /** | |
| 1151 | + * Bypass gates that describe THE VISITOR rather than THIS REQUEST. | |
| 1152 | + * | |
| 1153 | + * Only these may be recorded in the bypass cookie. A visitor-scoped | |
| 1154 | + * verdict stays true for the visitor's next request — they are still | |
| 1155 | + * logged in, still hold a cart cookie — so the web server can act on | |
| 1156 | + * it without booting PHP. | |
| 1157 | + * | |
| 1158 | + * Every other gate describes the request in front of us: its method, | |
| 1159 | + * its URL, its query string, the client's user agent. Persisting one | |
| 1160 | + * of those pins a visitor to the uncached path over a property that | |
| 1161 | + * was never theirs to begin with. (#218) | |
| 1162 | + */ | |
| 1163 | + private const VISITOR_SCOPED_BYPASS = array( 'logged-in', 'excluded-cookie' ); | |
| 1164 | + | |
| 1165 | + /** | |
| 1166 | + * Whether $reason describes the visitor (persist it) or merely this | |
| 1167 | + * request (don't). | |
| 1168 | + * | |
| 1169 | + * Split out as a pure function because it is the whole decision behind | |
| 1170 | + * the bypass cookie, and the cookie write itself (setcookie()) can't be | |
| 1171 | + * asserted in a unit test. | |
| 1172 | + */ | |
| 1173 | + public static function bypass_is_visitor_scoped( string $reason ): bool { | |
| 1174 | + return in_array( $reason, self::VISITOR_SCOPED_BYPASS, true ); | |
| 1175 | + } | |
| 1176 | + | |
| 65 | 1177 | public function maybe_start_cache() { |
| 66 | 1178 | if ( ! self::should_cache() ) { |
| 1179 | + // PHP has just evaluated the FULL exclusion rule list — including | |
| 1180 | + // the `~regex` patterns the server config can't express — and | |
| 1181 | + // decided this response must not be served from cache. Record that | |
| 1182 | + // verdict in the conventional bypass cookie so the web server can | |
| 1183 | + // enforce it on subsequent requests without starting PHP. | |
| 1184 | + // | |
| 1185 | + // This is what stops most settings changes from needing an nginx | |
| 1186 | + // reload: the config tests one fixed cookie name forever, and the | |
| 1187 | + // rule list behind it can change freely. | |
| 1188 | + // | |
| 1189 | + // But ONLY when the verdict is about the visitor. A request-shape | |
| 1190 | + // gate — `non-get` above all — says nothing about who is asking, | |
| 1191 | + // and persisting it pinned that visitor to the uncached path for | |
| 1192 | + // the rest of their session: one search-form POST, one comment, | |
| 1193 | + // one `curl -I` from an uptime monitor, and every later GET | |
| 1194 | + // bypassed. It could not self-heal either, because the bypass | |
| 1195 | + // cookie is itself in excluded_cookies, so the next GET bypassed | |
| 1196 | + // with `excluded-cookie` and landed right back here, where | |
| 1197 | + // sync_bypass_cookie()'s no-change short-circuit left the cookie | |
| 1198 | + // exactly where it was. (#218) | |
| 1199 | + if ( self::bypass_is_visitor_scoped( self::bypass_reason() ) ) { | |
| 1200 | + self::sync_bypass_cookie( true ); | |
| 1201 | + } | |
| 67 | 1202 | return; |
| 68 | 1203 | } |
| 69 | 1204 | |
| 1205 | + // Cacheable: clear any stale bypass cookie, or a visitor who once | |
| 1206 | + // had a cart would keep skipping the fast path long after checkout. | |
| 1207 | + self::sync_bypass_cookie( false ); | |
| 1208 | + | |
| 70 | 1209 | $key = self::cache_key(); |
| 71 | 1210 | $file = self::cache_file_for( $key ); |
| 72 | 1211 | |
| 73 | 1212 | if ( file_exists( $file ) && ! self::is_expired( $file ) ) { |
| @@ -78,11 +1217,9 @@ | ||
| 78 | 1217 | // serve path — the one that runs when the drop-in isn't loaded |
| 79 | 1218 | // (e.g. WP_CACHE not true) — previously streamed the cached |
| 80 | 1219 | // file with NO marker, so a genuine HIT looked like a MISS in |
| 81 | 1220 | // the response headers. Same header + value as the drop-in. |
| 82 | - if ( ! headers_sent() ) { | |
| 83 | - header( 'X-XSpeed-Cache: HIT (php)' ); | |
| 84 | - } | |
| 1221 | + self::mark( 'HIT (php)' ); | |
| 85 | 1222 | // Replay stored response bits so the HIT matches the original: |
| 86 | 1223 | // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200 |
| 87 | 1224 | // status (a cached 404 must serve 404, not 200). No-op for |
| 88 | 1225 | // ordinary pages, which write no .meta. |
| @@ -126,11 +1263,27 @@ | ||
| 126 | 1263 | // Apache. See maybe_emit_lscache_headers() for the full rationale. |
| 127 | 1264 | self::maybe_emit_lscache_headers(); |
| 128 | 1265 | |
| 129 | 1266 | // We're about to render fresh + cache → miss for this request. |
| 130 | - Hit_Counter::record_miss(); | |
| 1267 | + // …UNLESS this request is a 404 or a known bot/scanner. Those reach the | |
| 1268 | + // render path too, but counting them as cache misses makes the ratio | |
| 1269 | + // meaningless — a wave of `/wp-x7.php` scanner 404s reads as a collapsing | |
| 1270 | + // cache when nothing is wrong. Runs at template_redirect (priority 0), so | |
| 1271 | + // is_404() is already resolved. Excluded requests are tallied separately | |
| 1272 | + // for the "you absorbed N scanner hits" line, not dropped. (#118) | |
| 1273 | + if ( self::miss_is_excluded() ) { | |
| 1274 | + Hit_Counter::record_excluded(); | |
| 1275 | + } else { | |
| 1276 | + Hit_Counter::record_miss(); | |
| 1277 | + } | |
| 131 | 1278 | |
| 1279 | + // Stamp it, so "eligible but not cached yet" is visibly different | |
| 1280 | + // from "deliberately bypassed" (issue #10). Headers can't be sent | |
| 1281 | + // after the body starts, so this has to happen here, not in | |
| 1282 | + // finalize_buffer() — nothing has been output at template_redirect. | |
| 1283 | + self::mark( 'MISS' ); | |
| 132 | 1284 | |
| 1285 | + | |
| 133 | 1286 | // WP < 6.9 fallback: ob_start() with a callback, paired with an |
| 134 | 1287 | // explicit shutdown close so the buffer lifecycle is visible to |
| 135 | 1288 | // reviewers and Plugin Check, instead of relying on PHP's implicit |
| 136 | 1289 | // request-end flush. We record our nesting level so close_buffer() |
| @@ -159,20 +1312,192 @@ | ||
| 159 | 1312 | } |
| 160 | 1313 | self::$buffer_level = null; |
| 161 | 1314 | } |
| 162 | 1315 | |
| 1316 | + /** | |
| 1317 | + * Are we buffering this request? | |
| 1318 | + * | |
| 1319 | + * Asked by Css_Combine_Buffer, which needs the finished HTML but must not | |
| 1320 | + * open a second buffer when this one is already going to hand it the page | |
| 1321 | + * through `xspeed_cache_final_html`. False here means the request is not | |
| 1322 | + * cacheable — cache off, excluded URL, logged in — and the combiner has to | |
| 1323 | + * provide its own buffer or it silently stops working. (#195) | |
| 1324 | + */ | |
| 1325 | + public static function is_buffering(): bool { | |
| 1326 | + return null !== self::$buffer_level; | |
| 1327 | + } | |
| 1328 | + | |
| 1329 | + /** | |
| 1330 | + * Is a render-time translation plugin going to wrap our output buffer? | |
| 1331 | + * | |
| 1332 | + * TranslatePress opens its translation buffer on `init` priority 0. We | |
| 1333 | + * open ours on `template_redirect`, which runs much later, so ours nests | |
| 1334 | + * INSIDE theirs. PHP unwinds output buffers LIFO — innermost callback | |
| 1335 | + * first — so `finalize_buffer()` saw the raw, pre-translation HTML and | |
| 1336 | + * cached that, while the live visitor still got the translated bytes from | |
| 1337 | + * TRP's outer buffer. | |
| 1338 | + * | |
| 1339 | + * Result: the first (MISS) visitor to /fr/some-page/ got correct French; | |
| 1340 | + * every visitor after got English body text under a `lang="fr-FR"` | |
| 1341 | + * document, plus TRP's internal `#TRPLINKPROCESSED` link markers, which | |
| 1342 | + * TRP strips at the very end of its own buffer and which therefore leak | |
| 1343 | + * into anything captured from inside it. | |
| 1344 | + * | |
| 1345 | + * Note the ordering cannot be fixed from TRP's side: its | |
| 1346 | + * `trp_start_output_buffer_priority` filter only moves the PRIORITY on | |
| 1347 | + * `init`, and `init` always fires before `template_redirect` whatever the | |
| 1348 | + * priority. The buffer that has to move is ours. | |
| 1349 | + * | |
| 1350 | + * Detected by main class rather than plugin path, so a renamed directory | |
| 1351 | + * or a bundled copy still matches. | |
| 1352 | + */ | |
| 1353 | + public static function translation_plugin_active(): bool { | |
| 1354 | + $active = class_exists( 'TRP_Translate_Press' ); | |
| 1355 | + | |
| 1356 | + /** | |
| 1357 | + * Whether to treat this request as wrapped by a translation buffer. | |
| 1358 | + * | |
| 1359 | + * Lets a site add another render-time translation plugin (or opt out) | |
| 1360 | + * without patching the engine. | |
| 1361 | + * | |
| 1362 | + * @param bool $active | |
| 1363 | + */ | |
| 1364 | + return (bool) apply_filters( 'xspeed_translation_plugin_active', $active ); | |
| 1365 | + } | |
| 1366 | + | |
| 1367 | + /** | |
| 1368 | + * Write the cache file for a request whose output was wrapped by a | |
| 1369 | + * render-time translation plugin. | |
| 1370 | + * | |
| 1371 | + * Registered as a PHP shutdown function (not a WP `shutdown` action) so | |
| 1372 | + * it runs after PHP has unwound the output-buffer stack — by which point | |
| 1373 | + * the translation plugin's callback has transformed the bytes and its | |
| 1374 | + * internal markers are gone. | |
| 1375 | + * | |
| 1376 | + * finalize_buffer() has already applied the status gate, the | |
| 1377 | + * xspeed_cache_final_html filter and HTML minification to the | |
| 1378 | + * untranslated copy and then declined to write it. Here we re-run only | |
| 1379 | + * what's needed on the translated bytes: minify, write, and fire the | |
| 1380 | + * same downstream hooks so Brotli / static-tree listeners behave | |
| 1381 | + * identically to the ordinary path. | |
| 1382 | + */ | |
| 1383 | + public static function write_deferred_translated_cache(): void { | |
| 1384 | + $key = self::$deferred_key; | |
| 1385 | + self::$deferred_key = null; | |
| 1386 | + | |
| 1387 | + // Release the collected bytes BEFORE the early return, so the static | |
| 1388 | + // is cleared on every path rather than only when a key survived. | |
| 1389 | + $full = self::$translated_output; | |
| 1390 | + self::$translated_output = ''; | |
| 1391 | + | |
| 1392 | + $completed = self::$render_completed; | |
| 1393 | + self::$render_completed = false; | |
| 1394 | + | |
| 1395 | + if ( null === $key ) { | |
| 1396 | + return; | |
| 1397 | + } | |
| 1398 | + | |
| 1399 | + // Did the render actually finish? | |
| 1400 | + // | |
| 1401 | + // This runs as a PHP shutdown function, which fires after a wp_die() | |
| 1402 | + // or a bare exit() just as readily as after a clean render — but in | |
| 1403 | + // those cases finalize_buffer() never returned, so the bytes we hold | |
| 1404 | + // are a page that was cut off partway through. The length and | |
| 1405 | + // TRPLINKPROCESSED checks below don't catch that: a fatal after the | |
| 1406 | + // footer's translated markup is both over 255 bytes and free of TRP | |
| 1407 | + // markers, i.e. truncated but entirely plausible. Caching it would | |
| 1408 | + // freeze a half-rendered page under the real key for the full TTL. | |
| 1409 | + // | |
| 1410 | + // Serving this one URL uncached is the cheap failure; the corrupt | |
| 1411 | + // cache entry is the expensive one. | |
| 1412 | + if ( ! $completed ) { | |
| 1413 | + return; | |
| 1414 | + } | |
| 1415 | + | |
| 1416 | + if ( strlen( $full ) < 255 ) { | |
| 1417 | + return; | |
| 1418 | + } | |
| 1419 | + | |
| 1420 | + // Refuse to cache a copy still carrying the translation plugin's | |
| 1421 | + // internal link markers. TRP strips these at the very end of its own | |
| 1422 | + // buffer, so their presence means we captured too early — and a | |
| 1423 | + // cached page containing them is SEO-visible damage. Better to serve | |
| 1424 | + // this URL uncached than to freeze broken markup for the full TTL. | |
| 1425 | + if ( false !== strpos( $full, 'TRPLINKPROCESSED' ) ) { | |
| 1426 | + return; | |
| 1427 | + } | |
| 1428 | + | |
| 1429 | + $minify_opts = Settings_Manager::get( 'minify' ); | |
| 1430 | + if ( ! empty( $minify_opts['minify_html'] ) ) { | |
| 1431 | + $full = Minifier::minify_html( $full ); | |
| 1432 | + } | |
| 1433 | + $full = self::signed( $full ); | |
| 1434 | + | |
| 1435 | + // Per-site directory: on multisite every blog shares this tree, so | |
| 1436 | + // entries are bucketed by host to keep one site's purge from | |
| 1437 | + // sweeping the whole network. (#6) | |
| 1438 | + self::ensure_host_dir(); | |
| 1439 | + | |
| 1440 | + // Never author a cache entry from a request that carried a query | |
| 1441 | + // string: cache_key() files it under the BARE url, so the params' | |
| 1442 | + // render would be served to every clean-URL visitor (#241). | |
| 1443 | + if ( self::query_string_blocks_write() ) { | |
| 1444 | + return; | |
| 1445 | + } | |
| 1446 | + | |
| 1447 | + $file = self::cache_file_for( $key ); | |
| 1448 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; this runs on a frontend shutdown where it's unavailable. | |
| 1449 | + file_put_contents( $file, $full, LOCK_EX ); | |
| 1450 | + | |
| 1451 | + /** This action is documented in includes/class-cache.php */ | |
| 1452 | + do_action( 'xspeed_flat_file_written', $file, $full ); | |
| 1453 | + | |
| 1454 | + self::write_meta( $key, $full ); | |
| 1455 | + | |
| 1456 | + // Static tree too, under the same gates finalize_buffer() applies — | |
| 1457 | + // otherwise deferring the write would silently cost translated pages | |
| 1458 | + // the web-server fast path and leave them on the slower drop-in. | |
| 1459 | + // The static tree cannot replay a sidecar. A file served straight by | |
| 1460 | + // the web server carries the headers baked into the rule that serves | |
| 1461 | + // the whole site — the very answer this entry exists because it | |
| 1462 | + // disagreed with. Same reasoning as the status and content-type | |
| 1463 | + // cases: what the fast path cannot replay belongs on the drop-in path. | |
| 1464 | + if ( self::static_rewrite_allowed() | |
| 1465 | + && self::response_is_plain_html() | |
| 1466 | + && array() === self::per_entry_edge_headers() ) { | |
| 1467 | + self::store_static( $full ); | |
| 1468 | + } | |
| 1469 | + } | |
| 1470 | + | |
| 163 | 1471 | public static function should_cache() { |
| 1472 | + // Reset first: a single request only reaches this once (the sole | |
| 1473 | + // caller is maybe_start_cache()), but tests and any future caller | |
| 1474 | + // must never inherit the previous request's verdict. | |
| 1475 | + self::$status_header = ''; | |
| 1476 | + self::$bypass_reason = ''; | |
| 1477 | + self::$edge_headers = array(); | |
| 1478 | + self::$per_entry_edge = null; | |
| 1479 | + // Under PHP-FPM a process serves one request and this is moot. Under | |
| 1480 | + // a persistent worker runtime it is not: without it, an answer | |
| 1481 | + // resolved from one visitor's forgeable headers would be reused for | |
| 1482 | + // every later request the worker handles. | |
| 1483 | + Edge_Provider::forget(); | |
| 1484 | + | |
| 164 | 1485 | $opts = Settings::get(); |
| 165 | 1486 | if ( empty( $opts['cache_enabled'] ) ) { |
| 166 | - return false; | |
| 1487 | + return self::bypass( 'cache-disabled' ); | |
| 167 | 1488 | } |
| 168 | 1489 | |
| 169 | - if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { | |
| 170 | - return false; | |
| 1490 | + if ( is_user_logged_in() ) { | |
| 1491 | + return self::bypass( 'logged-in' ); | |
| 171 | 1492 | } |
| 172 | 1493 | |
| 1494 | + if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { | |
| 1495 | + return self::bypass( 'non-frontend' ); | |
| 1496 | + } | |
| 1497 | + | |
| 173 | 1498 | if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) { |
| 174 | - return false; | |
| 1499 | + return self::bypass( 'donotcachepage' ); | |
| 175 | 1500 | } |
| 176 | 1501 | |
| 177 | 1502 | // All exclusion knobs now owned by CacheModule. |
| 178 | 1503 | $cache_opts = Settings_Manager::get( 'cache' ); |
| @@ -178,9 +1503,9 @@ | ||
| 178 | 1503 | $cache_opts = Settings_Manager::get( 'cache' ); |
| 179 | 1504 | |
| 180 | 1505 | $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : ''; |
| 181 | 1506 | if ( 'GET' !== $method ) { |
| 182 | - return false; | |
| 1507 | + return self::bypass( 'non-get' ); | |
| 183 | 1508 | } |
| 184 | 1509 | |
| 185 | 1510 | // Search-results requests carry a `s` query param, which the |
| 186 | 1511 | // query-string gate below would normally reject as "dynamic". An |
| @@ -207,13 +1532,35 @@ | ||
| 207 | 1532 | * @param bool $cache_feed Whether to cache this feed request. |
| 208 | 1533 | */ |
| 209 | 1534 | $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false ); |
| 210 | 1535 | |
| 1536 | + // WordPress's virtual robots.txt (and virtual favicon) are not HTML: | |
| 1537 | + // caching one runs it through the whole HTML pipeline, which stamped | |
| 1538 | + // the footer comment onto text/plain and let HTML minification | |
| 1539 | + // collapse robots.txt to a single line — a line-based format, so | |
| 1540 | + // every directive after the first was lost and crawlers read an | |
| 1541 | + // invalid file. No opt-in filter here: there is no correct way to | |
| 1542 | + // treat these as pages. (Reported live on a customer site.) | |
| 1543 | + if ( ( function_exists( 'is_robots' ) && is_robots() ) | |
| 1544 | + || ( function_exists( 'is_favicon' ) && is_favicon() ) ) { | |
| 1545 | + return self::bypass( 'non-html' ); | |
| 1546 | + } | |
| 1547 | + | |
| 211 | 1548 | // Query string handling: anything OUTSIDE the ignored-params |
| 212 | 1549 | // allow-list (utm_*, fbclid, gclid by default) means a unique |
| 213 | 1550 | // request that we don't want to share with the canonical cache |
| 214 | 1551 | // entry. Skip cache rather than poison the key. |
| 215 | - $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : ''; | |
| 1552 | + // | |
| 1553 | + // Parse the RAW query string, NOT a sanitize_text_field() copy: | |
| 1554 | + // that filter strips percent-encoded octets (%XX), so `?%73=…` | |
| 1555 | + // would lose its `s` key here while WordPress still decodes it to | |
| 1556 | + // a search request — the gate would wave the request through and | |
| 1557 | + // cache_key() would file the search page under the bare URL, | |
| 1558 | + // letting an attacker poison the homepage cache with `/?%73=<spam>`. | |
| 1559 | + // parse_str() does its own urldecoding, matching WP's own parse, and | |
| 1560 | + // only the KEYS are used below (fed to Glob_Matcher → preg_match, | |
| 1561 | + // never echoed or executed), so no sanitization is needed here. | |
| 1562 | + $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- see note above: parse_str() urldecodes to match WP; only keys are consumed, via preg_match, never output. | |
| 216 | 1563 | if ( '' !== $query_raw ) { |
| 217 | 1564 | $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array(); |
| 218 | 1565 | parse_str( $query_raw, $params ); |
| 219 | 1566 | foreach ( $params as $key => $_ ) { |
| @@ -226,9 +1573,11 @@ | ||
| 226 | 1573 | if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) { |
| 227 | 1574 | continue; |
| 228 | 1575 | } |
| 229 | 1576 | if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) { |
| 230 | - return false; | |
| 1577 | + // Slug only — never the param name, which is attacker- | |
| 1578 | + // controlled and would be reflected into a header. | |
| 1579 | + return self::bypass( 'query-param' ); | |
| 231 | 1580 | } |
| 232 | 1581 | } |
| 233 | 1582 | } |
| 234 | 1583 | |
| @@ -236,9 +1585,9 @@ | ||
| 236 | 1585 | $path = (string) strtok( $request_uri, '?' ); |
| 237 | 1586 | |
| 238 | 1587 | $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array(); |
| 239 | 1588 | if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) { |
| 240 | - return false; | |
| 1589 | + return self::bypass( 'excluded-url' ); | |
| 241 | 1590 | } |
| 242 | 1591 | |
| 243 | 1592 | // Cookie-based exclusion. We only check cookie NAMES (matching |
| 244 | 1593 | // values would leak content-sensitive logic into the cache key |
| @@ -245,10 +1594,21 @@ | ||
| 245 | 1594 | // rules); presence of any matching cookie name skips cache. |
| 246 | 1595 | $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array(); |
| 247 | 1596 | if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) { |
| 248 | 1597 | foreach ( array_keys( $_COOKIE ) as $cookie_name ) { |
| 1598 | + // Our own bypass cookie is a RECORD of a previous verdict, not | |
| 1599 | + // evidence about this visitor, so it never gets a vote here. | |
| 1600 | + // Letting it match made the verdict self-confirming: once set, | |
| 1601 | + // it produced `excluded-cookie` forever, which re-set it, and | |
| 1602 | + // no later request could ever re-evaluate the visitor on the | |
| 1603 | + // rules that actually describe them. The web server still acts | |
| 1604 | + // on the cookie without booting PHP; when PHP does boot it is | |
| 1605 | + // authoritative and re-decides from scratch. (#218) | |
| 1606 | + if ( Server_Rules::BYPASS_COOKIE === $cookie_name ) { | |
| 1607 | + continue; | |
| 1608 | + } | |
| 249 | 1609 | if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) { |
| 250 | - return false; | |
| 1610 | + return self::bypass( 'excluded-cookie' ); | |
| 251 | 1611 | } |
| 252 | 1612 | } |
| 253 | 1613 | } |
| 254 | 1614 | |
| @@ -259,9 +1619,9 @@ | ||
| 259 | 1619 | if ( ! empty( $bypass_uas ) ) { |
| 260 | 1620 | $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; |
| 261 | 1621 | foreach ( $bypass_uas as $needle ) { |
| 262 | 1622 | if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) { |
| 263 | - return false; | |
| 1623 | + return self::bypass( 'user-agent' ); | |
| 264 | 1624 | } |
| 265 | 1625 | } |
| 266 | 1626 | } |
| 267 | 1627 | |
| @@ -268,9 +1628,9 @@ | ||
| 268 | 1628 | // Per-post override (Phase 3.4). Honored only on singular |
| 269 | 1629 | // post-context requests — archives / 404s / taxonomies use the |
| 270 | 1630 | // global policy above. |
| 271 | 1631 | if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) { |
| 272 | - return false; | |
| 1632 | + return self::bypass( 'post-excluded' ); | |
| 273 | 1633 | } |
| 274 | 1634 | |
| 275 | 1635 | /** |
| 276 | 1636 | * Final say on whether the current request is cacheable. |
| @@ -290,9 +1650,16 @@ | ||
| 290 | 1650 | * advanced-cache.php. |
| 291 | 1651 | * |
| 292 | 1652 | * @param bool $should_cache Whether to cache the current request. |
| 293 | 1653 | */ |
| 294 | - return (bool) apply_filters( 'xspeed_should_cache', true ); | |
| 1654 | + if ( ! apply_filters( 'xspeed_should_cache', true ) ) { | |
| 1655 | + // One slug for every listener — a third-party callback name is | |
| 1656 | + // not ours to put in a response header. Which listener vetoed is | |
| 1657 | + // a WP_DEBUG-level question the filter itself can answer. | |
| 1658 | + return self::bypass( 'filtered' ); | |
| 1659 | + } | |
| 1660 | + | |
| 1661 | + return true; | |
| 295 | 1662 | } |
| 296 | 1663 | |
| 297 | 1664 | /** |
| 298 | 1665 | * Whether the current request is a 404 we may cache. |
| @@ -336,8 +1703,75 @@ | ||
| 336 | 1703 | * search_term() / cache_key()) so different searches stay distinct. |
| 337 | 1704 | * The xspeed-pro search cache flips the filter; Free never caches |
| 338 | 1705 | * search results on its own. |
| 339 | 1706 | */ |
| 1707 | + /** | |
| 1708 | + * Whether this response was rendered for a query string and therefore | |
| 1709 | + * must not be STORED under the bare-URL key. | |
| 1710 | + * | |
| 1711 | + * should_cache() lets a request through when every key is on the | |
| 1712 | + * `ignored_query_params` allow-list, and cache_key() then drops the | |
| 1713 | + * query string so `/post` and `/post?utm_source=x` share one entry. | |
| 1714 | + * Sharing on READ is the point of the allow-list and stays. Sharing on | |
| 1715 | + * WRITE is a cache-poisoning vector: the response was rendered *with* | |
| 1716 | + * those params, and WordPress reflects REQUEST_URI into form actions, | |
| 1717 | + * share links, canonical helpers and plugin smart tags. One anonymous | |
| 1718 | + * GET to a cold URL therefore freezes an attacker-chosen variant under | |
| 1719 | + * the clean URL's key, served for the whole TTL by the drop-in and by | |
| 1720 | + * the web server — neither of which runs these checks (issue #241). | |
| 1721 | + * | |
| 1722 | + * The allow-list keeps its benefit: a visitor arriving on | |
| 1723 | + * `?utm_source=…` is still SERVED the canonical cached entry. Only the | |
| 1724 | + * write is skipped, so the entry is authored by a clean request. | |
| 1725 | + * | |
| 1726 | + * This is the same reasoning as the `should_cache_search()` guard in | |
| 1727 | + * store_static() (#191), generalised to the allow-listed params. | |
| 1728 | + */ | |
| 1729 | + public static function request_has_query_string(): bool { | |
| 1730 | + $query = isset( $_SERVER['QUERY_STRING'] ) | |
| 1731 | + ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only tested for emptiness; never echoed, stored or used as a path. | |
| 1732 | + : ''; | |
| 1733 | + | |
| 1734 | + return '' !== trim( $query ); | |
| 1735 | + } | |
| 1736 | + | |
| 1737 | + /** | |
| 1738 | + * Would authoring a cache entry from THIS request file a query-string | |
| 1739 | + * render under the bare URL? | |
| 1740 | + * | |
| 1741 | + * The one predicate both write sites ask, so they cannot drift. | |
| 1742 | + * | |
| 1743 | + * Two shapes are exempt because cache_key() does NOT drop their query — | |
| 1744 | + * it folds the distinguishing part into the key, so each variant gets | |
| 1745 | + * its own entry and none is filed under the bare URL: | |
| 1746 | + * | |
| 1747 | + * - searches, keyed by `|s=<term>` (#191) | |
| 1748 | + * - feeds, keyed by `|feed=<type>` — `/?feed=rss2` is the ONLY feed URL | |
| 1749 | + * core generates on plain permalinks, so treating it as poisonable | |
| 1750 | + * made feed caching a no-op on exactly the sites that need it | |
| 1751 | + * | |
| 1752 | + * @return bool True when the write must be skipped. | |
| 1753 | + */ | |
| 1754 | + public static function query_string_blocks_write(): bool { | |
| 1755 | + if ( ! self::request_has_query_string() ) { | |
| 1756 | + return false; | |
| 1757 | + } | |
| 1758 | + | |
| 1759 | + if ( self::should_cache_search() ) { | |
| 1760 | + return false; | |
| 1761 | + } | |
| 1762 | + | |
| 1763 | + // Feed caching is opt-in, via the same filter should_cache() reads | |
| 1764 | + // to admit the feed params in the first place. | |
| 1765 | + if ( function_exists( 'is_feed' ) && is_feed() | |
| 1766 | + && (bool) apply_filters( 'xspeed_should_cache_feed', false ) | |
| 1767 | + ) { | |
| 1768 | + return false; | |
| 1769 | + } | |
| 1770 | + | |
| 1771 | + return true; | |
| 1772 | + } | |
| 1773 | + | |
| 340 | 1774 | public static function should_cache_search(): bool { |
| 341 | 1775 | if ( ! function_exists( 'is_search' ) || ! is_search() ) { |
| 342 | 1776 | return false; |
| 343 | 1777 | } |
| @@ -377,13 +1811,22 @@ | ||
| 377 | 1811 | } |
| 378 | 1812 | |
| 379 | 1813 | /** |
| 380 | 1814 | * Is this query-string key on the ignored-params allow-list? Supports |
| 381 | - * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`, | |
| 382 | - * etc.) so users don't have to enumerate every UTM variant. | |
| 1815 | + * globs (`utm_*` matches `utm_source`, `utm_medium`, etc.) so users | |
| 1816 | + * don't have to enumerate every UTM variant, and `~regex`. | |
| 1817 | + * | |
| 1818 | + * Matching is whole-name, not "contains" — a param name is an | |
| 1819 | + * identifier, not a path. Under the old contains match the shipped | |
| 1820 | + * default `ref` also swallowed `preference`, `product_ref` and | |
| 1821 | + * `referrer`: those params were dropped from the cache key, so | |
| 1822 | + * `/shop?preference=1` was served — and, on a cold entry, WRITTEN as — | |
| 1823 | + * `/shop`. Same for `_ga` vs `_gallery`, and for the unanchored | |
| 1824 | + * `~utm_…` default vs `my_utm_source`. A param name that is genuinely | |
| 1825 | + * unknown now bypasses the cache, which is the safe direction. | |
| 383 | 1826 | */ |
| 384 | 1827 | private static function query_key_is_ignored( string $key, array $ignored ): bool { |
| 385 | - return Glob_Matcher::any_match( $ignored, $key ); | |
| 1828 | + return Glob_Matcher::any_match_name( $ignored, $key ); | |
| 386 | 1829 | } |
| 387 | 1830 | |
| 388 | 1831 | public static function cache_key() { |
| 389 | 1832 | $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default'; |
| @@ -459,10 +1902,301 @@ | ||
| 459 | 1902 | } |
| 460 | 1903 | return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua ); |
| 461 | 1904 | } |
| 462 | 1905 | |
| 1906 | + /** | |
| 1907 | + * Filesystem-safe directory name for a host, or '' when unusable. | |
| 1908 | + * | |
| 1909 | + * The charset MUST match the static tree (store_static()) and the | |
| 1910 | + * drop-in's own copy, or the paths disagree about where an entry lives. | |
| 1911 | + * The colon of `host:port` is stripped: it is legal in a Host header but | |
| 1912 | + * not portable in a path. | |
| 1913 | + * | |
| 1914 | + * @param string $host Raw host, e.g. from HTTP_HOST. | |
| 1915 | + * @return string Safe directory segment, or '' if nothing usable remains. | |
| 1916 | + */ | |
| 1917 | + /** | |
| 1918 | + * The host segment of the STATIC tree — `xspeed-static/<host>/…`, which | |
| 1919 | + * the web server resolves without PHP. | |
| 1920 | + * | |
| 1921 | + * Different from host_dir(): here the port is folded INTO the segment | |
| 1922 | + * (`localhost:8080` → `localhost8080`) rather than dropped, because the | |
| 1923 | + * generated server rules have to reproduce this from their own variables | |
| 1924 | + * and nginx's `$host` has no port to drop — see the `$xspeed_host` | |
| 1925 | + * derivation in nginx_snippet(). Shared by the write and the purge so the | |
| 1926 | + * two can't drift; when they did, purging a page on a ported host deleted | |
| 1927 | + * nothing and the stale copy kept being served by the rewrite. | |
| 1928 | + */ | |
| 1929 | + public static function static_host_dir( string $host ): string { | |
| 1930 | + return (string) preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host ); | |
| 1931 | + } | |
| 1932 | + | |
| 1933 | + public static function host_dir( string $host ): string { | |
| 1934 | + $host = str_replace( "\0", '', $host ); | |
| 1935 | + // Drop the port BEFORE filtering, or `example.com:8080` collapses to | |
| 1936 | + // `example.com8080` — which both loses the boundary and could collide | |
| 1937 | + // with a real host of that name. | |
| 1938 | + $colon = strpos( $host, ':' ); | |
| 1939 | + if ( false !== $colon ) { | |
| 1940 | + $host = substr( $host, 0, $colon ); | |
| 1941 | + } | |
| 1942 | + $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host ); | |
| 1943 | + // Collapse any run of dots so no traversal sequence can survive the | |
| 1944 | + // charset filter (`a/../b` would otherwise reduce to `a..b`). | |
| 1945 | + $host = preg_replace( '/\.{2,}/', '.', (string) $host ); | |
| 1946 | + $host = trim( (string) $host, '.-' ); | |
| 1947 | + return '' === $host ? '' : $host; | |
| 1948 | + } | |
| 1949 | + | |
| 1950 | + /** | |
| 1951 | + * The per-site bucket a cache entry belongs to: `<host>` on a single | |
| 1952 | + * site, `<host>/<path-prefix>` for a subdirectory multisite blog. | |
| 1953 | + * | |
| 1954 | + * On multisite every blog shares one cache directory, and a flat md5 | |
| 1955 | + * filename carries no clue which site wrote it — so purging one subsite | |
| 1956 | + * swept the whole network cold. (#6) | |
| 1957 | + * | |
| 1958 | + * Host alone is NOT enough: a subdirectory network (the common layout) | |
| 1959 | + * puts every blog on the same host, so `example.com/` and | |
| 1960 | + * `example.com/siteb/` would share a bucket and keep purging each other. | |
| 1961 | + * The path prefix is what separates them, and it is derivable from the | |
| 1962 | + * REQUEST_URI alone — which matters because the drop-in must compute | |
| 1963 | + * this identical value before WordPress (and get_blog_details()) exist. | |
| 1964 | + * | |
| 1965 | + * Subdomain and domain-mapped networks differ by host already, so they | |
| 1966 | + * get a bare host bucket and are unaffected. | |
| 1967 | + * | |
| 1968 | + * @param string $host Raw host. | |
| 1969 | + * @param string $uri Raw REQUEST_URI (query string is ignored). | |
| 1970 | + * @return string Bucket path, always non-empty. | |
| 1971 | + */ | |
| 1972 | + public static function site_bucket( string $host, string $uri ): string { | |
| 1973 | + $dir = self::host_dir( $host ); | |
| 1974 | + if ( '' === $dir ) { | |
| 1975 | + $dir = 'default'; | |
| 1976 | + } | |
| 1977 | + | |
| 1978 | + $prefix = self::site_path_prefix(); | |
| 1979 | + return '' === $prefix ? $dir : $dir . '/' . $prefix; | |
| 1980 | + } | |
| 1981 | + | |
| 1982 | + /** | |
| 1983 | + * The current blog's path prefix as a single safe segment ('' for the | |
| 1984 | + * root blog or a non-multisite install). `/siteb/` becomes `siteb`; | |
| 1985 | + * a nested `/a/b/` becomes `a-b` so the bucket stays one level deep. | |
| 1986 | + * | |
| 1987 | + * Written to a sidecar for the drop-in by sync_site_paths(). | |
| 1988 | + */ | |
| 1989 | + public static function site_path_prefix(): string { | |
| 1990 | + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) { | |
| 1991 | + return ''; | |
| 1992 | + } | |
| 1993 | + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) { | |
| 1994 | + return ''; // Hosts already differ; no prefix needed. | |
| 1995 | + } | |
| 1996 | + $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/'; | |
| 1997 | + return self::path_prefix_segment( $path ); | |
| 1998 | + } | |
| 1999 | + | |
| 2000 | + /** | |
| 2001 | + * The bucket an arbitrary URL's cache entry lives in. | |
| 2002 | + * | |
| 2003 | + * `site_bucket()` answers for the CURRENT request; this answers for a URL | |
| 2004 | + * that may belong to another blog entirely — which is what a per-URL purge | |
| 2005 | + * is usually doing (WP-CLI, cron, the MCP tool, a network-admin action). | |
| 2006 | + * | |
| 2007 | + * The blog is resolved from the URL itself: on a subdirectory network | |
| 2008 | + * `get_blog_details()` is asked which blog owns `<host><path>`, and its | |
| 2009 | + * registered path becomes the prefix. Deriving the prefix from the URL's | |
| 2010 | + * first path segment directly would be wrong — `/shop/` on the main blog | |
| 2011 | + * is a page, not a subsite, and would send the purge into a bucket that | |
| 2012 | + * does not exist. (QA B2 on #166) | |
| 2013 | + * | |
| 2014 | + * @param string $host Host of the URL being purged. | |
| 2015 | + * @param string $path Path of the URL being purged. | |
| 2016 | + * @return string Bucket path, always non-empty. | |
| 2017 | + */ | |
| 2018 | + public static function bucket_for_url( string $host, string $path ): string { | |
| 2019 | + $dir = self::host_dir( $host ); | |
| 2020 | + if ( '' === $dir ) { | |
| 2021 | + $dir = 'default'; | |
| 2022 | + } | |
| 2023 | + | |
| 2024 | + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) { | |
| 2025 | + return $dir; | |
| 2026 | + } | |
| 2027 | + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) { | |
| 2028 | + return $dir; // Hosts already differ; no prefix. | |
| 2029 | + } | |
| 2030 | + if ( ! function_exists( 'get_blog_details' ) ) { | |
| 2031 | + return $dir; | |
| 2032 | + } | |
| 2033 | + | |
| 2034 | + // Longest registered blog path that prefixes this URL wins, so | |
| 2035 | + // `/one/2026/post/` resolves to blog `/one/` and not to the root blog. | |
| 2036 | + $blog = self::blog_for_path( $host, $path ); | |
| 2037 | + if ( null === $blog ) { | |
| 2038 | + return $dir; | |
| 2039 | + } | |
| 2040 | + $prefix = self::path_prefix_segment( (string) $blog ); | |
| 2041 | + return '' === $prefix ? $dir : $dir . '/' . $prefix; | |
| 2042 | + } | |
| 2043 | + | |
| 2044 | + /** | |
| 2045 | + * The registered path of the blog that owns `<host><path>`, or null. | |
| 2046 | + * | |
| 2047 | + * Uses get_blog_details() with a domain/path pair rather than scanning | |
| 2048 | + * every blog, so a large network costs one lookup per candidate segment | |
| 2049 | + * instead of a full table read. | |
| 2050 | + */ | |
| 2051 | + private static function blog_for_path( string $host, string $path ): ?string { | |
| 2052 | + $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) ); | |
| 2053 | + | |
| 2054 | + // Try the longest candidate first: /a/b/ before /a/ before /. | |
| 2055 | + for ( $take = min( count( $segments ), 2 ); $take >= 1; $take-- ) { | |
| 2056 | + $candidate = '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/'; | |
| 2057 | + $details = get_blog_details( | |
| 2058 | + array( | |
| 2059 | + 'domain' => $host, | |
| 2060 | + 'path' => $candidate, | |
| 2061 | + ), | |
| 2062 | + false | |
| 2063 | + ); | |
| 2064 | + if ( $details && ! empty( $details->path ) ) { | |
| 2065 | + return (string) $details->path; | |
| 2066 | + } | |
| 2067 | + } | |
| 2068 | + return null; | |
| 2069 | + } | |
| 2070 | + | |
| 2071 | + /** | |
| 2072 | + * Normalise a blog path ('/', '/siteb/', '/a/b/') into a single | |
| 2073 | + * filesystem-safe segment. Shared with the drop-in's copy. | |
| 2074 | + */ | |
| 2075 | + public static function path_prefix_segment( string $path ): string { | |
| 2076 | + $path = trim( str_replace( "\0", '', $path ), '/' ); | |
| 2077 | + if ( '' === $path ) { | |
| 2078 | + return ''; | |
| 2079 | + } | |
| 2080 | + $path = preg_replace( '/[^a-zA-Z0-9._\-\/]/', '', $path ); | |
| 2081 | + $path = str_replace( '/', '-', (string) $path ); | |
| 2082 | + return trim( (string) $path, '.-' ); | |
| 2083 | + } | |
| 2084 | + | |
| 2085 | + /** | |
| 2086 | + * The current blog's path as the static tree stores it — real slashes | |
| 2087 | + * preserved, because that tree mirrors the URL | |
| 2088 | + * (`xspeed-static/{host}{request_uri}/index.html`) rather than using a | |
| 2089 | + * single flattened segment. '' for a root blog / single site. | |
| 2090 | + */ | |
| 2091 | + public static function site_path_raw(): string { | |
| 2092 | + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) { | |
| 2093 | + return ''; | |
| 2094 | + } | |
| 2095 | + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) { | |
| 2096 | + return ''; | |
| 2097 | + } | |
| 2098 | + $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/'; | |
| 2099 | + $path = trim( str_replace( "\0", '', $path ), '/' ); | |
| 2100 | + if ( '' === $path ) { | |
| 2101 | + return ''; | |
| 2102 | + } | |
| 2103 | + $path = preg_replace( '#[^a-zA-Z0-9._\-/]#', '', $path ); | |
| 2104 | + return trim( (string) $path, '/' ); | |
| 2105 | + } | |
| 2106 | + | |
| 2107 | + /** | |
| 2108 | + * Static-tree root for the current site: `<host>` plus the blog's real | |
| 2109 | + * path. Mirrors store_static()'s layout so a scoped purge deletes | |
| 2110 | + * exactly this blog's pages. | |
| 2111 | + */ | |
| 2112 | + public static function current_static_scope(): string { | |
| 2113 | + // Same switch_to_blog() caveat as current_host_dir() — see current_host(). | |
| 2114 | + // Keep the port folded into the segment exactly as store_static() does. | |
| 2115 | + $dir = self::static_host_dir( self::current_host() ); | |
| 2116 | + if ( '' === $dir ) { | |
| 2117 | + $dir = 'default'; | |
| 2118 | + } | |
| 2119 | + $path = self::site_path_raw(); | |
| 2120 | + return '' === $path ? $dir : $dir . '/' . $path; | |
| 2121 | + } | |
| 2122 | + | |
| 2123 | + /** | |
| 2124 | + * The bucket for the CURRENT request. Never empty, so an entry is never | |
| 2125 | + * written to the tree root (which is what the unscoped sweeps used to | |
| 2126 | + * delete indiscriminately). | |
| 2127 | + */ | |
| 2128 | + public static function current_host_dir(): string { | |
| 2129 | + $host = self::current_host(); | |
| 2130 | + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; | |
| 2131 | + return self::site_bucket( $host, $uri ); | |
| 2132 | + } | |
| 2133 | + | |
| 2134 | + /** | |
| 2135 | + * The host the CURRENT blog is served from. | |
| 2136 | + * | |
| 2137 | + * Deliberately NOT just $_SERVER['HTTP_HOST']: inside a | |
| 2138 | + * switch_to_blog() the request header still names whichever site is | |
| 2139 | + * serving the admin screen, while the cache entries we want belong to | |
| 2140 | + * the switched-to blog. On a subdomain network the host IS the bucket, | |
| 2141 | + * so reading the header there would make Pro's per-site "purge this | |
| 2142 | + * site" button clear the network admin's own cache instead — the very | |
| 2143 | + * bug this scoping exists to fix, surviving in one topology. | |
| 2144 | + * | |
| 2145 | + * get_blog_details() follows the switch, so prefer it whenever we are | |
| 2146 | + * on multisite, and fall back to the request header otherwise. | |
| 2147 | + */ | |
| 2148 | + public static function current_host(): string { | |
| 2149 | + if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_blog_details' ) ) { | |
| 2150 | + $details = get_blog_details(); | |
| 2151 | + if ( $details && ! empty( $details->domain ) ) { | |
| 2152 | + return (string) $details->domain; | |
| 2153 | + } | |
| 2154 | + } | |
| 2155 | + | |
| 2156 | + if ( isset( $_SERVER['HTTP_HOST'] ) ) { | |
| 2157 | + return sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ); | |
| 2158 | + } | |
| 2159 | + | |
| 2160 | + /* | |
| 2161 | + * No request header — WP-CLI, or WP-Cron driven by system cron. | |
| 2162 | + * | |
| 2163 | + * Returning '' here made the bucket resolve to the literal `default` | |
| 2164 | + * while HTTP requests were writing to `<host>/`, so a scheduled purge | |
| 2165 | + * swept an empty directory and reported success, and get_stats() | |
| 2166 | + * reported 0 cached pages on a site with a full cache. That is the | |
| 2167 | + * normal setup on any host running DISABLE_WP_CRON, which is most of | |
| 2168 | + * them. Fall back to the site's own registered host. (QA D4 on #166) | |
| 2169 | + */ | |
| 2170 | + if ( function_exists( 'home_url' ) ) { | |
| 2171 | + $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- early-boot fallback only. | |
| 2172 | + if ( is_array( $parts ) && ! empty( $parts['host'] ) ) { | |
| 2173 | + return (string) $parts['host']; | |
| 2174 | + } | |
| 2175 | + } | |
| 2176 | + | |
| 2177 | + return ''; | |
| 2178 | + } | |
| 2179 | + | |
| 2180 | + /** | |
| 2181 | + * Ensure the current site's cache directory exists, with the silence | |
| 2182 | + * index in both it and the shared root. Returns the directory. | |
| 2183 | + */ | |
| 2184 | + public static function ensure_host_dir(): string { | |
| 2185 | + $dir = XSPEED_CACHE_DIR . '/' . self::current_host_dir(); | |
| 2186 | + if ( ! file_exists( XSPEED_CACHE_DIR ) ) { | |
| 2187 | + wp_mkdir_p( XSPEED_CACHE_DIR ); | |
| 2188 | + self::write_silence( XSPEED_CACHE_DIR ); | |
| 2189 | + } | |
| 2190 | + if ( ! file_exists( $dir ) ) { | |
| 2191 | + wp_mkdir_p( $dir ); | |
| 2192 | + self::write_silence( $dir ); | |
| 2193 | + } | |
| 2194 | + return $dir; | |
| 2195 | + } | |
| 2196 | + | |
| 463 | 2197 | public static function cache_file_for( $key ) { |
| 464 | - return XSPEED_CACHE_DIR . '/' . $key . '.html'; | |
| 2198 | + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.html'; | |
| 465 | 2199 | } |
| 466 | 2200 | |
| 467 | 2201 | /** |
| 468 | 2202 | * If a precompressed Brotli sibling (`<file>.br`) exists and the client |
| @@ -493,8 +2227,11 @@ | ||
| 493 | 2227 | $br = $file . '.br'; |
| 494 | 2228 | if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) { |
| 495 | 2229 | return null; |
| 496 | 2230 | } |
| 2231 | + if ( ! self::brotli_sibling_is_usable( $file, $br ) ) { | |
| 2232 | + return null; // fall through to the plain .html | |
| 2233 | + } | |
| 497 | 2234 | header( 'Content-Encoding: br' ); |
| 498 | 2235 | header( 'Vary: Accept-Encoding', false ); |
| 499 | 2236 | // The byte length changes for the compressed body — drop any |
| 500 | 2237 | // Content-Length the caller may have set so the stream isn't |
| @@ -503,8 +2240,217 @@ | ||
| 503 | 2240 | return $br; |
| 504 | 2241 | } |
| 505 | 2242 | |
| 506 | 2243 | /** |
| 2244 | + * Is a precompressed `.br` sibling safe to serve? | |
| 2245 | + * | |
| 2246 | + * Existence is not enough. The sibling is written with a plain | |
| 2247 | + * file_put_contents() — no atomic rename — so a crash, a full disk, or a | |
| 2248 | + * read that races the write leaves a TRUNCATED file behind. Serving that | |
| 2249 | + * with `Content-Encoding: br` hands the browser a stream it cannot | |
| 2250 | + * inflate: it renders nothing at all (document.body is null) and the | |
| 2251 | + * navigation can hang. A 16-byte .br for a 172KB page reproduces it | |
| 2252 | + * exactly. (#286) | |
| 2253 | + * | |
| 2254 | + * Brotli has no magic number, and no byte-level marker distinguishes a | |
| 2255 | + * truncated stream from a short valid one (the ISLAST bit is bit-packed, | |
| 2256 | + * not byte-aligned). So this checks only what CAN be known by stat: | |
| 2257 | + * | |
| 2258 | + * - Not empty. A zero-byte sibling is unambiguously broken. | |
| 2259 | + * - Not older than the HTML. A stale sibling would serve the PREVIOUS | |
| 2260 | + * revision of the page under the current entry's ETag. | |
| 2261 | + * | |
| 2262 | + * A size-RATIO floor was tried here and removed. Brotli's ratio is | |
| 2263 | + * unbounded on repetitive input: a ~1 MB page of table rows or a product | |
| 2264 | + * grid — the ordinary shape of a big generated page — compresses to | |
| 2265 | + * about 0.04%, so a 2% floor rejected a perfectly good sibling and sent | |
| 2266 | + * visitors the uncompressed page instead, silently. Measured: 963 KB of | |
| 2267 | + * repeated markup → 89 bytes at q5 (0.009%). No floor can separate | |
| 2268 | + * "impossibly small" from "extremely compressible" for arbitrary HTML. | |
| 2269 | + * | |
| 2270 | + * Truncation is prevented at the WRITE side instead — see | |
| 2271 | + * write_atomic(), which the Brotli writer uses so a partial file is | |
| 2272 | + * never visible under the final name. Detection at read time cannot be | |
| 2273 | + * made correct; not creating the bad file can. | |
| 2274 | + * | |
| 2275 | + * Anything suspicious returns false and the caller streams the plain | |
| 2276 | + * .html — slower, always correct. Serving an uninflatable body is worse | |
| 2277 | + * than serving no compression at all. | |
| 2278 | + * | |
| 2279 | + * @param string $file Absolute path to the .html cache file. | |
| 2280 | + * @param string $br Absolute path to its .br sibling. | |
| 2281 | + * @return bool True when the sibling may be served. | |
| 2282 | + */ | |
| 2283 | + /** | |
| 2284 | + * Write a cache sidecar so a partial file is never visible. | |
| 2285 | + * | |
| 2286 | + * `file_put_contents()` truncates the target and then fills it, so any | |
| 2287 | + * reader arriving mid-write — or any crash, full disk, or killed worker | |
| 2288 | + * — leaves a SHORT file under the real name. For HTML that degrades to a | |
| 2289 | + * clipped page; for a `.br` sibling it is worse, because a truncated | |
| 2290 | + * brotli stream is not a short page but an UNINFLATABLE one: the browser | |
| 2291 | + * renders nothing at all and the navigation can hang. | |
| 2292 | + * | |
| 2293 | + * Writing to a unique temp file in the same directory and renaming is | |
| 2294 | + * atomic on POSIX, so readers see either the previous complete file or | |
| 2295 | + * the new complete file, never a partial one. This is the half of #286 | |
| 2296 | + * that is actually fixable — a read-time heuristic cannot tell a | |
| 2297 | + * truncated brotli stream from a very small valid one, but a truncated | |
| 2298 | + * file that never becomes visible needs no detection. | |
| 2299 | + * | |
| 2300 | + * @param string $path Absolute destination path. | |
| 2301 | + * @param string $contents Bytes to write. | |
| 2302 | + * @return bool True when the destination now holds exactly $contents. | |
| 2303 | + */ | |
| 2304 | + public static function write_atomic( string $path, string $contents ): bool { | |
| 2305 | + $dir = dirname( $path ); | |
| 2306 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem needs admin creds unavailable on a frontend cache write; this is our own cache dir. | |
| 2307 | + if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) { | |
| 2308 | + return false; | |
| 2309 | + } | |
| 2310 | + | |
| 2311 | + // Same directory, so the rename stays on one filesystem — a rename | |
| 2312 | + // across devices is a copy and loses atomicity. | |
| 2313 | + $tmp = @tempnam( $dir, '.xspeed-tmp-' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a failure returns false and the caller skips the write. | |
| 2314 | + if ( ! is_string( $tmp ) || '' === $tmp ) { | |
| 2315 | + return false; | |
| 2316 | + } | |
| 2317 | + | |
| 2318 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem needs admin creds unavailable on a frontend cache write; target is our own cache dir. | |
| 2319 | + $written = @file_put_contents( $tmp, $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- handled by the length check below. | |
| 2320 | + | |
| 2321 | + // A short write is exactly the failure this function exists to | |
| 2322 | + // prevent, so verify the byte count before publishing the file. | |
| 2323 | + if ( false === $written || $written !== strlen( $contents ) ) { | |
| 2324 | + @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own temp file; non-fatal. | |
| 2325 | + return false; | |
| 2326 | + } | |
| 2327 | + | |
| 2328 | + // tempnam() creates the file 0600; cache files must stay readable by | |
| 2329 | + // the web server, which may run as a different user. | |
| 2330 | + @chmod( $tmp, defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod, WordPress.PHP.NoSilencedErrors.Discouraged -- the web server may run as another uid and must be able to read the published file; a chmod failure is not fatal. | |
| 2331 | + | |
| 2332 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged -- the atomic publish this function exists for; WP_Filesystem offers no atomic rename and needs admin creds. | |
| 2333 | + if ( ! @rename( $tmp, $path ) ) { | |
| 2334 | + @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup; non-fatal. | |
| 2335 | + return false; | |
| 2336 | + } | |
| 2337 | + | |
| 2338 | + return true; | |
| 2339 | + } | |
| 2340 | + | |
| 2341 | + public static function brotli_sibling_is_usable( string $file, string $br ): bool { | |
| 2342 | + $br_size = (int) @filesize( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a stat failure means "don't serve it", handled by the <= 0 check. | |
| 2343 | + if ( $br_size <= 0 ) { | |
| 2344 | + return false; | |
| 2345 | + } | |
| 2346 | + | |
| 2347 | + $html_size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above. | |
| 2348 | + if ( $html_size <= 0 ) { | |
| 2349 | + return false; | |
| 2350 | + } | |
| 2351 | + | |
| 2352 | + // A sibling older than the page it compresses is stale. | |
| 2353 | + $br_mtime = (int) @filemtime( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above. | |
| 2354 | + $html_mtime = (int) @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above. | |
| 2355 | + if ( $br_mtime > 0 && $html_mtime > 0 && $br_mtime < $html_mtime ) { | |
| 2356 | + return false; | |
| 2357 | + } | |
| 2358 | + | |
| 2359 | + // The writer recorded how many bytes it produced. Where that record | |
| 2360 | + // exists, truncation is a certainty rather than an inference: a | |
| 2361 | + // stream shorter than its own declared length cannot inflate, and | |
| 2362 | + // one that matches was published whole. This is what a size ratio | |
| 2363 | + // could never be — brotli's ratio is unbounded on repetitive input, | |
| 2364 | + // so a 0.01% sibling of a generated page is genuinely valid. | |
| 2365 | + // | |
| 2366 | + // Absent for a sibling written before this version, or by an add-on | |
| 2367 | + // that writes the file directly. That case keeps the checks above | |
| 2368 | + // and no more, which is where a pre-existing truncated file on a | |
| 2369 | + // live site still slips through — write_atomic() stops NEW ones, | |
| 2370 | + // but it cannot retroactively vouch for what is already on disk. | |
| 2371 | + $expected = self::brotli_expected_size( $br ); | |
| 2372 | + if ( $expected > 0 && $br_size !== $expected ) { | |
| 2373 | + return false; | |
| 2374 | + } | |
| 2375 | + | |
| 2376 | + return true; | |
| 2377 | + } | |
| 2378 | + | |
| 2379 | + /** | |
| 2380 | + * Path of the sidecar recording a `.br` sibling's complete byte count. | |
| 2381 | + * | |
| 2382 | + * Kept beside the sibling as `<file>.html.br.size` rather than folded | |
| 2383 | + * into the entry's `.meta`: the static tree the web server serves has no | |
| 2384 | + * `.meta` at all, and the two trees must answer this question the same | |
| 2385 | + * way. Every path that deletes a `.br` deletes this with it. | |
| 2386 | + * | |
| 2387 | + * @param string $br Absolute path to the `.br` sibling. | |
| 2388 | + * @return string Absolute path to its size sidecar. | |
| 2389 | + */ | |
| 2390 | + public static function brotli_size_sidecar( string $br ): string { | |
| 2391 | + return $br . '.size'; | |
| 2392 | + } | |
| 2393 | + | |
| 2394 | + /** | |
| 2395 | + * The byte count the writer recorded for a `.br` sibling, or 0 when no | |
| 2396 | + * record exists (a sibling predating this version, or written by an | |
| 2397 | + * add-on that bypassed write_brotli_sibling()). | |
| 2398 | + * | |
| 2399 | + * @param string $br Absolute path to the `.br` sibling. | |
| 2400 | + * @return int Expected size in bytes, or 0 when unknown. | |
| 2401 | + */ | |
| 2402 | + public static function brotli_expected_size( string $br ): int { | |
| 2403 | + $sidecar = self::brotli_size_sidecar( $br ); | |
| 2404 | + if ( ! is_file( $sidecar ) ) { | |
| 2405 | + return 0; | |
| 2406 | + } | |
| 2407 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend HIT. | |
| 2408 | + $raw = @file_get_contents( $sidecar ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable sidecar means "unknown", handled by the cast below. | |
| 2409 | + return max( 0, (int) trim( (string) $raw ) ); | |
| 2410 | + } | |
| 2411 | + | |
| 2412 | + /** | |
| 2413 | + * Publish a `.br` sibling together with the record of its own length. | |
| 2414 | + * | |
| 2415 | + * The single writer every producer of a `.br` should route through — the | |
| 2416 | + * Pro Brotli module included. Publishing the body atomically stops a | |
| 2417 | + * truncated file from ever becoming visible; recording the byte count | |
| 2418 | + * lets the serve path prove wholeness for the files that already exist | |
| 2419 | + * on disk when this ships. | |
| 2420 | + * | |
| 2421 | + * Order matters: the size sidecar is removed first and written last, so | |
| 2422 | + * a reader arriving mid-update sees "no record" (checks above still | |
| 2423 | + * apply) rather than the previous body's length against the new body. | |
| 2424 | + * | |
| 2425 | + * @param string $br Absolute path to the `.br` sibling to write. | |
| 2426 | + * @param string $contents Compressed bytes. | |
| 2427 | + * @return bool True when both the sibling and its size record are in place. | |
| 2428 | + */ | |
| 2429 | + public static function write_brotli_sibling( string $br, string $contents ): bool { | |
| 2430 | + $sidecar = self::brotli_size_sidecar( $br ); | |
| 2431 | + if ( is_file( $sidecar ) ) { | |
| 2432 | + wp_delete_file( $sidecar ); | |
| 2433 | + } | |
| 2434 | + | |
| 2435 | + if ( ! self::write_atomic( $br, $contents ) ) { | |
| 2436 | + return false; | |
| 2437 | + } | |
| 2438 | + | |
| 2439 | + if ( self::write_atomic( $sidecar, (string) strlen( $contents ) ) ) { | |
| 2440 | + return true; | |
| 2441 | + } | |
| 2442 | + | |
| 2443 | + // The body landed but its length did not. That sibling is servable | |
| 2444 | + // and unguarded — exactly the file this function exists to prevent — | |
| 2445 | + // and the caller has no way to know. Withdraw it: a MISS costs one | |
| 2446 | + // uncompressed response, where an unguarded sibling can cost a blank | |
| 2447 | + // page for as long as the entry lives. | |
| 2448 | + wp_delete_file( $br ); | |
| 2449 | + return false; | |
| 2450 | + } | |
| 2451 | + | |
| 2452 | + /** | |
| 507 | 2453 | * Sidecar metadata file for a cache entry. Holds response bits the HIT |
| 508 | 2454 | * path must replay — Content-Type (cached feeds → application/rss+xml, |
| 509 | 2455 | * sitemaps → text/xml) and status (a cached 404 must serve 404, not |
| 510 | 2456 | * 200). JSON, one tiny file per entry, written only when there's |
| @@ -510,17 +2456,19 @@ | ||
| 510 | 2456 | * 200). JSON, one tiny file per entry, written only when there's |
| 511 | 2457 | * something non-default to replay. |
| 512 | 2458 | */ |
| 513 | 2459 | public static function cache_meta_for( $key ) { |
| 514 | - return XSPEED_CACHE_DIR . '/' . $key . '.meta'; | |
| 2460 | + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.meta'; | |
| 515 | 2461 | } |
| 516 | 2462 | |
| 517 | 2463 | /** |
| 518 | 2464 | * Read the .meta sidecar for a cache entry as an array, or [] if none. |
| 519 | - * Keys: 'content_type' (string), 'status' (int). Used on the HIT path | |
| 520 | - * to replay them before streaming the file. | |
| 2465 | + * Keys: 'content_type' (string), 'status' (int), 'ttl' (int seconds). | |
| 2466 | + * Used on the HIT path to replay content-type/status before streaming | |
| 2467 | + * the file, and by Cache_GC to age an entry by its own TTL rather than | |
| 2468 | + * the global one — hence public. | |
| 521 | 2469 | */ |
| 522 | - private static function read_meta( $key ): array { | |
| 2470 | + public static function read_meta( $key ): array { | |
| 523 | 2471 | $meta_file = self::cache_meta_for( $key ); |
| 524 | 2472 | if ( ! file_exists( $meta_file ) ) { |
| 525 | 2473 | return array(); |
| 526 | 2474 | } |
| @@ -586,9 +2534,45 @@ | ||
| 586 | 2534 | * @param int $max_age Computed max-age in seconds. |
| 587 | 2535 | */ |
| 588 | 2536 | $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age ); |
| 589 | 2537 | |
| 590 | - return ( time() - filemtime( $file ) ) > $max_age; | |
| 2538 | + // Honour the per-entry TTL the .meta sidecar carries, when it is | |
| 2539 | + // SHORTER than what we just resolved. The sidecar records the TTL | |
| 2540 | + // this specific entry was written under — a nonce cap (#236), a Pro | |
| 2541 | + // feed/404 expiry — and the drop-in already reads it. is_expired() | |
| 2542 | + // did not, so on the engine path a capped entry was still served for | |
| 2543 | + // the full configured lifetime: exactly the stale nonce the cap | |
| 2544 | + // exists to prevent. Only ever shortens, so an entry can never be | |
| 2545 | + // kept alive past the configured maximum by a stale sidecar. | |
| 2546 | + // Derive the sidecar from the FILE we were handed rather than | |
| 2547 | + // recomputing cache_key(): callers legitimately ask about an entry | |
| 2548 | + // that isn't the current request's (Cache_GC sweeps, Pro's warmer), | |
| 2549 | + // and cache_key() would answer for the wrong one — besides needing a | |
| 2550 | + // request context this function has no business requiring. | |
| 2551 | + $meta_file = preg_replace( '/\.html$/', '.meta', (string) $file ); | |
| 2552 | + if ( is_string( $meta_file ) && $meta_file !== $file && is_readable( $meta_file ) ) { | |
| 2553 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend read. | |
| 2554 | + $raw = file_get_contents( $meta_file ); | |
| 2555 | + $decoded = is_string( $raw ) ? json_decode( $raw, true ) : null; | |
| 2556 | + if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) { | |
| 2557 | + $entry_ttl = (int) $decoded['ttl']; | |
| 2558 | + if ( $entry_ttl > 0 && ( $max_age < 1 || $entry_ttl < $max_age ) ) { | |
| 2559 | + $max_age = $entry_ttl; | |
| 2560 | + } | |
| 2561 | + } | |
| 2562 | + } | |
| 2563 | + | |
| 2564 | + // A missing file is "expired" — the caller should re-render. Guard | |
| 2565 | + // filemtime() rather than letting it warn: callers legitimately ask | |
| 2566 | + // about a file that isn't there (Pro's predictive warmer probes for | |
| 2567 | + // freshness, and Cache_GC can collect an entry between the check and | |
| 2568 | + // the read), and on a site with WP_DEBUG the warning is noise. | |
| 2569 | + $mtime = file_exists( $file ) ? filemtime( $file ) : false; | |
| 2570 | + if ( false === $mtime ) { | |
| 2571 | + return true; | |
| 2572 | + } | |
| 2573 | + | |
| 2574 | + return ( time() - (int) $mtime ) > $max_age; | |
| 591 | 2575 | } |
| 592 | 2576 | |
| 593 | 2577 | /** |
| 594 | 2578 | * Accumulator for the full response body across all output-handler phases. |
| @@ -673,19 +2657,74 @@ | ||
| 673 | 2657 | $buffer = $full; |
| 674 | 2658 | } |
| 675 | 2659 | } |
| 676 | 2660 | |
| 677 | - if ( ! file_exists( XSPEED_CACHE_DIR ) ) { | |
| 678 | - wp_mkdir_p( XSPEED_CACHE_DIR ); | |
| 679 | - self::write_silence( XSPEED_CACHE_DIR ); | |
| 2661 | + // AFTER minification on purpose — the HTML minifier strips comments, | |
| 2662 | + // so signing earlier would erase the signature from every minified | |
| 2663 | + // page. Baked into the cached bytes so all three serve paths (nginx | |
| 2664 | + // static rewrite, .htaccess, the PHP drop-in) carry it identically. | |
| 2665 | + $full = self::signed( $full ); | |
| 2666 | + if ( $single_chunk ) { | |
| 2667 | + $buffer = $full; | |
| 680 | 2668 | } |
| 681 | 2669 | |
| 682 | - // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'` | |
| 683 | - // where $key comes from md5() — guaranteed to be exactly 32 lowercase | |
| 684 | - // hex chars, so no traversal sequence ('..', '/', null byte, etc.) | |
| 685 | - // can appear. The write is therefore always inside XSPEED_CACHE_DIR. | |
| 686 | - $key = self::cache_key(); | |
| 2670 | + // Per-site directory — see ensure_host_dir(). (#6) | |
| 2671 | + self::ensure_host_dir(); | |
| 2672 | + | |
| 2673 | + // Path safety: cache_file_for() builds | |
| 2674 | + // `XSPEED_CACHE_DIR . '/' . <host> . '/' . $key . '.html'` where $key | |
| 2675 | + // comes from md5() — guaranteed to be exactly 32 lowercase hex chars — | |
| 2676 | + // and <host> is filtered by host_dir() to [A-Za-z0-9.-] with leading | |
| 2677 | + // dots trimmed, so no traversal sequence ('..', '/', null byte, etc.) | |
| 2678 | + // can appear in either segment. The write is therefore always inside | |
| 2679 | + // XSPEED_CACHE_DIR. | |
| 2680 | + $key = self::cache_key(); | |
| 2681 | + | |
| 2682 | + // Query-string gate. should_cache() waved this request through | |
| 2683 | + // because every param is on the ignored_query_params allow-list, and | |
| 2684 | + // cache_key() drops the query so reads share the canonical entry. | |
| 2685 | + // That sharing is safe on READ but not on WRITE: this response was | |
| 2686 | + // rendered WITH the params, and WordPress reflects REQUEST_URI into | |
| 2687 | + // form actions, share links and plugin smart tags — so storing it | |
| 2688 | + // would serve an attacker-chosen variant under the clean URL for the | |
| 2689 | + // whole TTL (#241). | |
| 2690 | + // | |
| 2691 | + // This sits BELOW the transforms deliberately. Returning above them | |
| 2692 | + // also skipped xspeed_cache_final_html, and every listener disables | |
| 2693 | + // its own fallback ob_start() when the page cache is on precisely | |
| 2694 | + // because that filter is the shared transport — so a visitor | |
| 2695 | + // arriving on ?utm_source=… was served HTML with no LCP preload, no | |
| 2696 | + // preconnect, no CDN rewrite, no CSS combine and no HTML minify. | |
| 2697 | + // That is the ad-click and newsletter cohort getting the least | |
| 2698 | + // optimised page on the site. Only the WRITE is skipped, which is | |
| 2699 | + // what this fix was always meant to do — and it is where the | |
| 2700 | + // deferred writer has always placed its own copy of the guard. | |
| 2701 | + if ( self::query_string_blocks_write() ) { | |
| 2702 | + return $buffer; | |
| 2703 | + } | |
| 687 | 2704 | $file = self::cache_file_for( $key ); |
| 2705 | + | |
| 2706 | + // A render-time translation plugin (TranslatePress) wraps our buffer, | |
| 2707 | + // so the bytes we hold here are still UNTRANSLATED — its callback has | |
| 2708 | + // not run yet, and writing now would cache English under a French URL | |
| 2709 | + // and bake in its internal #TRPLINKPROCESSED markers. Hand off to | |
| 2710 | + // shutdown, where the outer buffer has already translated, and let | |
| 2711 | + // the pass-through below deliver this request untouched. | |
| 2712 | + if ( self::translation_plugin_active() ) { | |
| 2713 | + self::$deferred_key = $key; | |
| 2714 | + // Reaching here means finalize_buffer() ran to completion: the | |
| 2715 | + // status gate passed, should_cache() said yes, and PHP handed us | |
| 2716 | + // the whole buffer. A wp_die() or exit() mid-render unwinds the | |
| 2717 | + // buffer stack WITHOUT calling this callback, so the flag stays | |
| 2718 | + // false and the shutdown writer declines — see the guard there. | |
| 2719 | + self::$render_completed = true; | |
| 2720 | + // A PHP shutdown function, not a WP `shutdown` action: this must | |
| 2721 | + // run after the output-buffer stack has unwound, and WP's | |
| 2722 | + // shutdown action fires while our outer buffer is still open. | |
| 2723 | + register_shutdown_function( array( __CLASS__, 'write_deferred_translated_cache' ) ); | |
| 2724 | + return $buffer; | |
| 2725 | + } | |
| 2726 | + | |
| 688 | 2727 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache writes happen on frontend requests where it's unavailable. |
| 689 | 2728 | file_put_contents( $file, $full, LOCK_EX ); |
| 690 | 2729 | |
| 691 | 2730 | /** |
| @@ -707,9 +2746,9 @@ | ||
| 707 | 2746 | // Persist a non-default Content-Type so the HIT path can replay it |
| 708 | 2747 | // (cached feeds must serve application/rss+xml, not text/html). |
| 709 | 2748 | // Only written when the response set a content-type other than |
| 710 | 2749 | // the HTML default — pages don't pay for an extra file. |
| 711 | - self::write_meta( $key ); | |
| 2750 | + self::write_meta( $key, $full ); | |
| 712 | 2751 | |
| 713 | 2752 | // Static-cache tree (xspeed-static/{host}{path}/index.html). The |
| 714 | 2753 | // .htaccess rewrite block serves this file directly via the web |
| 715 | 2754 | // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path. |
| @@ -724,9 +2763,16 @@ | ||
| 724 | 2763 | // 200, FBS-82406) or a non-HTML content-type (a cached feed would go |
| 725 | 2764 | // out as text/html, FBS-82407). The web server serves these .html files |
| 726 | 2765 | // directly with no PHP, so there's no .meta replay — keep them on the |
| 727 | 2766 | // drop-in / PHP path instead, which DOES replay status + content-type. |
| 728 | - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) { | |
| 2767 | + // The static tree cannot replay a sidecar. A file served straight by | |
| 2768 | + // the web server carries the headers baked into the rule that serves | |
| 2769 | + // the whole site — the very answer this entry exists because it | |
| 2770 | + // disagreed with. Same reasoning as the status and content-type | |
| 2771 | + // cases: what the fast path cannot replay belongs on the drop-in path. | |
| 2772 | + if ( self::static_rewrite_allowed() | |
| 2773 | + && self::response_is_plain_html() | |
| 2774 | + && array() === self::per_entry_edge_headers() ) { | |
| 729 | 2775 | self::store_static( $full ); |
| 730 | 2776 | } |
| 731 | 2777 | |
| 732 | 2778 | return $buffer; |
| @@ -742,13 +2788,149 @@ | ||
| 742 | 2788 | * $uri has its query string stripped, null bytes removed, '..' |
| 743 | 2789 | * sequences collapsed, and after concatenation we verify the |
| 744 | 2790 | * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before |
| 745 | 2791 | * any write. Anything off the happy path returns silently. |
| 2792 | + * | |
| 2793 | + * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING | |
| 2794 | + * else, and both generated rewrites refuse any request that carries a | |
| 2795 | + * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache, | |
| 2796 | + * `if ($args)` in nginx_snippet()). So a response may only be stored | |
| 2797 | + * here when cache_key() adds no discriminator beyond `{host}{path}`: | |
| 2798 | + * a query-keyed entry can never be *served* from here, only mis-served | |
| 2799 | + * as the bare path. Any future opt-in that folds a query param into the | |
| 2800 | + * key needs a guard below, exactly like the search one. | |
| 746 | 2801 | */ |
| 2802 | + /** | |
| 2803 | + * Transient holding the most recent static-tree refusal. | |
| 2804 | + * | |
| 2805 | + * Short-lived on purpose: it describes what the last cacheable render | |
| 2806 | + * actually did, so a stale entry would keep warning about a page whose | |
| 2807 | + * nonces have since been removed. A site that still refuses simply | |
| 2808 | + * rewrites it on the next render. (#372) | |
| 2809 | + */ | |
| 2810 | + private const STATIC_SKIP_TRANSIENT = 'xspeed_static_skip'; | |
| 2811 | + | |
| 2812 | + /** | |
| 2813 | + * Remember why a page was kept out of the static tree, for Health. | |
| 2814 | + * | |
| 2815 | + * Records the URL, the reason, and — for the nonce case — the distinct | |
| 2816 | + * nonce KEYS found, which is what makes the finding actionable: the names | |
| 2817 | + * (`eael_login_nonce`, `post_grid_pagination_nonce`, …) trace straight back | |
| 2818 | + * to the plugin emitting them, and it is usually a widget the site does not | |
| 2819 | + * use on that page. Only key names are kept, never the nonce values. | |
| 2820 | + * | |
| 2821 | + * @param string $reason Machine-readable refusal reason. | |
| 2822 | + * @param string $html The response, for extracting the nonce keys. | |
| 2823 | + */ | |
| 2824 | + private static function note_static_skip( string $reason, string $html = '' ): void { | |
| 2825 | + if ( ! function_exists( 'set_transient' ) ) { | |
| 2826 | + return; | |
| 2827 | + } | |
| 2828 | + | |
| 2829 | + $keys = array(); | |
| 2830 | + if ( 'nonce' === $reason && '' !== $html ) { | |
| 2831 | + // Must recognise the SAME shapes response_has_nonce() refuses on, | |
| 2832 | + // or a page is skipped and reported with no keys at all — which is | |
| 2833 | + // most of them, since the plain `name="_wpnonce"` form field is the | |
| 2834 | + // commonest shape by far and only the JSON one was handled here. | |
| 2835 | + // The keys are the actionable half of the message, so a mismatch | |
| 2836 | + // leaves the admin with bad news and nothing to act on. | |
| 2837 | + // | |
| 2838 | + // Both alternations capture the KEY only: each value pattern sits | |
| 2839 | + // outside the capture group, so a nonce secret can never be stored. | |
| 2840 | + $found = array(); | |
| 2841 | + if ( preg_match_all( '/name=["\']([a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*)["\']/i', $html, $m ) ) { | |
| 2842 | + $found = array_merge( $found, $m[1] ); | |
| 2843 | + } | |
| 2844 | + if ( preg_match_all( '/["\']([a-z0-9_\-]*nonce[a-z0-9_\-]*)["\']\s*:\s*["\'][a-f0-9]{8,}["\']/i', $html, $m ) ) { | |
| 2845 | + $found = array_merge( $found, $m[1] ); | |
| 2846 | + } | |
| 2847 | + // The query-arg shape (`?_wpnonce=…`) has no key name to report | |
| 2848 | + // beyond the literal, so name it explicitly rather than reporting | |
| 2849 | + // nothing for a page that was genuinely refused. | |
| 2850 | + if ( preg_match( '/[?&]_wpnonce=/i', $html ) ) { | |
| 2851 | + $found[] = '_wpnonce'; | |
| 2852 | + } | |
| 2853 | + $keys = array_slice( array_values( array_unique( $found ) ), 0, 10 ); | |
| 2854 | + } | |
| 2855 | + | |
| 2856 | + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; | |
| 2857 | + | |
| 2858 | + set_transient( | |
| 2859 | + self::STATIC_SKIP_TRANSIENT, | |
| 2860 | + array( | |
| 2861 | + 'reason' => $reason, | |
| 2862 | + 'url' => (string) strtok( $uri, '?' ), | |
| 2863 | + 'keys' => $keys, | |
| 2864 | + 'at' => time(), | |
| 2865 | + ), | |
| 2866 | + HOUR_IN_SECONDS | |
| 2867 | + ); | |
| 2868 | + } | |
| 2869 | + | |
| 2870 | + /** | |
| 2871 | + * The most recent static-tree refusal, or an empty array when there is none. | |
| 2872 | + * | |
| 2873 | + * @return array{reason:string,url:string,keys:string[],at:int}|array{} | |
| 2874 | + */ | |
| 2875 | + public static function last_static_skip(): array { | |
| 2876 | + $stored = function_exists( 'get_transient' ) ? get_transient( self::STATIC_SKIP_TRANSIENT ) : false; | |
| 2877 | + return is_array( $stored ) && ! empty( $stored['reason'] ) ? $stored : array(); | |
| 2878 | + } | |
| 2879 | + | |
| 747 | 2880 | private static function store_static( string $html ): void { |
| 2881 | + // Search results are keyed by term in cache_key() (`|s=<term>`) but | |
| 2882 | + // carry the *path* of whatever URL was searched from — for the usual | |
| 2883 | + // `/?s=<term>` that path is `/`. Writing them here would file the | |
| 2884 | + // results page as `{host}/index.html` and the web server would serve | |
| 2885 | + // it to every visitor as the homepage: an unauthenticated visitor | |
| 2886 | + // poisons the front page with one request. Searches stay on the | |
| 2887 | + // drop-in, which replays the term-keyed entry correctly. (#191) | |
| 2888 | + // | |
| 2889 | + // This is a superset of the query-string check the exclusion gate | |
| 2890 | + // does: it also covers `/?%73=<term>`, which decodes to the same | |
| 2891 | + // search (the shape #109 fixed on the gate side). | |
| 2892 | + if ( self::should_cache_search() ) { | |
| 2893 | + return; | |
| 2894 | + } | |
| 2895 | + | |
| 2896 | + // Same hazard for the allow-listed query params: store_static() | |
| 2897 | + // strips the query and files the response under the bare path, which | |
| 2898 | + // the web server then serves to every visitor of the clean URL with | |
| 2899 | + // no PHP involved at all — so none of the engine's checks can catch | |
| 2900 | + // it later (#241). The callers already gate on this, but the guard | |
| 2901 | + // is repeated here because this tree is the most dangerous of the | |
| 2902 | + // three write sites and must not depend on its callers. | |
| 2903 | + if ( self::request_has_query_string() ) { | |
| 2904 | + return; | |
| 2905 | + } | |
| 2906 | + | |
| 2907 | + // A nonce-bearing page is served here with NO PHP: no TTL check and | |
| 2908 | + // no .meta replay, so the per-entry cap that keeps the drop-in honest | |
| 2909 | + // (#236) cannot reach a file once it is written. Only Cache_GC removes | |
| 2910 | + // it, and until it does the page hands every visitor the same nonce — | |
| 2911 | + // which, once that nonce dies, breaks every anonymous form on it. | |
| 2912 | + // | |
| 2913 | + // Refusing outright was the safe answer, and it cost every | |
| 2914 | + // nonce-bearing page the static tree entirely: a site whose homepage | |
| 2915 | + // carries one unused login nonce ran PHP on every request forever. | |
| 2916 | + // The nonce's own remaining life is the better gate — the page is | |
| 2917 | + // written and its deadline recorded below for GC to enforce. | |
| 2918 | + // | |
| 2919 | + // A nonce we cannot put a clock on is still refused, and that refusal | |
| 2920 | + // is still recorded: it stays completely silent otherwise, because the | |
| 2921 | + // drop-in answers HIT while Health reports the fast path active from a | |
| 2922 | + // probe that writes its OWN file and never proves real pages reach the | |
| 2923 | + // tree. (#372) | |
| 2924 | + $nonce_ttl = self::response_has_nonce( $html ) ? self::nonce_capped_ttl( $html, 0 ) : 0; | |
| 2925 | + if ( self::response_has_nonce( $html ) && $nonce_ttl < 1 ) { | |
| 2926 | + self::note_static_skip( 'nonce', $html ); | |
| 2927 | + return; | |
| 2928 | + } | |
| 2929 | + | |
| 748 | 2930 | $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; |
| 749 | 2931 | $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; |
| 750 | - $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host ); | |
| 2932 | + $host = self::static_host_dir( $host ); | |
| 751 | 2933 | $uri = str_replace( "\0", '', $uri ); |
| 752 | 2934 | $uri = (string) strtok( $uri, '?' ); |
| 753 | 2935 | if ( '' === $host || '' === $uri ) { |
| 754 | 2936 | return; |
| @@ -779,8 +2961,18 @@ | ||
| 779 | 2961 | } |
| 780 | 2962 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Same rationale as the flat-hash cache write above: WP_Filesystem isn't available on frontend requests, and the cache write must happen during shutdown. |
| 781 | 2963 | $written = file_put_contents( $file, $html, LOCK_EX ); |
| 782 | 2964 | |
| 2965 | + // A nonce-bearing page expires on the nonce's schedule, not the site's. | |
| 2966 | + // Nothing reads this file at serve time — the web server hands over | |
| 2967 | + // index.html without PHP — so the deadline is recorded beside it for | |
| 2968 | + // GC, which is the only thing that can enforce it. Written before the | |
| 2969 | + // action below so a listener that shells out cannot race the sweep. | |
| 2970 | + if ( false !== $written && $nonce_ttl > 0 ) { | |
| 2971 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- same rationale as the write above. | |
| 2972 | + file_put_contents( $dir . '/.xspeed-expires', (string) ( time() + $nonce_ttl ), LOCK_EX ); | |
| 2973 | + } | |
| 2974 | + | |
| 783 | 2975 | if ( false !== $written ) { |
| 784 | 2976 | /** |
| 785 | 2977 | * Fires after a static cache file (index.html) is written. |
| 786 | 2978 | * |
| @@ -832,9 +3024,236 @@ | ||
| 832 | 3024 | } |
| 833 | 3025 | return true; |
| 834 | 3026 | } |
| 835 | 3027 | |
| 836 | - private static function write_meta( string $key ): void { | |
| 3028 | + /** | |
| 3029 | + * Append the cache signature comment to a finished page. | |
| 3030 | + * | |
| 3031 | + * The plugin's one outward version signal: external scanners (the | |
| 3032 | + * xspeedcache.com speed test among them) read it to detect xSpeed and | |
| 3033 | + * its version on a cached page, the way other cache plugins sign their | |
| 3034 | + * output. Callers apply it AFTER HTML minification — the minifier strips | |
| 3035 | + * comments — and before every cache write, so all serve paths carry the | |
| 3036 | + * same bytes. | |
| 3037 | + * | |
| 3038 | + * The generation time is baked in here, at write time, in UTC. It is the | |
| 3039 | + * moment the cached bytes were produced — NOT the moment they were served | |
| 3040 | + * — because all three serve paths replay the same stored file, and two of | |
| 3041 | + * them (the nginx/`.htaccess` static rewrite) run no PHP at all and so | |
| 3042 | + * could never stamp a serve-time value. Reading the age of a page is the | |
| 3043 | + * point: `generated` plus the current clock tells you how stale it is. | |
| 3044 | + * `gmdate()` (not `current_time()`) keeps the value comparable across | |
| 3045 | + * sites regardless of the configured timezone. | |
| 3046 | + * | |
| 3047 | + * @param string $html Finished page HTML. | |
| 3048 | + * @return string HTML with the signature appended (or unchanged when a | |
| 3049 | + * filter removed it). | |
| 3050 | + */ | |
| 3051 | + private static function signed( string $html ): string { | |
| 3052 | + $version = defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : ''; | |
| 3053 | + $generated = gmdate( 'Y-m-d H:i:s' ) . ' UTC'; | |
| 3054 | + // The literal ' | xspeedcache.com' must survive intact, and what | |
| 3055 | + // precedes it is where an edition suffix lands: Pro appends itself by | |
| 3056 | + // str_replace()-ing on that exact token | |
| 3057 | + // (Pro_Plugin::sign_cache_signature). So the stamp goes AFTER it — | |
| 3058 | + // placed before, it sits between the version and the anchor and | |
| 3059 | + // composes as "generated <date> + Pro v1.1.3". | |
| 3060 | + $signature = sprintf( | |
| 3061 | + '<!-- Page cached by xSpeed Cache v%s | xspeedcache.com | generated %s -->', | |
| 3062 | + $version, | |
| 3063 | + $generated | |
| 3064 | + ); | |
| 3065 | + | |
| 3066 | + /** | |
| 3067 | + * Filter: xspeed_cache_signature | |
| 3068 | + * | |
| 3069 | + * The HTML comment appended to every cached page. Add-ons append | |
| 3070 | + * their own edition/version here; white-label setups return '' to | |
| 3071 | + * remove the comment entirely. Must remain a valid HTML comment (or | |
| 3072 | + * an empty string) — it ships inside the cached body. | |
| 3073 | + * | |
| 3074 | + * @param string $signature The signature comment. | |
| 3075 | + * @param string $version The plugin version baked into it. | |
| 3076 | + * @param string $generated The write-time timestamp baked into it, | |
| 3077 | + * formatted `Y-m-d H:i:s UTC`. | |
| 3078 | + */ | |
| 3079 | + $signature = (string) apply_filters( 'xspeed_cache_signature', $signature, $version, $generated ); | |
| 3080 | + if ( '' === trim( $signature ) ) { | |
| 3081 | + return $html; | |
| 3082 | + } | |
| 3083 | + return $html . "\n" . $signature; | |
| 3084 | + } | |
| 3085 | + | |
| 3086 | + /** | |
| 3087 | + * Does this response carry a WordPress nonce? | |
| 3088 | + * | |
| 3089 | + * Anonymous nonces depend only on the tick (user 0, empty session | |
| 3090 | + * token), so they are identical for every visitor — which is exactly why | |
| 3091 | + * they cache "successfully" and then fail silently once the tick moves. | |
| 3092 | + * | |
| 3093 | + * Matches any form field whose NAME contains "nonce" — `_wpnonce`, | |
| 3094 | + * `_wpnonce_<action>`, Tutor's `_tutor_nonce`, CF7's `_wpcf7_nonce` and | |
| 3095 | + * WooCommerce's `woocommerce-add-to-cart-nonce` (which does NOT start | |
| 3096 | + * with an underscore, so a `_`-anchored pattern misses it) — plus the | |
| 3097 | + * `_wpnonce=` form used in nonce-bearing URLs. Deliberately keyed on | |
| 3098 | + * `name=` so prose, CSS classes and data attributes don't false-positive. | |
| 3099 | + * | |
| 3100 | + * @param string $html Rendered response body. | |
| 3101 | + */ | |
| 3102 | + public static function response_has_nonce( string $html ): bool { | |
| 3103 | + if ( '' === $html ) { | |
| 3104 | + return false; | |
| 3105 | + } | |
| 3106 | + | |
| 3107 | + /* | |
| 3108 | + * Three shapes, because a nonce reaches the page in three ways: | |
| 3109 | + * | |
| 3110 | + * 1. A form field name — `_wpnonce`, `woocommerce-login-nonce`, and | |
| 3111 | + * the GROUPED names form builders emit (`data[_wpnonce]`, | |
| 3112 | + * `frm[nonce]`). The character class deliberately allows `[` and | |
| 3113 | + * `]` so grouping does not hide the field: form builders are | |
| 3114 | + * exactly the kind of plugin #236 is about, and a missed page | |
| 3115 | + * keeps the old broken behaviour silently. | |
| 3116 | + * 2. A query argument (`?_wpnonce=`) on a link. | |
| 3117 | + * 3. A nonce handed to the page's own scripts rather than placed in | |
| 3118 | + * a visible form — `wp_localize_script()` output and inline JSON | |
| 3119 | + * both land as a `"nonce":"…"`-shaped pair. | |
| 3120 | + */ | |
| 3121 | + return 1 === preg_match( | |
| 3122 | + '/(name=["\'][a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*["\']' | |
| 3123 | + . '|[?&]_wpnonce=' | |
| 3124 | + . '|["\'][a-z0-9_\-]*nonce[a-z0-9_\-]*["\']\s*:\s*["\'][a-f0-9]{8,}["\'])/i', | |
| 3125 | + $html | |
| 3126 | + ); | |
| 3127 | + } | |
| 3128 | + | |
| 3129 | + /** | |
| 3130 | + * The TTL (seconds) a response may be cached for, capped to the nonce | |
| 3131 | + * lifetime when it carries one. | |
| 3132 | + * | |
| 3133 | + * WordPress nonces are valid for at most `nonce_life` — 24h by default — | |
| 3134 | + * because wp_verify_nonce() accepts the current tick and the previous | |
| 3135 | + * one. Our own lifetime maximum is 720h and the shipped Aggressive | |
| 3136 | + * preset is 168h, so on any site configured above 24h every anonymous | |
| 3137 | + * front-end form carried a DEAD nonce for the majority of the cache's | |
| 3138 | + * life and every submission was rejected — with the other plugin's error | |
| 3139 | + * string ("Nonce not matched"), so the report never reached us (#236). | |
| 3140 | + * | |
| 3141 | + * `nonce_life` is the MAXIMUM a nonce can live, not the minimum, so it | |
| 3142 | + * is the wrong number to cap with. wp_nonce_tick() buckets time into | |
| 3143 | + * `nonce_life / 2` slices; a nonce minted x seconds into its bucket is | |
| 3144 | + * valid for `nonce_life - x`, where x can be as large as a full bucket. | |
| 3145 | + * Capping the entry at `nonce_life` therefore still served a dead nonce | |
| 3146 | + * for up to half of every entry's life — 0-12h of each 24h entry, | |
| 3147 | + * averaging 6h, re-rolled by every purge so it reads as intermittent. | |
| 3148 | + * Capping at the guaranteed-valid remainder closes the window at every | |
| 3149 | + * tick phase, at the cost of caching nonce-bearing pages for 12h rather | |
| 3150 | + * than 24h. | |
| 3151 | + * | |
| 3152 | + * Capping is per-entry, so only nonce-bearing pages pay for it; the rest | |
| 3153 | + * of the site keeps the configured lifetime. | |
| 3154 | + * | |
| 3155 | + * @param string $html Rendered response body. | |
| 3156 | + * @param int $ttl Otherwise-resolved TTL in seconds. | |
| 3157 | + * @return int TTL to actually use. | |
| 3158 | + */ | |
| 3159 | + /** | |
| 3160 | + * The nonce lifetime to cap against, in seconds. | |
| 3161 | + * | |
| 3162 | + * `nonce_life` is a TWO-argument filter in core: | |
| 3163 | + * | |
| 3164 | + * $nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action ); | |
| 3165 | + * | |
| 3166 | + * Applying it with one argument is not merely incomplete — a callback | |
| 3167 | + * that declares both parameters as required (the documented shape, and | |
| 3168 | + * what a site branching per action must write) raises ArgumentCountError | |
| 3169 | + * the moment we call it. That fatal lands in the shutdown cache write, | |
| 3170 | + * so the visitor still sees a perfectly normal page while the sidecar is | |
| 3171 | + * never written: the entry then keeps the FULL configured lifetime | |
| 3172 | + * carrying a dead nonce, which is precisely the bug #236 set out to fix. | |
| 3173 | + * Worse, the entry stays that way until a purge, even after the site | |
| 3174 | + * removes whatever customised the lifetime. | |
| 3175 | + * | |
| 3176 | + * We are inspecting rendered markup, so we cannot know which action | |
| 3177 | + * minted the nonce we found. Two consequences: | |
| 3178 | + * | |
| 3179 | + * 1. We pass `''` as the action. A per-action callback therefore sees | |
| 3180 | + * the same "unknown action" value core itself passes when a nonce is | |
| 3181 | + * created with no action, and can branch on it deliberately. | |
| 3182 | + * 2. A page may carry nonces from SEVERAL actions with different | |
| 3183 | + * lifetimes. The entry can only have one TTL, so the safe choice is | |
| 3184 | + * the SHORTEST lifetime any action on the site resolves to — capping | |
| 3185 | + * to a longer one would serve a dead nonce for the shorter action. | |
| 3186 | + * Sites can narrow this with `xspeed_cache_nonce_life_actions`. | |
| 3187 | + * | |
| 3188 | + * @param string $html Response body being cached. | |
| 3189 | + * @return int Nonce lifetime in seconds (0 = do not cap). | |
| 3190 | + */ | |
| 3191 | + private static function nonce_life_seconds( string $html ): int { | |
| 3192 | + /** | |
| 3193 | + * Filter the nonce actions whose lifetimes are consulted when | |
| 3194 | + * capping a cache entry. | |
| 3195 | + * | |
| 3196 | + * The default `''` is the "action unknown" case — we are reading | |
| 3197 | + * rendered HTML, not minting a nonce. A site whose `nonce_life` | |
| 3198 | + * callback shortens specific actions can list them here so the cap | |
| 3199 | + * accounts for the shortest one that could appear on the page. | |
| 3200 | + * | |
| 3201 | + * @since 1.1.8 | |
| 3202 | + * @param string[] $actions Nonce actions to resolve. | |
| 3203 | + * @param string $html The response body being cached. | |
| 3204 | + */ | |
| 3205 | + $actions = (array) apply_filters( 'xspeed_cache_nonce_life_actions', array( '' ), $html ); | |
| 3206 | + if ( empty( $actions ) ) { | |
| 3207 | + $actions = array( '' ); | |
| 3208 | + } | |
| 3209 | + | |
| 3210 | + $shortest = 0; | |
| 3211 | + foreach ( $actions as $action ) { | |
| 3212 | + // Both arguments, exactly as core passes them. | |
| 3213 | + $life = (int) apply_filters( 'nonce_life', DAY_IN_SECONDS, (string) $action ); | |
| 3214 | + if ( $life < 1 ) { | |
| 3215 | + continue; | |
| 3216 | + } | |
| 3217 | + if ( 0 === $shortest || $life < $shortest ) { | |
| 3218 | + $shortest = $life; | |
| 3219 | + } | |
| 3220 | + } | |
| 3221 | + | |
| 3222 | + return $shortest; | |
| 3223 | + } | |
| 3224 | + | |
| 3225 | + public static function nonce_capped_ttl( string $html, int $ttl ): int { | |
| 3226 | + if ( ! self::response_has_nonce( $html ) ) { | |
| 3227 | + return $ttl; | |
| 3228 | + } | |
| 3229 | + | |
| 3230 | + $nonce_life = self::nonce_life_seconds( $html ); | |
| 3231 | + if ( $nonce_life < 1 ) { | |
| 3232 | + return $ttl; | |
| 3233 | + } | |
| 3234 | + | |
| 3235 | + // Half of nonce_life is the GUARANTEED-valid remainder — see above. | |
| 3236 | + $guaranteed = max( 1, intdiv( $nonce_life, 2 ) ); | |
| 3237 | + $capped = ( $ttl > 0 ) ? min( $ttl, $guaranteed ) : $guaranteed; | |
| 3238 | + | |
| 3239 | + /** | |
| 3240 | + * Filter the nonce-capped TTL for a cache entry. | |
| 3241 | + * | |
| 3242 | + * Escape hatch for a site whose nonce-shaped markup is decorative — | |
| 3243 | + * return the uncapped $ttl to keep the configured lifetime. Most | |
| 3244 | + * sites should leave this alone: serving a dead nonce breaks every | |
| 3245 | + * anonymous form on the page. | |
| 3246 | + * | |
| 3247 | + * @param int $capped TTL after the nonce cap (seconds). | |
| 3248 | + * @param int $ttl TTL before the cap (seconds). | |
| 3249 | + * @param int $nonce_life Current nonce lifetime (seconds). | |
| 3250 | + * @param string $html The response body being cached. | |
| 3251 | + */ | |
| 3252 | + return (int) apply_filters( 'xspeed_cache_nonce_ttl_cap', $capped, $ttl, $nonce_life, $html ); | |
| 3253 | + } | |
| 3254 | + | |
| 3255 | + private static function write_meta( string $key, string $html = '' ): void { | |
| 837 | 3256 | $content_type = ''; |
| 838 | 3257 | foreach ( headers_list() as $header ) { |
| 839 | 3258 | if ( 0 === stripos( $header, 'content-type:' ) ) { |
| 840 | 3259 | $content_type = trim( substr( $header, strlen( 'content-type:' ) ) ); |
| @@ -855,15 +3274,48 @@ | ||
| 855 | 3274 | // call is_expired() / the xspeed_cache_max_age filter (they run before |
| 856 | 3275 | // WP), so persist the resolved max-age here whenever it differs from |
| 857 | 3276 | // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page |
| 858 | 3277 | // default. The fast paths read this to expire correctly. (FBS-82407) |
| 859 | - $opts = Settings_Manager::get( 'cache' ); | |
| 860 | - $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS; | |
| 861 | - $ttl = (int) apply_filters( 'xspeed_cache_max_age', $default_ttl ); | |
| 3278 | + // This MUST resolve the TTL the same way is_expired() does, including | |
| 3279 | + // the per-post override — the sidecar is the only channel that can | |
| 3280 | + // carry a per-entry TTL into the pre-boot fast paths. Omitting the | |
| 3281 | + // override here left an editor's "expire this post after 1h" visible | |
| 3282 | + // to the engine but invisible to the drop-in, which kept serving the | |
| 3283 | + // entry until the global lifetime elapsed (#240 AC#3). Handing the | |
| 3284 | + // filter the same base as is_expired() also keeps a filter that | |
| 3285 | + // SCALES its input (e.g. $max_age * 2) consistent between the two. | |
| 3286 | + $opts = Settings_Manager::get( 'cache' ); | |
| 3287 | + $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS; | |
| 3288 | + $max_age = $default_ttl; | |
| 3289 | + $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() ); | |
| 3290 | + if ( null !== $post_override ) { | |
| 3291 | + $max_age = $post_override; | |
| 3292 | + } | |
| 3293 | + /** This filter is documented in includes/class-cache.php */ | |
| 3294 | + $ttl = (int) apply_filters( 'xspeed_cache_max_age', $max_age ); | |
| 3295 | + | |
| 3296 | + // A response carrying a nonce may not outlive that nonce, however | |
| 3297 | + // long the site's configured lifetime is (#236). This runs AFTER the | |
| 3298 | + // max-age filter so it caps whatever the filter resolved rather than | |
| 3299 | + // being overridden by it — a Pro module lengthening the TTL must not | |
| 3300 | + // be able to reintroduce a dead nonce. | |
| 3301 | + $ttl = self::nonce_capped_ttl( $html, $ttl ); | |
| 3302 | + | |
| 862 | 3303 | if ( $ttl > 0 && $ttl !== $default_ttl ) { |
| 863 | 3304 | $meta['ttl'] = $ttl; |
| 864 | 3305 | } |
| 865 | 3306 | |
| 3307 | + // This entry's edge headers, when they differ from the site-wide set | |
| 3308 | + // baked into the drop-in. The sidecar is the only channel that can | |
| 3309 | + // carry a per-page answer into the pre-boot fast path, and the drop-in | |
| 3310 | + // REPLACES the baked set with it rather than merging: the two describe | |
| 3311 | + // the same response, so merging would leave the baked lifetime in | |
| 3312 | + // place beside the hold meant to overrule it. | |
| 3313 | + $edge = self::per_entry_edge_headers(); | |
| 3314 | + if ( array() !== $edge ) { | |
| 3315 | + $meta['edge_headers'] = $edge; | |
| 3316 | + } | |
| 3317 | + | |
| 866 | 3318 | // Nothing to replay → no sidecar. |
| 867 | 3319 | if ( empty( $meta ) ) { |
| 868 | 3320 | return; |
| 869 | 3321 | } |
| @@ -881,47 +3333,1099 @@ | ||
| 881 | 3333 | * Activity log to give users context (e.g. |
| 882 | 3334 | * 'post saved', 'settings change', 'manual', |
| 883 | 3335 | * 'theme switch'). |
| 884 | 3336 | */ |
| 885 | - public static function purge_all( string $cause = 'manual' ) { | |
| 3337 | + /** | |
| 3338 | + * Purge the cache entries for ONE URL — every variant of it: the | |
| 3339 | + * flat-hash entry (+ .meta / .html.br siblings), both device buckets | |
| 3340 | + * (mobile_separate keys them separately), both trailing-slash forms, | |
| 3341 | + * and the static-tree index.html (+ .br) the server rewrite serves. | |
| 3342 | + * The rest of the cache is untouched — this is the surgical | |
| 3343 | + * alternative to purge_all for "I just edited this one page". | |
| 3344 | + * | |
| 3345 | + * @param string $url Absolute URL, or site-relative path ("/about/"). | |
| 3346 | + * @param string $cause Who asked, for the purge log. See purge_all(). | |
| 3347 | + * @return int Number of cache files removed. | |
| 3348 | + */ | |
| 3349 | + /** | |
| 3350 | + * Post types that are not "viewable" but ARE the presentation layer. | |
| 3351 | + * | |
| 3352 | + * `is_post_type_viewable()` answers "does this type have a front end of | |
| 3353 | + * its own?" — which is the right question for `shop_order`, but the | |
| 3354 | + * wrong one for the types core uses to render every OTHER page. A | |
| 3355 | + * template part, a global-styles record, a navigation or a synced | |
| 3356 | + * pattern has no permalink, yet editing one changes how the whole site | |
| 3357 | + * looks. Gating purges on viewability alone meant a Site Editor save | |
| 3358 | + * invalidated nothing and visitors kept the old design for the full | |
| 3359 | + * TTL — up to 30 days at the maximum lifetime. (#270 regression) | |
| 3360 | + * | |
| 3361 | + * @return string[] | |
| 3362 | + */ | |
| 3363 | + /** | |
| 3364 | + * Could this post change alter anything an anonymous visitor had cached? | |
| 3365 | + * | |
| 3366 | + * Deleting one post fired a full purge for the post AND for every stored | |
| 3367 | + * revision, because wp_delete_post() removes each revision through | |
| 3368 | + * wp_delete_post() again and every one of those fires before_delete_post | |
| 3369 | + * with post_type 'revision'. A post with six revisions cost seven whole- | |
| 3370 | + * site sweeps, each one also announcing to LiteSpeed, purging the object | |
| 3371 | + * cache network-wide on Redis, rewriting the stats option and running | |
| 3372 | + * every xspeed_after_purge_all listener -- including Pro's Cloudflare | |
| 3373 | + * purge, so seven API calls. Trashing cost two, via save_post and then | |
| 3374 | + * trashed_post. (QA #348) | |
| 3375 | + * | |
| 3376 | + * The check lives here, ahead of purge_all(), so one early return covers | |
| 3377 | + * the local sweep, the server-cache announcement and both action hooks. | |
| 3378 | + * It deliberately does NOT live inside purge_all(): a manual, CLI or | |
| 3379 | + * explicit caller asked for a purge and must get one. | |
| 3380 | + * | |
| 3381 | + * @param int $post_id Post being saved or removed. | |
| 3382 | + * @param mixed $post Post object when the hook passed one. | |
| 3383 | + * @param string $event 'save' or 'remove'. | |
| 3384 | + */ | |
| 3385 | + private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool { | |
| 3386 | + $post_id = (int) $post_id; | |
| 3387 | + | |
| 3388 | + // Only `save_post` and `before_delete_post` hand over a post object. | |
| 3389 | + // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING -- | |
| 3390 | + // so reaching for ->post_status on the second argument finds nothing | |
| 3391 | + // and the status rule below would never fire. Read the row instead. | |
| 3392 | + if ( ! is_object( $post ) && function_exists( 'get_post' ) ) { | |
| 3393 | + $post = get_post( $post_id ); | |
| 3394 | + } | |
| 3395 | + | |
| 3396 | + $type = is_object( $post ) && isset( $post->post_type ) | |
| 3397 | + ? (string) $post->post_type | |
| 3398 | + : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' ); | |
| 3399 | + if ( '' === $type ) { | |
| 3400 | + return false; | |
| 3401 | + } | |
| 3402 | + | |
| 3403 | + // A revision is a copy of content nobody can browse to. | |
| 3404 | + if ( 'revision' === $type ) { | |
| 3405 | + return false; | |
| 3406 | + } | |
| 3407 | + if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) { | |
| 3408 | + return false; | |
| 3409 | + } | |
| 3410 | + if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) { | |
| 3411 | + return false; | |
| 3412 | + } | |
| 3413 | + | |
| 3414 | + $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : ''; | |
| 3415 | + | |
| 3416 | + // Clicking "Add New" inserts an auto-draft and fires save_post. There | |
| 3417 | + // is nothing cached of a post that has never existed publicly. | |
| 3418 | + if ( 'auto-draft' === $status ) { | |
| 3419 | + return false; | |
| 3420 | + } | |
| 3421 | + | |
| 3422 | + // Unknown/!viewable → nothing anonymous can see changed, UNLESS the | |
| 3423 | + // type is itself part of how pages render (#270 regression). | |
| 3424 | + if ( function_exists( 'is_post_type_viewable' ) | |
| 3425 | + && ! is_post_type_viewable( $type ) | |
| 3426 | + && ! in_array( $type, self::presentation_post_types(), true ) | |
| 3427 | + ) { | |
| 3428 | + return false; | |
| 3429 | + } | |
| 3430 | + | |
| 3431 | + // Deleting something that was already invisible changes no cached | |
| 3432 | + // page: the transition that hid it purged at the time. This is what | |
| 3433 | + // makes emptying a trash of a hundred posts cost nothing rather than | |
| 3434 | + // a hundred full sweeps. | |
| 3435 | + // | |
| 3436 | + // It also collapses trashing to a single purge: wp_trash_post() fires | |
| 3437 | + // save_post first, where the post is genuinely disappearing from | |
| 3438 | + // listings and SHOULD purge, then trashed_post, by which point the | |
| 3439 | + // row reads 'trash' and is skipped. A status we cannot read, on a row | |
| 3440 | + // that still reports a type, means assume viewable -- erring toward | |
| 3441 | + // an extra purge, never toward serving a stale page. A row that is | |
| 3442 | + // gone entirely reports no type either and was refused above. | |
| 3443 | + // 'inherit' is an INTERNAL status in core, so is_post_status_viewable() | |
| 3444 | + // says no -- but an attachment carrying it is genuinely public. Judge | |
| 3445 | + // those on the post type alone, which is already checked above. | |
| 3446 | + if ( 'remove' === $event && '' !== $status && 'inherit' !== $status | |
| 3447 | + && function_exists( 'is_post_status_viewable' ) | |
| 3448 | + && ! is_post_status_viewable( $status ) | |
| 3449 | + ) { | |
| 3450 | + return false; | |
| 3451 | + } | |
| 3452 | + | |
| 3453 | + return true; | |
| 3454 | + } | |
| 3455 | + | |
| 3456 | + public static function presentation_post_types(): array { | |
| 3457 | + $types = array( | |
| 3458 | + 'wp_template', // Site Editor templates. | |
| 3459 | + 'wp_template_part', // Header / footer / reusable parts. | |
| 3460 | + 'wp_global_styles', // Colours, typography, spacing. | |
| 3461 | + 'wp_navigation', // Navigation block menus. | |
| 3462 | + 'nav_menu_item', // Classic menus. | |
| 3463 | + 'wp_block', // Synced patterns / reusable blocks. | |
| 3464 | + ); | |
| 3465 | + | |
| 3466 | + /** | |
| 3467 | + * Filter the non-viewable post types that still invalidate the cache. | |
| 3468 | + * | |
| 3469 | + * Add a type here when it has no front end of its own but changes | |
| 3470 | + * how other pages render (a theme's own layout CPT, for example). | |
| 3471 | + * | |
| 3472 | + * @param string[] $types Post type slugs. | |
| 3473 | + */ | |
| 3474 | + return (array) apply_filters( 'xspeed_presentation_post_types', $types ); | |
| 3475 | + } | |
| 3476 | + | |
| 3477 | + /** | |
| 3478 | + * Describe a broad hook invalidation for response-cache adapters. | |
| 3479 | + * | |
| 3480 | + * Term, menu, theme and plugin changes can alter navigation, archives or | |
| 3481 | + * markup across the site, so they require a site response-cache purge. | |
| 3482 | + * Content saves also require this scope while their local operation is a | |
| 3483 | + * complete bucket sweep. | |
| 3484 | + * | |
| 3485 | + * A new term is `content`, not `presentation`. It has no posts yet, so no | |
| 3486 | + * page renders it until a post is saved with it, and that save is its own | |
| 3487 | + * content purge. Classed as presentation, it cleared the host's whole | |
| 3488 | + * nginx cache every time a post was published with a tag that did not | |
| 3489 | + * exist yet, which is most publishing. Renaming or deleting a term stays | |
| 3490 | + * presentation: the new name shows on every post in the term, and Nginx | |
| 3491 | + * Helper purges only the homepage for either. (QA #448) | |
| 3492 | + * | |
| 3493 | + * @return array{scope:string,intent:string,urls:array<int,string>} | |
| 3494 | + */ | |
| 3495 | + private static function invalidation_for_hook( string $hook ): array { | |
| 3496 | + $presentation = array( | |
| 3497 | + 'switch_theme', | |
| 3498 | + 'activated_plugin', | |
| 3499 | + 'deactivated_plugin', | |
| 3500 | + 'edited_term', | |
| 3501 | + 'delete_term', | |
| 3502 | + 'wp_update_nav_menu', | |
| 3503 | + ); | |
| 3504 | + | |
| 3505 | + return array( | |
| 3506 | + 'scope' => 'site', | |
| 3507 | + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content', | |
| 3508 | + 'urls' => array(), | |
| 3509 | + ); | |
| 3510 | + } | |
| 3511 | + | |
| 3512 | + | |
| 3513 | + /** | |
| 3514 | + * save_post → purge only when the saved thing can appear on a cached page. | |
| 3515 | + * | |
| 3516 | + * Revisions and autosaves are never rendered. Non-viewable post types — | |
| 3517 | + * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund` | |
| 3518 | + * / `shop_coupon`, Flamingo's `flamingo_inbound` (#229), Tutor's | |
| 3519 | + * `tutor_enrolled` (#231) — are invisible to anonymous visitors, so | |
| 3520 | + * writing one changes nothing that is cached. (#243) | |
| 3521 | + * | |
| 3522 | + * The exception is the presentation types above, which are non-viewable | |
| 3523 | + * yet render every page — they are allow-listed BEFORE the viewability | |
| 3524 | + * test. (#270 regression) | |
| 3525 | + * | |
| 3526 | + * @param int $post_id Saved post ID. | |
| 3527 | + * @param \WP_Post $post Saved post object. | |
| 3528 | + */ | |
| 3529 | + public static function on_save_post( $post_id, $post = null ): void { | |
| 3530 | + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) { | |
| 3531 | + return; | |
| 3532 | + } | |
| 3533 | + | |
| 3534 | + $post_type = is_object( $post ) && isset( $post->post_type ) | |
| 3535 | + ? (string) $post->post_type | |
| 3536 | + : (string) get_post_type( $post_id ); | |
| 3537 | + | |
| 3538 | + // Name the trigger rather than logging a bare numeric id — the old | |
| 3539 | + // wiring passed the post ID into $cause, so the log read | |
| 3540 | + // "Cache purged (46)" with no indication of what caused it. (#243) | |
| 3541 | + $presentation = in_array( $post_type, self::presentation_post_types(), true ); | |
| 3542 | + self::purge_all( | |
| 3543 | + 'post:' . $post_type, | |
| 3544 | + null, | |
| 3545 | + array( | |
| 3546 | + // purge_all() sweeps every local response in this site's bucket. | |
| 3547 | + // Without dependency tracking, the server cache must match that | |
| 3548 | + // same boundary or unrelated pages can remain stale there. | |
| 3549 | + 'scope' => 'site', | |
| 3550 | + 'intent' => $presentation ? 'presentation' : 'content', | |
| 3551 | + 'urls' => array(), | |
| 3552 | + ) | |
| 3553 | + ); | |
| 3554 | + if ( class_exists( '\XSpeed\Minifier' ) ) { | |
| 3555 | + Minifier::purge_minified(); | |
| 3556 | + } | |
| 3557 | + } | |
| 3558 | + | |
| 3559 | + /** | |
| 3560 | + * Delete/trash invalidation while the post type is still available. | |
| 3561 | + * The local and server response-cache sweeps share the same site boundary. | |
| 3562 | + * | |
| 3563 | + * @param int $post_id Removed post ID. | |
| 3564 | + * @param object|null $post Post object supplied by core when available. | |
| 3565 | + */ | |
| 3566 | + public static function on_post_removed( $post_id, $post = null ): void { | |
| 3567 | + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) { | |
| 3568 | + return; | |
| 3569 | + } | |
| 3570 | + | |
| 3571 | + $post_type = is_object( $post ) && isset( $post->post_type ) | |
| 3572 | + ? (string) $post->post_type | |
| 3573 | + : (string) get_post_type( $post_id ); | |
| 3574 | + | |
| 3575 | + self::purge_all( | |
| 3576 | + 'post-removed:' . $post_type, | |
| 3577 | + null, | |
| 3578 | + array( | |
| 3579 | + 'scope' => 'site', | |
| 3580 | + // Match on_save_post: a presentation type changes how pages | |
| 3581 | + // render rather than what they say. | |
| 3582 | + 'intent' => in_array( $post_type, self::presentation_post_types(), true ) | |
| 3583 | + ? 'presentation' | |
| 3584 | + : 'content', | |
| 3585 | + 'urls' => array(), | |
| 3586 | + ) | |
| 3587 | + ); | |
| 3588 | + } | |
| 3589 | + | |
| 3590 | + /** Purge site responses when moderation changes visible comments. */ | |
| 3591 | + public static function on_comment_status( $comment_id, $status = '' ): void { | |
| 3592 | + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null; | |
| 3593 | + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0; | |
| 3594 | + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) { | |
| 3595 | + return; | |
| 3596 | + } | |
| 3597 | + $url = get_permalink( $post_id ); | |
| 3598 | + if ( ! is_string( $url ) || '' === $url ) { | |
| 3599 | + return; | |
| 3600 | + } | |
| 3601 | + self::purge_all( | |
| 3602 | + 'comment-status:' . (string) $status, | |
| 3603 | + null, | |
| 3604 | + array( | |
| 3605 | + 'scope' => 'site', | |
| 3606 | + 'intent' => 'content', | |
| 3607 | + 'urls' => array(), | |
| 3608 | + ) | |
| 3609 | + ); | |
| 3610 | + } | |
| 3611 | + | |
| 3612 | + /** | |
| 3613 | + * comment_post → purge just the commented-on URL, and only once the | |
| 3614 | + * comment is actually visible. | |
| 3615 | + * | |
| 3616 | + * A comment held for moderation changes nothing on the front end, and an | |
| 3617 | + * approved one changes exactly one page — not the whole site. Product | |
| 3618 | + * reviews are comments and guest reviews are on by default, so under the | |
| 3619 | + * old wiring any visitor could flush a store's entire cache, repeatedly, | |
| 3620 | + * with no account. (#243) | |
| 3621 | + * | |
| 3622 | + * @param int $comment_id New comment ID. | |
| 3623 | + * @param int|string $approved 1 when approved, 0 when held, 'spam'. | |
| 3624 | + * @param array $data Comment data. | |
| 3625 | + */ | |
| 3626 | + public static function on_comment_post( $comment_id, $approved = 0, $data = array() ): void { | |
| 3627 | + if ( 1 !== (int) $approved ) { | |
| 3628 | + return; | |
| 3629 | + } | |
| 3630 | + $post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0; | |
| 3631 | + if ( $post_id < 1 ) { | |
| 3632 | + return; | |
| 3633 | + } | |
| 3634 | + $url = get_permalink( $post_id ); | |
| 3635 | + if ( is_string( $url ) && '' !== $url ) { | |
| 3636 | + self::purge_url( $url, 'comment' ); | |
| 3637 | + } | |
| 3638 | + } | |
| 3639 | + | |
| 3640 | + /** | |
| 3641 | + * user_register / profile_update → purge only when the user can author | |
| 3642 | + * content that appears on the front end. | |
| 3643 | + * | |
| 3644 | + * A customer registering at checkout changes no rendered page, and cannot | |
| 3645 | + * change an enqueued asset — so it must not purge the cache, and must not | |
| 3646 | + * rebuild the minified bundles. Checkout account-creation fired FOUR | |
| 3647 | + * full-site purges plus four purge_minified() runs in a single request | |
| 3648 | + * before this gate. (#243) | |
| 3649 | + * | |
| 3650 | + * @param int $user_id Affected user. | |
| 3651 | + */ | |
| 3652 | + public static function on_user_change( $user_id ): void { | |
| 3653 | + $user = function_exists( 'get_userdata' ) ? get_userdata( (int) $user_id ) : null; | |
| 3654 | + if ( ! $user ) { | |
| 3655 | + return; | |
| 3656 | + } | |
| 3657 | + | |
| 3658 | + // Only roles that can publish can change a rendered page. WooCommerce | |
| 3659 | + // customers and WordPress subscribers cannot. | |
| 3660 | + if ( ! user_can( $user, 'edit_posts' ) ) { | |
| 3661 | + return; | |
| 3662 | + } | |
| 3663 | + | |
| 3664 | + $url = get_author_posts_url( (int) $user_id ); | |
| 3665 | + if ( is_string( $url ) && '' !== $url ) { | |
| 3666 | + self::purge_url( $url, 'user' ); | |
| 3667 | + } | |
| 3668 | + } | |
| 3669 | + | |
| 3670 | + /** | |
| 3671 | + * Purge everything a product's price / stock / sale state is rendered on. | |
| 3672 | + * | |
| 3673 | + * The product permalink is not enough: the shop archive and the product's | |
| 3674 | + * category and tag archives render the same price and Sale! badge, and | |
| 3675 | + * #242 reproduces all three going stale together. | |
| 3676 | + * | |
| 3677 | + * Accepts a product ID or a WC_Product. A variation resolves to its | |
| 3678 | + * parent, which is the page that actually renders. | |
| 3679 | + * | |
| 3680 | + * @param int|object $product Product ID or WC_Product. | |
| 3681 | + */ | |
| 3682 | + public static function purge_product( $product ): void { | |
| 3683 | + $product_id = is_object( $product ) && method_exists( $product, 'get_id' ) | |
| 3684 | + ? (int) $product->get_id() | |
| 3685 | + : (int) $product; | |
| 3686 | + if ( $product_id < 1 ) { | |
| 3687 | + return; | |
| 3688 | + } | |
| 3689 | + | |
| 3690 | + // Variations are never rendered on their own URL. | |
| 3691 | + $parent = (int) wp_get_post_parent_id( $product_id ); | |
| 3692 | + if ( $parent > 0 ) { | |
| 3693 | + $product_id = $parent; | |
| 3694 | + } | |
| 3695 | + | |
| 3696 | + $urls = array(); | |
| 3697 | + | |
| 3698 | + $permalink = get_permalink( $product_id ); | |
| 3699 | + if ( is_string( $permalink ) && '' !== $permalink ) { | |
| 3700 | + $urls[] = $permalink; | |
| 3701 | + } | |
| 3702 | + | |
| 3703 | + // The shop archive. | |
| 3704 | + if ( function_exists( 'wc_get_page_id' ) ) { | |
| 3705 | + $shop_id = (int) wc_get_page_id( 'shop' ); | |
| 3706 | + if ( $shop_id > 0 ) { | |
| 3707 | + $shop_url = get_permalink( $shop_id ); | |
| 3708 | + if ( is_string( $shop_url ) && '' !== $shop_url ) { | |
| 3709 | + $urls[] = $shop_url; | |
| 3710 | + } | |
| 3711 | + } | |
| 3712 | + } | |
| 3713 | + | |
| 3714 | + // Every category / tag archive this product appears on. | |
| 3715 | + foreach ( array( 'product_cat', 'product_tag' ) as $taxonomy ) { | |
| 3716 | + $terms = get_the_terms( $product_id, $taxonomy ); | |
| 3717 | + if ( ! is_array( $terms ) ) { | |
| 3718 | + continue; | |
| 3719 | + } | |
| 3720 | + foreach ( $terms as $term ) { | |
| 3721 | + $term_url = get_term_link( $term ); | |
| 3722 | + if ( is_string( $term_url ) && '' !== $term_url ) { | |
| 3723 | + $urls[] = $term_url; | |
| 3724 | + } | |
| 3725 | + } | |
| 3726 | + } | |
| 3727 | + | |
| 3728 | + // The front page, when it is not the shop page but still lists | |
| 3729 | + // products (a block/shortcode storefront). | |
| 3730 | + $front_id = (int) get_option( 'page_on_front' ); | |
| 3731 | + if ( $front_id > 0 ) { | |
| 3732 | + $front_url = get_permalink( $front_id ); | |
| 3733 | + if ( is_string( $front_url ) && '' !== $front_url ) { | |
| 3734 | + $urls[] = $front_url; | |
| 3735 | + } | |
| 3736 | + } | |
| 3737 | + | |
| 3738 | + /** | |
| 3739 | + * Filter the URLs purged when a product changes. | |
| 3740 | + * | |
| 3741 | + * A storefront that renders products somewhere else — a landing page, | |
| 3742 | + * a custom archive — can add its URLs here rather than falling back | |
| 3743 | + * to purging the whole site. | |
| 3744 | + * | |
| 3745 | + * @param string[] $urls URLs about to be purged. | |
| 3746 | + * @param int $product_id The product that changed. | |
| 3747 | + */ | |
| 3748 | + $urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id ); | |
| 3749 | + | |
| 3750 | + foreach ( array_unique( array_filter( $urls ) ) as $url ) { | |
| 3751 | + self::purge_url( (string) $url, 'product' ); | |
| 3752 | + } | |
| 3753 | + } | |
| 3754 | + | |
| 3755 | + /** | |
| 3756 | + * Adapter for the WooCommerce stock actions that pass a product OBJECT | |
| 3757 | + * where the status actions pass an ID. | |
| 3758 | + * | |
| 3759 | + * @param object $product WC_Product (or variation). | |
| 3760 | + */ | |
| 3761 | + public static function purge_product_object( $product ): void { | |
| 3762 | + self::purge_product( $product ); | |
| 3763 | + } | |
| 3764 | + | |
| 3765 | + /** | |
| 3766 | + * Re-entry guard for the purge-event contract. | |
| 3767 | + * | |
| 3768 | + * A listener on `xspeed_after_purge_url` legitimately purges its own | |
| 3769 | + * layer, and a server-cache or CDN adapter that calls back into xSpeed | |
| 3770 | + * while doing so re-enters this method — unbounded, because each pass | |
| 3771 | + * looks like a fresh purge. | |
| 3772 | + * | |
| 3773 | + * A single global flag stops too much: a nested purge of a DIFFERENT URL is | |
| 3774 | + * a real purge whose listeners must hear about it. But a per-request | |
| 3775 | + * "already published" set stops too much in the other direction — a | |
| 3776 | + * network purge loops every blog in one request, and on a subdirectory | |
| 3777 | + * network they share a host, so blogs 2..N would be silently skipped. It | |
| 3778 | + * also grows for the life of the process. | |
| 3779 | + * | |
| 3780 | + * So the guard tracks what is IN FLIGHT, not what has been published: a | |
| 3781 | + * target is marked while its own dispatch is on the stack and unmarked | |
| 3782 | + * when it returns. Re-entering the same target recurses, so it is refused; | |
| 3783 | + * purging the same URL again later is a new event and publishes. The set | |
| 3784 | + * is bounded by call depth rather than by how many URLs a request touches. | |
| 3785 | + * | |
| 3786 | + * @var array<string,bool> | |
| 3787 | + */ | |
| 3788 | + private static $purge_events_in_flight = array(); | |
| 3789 | + | |
| 3790 | + /** Monotonic count used to detect whether a delegated purge published. */ | |
| 3791 | + private static $purge_event_sequence = 0; | |
| 3792 | + | |
| 3793 | + /** | |
| 3794 | + * Publish a purge event exactly once, with bounded arguments. | |
| 3795 | + * | |
| 3796 | + * Deliberately carries only what an integration needs to invalidate its | |
| 3797 | + * own copy: the canonical URL (or null for a full purge), the site host, | |
| 3798 | + * the cause label, and how many files went. No filesystem paths, no cache | |
| 3799 | + * contents, no request headers, no user data. The URL query and caller- | |
| 3800 | + * supplied cause may nevertheless contain sensitive text, so listeners | |
| 3801 | + * must redact them in logs or unrelated destinations that do not need the | |
| 3802 | + * exact cache key. | |
| 3803 | + * | |
| 3804 | + * A listener that throws must not take the purge down with it: the files | |
| 3805 | + * are already gone by the time we get here, and an integration's bad day | |
| 3806 | + * is not a reason to report a failed purge to the caller. | |
| 3807 | + * | |
| 3808 | + * @param string $hook Hook name to emit. | |
| 3809 | + * @param array<string,mixed> $context Bounded context, see above. | |
| 3810 | + */ | |
| 3811 | + private static function dispatch_purge_event( string $hook, array $context ): void { | |
| 3812 | + if ( ! function_exists( 'do_action' ) ) { | |
| 3813 | + return; | |
| 3814 | + } | |
| 3815 | + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' ) | |
| 3816 | + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' ); | |
| 3817 | + if ( isset( self::$purge_events_in_flight[ $target ] ) ) { | |
| 3818 | + return; | |
| 3819 | + } | |
| 3820 | + self::$purge_events_in_flight[ $target ] = true; | |
| 3821 | + ++self::$purge_event_sequence; | |
| 3822 | + | |
| 3823 | + // Our own integrations get their own try. Sharing one with the public | |
| 3824 | + // action below meant a listener on the extension seam could throw and | |
| 3825 | + // take the contract event down with it — the mirror of the failure | |
| 3826 | + // this separation exists to prevent. | |
| 3827 | + try { | |
| 3828 | + // Built-in server-cache integrations run FIRST, and by a direct | |
| 3829 | + // call rather than as listeners on the action below. | |
| 3830 | + // | |
| 3831 | + // WordPress stops dispatching an action's remaining callbacks when | |
| 3832 | + // one of them throws. As a listener, our LiteSpeed forwarding | |
| 3833 | + // would then be skipped by any unrelated third-party callback that | |
| 3834 | + // happened to be registered earlier and blew up — and the visible | |
| 3835 | + // result is the worst kind: xSpeed reports a successful purge while | |
| 3836 | + // the server keeps serving stale HTML. Shipped behaviour must not | |
| 3837 | + // be hostage to a listener's bug. | |
| 3838 | + self::forward_to_server_caches( $context ); | |
| 3839 | + } catch ( \Throwable $e ) { | |
| 3840 | + self::log_purge_listener_error( $hook, $e ); | |
| 3841 | + } | |
| 3842 | + | |
| 3843 | + try { | |
| 3844 | + self::do_action_isolated( $hook, $context ); | |
| 3845 | + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch | |
| 3846 | + // Swallow: see docblock. The purge succeeded regardless. | |
| 3847 | + self::log_purge_listener_error( $hook, $e ); | |
| 3848 | + } finally { | |
| 3849 | + unset( self::$purge_events_in_flight[ $target ] ); | |
| 3850 | + } | |
| 3851 | + } | |
| 3852 | + | |
| 3853 | + /** | |
| 3854 | + * Run every listener on a purge hook, isolating each from the others. | |
| 3855 | + * | |
| 3856 | + * `do_action()` dispatches callbacks in one loop, so the first one to | |
| 3857 | + * throw takes every LATER listener down with it. On a purge that meant a | |
| 3858 | + * failing CDN integration silently cancelled the ones queued behind it — | |
| 3859 | + * and because the throw was swallowed to keep the purge itself succeeding, | |
| 3860 | + * the user was told the clear worked while two edges were never touched. | |
| 3861 | + * Invisible unless WP_DEBUG happened to be on. (QA #348) | |
| 3862 | + * | |
| 3863 | + * Each callback gets its own try/catch here, so one integration's bad day | |
| 3864 | + * costs only that integration. Priority order is preserved. Falls back to | |
| 3865 | + * a plain `do_action()` when the filter registry is not the shape we | |
| 3866 | + * expect, so an unusual environment degrades to the old behaviour rather | |
| 3867 | + * than skipping listeners entirely. | |
| 3868 | + * | |
| 3869 | + * @param string $hook Hook name to emit. | |
| 3870 | + * @param mixed $arg Single argument passed to each listener. | |
| 3871 | + */ | |
| 3872 | + public static function do_action_isolated( string $hook, $arg ): void { | |
| 3873 | + global $wp_filter; | |
| 3874 | + | |
| 3875 | + // Walking $wp_filter by hand and calling each callback directly was the | |
| 3876 | + // obvious way to do this, and it was wrong: it bypasses WordPress, so | |
| 3877 | + // `current_filter()` came back empty, `did_action()` stayed at 0, the | |
| 3878 | + // `all` hook never fired, and Query Monitor and Debug Bar could not see | |
| 3879 | + // the very contract this class publishes. A shared handler branching on | |
| 3880 | + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3) | |
| 3881 | + // | |
| 3882 | + // So let do_action() dispatch — WordPress keeps its bookkeeping — and | |
| 3883 | + // isolate one level down instead: each registered callback is swapped | |
| 3884 | + // for a wrapper that runs it inside a try/catch. One listener throwing | |
| 3885 | + // then costs only that listener, which is the whole point, without | |
| 3886 | + // costing the hook its identity. | |
| 3887 | + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) { | |
| 3888 | + do_action( $hook, $arg ); | |
| 3889 | + return; | |
| 3890 | + } | |
| 3891 | + | |
| 3892 | + $hook_object = $wp_filter[ $hook ]; | |
| 3893 | + $original = $hook_object->callbacks; | |
| 3894 | + if ( ! is_array( $original ) || array() === $original ) { | |
| 3895 | + do_action( $hook, $arg ); | |
| 3896 | + return; | |
| 3897 | + } | |
| 3898 | + | |
| 3899 | + $wrapped = array(); | |
| 3900 | + $restorations = array(); | |
| 3901 | + foreach ( $original as $priority => $group ) { | |
| 3902 | + if ( ! is_array( $group ) ) { | |
| 3903 | + $wrapped[ $priority ] = $group; | |
| 3904 | + continue; | |
| 3905 | + } | |
| 3906 | + foreach ( $group as $id => $registered ) { | |
| 3907 | + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) { | |
| 3908 | + $wrapped[ $priority ][ $id ] = $registered; | |
| 3909 | + continue; | |
| 3910 | + } | |
| 3911 | + $callback = $registered['function']; | |
| 3912 | + $wrapper = static function ( ...$args ) use ( $callback, $hook ) { | |
| 3913 | + try { | |
| 3914 | + return $callback( ...$args ); | |
| 3915 | + } catch ( \Throwable $e ) { | |
| 3916 | + self::log_purge_listener_error( $hook, $e ); | |
| 3917 | + return null; | |
| 3918 | + } | |
| 3919 | + }; | |
| 3920 | + $wrapped[ $priority ][ $id ] = array( | |
| 3921 | + // Keep accepted_args: a listener registered for 0 or 1 | |
| 3922 | + // arguments must still be called the way it asked. | |
| 3923 | + 'accepted_args' => $registered['accepted_args'] ?? 1, | |
| 3924 | + 'function' => $wrapper, | |
| 3925 | + ); | |
| 3926 | + $restorations[ $priority ][ $id ] = array( | |
| 3927 | + 'original' => $registered, | |
| 3928 | + 'wrapper' => $wrapper, | |
| 3929 | + ); | |
| 3930 | + } | |
| 3931 | + } | |
| 3932 | + | |
| 3933 | + $hook_object->callbacks = $wrapped; | |
| 3934 | + try { | |
| 3935 | + do_action( $hook, $arg ); | |
| 3936 | + } finally { | |
| 3937 | + // Restore only wrappers still present. Native add/remove operations | |
| 3938 | + // performed by listeners must survive this temporary substitution. | |
| 3939 | + foreach ( $restorations as $priority => $group ) { | |
| 3940 | + foreach ( $group as $id => $restore ) { | |
| 3941 | + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null; | |
| 3942 | + if ( $current === $restore['wrapper'] ) { | |
| 3943 | + $hook_object->callbacks[ $priority ][ $id ] = $restore['original']; | |
| 3944 | + } | |
| 3945 | + } | |
| 3946 | + } | |
| 3947 | + } | |
| 3948 | + } | |
| 3949 | + | |
| 3950 | + /** | |
| 3951 | + * Name a listener that threw, under WP_DEBUG only. | |
| 3952 | + * | |
| 3953 | + * Gated like the rest of Free's diagnostics: a third-party listener | |
| 3954 | + * throwing on every purge must not fill a production log. | |
| 3955 | + */ | |
| 3956 | + private static function log_purge_listener_error( string $hook, \Throwable $e ): void { | |
| 3957 | + // An \Error — a TypeError from one of OUR listeners, say — is a bug | |
| 3958 | + // rather than a runtime condition a third party imposed on us, and | |
| 3959 | + // swallowing it silently in production turns it into a purge that | |
| 3960 | + // quietly stops working. Those are logged whatever WP_DEBUG says; | |
| 3961 | + // third-party \Exceptions stay gated so a noisy integration cannot | |
| 3962 | + // fill a production log. | |
| 3963 | + $always = $e instanceof \Error; | |
| 3964 | + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) { | |
| 3965 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge. | |
| 3966 | + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() ); | |
| 3967 | + } | |
| 3968 | + } | |
| 3969 | + | |
| 3970 | + /** Test seam: clear the in-flight set left behind by an aborted dispatch. */ | |
| 3971 | + public static function reset_purge_events(): void { | |
| 3972 | + self::$purge_events_in_flight = array(); | |
| 3973 | + self::$purge_event_sequence = 0; | |
| 3974 | + } | |
| 3975 | + | |
| 3976 | + /** | |
| 3977 | + * Hand the purge to the caches we ship integrations for. | |
| 3978 | + * | |
| 3979 | + * Isolated from the public action on purpose — see dispatch_purge_event(). | |
| 3980 | + * Guarded so a missing class (a partial upgrade, a stripped build) cannot | |
| 3981 | + * turn a working purge into a fatal. | |
| 3982 | + * | |
| 3983 | + * @param array<string,mixed> $context Bounded purge context. | |
| 3984 | + */ | |
| 3985 | + private static function forward_to_server_caches( array $context ): void { | |
| 3986 | + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) { | |
| 3987 | + Server_Caches::forward( $context ); | |
| 3988 | + } | |
| 3989 | + } | |
| 3990 | + | |
| 3991 | + /** | |
| 3992 | + * `host[:port]` for a cache key, from a parsed URL. | |
| 3993 | + * | |
| 3994 | + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and | |
| 3995 | + * that carries `:8080` on any install not served from 80/443 — dropping it | |
| 3996 | + * computed a different md5, found no file, and reported "already cold" | |
| 3997 | + * while the page kept serving HIT. | |
| 3998 | + * | |
| 3999 | + * A port that is the DEFAULT for the scheme is dropped, though, because | |
| 4000 | + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for | |
| 4001 | + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file | |
| 4002 | + * stored under `site.com` — the same silent no-op in the other direction, | |
| 4003 | + * and the one QA hit passing a canonical URL with the port spelled out. | |
| 4004 | + * (QA #348) | |
| 4005 | + * | |
| 4006 | + * @param array<string,mixed> $parts Output of wp_parse_url(). | |
| 4007 | + */ | |
| 4008 | + private static function host_port_of( array $parts ): string { | |
| 4009 | + if ( ! isset( $parts['host'] ) ) { | |
| 4010 | + return ''; | |
| 4011 | + } | |
| 4012 | + $host = strtolower( (string) $parts['host'] ); | |
| 4013 | + if ( '' === $host || ! isset( $parts['port'] ) ) { | |
| 4014 | + return $host; | |
| 4015 | + } | |
| 4016 | + $port = (int) $parts['port']; | |
| 4017 | + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; | |
| 4018 | + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) { | |
| 4019 | + return $host; | |
| 4020 | + } | |
| 4021 | + return $host . ':' . $port; | |
| 4022 | + } | |
| 4023 | + | |
| 4024 | + public static function purge_url( string $url, string $cause = 'manual' ): int { | |
| 4025 | + // A URL that names nothing is not a purge of everything. An empty or | |
| 4026 | + // blank string used to fall through to the home_url() default below | |
| 4027 | + // and clear the HOMEPAGE — so a third party calling | |
| 4028 | + // `purge_url( get_permalink( $id ) )` on a post whose permalink came | |
| 4029 | + // back empty silently purged the front page instead of nothing. The | |
| 4030 | + // CLI and the MCP tool reject empties before reaching this, so only | |
| 4031 | + // direct API callers were exposed, but they are exactly the audience | |
| 4032 | + // this public contract is for. (QA #348) | |
| 4033 | + if ( '' === trim( $url ) ) { | |
| 4034 | + return 0; | |
| 4035 | + } | |
| 4036 | + $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- fallback for early-boot contexts only. | |
| 4037 | + if ( ! is_array( $parts ) ) { | |
| 4038 | + return 0; | |
| 4039 | + } | |
| 4040 | + // Absolute URLs are accepted only for HTTP response caches. Schemes such | |
| 4041 | + // as ftp:, file: and javascript: can parse cleanly but do not name a page | |
| 4042 | + // xSpeed or a server response cache can invalidate. A leading-slash path | |
| 4043 | + // remains a supported site-relative target. | |
| 4044 | + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) { | |
| 4045 | + return 0; | |
| 4046 | + } | |
| 4047 | + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) { | |
| 4048 | + return 0; | |
| 4049 | + } | |
| 4050 | + // Reject a string that parsed but is not a URL we can act on: no | |
| 4051 | + // scheme AND no host AND no leading-slash path means something like | |
| 4052 | + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a | |
| 4053 | + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)` | |
| 4054 | + // — a nonsense tag sent to LiteSpeed for every malformed call. | |
| 4055 | + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) { | |
| 4056 | + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : ''; | |
| 4057 | + if ( '' === $raw || '/' !== $raw[0] ) { | |
| 4058 | + return 0; | |
| 4059 | + } | |
| 4060 | + } | |
| 4061 | + // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which | |
| 4062 | + // carries `:8080` on any install not served from 80/443 — while | |
| 4063 | + // parse_url() splits the port into its own component, so a purge that | |
| 4064 | + // used the bare host computed a different md5, found no file, and | |
| 4065 | + // reported "already cold". A silent no-op: the page kept serving HIT | |
| 4066 | + // until its TTL ran out. Intranet installs, panel hosts on :8443 and | |
| 4067 | + // proxies that forward `Host: site.com:8080` all hit this. | |
| 4068 | + // A scheme-less `site.test:443/page/` is a supported explicit-host | |
| 4069 | + // target. Infer a scheme only when it names THIS site's hostname: then | |
| 4070 | + // its explicit default port is the same origin and the same local cache | |
| 4071 | + // key. Never apply this to another host or to a non-default port. | |
| 4072 | + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) { | |
| 4073 | + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above. | |
| 4074 | + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] ) | |
| 4075 | + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] ) | |
| 4076 | + ) { | |
| 4077 | + $home_scheme = strtolower( (string) $home['scheme'] ); | |
| 4078 | + $port = (int) $parts['port']; | |
| 4079 | + $home_port = isset( $home['port'] ) | |
| 4080 | + ? (int) $home['port'] | |
| 4081 | + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) ); | |
| 4082 | + if ( $home_port === $port | |
| 4083 | + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) ) | |
| 4084 | + ) { | |
| 4085 | + $parts['scheme'] = $home_scheme; | |
| 4086 | + } | |
| 4087 | + } | |
| 4088 | + } | |
| 4089 | + $host = self::host_port_of( $parts ); | |
| 4090 | + if ( '' === $host && function_exists( 'home_url' ) ) { | |
| 4091 | + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above. | |
| 4092 | + if ( is_array( $home ) ) { | |
| 4093 | + $host = self::host_port_of( $home ); | |
| 4094 | + } | |
| 4095 | + } | |
| 4096 | + if ( '' === $host ) { | |
| 4097 | + return 0; | |
| 4098 | + } | |
| 4099 | + $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/'; | |
| 4100 | + $path = '/' . ltrim( $path, '/' ); | |
| 4101 | + if ( false !== strpos( $path, '..' ) ) { | |
| 4102 | + return 0; | |
| 4103 | + } | |
| 4104 | + | |
| 4105 | + // The cache key preserves REQUEST_URI's trailing-slash form, so | |
| 4106 | + // purge both. Root stays a single '/'. | |
| 4107 | + $forms = array( $path ); | |
| 4108 | + if ( '/' !== $path ) { | |
| 4109 | + $forms[] = rtrim( $path, '/' ); | |
| 4110 | + $forms[] = rtrim( $path, '/' ) . '/'; | |
| 4111 | + } | |
| 4112 | + $forms = array_unique( $forms ); | |
| 4113 | + | |
| 4114 | + /* | |
| 4115 | + * Entries live under the bucket they were written for, and this URL's | |
| 4116 | + * site may not be the one serving THIS request (a cross-site purge on | |
| 4117 | + * multisite, WP-CLI, or cron). Build the directory from the URL's own | |
| 4118 | + * host AND path. (#6) | |
| 4119 | + * | |
| 4120 | + * Host alone is wrong on a subdirectory network: `store()` wrote to | |
| 4121 | + * `<host>/<prefix>/`, so looking in `<host>/` found nothing and the | |
| 4122 | + * call reported "already cold" while the page kept serving HIT — a | |
| 4123 | + * false success, which is worse than an error. The prefix has to come | |
| 4124 | + * from the URL being purged rather than from the current blog, because | |
| 4125 | + * the caller is usually purging some OTHER site. (QA B2 on #166) | |
| 4126 | + */ | |
| 4127 | + $base = XSPEED_CACHE_DIR . '/' . self::bucket_for_url( $host, $path ); | |
| 4128 | + | |
| 886 | 4129 | $count = 0; |
| 887 | - if ( is_dir( XSPEED_CACHE_DIR ) ) { | |
| 888 | - $files = glob( XSPEED_CACHE_DIR . '/*.html' ); | |
| 889 | - if ( $files ) { | |
| 890 | - $count = count( $files ); | |
| 891 | - foreach ( $files as $f ) { | |
| 892 | - wp_delete_file( $f ); | |
| 4130 | + foreach ( $forms as $uri ) { | |
| 4131 | + // '' = mobile_separate off; '|m' / '|d' = the device buckets. | |
| 4132 | + foreach ( array( '', '|m', '|d' ) as $device ) { | |
| 4133 | + $key = md5( $host . $uri . $device ); | |
| 4134 | + $file = $base . '/' . $key . '.html'; | |
| 4135 | + if ( is_file( $file ) ) { | |
| 4136 | + wp_delete_file( $file ); | |
| 4137 | + ++$count; | |
| 893 | 4138 | } |
| 4139 | + foreach ( array( $base . '/' . $key . '.meta', $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) { | |
| 4140 | + if ( is_file( $sidecar ) ) { | |
| 4141 | + wp_delete_file( $sidecar ); | |
| 4142 | + } | |
| 4143 | + } | |
| 894 | 4144 | } |
| 895 | - // Remove the .meta sidecars (content-type for feeds/sitemaps) | |
| 896 | - // alongside their .html entries. Not counted — they're not | |
| 897 | - // cache "pages", just per-entry metadata. | |
| 898 | - $meta = glob( XSPEED_CACHE_DIR . '/*.meta' ); | |
| 899 | - if ( $meta ) { | |
| 900 | - foreach ( $meta as $m ) { | |
| 901 | - wp_delete_file( $m ); | |
| 4145 | + } | |
| 4146 | + | |
| 4147 | + // Static tree (served directly by the nginx/.htaccess rewrite). | |
| 4148 | + if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) { | |
| 4149 | + // Same transform the write used — `localhost:8080` files under | |
| 4150 | + // `localhost8080`, so the bare host found nothing here either. | |
| 4151 | + $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . ( '/' === $path ? '' : rtrim( $path, '/' ) ); | |
| 4152 | + $file = $dir . '/index.html'; | |
| 4153 | + if ( is_file( $file ) ) { | |
| 4154 | + wp_delete_file( $file ); | |
| 4155 | + ++$count; | |
| 4156 | + } | |
| 4157 | + foreach ( array( $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) { | |
| 4158 | + if ( is_file( $sidecar ) ) { | |
| 4159 | + wp_delete_file( $sidecar ); | |
| 902 | 4160 | } |
| 903 | 4161 | } |
| 904 | - // Remove precompressed siblings (e.g. <key>.html.br from the Pro | |
| 905 | - // Brotli module). Not counted — same as .meta. Without this a | |
| 906 | - // purge leaves stale .br bodies behind: disk bloat, and a | |
| 907 | - // staleness window if precompression is later disabled. | |
| 908 | - $br = glob( XSPEED_CACHE_DIR . '/*.br' ); | |
| 909 | - if ( $br ) { | |
| 910 | - foreach ( $br as $b ) { | |
| 911 | - wp_delete_file( $b ); | |
| 4162 | + } | |
| 4163 | + | |
| 4164 | + if ( $count > 0 ) { | |
| 4165 | + Cache_Inventory::invalidate(); | |
| 4166 | + Activity_Log::record( | |
| 4167 | + 'cache_purge_url', | |
| 4168 | + sprintf( | |
| 4169 | + /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */ | |
| 4170 | + __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ), | |
| 4171 | + $cause, | |
| 4172 | + $host . $path, | |
| 4173 | + $count | |
| 4174 | + ), | |
| 4175 | + Activity_Log::INFO | |
| 4176 | + ); | |
| 4177 | + } | |
| 4178 | + | |
| 4179 | + /** | |
| 4180 | + * Fires after one URL's cached copy has been purged. | |
| 4181 | + * | |
| 4182 | + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe | |
| 4183 | + * here to invalidate a cache xSpeed does not own — a server-level | |
| 4184 | + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for | |
| 4185 | + * the same URL. | |
| 4186 | + * | |
| 4187 | + * Only fires when the purge actually ran. A malformed URL, a URL with | |
| 4188 | + * no resolvable host, or a traversal attempt returns earlier and | |
| 4189 | + * publishes nothing, so a listener can treat this as "xSpeed purged | |
| 4190 | + * this URL" rather than "xSpeed was asked to". `removed` may legitimately | |
| 4191 | + * be 0: the URL was not in xSpeed's cache, which says nothing about | |
| 4192 | + * whether it is in yours. | |
| 4193 | + * | |
| 4194 | + * Fires at most once per purge. A listener that calls back into | |
| 4195 | + * xSpeed's purge API will not re-enter this event. | |
| 4196 | + * | |
| 4197 | + * @since 1.2.3 | |
| 4198 | + * | |
| 4199 | + * @param array $context { | |
| 4200 | + * Bounded description of the purge. URL queries and caller-supplied | |
| 4201 | + * causes can contain sensitive values and are not logging fields. | |
| 4202 | + * | |
| 4203 | + * @type string $url Canonical scheme://host/path[?query] of the purged URL. | |
| 4204 | + * The query is preserved because caches in front | |
| 4205 | + * commonly key on it; xSpeed's own sweep is | |
| 4206 | + * path-based, so `removed` describes that. | |
| 4207 | + * @type string $host Host (with port when non-standard). | |
| 4208 | + * @type string $path Path component, leading slash. | |
| 4209 | + * @type string $cause Short label for who asked. See purge_all(). | |
| 4210 | + * @type int $removed Number of cache files removed. | |
| 4211 | + * @type string $scope Actionable adapter scope: `urls`. | |
| 4212 | + * @type string $intent Why responses changed: `content`. | |
| 4213 | + * @type string[] $urls Exact response URLs to invalidate. | |
| 4214 | + * } | |
| 4215 | + */ | |
| 4216 | + $canonical_url = self::canonical_purge_url( | |
| 4217 | + $host, | |
| 4218 | + $path, | |
| 4219 | + isset( $parts['query'] ) ? (string) $parts['query'] : '', | |
| 4220 | + isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '' | |
| 4221 | + ); | |
| 4222 | + self::dispatch_purge_event( | |
| 4223 | + 'xspeed_after_purge_url', | |
| 4224 | + array( | |
| 4225 | + 'url' => $canonical_url, | |
| 4226 | + 'host' => $host, | |
| 4227 | + 'path' => $path, | |
| 4228 | + 'cause' => $cause, | |
| 4229 | + 'removed' => $count, | |
| 4230 | + 'scope' => 'urls', | |
| 4231 | + 'intent' => 'content', | |
| 4232 | + 'urls' => array( $canonical_url ), | |
| 4233 | + ) | |
| 4234 | + ); | |
| 4235 | + | |
| 4236 | + return $count; | |
| 4237 | + } | |
| 4238 | + | |
| 4239 | + /** Host this site's purge is scoped to, for the purge-event context. */ | |
| 4240 | + private static function current_purge_host(): string { | |
| 4241 | + if ( ! function_exists( 'home_url' ) ) { | |
| 4242 | + return ''; | |
| 4243 | + } | |
| 4244 | + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- host only. | |
| 4245 | + if ( ! is_array( $home ) || empty( $home['host'] ) ) { | |
| 4246 | + return ''; | |
| 4247 | + } | |
| 4248 | + // Same default-port normalisation as purge_url(): a site whose | |
| 4249 | + // home_url() carries `:443` (normal behind a proxy) otherwise stamps | |
| 4250 | + // every full-purge event with a host that matches none of its own | |
| 4251 | + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348) | |
| 4252 | + return self::host_port_of( $home ); | |
| 4253 | + } | |
| 4254 | + | |
| 4255 | + /** | |
| 4256 | + * Rebuild the canonical URL a purge applied to. | |
| 4257 | + * | |
| 4258 | + * Built from the parts the purge itself used, so a listener is told the | |
| 4259 | + * URL we acted on rather than the string the caller happened to pass — | |
| 4260 | + * those differ whenever the caller supplied a site-relative path, a | |
| 4261 | + * different scheme, or a query string the cache key ignores. | |
| 4262 | + */ | |
| 4263 | + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string { | |
| 4264 | + // The purged URL's own scheme wins. purge_url() explicitly supports | |
| 4265 | + // cross-site purges (multisite, WP-CLI, cron), where composing the | |
| 4266 | + // current site's scheme onto another site's host builds a URL that was | |
| 4267 | + // never served — and a CDN listener then purges the wrong key and | |
| 4268 | + // reports success. | |
| 4269 | + if ( '' !== $url_scheme ) { | |
| 4270 | + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' ); | |
| 4271 | + } | |
| 4272 | + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http'; | |
| 4273 | + if ( function_exists( 'home_url' ) ) { | |
| 4274 | + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- scheme only. | |
| 4275 | + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) { | |
| 4276 | + $scheme = (string) $home['scheme']; | |
| 4277 | + } | |
| 4278 | + } | |
| 4279 | + // The query is carried even though OUR sweep above is path-based. | |
| 4280 | + // Caches in front commonly key on the full request line — LiteSpeed | |
| 4281 | + // tags `/shop/?page=2` separately from `/shop/` — so publishing the | |
| 4282 | + // bare path would have a listener confidently purge the wrong entry | |
| 4283 | + // and report success. Telling it exactly what was asked for lets it | |
| 4284 | + // act correctly; `removed` still describes only what WE removed. | |
| 4285 | + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' ); | |
| 4286 | + } | |
| 4287 | + | |
| 4288 | + /** | |
| 4289 | + * Sweep this site's cache files. | |
| 4290 | + * | |
| 4291 | + * On multisite every blog shares one cache directory, so an unscoped | |
| 4292 | + * sweep here took the whole network cold — one subsite's settings save | |
| 4293 | + * or post publish rebuilt every other site from PHP. Entries are stored | |
| 4294 | + * per host (see host_dir()), and the sweep is scoped to match, so a | |
| 4295 | + * purge originating on site-a leaves site-b's cache warm. (#6) | |
| 4296 | + * | |
| 4297 | + * Clears the files only: the flat tree, the static tree, the REST | |
| 4298 | + * responses and the minified assets. The object-cache flush, the stats | |
| 4299 | + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract | |
| 4300 | + * event and the log entry live in purge_all(), which is still the entry | |
| 4301 | + * point for every existing caller. Split out so `wp xspeed purge` can | |
| 4302 | + * report the local sweep as one line item and the object cache as | |
| 4303 | + * another, each with its own status — see Purge_Runner. | |
| 4304 | + * | |
| 4305 | + * @param string|null $host Host to purge. Defaults to the current site. | |
| 4306 | + * Pass '*' to sweep the ENTIRE tree — network | |
| 4307 | + * admin's "purge all sites", and the migration | |
| 4308 | + * of pre-#6 entries that sit in the tree root. | |
| 4309 | + * @return array{pages:int,rest:int,assets:int,bytes:int} Entries removed | |
| 4310 | + * per store, and the bytes freed by the two file sweeps | |
| 4311 | + * that measure themselves. | |
| 4312 | + */ | |
| 4313 | + public static function purge_local( ?string $host = null ): array { | |
| 4314 | + $network_wide = ( '*' === $host ); | |
| 4315 | + self::$sweep_bytes = 0; | |
| 4316 | + // The flat tree buckets by a flattened segment (host/a-b) while the | |
| 4317 | + // static tree mirrors the URL (host/a/b), so they need separate | |
| 4318 | + // scopes — see current_host_dir() vs current_static_scope(). | |
| 4319 | + $static_scope = ''; | |
| 4320 | + if ( null === $host || $network_wide ) { | |
| 4321 | + $scope = $network_wide ? '' : self::current_host_dir(); | |
| 4322 | + $static_scope = $network_wide ? '' : self::current_static_scope(); | |
| 4323 | + } else { | |
| 4324 | + $dir = self::host_dir( $host ); | |
| 4325 | + $scope = '' === $dir ? 'default' : $dir; | |
| 4326 | + $static_dir = self::static_host_dir( $host ); | |
| 4327 | + $static_scope = '' === $static_dir ? 'default' : $static_dir; | |
| 4328 | + } | |
| 4329 | + | |
| 4330 | + $count = 0; | |
| 4331 | + if ( is_dir( XSPEED_CACHE_DIR ) ) { | |
| 4332 | + // Scoped to one host directory, or the whole tree (including the | |
| 4333 | + // legacy top-level entries written before #6) when network-wide. | |
| 4334 | + /* | |
| 4335 | + * Network-wide sweeps go TWO levels deep, not one. A subdirectory | |
| 4336 | + * subsite's bucket is `<host>/<prefix>/`, so globbing only | |
| 4337 | + * `<cache>/*` reached the main site and left every subsite's | |
| 4338 | + * entries in place. (QA D5 on #166) | |
| 4339 | + * | |
| 4340 | + * A scoped purge also has to cover its own nested buckets: when | |
| 4341 | + * the main blog of a subdirectory network purges, `<host>/` is its | |
| 4342 | + * bucket and `<host>/one/` belongs to another blog — so the scoped | |
| 4343 | + * branch deliberately does NOT descend, which is what keeps | |
| 4344 | + * site-level purges isolated. | |
| 4345 | + */ | |
| 4346 | + $roots = $network_wide | |
| 4347 | + ? array_merge( | |
| 4348 | + array( XSPEED_CACHE_DIR ), | |
| 4349 | + array_filter( (array) glob( XSPEED_CACHE_DIR . '/*', GLOB_ONLYDIR ) ), | |
| 4350 | + array_filter( (array) glob( XSPEED_CACHE_DIR . '/*/*', GLOB_ONLYDIR ) ) | |
| 4351 | + ) | |
| 4352 | + : array( XSPEED_CACHE_DIR . '/' . $scope ); | |
| 4353 | + | |
| 4354 | + foreach ( $roots as $root ) { | |
| 4355 | + /* | |
| 4356 | + * min/ and rest/ are swept by their own purgers below; never | |
| 4357 | + * treat them as host buckets. | |
| 4358 | + * | |
| 4359 | + * Checked on every path SEGMENT, not just the basename: now | |
| 4360 | + * that the network-wide glob descends two levels it can reach | |
| 4361 | + * `min/combined`, whose basename is `combined` and would sail | |
| 4362 | + * past a basename-only test — deleting the combined | |
| 4363 | + * stylesheets out from under the pages that link them. | |
| 4364 | + */ | |
| 4365 | + if ( ! $network_wide || XSPEED_CACHE_DIR !== $root ) { | |
| 4366 | + $relative = trim( str_replace( XSPEED_CACHE_DIR, '', (string) $root ), '/' ); | |
| 4367 | + $segments = '' === $relative ? array() : explode( '/', $relative ); | |
| 4368 | + if ( array_intersect( $segments, array( 'min', 'rest' ) ) ) { | |
| 4369 | + continue; | |
| 4370 | + } | |
| 912 | 4371 | } |
| 4372 | + if ( ! is_dir( $root ) ) { | |
| 4373 | + continue; | |
| 4374 | + } | |
| 4375 | + $files = glob( $root . '/*.html' ); | |
| 4376 | + if ( $files ) { | |
| 4377 | + $count += count( $files ); | |
| 4378 | + foreach ( $files as $f ) { | |
| 4379 | + self::sweep_delete( $f ); | |
| 4380 | + } | |
| 4381 | + } | |
| 4382 | + // Remove the .meta sidecars (content-type for feeds/sitemaps) | |
| 4383 | + // alongside their .html entries. Not counted — they're not | |
| 4384 | + // cache "pages", just per-entry metadata. | |
| 4385 | + $meta = glob( $root . '/*.meta' ); | |
| 4386 | + if ( $meta ) { | |
| 4387 | + foreach ( $meta as $m ) { | |
| 4388 | + self::sweep_delete( $m ); | |
| 4389 | + } | |
| 4390 | + } | |
| 4391 | + // Remove precompressed siblings (e.g. <key>.html.br from the Pro | |
| 4392 | + // Brotli module). Not counted — same as .meta. Without this a | |
| 4393 | + // purge leaves stale .br bodies behind: disk bloat, and a | |
| 4394 | + // staleness window if precompression is later disabled. | |
| 4395 | + $br = glob( $root . '/*.br' ); | |
| 4396 | + if ( $br ) { | |
| 4397 | + foreach ( $br as $b ) { | |
| 4398 | + self::sweep_delete( $b ); | |
| 4399 | + } | |
| 4400 | + } | |
| 4401 | + // `*.br` does not match `*.br.size` — same reason as the flat-root | |
| 4402 | + // sweep above: a size record outliving its body would later be | |
| 4403 | + // read against a different sibling's bytes. | |
| 4404 | + $br_size = glob( $root . '/*.br.size' ); | |
| 4405 | + if ( $br_size ) { | |
| 4406 | + foreach ( $br_size as $b ) { | |
| 4407 | + self::sweep_delete( $b ); | |
| 4408 | + } | |
| 4409 | + } | |
| 913 | 4410 | } |
| 914 | 4411 | } |
| 915 | 4412 | // Static-cache tree purge — recursive because the layout is |
| 916 | 4413 | // xspeed-static/{host}/{path}/index.html, so a flat glob can't |
| 917 | - // reach everything. | |
| 4414 | + // reach everything. Already host-segmented, so scoping is just a | |
| 4415 | + // matter of starting one level down. | |
| 918 | 4416 | if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) { |
| 919 | - $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR ); | |
| 4417 | + $static_root = $network_wide | |
| 4418 | + ? XSPEED_CACHE_STATIC_DIR | |
| 4419 | + : XSPEED_CACHE_STATIC_DIR . '/' . $static_scope; | |
| 4420 | + if ( is_dir( $static_root ) ) { | |
| 4421 | + $count += self::rmtree_html( $static_root ); | |
| 4422 | + } | |
| 920 | 4423 | } |
| 921 | 4424 | // REST response cache (cache/xspeed/rest/*.json) — same purge |
| 922 | 4425 | // triggers (publish, settings change) invalidate it too. |
| 923 | - $count += Rest_Cache::purge(); | |
| 4426 | + $rest = Rest_Cache::purge(); | |
| 4427 | + $count += $rest; | |
| 924 | 4428 | |
| 925 | 4429 | // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/). |
| 926 | 4430 | // purge_all is a full filesystem sweep and must clear these too, even |
| 927 | 4431 | // when the Minify module is currently disabled — orphaned min/ files |
| @@ -927,19 +4431,93 @@ | ||
| 927 | 4431 | // when the Minify module is currently disabled — orphaned min/ files |
| 928 | 4432 | // from a feature the user later turned off must still be removed, and |
| 929 | 4433 | // a stale combined-<hash>.css that the regenerated page no longer |
| 930 | 4434 | // references otherwise 404s and breaks the frontend. (FBS-83114/83116) |
| 931 | - if ( class_exists( '\\XSpeed\\Minifier' ) ) { | |
| 932 | - Minifier::purge_minified(); | |
| 4435 | + $assets = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_minified() : 0; | |
| 4436 | + | |
| 4437 | + return array( | |
| 4438 | + 'pages' => $count - $rest, | |
| 4439 | + 'rest' => $rest, | |
| 4440 | + 'assets' => $assets, | |
| 4441 | + 'bytes' => self::$sweep_bytes, | |
| 4442 | + ); | |
| 4443 | + } | |
| 4444 | + | |
| 4445 | + /** | |
| 4446 | + * Flush the persistent object cache (Redis / Memcached). | |
| 4447 | + * | |
| 4448 | + * Runs regardless of whether the Object Cache module is currently | |
| 4449 | + * enabled — a drop-in installed earlier keeps serving until flushed. | |
| 4450 | + * | |
| 4451 | + * @param bool $network_wide Flush every blog's entries. wp_cache_flush() | |
| 4452 | + * is NETWORK-global, so on multisite the | |
| 4453 | + * default prefers the blog-scoped group flush | |
| 4454 | + * (WP 6.1+) — otherwise one site's purge drops | |
| 4455 | + * every other site's object cache, the same bug | |
| 4456 | + * #6 fixed for the page cache. | |
| 4457 | + * @return bool Whether a flush was actually performed. | |
| 4458 | + */ | |
| 4459 | + public static function flush_object_cache( bool $network_wide = false ): bool { | |
| 4460 | + if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) { | |
| 4461 | + // Blog-scoped groups only; a shared/global group (site options, | |
| 4462 | + // user meta) is intentionally left alone. | |
| 4463 | + foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) { | |
| 4464 | + wp_cache_flush_group( $group ); | |
| 4465 | + } | |
| 4466 | + return true; | |
| 933 | 4467 | } |
| 4468 | + if ( function_exists( 'wp_cache_flush' ) ) { | |
| 4469 | + return (bool) wp_cache_flush(); | |
| 4470 | + } | |
| 4471 | + return false; | |
| 4472 | + } | |
| 934 | 4473 | |
| 935 | - // Persistent object cache (Redis / Memcached). Flush regardless of | |
| 936 | - // whether the Object Cache module is currently enabled — a drop-in | |
| 937 | - // installed earlier keeps serving until flushed. | |
| 938 | - if ( function_exists( 'wp_cache_flush' ) ) { | |
| 939 | - wp_cache_flush(); | |
| 4474 | + /** | |
| 4475 | + * Purge this site's cache: the local sweep, then the object cache, then | |
| 4476 | + * the bookkeeping every caller expects (stats, `xspeed_after_purge_all`, | |
| 4477 | + * inventory invalidation, purge log). | |
| 4478 | + * | |
| 4479 | + * @param string $cause Who asked, for the purge log. | |
| 4480 | + * @param string|null $host See purge_local(). | |
| 4481 | + * @param array<string,mixed> $invalidation Public adapter policy. `scope` | |
| 4482 | + * is urls/site/network/none, | |
| 4483 | + * `intent` explains why, and | |
| 4484 | + * `urls` supplies exact targets. | |
| 4485 | + * @return int Page + REST entries removed. | |
| 4486 | + */ | |
| 4487 | + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) { | |
| 4488 | + $network_wide = ( '*' === $host ); | |
| 4489 | + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] ) | |
| 4490 | + ? $invalidation['scope'] | |
| 4491 | + : ( $network_wide ? 'network' : 'site' ); | |
| 4492 | + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) { | |
| 4493 | + $adapter_scope = $network_wide ? 'network' : 'site'; | |
| 940 | 4494 | } |
| 4495 | + if ( $network_wide ) { | |
| 4496 | + $adapter_scope = 'network'; | |
| 4497 | + } | |
| 4498 | + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent'] | |
| 4499 | + ? $invalidation['intent'] | |
| 4500 | + : 'complete'; | |
| 4501 | + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] ) | |
| 4502 | + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) ) | |
| 4503 | + : array(); | |
| 4504 | + // This method always sweeps a complete local bucket. A narrower adapter | |
| 4505 | + // announcement would claim unrelated local pages stayed warm when they | |
| 4506 | + // did not, leaving their server copies stale. Until purge_all() gains | |
| 4507 | + // dependency-aware local deletion, its response scope cannot be `urls`. | |
| 4508 | + if ( 'urls' === $adapter_scope ) { | |
| 4509 | + $adapter_scope = $network_wide ? 'network' : 'site'; | |
| 4510 | + } | |
| 4511 | + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) { | |
| 4512 | + $urls = array(); | |
| 4513 | + } | |
| 941 | 4514 | |
| 4515 | + $removed = self::purge_local( $host ); | |
| 4516 | + $count = $removed['pages'] + $removed['rest']; | |
| 4517 | + | |
| 4518 | + self::flush_object_cache( $network_wide ); | |
| 4519 | + | |
| 942 | 4520 | self::update_stats( array( 'last_purge' => time() ) ); |
| 943 | 4521 | |
| 944 | 4522 | // Fire AFTER the local sweep so module listeners (Critical CSS, |
| 945 | 4523 | // Unused CSS, Cloudflare edge purge) run — this action had three |
| @@ -945,10 +4523,69 @@ | ||
| 945 | 4523 | // Unused CSS, Cloudflare edge purge) run — this action had three |
| 946 | 4524 | // registered listeners but was never emitted. Treat it as additive |
| 947 | 4525 | // (CDN / edge invalidation), not the mechanism for clearing local |
| 948 | 4526 | // files. (FBS-83114) |
| 949 | - do_action( 'xspeed_after_purge_all', $cause ); | |
| 4527 | + // Wrapped: this action predates the purge-event contract and has its | |
| 4528 | + // own third-party listeners. One of them throwing used to abort | |
| 4529 | + // purge_all() here, which now also means the contract event below | |
| 4530 | + // never fires and a server cache keeps serving stale HTML. The local | |
| 4531 | + // sweep is already done by this point, so swallowing is strictly safer | |
| 4532 | + // than letting a listener decide the rest of the method runs. | |
| 4533 | + try { | |
| 4534 | + // Isolated per listener: one throwing used to cancel every | |
| 4535 | + // listener queued behind it — Critical CSS, Unused CSS and the | |
| 4536 | + // Cloudflare edge purge all hang off this hook. (QA #348) | |
| 4537 | + self::do_action_isolated( 'xspeed_after_purge_all', $cause ); | |
| 4538 | + } catch ( \Throwable $e ) { | |
| 4539 | + self::log_purge_listener_error( 'xspeed_after_purge_all', $e ); | |
| 4540 | + } | |
| 950 | 4541 | |
| 4542 | + /** | |
| 4543 | + * Fires after a full purge, with the same bounded context shape as | |
| 4544 | + * `xspeed_after_purge_url`. | |
| 4545 | + * | |
| 4546 | + * Distinct from `xspeed_after_purge_all` on purpose. That action is | |
| 4547 | + * the long-standing internal signal — it passes a bare `$cause` string | |
| 4548 | + * and Free's own modules use it for local bookkeeping. This one is the | |
| 4549 | + * documented contract for OUTSIDE integrations: same argument shape as | |
| 4550 | + * the per-URL event, so a server-cache or CDN adapter can subscribe to | |
| 4551 | + * both with one handler and branch on a null `url`. | |
| 4552 | + * | |
| 4553 | + * Fires at most once per purge, and not at all when a listener's own | |
| 4554 | + * purge re-enters xSpeed. | |
| 4555 | + * | |
| 4556 | + * @since 1.2.3 | |
| 4557 | + * | |
| 4558 | + * @param array $context { | |
| 4559 | + * @type null $url Always null — a full purge has no single URL. | |
| 4560 | + * @type string $host Host swept, or '*' for the entire tree. | |
| 4561 | + * @type null $path Always null. | |
| 4562 | + * @type string $cause Short label for who asked. | |
| 4563 | + * @type int $removed Number of cache files removed. | |
| 4564 | + * @type string $scope Adapter action: urls/site/network/none. | |
| 4565 | + * @type string $intent content/presentation/complete or a caller-defined intent. | |
| 4566 | + * @type string[] $urls Exact targets when scope is urls. | |
| 4567 | + * } | |
| 4568 | + */ | |
| 4569 | + self::dispatch_purge_event( | |
| 4570 | + 'xspeed_after_purge', | |
| 4571 | + array( | |
| 4572 | + 'url' => null, | |
| 4573 | + 'host' => null === $host ? self::current_purge_host() : (string) $host, | |
| 4574 | + 'path' => null, | |
| 4575 | + 'cause' => $cause, | |
| 4576 | + 'removed' => $count, | |
| 4577 | + 'scope' => $adapter_scope, | |
| 4578 | + 'intent' => $intent, | |
| 4579 | + 'urls' => $urls, | |
| 4580 | + ) | |
| 4581 | + ); | |
| 4582 | + | |
| 4583 | + // The list behind the "Cached pages" card is memoized for a minute; | |
| 4584 | + // a purge has to drop it or the drill-down shows pages that no | |
| 4585 | + // longer exist. | |
| 4586 | + Cache_Inventory::invalidate(); | |
| 4587 | + | |
| 951 | 4588 | // Trigger of WP_CLI / hook / admin-bar purges all hit the same |
| 952 | 4589 | // path. Record once with the supplied cause so the dashboard |
| 953 | 4590 | // activity feed reads naturally. |
| 954 | 4591 | Activity_Log::record( |
| @@ -960,8 +4597,508 @@ | ||
| 960 | 4597 | return $count; |
| 961 | 4598 | } |
| 962 | 4599 | |
| 963 | 4600 | /** |
| 4601 | + * Purge everything after a plugin / theme / core update completes. | |
| 4602 | + * | |
| 4603 | + * Bound to `upgrader_process_complete`, which is the only hook an update | |
| 4604 | + * fires — no activation hook runs, so without this the cached HTML (and | |
| 4605 | + * the asset URLs baked into it) outlives the code that produced it. | |
| 4606 | + * | |
| 4607 | + * Runs for plugin, theme and core updates alike, including bulk runs and | |
| 4608 | + * auto-updates, and purges the WHOLE network rather than the current | |
| 4609 | + * site — see the call below. Translation updates are skipped: they | |
| 4610 | + * change no markup a cached page depends on, and language packs update | |
| 4611 | + * often enough that purging on them would keep a multilingual site | |
| 4612 | + * permanently cold. | |
| 4613 | + * | |
| 4614 | + * Note this cannot be folded into the `$invalidate_hooks` loop above: | |
| 4615 | + * that binds `purge_all` directly, and `purge_all( string $cause )` would | |
| 4616 | + * then receive the WP_Upgrader instance as its cause. | |
| 4617 | + * | |
| 4618 | + * @param mixed $upgrader WP_Upgrader instance (unused). | |
| 4619 | + * @param array $hook_extra Context for the completed operation. | |
| 4620 | + * @return void | |
| 4621 | + */ | |
| 4622 | + public static function purge_after_upgrade( $upgrader = null, $hook_extra = array() ) { | |
| 4623 | + $cleared = self::$upgrade_cleared_destination; | |
| 4624 | + | |
| 4625 | + if ( ! self::upgrade_produced_something( $upgrader ) ) { | |
| 4626 | + return; | |
| 4627 | + } | |
| 4628 | + | |
| 4629 | + if ( ! self::upgrade_should_purge( is_array( $hook_extra ) ? $hook_extra : array(), $cleared ) ) { | |
| 4630 | + return; | |
| 4631 | + } | |
| 4632 | + | |
| 4633 | + self::purge_for_upgrade(); | |
| 4634 | + } | |
| 4635 | + | |
| 4636 | + /** | |
| 4637 | + * Whether this request's upgrader removed an existing copy. | |
| 4638 | + * | |
| 4639 | + * @var bool | |
| 4640 | + */ | |
| 4641 | + private static $upgrade_cleared_destination = false; | |
| 4642 | + | |
| 4643 | + /** | |
| 4644 | + * How many `upgrader_process_complete` dispatches are on the stack. | |
| 4645 | + * | |
| 4646 | + * @var int | |
| 4647 | + */ | |
| 4648 | + private static $upgrade_dispatch_depth = 0; | |
| 4649 | + | |
| 4650 | + /** | |
| 4651 | + * Enter an `upgrader_process_complete` dispatch. | |
| 4652 | + * | |
| 4653 | + * Bound at PHP_INT_MIN, so it runs before any listener that might read | |
| 4654 | + * the replacement signal. Public because it is a hook target. | |
| 4655 | + * | |
| 4656 | + * @return void | |
| 4657 | + */ | |
| 4658 | + public static function note_upgrade_dispatch(): void { | |
| 4659 | + ++self::$upgrade_dispatch_depth; | |
| 4660 | + } | |
| 4661 | + | |
| 4662 | + /** | |
| 4663 | + * Drop the replacement signal once every listener has read it. | |
| 4664 | + * | |
| 4665 | + * Bound at PHP_INT_MAX so a second upgrade in the same request starts | |
| 4666 | + * clean, without taking the answer away from the add-on callbacks that | |
| 4667 | + * run at the same priority as ours. | |
| 4668 | + * | |
| 4669 | + * Only the OUTERMOST dispatch clears it. A nested run — core's language | |
| 4670 | + * pack upgrader, or any add-on that installs something from this hook — | |
| 4671 | + * fires the action again, and clearing there would answer for a run that | |
| 4672 | + * has not finished. Called directly (no dispatch on the stack) it still | |
| 4673 | + * clears, which is what a test wants. | |
| 4674 | + * | |
| 4675 | + * Known limit: a nested run INHERITS the outer run's signal, because the | |
| 4676 | + * only evidence we get is a filter that fires before the nested dispatch | |
| 4677 | + * begins and carries no upgrader identity. So a fresh install performed | |
| 4678 | + * from inside a replacement run reads as a replacement and purges once | |
| 4679 | + * more than it needs to. A cold cache is the cheap direction, and the | |
| 4680 | + * alternative — scoping the signal per upgrader — is not knowable from | |
| 4681 | + * `upgrader_clear_destination`. | |
| 4682 | + * | |
| 4683 | + * @return void | |
| 4684 | + */ | |
| 4685 | + public static function forget_cleared_destination(): void { | |
| 4686 | + if ( self::$upgrade_dispatch_depth > 0 ) { | |
| 4687 | + --self::$upgrade_dispatch_depth; | |
| 4688 | + } | |
| 4689 | + | |
| 4690 | + if ( 0 === self::$upgrade_dispatch_depth ) { | |
| 4691 | + self::$upgrade_cleared_destination = false; | |
| 4692 | + } | |
| 4693 | + } | |
| 4694 | + | |
| 4695 | + /** | |
| 4696 | + * Record that the upgrader cleared an existing destination. | |
| 4697 | + * | |
| 4698 | + * A pass-through listener on `upgrader_clear_destination`: WordPress only | |
| 4699 | + * fires it when `clear_destination` was set AND something was there to | |
| 4700 | + * remove, which is the one signal that separates an upload-and-replace | |
| 4701 | + * from a first-time install. The filtered value is returned untouched. | |
| 4702 | + * | |
| 4703 | + * @param true|\WP_Error $removed Whether the destination was cleared. | |
| 4704 | + * @return true|\WP_Error | |
| 4705 | + */ | |
| 4706 | + public static function note_cleared_destination( $removed ) { | |
| 4707 | + if ( ! is_wp_error( $removed ) ) { | |
| 4708 | + self::$upgrade_cleared_destination = true; | |
| 4709 | + } | |
| 4710 | + | |
| 4711 | + return $removed; | |
| 4712 | + } | |
| 4713 | + | |
| 4714 | + /** | |
| 4715 | + * Did the completed run actually replace anything? | |
| 4716 | + * | |
| 4717 | + * `upgrader_process_complete` fires whether the run succeeded or failed — | |
| 4718 | + * the failure branch in WP_Upgrader::run() only feeds the skin before the | |
| 4719 | + * action fires. A run that installed nothing changed no markup, so purging | |
| 4720 | + * for it is a cold cache bought for nothing. | |
| 4721 | + * | |
| 4722 | + * Deliberately conservative: this returns false ONLY when every result we | |
| 4723 | + * can see is an error. An upgrader we cannot read, a mixed bulk run, or a | |
| 4724 | + * missing result all fall through to purging, which is the safe direction | |
| 4725 | + * everywhere else in this handler. | |
| 4726 | + * | |
| 4727 | + * @param mixed $upgrader WP_Upgrader instance, or anything else. | |
| 4728 | + * @return bool | |
| 4729 | + */ | |
| 4730 | + public static function upgrade_produced_something( $upgrader ): bool { | |
| 4731 | + if ( ! is_object( $upgrader ) ) { | |
| 4732 | + return true; | |
| 4733 | + } | |
| 4734 | + | |
| 4735 | + // A bulk run collects one entry per item; `result` alone would only | |
| 4736 | + // describe the last of them. | |
| 4737 | + if ( isset( $upgrader->results ) && is_array( $upgrader->results ) && ! empty( $upgrader->results ) ) { | |
| 4738 | + foreach ( $upgrader->results as $result ) { | |
| 4739 | + if ( ! is_wp_error( $result ) && ! empty( $result ) ) { | |
| 4740 | + return true; | |
| 4741 | + } | |
| 4742 | + } | |
| 4743 | + return false; | |
| 4744 | + } | |
| 4745 | + | |
| 4746 | + if ( ! property_exists( $upgrader, 'result' ) ) { | |
| 4747 | + return true; | |
| 4748 | + } | |
| 4749 | + | |
| 4750 | + return ! is_wp_error( $upgrader->result ) && ! empty( $upgrader->result ); | |
| 4751 | + } | |
| 4752 | + | |
| 4753 | + /** | |
| 4754 | + * Decide whether a completed operation invalidates the cache. | |
| 4755 | + * | |
| 4756 | + * Split out from the handler so the decision is testable on its own: | |
| 4757 | + * purge_all() reaches straight for glob() and unlink(), which a unit test | |
| 4758 | + * cannot observe honestly, while every rule that matters lives here. | |
| 4759 | + * | |
| 4760 | + * @param array $hook_extra Context for the completed operation. | |
| 4761 | + * @return bool | |
| 4762 | + */ | |
| 4763 | + public static function upgrade_should_purge( array $hook_extra, bool $destination_cleared = false ): bool { | |
| 4764 | + if ( ! self::upgrade_replaced_code( $hook_extra, $destination_cleared ) ) { | |
| 4765 | + return false; | |
| 4766 | + } | |
| 4767 | + | |
| 4768 | + // An update to xSpeed ITSELF always purges, whatever the setting says. | |
| 4769 | + // This plugin's own code is what rendered every cached page — the | |
| 4770 | + // minifier, lazy-loader, resource hints and CDN rewriter all changed | |
| 4771 | + // underneath it — so serving that HTML after an update means serving | |
| 4772 | + // output from a version that no longer exists. Minified assets make it | |
| 4773 | + // concrete rather than theoretical: their filenames are keyed on the | |
| 4774 | + // source filemtime, so they regenerate under NEW hashes while the | |
| 4775 | + // cached pages still link the old ones, and the page requests files | |
| 4776 | + // that are no longer on disk. Offering an opt-out for that would be | |
| 4777 | + // offering a broken site. | |
| 4778 | + return self::upgrade_touches_xspeed( $hook_extra ) || self::purge_on_upgrade_enabled(); | |
| 4779 | + } | |
| 4780 | + | |
| 4781 | + /** | |
| 4782 | + * Did this completed run replace code that renders pages? | |
| 4783 | + * | |
| 4784 | + * The half of the decision that has nothing to do with our settings: it | |
| 4785 | + * asks only whether live code changed underneath the output we cached. | |
| 4786 | + * Add-ons that keep their own derived artifacts — generated CSS, captured | |
| 4787 | + * selectors, fingerprints — need the same answer and must not have to | |
| 4788 | + * rebuild these rules, or they drift apart. Call it with the hook's own | |
| 4789 | + * `$hook_extra`; the upload-and-replace signal is read from this request. | |
| 4790 | + * | |
| 4791 | + * Deliberately independent of the "Purge After Updates" setting. That | |
| 4792 | + * setting governs the page cache, not whether an add-on's derived data is | |
| 4793 | + * still valid. | |
| 4794 | + * | |
| 4795 | + * @param array $hook_extra Context for the completed operation. | |
| 4796 | + * @param bool|null $destination_cleared Override the recorded signal; null reads this request's. | |
| 4797 | + * @return bool | |
| 4798 | + */ | |
| 4799 | + public static function upgrade_replaced_code( array $hook_extra, ?bool $destination_cleared = null ): bool { | |
| 4800 | + $cleared = null === $destination_cleared ? self::$upgrade_cleared_destination : $destination_cleared; | |
| 4801 | + | |
| 4802 | + $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : ''; | |
| 4803 | + $action = isset( $hook_extra['action'] ) ? (string) $hook_extra['action'] : ''; | |
| 4804 | + | |
| 4805 | + // `upgrader_process_complete` fires for INSTALLS as well as updates. | |
| 4806 | + // A freshly installed plugin is inactive and a freshly installed theme | |
| 4807 | + // is not the active one, so neither can change a single rendered page | |
| 4808 | + // — but the first cut of this handler purged the whole tree anyway, so | |
| 4809 | + // evaluating three plugins in a row emptied the cache three times. | |
| 4810 | + // | |
| 4811 | + // 'install' alone is NOT enough to skip on, because WordPress reports | |
| 4812 | + // an upload-and-replace as an install: `Plugin_Upgrader::install()` | |
| 4813 | + // hardcodes `action => install` and `overwrite_package` does not change | |
| 4814 | + // it, so "Replace current with uploaded" and `wp plugin install <zip> | |
| 4815 | + // --force` both arrive here labelled install while genuinely replacing | |
| 4816 | + // live code. That is how a plugin distributed as a zip is updated, and | |
| 4817 | + // skipping it put back the stale markup this handler exists to clear. | |
| 4818 | + // | |
| 4819 | + // The distinguishing signal is whether the destination was cleared: | |
| 4820 | + // WP_Upgrader only fires `upgrader_clear_destination` when it removed | |
| 4821 | + // something that was already there. Installing beside nothing does not. | |
| 4822 | + if ( 'install' === $action && ! $cleared ) { | |
| 4823 | + return false; | |
| 4824 | + } | |
| 4825 | + | |
| 4826 | + // 'translation' is the one update type that cannot change rendered | |
| 4827 | + // markup. Anything else — including an empty type from a custom | |
| 4828 | + // updater — is treated as cache-invalidating, because guessing wrong | |
| 4829 | + // in that direction only costs a cold cache. | |
| 4830 | + if ( 'translation' === $type ) { | |
| 4831 | + return false; | |
| 4832 | + } | |
| 4833 | + | |
| 4834 | + return true; | |
| 4835 | + } | |
| 4836 | + | |
| 4837 | + /** | |
| 4838 | + * Purge everything an update can invalidate. | |
| 4839 | + * | |
| 4840 | + * Network-wide ('*'), not the calling site's bucket. A plugin, theme or | |
| 4841 | + * core update replaces code shared by EVERY site on the network, so a | |
| 4842 | + * scoped purge would clear the site that happened to run the updater and | |
| 4843 | + * leave every other subsite serving pre-update HTML for the whole TTL — | |
| 4844 | + * the very bug this handler exists to fix, one level down. On single-site | |
| 4845 | + * this is identical to the scoped call, since there is only ever one | |
| 4846 | + * bucket. | |
| 4847 | + * | |
| 4848 | + * @return void | |
| 4849 | + */ | |
| 4850 | + private static function purge_for_upgrade(): void { | |
| 4851 | + self::purge_all( 'upgrade', '*' ); | |
| 4852 | + Minifier::purge_minified(); | |
| 4853 | + } | |
| 4854 | + | |
| 4855 | + /** | |
| 4856 | + * Purge after an unattended background update run. | |
| 4857 | + * | |
| 4858 | + * `automatic_updates_complete` passes ONE argument, and it is not a | |
| 4859 | + * hook_extra: it is WordPress's results array, keyed by what was updated | |
| 4860 | + * ('core', 'plugin', 'theme', 'translation'). Handing it to | |
| 4861 | + * purge_after_upgrade() put it in the unused $upgrader slot and left the | |
| 4862 | + * type empty, so a night on which only a language pack updated purged | |
| 4863 | + * every cached page — the exact case the translation exemption exists to | |
| 4864 | + * prevent, and WordPress auto-updates language packs by default. | |
| 4865 | + * | |
| 4866 | + * @param array $results Update results, keyed by type. | |
| 4867 | + * @return void | |
| 4868 | + */ | |
| 4869 | + public static function purge_after_auto_updates( $results = array() ): void { | |
| 4870 | + if ( ! self::auto_updates_should_purge( is_array( $results ) ? $results : array() ) ) { | |
| 4871 | + return; | |
| 4872 | + } | |
| 4873 | + | |
| 4874 | + self::purge_for_upgrade(); | |
| 4875 | + } | |
| 4876 | + | |
| 4877 | + /** | |
| 4878 | + * Decide whether a background update run invalidates the cache. | |
| 4879 | + * | |
| 4880 | + * @param array $results Update results, keyed by type. | |
| 4881 | + * @return bool | |
| 4882 | + */ | |
| 4883 | + public static function auto_updates_should_purge( array $results ): bool { | |
| 4884 | + // An unrecognisable payload is treated as invalidating, the same | |
| 4885 | + // direction every other unknown takes here. | |
| 4886 | + if ( empty( $results ) ) { | |
| 4887 | + return true; | |
| 4888 | + } | |
| 4889 | + | |
| 4890 | + // Failed items are listed alongside successful ones — WP_Automatic_Updater | |
| 4891 | + // appends an entry whatever the outcome — and a night on which every | |
| 4892 | + // update failed replaced no code, so it invalidates nothing. | |
| 4893 | + $updated = array(); | |
| 4894 | + foreach ( $results as $type => $items ) { | |
| 4895 | + if ( ! is_array( $items ) ) { | |
| 4896 | + continue; | |
| 4897 | + } | |
| 4898 | + foreach ( $items as $item ) { | |
| 4899 | + $result = is_object( $item ) && isset( $item->result ) ? $item->result : true; | |
| 4900 | + if ( ! is_wp_error( $result ) && ! empty( $result ) ) { | |
| 4901 | + $updated[] = (string) $type; | |
| 4902 | + break; | |
| 4903 | + } | |
| 4904 | + } | |
| 4905 | + } | |
| 4906 | + | |
| 4907 | + if ( empty( $updated ) ) { | |
| 4908 | + return false; | |
| 4909 | + } | |
| 4910 | + | |
| 4911 | + // Nothing but language packs: a language pack changes no markup a | |
| 4912 | + // cached page depends on, and purging on one would keep a multilingual | |
| 4913 | + // site permanently cold. | |
| 4914 | + if ( array( 'translation' ) === array_values( array_unique( $updated ) ) ) { | |
| 4915 | + return false; | |
| 4916 | + } | |
| 4917 | + | |
| 4918 | + return self::auto_updates_touch_xspeed( $results ) || self::purge_on_upgrade_enabled(); | |
| 4919 | + } | |
| 4920 | + | |
| 4921 | + /** | |
| 4922 | + * Does a background run include one of our own plugins? | |
| 4923 | + * | |
| 4924 | + * Same rule as a foreground self-update, read out of the results array's | |
| 4925 | + * shape instead of a hook_extra: each plugin entry carries the update | |
| 4926 | + * object on `->item->plugin`. | |
| 4927 | + * | |
| 4928 | + * @param array $results Update results, keyed by type. | |
| 4929 | + * @return bool | |
| 4930 | + */ | |
| 4931 | + private static function auto_updates_touch_xspeed( array $results ): bool { | |
| 4932 | + if ( empty( $results['plugin'] ) || ! is_array( $results['plugin'] ) ) { | |
| 4933 | + return false; | |
| 4934 | + } | |
| 4935 | + | |
| 4936 | + $ours = self::self_update_plugins(); | |
| 4937 | + foreach ( $results['plugin'] as $entry ) { | |
| 4938 | + $item = is_object( $entry ) && isset( $entry->item ) ? $entry->item : null; | |
| 4939 | + $file = is_object( $item ) && isset( $item->plugin ) ? (string) $item->plugin : ''; | |
| 4940 | + if ( '' !== $file && in_array( $file, $ours, true ) ) { | |
| 4941 | + return true; | |
| 4942 | + } | |
| 4943 | + } | |
| 4944 | + | |
| 4945 | + return false; | |
| 4946 | + } | |
| 4947 | + | |
| 4948 | + /** | |
| 4949 | + * Is the "Purge After Updates" setting on? | |
| 4950 | + * | |
| 4951 | + * Gates THIRD-PARTY updates only — an xSpeed self-update ignores it, see | |
| 4952 | + * purge_after_upgrade(). Defaults to true when the option has never been | |
| 4953 | + * written, matching the schema default in CacheModule: an unset value on | |
| 4954 | + * an existing install must not read as "the user turned this off". | |
| 4955 | + * | |
| 4956 | + * Unlike LiteSpeed, which ships the equivalent toggle OFF, this defaults | |
| 4957 | + * ON — a cold cache costs one slow request, whereas stale HTML is a wrong | |
| 4958 | + * page for up to the full TTL and the site owner has no way to tell why. | |
| 4959 | + * | |
| 4960 | + * @return bool | |
| 4961 | + */ | |
| 4962 | + private static function purge_on_upgrade_enabled(): bool { | |
| 4963 | + $opts = Settings_Manager::get( 'cache' ); | |
| 4964 | + return ! array_key_exists( 'purge_on_upgrade', $opts ) || ! empty( $opts['purge_on_upgrade'] ); | |
| 4965 | + } | |
| 4966 | + | |
| 4967 | + /** | |
| 4968 | + * Does this completed update include xSpeed itself? | |
| 4969 | + * | |
| 4970 | + * Mirrors the payload shapes Plugin::maybe_restore_after_update() reads: | |
| 4971 | + * a single update carries 'plugin', a bulk run carries 'plugins'. | |
| 4972 | + * | |
| 4973 | + * @param array $hook_extra Context for the completed operation. | |
| 4974 | + * @return bool | |
| 4975 | + */ | |
| 4976 | + private static function upgrade_touches_xspeed( array $hook_extra ): bool { | |
| 4977 | + if ( ! isset( $hook_extra['type'] ) || 'plugin' !== $hook_extra['type'] ) { | |
| 4978 | + return false; | |
| 4979 | + } | |
| 4980 | + | |
| 4981 | + $updated = array(); | |
| 4982 | + if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) { | |
| 4983 | + // Strings only: array_intersect() stringifies what it is given, so | |
| 4984 | + // an object without __toString in a custom updater's payload would | |
| 4985 | + // be a fatal rather than a miss. | |
| 4986 | + $updated = array_filter( $hook_extra['plugins'], 'is_string' ); | |
| 4987 | + } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) { | |
| 4988 | + $updated = array( $hook_extra['plugin'] ); | |
| 4989 | + } | |
| 4990 | + | |
| 4991 | + return (bool) array_intersect( self::self_update_plugins(), $updated ); | |
| 4992 | + } | |
| 4993 | + | |
| 4994 | + /** | |
| 4995 | + * Plugin files whose update counts as an update to us. | |
| 4996 | + * | |
| 4997 | + * The self-update rule is "our own code rendered this cached HTML, so it | |
| 4998 | + * must not survive the code being replaced". That is true of any add-on | |
| 4999 | + * that writes into the same page: an add-on inlines critical CSS, rewrites | |
| 5000 | + * stylesheet links and image URLs, and produces the compressed and static | |
| 5001 | + * copies, so its update leaves exactly the stale markup this rule exists | |
| 5002 | + * to clear. Free cannot name an add-on, so it asks instead. | |
| 5003 | + * | |
| 5004 | + * Filter: xspeed_self_update_plugins | |
| 5005 | + * | |
| 5006 | + * Add-ons add their own `plugin_basename( __FILE__ )`. Entries are matched | |
| 5007 | + * against the plugin files WordPress reports for the completed update, so | |
| 5008 | + * a value that is not a `dir/file.php` basename simply never matches. | |
| 5009 | + * | |
| 5010 | + * @param string[] $plugins Plugin basenames treated as our own. | |
| 5011 | + * @return string[] | |
| 5012 | + */ | |
| 5013 | + private static function self_update_plugins(): array { | |
| 5014 | + $ours = array( plugin_basename( XSPEED_FILE ) ); | |
| 5015 | + | |
| 5016 | + /** This filter is documented above. */ | |
| 5017 | + $filtered = apply_filters( 'xspeed_self_update_plugins', $ours ); | |
| 5018 | + | |
| 5019 | + // Our own file is merged back afterwards rather than trusted to survive | |
| 5020 | + // the round trip. A listener that returns null, a bare string, or a | |
| 5021 | + // list it built from scratch would otherwise drop it, and the plugin | |
| 5022 | + // would quietly stop exempting its OWN update from the setting — a | |
| 5023 | + // failure no add-on author would think to test for. | |
| 5024 | + $claimed = array_filter( is_array( $filtered ) ? $filtered : array(), 'is_string' ); | |
| 5025 | + | |
| 5026 | + return array_values( array_unique( array_merge( $ours, array_filter( $claimed ) ) ) ); | |
| 5027 | + } | |
| 5028 | + | |
| 5029 | + /** | |
| 5030 | + * Invalidate caches of RENDERED output owned by other plugins. | |
| 5031 | + * | |
| 5032 | + * purge_all() sweeps only what xSpeed wrote. A page builder that stores | |
| 5033 | + * rendered HTML or generated CSS of its own — Elementor's element cache | |
| 5034 | + * and `uploads/elementor/css/`, and the equivalents in Beaver / Divi / | |
| 5035 | + * Bricks / Oxygen — keeps whatever asset URLs were current when it was | |
| 5036 | + * written, and no xSpeed purge has ever reached it. | |
| 5037 | + * | |
| 5038 | + * That only matters for rewrites that happen DURING render rather than on | |
| 5039 | + * the finished page. Minify, combine, lazy-load and resource hints all run | |
| 5040 | + * on `xspeed_cache_final_html` or a `template_redirect` buffer — after the | |
| 5041 | + * builder has already stored its copy — so nothing they emit can leak. | |
| 5042 | + * The CDN module's `wp_get_attachment_url` filter is the one that can. | |
| 5043 | + * | |
| 5044 | + * Called ONLY from purges where asset URLs themselves can have changed | |
| 5045 | + * (a CDN settings write, an explicit Purge All). NOT from purge_all(), | |
| 5046 | + * which also runs on every post publish — regenerating every builder CSS | |
| 5047 | + * file that often would cost more than it saves, and the builder already | |
| 5048 | + * invalidates its own copy for the post being saved. | |
| 5049 | + * | |
| 5050 | + * @param string $cause Who asked. Threaded through to the listeners and | |
| 5051 | + * the activity log. | |
| 5052 | + * @return string[] Labels of the caches that were actually cleared. | |
| 5053 | + */ | |
| 5054 | + public static function purge_render_caches( string $cause = 'manual' ): array { | |
| 5055 | + /** | |
| 5056 | + * Clear render caches belonging to other plugins. | |
| 5057 | + * | |
| 5058 | + * A listener does its own work and appends a human-readable label for | |
| 5059 | + * what it cleared, so the activity log can name it. Returning | |
| 5060 | + * `$cleared` unchanged means "nothing of mine is installed" and is the | |
| 5061 | + * correct no-op — never a failure. | |
| 5062 | + * | |
| 5063 | + * Detect the owning plugin by class or constant, not by an | |
| 5064 | + * `is_plugin_active()` path check: a renamed plugin folder must not | |
| 5065 | + * silently disable the integration. | |
| 5066 | + * | |
| 5067 | + * @param string[] $cleared Labels of caches cleared so far. | |
| 5068 | + * @param string $cause Why the purge is happening. | |
| 5069 | + */ | |
| 5070 | + $cleared = (array) apply_filters( 'xspeed_purge_third_party_render_caches', array(), $cause ); | |
| 5071 | + | |
| 5072 | + // Labels are strings destined for the activity feed. Anything else a | |
| 5073 | + // third-party listener returns is dropped rather than coerced — a | |
| 5074 | + // stray `0` or `null` in the log reads as a cache we cleared. | |
| 5075 | + $cleared = array_values( | |
| 5076 | + array_filter( | |
| 5077 | + $cleared, | |
| 5078 | + static function ( $label ) { | |
| 5079 | + return is_string( $label ) && '' !== trim( $label ); | |
| 5080 | + } | |
| 5081 | + ) | |
| 5082 | + ); | |
| 5083 | + | |
| 5084 | + if ( ! $cleared ) { | |
| 5085 | + return $cleared; | |
| 5086 | + } | |
| 5087 | + | |
| 5088 | + // Logged separately from the page-cache purge above it. "I turned the | |
| 5089 | + // CDN off and the images are still wrong" is only diagnosable if the | |
| 5090 | + // feed says which OTHER plugin's cache was regenerated and when. | |
| 5091 | + Activity_Log::record( | |
| 5092 | + 'cache_purged', | |
| 5093 | + sprintf( 'Render caches cleared (%s) — %s', $cause, implode( ', ', $cleared ) ), | |
| 5094 | + Activity_Log::INFO | |
| 5095 | + ); | |
| 5096 | + | |
| 5097 | + return $cleared; | |
| 5098 | + } | |
| 5099 | + | |
| 5100 | + /** | |
| 964 | 5101 | * The per-type purge menu, LiteSpeed-style. Each entry is a cache type |
| 965 | 5102 | * the user can purge individually from the admin-bar dropdown. `visible` |
| 966 | 5103 | * controls whether the item shows (active + licensed module only) — it |
| 967 | 5104 | * NEVER limits Purge All, which always sweeps everything on disk. |
| @@ -1019,32 +5156,33 @@ | ||
| 1019 | 5156 | * every other slug clears just its own artifacts. Unknown slugs (e.g. a |
| 1020 | 5157 | * Pro type) fan out via the `xspeed_purge_type_{slug}` action so the |
| 1021 | 5158 | * owning module can handle it. Returns the number of items removed where |
| 1022 | 5159 | * countable. |
| 5160 | + * | |
| 5161 | + * @param string $type Cache type slug. | |
| 5162 | + * @param string $cause Who asked. Threaded through so the purge log can | |
| 5163 | + * tell an AI assistant's purge apart from a click — | |
| 5164 | + * "the cache cleared four times today" is only | |
| 5165 | + * actionable once you know what kept clearing it. | |
| 1023 | 5166 | */ |
| 1024 | - public static function purge_type( string $type ): int { | |
| 5167 | + public static function purge_type( string $type, string $cause = 'manual' ): int { | |
| 1025 | 5168 | switch ( $type ) { |
| 1026 | 5169 | case 'all': |
| 1027 | - return self::purge_all( 'manual' ); | |
| 5170 | + $count = self::purge_all( $cause ); | |
| 5171 | + // "Purge All" is the user saying they don't trust anything | |
| 5172 | + // stored anywhere — the one purge that should also reach | |
| 5173 | + // caches of rendered output we don't own. purge_all() itself | |
| 5174 | + // deliberately does NOT, because it also runs on every post | |
| 5175 | + // publish. (See Render_Caches.) | |
| 5176 | + self::purge_render_caches( $cause ); | |
| 5177 | + return $count; | |
| 1028 | 5178 | |
| 1029 | 5179 | case 'page': |
| 1030 | - $count = 0; | |
| 1031 | - if ( is_dir( XSPEED_CACHE_DIR ) ) { | |
| 1032 | - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.html' ) as $f ) { | |
| 1033 | - wp_delete_file( $f ); | |
| 1034 | - ++$count; | |
| 1035 | - } | |
| 1036 | - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.meta' ) as $m ) { | |
| 1037 | - wp_delete_file( $m ); | |
| 1038 | - } | |
| 1039 | - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.br' ) as $b ) { | |
| 1040 | - wp_delete_file( $b ); | |
| 1041 | - } | |
| 1042 | - } | |
| 1043 | - if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) { | |
| 1044 | - $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR ); | |
| 1045 | - } | |
| 5180 | + $count = self::purge_pages(); | |
| 1046 | 5181 | self::update_stats( array( 'last_purge' => time() ) ); |
| 5182 | + Cache_Inventory::invalidate(); | |
| 5183 | + self::record_partial_purge( 'page', $cause, $count ); | |
| 5184 | + self::announce_purge( $cause, $count ); | |
| 1047 | 5185 | return $count; |
| 1048 | 5186 | |
| 1049 | 5187 | case 'assets': |
| 1050 | 5188 | if ( class_exists( '\\XSpeed\\Minifier' ) ) { |
| @@ -1049,27 +5187,235 @@ | ||
| 1049 | 5187 | case 'assets': |
| 1050 | 5188 | if ( class_exists( '\\XSpeed\\Minifier' ) ) { |
| 1051 | 5189 | Minifier::purge_minified(); |
| 1052 | 5190 | } |
| 1053 | - return 0; | |
| 5191 | + // Deleting min/ without clearing the pages that link it left | |
| 5192 | + // every cached page pointing at files that no longer exist. | |
| 5193 | + // WordPress answers the missing asset by 301-ing to its | |
| 5194 | + // pretty-permalink form and serving the 404 TEMPLATE as | |
| 5195 | + // `HTTP 200 text/html`, which the browser accepts as a | |
| 5196 | + // stylesheet and parses to zero rules — no console error, no | |
| 5197 | + // network failure, no 4xx anywhere in devtools. The pages | |
| 5198 | + // stayed broken for the rest of the TTL (7 days on | |
| 5199 | + // Aggressive, up to 30), and the admin who clicked could not | |
| 5200 | + // see it: they are logged in, so their own requests bypass | |
| 5201 | + // the page cache and re-render, regenerating the assets as a | |
| 5202 | + // side effect. Only anonymous visitors were served the stale | |
| 5203 | + // HTML. (#244) | |
| 5204 | + // | |
| 5205 | + // The assets are the pages' dependency, so invalidating them | |
| 5206 | + // invalidates the pages. Same invariant Cache_GC enforces | |
| 5207 | + // with is_referenced(): never leave a cached page pointing at | |
| 5208 | + // an asset that is gone. | |
| 5209 | + $count = self::purge_pages(); | |
| 5210 | + self::update_stats( array( 'last_purge' => time() ) ); | |
| 5211 | + Cache_Inventory::invalidate(); | |
| 5212 | + self::record_partial_purge( 'assets', $cause, $count ); | |
| 5213 | + self::announce_purge( $cause, $count ); | |
| 5214 | + return $count; | |
| 1054 | 5215 | |
| 1055 | 5216 | case 'object': |
| 1056 | 5217 | if ( function_exists( 'wp_cache_flush' ) ) { |
| 1057 | 5218 | wp_cache_flush(); |
| 1058 | 5219 | } |
| 5220 | + self::record_partial_purge( 'object cache', $cause, null ); | |
| 1059 | 5221 | return 0; |
| 1060 | 5222 | |
| 1061 | 5223 | case 'rest': |
| 1062 | - return Rest_Cache::purge(); | |
| 5224 | + $count = Rest_Cache::purge(); | |
| 5225 | + self::record_partial_purge( 'REST responses', $cause, $count ); | |
| 5226 | + self::announce_purge( $cause, $count ); | |
| 5227 | + return $count; | |
| 1063 | 5228 | |
| 1064 | 5229 | default: |
| 1065 | - // Pro / third-party type — let the owning module handle it. | |
| 1066 | - do_action( 'xspeed_purge_type_' . $type ); | |
| 1067 | - return 0; | |
| 5230 | + return self::purge_type_unhandled( $type, $cause ); | |
| 1068 | 5231 | } |
| 1069 | 5232 | } |
| 1070 | 5233 | |
| 1071 | 5234 | /** |
| 5235 | + * Delete this site's cached pages from both the flat and static trees. | |
| 5236 | + * | |
| 5237 | + * Extracted so the `assets` purge can reuse it: minified assets are a | |
| 5238 | + * dependency of the cached HTML, so clearing them must clear the pages | |
| 5239 | + * too or the pages are left referencing deleted files (#244). | |
| 5240 | + * | |
| 5241 | + * @return int Number of page entries removed. | |
| 5242 | + */ | |
| 5243 | + private static function purge_pages(): int { | |
| 5244 | + $count = 0; | |
| 5245 | + // Scoped to this site — see purge_all(). (#6) | |
| 5246 | + $scope = self::current_host_dir(); | |
| 5247 | + $flat_root = XSPEED_CACHE_DIR . '/' . $scope; | |
| 5248 | + if ( is_dir( $flat_root ) ) { | |
| 5249 | + foreach ( (array) glob( $flat_root . '/*.html' ) as $f ) { | |
| 5250 | + wp_delete_file( $f ); | |
| 5251 | + ++$count; | |
| 5252 | + } | |
| 5253 | + foreach ( (array) glob( $flat_root . '/*.meta' ) as $m ) { | |
| 5254 | + wp_delete_file( $m ); | |
| 5255 | + } | |
| 5256 | + foreach ( (array) glob( $flat_root . '/*.br' ) as $b ) { | |
| 5257 | + wp_delete_file( $b ); | |
| 5258 | + } | |
| 5259 | + // `*.br` does not match `*.br.size`; a size record outliving its | |
| 5260 | + // body would later be read against a DIFFERENT sibling's bytes. | |
| 5261 | + foreach ( (array) glob( $flat_root . '/*.br.size' ) as $b ) { | |
| 5262 | + wp_delete_file( $b ); | |
| 5263 | + } | |
| 5264 | + } | |
| 5265 | + $static_root = XSPEED_CACHE_STATIC_DIR . '/' . self::current_static_scope(); | |
| 5266 | + if ( is_dir( $static_root ) ) { | |
| 5267 | + $count += self::rmtree_html( $static_root ); | |
| 5268 | + } | |
| 5269 | + | |
| 5270 | + return $count; | |
| 5271 | + } | |
| 5272 | + | |
| 5273 | + /** | |
| 5274 | + * A purge type this class does not own — a Pro or third-party module | |
| 5275 | + * registered it via the `xspeed_purge_types` filter, so hand it off. | |
| 5276 | + * | |
| 5277 | + * @param string $type Purge-type slug. | |
| 5278 | + * @param string $cause Who asked. | |
| 5279 | + */ | |
| 5280 | + private static function purge_type_unhandled( string $type, string $cause ): int { | |
| 5281 | + $event_sequence = self::$purge_event_sequence; | |
| 5282 | + $hook = 'xspeed_purge_type_' . $type; | |
| 5283 | + $has_handler = false !== has_action( $hook ); | |
| 5284 | + do_action( $hook ); | |
| 5285 | + self::record_partial_purge( $type, $cause, null ); | |
| 5286 | + | |
| 5287 | + // Announce, same as the types this class owns. Pro's "Purge Critical | |
| 5288 | + // CSS" and "Purge Unused CSS" arrive here, and they change what a | |
| 5289 | + // cached page CONTAINS — critical CSS is inlined into the HTML, so a | |
| 5290 | + // server cache goes on serving pages with the old styles baked in. | |
| 5291 | + // Fixing the three Free buttons and leaving these two silent left the | |
| 5292 | + // same hole for the tier most likely to be using both plugins. | |
| 5293 | + // (QA #348 round 2, issue 2) | |
| 5294 | + // | |
| 5295 | + // Unknown slugs must not turn into a site-wide purge merely because no | |
| 5296 | + // handler exists. These are the response-changing Pro types Free knows; | |
| 5297 | + // third parties can declare another through the filter. A registered | |
| 5298 | + // handler plus this explicit response scope is the handled signal. | |
| 5299 | + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none'; | |
| 5300 | + /** | |
| 5301 | + * Declare whether a handled custom purge type changes cached responses. | |
| 5302 | + * | |
| 5303 | + * @since 1.2.3 | |
| 5304 | + * @param string $scope site/network/none. | |
| 5305 | + * @param string $type Purge-type slug. | |
| 5306 | + */ | |
| 5307 | + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type ); | |
| 5308 | + if ( $has_handler | |
| 5309 | + && $event_sequence === self::$purge_event_sequence | |
| 5310 | + && in_array( $scope, array( 'site', 'network' ), true ) | |
| 5311 | + ) { | |
| 5312 | + self::announce_purge( $cause, 0, $scope, 'presentation' ); | |
| 5313 | + } | |
| 5314 | + | |
| 5315 | + return 0; | |
| 5316 | + } | |
| 5317 | + | |
| 5318 | + /** | |
| 5319 | + * Tell the server cache that a PARTIAL purge cleared cached responses. | |
| 5320 | + * | |
| 5321 | + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST | |
| 5322 | + * Cache" each delete cached RESPONSES for the whole site, so a cache in | |
| 5323 | + * front of PHP is now serving copies xSpeed has just thrown away. Only | |
| 5324 | + * "Purge All" announced itself, which left three of the four toolbar | |
| 5325 | + * buttons doing exactly what this contract exists to prevent: clearing | |
| 5326 | + * our copy while the server kept serving the stale one. The `assets` case | |
| 5327 | + * was the sharpest — it deletes the minified bundles too, so LiteSpeed | |
| 5328 | + * went on serving pages whose CSS and JS no longer exist. (QA #348) | |
| 5329 | + * | |
| 5330 | + * Sent as the full-purge shape (`url` null) because that is what happened: | |
| 5331 | + * every cached page for this site went, not one address. `object` is not | |
| 5332 | + * announced — flushing the object cache changes no rendered response a | |
| 5333 | + * server cache could be holding. | |
| 5334 | + * | |
| 5335 | + * Public because Purge_Runner sweeps the local files itself, through | |
| 5336 | + * purge_local(), rather than through purge_all() — so it has to announce | |
| 5337 | + * on its own behalf or `wp xspeed purge` and the dashboard button clear | |
| 5338 | + * our copy while LiteSpeed keeps serving the stale one. | |
| 5339 | + * | |
| 5340 | + * @param string $cause Who asked. | |
| 5341 | + * @param int $removed Entries removed locally. | |
| 5342 | + * @param string $scope Actionable adapter scope. | |
| 5343 | + * @param string $intent Reason rendered responses changed. | |
| 5344 | + */ | |
| 5345 | + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void { | |
| 5346 | + // Announcing is additive: the local sweep has already happened and | |
| 5347 | + // succeeded. Notification must never be able to turn a working purge | |
| 5348 | + // into a fatal, so anything the URL helpers do in an unusual context | |
| 5349 | + // (early boot, a drop-in, a bare test harness) is contained here | |
| 5350 | + // rather than propagating to the caller. | |
| 5351 | + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) { | |
| 5352 | + return; | |
| 5353 | + } | |
| 5354 | + try { | |
| 5355 | + self::dispatch_purge_event( | |
| 5356 | + 'xspeed_after_purge', | |
| 5357 | + array( | |
| 5358 | + 'url' => null, | |
| 5359 | + 'host' => self::current_purge_host(), | |
| 5360 | + 'path' => null, | |
| 5361 | + 'cause' => $cause, | |
| 5362 | + 'removed' => $removed, | |
| 5363 | + 'scope' => $scope, | |
| 5364 | + 'intent' => $intent, | |
| 5365 | + 'urls' => array(), | |
| 5366 | + ) | |
| 5367 | + ); | |
| 5368 | + } catch ( \Throwable $e ) { | |
| 5369 | + self::log_purge_listener_error( 'xspeed_after_purge', $e ); | |
| 5370 | + } | |
| 5371 | + } | |
| 5372 | + | |
| 5373 | + /** | |
| 5374 | + * Log a partial purge so the drill-down behind "Last purge" shows every | |
| 5375 | + * clear, not only the full ones. Without this a site whose object cache | |
| 5376 | + * is flushed on a schedule looks, from the log, like nothing happens. | |
| 5377 | + * | |
| 5378 | + * @param string $what Human label for the slice purged. | |
| 5379 | + * @param string $cause Who asked. | |
| 5380 | + * @param int|null $count Items removed, when countable. | |
| 5381 | + */ | |
| 5382 | + private static function record_partial_purge( string $what, string $cause, ?int $count ): void { | |
| 5383 | + $message = null === $count | |
| 5384 | + ? sprintf( | |
| 5385 | + /* translators: 1: what was purged, 2: cause of the purge. */ | |
| 5386 | + __( 'Purged %1$s (%2$s)', 'xspeed' ), | |
| 5387 | + $what, | |
| 5388 | + $cause | |
| 5389 | + ) | |
| 5390 | + : sprintf( | |
| 5391 | + /* translators: 1: what was purged, 2: cause of the purge, 3: number of files removed. */ | |
| 5392 | + __( 'Purged %1$s (%2$s) — %3$d file(s) removed', 'xspeed' ), | |
| 5393 | + $what, | |
| 5394 | + $cause, | |
| 5395 | + $count | |
| 5396 | + ); | |
| 5397 | + | |
| 5398 | + Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO ); | |
| 5399 | + } | |
| 5400 | + | |
| 5401 | + /** | |
| 5402 | + * Clear the static tree only, leaving the flat cache in place. | |
| 5403 | + * | |
| 5404 | + * A narrower purge_all() for the case where only the web-server tree can | |
| 5405 | + * be wrong: its files are keyed by `{host}{path}` and nothing else, so a | |
| 5406 | + * response filed under the wrong path poisons it while the flat cache — | |
| 5407 | + * keyed by cache_key(), discriminators included — stays correct. Avoids | |
| 5408 | + * throwing away Critical CSS, minified bundles and the object cache to | |
| 5409 | + * fix a static-only problem. | |
| 5410 | + * | |
| 5411 | + * @return int Number of index.html files removed. | |
| 5412 | + */ | |
| 5413 | + public static function purge_static_tree(): int { | |
| 5414 | + return self::rmtree_html( XSPEED_CACHE_STATIC_DIR ); | |
| 5415 | + } | |
| 5416 | + | |
| 5417 | + /** | |
| 1072 | 5418 | * Recursively delete every `index.html` (and its precompressed |
| 1073 | 5419 | * `index.html.br` sibling, if the Pro Brotli module wrote one) plus |
| 1074 | 5420 | * empty directories inside the static-cache tree. Used by purge_all(). |
| 1075 | 5421 | * Returns the number of .html files removed so purge stats stay accurate |
| @@ -1075,8 +5421,24 @@ | ||
| 1075 | 5421 | * Returns the number of .html files removed so purge stats stay accurate |
| 1076 | 5422 | * across the flat + static caches — .br siblings are not counted |
| 1077 | 5423 | * (they're encodings of a page, not pages). |
| 1078 | 5424 | */ |
| 5425 | + /** | |
| 5426 | + * Delete a cache file, adding its size to the current sweep's byte | |
| 5427 | + * total. filesize() is silenced and re-checked because the file can | |
| 5428 | + * vanish between the glob and the unlink — a concurrent purge, or the | |
| 5429 | + * cache GC — and a warning there would be noise, not news. | |
| 5430 | + * | |
| 5431 | + * @param string $file Absolute path inside the cache tree. | |
| 5432 | + */ | |
| 5433 | + private static function sweep_delete( string $file ): void { | |
| 5434 | + $size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the file may be gone already; see docblock. | |
| 5435 | + if ( is_int( $size ) ) { | |
| 5436 | + self::$sweep_bytes += $size; | |
| 5437 | + } | |
| 5438 | + wp_delete_file( $file ); | |
| 5439 | + } | |
| 5440 | + | |
| 1079 | 5441 | private static function rmtree_html( string $dir ): int { |
| 1080 | 5442 | if ( ! is_dir( $dir ) ) { |
| 1081 | 5443 | return 0; |
| 1082 | 5444 | } |
| @@ -1100,14 +5462,16 @@ | ||
| 1100 | 5462 | @rmdir( $path ); |
| 1101 | 5463 | continue; |
| 1102 | 5464 | } |
| 1103 | 5465 | if ( substr( $entry, -5 ) === '.html' ) { |
| 1104 | - wp_delete_file( $path ); | |
| 5466 | + self::sweep_delete( $path ); | |
| 1105 | 5467 | ++$removed; |
| 1106 | - } elseif ( substr( $entry, -3 ) === '.br' ) { | |
| 1107 | - // Precompressed sibling (index.html.br). Remove it too so a | |
| 1108 | - // purge doesn't orphan stale Brotli bodies. Not counted. | |
| 1109 | - wp_delete_file( $path ); | |
| 5468 | + } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) { | |
| 5469 | + // Precompressed sibling (index.html.br) and the record of its | |
| 5470 | + // length. Remove both so a purge doesn't orphan stale Brotli | |
| 5471 | + // bodies, or a size record that would later be read against a | |
| 5472 | + // different sibling's bytes. Not counted. | |
| 5473 | + self::sweep_delete( $path ); | |
| 1110 | 5474 | } |
| 1111 | 5475 | } |
| 1112 | 5476 | return $removed; |
| 1113 | 5477 | } |
| @@ -1124,25 +5488,42 @@ | ||
| 1124 | 5488 | } |
| 1125 | 5489 | } |
| 1126 | 5490 | |
| 1127 | 5491 | /** |
| 5492 | + * The raw xspeed_stats option as an array. Keys currently in use: | |
| 5493 | + * 'last_purge', 'last_gc', 'gc_removed', 'gc_removed_total'. | |
| 5494 | + */ | |
| 5495 | + public static function get_stats_option(): array { | |
| 5496 | + $stats = get_option( 'xspeed_stats', array() ); | |
| 5497 | + return is_array( $stats ) ? $stats : array(); | |
| 5498 | + } | |
| 5499 | + | |
| 5500 | + /** | |
| 1128 | 5501 | * Persist stats with autoload disabled — stats are only read in admin |
| 1129 | 5502 | * contexts, so there is no reason to inflate every frontend request's |
| 1130 | 5503 | * `wp_load_alloptions()` payload. |
| 5504 | + * | |
| 5505 | + * MERGES into whatever is already stored. It used to overwrite, which | |
| 5506 | + * was harmless while `last_purge` was the only key — with the GC keys | |
| 5507 | + * alongside it, a purge would have wiped the GC history and vice versa. | |
| 1131 | 5508 | */ |
| 1132 | - private static function update_stats( array $stats ) { | |
| 1133 | - if ( false === get_option( 'xspeed_stats' ) ) { | |
| 5509 | + public static function update_stats( array $stats ) { | |
| 5510 | + if ( false === get_option( 'xspeed_stats', false ) ) { | |
| 1134 | 5511 | add_option( 'xspeed_stats', $stats, '', 'no' ); |
| 1135 | 5512 | return; |
| 1136 | 5513 | } |
| 1137 | - update_option( 'xspeed_stats', $stats ); | |
| 5514 | + update_option( 'xspeed_stats', array_merge( self::get_stats_option(), $stats ) ); | |
| 1138 | 5515 | } |
| 1139 | 5516 | |
| 1140 | 5517 | public static function get_stats() { |
| 1141 | 5518 | $count = 0; |
| 1142 | 5519 | $size = 0; |
| 1143 | - if ( is_dir( XSPEED_CACHE_DIR ) ) { | |
| 1144 | - $files = glob( XSPEED_CACHE_DIR . '/*.html' ); | |
| 5520 | + // This site's entries only — on multisite the tree is shared, so an | |
| 5521 | + // unscoped count reported the whole network's pages on every | |
| 5522 | + // subsite's dashboard. (#6) | |
| 5523 | + $flat_root = XSPEED_CACHE_DIR . '/' . self::current_host_dir(); | |
| 5524 | + if ( is_dir( $flat_root ) ) { | |
| 5525 | + $files = glob( $flat_root . '/*.html' ); | |
| 1145 | 5526 | if ( $files ) { |
| 1146 | 5527 | $count = count( $files ); |
| 1147 | 5528 | foreach ( $files as $f ) { |
| 1148 | 5529 | $size += filesize( $f ); |
| @@ -1165,8 +5546,12 @@ | ||
| 1165 | 5546 | Hit_Counter::collect_server_log_hits(); |
| 1166 | 5547 | |
| 1167 | 5548 | $stats = get_option( 'xspeed_stats', array() ); |
| 1168 | 5549 | $totals = Hit_Counter::totals_24h(); |
| 5550 | + // One read of the ground truth for both fields below: it costs a | |
| 5551 | + // stat of advanced-cache.php and a tokenize of wp-config.php, and | |
| 5552 | + // this runs on every dashboard poll. | |
| 5553 | + $serving = self::page_cache_operational(); | |
| 1169 | 5554 | return array( |
| 1170 | 5555 | 'cached_pages' => $count, |
| 1171 | 5556 | 'cache_size' => $size, |
| 1172 | 5557 | 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0, |
| @@ -1175,21 +5560,141 @@ | ||
| 1175 | 5560 | // CacheHero stat grid + the Health module's panel. |
| 1176 | 5561 | 'hits_24h' => $totals['hits'], |
| 1177 | 5562 | 'misses_24h' => $totals['misses'], |
| 1178 | 5563 | 'hit_ratio' => $totals['ratio'], |
| 5564 | + // Requests kept OUT of the ratio (404s + bots) — surfaced as its own | |
| 5565 | + // "absorbed N scanner/bot requests" line rather than distorting the | |
| 5566 | + // cache-performance number. (#118) | |
| 5567 | + 'excluded_24h' => $totals['excluded'], | |
| 5568 | + // True when an edge cache (Cloudflare) fronts the origin, so hits are | |
| 5569 | + // absorbed before reaching PHP. The dashboard labels the ratio | |
| 5570 | + // "origin-layer only" instead of implying it's the full picture. (#118) | |
| 5571 | + 'edge_cache' => self::edge_cache_detected(), | |
| 5572 | + // LiteSpeed Static Fast Path (#509): the web server serves hits | |
| 5573 | + // with no PHP, no way to tag them, and no way to count them. The | |
| 5574 | + // dashboard labels the ratio as PHP-layer only so a low number | |
| 5575 | + // reads as the trade the user chose, not a fault. | |
| 5576 | + // | |
| 5577 | + // rewrite_installed() is part of the condition (QA on #513): when | |
| 5578 | + // the .htaccess write failed (read-only file), hits still take | |
| 5579 | + // the drop-in path and ARE counted — the disclosure would be the | |
| 5580 | + // opposite of the truth. Health carries the "block missing" | |
| 5581 | + // warning for that state; this flag only speaks when static | |
| 5582 | + // serving is genuinely in effect. | |
| 5583 | + 'static_hits_uncounted' => ( | |
| 5584 | + Server::LITESPEED === Server::type() | |
| 5585 | + && ! empty( Settings::get()['cache_enabled'] ) | |
| 5586 | + && self::static_rewrite_allowed() | |
| 5587 | + && self::rewrite_installed() | |
| 5588 | + ), | |
| 5589 | + /* | |
| 5590 | + * Whether the page cache is actually SERVING, as opposed to | |
| 5591 | + * switched on in settings. The hero read the setting alone and | |
| 5592 | + * announced "Active — serving cached HTML"; a site whose | |
| 5593 | + * advanced-cache.php had been taken over by another cache plugin | |
| 5594 | + * got that line while every response carried | |
| 5595 | + * `X-XSpeed-Cache: BYPASS`. The setting is the user's intent; | |
| 5596 | + * this is the outcome, and the dashboard needs both to explain | |
| 5597 | + * the difference. | |
| 5598 | + */ | |
| 5599 | + 'page_cache_serving' => $serving, | |
| 5600 | + /* | |
| 5601 | + * Why not, when intent and outcome disagree. Only computed in | |
| 5602 | + * that state — the detector sweep behind it is far more work than | |
| 5603 | + * a stats call should do on an ordinary healthy site. | |
| 5604 | + */ | |
| 5605 | + 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) ) | |
| 5606 | + ? ( self::acquisition_blocker() ?? self::not_serving_reason() ) | |
| 5607 | + : null, | |
| 1179 | 5608 | ); |
| 1180 | 5609 | } |
| 1181 | 5610 | |
| 1182 | 5611 | /** |
| 1183 | - * Apply the user's enable/disable choice. Called only from the REST | |
| 1184 | - * toggle endpoint, which is gated by current_user_can( 'manage_options' ) | |
| 1185 | - * and a verified REST nonce. This is the only place the drop-in and | |
| 1186 | - * the WP_CACHE constant are written — they MUST NOT happen on | |
| 1187 | - * register_activation_hook (WordPress.org review requirement). | |
| 5612 | + * Why the cache is not serving, when nothing REFUSES to enable it. | |
| 1188 | 5613 | * |
| 5614 | + * acquisition_blocker() answers "may we take the field", and since a | |
| 5615 | + * foreign drop-in became takeable it answers null on a site where another | |
| 5616 | + * plugin is nonetheless holding that file. Intent and outcome still | |
| 5617 | + * disagree there, and the dashboard was left reporting the symptom -- not | |
| 5618 | + * serving -- with no reason under it, which is exactly the state a user | |
| 5619 | + * cannot act on. | |
| 5620 | + * | |
| 5621 | + * So this names the holder and says what to do: enabling takes it over. | |
| 5622 | + */ | |
| 5623 | + private static function not_serving_reason(): ?string { | |
| 5624 | + $owner = self::dropin_owner(); | |
| 5625 | + if ( self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner ) { | |
| 5626 | + return null; | |
| 5627 | + } | |
| 5628 | + | |
| 5629 | + if ( self::DROPIN_UNREADABLE === $owner ) { | |
| 5630 | + return __( 'advanced-cache.php cannot be read, so xSpeed cannot tell whose page cache is installed.', 'xspeed' ); | |
| 5631 | + } | |
| 5632 | + | |
| 5633 | + $label = Page_Cache_Detector::dropin_owner_label(); | |
| 5634 | + return $label | |
| 5635 | + ? sprintf( | |
| 5636 | + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */ | |
| 5637 | + __( '%s is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ), | |
| 5638 | + $label | |
| 5639 | + ) | |
| 5640 | + : __( 'Another plugin is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ); | |
| 5641 | + } | |
| 5642 | + | |
| 5643 | + /** | |
| 5644 | + * Whether the current request should be kept OUT of the cache hit/miss | |
| 5645 | + * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect | |
| 5646 | + * time, so is_404() is resolved. (#118) | |
| 5647 | + */ | |
| 5648 | + private static function miss_is_excluded(): bool { | |
| 5649 | + if ( function_exists( 'is_404' ) && is_404() ) { | |
| 5650 | + return true; | |
| 5651 | + } | |
| 5652 | + $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) | |
| 5653 | + ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) ) | |
| 5654 | + : ''; | |
| 5655 | + return Hit_Counter::is_bot_ua( $ua ); | |
| 5656 | + } | |
| 5657 | + | |
| 5658 | + /** | |
| 5659 | + * Whether an edge cache fronts this origin, so an unknown share of hits | |
| 5660 | + * is served there and never counted here — which makes the origin ratio a | |
| 5661 | + * partial view the dashboard has to label as such. (#118) | |
| 5662 | + * | |
| 5663 | + * This used to mean "the Cloudflare module is switched on", which answered | |
| 5664 | + * no for every site fronted by anything else, and no for a site on | |
| 5665 | + * Cloudflare that had never opened our Cloudflare panel. Both of those | |
| 5666 | + * sites had their ratio presented as the whole story. Edge_Provider knows | |
| 5667 | + * better and knows it per request, so ask it. | |
| 5668 | + */ | |
| 5669 | + private static function edge_cache_detected(): bool { | |
| 5670 | + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence']; | |
| 5671 | + } | |
| 5672 | + | |
| 5673 | + /** | |
| 5674 | + * Apply the user's enable/disable choice. Called from the REST toggle | |
| 5675 | + * endpoint, which is gated by current_user_can( 'manage_options' ) and | |
| 5676 | + * a verified REST nonce. | |
| 5677 | + * | |
| 5678 | + * This is the only path that ENABLES caching — a drop-in is never | |
| 5679 | + * created for a user who hasn't opted in, which is the guideline that | |
| 5680 | + * matters (a plugin must not install drop-ins or edit wp-config.php | |
| 5681 | + * on a fresh activation). RESTORING the drop-in for a site that | |
| 5682 | + * already has cache_enabled = true is a different act and is handled | |
| 5683 | + * by restore_dropin_if_enabled() on activation and auto_heal() at | |
| 5684 | + * runtime; without it every plugin update silently un-caches the site. | |
| 5685 | + * | |
| 5686 | + * Enabling is gated on acquisition_blocker(): if another plugin owns the | |
| 5687 | + * drop-in, or WP_CACHE is written in a form we must not rewrite, nothing | |
| 5688 | + * is written and the returned state carries `blocked` + a reason the | |
| 5689 | + * caller can show. Callers must persist `cache_enabled` from the returned | |
| 5690 | + * `enabled`, never from what they asked for. | |
| 5691 | + * | |
| 1189 | 5692 | * @param bool $enable User's choice. |
| 1190 | 5693 | * @return array{ |
| 1191 | 5694 | * enabled: bool, |
| 5695 | + * blocked: bool, | |
| 5696 | + * blocked_reason: ?string, | |
| 1192 | 5697 | * dropin_installed: bool, |
| 1193 | 5698 | * wp_cache_constant: bool, |
| 1194 | 5699 | * wp_config_writable: bool, |
| 1195 | 5700 | * manual_snippet: ?string |
| @@ -1194,29 +5699,228 @@ | ||
| 1194 | 5699 | * wp_config_writable: bool, |
| 1195 | 5700 | * manual_snippet: ?string |
| 1196 | 5701 | * } |
| 1197 | 5702 | */ |
| 1198 | - public static function toggle( $enable ) { | |
| 5703 | + public static function toggle( $enable, bool $consented = true ) { | |
| 5704 | + Page_Cache_Detector::invalidate(); | |
| 5705 | + $expected = Page_Cache_Detector::inspect()['revision']; | |
| 5706 | + /** Diagnostic seam; changing the expected revision can only force a safe refusal. */ | |
| 5707 | + $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected ); | |
| 5708 | + $lock = self::page_cache_lock(); | |
| 5709 | + if ( ! is_resource( $lock ) ) { | |
| 5710 | + return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) ); | |
| 5711 | + } | |
| 5712 | + try { | |
| 5713 | + Page_Cache_Detector::invalidate(); | |
| 5714 | + $fresh = Page_Cache_Detector::inspect()['revision']; | |
| 5715 | + if ( ! hash_equals( (string) $expected, (string) $fresh ) ) { | |
| 5716 | + return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) ); | |
| 5717 | + } | |
| 5718 | + $state = self::toggle_unlocked( (bool) $enable, $consented ); | |
| 5719 | + return $state; | |
| 5720 | + } finally { | |
| 5721 | + flock( $lock, LOCK_UN ); | |
| 5722 | + fclose( $lock ); | |
| 5723 | + } | |
| 5724 | + } | |
| 5725 | + | |
| 5726 | + /** Run the page-cache mutation while toggle() owns the scoped lock. */ | |
| 5727 | + /** | |
| 5728 | + * @param bool $consented The user asked for this in the dashboard, so a | |
| 5729 | + * foreign drop-in may be taken over. False on the | |
| 5730 | + * unattended paths, which stand down instead. | |
| 5731 | + */ | |
| 5732 | + private static function toggle_unlocked( bool $enable, bool $consented = true ) { | |
| 1199 | 5733 | $enable = (bool) $enable; |
| 1200 | 5734 | |
| 1201 | 5735 | if ( $enable ) { |
| 1202 | - $dropin_ok = self::install_dropin(); | |
| 1203 | - $wp_config_ok = self::set_wp_cache_constant( true ); | |
| 5736 | + /* | |
| 5737 | + * Preflight. The drop-in and the WP_CACHE define are shared, | |
| 5738 | + * single-occupancy state; if we do not own them, no part of this | |
| 5739 | + * runs — not the drop-in, not wp-config.php, not the rewrite | |
| 5740 | + * block. Refusing whole is the point: a partial enable leaves the | |
| 5741 | + * site claiming a cache it cannot serve. | |
| 5742 | + * | |
| 5743 | + * Every caller routes through here (REST, onboarding, MCP, CLI, | |
| 5744 | + * the optimize runner, Pro's migration), so the gate lives here | |
| 5745 | + * rather than being re-implemented at each entry point. | |
| 5746 | + * | |
| 5747 | + * Except when there is nothing to acquire. A site where we | |
| 5748 | + * already own the drop-in and are already serving is being asked | |
| 5749 | + * to stay as it is, and the gate answers a different question — | |
| 5750 | + * "is the field free to take" — which a merely ACTIVE competitor | |
| 5751 | + * makes false. So "make sure caching is on", from an AI agent, | |
| 5752 | + * the optimize runner or Pro's migration, came back as a refusal | |
| 5753 | + * telling the user to deactivate a plugin on a site that was | |
| 5754 | + * caching perfectly. The dashboard never saw it, because nobody | |
| 5755 | + * presses Enable on a cache that is already enabled. | |
| 5756 | + * | |
| 5757 | + * Only the GATE is skipped. The writes below still run, and every | |
| 5758 | + * one of them is individually idempotent — which matters, because | |
| 5759 | + * this is the path CacheModule re-bakes the drop-in through when | |
| 5760 | + * an exclusion rule or the TTL changes (#240, #251), and the path | |
| 5761 | + * auto_heal() restores a stripped WP_CACHE through. Returning | |
| 5762 | + * early here left both of those doing nothing at all, silently, | |
| 5763 | + * on exactly the healthy sites this branch is about. | |
| 5764 | + */ | |
| 5765 | + $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner(); | |
| 5766 | + $blocker = $reasserting ? null : self::acquisition_blocker(); | |
| 5767 | + | |
| 5768 | + /* | |
| 5769 | + * Taking over another plugin's drop-in needs the user to have | |
| 5770 | + * asked for it. On the dashboard they did -- they clicked the | |
| 5771 | + * switch, having been told whose file it is. The UNATTENDED | |
| 5772 | + * callers have no such click: restore_dropin_if_enabled() runs | |
| 5773 | + * after a plugin update and auto_heal() on an admin page load, | |
| 5774 | + * both from nothing more than `cache_enabled` still being true. | |
| 5775 | + * | |
| 5776 | + * A competitor installed since that flag was set would have its | |
| 5777 | + * page cache seized by a background repair, which is the silent | |
| 5778 | + * acquisition this plugin refuses to perform. So those callers | |
| 5779 | + * pass $consented = false and stand down instead. | |
| 5780 | + */ | |
| 5781 | + if ( null === $blocker && ! $consented && self::DROPIN_FOREIGN === self::dropin_owner() ) { | |
| 5782 | + // Name the owner. This string is rendered by host plugins | |
| 5783 | + // through Host::enable_page_cache(), and an unnamed refusal | |
| 5784 | + // is what made every host invent its own explanation. | |
| 5785 | + $owner_label = Page_Cache_Detector::dropin_owner_label(); | |
| 5786 | + return self::blocked_toggle_state( | |
| 5787 | + $owner_label | |
| 5788 | + ? sprintf( | |
| 5789 | + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */ | |
| 5790 | + __( '%s owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ), | |
| 5791 | + $owner_label | |
| 5792 | + ) | |
| 5793 | + : __( 'Another plugin owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ) | |
| 5794 | + ); | |
| 5795 | + } | |
| 5796 | + if ( null !== $blocker ) { | |
| 5797 | + Activity_Log::record( | |
| 5798 | + 'cache_enable_blocked', | |
| 5799 | + 'Cache not enabled — ' . $blocker, | |
| 5800 | + Activity_Log::WARN | |
| 5801 | + ); | |
| 5802 | + | |
| 5803 | + return self::blocked_toggle_state( $blocker ); | |
| 5804 | + } | |
| 5805 | + | |
| 5806 | + $dropin_path = WP_CONTENT_DIR . '/advanced-cache.php'; | |
| 5807 | + $config_path = self::wp_config_path(); | |
| 5808 | + $dropin_before = file_exists( $dropin_path ) ? self::read_file( $dropin_path ) : null; | |
| 5809 | + $config_before = '' !== $config_path ? self::read_file( $config_path ) : null; | |
| 5810 | + $dropin_ok = self::install_dropin(); | |
| 5811 | + if ( ! $dropin_ok ) { | |
| 5812 | + $partial = self::read_file( $dropin_path ); | |
| 5813 | + if ( is_string( $partial ) && xspeed_has_canonical_dropin_signature( $partial ) ) { | |
| 5814 | + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $partial, $config_path, $config_before, null ); | |
| 5815 | + } | |
| 5816 | + /* | |
| 5817 | + * Preflight said the field was clear, so this is a filesystem | |
| 5818 | + * failure (or a drop-in that appeared in between). Without the | |
| 5819 | + * drop-in there is no cache to enable, and persisting | |
| 5820 | + * cache_enabled anyway is what produced sites reporting a | |
| 5821 | + * healthy cache while serving every request uncached. | |
| 5822 | + */ | |
| 5823 | + $reason = __( 'Could not write wp-content/advanced-cache.php. Check filesystem permissions.', 'xspeed' ); | |
| 5824 | + Activity_Log::record( | |
| 5825 | + 'cache_enable_blocked', | |
| 5826 | + 'Cache not enabled — ' . $reason, | |
| 5827 | + Activity_Log::WARN | |
| 5828 | + ); | |
| 5829 | + | |
| 5830 | + return array( | |
| 5831 | + 'enabled' => false, | |
| 5832 | + 'blocked' => true, | |
| 5833 | + 'blocked_reason' => $reason, | |
| 5834 | + 'dropin_installed' => false, | |
| 5835 | + 'wp_cache_constant' => false, | |
| 5836 | + 'rewrite_installed' => false, | |
| 5837 | + 'wp_config_writable' => self::wp_config_writable(), | |
| 5838 | + 'manual_snippet' => null, | |
| 5839 | + 'nginx_snippet' => self::nginx_snippet(), | |
| 5840 | + 'nginx_server_block' => self::full_nginx_server_block(), | |
| 5841 | + ); | |
| 5842 | + } | |
| 5843 | + | |
| 5844 | + $dropin_written = self::read_file( $dropin_path ); | |
| 5845 | + self::set_wp_cache_constant( true ); | |
| 5846 | + $config_written = '' !== $config_path ? self::read_file( $config_path ) : null; | |
| 5847 | + Page_Cache_Detector::invalidate(); | |
| 5848 | + $dropin_ours = self::DROPIN_XSPEED === self::dropin_owner(); | |
| 5849 | + $constant_state = self::wp_cache_define_state(); | |
| 5850 | + $constant_ok = 'true' === $constant_state; | |
| 5851 | + | |
| 5852 | + /* | |
| 5853 | + * A wp-config.php we cannot write at all is a supported state, not | |
| 5854 | + * a failed transaction. Plenty of managed hosts ship the file | |
| 5855 | + * read-only; there the drop-in is ours and installed, the cache | |
| 5856 | + * works the moment WP_CACHE exists, and the one line to paste | |
| 5857 | + * comes back as `manual_snippet`. Rolling back instead left those | |
| 5858 | + * hosts unable to turn the page cache on by any route — including | |
| 5859 | + * when the user had already pasted the define, since the write | |
| 5860 | + * fails on an unwritable file whatever value is already there. | |
| 5861 | + * | |
| 5862 | + * `undefined` ONLY. `false` looks eligible — this method would | |
| 5863 | + * have rewritten it — but the snippet we hand back cannot work | |
| 5864 | + * there: the file already says `define( 'WP_CACHE', false )`, the | |
| 5865 | + * first define() call wins, and a user who pastes our line via | |
| 5866 | + * FTP ends up with a cache that never serves AND a `duplicate` | |
| 5867 | + * wp-config that blocks every future toggle in both directions. | |
| 5868 | + * They have to edit the existing line, which means refusing here | |
| 5869 | + * and saying so. `duplicate` and `dynamic` are refused by | |
| 5870 | + * acquisition_blocker() before we get here, and if one appears in | |
| 5871 | + * the race window it must still fail closed. | |
| 5872 | + */ | |
| 5873 | + $manual_mode = ! $constant_ok | |
| 5874 | + && 'undefined' === $constant_state | |
| 5875 | + && ! self::can_write_wp_config(); | |
| 5876 | + | |
| 5877 | + if ( ! $dropin_ours || ( ! $constant_ok && ! $manual_mode ) ) { | |
| 5878 | + if ( ! self::can_write_wp_config() ) { | |
| 5879 | + $reason = 'false' === $constant_state | |
| 5880 | + ? __( "wp-config.php is not writable and already contains define( 'WP_CACHE', false ). Change that line to true — adding a second one would leave the cache off and block xSpeed from changing it again.", 'xspeed' ) | |
| 5881 | + : __( 'xSpeed could not verify the complete page-cache write, and wp-config.php is not writable. Its changes were rolled back.', 'xspeed' ); | |
| 5882 | + } else { | |
| 5883 | + $reason = __( 'xSpeed could not verify the complete page-cache write. Its changes were rolled back.', 'xspeed' ); | |
| 5884 | + } | |
| 5885 | + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written ); | |
| 5886 | + return self::blocked_toggle_state( $reason ); | |
| 5887 | + } | |
| 5888 | + $wp_config_ok = $constant_ok; | |
| 1204 | 5889 | $rewrite_ok = self::install_rewrite(); |
| 1205 | 5890 | self::ensure_hits_log_file(); |
| 1206 | 5891 | self::sync_mobile_flag(); |
| 1207 | 5892 | $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );"; |
| 5893 | + Settings::update( array( 'cache_enabled' => true ) ); | |
| 5894 | + if ( empty( Settings::get()['cache_enabled'] ) ) { | |
| 5895 | + self::remove_rewrite(); | |
| 5896 | + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written ); | |
| 5897 | + delete_option( 'xspeed_page_cache_ownership_receipt' ); | |
| 5898 | + return self::blocked_toggle_state( __( 'xSpeed could not save the page-cache setting. Its file changes were rolled back.', 'xspeed' ) ); | |
| 5899 | + } | |
| 1208 | 5900 | |
| 1209 | - Activity_Log::record( | |
| 1210 | - 'cache_enabled_event', | |
| 1211 | - $wp_config_ok | |
| 1212 | - ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.' | |
| 1213 | - : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.', | |
| 1214 | - $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN | |
| 1215 | - ); | |
| 5901 | + /* | |
| 5902 | + * Only when this call actually changed something. auto_heal() runs | |
| 5903 | + * the enable transaction on every admin_init, and an unconditional | |
| 5904 | + * entry filled the 50-slot log with identical "Cache enabled" lines | |
| 5905 | + * within 50 wp-admin page loads, evicting every real event — plus | |
| 5906 | + * an option write per admin request. The sentence is also false | |
| 5907 | + * when nothing was installed. | |
| 5908 | + */ | |
| 5909 | + if ( $dropin_written !== $dropin_before || $config_written !== $config_before ) { | |
| 5910 | + Activity_Log::record( | |
| 5911 | + 'cache_enabled_event', | |
| 5912 | + $wp_config_ok | |
| 5913 | + ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.' | |
| 5914 | + : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.', | |
| 5915 | + $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN | |
| 5916 | + ); | |
| 5917 | + } | |
| 1216 | 5918 | |
| 1217 | 5919 | return array( |
| 1218 | 5920 | 'enabled' => true, |
| 5921 | + 'blocked' => false, | |
| 5922 | + 'blocked_reason' => null, | |
| 1219 | 5923 | 'dropin_installed' => (bool) $dropin_ok, |
| 1220 | 5924 | 'wp_cache_constant' => (bool) $wp_config_ok, |
| 1221 | 5925 | 'rewrite_installed' => (bool) $rewrite_ok, |
| 1222 | 5926 | 'wp_config_writable' => self::wp_config_writable(), |
| @@ -1229,26 +5933,168 @@ | ||
| 1229 | 5933 | 'nginx_server_block' => self::full_nginx_server_block(), |
| 1230 | 5934 | ); |
| 1231 | 5935 | } |
| 1232 | 5936 | |
| 5937 | + /* | |
| 5938 | + * Whose advanced-cache.php is on disk decides how much of the disable | |
| 5939 | + * below may run. Read it once, before anything is touched. | |
| 5940 | + */ | |
| 5941 | + $owner = self::dropin_owner(); | |
| 5942 | + $not_ours = self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner; | |
| 5943 | + if ( ! self::set_wp_cache_constant( false ) ) { | |
| 5944 | + /* | |
| 5945 | + * The mirror of the enable path. A wp-config.php nobody can write | |
| 5946 | + * does not trap the user in a cache they turned off: WP_CACHE on | |
| 5947 | + * its own does nothing once advanced-cache.php is gone, and core | |
| 5948 | + * simply skips the missing drop-in. Refusing here left the | |
| 5949 | + * read-only managed hosts able to enable the page cache and never | |
| 5950 | + * able to disable it again. | |
| 5951 | + * | |
| 5952 | + * A drop-in that is not ours reaches the same conclusion by a | |
| 5953 | + * different road. WP_CACHE is then the switch for THEIR cache, so | |
| 5954 | + * set_wp_cache_constant() refuses it — correctly, and permanently, | |
| 5955 | + * because nothing the user does to xSpeed will make that file ours | |
| 5956 | + * again. Treating that refusal as a failed disable was a trap with | |
| 5957 | + * no exit: install any competing cache plugin while xSpeed's cache | |
| 5958 | + * was on, and xSpeed's toggle could never be turned off again, | |
| 5959 | + * while the dashboard went on claiming a cache that was serving | |
| 5960 | + * nothing. Turning xSpeed off is entirely within our own state — | |
| 5961 | + * our setting, our rewrite block — so it proceeds, and their | |
| 5962 | + * constant and their file are left exactly as they are. | |
| 5963 | + */ | |
| 5964 | + /* | |
| 5965 | + * Every reason set_wp_cache_constant() refuses is structural | |
| 5966 | + * except one, and the exception is the only one worth blocking | |
| 5967 | + * on. It will not touch a constant it cannot prove is ours; it | |
| 5968 | + * will not rewrite a define it cannot read as a literal — | |
| 5969 | + * duplicate, dynamic, or inside a conditional; and it cannot | |
| 5970 | + * write a file the filesystem will not let it write. None of | |
| 5971 | + * those improve on a retry, and all of them leave a WP_CACHE | |
| 5972 | + * that does nothing once our drop-in is gone. What is left — our | |
| 5973 | + * own constant, in a shape we can rewrite, in a file we can | |
| 5974 | + * write, and the write still failed — is a real I/O failure, and | |
| 5975 | + * that one still refuses so the user is not told a cache was | |
| 5976 | + * turned off while it goes on serving. | |
| 5977 | + * | |
| 5978 | + * The proof, not the drop-in, is the test. A user who pasted our | |
| 5979 | + * manual snippet on a locked-down host has a WP_CACHE line with | |
| 5980 | + * no receipt on it; if their drop-in later goes missing, we can | |
| 5981 | + * never prove that line is ours, so refusing left the toggle | |
| 5982 | + * stuck on with no way out but enabling first and disabling | |
| 5983 | + * again. Nothing loads a drop-in that is not there, so the line | |
| 5984 | + * is inert either way and the disable proceeds without it. | |
| 5985 | + */ | |
| 5986 | + $leave_it = ! self::wp_cache_define_is_ours_to_remove( $owner ) | |
| 5987 | + || ! in_array( self::wp_cache_define_state(), array( 'true', 'false', 'undefined' ), true ) | |
| 5988 | + || ! self::can_write_wp_config(); | |
| 5989 | + if ( ! $leave_it ) { | |
| 5990 | + return self::blocked_toggle_state( __( 'xSpeed could not safely remove its WP_CACHE setting. The cache remains enabled.', 'xspeed' ) ); | |
| 5991 | + } | |
| 5992 | + } | |
| 1233 | 5993 | self::remove_dropin(); |
| 1234 | - self::set_wp_cache_constant( false ); | |
| 5994 | + if ( self::DROPIN_XSPEED === self::dropin_owner() ) { | |
| 5995 | + // Put WP_CACHE back, and say so if we could not. Reporting a | |
| 5996 | + // hardcoded `enabled: true` here claimed a working cache on a | |
| 5997 | + // site whose constant we had just failed to restore. | |
| 5998 | + // Put WP_CACHE back, then read the outcome off disk rather than | |
| 5999 | + // trusting the write's return value — a write can report failure | |
| 6000 | + // for a value that was already correct, and the question the | |
| 6001 | + // caller needs answered is whether the cache serves. | |
| 6002 | + self::set_wp_cache_constant( true ); | |
| 6003 | + return self::blocked_toggle_state( | |
| 6004 | + self::page_cache_operational() | |
| 6005 | + ? __( 'xSpeed could not remove its page-cache drop-in. The cache remains enabled.', 'xspeed' ) | |
| 6006 | + : __( 'xSpeed could not remove its page-cache drop-in, and could not put WP_CACHE back. The cache is not serving; check wp-config.php before changing the page cache again.', 'xspeed' ) | |
| 6007 | + ); | |
| 6008 | + } | |
| 1235 | 6009 | self::remove_rewrite(); |
| 6010 | + /* | |
| 6011 | + * The .htaccess block serves cached HTML straight off disk without | |
| 6012 | + * ever reaching PHP, so a block we failed to remove keeps answering | |
| 6013 | + * requests from a cache the user just turned off — and nothing else | |
| 6014 | + * in this method can stop it. remove_rewrite() also returns false | |
| 6015 | + * when there is no .htaccess to clean, which is the ordinary case, | |
| 6016 | + * so ask the file rather than trust the return value. | |
| 6017 | + */ | |
| 6018 | + if ( self::rewrite_installed() ) { | |
| 6019 | + if ( $not_ours ) { | |
| 6020 | + // Nothing to roll back — under a foreign drop-in this method | |
| 6021 | + // removed no drop-in and wrote no constant, and it could not | |
| 6022 | + // put either back if it wanted to. Say what is actually left. | |
| 6023 | + return self::blocked_toggle_state( __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. Remove the xSpeed block from .htaccess by hand before turning the page cache off.', 'xspeed' ) ); | |
| 6024 | + } | |
| 6025 | + /* | |
| 6026 | + * Roll the disable back. Both calls can fail — a filesystem that | |
| 6027 | + * would not let us remove the block may not let us write the | |
| 6028 | + * drop-in either — and discarding their results reported an | |
| 6029 | + * enabled cache over a site left with no drop-in and no | |
| 6030 | + * constant. Fall through to the default state so the artifact | |
| 6031 | + * fields are read from disk rather than asserted. | |
| 6032 | + */ | |
| 6033 | + self::install_dropin(); | |
| 6034 | + self::set_wp_cache_constant( true ); | |
| 6035 | + // Both of those can fail — a filesystem that would not let us | |
| 6036 | + // remove the block may not let us write the drop-in either — so | |
| 6037 | + // the message follows what is on disk afterwards, not what the | |
| 6038 | + // calls returned. | |
| 6039 | + return self::blocked_toggle_state( | |
| 6040 | + self::page_cache_operational() | |
| 6041 | + ? __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. The cache remains enabled.', 'xspeed' ) | |
| 6042 | + : __( 'xSpeed could not remove its rewrite rules from .htaccess, and could not restore the drop-in it had just removed. The cache is not serving, and the site may still return stale cached pages until the xSpeed block is removed from .htaccess by hand.', 'xspeed' ) | |
| 6043 | + ); | |
| 6044 | + } | |
| 1236 | 6045 | // Drop the device-bucket marker too — with the drop-in gone there's |
| 1237 | 6046 | // nothing left to read it, and leaving it behind would dirty a fresh |
| 1238 | 6047 | // re-enable (and leaks across test runs). |
| 1239 | 6048 | self::sync_mobile_flag( false ); |
| 6049 | + Settings::update( array( 'cache_enabled' => false ) ); | |
| 6050 | + if ( ! empty( Settings::get()['cache_enabled'] ) ) { | |
| 6051 | + if ( $not_ours ) { | |
| 6052 | + // Same as above: there is nothing of ours on disk to restore. | |
| 6053 | + return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state.', 'xspeed' ) ); | |
| 6054 | + } | |
| 6055 | + self::install_dropin(); | |
| 6056 | + self::set_wp_cache_constant( true ); | |
| 6057 | + return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state. The page cache was restored.', 'xspeed' ) ); | |
| 6058 | + } | |
| 1240 | 6059 | |
| 6060 | + // A WP_CACHE we could not remove because wp-config.php is read-only | |
| 6061 | + // is left behind deliberately (see above) — say so rather than | |
| 6062 | + // reporting a constant that is still in the file as gone. | |
| 6063 | + $constant_left = 'true' === self::wp_cache_define_state(); | |
| 6064 | + /* | |
| 6065 | + * Say why the constant is still there, because there are now three | |
| 6066 | + * different reasons and they call for different advice. Keyed off the | |
| 6067 | + * same facts $leave_it was, so the log cannot drift from the decision | |
| 6068 | + * it is describing — it did, briefly, and reported a wp-config.php as | |
| 6069 | + * unwritable when the real reason was that we could not prove the | |
| 6070 | + * line was ours. | |
| 6071 | + */ | |
| 6072 | + if ( self::DROPIN_UNREADABLE === $owner ) { | |
| 6073 | + $log_message = 'Cache disabled. advanced-cache.php could not be read, so it and the WP_CACHE setting were left untouched.'; | |
| 6074 | + } elseif ( $not_ours ) { | |
| 6075 | + $log_message = 'Cache disabled. Another plugin owns advanced-cache.php, so its drop-in and its WP_CACHE setting were left untouched.'; | |
| 6076 | + } elseif ( ! $constant_left ) { | |
| 6077 | + $log_message = 'Cache disabled. Drop-in removed.'; | |
| 6078 | + } elseif ( ! self::wp_cache_define_is_ours_to_remove( $owner ) ) { | |
| 6079 | + $log_message = 'Cache disabled. WP_CACHE was left in place — it carries no proof xSpeed wrote it, and it does nothing without a drop-in.'; | |
| 6080 | + } elseif ( ! self::can_write_wp_config() ) { | |
| 6081 | + $log_message = 'Cache disabled. Drop-in removed; wp-config.php not writable, so WP_CACHE was left in place (harmless without the drop-in).'; | |
| 6082 | + } else { | |
| 6083 | + $log_message = 'Cache disabled. Drop-in removed; WP_CACHE was left in place (harmless without the drop-in).'; | |
| 6084 | + } | |
| 1241 | 6085 | Activity_Log::record( |
| 1242 | 6086 | 'cache_disabled_event', |
| 1243 | - 'Cache disabled. Drop-in removed.', | |
| 1244 | - Activity_Log::INFO | |
| 6087 | + $log_message, | |
| 6088 | + $constant_left ? Activity_Log::WARN : Activity_Log::INFO | |
| 1245 | 6089 | ); |
| 1246 | 6090 | |
| 1247 | 6091 | return array( |
| 1248 | 6092 | 'enabled' => false, |
| 6093 | + 'blocked' => false, | |
| 6094 | + 'blocked_reason' => null, | |
| 1249 | 6095 | 'dropin_installed' => false, |
| 1250 | - 'wp_cache_constant' => false, | |
| 6096 | + 'wp_cache_constant' => $constant_left, | |
| 1251 | 6097 | 'rewrite_installed' => false, |
| 1252 | 6098 | 'wp_config_writable' => self::wp_config_writable(), |
| 1253 | 6099 | 'manual_snippet' => null, |
| 1254 | 6100 | 'nginx_snippet' => self::nginx_snippet(), |
| @@ -1255,9 +6101,134 @@ | ||
| 1255 | 6101 | 'nginx_server_block' => self::full_nginx_server_block(), |
| 1256 | 6102 | ); |
| 1257 | 6103 | } |
| 1258 | 6104 | |
| 6105 | + /** Acquire the local lock that serializes page-cache ownership changes. */ | |
| 6106 | + private static function page_cache_lock() { | |
| 6107 | + $path = WP_CONTENT_DIR . '/.xspeed-page-cache.lock'; | |
| 6108 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.PHP.NoSilencedErrors.Discouraged -- flock requires a local handle; failure is a safe blocked result. | |
| 6109 | + $lock = @fopen( $path, 'c+' ); | |
| 6110 | + if ( ! is_resource( $lock ) || ! flock( $lock, LOCK_EX ) ) { | |
| 6111 | + return false; | |
| 6112 | + } | |
| 6113 | + return $lock; | |
| 6114 | + } | |
| 6115 | + | |
| 1259 | 6116 | /** |
| 6117 | + * Build the stable response shape for a refused transaction. | |
| 6118 | + * | |
| 6119 | + * The artifact fields report what is ON DISK, not zeros. A refusal means | |
| 6120 | + * xSpeed changed nothing — on a site already running our cache that is | |
| 6121 | + * exactly the state where the drop-in and WP_CACHE are both still in | |
| 6122 | + * place and still serving hits. Hardcoding false told the dashboard the | |
| 6123 | + * cache had been dismantled every time a refusal was returned. | |
| 6124 | + */ | |
| 6125 | + private static function blocked_toggle_state( string $reason ): array { | |
| 6126 | + /* | |
| 6127 | + * `enabled` answers ONE question: is the page cache operational right | |
| 6128 | + * now. Not what was asked for, and not what the option says. | |
| 6129 | + * | |
| 6130 | + * WordPress loads advanced-cache.php only when WP_CACHE is truthy, so | |
| 6131 | + * those two files together are the whole answer, and reading them is | |
| 6132 | + * the only source that cannot go stale. Both of the alternatives were | |
| 6133 | + * tried here and both produced wrong answers on real paths: a | |
| 6134 | + * hardcoded false told a caller the cache had gone away on a site | |
| 6135 | + * still serving hits, and the persisted setting told a caller the | |
| 6136 | + * cache was healthy after a rollback had just removed the artifacts | |
| 6137 | + * — the option is not written until the end of the transaction, so | |
| 6138 | + * mid-transaction it is stale by construction. | |
| 6139 | + * | |
| 6140 | + * Deliberately not a parameter. Every branch that got to choose its | |
| 6141 | + * own answer eventually chose wrong. | |
| 6142 | + */ | |
| 6143 | + return array( | |
| 6144 | + 'enabled' => self::page_cache_operational(), | |
| 6145 | + 'blocked' => true, | |
| 6146 | + 'blocked_reason' => $reason, | |
| 6147 | + 'dropin_installed' => self::DROPIN_XSPEED === self::dropin_owner(), | |
| 6148 | + 'wp_cache_constant' => 'true' === self::wp_cache_define_state(), | |
| 6149 | + 'rewrite_installed' => self::rewrite_installed(), | |
| 6150 | + 'wp_config_writable' => self::wp_config_writable(), | |
| 6151 | + 'manual_snippet' => null, | |
| 6152 | + 'nginx_snippet' => self::nginx_snippet(), | |
| 6153 | + 'nginx_server_block' => self::full_nginx_server_block(), | |
| 6154 | + ); | |
| 6155 | + } | |
| 6156 | + | |
| 6157 | + /** | |
| 6158 | + * The wp-config.php line a user must paste, or null when none is needed. | |
| 6159 | + * | |
| 6160 | + * Non-null only where the drop-in is ours and WP_CACHE is not set to true | |
| 6161 | + * in a file we can write — the read-only managed host. Everywhere else the | |
| 6162 | + * constant is ours to manage and there is nothing to ask for. | |
| 6163 | + */ | |
| 6164 | + public static function manual_wp_cache_snippet(): ?string { | |
| 6165 | + if ( self::DROPIN_XSPEED !== self::dropin_owner() ) { | |
| 6166 | + return null; | |
| 6167 | + } | |
| 6168 | + if ( 'true' === self::wp_cache_define_state() ) { | |
| 6169 | + return null; | |
| 6170 | + } | |
| 6171 | + return self::wp_config_writable() ? null : "define( 'WP_CACHE', true );"; | |
| 6172 | + } | |
| 6173 | + | |
| 6174 | + /** | |
| 6175 | + * Is the page cache serving right now? | |
| 6176 | + * | |
| 6177 | + * Two things decide it, and `WP_CACHE` is not one of them. | |
| 6178 | + * | |
| 6179 | + * xSpeed serves a cached page from `template_redirect` whenever the | |
| 6180 | + * setting is on — see the `HIT (php)` mark on that path, which exists | |
| 6181 | + * precisely for "the drop-in isn't loaded". `advanced-cache.php` and the | |
| 6182 | + * `WP_CACHE` constant that loads it are the FAST path: they answer before | |
| 6183 | + * WordPress boots, which is worth a lot of milliseconds and nothing at | |
| 6184 | + * all to the question of whether pages are being served from cache. | |
| 6185 | + * | |
| 6186 | + * Conflating the two reported a dead cache over a live one. On a managed | |
| 6187 | + * host with an unwritable wp-config.php — the exact case the manual | |
| 6188 | + * snippet exists for — one card said "Your cache works on every request", | |
| 6189 | + * "On, but not serving", "nothing will be cached until you add this line" | |
| 6190 | + * and "hit ratio 67%", all at once, and told the user to edit a file they | |
| 6191 | + * have no permission to write. The released 1.2.1 reported that site as | |
| 6192 | + * active, correctly. | |
| 6193 | + * | |
| 6194 | + * So: the setting, and whether anyone else holds the drop-in. A foreign | |
| 6195 | + * drop-in answers before WordPress loads us, so ours never runs and we | |
| 6196 | + * genuinely are not serving. An unreadable one we must assume the same of. | |
| 6197 | + * Everything else — our drop-in, or none at all — serves. | |
| 6198 | + * | |
| 6199 | + * Public because it is part of the host-plugin contract — see Host. A | |
| 6200 | + * plugin that installed xSpeed needs to be able to say whether the cache | |
| 6201 | + * it asked for is actually serving, and no combination of settings reads | |
| 6202 | + * answers that. | |
| 6203 | + */ | |
| 6204 | + public static function page_cache_operational(): bool { | |
| 6205 | + $settings = Settings::get(); | |
| 6206 | + if ( empty( $settings['cache_enabled'] ) ) { | |
| 6207 | + return false; | |
| 6208 | + } | |
| 6209 | + $owner = self::dropin_owner(); | |
| 6210 | + return self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner; | |
| 6211 | + } | |
| 6212 | + | |
| 6213 | + /** Restore exact snapshots only while disk still matches our own write. */ | |
| 6214 | + private static function rollback_page_cache_artifacts( string $dropin_path, ?string $dropin_before, ?string $dropin_written, string $config_path, ?string $config_before, ?string $config_written ): void { | |
| 6215 | + // Roll back only files that still carry xSpeed's just-written state. | |
| 6216 | + if ( null !== $dropin_written && hash_equals( $dropin_written, (string) self::read_file( $dropin_path ) ) ) { | |
| 6217 | + if ( null === $dropin_before ) { | |
| 6218 | + wp_delete_file( $dropin_path ); | |
| 6219 | + } else { | |
| 6220 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock. | |
| 6221 | + file_put_contents( $dropin_path, $dropin_before ); | |
| 6222 | + } | |
| 6223 | + } | |
| 6224 | + if ( '' !== $config_path && null !== $config_before && null !== $config_written && hash_equals( $config_written, (string) self::read_file( $config_path ) ) && self::wp_cache_receipt_matches_source( $config_written ) ) { | |
| 6225 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock. | |
| 6226 | + file_put_contents( $config_path, $config_before ); | |
| 6227 | + } | |
| 6228 | + } | |
| 6229 | + | |
| 6230 | + /** | |
| 1260 | 6231 | * Check wp-config.php writability via WP_Filesystem. Plugin Check flags |
| 1261 | 6232 | * direct is_writable() under WordPress.WP.AlternativeFunctions. |
| 1262 | 6233 | */ |
| 1263 | 6234 | private static function wp_config_writable() { |
| @@ -1294,10 +6265,11 @@ | ||
| 1294 | 6265 | * they don't share a user at all. A default-umask 0644 file is then |
| 1295 | 6266 | * unwritable by nginx, the access_log write silently fails, and the |
| 1296 | 6267 | * dashboard shows a 0% hit ratio even though static HITs are serving. |
| 1297 | 6268 | * So we widen the dir to 0777 and the file to 0666 — group/other write — |
| 1298 | - * so whatever uid nginx runs as can append. (The file holds only HIT | |
| 1299 | - * request lines, no secrets.) | |
| 6269 | + * so whatever uid nginx runs as can append. The file holds HIT request | |
| 6270 | + * lines and must be protected like an access log: paths and queries can | |
| 6271 | + * contain sensitive values. | |
| 1300 | 6272 | */ |
| 1301 | 6273 | /** |
| 1302 | 6274 | * Directory holding the nginx hit log. Lives under uploads/, NOT the |
| 1303 | 6275 | * cache dir — uninstall.php and a cache purge both delete the cache |
| @@ -1340,12 +6312,215 @@ | ||
| 1340 | 6312 | * and the fast pre-WP path was silently dead. |
| 1341 | 6313 | * |
| 1342 | 6314 | * @param bool|null $enabled Force a state; null reads the current setting. |
| 1343 | 6315 | */ |
| 6316 | + /** | |
| 6317 | + * Write the subdirectory-multisite path list the drop-in needs to work | |
| 6318 | + * out which blog a request belongs to. | |
| 6319 | + * | |
| 6320 | + * The drop-in runs before WordPress, so it cannot call is_multisite() | |
| 6321 | + * or get_blog_details(). It can only see REQUEST_URI — so we persist the | |
| 6322 | + * network's blog paths (one per line, longest first) next to the cache | |
| 6323 | + * files, exactly as sync_mobile_flag() persists the device flag. The | |
| 6324 | + * drop-in prefix-matches the URI against that list to pick the same | |
| 6325 | + * bucket Cache::current_host_dir() picks. (#6) | |
| 6326 | + * | |
| 6327 | + * No file is written for a single site or a subdomain network — there | |
| 6328 | + * the host alone identifies the blog and the bucket carries no prefix. | |
| 6329 | + */ | |
| 6330 | + public static function sync_site_paths(): void { | |
| 6331 | + $file = XSPEED_CACHE_DIR . '/.site-paths'; | |
| 6332 | + | |
| 6333 | + $needed = function_exists( 'is_multisite' ) && is_multisite() | |
| 6334 | + && ( ! function_exists( 'is_subdomain_install' ) || ! is_subdomain_install() ); | |
| 6335 | + | |
| 6336 | + if ( ! $needed ) { | |
| 6337 | + if ( file_exists( $file ) ) { | |
| 6338 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. | |
| 6339 | + @unlink( $file ); | |
| 6340 | + } | |
| 6341 | + return; | |
| 6342 | + } | |
| 6343 | + | |
| 6344 | + if ( ! function_exists( 'get_sites' ) ) { | |
| 6345 | + return; | |
| 6346 | + } | |
| 6347 | + | |
| 6348 | + $paths = array(); | |
| 6349 | + foreach ( get_sites( array( 'number' => 0 ) ) as $site ) { | |
| 6350 | + $prefix = self::path_prefix_segment( (string) $site->path ); | |
| 6351 | + if ( '' !== $prefix ) { | |
| 6352 | + // Store the raw path so the drop-in can prefix-match a URI, | |
| 6353 | + // alongside the segment it maps to. | |
| 6354 | + $paths[ trim( (string) $site->path, '/' ) ] = $prefix; | |
| 6355 | + } | |
| 6356 | + } | |
| 6357 | + | |
| 6358 | + if ( empty( $paths ) ) { | |
| 6359 | + if ( file_exists( $file ) ) { | |
| 6360 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- see above. | |
| 6361 | + @unlink( $file ); | |
| 6362 | + } | |
| 6363 | + return; | |
| 6364 | + } | |
| 6365 | + | |
| 6366 | + // Longest path first so /a/b wins over /a. | |
| 6367 | + uksort( | |
| 6368 | + $paths, | |
| 6369 | + static function ( $x, $y ) { | |
| 6370 | + return strlen( (string) $y ) <=> strlen( (string) $x ); | |
| 6371 | + } | |
| 6372 | + ); | |
| 6373 | + | |
| 6374 | + $lines = array(); | |
| 6375 | + foreach ( $paths as $raw => $segment ) { | |
| 6376 | + $lines[] = $raw . '|' . $segment; | |
| 6377 | + } | |
| 6378 | + | |
| 6379 | + if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) { | |
| 6380 | + return; | |
| 6381 | + } | |
| 6382 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- read by the pre-WP drop-in; WP_Filesystem needs admin credentials unavailable here. | |
| 6383 | + file_put_contents( $file, implode( "\n", $lines ), LOCK_EX ); | |
| 6384 | + } | |
| 6385 | + | |
| 6386 | + /** | |
| 6387 | + * Compile `ignored_query_params` into a regex the DROP-IN can use. | |
| 6388 | + * | |
| 6389 | + * Tracking traffic was cached but never served fast. should_cache() | |
| 6390 | + * learned to allow `?utm_source=…` through and cache_key() strips the | |
| 6391 | + * query, so `/post` and `/post?utm_source=x` share one entry — but the | |
| 6392 | + * drop-in still bailed on ANY query string, so every visitor from an | |
| 6393 | + * email or ad campaign paid a full WordPress boot to be handed a file | |
| 6394 | + * that was already on disk. On a marketing site that is most of the | |
| 6395 | + * paid traffic taking the slowest path. (#13) | |
| 6396 | + * | |
| 6397 | + * The drop-in runs before WordPress, so it cannot read the option or | |
| 6398 | + * call Glob_Matcher. It gets a precompiled alternation instead, written | |
| 6399 | + * next to the cache files exactly as sync_mobile_flag() writes the | |
| 6400 | + * device flag. Regenerated whenever cache settings are saved. | |
| 6401 | + * | |
| 6402 | + * Only the KEYS matter: a param whose name is on the list contributes | |
| 6403 | + * nothing to the response, so the entry keyed without it is correct. | |
| 6404 | + * Anything not on the list means the drop-in must stand down and let | |
| 6405 | + * PHP decide — the file is deleted rather than left stale when the | |
| 6406 | + * list is empty, so a missing sidecar always fails safe. | |
| 6407 | + */ | |
| 6408 | + public static function sync_query_allowlist(): void { | |
| 6409 | + $file = XSPEED_CACHE_DIR . '/.ignored-query-params'; | |
| 6410 | + | |
| 6411 | + /* | |
| 6412 | + * Stored read, not Settings_Manager::get() — this runs from boot(), | |
| 6413 | + * before translation is legal (see stored_cache_opts()). | |
| 6414 | + * | |
| 6415 | + * A raw read applies no schema defaults, and this field's default is a | |
| 6416 | + * long tracking-parameter list, NOT empty. Falling back to array() | |
| 6417 | + * would strip that whole allow-list from the drop-in on any install | |
| 6418 | + * that has never saved the Cache panel. So fall back to the schema's | |
| 6419 | + * own default, read from the module without building its labels. | |
| 6420 | + */ | |
| 6421 | + $opts = self::stored_cache_opts(); | |
| 6422 | + $ignored = is_array( $opts['ignored_query_params'] ?? null ) | |
| 6423 | + ? $opts['ignored_query_params'] | |
| 6424 | + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_IGNORED_QUERY_PARAMS; | |
| 6425 | + | |
| 6426 | + $parts = array(); | |
| 6427 | + foreach ( $ignored as $pattern ) { | |
| 6428 | + $pattern = trim( (string) $pattern ); | |
| 6429 | + if ( '' === $pattern ) { | |
| 6430 | + continue; | |
| 6431 | + } | |
| 6432 | + if ( '~' === $pattern[0] ) { | |
| 6433 | + // Raw regex, PHP-side dialect. Keep it — unlike a server | |
| 6434 | + // config, the drop-in runs the same PCRE engine, so the | |
| 6435 | + // pattern behaves identically. Anchored below with the rest. | |
| 6436 | + $body = substr( $pattern, 1 ); | |
| 6437 | + if ( '' !== $body && false !== @preg_match( '#^(?:' . $body . ')$#', '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed user pattern must be dropped, not fatal. | |
| 6438 | + $parts[] = $body; | |
| 6439 | + } | |
| 6440 | + continue; | |
| 6441 | + } | |
| 6442 | + // Glob semantics, same as Glob_Matcher: * is any run, ? is one. | |
| 6443 | + $esc = preg_quote( $pattern, '#' ); | |
| 6444 | + $esc = str_replace( array( '\*', '\?' ), array( '.*', '.' ), $esc ); | |
| 6445 | + $parts[] = $esc; | |
| 6446 | + } | |
| 6447 | + | |
| 6448 | + if ( empty( $parts ) ) { | |
| 6449 | + if ( file_exists( $file ) ) { | |
| 6450 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal. | |
| 6451 | + @unlink( $file ); | |
| 6452 | + } | |
| 6453 | + return; | |
| 6454 | + } | |
| 6455 | + | |
| 6456 | + if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) { | |
| 6457 | + return; | |
| 6458 | + } | |
| 6459 | + | |
| 6460 | + $payload = '(?:' . implode( '|', array_unique( $parts ) ) . ')'; | |
| 6461 | + | |
| 6462 | + // Only write when the value actually changed. This runs from | |
| 6463 | + // reconcile_mobile_separate() on CacheModule::boot(), so an | |
| 6464 | + // unconditional write cost a file write and an exclusive lock on every | |
| 6465 | + // request that boots WordPress — every MISS, every BYPASS, every admin | |
| 6466 | + // screen, every REST call. sync_mobile_flag() below is the model: it | |
| 6467 | + // touches the marker only when the setting flips. | |
| 6468 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- our own sidecar; an unreadable file falls through to the write below. | |
| 6469 | + if ( is_readable( $file ) && (string) @file_get_contents( $file ) === $payload ) { | |
| 6470 | + return; | |
| 6471 | + } | |
| 6472 | + | |
| 6473 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- read by the pre-WP drop-in; WP_Filesystem needs admin credentials unavailable here. | |
| 6474 | + file_put_contents( $file, $payload, LOCK_EX ); | |
| 6475 | + } | |
| 6476 | + | |
| 6477 | + /** | |
| 6478 | + * CacheModule's STORED settings, read straight from the option. | |
| 6479 | + * | |
| 6480 | + * `Settings_Manager::get( 'cache' )` builds CacheModule's settings schema, | |
| 6481 | + * whose labels are declared through `__()`. The reconcile chain below runs | |
| 6482 | + * from `CacheModule::boot()` on `plugins_loaded` — before | |
| 6483 | + * `after_setup_theme`, the point WordPress 6.7+ treats as safe to | |
| 6484 | + * translate — so going through the schema there fires | |
| 6485 | + * `_load_textdomain_just_in_time` on every request AND resolves the labels | |
| 6486 | + * against a domain that is not loaded yet. | |
| 6487 | + * | |
| 6488 | + * The callers here need stored values, not schema metadata, so a raw read | |
| 6489 | + * is equivalent. It applies NO defaults or coercion: read each key with a | |
| 6490 | + * fallback matching the schema's own default. | |
| 6491 | + * | |
| 6492 | + * @return array<string,mixed> | |
| 6493 | + */ | |
| 6494 | + private static function stored_cache_opts(): array { | |
| 6495 | + $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() ); | |
| 6496 | + return is_array( $stored ) ? $stored : array(); | |
| 6497 | + } | |
| 6498 | + | |
| 6499 | + /** | |
| 6500 | + * Strict truthiness for the LiteSpeed Static Fast Path opt-in. | |
| 6501 | + * | |
| 6502 | + * On non-LiteSpeed servers the key is out of the schema and carried by | |
| 6503 | + * preserved_keys(), so a REST/MCP write lands VERBATIM — QA on #513 | |
| 6504 | + * stored the string "false" on Apache and the fast path installed | |
| 6505 | + * itself the moment the site moved to LiteSpeed, because | |
| 6506 | + * empty("false") is false. Only an explicit, unambiguous "yes" may | |
| 6507 | + * enable a path that trades away hit tagging; any other value — | |
| 6508 | + * "false", "no", arbitrary junk — stays OFF, which is the default the | |
| 6509 | + * user never left. | |
| 6510 | + */ | |
| 6511 | + private static function litespeed_optin_enabled( $value ): bool { | |
| 6512 | + if ( true === $value || 1 === $value ) { | |
| 6513 | + return true; | |
| 6514 | + } | |
| 6515 | + return is_string( $value ) | |
| 6516 | + && in_array( strtolower( trim( $value ) ), array( '1', 'true', 'on', 'yes' ), true ); | |
| 6517 | + } | |
| 6518 | + | |
| 1344 | 6519 | public static function sync_mobile_flag( $enabled = null ): void { |
| 1345 | 6520 | if ( null === $enabled ) { |
| 1346 | - $opts = Settings_Manager::get( 'cache' ); | |
| 1347 | - $enabled = ! empty( $opts['mobile_separate'] ); | |
| 6521 | + $stored = self::stored_cache_opts(); | |
| 6522 | + $enabled = ! empty( $stored['mobile_separate'] ); | |
| 1348 | 6523 | } |
| 1349 | 6524 | $dir = XSPEED_CACHE_DIR; |
| 1350 | 6525 | $flag = $dir . '/.mobile-separate'; |
| 1351 | 6526 | if ( $enabled ) { |
| @@ -1411,8 +6586,16 @@ | ||
| 1411 | 6586 | * reconcile, and toggle() handles install/teardown itself. |
| 1412 | 6587 | */ |
| 1413 | 6588 | public static function reconcile_mobile_separate(): void { |
| 1414 | 6589 | self::sync_mobile_flag(); |
| 6590 | + if ( defined( 'XSPEED_CACHE_DIR' ) ) { | |
| 6591 | + // Keep the drop-in's view of the network's blog paths current — a | |
| 6592 | + // site added or removed changes which bucket its URLs belong to. (#6) | |
| 6593 | + self::sync_site_paths(); | |
| 6594 | + // Keep the drop-in's copy of the query allow-list current — a param | |
| 6595 | + // added in settings must reach the fast path too. (#13) | |
| 6596 | + self::sync_query_allowlist(); | |
| 6597 | + } | |
| 1415 | 6598 | |
| 1416 | 6599 | // The rewrite/static reconciliation below needs the plugin's path |
| 1417 | 6600 | // constants. They're absent in early-boot / unit-test contexts where |
| 1418 | 6601 | // only the drop-in flag matters — bail to the flag-only behavior then. |
| @@ -1428,8 +6611,31 @@ | ||
| 1428 | 6611 | |
| 1429 | 6612 | $rewrite_present = self::rewrite_installed(); |
| 1430 | 6613 | $rewrite_wanted = self::static_rewrite_allowed(); |
| 1431 | 6614 | |
| 6615 | + // Did the thing that actually invalidates cache KEYS change? | |
| 6616 | + // mobile_separate buckets entries as |d / |m, so flipping it makes | |
| 6617 | + // stored entries mis-bucketed and they must go. A rewrite-state | |
| 6618 | + // mismatch from anything else (e.g. mod_headers detection, a hand- | |
| 6619 | + // edited .htaccess) changes no key at all — the same files are still | |
| 6620 | + // valid, they're just served by PHP instead of by the web server. | |
| 6621 | + // Purging there is what let one WP-CLI call wipe the whole cache on | |
| 6622 | + // every bootstrap. (#138) | |
| 6623 | + // | |
| 6624 | + // Read the setting from the SAME place static_rewrite_allowed() and | |
| 6625 | + // sync_mobile_flag() do — the cache module's settings, not the | |
| 6626 | + // top-level xspeed_options — or this marker would track a key that | |
| 6627 | + // never changes and a real flip would go unnoticed. | |
| 6628 | + // Stored read — this runs from boot(); see stored_cache_opts(). | |
| 6629 | + $cache_opts = self::stored_cache_opts(); | |
| 6630 | + $mobile_now = ! empty( $cache_opts['mobile_separate'] ); | |
| 6631 | + $mobile_last = get_option( 'xspeed_last_mobile_separate', null ); | |
| 6632 | + $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now ); | |
| 6633 | + | |
| 6634 | + if ( (string) (int) $mobile_now !== (string) $mobile_last ) { | |
| 6635 | + update_option( 'xspeed_last_mobile_separate', $mobile_now ? '1' : '0', false ); | |
| 6636 | + } | |
| 6637 | + | |
| 1432 | 6638 | if ( $rewrite_present === $rewrite_wanted ) { |
| 1433 | 6639 | // Already consistent — nothing flipped, leave caches intact so a |
| 1434 | 6640 | // plain settings save (e.g. expiry change) doesn't blow the cache. |
| 1435 | 6641 | return; |
| @@ -1434,17 +6640,19 @@ | ||
| 1434 | 6640 | // plain settings save (e.g. expiry change) doesn't blow the cache. |
| 1435 | 6641 | return; |
| 1436 | 6642 | } |
| 1437 | 6643 | |
| 1438 | - // The setting flipped. Bring the rewrite into line and purge the | |
| 1439 | - // now-misbucketed cache so the next request re-primes under the new | |
| 1440 | - // device scheme. | |
| 6644 | + // Bring the rewrite into line with what this server actually supports. | |
| 1441 | 6645 | if ( $rewrite_wanted ) { |
| 1442 | 6646 | self::install_rewrite(); |
| 1443 | 6647 | } else { |
| 1444 | 6648 | self::remove_rewrite(); |
| 1445 | 6649 | } |
| 1446 | - self::purge_all( 'mobile_separate changed' ); | |
| 6650 | + | |
| 6651 | + // Only discard cache contents when the device bucketing changed. | |
| 6652 | + if ( $mobile_flipped ) { | |
| 6653 | + self::purge_all( 'mobile_separate changed' ); | |
| 6654 | + } | |
| 1447 | 6655 | } |
| 1448 | 6656 | |
| 1449 | 6657 | /** |
| 1450 | 6658 | * Whether the server-level static-rewrite fast path may be used. |
| @@ -1468,10 +6676,12 @@ | ||
| 1468 | 6676 | * `.htaccess` equivalent of nginx's per-location `access_log` to record |
| 1469 | 6677 | * the hit. The result was a cache that worked but was invisible: no HIT |
| 1470 | 6678 | * header and a hit-ratio frozen near 0%. Every OTHER server gives the |
| 1471 | 6679 | * user a visible HIT header + a counted hit (nginx via add_header + |
| 1472 | - * access_log in its snippet; Apache via .htaccess mod_headers, which it | |
| 1473 | - * honors). To keep LiteSpeed CONSISTENT with the rest, we route its hits | |
| 6680 | + * access_log in its snippet; Apache via the `<IfModule mod_headers.c>` | |
| 6681 | + * block in rewrite_block_lines(), WHEN that module is loaded — when it is | |
| 6682 | + * not, Apache takes this same drop-in fallback). To keep LiteSpeed | |
| 6683 | + * CONSISTENT with the rest, we route its hits | |
| 1474 | 6684 | * through the PHP drop-in instead — the drop-in emits |
| 1475 | 6685 | * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the |
| 1476 | 6686 | * observable behavior the other servers get. The cost is the drop-in's |
| 1477 | 6687 | * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in |
| @@ -1479,15 +6689,39 @@ | ||
| 1479 | 6689 | * the truth there. (Apache keeps the static fast path — it honors the |
| 1480 | 6690 | * header.) See maybe_emit_lscache_headers() for the paired LSCache |
| 1481 | 6691 | * stand-down that stops LiteSpeed's own module from shadowing the |
| 1482 | 6692 | * drop-in. |
| 6693 | + * | |
| 6694 | + * Opt-in (#509): `litespeed_static_rewrite` re-enables the fast path on | |
| 6695 | + * LiteSpeed for users who value raw TTFB over hit accounting. The trade | |
| 6696 | + * is stated in the setting's copy: statically served hits carry no | |
| 6697 | + * X-XSpeed-Cache header and are not counted (LiteSpeed logs the | |
| 6698 | + * original request line, so even the access-log scan cannot see | |
| 6699 | + * them — see Hit_Counter::collect_server_log_hits()). The drop-in | |
| 6700 | + * default above stays — nobody is surprised into an unverifiable cache. | |
| 1483 | 6701 | */ |
| 1484 | 6702 | public static function static_rewrite_allowed(): bool { |
| 1485 | - // LiteSpeed: drop-in serves hits (visible + counted) — see docblock. | |
| 1486 | - if ( Server::LITESPEED === Server::type() ) { | |
| 6703 | + // Stored read — reached from boot(); see stored_cache_opts(). | |
| 6704 | + $opts = self::stored_cache_opts(); | |
| 6705 | + // LiteSpeed: drop-in serves hits (visible + counted) unless the user | |
| 6706 | + // explicitly opted into the static fast path — see docblock. | |
| 6707 | + if ( Server::LITESPEED === Server::type() | |
| 6708 | + && ! self::litespeed_optin_enabled( $opts['litespeed_static_rewrite'] ?? false ) ) { | |
| 1487 | 6709 | return false; |
| 1488 | 6710 | } |
| 1489 | - $opts = Settings_Manager::get( 'cache' ); | |
| 6711 | + // Apache without mod_headers is in EXACTLY the position LiteSpeed | |
| 6712 | + // is in above: it can run the RewriteRule and serve the static | |
| 6713 | + // file, but it cannot stamp `X-XSpeed-Cache` on the response, so | |
| 6714 | + // the hit is invisible to the user and uncountable by | |
| 6715 | + // Hit_Counter. The docblock above used to assert Apache "honors | |
| 6716 | + // mod_headers" and left it on the fast path unconditionally — | |
| 6717 | + // true only when the module is actually loaded. Fall back to the | |
| 6718 | + // drop-in when it isn't, trading ~10ms of TTFB for a hit that | |
| 6719 | + // shows up in the header and the ratio. (Field report: hit ratio | |
| 6720 | + // pinned at 0% on a working Apache cache.) | |
| 6721 | + if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { | |
| 6722 | + return false; | |
| 6723 | + } | |
| 1490 | 6724 | return empty( $opts['mobile_separate'] ); |
| 1491 | 6725 | } |
| 1492 | 6726 | |
| 1493 | 6727 | /** |
| @@ -1493,17 +6727,165 @@ | ||
| 1493 | 6727 | /** |
| 1494 | 6728 | * Why the device-blind static rewrite is NOT installed, when it isn't. |
| 1495 | 6729 | * Returns 'mobile_separate' when Separate Mobile Cache is the blocker |
| 1496 | 6730 | * (the static file is one-per-URL, so it can't coexist with per-device |
| 1497 | - * buckets), '' otherwise. Lets the dashboard explain the slow path | |
| 1498 | - * instead of silently falling back to PHP serving. (FBS-83145) | |
| 6731 | + * buckets), 'no_mod_headers' when Apache can't stamp the HIT header, | |
| 6732 | + * '' otherwise. Lets the dashboard explain the slow path instead of | |
| 6733 | + * silently falling back to PHP serving. (FBS-83145) | |
| 6734 | + * | |
| 6735 | + * Every refusal in static_rewrite_allowed() that is NOT self-explanatory | |
| 6736 | + * must have a branch here. Otherwise the Health card falls through to | |
| 6737 | + * "Block missing — toggle Enable Cache off and on to reinstall it", | |
| 6738 | + * advice that cannot work: the same condition that suppressed the write | |
| 6739 | + * suppresses the reinstall, and auto_heal() strips the block again on | |
| 6740 | + * the next admin page load. (Field report: Apache host with mod_headers | |
| 6741 | + * unloaded sat on the slow path with no way to find out why.) | |
| 1499 | 6742 | */ |
| 6743 | + /** | |
| 6744 | + * Qualify a raw probe result with what we already KNOW about config. | |
| 6745 | + * | |
| 6746 | + * probe_static_rewrite() writes its own file under the static-cache tree | |
| 6747 | + * and fetches that, which succeeds whenever the web server can serve a | |
| 6748 | + * static file at all — including when static_rewrite_allowed() is false | |
| 6749 | + * and no real page is on the static path. So `active: true` on its own is | |
| 6750 | + * not evidence that pages are being served statically. | |
| 6751 | + * | |
| 6752 | + * The reachable case is nginx with Separate Mobile Cache on: the snippet | |
| 6753 | + * lives in the server block and we cannot remove it, pages are | |
| 6754 | + * deliberately routed to the PHP drop-in, but the probe file is still | |
| 6755 | + * served directly. | |
| 6756 | + * | |
| 6757 | + * The Health panel learned this in 88b4b50; the CLI, REST and MCP paths | |
| 6758 | + * did not, so they kept reporting "active" in exactly that configuration. | |
| 6759 | + * Rather than repeat the reasoning at each call site, they now all come | |
| 6760 | + * through here. | |
| 6761 | + * | |
| 6762 | + * Deliberately does NOT consult rewrite_installed(): on nginx the fast | |
| 6763 | + * path is the pasted snippet and there is no .htaccess marker to find, so | |
| 6764 | + * requiring one would report every correctly-configured nginx site as | |
| 6765 | + * broken. | |
| 6766 | + * | |
| 6767 | + * @param array $probe Raw result from probe_static_rewrite(). | |
| 6768 | + * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string} | |
| 6769 | + */ | |
| 6770 | + public static function qualify_rewrite_probe( array $probe ): array { | |
| 6771 | + $active = (bool) ( $probe['active'] ?? false ); | |
| 6772 | + $inconclusive = (bool) ( $probe['inconclusive'] ?? false ); | |
| 6773 | + $reason = (string) ( $probe['reason'] ?? '' ); | |
| 6774 | + $block_reason = self::static_rewrite_block_reason(); | |
| 6775 | + | |
| 6776 | + // Same observed-refusal check Health makes. This is the shared path for | |
| 6777 | + // `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite — | |
| 6778 | + // and, because a CLI command is automatically an MCP tool, for the | |
| 6779 | + // AI-facing surface too. Leaving it out would have fixed the dashboard | |
| 6780 | + // while the CLI kept answering that the fast path was active. (#372) | |
| 6781 | + if ( '' === $block_reason ) { | |
| 6782 | + $skip = self::last_static_skip(); | |
| 6783 | + if ( ! empty( $skip['reason'] ) ) { | |
| 6784 | + $block_reason = 'skipped_' . (string) $skip['reason']; | |
| 6785 | + } | |
| 6786 | + } | |
| 6787 | + | |
| 6788 | + // With page caching off there is nothing to serve, so `active` can | |
| 6789 | + // never be true here whatever the raw probe says. probe_static_rewrite() | |
| 6790 | + // writes its OWN file under the static tree and fetches that, which | |
| 6791 | + // succeeds whenever the server can serve a static file at all — and on | |
| 6792 | + // nginx the snippet is server-level, so it keeps succeeding after the | |
| 6793 | + // cache is switched off. | |
| 6794 | + // | |
| 6795 | + // block_reason() used to carry this meaning by accident: it returned | |
| 6796 | + // 'mobile_separate' with caching off, and the refusal branch below | |
| 6797 | + // forced active=false. Now that it correctly reports '' (nothing can | |
| 6798 | + // block a fast path that isn't in use), this consumer has to state the | |
| 6799 | + // condition itself — otherwise `wp xspeed cache recheck-rewrite` and | |
| 6800 | + // POST /cache/recheck-rewrite claim "the web server is serving cache | |
| 6801 | + // hits directly" on a site with no cache. That is a positive false | |
| 6802 | + // claim rather than a nag, i.e. worse than the bug being fixed. | |
| 6803 | + $cache_opts = Settings::get(); | |
| 6804 | + if ( empty( $cache_opts['cache_enabled'] ) ) { | |
| 6805 | + return array( | |
| 6806 | + 'active' => false, | |
| 6807 | + 'inconclusive' => false, | |
| 6808 | + 'reason' => 'Page caching is off, so there is no cache for the web server to serve.', | |
| 6809 | + 'block_reason' => '', | |
| 6810 | + ); | |
| 6811 | + } | |
| 6812 | + | |
| 6813 | + // A known refusal outranks the probe, and also outranks | |
| 6814 | + // "inconclusive" — a blocked rewrite whose probe merely failed to | |
| 6815 | + // complete is still definitely blocked. | |
| 6816 | + if ( '' !== $block_reason ) { | |
| 6817 | + $active = false; | |
| 6818 | + $inconclusive = false; | |
| 6819 | + $reason = self::block_reason_text( $block_reason ); | |
| 6820 | + } | |
| 6821 | + | |
| 6822 | + return array( | |
| 6823 | + 'active' => $active, | |
| 6824 | + 'inconclusive' => $inconclusive, | |
| 6825 | + 'reason' => $reason, | |
| 6826 | + 'block_reason' => $block_reason, | |
| 6827 | + ); | |
| 6828 | + } | |
| 6829 | + | |
| 6830 | + /** | |
| 6831 | + * Human-readable explanation for a static_rewrite_block_reason() code. | |
| 6832 | + * | |
| 6833 | + * Each one has to say what to DO about it: "mobile_separate" alone tells | |
| 6834 | + * a user nothing, and the whole point of surfacing a refusal instead of | |
| 6835 | + * the probe verdict is that it is actionable. | |
| 6836 | + */ | |
| 6837 | + public static function block_reason_text( string $code ): string { | |
| 6838 | + switch ( $code ) { | |
| 6839 | + case 'mobile_separate': | |
| 6840 | + return 'Separate Mobile Cache is on, which disables the device-blind static rewrite. Cache hits are served by PHP instead. If your site serves the same HTML to every device, turn it off in Cache settings for much faster hits.'; | |
| 6841 | + case 'no_mod_headers': | |
| 6842 | + return "Apache's mod_headers is not loaded, so the static rewrite cannot mark its responses as cache hits. Enable mod_headers, or leave hits on the PHP path."; | |
| 6843 | + case 'litespeed_dropin': | |
| 6844 | + return 'On LiteSpeed, cache hits are served by the PHP drop-in so every hit is tagged X-XSpeed-Cache and counted in the hit ratio — LiteSpeed\'s .htaccess engine cannot do either for statically served files. If raw TTFB matters more to you than hit accounting, turn on LiteSpeed Static Fast Path in Cache settings to serve hits straight from the web server.'; | |
| 6845 | + case 'skipped_nonce': | |
| 6846 | + return 'The server config is correct, but pages are not reaching the static cache because they contain nonces, so hits are served by PHP instead. A static file is served with no PHP, so a nonce baked into one could never be refreshed and every anonymous form on the page would break once it expired — keeping these pages on PHP is deliberate. Nonces usually come from plugin widgets; disabling the ones the site does not use lets its pages be served statically again.'; | |
| 6847 | + default: | |
| 6848 | + return sprintf( 'The static rewrite is disabled (%s).', $code ); | |
| 6849 | + } | |
| 6850 | + } | |
| 6851 | + | |
| 1500 | 6852 | public static function static_rewrite_block_reason(): string { |
| 6853 | + // Nothing can be blocking the fast path when there is no cache to | |
| 6854 | + // serve from it. Without this the dashboard told users with page | |
| 6855 | + // caching switched OFF that Separate Mobile Cache "is disabling | |
| 6856 | + // faster static serving" — a fast path they were not using, about a | |
| 6857 | + // cache that did not exist. Every caller of this is a user-facing | |
| 6858 | + // explanation of why the rewrite is off, so "the cache is off" is | |
| 6859 | + // the honest answer, and it is silence. (#108) | |
| 6860 | + $opts = Settings::get(); | |
| 6861 | + if ( empty( $opts['cache_enabled'] ) ) { | |
| 6862 | + return ''; | |
| 6863 | + } | |
| 1501 | 6864 | if ( Server::LITESPEED === Server::type() ) { |
| 1502 | - return ''; // Intended on LiteSpeed — not a "block". | |
| 6865 | + // The opt-in is read RAW (stored_cache_opts), not through | |
| 6866 | + // Settings_Manager::get(): the schema's bool coercion is a PHP | |
| 6867 | + // cast, and (bool) "false" is true — so a junk string stored on | |
| 6868 | + // another server (where the key bypasses the schema) would come | |
| 6869 | + // back from the coercion layer as an ENABLE. Raw + the strict | |
| 6870 | + // parse below is the same read static_rewrite_allowed() makes, | |
| 6871 | + // so the two can't disagree either. (QA on #513) | |
| 6872 | + $stored = self::stored_cache_opts(); | |
| 6873 | + // The intended default — but no longer silent: with the opt-in | |
| 6874 | + // off, Health must be able to explain the PHP path and point at | |
| 6875 | + // the toggle instead of falling through to "reinstall the block" | |
| 6876 | + // advice that cannot work here. (#509) | |
| 6877 | + if ( ! self::litespeed_optin_enabled( $stored['litespeed_static_rewrite'] ?? false ) ) { | |
| 6878 | + return 'litespeed_dropin'; | |
| 6879 | + } | |
| 6880 | + $cache_opts = Settings_Manager::get( 'cache' ); | |
| 6881 | + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : ''; | |
| 1503 | 6882 | } |
| 1504 | - $opts = Settings_Manager::get( 'cache' ); | |
| 1505 | - return ! empty( $opts['mobile_separate'] ) ? 'mobile_separate' : ''; | |
| 6883 | + if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { | |
| 6884 | + return 'no_mod_headers'; | |
| 6885 | + } | |
| 6886 | + $cache_opts = Settings_Manager::get( 'cache' ); | |
| 6887 | + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : ''; | |
| 1506 | 6888 | } |
| 1507 | 6889 | |
| 1508 | 6890 | /** |
| 1509 | 6891 | * Whether migration flagged Separate Mobile Cache for user review. Set by |
| @@ -1513,10 +6895,21 @@ | ||
| 1513 | 6895 | * this flag so the dashboard can invite the user to turn it back on only |
| 1514 | 6896 | * if their site genuinely serves different HTML per device. (FBS-83145) |
| 1515 | 6897 | */ |
| 1516 | 6898 | public static function mobile_separate_needs_review(): bool { |
| 1517 | - $opts = Settings_Manager::get( 'cache' ); | |
| 1518 | - return ! empty( $opts['mobile_separate_review'] ); | |
| 6899 | + // Same reasoning as static_rewrite_block_reason(): the invitation is | |
| 6900 | + // "turn this back on if your site needs it, to regain the fast path", | |
| 6901 | + // which is meaningless with page caching off — there is no fast path | |
| 6902 | + // to regain, and the equality probe behind the prompt would fetch | |
| 6903 | + // pages that aren't being cached. Gated here rather than at the two | |
| 6904 | + // payload call sites (Admin + Rest_Api) so `enabled`, `blocking` and | |
| 6905 | + // `needs_review` are consistently gated on the same condition. (#108) | |
| 6906 | + $opts = Settings::get(); | |
| 6907 | + if ( empty( $opts['cache_enabled'] ) ) { | |
| 6908 | + return false; | |
| 6909 | + } | |
| 6910 | + $cache_opts = Settings_Manager::get( 'cache' ); | |
| 6911 | + return ! empty( $cache_opts['mobile_separate_review'] ); | |
| 1519 | 6912 | } |
| 1520 | 6913 | |
| 1521 | 6914 | /** |
| 1522 | 6915 | * Clear the review flag — called when the user has acted on the prompt |
| @@ -1617,18 +7010,111 @@ | ||
| 1617 | 7010 | * handful of well-known noise sources and collapses whitespace, so a site |
| 1618 | 7011 | * that truly serves different markup per device still compares as different. |
| 1619 | 7012 | */ |
| 1620 | 7013 | private static function normalize_html_for_diff( string $html ): string { |
| 7014 | + // Every rule here errs toward "they differ" being WRONG rather than | |
| 7015 | + // "they match" being wrong: this check only ever tells a user it is | |
| 7016 | + // SAFE to turn Separate Mobile Cache off, so a false "identical" | |
| 7017 | + // would cost them device-specific output. The risk of being too | |
| 7018 | + // conservative is milder but real — the useful answer never appears, | |
| 7019 | + // and the feature's whole pitch ("we'll prove it's safe to turn | |
| 7020 | + // off") silently never pays out. These close the gaps that made a | |
| 7021 | + // mismatch effectively guaranteed on an ordinary WordPress site. (#108) | |
| 1621 | 7022 | $patterns = array( |
| 1622 | - // WP nonces (data-nonce="...", _wpnonce=..., "nonce":"..."). | |
| 1623 | - '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-f0-9]{10}/i', | |
| 1624 | - // Generic 10+ hex tokens (CSRF, cache-buster hashes, session ids). | |
| 1625 | - '/\b[a-f0-9]{16,}\b/i', | |
| 7023 | + // WP nonces in attribute or JSON form: data-nonce="…", | |
| 7024 | + // _wpnonce=…, "nonce":"…". The `[:=]` adjacency below misses | |
| 7025 | + // wp_nonce_field()'s own markup — `name="_wpnonce" value="ab…"` | |
| 7026 | + // puts `value=` between the key and the token — which is the | |
| 7027 | + // single most common nonce shape in WordPress, so that form is | |
| 7028 | + // matched explicitly first. | |
| 7029 | + '/name=["\']?(_wpnonce|_ajax_nonce)["\']?\s+value=["\']?[a-z0-9]{8,}/i', | |
| 7030 | + // CSP nonces on script/style tags. Base64, so uppercase and | |
| 7031 | + // +/= appear — the hex-only rules below can never match one, | |
| 7032 | + // and a CSP-enabled site therefore differed on every fetch. | |
| 7033 | + // MUST precede the generic nonce rule: that one stops at the | |
| 7034 | + // first non-alphanumeric, leaving the rest of the token behind | |
| 7035 | + // and the two responses still unequal. | |
| 7036 | + // The quotes are optional so HTML5's legal unquoted attribute | |
| 7037 | + // form (`<script nonce=AbCd+q/r=>`) is covered too — without | |
| 7038 | + // that it fell through to the generic rule, which is the exact | |
| 7039 | + // failure this rule exists to remove. | |
| 7040 | + '/\bnonce=(["\'])?[A-Za-z0-9+\/=_-]{8,}(?(1)\1)/', | |
| 7041 | + '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-z0-9]{8,}/i', | |
| 7042 | + // Generic hex tokens: cache busters, session ids, md5/sha | |
| 7043 | + // digests. Was 16+, which left an 11-15 char gap above the | |
| 7044 | + // 10-char nonce rule. | |
| 7045 | + // | |
| 7046 | + // The token MUST contain at least one a-f letter. `[a-f0-9]` | |
| 7047 | + // also matches every decimal digit, so a bare `{10,}` erased | |
| 7048 | + // every 10+ digit INTEGER anywhere in the document — including | |
| 7049 | + // visible body text. A page whose desktop and mobile HTML | |
| 7050 | + // differed only by a per-device numeric id (an AdSense slot, an | |
| 7051 | + // A/B bucket, an analytics property) then compared as identical, | |
| 7052 | + // and the check told the user it was safe to switch off the very | |
| 7053 | + // setting keeping that output correct — the one direction this | |
| 7054 | + // function must never fail in. Decimal-only runs are left to the | |
| 7055 | + // bounded epoch rule below, which is deliberately narrower. | |
| 7056 | + // | |
| 7057 | + // Known, accepted (QA R2): a token whose letters all fall in a-f | |
| 7058 | + // reads as a digest, so a per-device `ABC1234567890` strips even | |
| 7059 | + // though it is an id, not a hash. Deliberately left open — the | |
| 7060 | + // alternatives all cost more than the bug: | |
| 7061 | + // | |
| 7062 | + // Token shape (lowercase-only, case-uniformity, a trailing | |
| 7063 | + // letter) cannot separate it. `ABC1234567890` and | |
| 7064 | + // `ABCDEF012345` — an uppercase digest this rule SHOULD strip — | |
| 7065 | + // are both all-hex, uniformly cased, letters-then-digits. | |
| 7066 | + // Each variant fixed the id only by sparing the digest. | |
| 7067 | + // | |
| 7068 | + // Letter density does separate them (23% letters vs 50%), but | |
| 7069 | + // measured over 2000 md5/sha1/sha256 samples, requiring letters | |
| 7070 | + // spread through the token leaves 21-67% of REAL digests | |
| 7071 | + // unmatched depending on the window. Digest noise is most of | |
| 7072 | + // what this function exists to remove, so that trade guts it. | |
| 7073 | + // | |
| 7074 | + // Context (protecting data-* attribute values from this rule) | |
| 7075 | + // works for ids and still strips digests in URLs, classes and | |
| 7076 | + // query strings — but regresses a CHANGING digest inside a | |
| 7077 | + // non-nonce data-* attribute, and needs a two-pass | |
| 7078 | + // hold/restore. Viable if R2 is ever worth pressing; its | |
| 7079 | + // failure at least errs toward "differ". | |
| 7080 | + // | |
| 7081 | + // An A-F-only prefix on a per-device id is rare, and the earlier | |
| 7082 | + // nonce rules already claim the data-nonce/_wpnonce shapes. | |
| 7083 | + '/\b(?=[a-f0-9]{10,}\b)[0-9]*[a-f][a-f0-9]*\b/i', | |
| 1626 | 7084 | // wp-generated unique ids (e.g. wp-block ids, aria ids). |
| 1627 | 7085 | '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i', |
| 1628 | 7086 | // ISO-ish timestamps + epoch-looking numbers in query strings. |
| 1629 | 7087 | '/\?ver=[0-9.]+/', |
| 1630 | 7088 | '/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/', |
| 7089 | + // Our own signature's generation stamp. The two fetches are | |
| 7090 | + // sequential and each writes its own entry, so this differs on | |
| 7091 | + // essentially every comparison — and it is space-separated, so | |
| 7092 | + // the ISO rule above (which requires a literal `T`) never | |
| 7093 | + // touches it. Without this the probe reports "differ" for every | |
| 7094 | + // site and the "safe to turn Separate Mobile Cache off" verdict | |
| 7095 | + // can never appear. | |
| 7096 | + '/generated [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} UTC/', | |
| 7097 | + // Raw epoch seconds. The two fetches are sequential, so any | |
| 7098 | + // template printing time() guaranteed a mismatch. | |
| 7099 | + // | |
| 7100 | + // This is the ONLY rule that may strip a decimal-only run, so | |
| 7101 | + // its bound is load-bearing rather than decorative — every digit | |
| 7102 | + // it gives away is a class of per-device id it silently erases. | |
| 7103 | + // `1[0-9]{9}` was too loose: it claimed the whole | |
| 7104 | + // 1000000000-1999999999 range (2001-2033) to cover timestamps | |
| 7105 | + // nobody serves, and took every 10-digit AdSense slot, order id | |
| 7106 | + // and SKU beginning with 1 along with it — reproducing the exact | |
| 7107 | + // false-"identical" verdict the hex rule above was tightened to | |
| 7108 | + // stop. `1[6-9]` covers 2020-2033, which is the only span a live | |
| 7109 | + // site can actually print, and collides with roughly a tenth as | |
| 7110 | + // many ids. | |
| 7111 | + // | |
| 7112 | + // Not airtight — an id beginning 16-19 still collides. Closing | |
| 7113 | + // that properly means scoping this to places a timestamp really | |
| 7114 | + // appears (an attribute value, a query parameter, a JSON value) | |
| 7115 | + // rather than bare body text; the bound is the cheap 90% of it. | |
| 7116 | + '/\b1[6-9][0-9]{8}\b/', | |
| 1631 | 7117 | ); |
| 1632 | 7118 | $html = (string) preg_replace( $patterns, 'X', $html ); |
| 1633 | 7119 | // Collapse all whitespace so trivial formatting differences don't count. |
| 1634 | 7120 | return trim( (string) preg_replace( '/\s+/', ' ', $html ) ); |
| @@ -1634,33 +7120,58 @@ | ||
| 1634 | 7120 | return trim( (string) preg_replace( '/\s+/', ' ', $html ) ); |
| 1635 | 7121 | } |
| 1636 | 7122 | |
| 1637 | 7123 | public static function ensure_hits_log_file(): bool { |
| 1638 | - // The HITs log exists ONLY so a server-level nginx `access_log` | |
| 1639 | - // directive has a world-writable file to append to (see nginx_snippet() | |
| 1640 | - // + Hit_Counter::collect_nginx_log_hits()). On Apache/LiteSpeed/managed | |
| 1641 | - // hosts nothing writes it, so creating it — and, worse, chmod()-ing it | |
| 1642 | - // world-writable — is pointless AND fails with "Operation not permitted" | |
| 1643 | - // when PHP can't chmod files it doesn't own (a warning that surfaces in | |
| 1644 | - // logs that capture @-suppressed errors). Skip the whole thing off nginx. | |
| 1645 | - if ( Server::NGINX !== Server::type() ) { | |
| 1646 | - return false; | |
| 1647 | - } | |
| 7124 | + // TWO writers append to this log, and an earlier fix conflated them: | |
| 7125 | + // | |
| 7126 | + // 1. nginx, via the server-level `access_log` directive in | |
| 7127 | + // nginx_snippet() — a DIFFERENT uid, which is why the file needs | |
| 7128 | + // to be world-writable there. | |
| 7129 | + // 2. the PHP drop-in (advanced-cache.php), on EVERY server. A hit it | |
| 7130 | + // serves bypasses WordPress entirely, so it can't call | |
| 7131 | + // Hit_Counter::record_hit() — appending here is the only way that | |
| 7132 | + // hit is ever counted. | |
| 7133 | + // | |
| 7134 | + // The nginx-only early return that used to sit at the top of this | |
| 7135 | + // method was fixing something real: chmod() on a file PHP doesn't own | |
| 7136 | + // raises "Operation not permitted", and off nginx that chmod buys | |
| 7137 | + // nothing. But it took directory creation with it, so on LiteSpeed | |
| 7138 | + // (which always serves via the drop-in), on Apache without mod_headers, | |
| 7139 | + // and anywhere mobile_separate forces the drop-in path, writer 2 was | |
| 7140 | + // appending to a file whose parent directory did not exist. The append | |
| 7141 | + // is @-suppressed and documented as non-fatal, so every one of those | |
| 7142 | + // hits vanished and the dashboard ratio sat at 0% forever. | |
| 7143 | + // | |
| 7144 | + // So: create the dir + file everywhere, and keep only the chmod gated | |
| 7145 | + // to nginx. | |
| 1648 | 7146 | $dir = self::hits_log_dir(); |
| 1649 | 7147 | if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { |
| 1650 | 7148 | return false; |
| 1651 | 7149 | } |
| 1652 | - // Ensure the dir is traversable + writable by a different-uid nginx. | |
| 1653 | - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- nginx (a separate uid in multi-container setups) must be able to create/append the log; WP_Filesystem layers ownership overrides that defeat that intent. | |
| 1654 | - @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails. | |
| 7150 | + | |
| 7151 | + $is_nginx = ( Server::NGINX === Server::type() ); | |
| 7152 | + | |
| 7153 | + if ( $is_nginx ) { | |
| 7154 | + // Ensure the dir is traversable + writable by a different-uid nginx. | |
| 7155 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- nginx (a separate uid in multi-container setups) must be able to create/append the log; WP_Filesystem layers ownership overrides that defeat that intent. | |
| 7156 | + @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails. | |
| 7157 | + } | |
| 7158 | + | |
| 1655 | 7159 | $path = self::hits_log_path(); |
| 1656 | 7160 | if ( ! file_exists( $path ) ) { |
| 1657 | 7161 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem. |
| 1658 | 7162 | @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check. |
| 1659 | 7163 | } |
| 1660 | - // World-writable so a different-uid nginx can append HIT lines. | |
| 1661 | - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock. | |
| 1662 | - @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort. | |
| 7164 | + | |
| 7165 | + if ( $is_nginx ) { | |
| 7166 | + // World-writable so a different-uid nginx can append HIT lines. | |
| 7167 | + // Off nginx the drop-in appends as the same uid that owns the file, | |
| 7168 | + // so this is unnecessary — and would emit the "Operation not | |
| 7169 | + // permitted" warnings the old early return was added to silence. | |
| 7170 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock. | |
| 7171 | + @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort. | |
| 7172 | + } | |
| 7173 | + | |
| 1663 | 7174 | return file_exists( $path ); |
| 1664 | 7175 | } |
| 1665 | 7176 | |
| 1666 | 7177 | public static function nginx_snippet(): ?string { |
| @@ -1724,9 +7235,55 @@ | ||
| 1724 | 7235 | $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host()) |
| 1725 | 7236 | $lines[] = 'set $xspeed_no_cache "no-cache";'; |
| 1726 | 7237 | $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }'; |
| 1727 | 7238 | $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }'; |
| 1728 | - $lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }'; | |
| 7239 | + // Cookie + user-agent exclusions, generated from the user's actual | |
| 7240 | + // settings rather than a hardcoded list. Before this, the rule | |
| 7241 | + // tested three fixed cookie names and no user agent at all, so | |
| 7242 | + // every excluded_cookies / bypass_user_agents entry applied only | |
| 7243 | + // while a page was cold — on a warm page nginx served the shared | |
| 7244 | + // anonymous copy to carts, members and bypassed bots alike. The | |
| 7245 | + // three historical names survive as a floor inside cookie_rule(). | |
| 7246 | + // `~*` is case-insensitive, matching PHP's stripos()/glob checks. | |
| 7247 | + // Stored read — reached from boot(); see stored_cache_opts(). The | |
| 7248 | + // fallbacks below mirror the schema's own defaults, which a raw read | |
| 7249 | + // does not apply. | |
| 7250 | + $cache_opts = self::stored_cache_opts(); | |
| 7251 | + $cookie_rule = Server_Rules::cookie_rule( | |
| 7252 | + is_array( $cache_opts['excluded_cookies'] ?? null ) | |
| 7253 | + ? $cache_opts['excluded_cookies'] | |
| 7254 | + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXCLUDED_COOKIES | |
| 7255 | + ); | |
| 7256 | + $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }'; | |
| 7257 | + | |
| 7258 | + $ua_rule = Server_Rules::user_agent_rule( | |
| 7259 | + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array() | |
| 7260 | + ); | |
| 7261 | + // Emitted only when the list is non-empty — an empty alternation | |
| 7262 | + // would compile to `(...)` matching every request and disable the | |
| 7263 | + // fast path entirely. | |
| 7264 | + if ( '' !== $ua_rule['regex'] ) { | |
| 7265 | + $lines[] = 'if ($http_user_agent ~* "(' . $ua_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-ua"; }'; | |
| 7266 | + } | |
| 7267 | + | |
| 7268 | + // URL exclusions. Without this an excluded URL was only excluded | |
| 7269 | + // while its page was cold: PHP won't write a static file for one, so | |
| 7270 | + // there is usually nothing to serve — but a page cached BEFORE the | |
| 7271 | + // rule was added still has its file on disk, and nginx serves it | |
| 7272 | + // without ever asking PHP. The exclusion then does nothing until the | |
| 7273 | + // next purge. (#169) | |
| 7274 | + // | |
| 7275 | + // Matched against $uri, not $request_uri: $uri is the decoded path | |
| 7276 | + // without the query string, which is what Cache::should_cache() | |
| 7277 | + // tests. Using $request_uri would make `/cart` fail to match | |
| 7278 | + // `/cart?x=1` inconsistently with PHP. Same empty-regex guard as the | |
| 7279 | + // UA rule above — an empty alternation matches everything. | |
| 7280 | + $url_rule = Server_Rules::url_rule( | |
| 7281 | + is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array() | |
| 7282 | + ); | |
| 7283 | + if ( '' !== $url_rule['regex'] ) { | |
| 7284 | + $lines[] = 'if ($uri ~* "(' . $url_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-url"; }'; | |
| 7285 | + } | |
| 1729 | 7286 | $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }'; |
| 1730 | 7287 | // Neither `add_header` nor `access_log` is allowed inside an `if{}` |
| 1731 | 7288 | // at server level (nginx rejects with "directive is not allowed |
| 1732 | 7289 | // here"). The logging therefore lives in a `location` block that |
| @@ -1756,8 +7313,24 @@ | ||
| 1756 | 7313 | // missing. So: hits are logged, and a user deleting the log can't take |
| 1757 | 7314 | // nginx down. |
| 1758 | 7315 | $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;'; |
| 1759 | 7316 | $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;'; |
| 7317 | + // Edge/CDN headers from the same seam the drop-in bakes. nginx serves | |
| 7318 | + // this path without ever starting PHP, so the answer cannot be | |
| 7319 | + // resolved per request — the pairs are resolved HERE, when the | |
| 7320 | + // snippet is generated, and a change of answer needs the snippet | |
| 7321 | + // regenerated and re-pasted to take effect. | |
| 7322 | + // | |
| 7323 | + // Skipped entirely when the static path is switched off. The only | |
| 7324 | + // reason that can fire under `bake` is mobile-split, and mobile-split | |
| 7325 | + // is also what switches the static path off — so the block would be | |
| 7326 | + // baked with a hold it can never serve, and would start serving it | |
| 7327 | + // the moment the setting is turned off and static files reappear, | |
| 7328 | + // until somebody regenerates and re-pastes. A rule that can only be | |
| 7329 | + // served once its premise is false is guaranteed to be stale. | |
| 7330 | + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $name => $value ) { | |
| 7331 | + $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;'; | |
| 7332 | + } | |
| 1760 | 7333 | $lines[] = '}'; |
| 1761 | 7334 | return implode( "\n", $lines ); |
| 1762 | 7335 | } |
| 1763 | 7336 | |
| @@ -1840,8 +7413,62 @@ | ||
| 1840 | 7413 | header( 'X-LiteSpeed-Cache-Control: no-cache' ); |
| 1841 | 7414 | } |
| 1842 | 7415 | |
| 1843 | 7416 | /** |
| 7417 | + * Restore the drop-in + WP_CACHE constant for a site that had caching | |
| 7418 | + * ON before this activation — and ONLY for such a site. | |
| 7419 | + * | |
| 7420 | + * WordPress runs an upgrade as deactivate → wipe plugin files → | |
| 7421 | + * install → activate. The wipe takes advanced-cache.php with it, so | |
| 7422 | + * without this the site serves 100% uncached from the moment the | |
| 7423 | + * update finishes until the next authenticated wp-admin page load | |
| 7424 | + * (auto_heal() is on admin_init). On a site whose admin logs in | |
| 7425 | + * rarely that window is hours or days of silent cache loss, while | |
| 7426 | + * the dashboard still reports cache_enabled = true. (FBS field | |
| 7427 | + * report against 1.1.2 / Pro 1.0.5.) | |
| 7428 | + * | |
| 7429 | + * The `cache_enabled` guard is the whole contract: a FRESH install | |
| 7430 | + * has the option unset, so activation writes nothing and the user | |
| 7431 | + * still opts in explicitly through Cache::toggle() via the | |
| 7432 | + * /cache/toggle REST endpoint. We only ever put back state the user | |
| 7433 | + * already chose — repair, never a new install path. This is what | |
| 7434 | + * keeps us on the right side of the "don't create drop-ins the user | |
| 7435 | + * didn't ask for" guideline while matching what WP Rocket, W3 Total | |
| 7436 | + * Cache and WP Super Cache all do on activation. | |
| 7437 | + * | |
| 7438 | + * @return bool True when a restore was performed. | |
| 7439 | + */ | |
| 7440 | + public static function restore_dropin_if_enabled(): bool { | |
| 7441 | + if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) { | |
| 7442 | + return false; | |
| 7443 | + } | |
| 7444 | + | |
| 7445 | + // The user's saved choice. Absent/false on a fresh install => no | |
| 7446 | + // drop-in is written and nothing touches wp-config.php. | |
| 7447 | + $opts = get_option( 'xspeed_options', array() ); | |
| 7448 | + if ( empty( $opts['cache_enabled'] ) ) { | |
| 7449 | + return false; | |
| 7450 | + } | |
| 7451 | + | |
| 7452 | + $state = self::toggle( true, false ); | |
| 7453 | + // A refusal reports whether the cache SERVES, which on this path can | |
| 7454 | + // be true for reasons that have nothing to do with this call — so a | |
| 7455 | + // refusal would otherwise log "drop-in restored" for a restore that | |
| 7456 | + // was declined. Restored means the transaction went through. | |
| 7457 | + $restored = empty( $state['blocked'] ) && ! empty( $state['enabled'] ); | |
| 7458 | + | |
| 7459 | + if ( $restored ) { | |
| 7460 | + Activity_Log::record( | |
| 7461 | + 'cache_dropin_restored', | |
| 7462 | + 'Cache drop-in restored after a plugin update — caching was already enabled.', | |
| 7463 | + Activity_Log::SUCCESS | |
| 7464 | + ); | |
| 7465 | + } | |
| 7466 | + | |
| 7467 | + return $restored; | |
| 7468 | + } | |
| 7469 | + | |
| 7470 | + /** | |
| 1844 | 7471 | * Reconcile drop-in + WP_CACHE + rewrite block with the user's |
| 1845 | 7472 | * saved choice. Runs on admin_init. Cheap when nothing's wrong |
| 1846 | 7473 | * (one option read + a handful of file_exists / defined checks); |
| 1847 | 7474 | * writes only when state has drifted (typical cause: plugin |
| @@ -1863,32 +7490,16 @@ | ||
| 1863 | 7490 | if ( empty( $opts['cache_enabled'] ) ) { |
| 1864 | 7491 | return; |
| 1865 | 7492 | } |
| 1866 | 7493 | |
| 1867 | - $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php'; | |
| 1868 | - $dropin_ours = false; | |
| 1869 | - $dropin_stale = false; | |
| 1870 | - if ( file_exists( $dropin_target ) ) { | |
| 1871 | - $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 1872 | - $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ); | |
| 1873 | - // Reinstall when OUR drop-in is an older version than the source — | |
| 1874 | - // the marker alone can't distinguish an old copy from a new one, so | |
| 1875 | - // a serve-logic change (e.g. the .meta read for 404s/feeds) would | |
| 1876 | - // otherwise never reach existing cache-enabled sites until a manual | |
| 1877 | - // cache toggle. (FBS-82406/82407) | |
| 1878 | - if ( $dropin_ours ) { | |
| 1879 | - $dropin_stale = self::dropin_version( (string) $contents ) < self::dropin_version( @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ) ?: '' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 1880 | - } | |
| 7494 | + $state = self::toggle( true, false ); | |
| 7495 | + // A refusal means something else now owns the page-cache field, or | |
| 7496 | + // the write could not be verified. Either way this is not the moment | |
| 7497 | + // to go on maintaining our rewrite block and log file. | |
| 7498 | + if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) { | |
| 7499 | + return; | |
| 1881 | 7500 | } |
| 1882 | 7501 | |
| 1883 | - if ( ! $dropin_ours || $dropin_stale ) { | |
| 1884 | - self::install_dropin(); | |
| 1885 | - } | |
| 1886 | - | |
| 1887 | - if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) { | |
| 1888 | - self::set_wp_cache_constant( true ); | |
| 1889 | - } | |
| 1890 | - | |
| 1891 | 7502 | // Rewrite block goes last. It's what turns the static-cache |
| 1892 | 7503 | // tree into a PHP-bypass — every cache hit served by the web |
| 1893 | 7504 | // server directly. Without it we still cache, just at drop-in |
| 1894 | 7505 | // speed (~85ms TTFB) instead of static-file speed (~25-40ms). |
| @@ -1913,8 +7524,54 @@ | ||
| 1913 | 7524 | self::ensure_hits_log_file(); |
| 1914 | 7525 | } |
| 1915 | 7526 | |
| 1916 | 7527 | /** |
| 7528 | + * Keep the generic bypass cookie in sync with PHP's caching verdict. | |
| 7529 | + * | |
| 7530 | + * The server config tests exactly one cookie name (Server_Rules:: | |
| 7531 | + * BYPASS_COOKIE) forever, and PHP decides what that name means. Adding | |
| 7532 | + * a new excluded cookie therefore needs no config change and no nginx | |
| 7533 | + * reload — the reason this exists. | |
| 7534 | + * | |
| 7535 | + * Session cookie (expiry 0) so it dies with the browser session, and | |
| 7536 | + * deliberately NOT HttpOnly-sensitive: it carries no identity, only the | |
| 7537 | + * boolean "don't serve this visitor a shared cached page". | |
| 7538 | + * | |
| 7539 | + * Honest limit: this can only ever help a visitor PHP has already seen | |
| 7540 | + * once. A bot's first request to a warm page never reaches PHP, which | |
| 7541 | + * is why user-agent rules are still written into the server config | |
| 7542 | + * rather than relying on this. | |
| 7543 | + * | |
| 7544 | + * @param bool $bypass Whether this visitor must skip the cache. | |
| 7545 | + */ | |
| 7546 | + private static function sync_bypass_cookie( bool $bypass ): void { | |
| 7547 | + if ( headers_sent() ) { | |
| 7548 | + return; | |
| 7549 | + } | |
| 7550 | + | |
| 7551 | + $name = Server_Rules::BYPASS_COOKIE; | |
| 7552 | + $has = isset( $_COOKIE[ $name ] ); | |
| 7553 | + | |
| 7554 | + // Only touch the header when the state actually changes — a | |
| 7555 | + // Set-Cookie on every request would make the response uncacheable | |
| 7556 | + // for intermediary caches and add noise to every hit. | |
| 7557 | + if ( $bypass === $has ) { | |
| 7558 | + return; | |
| 7559 | + } | |
| 7560 | + | |
| 7561 | + $path = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/'; | |
| 7562 | + $domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : ''; | |
| 7563 | + | |
| 7564 | + if ( $bypass ) { | |
| 7565 | + setcookie( $name, '1', 0, $path, (string) $domain, is_ssl(), false ); | |
| 7566 | + $_COOKIE[ $name ] = '1'; | |
| 7567 | + } else { | |
| 7568 | + setcookie( $name, '', time() - 3600, $path, (string) $domain, is_ssl(), false ); | |
| 7569 | + unset( $_COOKIE[ $name ] ); | |
| 7570 | + } | |
| 7571 | + } | |
| 7572 | + | |
| 7573 | + /** | |
| 1917 | 7574 | * Build the .htaccess rules that map cacheable requests to the |
| 1918 | 7575 | * static-cache tree. Conditions are deliberately strict: GET only, |
| 1919 | 7576 | * empty query string, no session/comment-author/post-password |
| 1920 | 7577 | * cookie, and the static file must exist on disk. Anything that |
| @@ -1931,14 +7588,46 @@ | ||
| 1931 | 7588 | $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ); |
| 1932 | 7589 | $rel = '/' . ltrim( $rel, '/' ); |
| 1933 | 7590 | $rel = rtrim( $rel, '/' ); |
| 1934 | 7591 | |
| 1935 | - return array( | |
| 7592 | + // Cookie + user-agent exclusions generated from the live settings. | |
| 7593 | + // See the matching block in nginx_snippet() — same generator, same | |
| 7594 | + // floor, so both servers enforce an identical policy. Apache reads | |
| 7595 | + // .htaccess on every request and we already self-heal this file, so | |
| 7596 | + // Apache/LiteSpeed users get the fix on upgrade with no action. | |
| 7597 | + $cache_opts = Settings_Manager::get( 'cache' ); | |
| 7598 | + $cookie_rule = Server_Rules::cookie_rule( | |
| 7599 | + is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array() | |
| 7600 | + ); | |
| 7601 | + $ua_rule = Server_Rules::user_agent_rule( | |
| 7602 | + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array() | |
| 7603 | + ); | |
| 7604 | + | |
| 7605 | + $lines = array( | |
| 1936 | 7606 | '<IfModule mod_rewrite.c>', |
| 1937 | 7607 | ' RewriteEngine On', |
| 1938 | 7608 | ' RewriteCond %{REQUEST_METHOD} ^GET$', |
| 1939 | 7609 | ' RewriteCond %{QUERY_STRING} ^$', |
| 1940 | - ' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]', | |
| 7610 | + ' RewriteCond %{HTTP_COOKIE} !(' . $cookie_rule['regex'] . ') [NC]', | |
| 7611 | + ); | |
| 7612 | + | |
| 7613 | + // Only emit the UA condition when there's something to match — | |
| 7614 | + // `!()` would negate an always-true empty match and refuse every | |
| 7615 | + // request, silently disabling the static path. | |
| 7616 | + if ( '' !== $ua_rule['regex'] ) { | |
| 7617 | + // Quoted, because RewriteCond is whitespace-delimited and real | |
| 7618 | + // user-agent fragments contain spaces ("Mozilla/5.0 (compatible"). | |
| 7619 | + // Unquoted, a space adds an argument and Apache answers every | |
| 7620 | + // request with a 500 — and because .htaccess is parsed per | |
| 7621 | + // request, `httpd -t` still reports Syntax OK. Server_Rules has | |
| 7622 | + // already excluded quotes and backslashes from the alternation, | |
| 7623 | + // so the closing quote here cannot be escaped away. | |
| 7624 | + $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]'; | |
| 7625 | + } | |
| 7626 | + | |
| 7627 | + $block = array_merge( | |
| 7628 | + $lines, | |
| 7629 | + array( | |
| 1941 | 7630 | // Capture REQUEST_URI without its trailing slash into %1. |
| 1942 | 7631 | // store_static() writes `{host}{uri-without-trailing-slash}/index.html`, |
| 1943 | 7632 | // so this normalization lets `/blog/` and `/blog` both hit |
| 1944 | 7633 | // the same cache file without producing the double-slash |
| @@ -1954,11 +7643,56 @@ | ||
| 1954 | 7643 | // `^` matches the empty string AND any non-empty path, so it |
| 1955 | 7644 | // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed |
| 1956 | 7645 | // 1.8: `.` → homepage served by PHP drop-in; `^` → served |
| 1957 | 7646 | // directly from the static file.) |
| 1958 | - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]', | |
| 7647 | + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]', | |
| 1959 | 7648 | '</IfModule>', |
| 7649 | + // Mark the statically-served response as a cache HIT. | |
| 7650 | + // | |
| 7651 | + // A file served by the rewrite above bypasses PHP entirely, so | |
| 7652 | + // this directive is the ONLY thing that can identify it as | |
| 7653 | + // cached — both for the user reading response headers and for | |
| 7654 | + // Hit_Counter, which reconciles static hits from the access | |
| 7655 | + // log. Without it the cache works perfectly and reports a 0% | |
| 7656 | + // hit ratio, which reads as "the plugin is broken". (Field | |
| 7657 | + // report against 1.1.2: homepage served byte-identical from | |
| 7658 | + // the static tree, no X-XSpeed-Cache header on any response.) | |
| 7659 | + // | |
| 7660 | + // `always` so the header is set on the 200 from the rewritten | |
| 7661 | + // file, not only on the successful-response table. The | |
| 7662 | + // <IfModule> guard keeps a server without mod_headers from | |
| 7663 | + // 500ing on an unknown directive — on such a host the header | |
| 7664 | + // is silently dropped, which is exactly why | |
| 7665 | + // static_rewrite_allowed() refuses the static path there and | |
| 7666 | + // routes hits through the drop-in instead. | |
| 7667 | + '<IfModule mod_headers.c>', | |
| 7668 | + ' <FilesMatch "\\.html$">', | |
| 7669 | + ' Header always set X-XSpeed-Cache "HIT (static)"', | |
| 7670 | + ' </FilesMatch>', | |
| 7671 | + ) | |
| 1960 | 7672 | ); |
| 7673 | + | |
| 7674 | + // Edge/CDN headers from the same seam the drop-in bakes. Like the | |
| 7675 | + // nginx snippet, the static rewrite answers without PHP, so the pairs | |
| 7676 | + // are resolved when the block is GENERATED rather than per request. | |
| 7677 | + // | |
| 7678 | + // `env=` rather than the `<FilesMatch>` scoping above, because these | |
| 7679 | + // must ride only on responses the rewrite produced. The marker header | |
| 7680 | + // stays filename-scoped: it is inert, and narrowing it would change a | |
| 7681 | + // header QA reads. | |
| 7682 | + // Same reasoning as the nginx snippet: a bake hold can only come from | |
| 7683 | + // mobile-split, and mobile-split is what turns this path off. | |
| 7684 | + $edge_lines = array(); | |
| 7685 | + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $edge_name => $edge_value ) { | |
| 7686 | + $edge_lines = array_merge( | |
| 7687 | + $edge_lines, | |
| 7688 | + self::static_hit_directives( | |
| 7689 | + ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"' | |
| 7690 | + ) | |
| 7691 | + ); | |
| 7692 | + } | |
| 7693 | + | |
| 7694 | + return array_merge( $block, $edge_lines, array( '</IfModule>' ) ); | |
| 1961 | 7695 | } |
| 1962 | 7696 | |
| 1963 | 7697 | /** |
| 1964 | 7698 | * Active probe that confirms the web-server static-rewrite path is |
| @@ -1985,8 +7719,21 @@ | ||
| 1985 | 7719 | * synchronously on every dashboard bootstrap, so a slow/timing-out |
| 1986 | 7720 | * loopback request added up to `timeout` seconds to admin page loads on |
| 1987 | 7721 | * hosts that block self-requests. (FBS-82142) |
| 1988 | 7722 | */ |
| 7723 | + /** | |
| 7724 | + * Discard the cached probe result and run a fresh one. | |
| 7725 | + * | |
| 7726 | + * Without this there was no way to re-check: the result sat in a transient | |
| 7727 | + * for five minutes and nothing ever deleted it, so a user who fixed their | |
| 7728 | + * nginx config kept seeing "nginx detected — configure for max cache speed" | |
| 7729 | + * with no means of confirming the fix worked. (FBS-84012) | |
| 7730 | + */ | |
| 7731 | + public static function recheck_static_rewrite(): array { | |
| 7732 | + delete_transient( 'xspeed_rewrite_probe' ); | |
| 7733 | + return self::probe_static_rewrite( true ); | |
| 7734 | + } | |
| 7735 | + | |
| 1989 | 7736 | public static function probe_static_rewrite( bool $allow_probe = false ): array { |
| 1990 | 7737 | $cached = get_transient( 'xspeed_rewrite_probe' ); |
| 1991 | 7738 | if ( is_array( $cached ) ) { |
| 1992 | 7739 | return $cached; |
| @@ -2000,9 +7747,12 @@ | ||
| 2000 | 7747 | |
| 2001 | 7748 | $home = home_url( '/' ); |
| 2002 | 7749 | $host = (string) wp_parse_url( $home, PHP_URL_HOST ); |
| 2003 | 7750 | if ( '' === $host ) { |
| 2004 | - $result = array( 'active' => false, 'reason' => 'home_url has no host' ); | |
| 7751 | + // Environmental failure, not evidence the server config is wrong — | |
| 7752 | + // mark it inconclusive so Health surfaces say "could not verify" | |
| 7753 | + // instead of demanding a snippet paste. (#480) | |
| 7754 | + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'home_url has no host' ); | |
| 2005 | 7755 | set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 2006 | 7756 | return $result; |
| 2007 | 7757 | } |
| 2008 | 7758 | |
| @@ -2019,9 +7769,12 @@ | ||
| 2019 | 7769 | if ( ! file_exists( $probe_dir ) ) { |
| 2020 | 7770 | wp_mkdir_p( $probe_dir ); |
| 2021 | 7771 | } |
| 2022 | 7772 | if ( ! is_dir( $probe_dir ) ) { |
| 2023 | - $result = array( 'active' => false, 'reason' => 'cannot create probe dir' ); | |
| 7773 | + // A cache-dir permissions problem — the probe never ran, so this | |
| 7774 | + // says nothing about the nginx config. Inconclusive, not | |
| 7775 | + // "required". (#480) | |
| 7776 | + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'cannot create probe dir' ); | |
| 2024 | 7777 | set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 2025 | 7778 | return $result; |
| 2026 | 7779 | } |
| 2027 | 7780 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir. |
| @@ -2058,8 +7811,14 @@ | ||
| 2058 | 7811 | |
| 2059 | 7812 | if ( is_wp_error( $resp ) ) { |
| 2060 | 7813 | $result = array( |
| 2061 | 7814 | 'active' => false, |
| 7815 | + // The request never completed, so we learned NOTHING about the | |
| 7816 | + // rewrite. Flagged inconclusive so the UI doesn't tell the user | |
| 7817 | + // to configure a server that may already be configured — a | |
| 7818 | + // blocked loopback, a self-signed cert, or a timeout is a probe | |
| 7819 | + // failure, not a missing rewrite. (FBS-84012) | |
| 7820 | + 'inconclusive' => true, | |
| 2062 | 7821 | 'reason' => 'http error: ' . $resp->get_error_message(), |
| 2063 | 7822 | ); |
| 2064 | 7823 | // Cache the failure for the full 5 minutes (not 1) so a host that |
| 2065 | 7824 | // times out on the loopback probe isn't re-probed — and re-stalled |
| @@ -2080,25 +7839,41 @@ | ||
| 2080 | 7839 | // AND didn't add an X-Powered-By: PHP header. All three are |
| 2081 | 7840 | // individually noisy; together they're conclusive. |
| 2082 | 7841 | $active = $match && $has_etag && ! $ua_php && 200 === $code; |
| 2083 | 7842 | |
| 7843 | + /* | |
| 7844 | + * `inconclusive` separates "we proved the rewrite isn't serving" from | |
| 7845 | + * "the probe couldn't tell". Only the former should drive a | |
| 7846 | + * configure-your-server banner; the latter previously rendered the | |
| 7847 | + * same alarming copy at a user who had already configured nginx | |
| 7848 | + * correctly, and there was no way to clear it. (FBS-84012) | |
| 7849 | + */ | |
| 7850 | + $inconclusive = false; | |
| 2084 | 7851 | if ( $active ) { |
| 2085 | 7852 | $reason = 'static-served'; |
| 2086 | 7853 | } elseif ( 200 === $code && $match && $ua_php ) { |
| 2087 | 7854 | $reason = 'php served the file instead of nginx/Apache (rewrite block missing)'; |
| 2088 | 7855 | } elseif ( 200 === $code && ! $match ) { |
| 2089 | - $reason = 'unexpected body (CDN cached an older response?)'; | |
| 7856 | + // Something answered 200 with content that isn't our nonce — a CDN, | |
| 7857 | + // a proxy, a security plugin. That tells us nothing about the | |
| 7858 | + // origin's rewrite. | |
| 7859 | + $reason = 'unexpected body (CDN cached an older response?)'; | |
| 7860 | + $inconclusive = true; | |
| 2090 | 7861 | } elseif ( 404 === $code ) { |
| 2091 | 7862 | $reason = 'probe URL returned 404 (rewrite block missing or wrong path)'; |
| 2092 | 7863 | } else { |
| 2093 | - $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' ); | |
| 7864 | + // Redirects, 403s from a WAF, 5xx — the probe never reached a | |
| 7865 | + // verdict about the rewrite itself. | |
| 7866 | + $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' ); | |
| 7867 | + $inconclusive = true; | |
| 2094 | 7868 | } |
| 2095 | 7869 | |
| 2096 | 7870 | $result = array( |
| 2097 | - 'active' => $active, | |
| 2098 | - 'reason' => $reason, | |
| 2099 | - 'code' => $code, | |
| 2100 | - 'php' => $ua_php, | |
| 7871 | + 'active' => $active, | |
| 7872 | + 'inconclusive' => $inconclusive, | |
| 7873 | + 'reason' => $reason, | |
| 7874 | + 'code' => $code, | |
| 7875 | + 'php' => $ua_php, | |
| 2101 | 7876 | ); |
| 2102 | 7877 | set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS ); |
| 2103 | 7878 | return $result; |
| 2104 | 7879 | } |
| @@ -2155,12 +7930,49 @@ | ||
| 2155 | 7930 | $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' ); |
| 2156 | 7931 | $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() ); |
| 2157 | 7932 | $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned ); |
| 2158 | 7933 | |
| 7934 | + /* | |
| 7935 | + * Nothing to change. auto_heal() runs the whole enable transaction on | |
| 7936 | + * every admin_init and this is called unconditionally from it, so | |
| 7937 | + * without this every wp-admin request truncated and rewrote .htaccess | |
| 7938 | + * with byte-identical content. Apache reads that file without a lock, | |
| 7939 | + * so the truncate window is a real 500 on a busy admin, and the churn | |
| 7940 | + * trips host file-integrity monitors. | |
| 7941 | + */ | |
| 7942 | + if ( $next === $existing ) { | |
| 7943 | + return true; | |
| 7944 | + } | |
| 7945 | + | |
| 2159 | 7946 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- WP_Filesystem requires admin credentials we don't have here; toggle() runs in a REST request authorized by manage_options nonce. The target is the site's .htaccess (configuration file managed by WP core itself), not user data — wp_upload_dir() doesn't apply. |
| 2160 | 7947 | return false !== file_put_contents( $htaccess, $next, LOCK_EX ); |
| 2161 | 7948 | } |
| 2162 | 7949 | |
| 7950 | + /** | |
| 7951 | + * Rewrite the .htaccess block in place when — and only when — one is | |
| 7952 | + * already installed. | |
| 7953 | + * | |
| 7954 | + * The block embeds the generated cookie / user-agent exclusion rules, | |
| 7955 | + * so it goes stale the moment those settings change. install_rewrite() | |
| 7956 | + * regenerates it from the live settings, but calling that unconditionally | |
| 7957 | + * on every save would CREATE a block on sites that never enabled the | |
| 7958 | + * static path — silently turning on server-level serving nobody asked | |
| 7959 | + * for. So we refresh only what's already there. | |
| 7960 | + * | |
| 7961 | + * @return bool True when a block was present and rewritten. | |
| 7962 | + */ | |
| 7963 | + public static function refresh_rewrite_if_installed(): bool { | |
| 7964 | + $htaccess = ABSPATH . '.htaccess'; | |
| 7965 | + if ( ! file_exists( $htaccess ) ) { | |
| 7966 | + return false; | |
| 7967 | + } | |
| 7968 | + $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; an unreadable file simply means nothing to refresh. | |
| 7969 | + if ( ! is_string( $existing ) || false === strpos( $existing, '# BEGIN xSpeed Static Cache' ) ) { | |
| 7970 | + return false; | |
| 7971 | + } | |
| 7972 | + return self::install_rewrite(); | |
| 7973 | + } | |
| 7974 | + | |
| 2163 | 7975 | public static function remove_rewrite(): bool { |
| 2164 | 7976 | $htaccess = ABSPATH . '.htaccess'; |
| 2165 | 7977 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale. |
| 2166 | 7978 | if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) { |
| @@ -2181,9 +7993,21 @@ | ||
| 2181 | 7993 | * follows it. Idempotent — returns the input unchanged if the |
| 2182 | 7994 | * marker isn't present. |
| 2183 | 7995 | */ |
| 2184 | 7996 | private static function strip_marker_block( string $contents, string $marker ): string { |
| 2185 | - $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s"; | |
| 7997 | + /* | |
| 7998 | + * The body may not contain another BEGIN for this marker. | |
| 7999 | + * | |
| 8000 | + * `.*?` is non-greedy but still spans anything, so an ORPHANED | |
| 8001 | + * `# BEGIN xSpeed Static Cache` — an END line lost to a hand edit or | |
| 8002 | + * a partial write — paired with the END of the NEXT block and deleted | |
| 8003 | + * everything between them. On a site where the orphan sits above | |
| 8004 | + * `# BEGIN WordPress`, that takes WordPress's own rewrite rules with | |
| 8005 | + * it and every permalink 404s. Refusing to cross a second BEGIN makes | |
| 8006 | + * the orphan a no-op instead of a site-wide outage. | |
| 8007 | + */ | |
| 8008 | + $begin = '# BEGIN ' . preg_quote( $marker, '/' ) . '\b'; | |
| 8009 | + $pattern = '/' . $begin . '(?:(?!' . $begin . ').)*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s"; | |
| 2186 | 8010 | $out = preg_replace( $pattern, '', $contents ); |
| 2187 | 8011 | return is_string( $out ) ? $out : $contents; |
| 2188 | 8012 | } |
| 2189 | 8013 | |
| @@ -2207,8 +8031,417 @@ | ||
| 2207 | 8031 | } |
| 2208 | 8032 | return 0; |
| 2209 | 8033 | } |
| 2210 | 8034 | |
| 8035 | + /** The advanced-cache.php drop-in is ours. */ | |
| 8036 | + public const DROPIN_XSPEED = 'xspeed'; | |
| 8037 | + /** Someone else's drop-in is installed. */ | |
| 8038 | + public const DROPIN_FOREIGN = 'foreign'; | |
| 8039 | + /** No drop-in installed. */ | |
| 8040 | + public const DROPIN_NONE = 'none'; | |
| 8041 | + /** A drop-in is installed and we could not read it. */ | |
| 8042 | + public const DROPIN_UNREADABLE = 'unreadable'; | |
| 8043 | + /** | |
| 8044 | + * Present but holding nothing -- empty, or whitespace only. WP Rocket | |
| 8045 | + * truncates advanced-cache.php to 0 bytes on deactivate, and calling that | |
| 8046 | + * FOREIGN made it a permanent blocker with no owner to ask. (#391) | |
| 8047 | + */ | |
| 8048 | + public const DROPIN_ABANDONED = 'abandoned'; | |
| 8049 | + | |
| 8050 | + /** | |
| 8051 | + * Who owns wp-content/advanced-cache.php right now. | |
| 8052 | + * | |
| 8053 | + * WordPress gives every caching plugin the same single file to live in, | |
| 8054 | + * so "is there a drop-in" and "is it ours" are completely different | |
| 8055 | + * questions, and only the second one licenses a write. An unreadable | |
| 8056 | + * drop-in is deliberately its own answer rather than folding into | |
| 8057 | + * "foreign": we cannot even name what we would be destroying. | |
| 8058 | + * | |
| 8059 | + * @return string One of the DROPIN_* constants. | |
| 8060 | + */ | |
| 8061 | + public static function dropin_owner(): string { | |
| 8062 | + require_once XSPEED_DIR . 'includes/wp-cache-constant.php'; | |
| 8063 | + $target = WP_CONTENT_DIR . '/advanced-cache.php'; | |
| 8064 | + if ( ! file_exists( $target ) ) { | |
| 8065 | + return self::DROPIN_NONE; | |
| 8066 | + } | |
| 8067 | + | |
| 8068 | + $contents = self::read_file( $target ); | |
| 8069 | + if ( null === $contents ) { | |
| 8070 | + return self::DROPIN_UNREADABLE; | |
| 8071 | + } | |
| 8072 | + | |
| 8073 | + if ( xspeed_has_canonical_dropin_signature( $contents ) ) { | |
| 8074 | + return self::DROPIN_XSPEED; | |
| 8075 | + } | |
| 8076 | + | |
| 8077 | + // Nothing in the file means nothing owns it. Kept distinct from | |
| 8078 | + // FOREIGN so the acquisition gate can tell "someone else's cache" from | |
| 8079 | + // "a husk the last plugin left behind". (#391) | |
| 8080 | + if ( '' === trim( $contents ) ) { | |
| 8081 | + return self::DROPIN_ABANDONED; | |
| 8082 | + } | |
| 8083 | + | |
| 8084 | + /* | |
| 8085 | + * The other half of the same question, and it cannot be answered from | |
| 8086 | + * the bytes: a file we cannot attribute is a COMPETITOR only while | |
| 8087 | + * some page cache is actually running. With every candidate switched | |
| 8088 | + * off it is abandoned -- a hosting company's own cache, a hand-rolled | |
| 8089 | + * one, or a plugin that was deleted without cleaning up. | |
| 8090 | + * | |
| 8091 | + * Asking the detector rather than re-deriving it here is the point: | |
| 8092 | + * these two answers disagreeing is a split brain with a bad ending -- | |
| 8093 | + * acquisition_blocker() opens the gate, install_dropin() then refuses | |
| 8094 | + * on FOREIGN, and toggle() blames the filesystem for a write it never | |
| 8095 | + * attempted. One question, one answer. (#391, #393) | |
| 8096 | + */ | |
| 8097 | + if ( class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) { | |
| 8098 | + $owner = (string) ( Page_Cache_Detector::inspect()['dropin']['owner'] ?? '' ); | |
| 8099 | + | |
| 8100 | + // Attributable to a named plugin -> somebody's cache, whatever its | |
| 8101 | + // activation state. Only a file NOBODY can be shown to own, with | |
| 8102 | + // nothing running, is abandoned. | |
| 8103 | + if ( Page_Cache_Detector::OWNER_UNKNOWN === $owner | |
| 8104 | + && ! Page_Cache_Detector::another_page_cache_is_active() ) { | |
| 8105 | + return self::DROPIN_ABANDONED; | |
| 8106 | + } | |
| 8107 | + } | |
| 8108 | + | |
| 8109 | + return self::DROPIN_FOREIGN; | |
| 8110 | + } | |
| 8111 | + | |
| 8112 | + /** | |
| 8113 | + * Why xSpeed must not install its page-cache artifacts right now, or null | |
| 8114 | + * when it may. | |
| 8115 | + * | |
| 8116 | + * This is the single gate in front of every write that touches shared | |
| 8117 | + * state — the drop-in and the WP_CACHE define. Both are single-occupancy: | |
| 8118 | + * whatever is there belongs to exactly one plugin, and taking it silently | |
| 8119 | + * breaks that plugin's caching with no way back. | |
| 8120 | + * | |
| 8121 | + * Returns a user-facing string, so a REST caller can hand it straight to | |
| 8122 | + * the dashboard instead of reporting a bare failure. | |
| 8123 | + */ | |
| 8124 | + public static function acquisition_blocker(): ?string { | |
| 8125 | + Page_Cache_Detector::invalidate(); | |
| 8126 | + $verdict = Page_Cache_Detector::classify(); | |
| 8127 | + $owner = self::dropin_owner(); | |
| 8128 | + // The reason we refuse, whether that reason already names a plugin, | |
| 8129 | + // and every other page cache the detector counted anywhere in the | |
| 8130 | + // verdict. See the tail of this method for why all three are needed. | |
| 8131 | + $primary = null; | |
| 8132 | + $primary_names = false; | |
| 8133 | + $named = array(); | |
| 8134 | + foreach ( $verdict['blockers'] as $blocker ) { | |
| 8135 | + $code = (string) ( $blocker['code'] ?? '' ); | |
| 8136 | + // The shared detector quite correctly reports xSpeed itself as a | |
| 8137 | + // page-cache owner. That is not a competitor to this transaction. | |
| 8138 | + // | |
| 8139 | + // Except when the two disagree about the DROP-IN. The detector | |
| 8140 | + // accepts our marker anywhere in a file's header; this plugin's | |
| 8141 | + // own check requires it to open the header, because only this | |
| 8142 | + // side authorizes overwriting and deleting. A foreign drop-in | |
| 8143 | + // that merely carries our marker further down its header is | |
| 8144 | + // attributed to us by the detector, and skipping it here dropped | |
| 8145 | + // the refusal entirely — the write then failed on the stricter | |
| 8146 | + // check and the user was told to go and fix file permissions. | |
| 8147 | + // Where they disagree, believe the stricter one. | |
| 8148 | + if ( self::PLUGIN_FILE === ( $blocker['plugin'] ?? null ) ) { | |
| 8149 | + $about_dropin = in_array( | |
| 8150 | + $code, | |
| 8151 | + array( | |
| 8152 | + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN, | |
| 8153 | + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN, | |
| 8154 | + ), | |
| 8155 | + true | |
| 8156 | + ); | |
| 8157 | + if ( ! $about_dropin || self::DROPIN_XSPEED === $owner ) { | |
| 8158 | + continue; | |
| 8159 | + } | |
| 8160 | + } | |
| 8161 | + if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) { | |
| 8162 | + continue; | |
| 8163 | + } | |
| 8164 | + /* | |
| 8165 | + * Another plugin's drop-in is no longer a refusal. | |
| 8166 | + * | |
| 8167 | + * It used to be: whoever held advanced-cache.php kept it, and | |
| 8168 | + * enabling was blocked with "deactivate its page cache first". | |
| 8169 | + * That left a user who had asked for our cache with no way to get | |
| 8170 | + * it — on a live site the only exit was deleting a file over SSH, | |
| 8171 | + * and the message could not even say which of its two causes | |
| 8172 | + * applied ("is active OR owns advanced-cache.php"). | |
| 8173 | + * | |
| 8174 | + * Turning the page cache on is the instruction to serve pages | |
| 8175 | + * from cache, and that is not possible without this file. So we | |
| 8176 | + * take it, and the dashboard says whose file it is first — | |
| 8177 | + * dropin_disclosure() names the owner, the user confirms, and | |
| 8178 | + * install_dropin() writes ours over the top. | |
| 8179 | + * | |
| 8180 | + * A still-active competitor is deliberately NOT re-added as a | |
| 8181 | + * blocker below: it is caught by `active_page_cache`, which the | |
| 8182 | + * capability rule already downgrades to a note. Two page caches | |
| 8183 | + * installed at once is the user's call to make, not ours to | |
| 8184 | + * refuse — they just told us which one they want serving. | |
| 8185 | + * | |
| 8186 | + * UNREADABLE is the exception and stays a refusal: we cannot name | |
| 8187 | + * what we would destroy, and install_dropin() refuses it too, so | |
| 8188 | + * opening the gate here would only produce a failed write. | |
| 8189 | + */ | |
| 8190 | + $about_dropin_owner = in_array( | |
| 8191 | + $code, | |
| 8192 | + array( | |
| 8193 | + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN, | |
| 8194 | + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN, | |
| 8195 | + ), | |
| 8196 | + true | |
| 8197 | + ); | |
| 8198 | + if ( $about_dropin_owner && self::DROPIN_UNREADABLE !== $owner ) { | |
| 8199 | + continue; | |
| 8200 | + } | |
| 8201 | + /* | |
| 8202 | + * Capability is not possession. `active_page_cache` and | |
| 8203 | + * `multiple_page_caches` both fire on a plugin that merely CAN | |
| 8204 | + * cache pages — the detector cannot prove a competitor's page | |
| 8205 | + * cache is off, so it counts it. As a warning that is right. As | |
| 8206 | + * a gate it refuses a write that takes nothing from anyone. | |
| 8207 | + * | |
| 8208 | + * This gate guards exactly two files: advanced-cache.php and the | |
| 8209 | + * WP_CACHE define that loads it. A plugin that does not hold the | |
| 8210 | + * drop-in has nothing here for us to overwrite, and one that does | |
| 8211 | + * is already refused by `foreign_dropin` / `unknown_dropin` a few | |
| 8212 | + * lines up. So when the field is ours or empty, an active | |
| 8213 | + * competitor is a note, not a refusal. | |
| 8214 | + * | |
| 8215 | + * QA found this on a live OpenLiteSpeed site keeping LiteSpeed | |
| 8216 | + * Cache for images and CDN with its page cache off, while xSpeed | |
| 8217 | + * served the pages. One click of the off switch and it could not | |
| 8218 | + * be turned back on: the only way out was deactivating LiteSpeed | |
| 8219 | + * entirely, and the message told them to "deactivate its page | |
| 8220 | + * cache" — which they already had. | |
| 8221 | + */ | |
| 8222 | + $about_capability = in_array( | |
| 8223 | + $code, | |
| 8224 | + array( | |
| 8225 | + Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE, | |
| 8226 | + Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES, | |
| 8227 | + ), | |
| 8228 | + true | |
| 8229 | + ); | |
| 8230 | + /* | |
| 8231 | + * FOREIGN belongs in this list now, and it is the whole point. | |
| 8232 | + * | |
| 8233 | + * The rule is still "capability is not possession": these two | |
| 8234 | + * blockers fire on any plugin that CAN cache pages, which the | |
| 8235 | + * detector cannot prove is switched off. What changed is that a | |
| 8236 | + * competitor holding the drop-in no longer stops us either — we | |
| 8237 | + * take the file, having said whose it is. So there is nothing | |
| 8238 | + * left for a merely-installed competitor to protect, and keeping | |
| 8239 | + * the refusal here would put back the dead end by another route: | |
| 8240 | + * "another page cache is active" on a site where the user has | |
| 8241 | + * just told us, by name, which cache they want serving. | |
| 8242 | + * | |
| 8243 | + * UNREADABLE is deliberately still absent — that one refuses. | |
| 8244 | + */ | |
| 8245 | + if ( $about_capability | |
| 8246 | + && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE, self::DROPIN_FOREIGN, self::DROPIN_ABANDONED ), true ) ) { | |
| 8247 | + continue; | |
| 8248 | + } | |
| 8249 | + if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) { | |
| 8250 | + $others = self::other_page_cache_names( $blocker ); | |
| 8251 | + if ( array() === $others ) { | |
| 8252 | + // We were the only owner counted — nothing to refuse — | |
| 8253 | + // unless the list is missing entirely, which is an older | |
| 8254 | + // detector copy we still must not talk past. | |
| 8255 | + if ( null === $primary && array() === (array) ( $blocker['plugins'] ?? array() ) ) { | |
| 8256 | + $primary = self::ownership_blocker_message( '', '' ); | |
| 8257 | + } | |
| 8258 | + continue; | |
| 8259 | + } | |
| 8260 | + $named = array_values( array_unique( array_merge( $named, $others ) ) ); | |
| 8261 | + if ( null === $primary ) { | |
| 8262 | + $primary = self::multiple_page_caches_message( $others ); | |
| 8263 | + $primary_names = true; | |
| 8264 | + } | |
| 8265 | + continue; | |
| 8266 | + } | |
| 8267 | + if ( null === $primary ) { | |
| 8268 | + $label = (string) ( $blocker['label'] ?? '' ); | |
| 8269 | + $primary = self::ownership_blocker_message( $code, $label ); | |
| 8270 | + $primary_names = '' !== $label; | |
| 8271 | + } | |
| 8272 | + } | |
| 8273 | + | |
| 8274 | + if ( null === $primary ) { | |
| 8275 | + return null; | |
| 8276 | + } | |
| 8277 | + /* | |
| 8278 | + * The first blocker decides WHY we refuse; it does not always know | |
| 8279 | + * WHO. The detector can only attribute a drop-in it recognises, and | |
| 8280 | + * an unrecognised one produces "its owner cannot be proved" — the | |
| 8281 | + * sentence a W3 Total Cache site used to get while a later blocker in | |
| 8282 | + * the same verdict was holding the name "W3 Total Cache". | |
| 8283 | + * | |
| 8284 | + * So keep the reason and add the names, rather than swapping one for | |
| 8285 | + * the other: the plugin the user must deal with is not necessarily | |
| 8286 | + * the owner of the file we could not identify, and promoting the | |
| 8287 | + * named blocker would have told them to deactivate a plugin that is | |
| 8288 | + * not what is in their way. | |
| 8289 | + */ | |
| 8290 | + if ( $primary_names || array() === $named ) { | |
| 8291 | + return $primary; | |
| 8292 | + } | |
| 8293 | + if ( 1 === count( $named ) ) { | |
| 8294 | + return sprintf( | |
| 8295 | + /* translators: 1: the refusal reason, 2: a page-caching plugin's name. */ | |
| 8296 | + __( '%1$s %2$s is also active on this site — deactivate its page cache before enabling xSpeed.', 'xspeed' ), | |
| 8297 | + $primary, | |
| 8298 | + $named[0] | |
| 8299 | + ); | |
| 8300 | + } | |
| 8301 | + return sprintf( | |
| 8302 | + /* translators: 1: the refusal reason, 2: comma-separated page-caching plugin names. */ | |
| 8303 | + __( '%1$s These page caches are also active on this site: %2$s. Deactivate them before enabling xSpeed.', 'xspeed' ), | |
| 8304 | + $primary, | |
| 8305 | + implode( ', ', $named ) | |
| 8306 | + ); | |
| 8307 | + } | |
| 8308 | + | |
| 8309 | + /** How xSpeed's own plugin file appears in the detector's catalog. */ | |
| 8310 | + private const PLUGIN_FILE = 'xspeed/xspeed.php'; | |
| 8311 | + | |
| 8312 | + /** | |
| 8313 | + * Name the OTHER page caches behind a `multiple_page_caches` refusal. | |
| 8314 | + * | |
| 8315 | + * This blocker has no single owner, so the detector leaves `plugin` and | |
| 8316 | + * `label` null and hands over the full list instead. Left unhandled it | |
| 8317 | + * fell through to the anonymous fallback sentence — and it is the blocker | |
| 8318 | + * an ordinary site hits most: xSpeed counts toward "multiple", so the | |
| 8319 | + * count reaches two the moment one other page-cache plugin is activated, | |
| 8320 | + * even one that has not written a drop-in. A site running our cache that | |
| 8321 | + * activated LiteSpeed could not re-enable it and was told only that "the | |
| 8322 | + * page-cache field is occupied". | |
| 8323 | + * | |
| 8324 | + * Returns an empty list when xSpeed was the only owner counted, or when | |
| 8325 | + * an older detector copy sent no list at all — the caller distinguishes | |
| 8326 | + * the two by looking at `plugins`. | |
| 8327 | + * | |
| 8328 | + * @param array<string,mixed> $blocker One entry from Detector::classify(). | |
| 8329 | + * @return string[] | |
| 8330 | + */ | |
| 8331 | + private static function other_page_cache_names( array $blocker ): array { | |
| 8332 | + $plugins = array_values( (array) ( $blocker['plugins'] ?? array() ) ); | |
| 8333 | + $labels = array_values( (array) ( $blocker['labels'] ?? array() ) ); | |
| 8334 | + | |
| 8335 | + $others = array(); | |
| 8336 | + foreach ( $plugins as $i => $plugin ) { | |
| 8337 | + if ( self::PLUGIN_FILE === $plugin ) { | |
| 8338 | + continue; | |
| 8339 | + } | |
| 8340 | + $others[] = isset( $labels[ $i ] ) && '' !== (string) $labels[ $i ] | |
| 8341 | + ? (string) $labels[ $i ] | |
| 8342 | + : (string) $plugin; | |
| 8343 | + } | |
| 8344 | + return array_values( array_unique( $others ) ); | |
| 8345 | + } | |
| 8346 | + | |
| 8347 | + /** | |
| 8348 | + * The refusal sentence for a `multiple_page_caches` blocker. | |
| 8349 | + * | |
| 8350 | + * @param string[] $others Page caches other than xSpeed. Never empty. | |
| 8351 | + */ | |
| 8352 | + private static function multiple_page_caches_message( array $others ): string { | |
| 8353 | + if ( 1 === count( $others ) ) { | |
| 8354 | + return self::ownership_blocker_message( Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE, $others[0] ); | |
| 8355 | + } | |
| 8356 | + return sprintf( | |
| 8357 | + /* translators: %s: comma-separated list of page-caching plugin names. */ | |
| 8358 | + __( 'More than one page cache is active on this site (%s). Turn off the other page caches before enabling xSpeed.', 'xspeed' ), | |
| 8359 | + implode( ', ', $others ) | |
| 8360 | + ); | |
| 8361 | + } | |
| 8362 | + | |
| 8363 | + private static function ownership_blocker_message( string $code, string $label ): string { | |
| 8364 | + if ( '' !== $label ) { | |
| 8365 | + return sprintf( __( '%s is active or owns advanced-cache.php. Deactivate its page cache before enabling xSpeed.', 'xspeed' ), $label ); | |
| 8366 | + } | |
| 8367 | + $messages = array( | |
| 8368 | + 'wp_cache_orphaned' => __( 'WP_CACHE is true but no page-cache drop-in owner can be proved. xSpeed will not claim it.', 'xspeed' ), | |
| 8369 | + 'wp_cache_duplicate' => __( 'wp-config.php defines WP_CACHE more than once. Remove the duplicate before enabling the cache.', 'xspeed' ), | |
| 8370 | + 'wp_cache_dynamic' => __( 'WP_CACHE is set from an expression in wp-config.php. xSpeed will not rewrite it.', 'xspeed' ), | |
| 8371 | + 'wp_cache_conditional' => __( 'WP_CACHE is defined inside a conditional in wp-config.php, so xSpeed cannot tell what it will be. Move it to a plain define before enabling the cache.', 'xspeed' ), | |
| 8372 | + 'wp_config_unreadable' => __( 'wp-config.php cannot be read, so xSpeed cannot safely change page-cache ownership.', 'xspeed' ), | |
| 8373 | + 'unknown_dropin' => __( 'advanced-cache.php is occupied but its owner cannot be proved. xSpeed will not replace it.', 'xspeed' ), | |
| 8374 | + 'unreadable_dropin' => __( 'advanced-cache.php cannot be read, so xSpeed cannot prove its owner.', 'xspeed' ), | |
| 8375 | + ); | |
| 8376 | + return $messages[ $code ] ?? __( 'The page-cache field is occupied or cannot be verified. xSpeed will not change it.', 'xspeed' ); | |
| 8377 | + } | |
| 8378 | + | |
| 8379 | + /** | |
| 8380 | + * How WP_CACHE is written in wp-config.php, as opposed to what it | |
| 8381 | + * evaluates to at runtime. | |
| 8382 | + * | |
| 8383 | + * The literal is what matters to a writer: a value behind an expression, | |
| 8384 | + * or two competing defines, cannot be rewritten by a regex without | |
| 8385 | + * guessing — and a wrong guess silently disables page caching (ours or | |
| 8386 | + * someone else's) with no error anywhere. | |
| 8387 | + * | |
| 8388 | + * @return string undefined | true | false | duplicate | dynamic | conditional | unreadable | |
| 8389 | + */ | |
| 8390 | + public static function wp_cache_define_state(): string { | |
| 8391 | + $path = self::wp_config_path(); | |
| 8392 | + if ( '' === $path ) { | |
| 8393 | + return 'unreadable'; | |
| 8394 | + } | |
| 8395 | + | |
| 8396 | + $config = self::read_file( $path ); | |
| 8397 | + if ( null === $config ) { | |
| 8398 | + return 'unreadable'; | |
| 8399 | + } | |
| 8400 | + | |
| 8401 | + require_once XSPEED_DIR . 'includes/wp-cache-constant.php'; | |
| 8402 | + $parsed = \xspeed_parse_wp_cache_defines( $config ); | |
| 8403 | + return $parsed['state']; | |
| 8404 | + } | |
| 8405 | + | |
| 8406 | + /** | |
| 8407 | + * Classify the captured right-hand side of a WP_CACHE define. | |
| 8408 | + * | |
| 8409 | + * Hosts and older tutorials write the value several ways — | |
| 8410 | + * `1`, `'1'`, `TRUE` — and all of them are literals a rewrite can safely | |
| 8411 | + * replace. Only a value we cannot evaluate by looking at it (a variable, a | |
| 8412 | + * function call, a ternary) counts as dynamic, because that is the case | |
| 8413 | + * where rewriting means guessing. | |
| 8414 | + * | |
| 8415 | + * @return string true | false | dynamic | |
| 8416 | + */ | |
| 8417 | + private static function classify_wp_cache_literal( string $raw ): string { | |
| 8418 | + $literal = strtolower( trim( $raw ) ); | |
| 8419 | + $literal = trim( $literal, "'\"" ); | |
| 8420 | + | |
| 8421 | + if ( in_array( $literal, array( 'true', '1' ), true ) ) { | |
| 8422 | + return 'true'; | |
| 8423 | + } | |
| 8424 | + if ( in_array( $literal, array( 'false', '0', '', 'null' ), true ) ) { | |
| 8425 | + return 'false'; | |
| 8426 | + } | |
| 8427 | + return 'dynamic'; | |
| 8428 | + } | |
| 8429 | + | |
| 8430 | + /** | |
| 8431 | + * Read a file for an ownership decision. Null on any failure — callers | |
| 8432 | + * treat null as "unknown", never as "empty", because an empty string | |
| 8433 | + * would read as "no marker found" and license an overwrite. | |
| 8434 | + */ | |
| 8435 | + private static function read_file( string $path ): ?string { | |
| 8436 | + if ( ! is_readable( $path ) ) { | |
| 8437 | + return null; | |
| 8438 | + } | |
| 8439 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Ownership check on a local file; WP_Filesystem would need credentials we must not prompt for here. | |
| 8440 | + $contents = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed read is a valid answer ("unknown"), not an error to surface. | |
| 8441 | + return is_string( $contents ) ? $contents : null; | |
| 8442 | + } | |
| 8443 | + | |
| 2211 | 8444 | public static function install_dropin() { |
| 2212 | 8445 | $source = XSPEED_DIR . 'includes/advanced-cache.php'; |
| 2213 | 8446 | $target = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 2214 | 8447 | if ( ! file_exists( $source ) ) { |
| @@ -2214,8 +8447,26 @@ | ||
| 2214 | 8447 | if ( ! file_exists( $source ) ) { |
| 2215 | 8448 | return false; |
| 2216 | 8449 | } |
| 2217 | 8450 | |
| 8451 | + /* | |
| 8452 | + * A drop-in we cannot READ is the one thing still refused here. Not | |
| 8453 | + * because of who owns it — we no longer refuse on ownership — but | |
| 8454 | + * because an unreadable file is usually a permissions problem, and | |
| 8455 | + * writing over it would fail anyway or destroy something we were | |
| 8456 | + * never able to look at. | |
| 8457 | + * | |
| 8458 | + * Everything else is ours to take. Enabling the page cache IS the | |
| 8459 | + * user's instruction to serve the cache, and serving it means holding | |
| 8460 | + * advanced-cache.php; the dashboard says whose file it is replacing | |
| 8461 | + * before the click (Page_Cache_Detector::dropin_disclosure()), so the | |
| 8462 | + * takeover is consented rather than silent. | |
| 8463 | + */ | |
| 8464 | + $owner = self::dropin_owner(); | |
| 8465 | + if ( self::DROPIN_UNREADABLE === $owner ) { | |
| 8466 | + return false; | |
| 8467 | + } | |
| 8468 | + | |
| 2218 | 8469 | global $wp_filesystem; |
| 2219 | 8470 | if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 2220 | 8471 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 2221 | 8472 | } |
| @@ -2239,35 +8490,85 @@ | ||
| 2239 | 8490 | str_replace( "'", "\\'", self::hits_log_path() ), |
| 2240 | 8491 | $source_contents |
| 2241 | 8492 | ); |
| 2242 | 8493 | |
| 8494 | + // Bake the cookie + user-agent exclusion rules in too. The drop-in | |
| 8495 | + // runs before WordPress loads, so it cannot read the settings — and | |
| 8496 | + // without them it served the shared anonymous page to any visitor | |
| 8497 | + // PHP had not yet seen (a first-time cart visitor, a bypassed bot). | |
| 8498 | + // The generic bypass cookie only covers repeat visitors; these two | |
| 8499 | + // regexes are what make the FIRST request correct. | |
| 8500 | + // | |
| 8501 | + // Both are already fully escaped by Server_Rules, and each is | |
| 8502 | + // embedded as a single-quoted PHP literal, so a settings value can | |
| 8503 | + // neither break the drop-in's syntax nor execute. | |
| 8504 | + $cache_opts = Settings_Manager::get( 'cache' ); | |
| 8505 | + $cookie_rule = Server_Rules::cookie_rule( | |
| 8506 | + is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array() | |
| 8507 | + ); | |
| 8508 | + $ua_rule = Server_Rules::user_agent_rule( | |
| 8509 | + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array() | |
| 8510 | + ); | |
| 8511 | + | |
| 8512 | + $source_contents = str_replace( | |
| 8513 | + '@@XSPEED_COOKIE_RE@@', | |
| 8514 | + str_replace( "'", "\\'", $cookie_rule['regex'] ), | |
| 8515 | + $source_contents | |
| 8516 | + ); | |
| 8517 | + $source_contents = str_replace( | |
| 8518 | + '@@XSPEED_UA_RE@@', | |
| 8519 | + str_replace( "'", "\\'", $ua_rule['regex'] ), | |
| 8520 | + $source_contents | |
| 8521 | + ); | |
| 8522 | + | |
| 8523 | + /* | |
| 8524 | + * Ours or absent — the ownership gate at the top of this method ruled | |
| 8525 | + * out everything else. The old code path that moved a foreign drop-in | |
| 8526 | + * into uploads/xspeed-backups and wrote ours on top is gone: it | |
| 8527 | + * disabled the other plugin's page cache the moment an xSpeed install | |
| 8528 | + * ran, with nothing in its own UI to explain why. | |
| 8529 | + */ | |
| 8530 | + | |
| 8531 | + // Bake the configured cache lifetime in. The drop-in runs before | |
| 8532 | + // WordPress loads, so it cannot read the option — it previously fell | |
| 8533 | + // back to a hardcoded 86400 for every ordinary page, because | |
| 8534 | + // write_meta() only emits a `ttl` sidecar when the value DIFFERS from | |
| 8535 | + // the page default. That made the admin's "1 to 720 hours" control a | |
| 8536 | + // no-op at the layer that actually answers the request: 12h served | |
| 8537 | + // stale for up to 2x the configured lifetime, and 168h lost the fast | |
| 8538 | + // path for 6 of every 7 days (issue #240). | |
| 8539 | + // | |
| 8540 | + // This is re-baked on every cache settings save (see CacheModule::boot), | |
| 8541 | + // exactly like the cookie / user-agent rules above. | |
| 8542 | + $expiry_hours = isset( $cache_opts['cache_expiry'] ) ? (int) $cache_opts['cache_expiry'] : 24; | |
| 8543 | + if ( $expiry_hours < 1 || $expiry_hours > 720 ) { | |
| 8544 | + $expiry_hours = 24; | |
| 8545 | + } | |
| 8546 | + $source_contents = str_replace( | |
| 8547 | + '@@XSPEED_DEFAULT_TTL@@', | |
| 8548 | + (string) ( $expiry_hours * HOUR_IN_SECONDS ), | |
| 8549 | + $source_contents | |
| 8550 | + ); | |
| 8551 | + | |
| 8552 | + // Bake the site-wide edge answer in. Resolved in a `bake` context, so | |
| 8553 | + // nothing per-page and nothing a request header vouched for can reach | |
| 8554 | + // it: a bake runs once, in an admin or CLI request, and answers for | |
| 8555 | + // every page on the site. A page that disagrees gets a sidecar | |
| 8556 | + // instead — see per_entry_edge_headers(). | |
| 8557 | + // | |
| 8558 | + // Re-baked on every cache settings save (see CacheModule::boot), | |
| 8559 | + // exactly like the cookie, user-agent and lifetime rules above. | |
| 8560 | + $source_contents = str_replace( | |
| 8561 | + "'@@XSPEED_EDGE_HEADERS@@'", | |
| 8562 | + self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ), | |
| 8563 | + $source_contents | |
| 8564 | + ); | |
| 8565 | + | |
| 2243 | 8566 | if ( file_exists( $target ) ) { |
| 2244 | 8567 | $existing = $wp_filesystem->get_contents( $target ); |
| 2245 | - $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' ); | |
| 2246 | - | |
| 2247 | - if ( $is_xspeed ) { | |
| 2248 | - if ( $existing === $source_contents ) { | |
| 2249 | - return true; | |
| 2250 | - } | |
| 2251 | - return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE ); | |
| 8568 | + if ( is_string( $existing ) && $existing === $source_contents ) { | |
| 8569 | + return true; | |
| 2252 | 8570 | } |
| 2253 | - | |
| 2254 | - // Foreign drop-in (e.g. left over from another cache plugin) — back it up | |
| 2255 | - // before overwriting so the user can recover if needed. Uploads dir | |
| 2256 | - // (not wp-content root) keeps the backup out of WordPress's reserved | |
| 2257 | - // drop-in location. | |
| 2258 | - $upload = wp_upload_dir( null, false ); | |
| 2259 | - $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false; | |
| 2260 | - if ( $basedir ) { | |
| 2261 | - if ( ! file_exists( $basedir ) ) { | |
| 2262 | - wp_mkdir_p( $basedir ); | |
| 2263 | - self::write_silence( $basedir ); | |
| 2264 | - } | |
| 2265 | - $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak'; | |
| 2266 | - $wp_filesystem->move( $target, $backup, true ); | |
| 2267 | - } else { | |
| 2268 | - $wp_filesystem->delete( $target ); | |
| 2269 | - } | |
| 2270 | 8571 | } |
| 2271 | 8572 | |
| 2272 | 8573 | return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE ); |
| 2273 | 8574 | } |
| @@ -2287,63 +8588,211 @@ | ||
| 2287 | 8588 | return; |
| 2288 | 8589 | } |
| 2289 | 8590 | |
| 2290 | 8591 | $contents = $wp_filesystem->get_contents( $target ); |
| 2291 | - if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) { | |
| 8592 | + if ( is_string( $contents ) && xspeed_has_canonical_dropin_signature( $contents ) ) { | |
| 2292 | 8593 | wp_delete_file( $target ); |
| 2293 | 8594 | } |
| 2294 | 8595 | } |
| 2295 | 8596 | |
| 8597 | + /** | |
| 8598 | + * Where wp-config.php actually is. | |
| 8599 | + * | |
| 8600 | + * WordPress core supports the file one directory ABOVE ABSPATH, and | |
| 8601 | + * plenty of installs use that layout. This used to look only in ABSPATH | |
| 8602 | + * and bail, so on those sites the constant could never be written — while | |
| 8603 | + * Health, which did fall back to the parent, reported the file writable | |
| 8604 | + * and told the user to toggle the cache off and on. The advice could | |
| 8605 | + * never work, and its fallback hint ("another plugin left WP_CACHE false | |
| 8606 | + * behind") was wrong too: there was no define at all. (#19, QA on #174) | |
| 8607 | + * | |
| 8608 | + * Returns '' when no wp-config.php can be found in either location. | |
| 8609 | + */ | |
| 8610 | + public static function wp_config_path(): string { | |
| 8611 | + $candidates = array( ABSPATH . 'wp-config.php', dirname( ABSPATH ) . '/wp-config.php' ); | |
| 8612 | + foreach ( $candidates as $path ) { | |
| 8613 | + if ( file_exists( $path ) ) { | |
| 8614 | + return $path; | |
| 8615 | + } | |
| 8616 | + } | |
| 8617 | + return ''; | |
| 8618 | + } | |
| 8619 | + | |
| 8620 | + /** | |
| 8621 | + * Can we actually write the constant right now? | |
| 8622 | + * | |
| 8623 | + * This is the single oracle for that question — Health asks THIS rather | |
| 8624 | + * than running its own `wp_is_writable()` test, so the message a user | |
| 8625 | + * reads can never disagree with what the plugin will do. The two differed | |
| 8626 | + * in both directions: on the path (above) and on the test itself, since | |
| 8627 | + * an FTP/SSH WP_Filesystem transport can refuse a file that | |
| 8628 | + * `wp_is_writable()` reports as writable. (#19, QA on #174) | |
| 8629 | + */ | |
| 8630 | + public static function can_write_wp_config(): bool { | |
| 8631 | + $wp_config = self::wp_config_path(); | |
| 8632 | + if ( '' === $wp_config ) { | |
| 8633 | + return false; | |
| 8634 | + } | |
| 8635 | + | |
| 8636 | + global $wp_filesystem; | |
| 8637 | + if ( ! function_exists( 'WP_Filesystem' ) ) { | |
| 8638 | + require_once ABSPATH . 'wp-admin/includes/file.php'; | |
| 8639 | + } | |
| 8640 | + WP_Filesystem(); | |
| 8641 | + return (bool) ( $wp_filesystem && $wp_filesystem->is_writable( $wp_config ) ); | |
| 8642 | + } | |
| 8643 | + | |
| 2296 | 8644 | public static function set_wp_cache_constant( $enable ) { |
| 2297 | - $wp_config = ABSPATH . 'wp-config.php'; | |
| 2298 | - if ( ! file_exists( $wp_config ) ) { | |
| 8645 | + $wp_config = self::wp_config_path(); | |
| 8646 | + if ( '' === $wp_config ) { | |
| 2299 | 8647 | return false; |
| 2300 | 8648 | } |
| 2301 | 8649 | |
| 8650 | + /* | |
| 8651 | + * WP_CACHE belongs to whoever owns the drop-in — it is the switch that | |
| 8652 | + * makes core load that one file. Editing it while someone else's | |
| 8653 | + * drop-in is installed either turns THEIR cache on or off; either way | |
| 8654 | + * it is a write to another plugin's state. So: no ownership, no edit. | |
| 8655 | + */ | |
| 8656 | + $owner = self::dropin_owner(); | |
| 8657 | + if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) { | |
| 8658 | + return false; | |
| 8659 | + } | |
| 8660 | + | |
| 8661 | + $state = self::wp_cache_define_state(); | |
| 8662 | + if ( 'duplicate' === $state || 'dynamic' === $state ) { | |
| 8663 | + // Two competing defines, or a value behind an expression. A regex | |
| 8664 | + // rewrite here is a guess, and a wrong guess silently kills page | |
| 8665 | + // caching with no error anywhere. | |
| 8666 | + return false; | |
| 8667 | + } | |
| 2302 | 8668 | global $wp_filesystem; |
| 2303 | 8669 | if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 2304 | 8670 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 2305 | 8671 | } |
| 2306 | 8672 | WP_Filesystem(); |
| 2307 | - if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) { | |
| 8673 | + if ( ! $wp_filesystem ) { | |
| 2308 | 8674 | return false; |
| 2309 | 8675 | } |
| 2310 | 8676 | |
| 2311 | 8677 | $config = $wp_filesystem->get_contents( $wp_config ); |
| 8678 | + if ( ! is_string( $config ) ) { | |
| 8679 | + return false; | |
| 8680 | + } | |
| 8681 | + require_once XSPEED_DIR . 'includes/wp-cache-constant.php'; | |
| 8682 | + $marker = $enable ? self::wp_cache_receipt() : ''; | |
| 8683 | + $updated = xspeed_rewrite_wp_cache_define( $config, (bool) $enable, $marker ); | |
| 8684 | + if ( ! is_string( $updated ) ) { | |
| 8685 | + return false; | |
| 8686 | + } | |
| 2312 | 8687 | |
| 2313 | - if ( $enable ) { | |
| 2314 | - // Own the constant. A previous caching plugin (e.g. WP Rocket sets | |
| 2315 | - // it false on deactivate) can leave `define( 'WP_CACHE', false );` | |
| 2316 | - // behind — presence alone is not enough, the VALUE must be true or | |
| 2317 | - // WordPress never loads advanced-cache.php and our drop-in is dead. | |
| 2318 | - if ( preg_match( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,/", $config ) ) { | |
| 2319 | - $rewritten = preg_replace( | |
| 2320 | - "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*[^)]*\\)\\s*;/", | |
| 2321 | - "define( 'WP_CACHE', true );", | |
| 2322 | - $config, | |
| 2323 | - 1 | |
| 2324 | - ); | |
| 2325 | - // If an existing define was already `true`, the rewrite is a | |
| 2326 | - // no-op string-wise; either way we end on WP_CACHE === true. | |
| 2327 | - if ( null !== $rewritten ) { | |
| 2328 | - $config = $rewritten; | |
| 2329 | - } | |
| 2330 | - } else { | |
| 2331 | - $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 ); | |
| 8688 | + /* | |
| 8689 | + * Removing a WP_CACHE line we cannot prove we wrote is somebody else's | |
| 8690 | + * configuration, so a disable needs either our drop-in or our receipt. | |
| 8691 | + * | |
| 8692 | + * The test is on the REWRITE, not on the request: it used to run | |
| 8693 | + * before the rewrite and refuse a disable that had nothing to remove. | |
| 8694 | + * An ordinary site with no drop-in and no define — every fresh | |
| 8695 | + * install — therefore failed to turn page caching off, so the | |
| 8696 | + * onboarding wizard reported "setup needs attention" to every user who | |
| 8697 | + * declined it and Migration reported the cache import as failed. | |
| 8698 | + */ | |
| 8699 | + if ( ! $enable && $updated !== $config | |
| 8700 | + && self::DROPIN_XSPEED !== $owner | |
| 8701 | + && ! self::wp_cache_receipt_matches_source( $config ) ) { | |
| 8702 | + return false; | |
| 8703 | + } | |
| 8704 | + | |
| 8705 | + /* | |
| 8706 | + * Nothing to write. auto_heal() runs the whole enable transaction on | |
| 8707 | + * every admin_init, so without this every wp-admin request rewrote | |
| 8708 | + * wp-config.php with byte-identical content: pointless disk churn | |
| 8709 | + * that trips host file-integrity monitors and widens the window for | |
| 8710 | + * a concurrent write on a busy admin. | |
| 8711 | + * | |
| 8712 | + * It is also what makes a correct WP_CACHE on a read-only | |
| 8713 | + * wp-config.php succeed. A managed host that ships the file | |
| 8714 | + * unwritable, on a site where the user already pasted the define, | |
| 8715 | + * is in the state we wanted — the writability test below is about | |
| 8716 | + * whether we can CHANGE the file, and there is nothing to change. | |
| 8717 | + */ | |
| 8718 | + if ( $updated === $config ) { | |
| 8719 | + if ( ! $enable ) { | |
| 8720 | + // Our line is not in the file, so the receipt that proved we | |
| 8721 | + // wrote it is stale — drop it on the same terms as a real | |
| 8722 | + // removal, or uninstall keeps a claim on nothing. | |
| 8723 | + delete_option( 'xspeed_page_cache_ownership_receipt' ); | |
| 2332 | 8724 | } |
| 2333 | - } else { | |
| 2334 | - $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config ); | |
| 8725 | + return true; | |
| 2335 | 8726 | } |
| 2336 | 8727 | |
| 2337 | - return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE ); | |
| 8728 | + if ( ! $wp_filesystem->is_writable( $wp_config ) ) { | |
| 8729 | + return false; | |
| 8730 | + } | |
| 8731 | + $written = (bool) $wp_filesystem->put_contents( $wp_config, $updated, FS_CHMOD_FILE ); | |
| 8732 | + if ( $written && ! $enable ) { | |
| 8733 | + delete_option( 'xspeed_page_cache_ownership_receipt' ); | |
| 8734 | + } | |
| 8735 | + return $written; | |
| 2338 | 8736 | } |
| 2339 | 8737 | |
| 8738 | + private static function wp_cache_receipt(): string { | |
| 8739 | + $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' ); | |
| 8740 | + if ( is_string( $receipt ) && preg_match( '/^[a-f0-9]{32}$/', $receipt ) ) { | |
| 8741 | + return $receipt; | |
| 8742 | + } | |
| 8743 | + $receipt = substr( hash( 'sha256', XSPEED_DIR . microtime( true ) . mt_rand() ), 0, 32 ); | |
| 8744 | + update_option( 'xspeed_page_cache_ownership_receipt', $receipt, false ); | |
| 8745 | + return $receipt; | |
| 8746 | + } | |
| 8747 | + | |
| 2340 | 8748 | /** |
| 8749 | + * Is the WP_CACHE line in wp-config.php ours to REMOVE? | |
| 8750 | + * | |
| 8751 | + * Two different questions live here and only one of them matters. "Did we | |
| 8752 | + * write it" is answered by our drop-in on disk or by our receipt comment | |
| 8753 | + * beside the define. "Is it ours to remove" also asks what the line does | |
| 8754 | + * NOW — and once a competitor owns advanced-cache.php, a line we wrote | |
| 8755 | + * ourselves is the switch that loads THEIR drop-in. They had no reason to | |
| 8756 | + * touch an already-true define, so our receipt is still sitting on it. | |
| 8757 | + * Removing it there would stop their live page cache. | |
| 8758 | + * | |
| 8759 | + * So a foreign or unreadable owner is never ours to remove, whatever the | |
| 8760 | + * receipt says, and the caller treats that as a reason to leave the line | |
| 8761 | + * and get on with disabling our own cache — not as a reason to refuse. | |
| 8762 | + */ | |
| 8763 | + private static function wp_cache_define_is_ours_to_remove( string $owner ): bool { | |
| 8764 | + if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) { | |
| 8765 | + return false; | |
| 8766 | + } | |
| 8767 | + if ( self::DROPIN_XSPEED === $owner ) { | |
| 8768 | + return true; | |
| 8769 | + } | |
| 8770 | + $path = self::wp_config_path(); | |
| 8771 | + if ( '' === $path ) { | |
| 8772 | + return false; | |
| 8773 | + } | |
| 8774 | + $config = self::read_file( $path ); | |
| 8775 | + return is_string( $config ) && self::wp_cache_receipt_matches_source( $config ); | |
| 8776 | + } | |
| 8777 | + | |
| 8778 | + private static function wp_cache_receipt_matches_source( string $source ): bool { | |
| 8779 | + $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' ); | |
| 8780 | + require_once XSPEED_DIR . 'includes/wp-cache-constant.php'; | |
| 8781 | + return xspeed_wp_cache_receipt_matches( $source, $receipt ); | |
| 8782 | + } | |
| 8783 | + | |
| 8784 | + /** | |
| 2341 | 8785 | * Admin-bar purge menu — a parent node plus one child per visible cache |
| 2342 | 8786 | * type (LiteSpeed-style), instead of a single "Purge All" link. Each |
| 2343 | 8787 | * child posts to the same admin-post handler with its type slug. The |
| 2344 | 8788 | * per-type items only appear for active/licensed modules; "Purge All" |
| 2345 | 8789 | * always shows and always sweeps everything. (FBS-83114) |
| 8790 | + * | |
| 8791 | + * The parent node links to the settings page rather than a purge URL — | |
| 8792 | + * clicking the top-level item used to wipe the whole cache instantly with | |
| 8793 | + * no confirmation, which is far too destructive for a stray click. Purging | |
| 8794 | + * stays available (and explicit) through the child items. (FBS-84068) | |
| 2346 | 8795 | */ |
| 2347 | 8796 | public function admin_bar_purge( $wp_admin_bar ) { |
| 2348 | 8797 | if ( ! current_user_can( 'manage_options' ) ) { |
| 2349 | 8798 | return; |
| @@ -2352,14 +8801,63 @@ | ||
| 2352 | 8801 | $wp_admin_bar->add_node( |
| 2353 | 8802 | array( |
| 2354 | 8803 | 'id' => 'xspeed-purge', |
| 2355 | 8804 | 'title' => __( 'xSpeed Cache', 'xspeed' ), |
| 2356 | - 'href' => self::purge_type_url( 'all' ), | |
| 8805 | + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ), | |
| 2357 | 8806 | ) |
| 2358 | 8807 | ); |
| 2359 | 8808 | |
| 2360 | - foreach ( self::purge_types() as $slug => $type ) { | |
| 2361 | - if ( empty( $type['visible'] ) ) { | |
| 8809 | + // Settings first, then the two whole-errand actions (Purge All, | |
| 8810 | + // Purge this URL), then the per-type items. The order is the one WP | |
| 8811 | + // Rocket uses, and it front-loads what people open this menu for: | |
| 8812 | + // nobody reaches for "Purge Object Cache" as often as they reach for | |
| 8813 | + // the page they are looking at. | |
| 8814 | + $wp_admin_bar->add_node( | |
| 8815 | + array( | |
| 8816 | + 'id' => 'xspeed-purge-settings', | |
| 8817 | + 'parent' => 'xspeed-purge', | |
| 8818 | + 'title' => esc_html__( 'Settings', 'xspeed' ), | |
| 8819 | + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ), | |
| 8820 | + ) | |
| 8821 | + ); | |
| 8822 | + | |
| 8823 | + $types = self::purge_types(); | |
| 8824 | + | |
| 8825 | + // 'all' is rendered out of band so the single-URL item can sit | |
| 8826 | + // directly under it. A filter that reorders or drops it is honoured: | |
| 8827 | + // the loop below skips whatever was emitted here. | |
| 8828 | + $emitted = array(); | |
| 8829 | + if ( ! empty( $types['all']['visible'] ) ) { | |
| 8830 | + $wp_admin_bar->add_node( | |
| 8831 | + array( | |
| 8832 | + 'id' => 'xspeed-purge-all', | |
| 8833 | + 'parent' => 'xspeed-purge', | |
| 8834 | + 'title' => esc_html( $types['all']['label'] ), | |
| 8835 | + 'href' => self::purge_type_url( 'all' ), | |
| 8836 | + ) | |
| 8837 | + ); | |
| 8838 | + $emitted['all'] = true; | |
| 8839 | + } | |
| 8840 | + | |
| 8841 | + // Only when the current screen is about one thing — a front-end view, | |
| 8842 | + // or a published post's edit screen. On a list table or a settings | |
| 8843 | + // page there is nothing for "this" to mean, so the item stays hidden | |
| 8844 | + // rather than silently targeting the dashboard. Purge_Ui decides both | |
| 8845 | + // the label and the scope, which differ between the two contexts. | |
| 8846 | + $context = Purge_Ui::context_node(); | |
| 8847 | + if ( null !== $context ) { | |
| 8848 | + $wp_admin_bar->add_node( | |
| 8849 | + array( | |
| 8850 | + 'id' => 'xspeed-purge-this-url', | |
| 8851 | + 'parent' => 'xspeed-purge', | |
| 8852 | + 'title' => esc_html( $context['title'] ), | |
| 8853 | + 'href' => $context['href'], | |
| 8854 | + ) | |
| 8855 | + ); | |
| 8856 | + } | |
| 8857 | + | |
| 8858 | + foreach ( $types as $slug => $type ) { | |
| 8859 | + if ( empty( $type['visible'] ) || isset( $emitted[ $slug ] ) ) { | |
| 2362 | 8860 | continue; |
| 2363 | 8861 | } |
| 2364 | 8862 | $wp_admin_bar->add_node( |
| 2365 | 8863 | array( |
| @@ -2394,11 +8892,34 @@ | ||
| 2394 | 8892 | // Only honour known types; anything else falls back to a full purge. |
| 2395 | 8893 | if ( ! array_key_exists( $type, self::purge_types() ) ) { |
| 2396 | 8894 | $type = 'all'; |
| 2397 | 8895 | } |
| 8896 | + | |
| 8897 | + // Answer the browser BEFORE purging. "Purge All" fans out to the local | |
| 8898 | + // sweep, the object cache, CSS/edge listeners (outbound HTTP) and | |
| 8899 | + // third-party render caches, all in this one request — on a large site | |
| 8900 | + // that can outlive PHP-FPM's request_terminate_timeout, FPM kills the | |
| 8901 | + // worker mid-purge, and nginx answers the admin's click with a 502. | |
| 8902 | + // fastcgi_finish_request() exists on exactly those FPM setups: send | |
| 8903 | + // the redirect, close the connection, then keep purging in the same | |
| 8904 | + // process. Elsewhere (mod_php, CLI tests) fall back to purge-then- | |
| 8905 | + // redirect as before. | |
| 8906 | + $redirect = self::safe_purge_redirect( wp_get_referer() ); | |
| 8907 | + if ( function_exists( 'ignore_user_abort' ) ) { | |
| 8908 | + ignore_user_abort( true ); | |
| 8909 | + } | |
| 8910 | + if ( function_exists( 'set_time_limit' ) ) { | |
| 8911 | + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort under safe-mode-like restrictions. | |
| 8912 | + } | |
| 8913 | + if ( function_exists( 'fastcgi_finish_request' ) ) { | |
| 8914 | + wp_safe_redirect( $redirect ); | |
| 8915 | + fastcgi_finish_request(); | |
| 8916 | + self::purge_type( $type ); | |
| 8917 | + exit; | |
| 8918 | + } | |
| 8919 | + | |
| 2398 | 8920 | self::purge_type( $type ); |
| 2399 | - | |
| 2400 | - wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) ); | |
| 8921 | + wp_safe_redirect( $redirect ); | |
| 2401 | 8922 | exit; |
| 2402 | 8923 | } |
| 2403 | 8924 | |
| 2404 | 8925 | /** |