| @@ -11,8 +11,64 @@ | ||
| 11 | 11 | |
| 12 | 12 | class Cache { |
| 13 | 13 | |
| 14 | 14 | /** |
| 15 | + * Response header the generated server rules stamp themselves with, so a | |
| 16 | + * cache hit says which version of the rules served it. See | |
| 17 | + * rules_marker_expected() for what the value means. | |
| 18 | + */ | |
| 19 | + public const RULES_HEADER = 'X-XSpeed-Rules'; | |
| 20 | + | |
| 21 | + /** | |
| 22 | + * Response header carrying the unix time the served HTML was generated. | |
| 23 | + * | |
| 24 | + * Free owns it on every path where PHP runs, and emits it from the same | |
| 25 | + * `filemtime()` of the file it is about to send: the drop-in stamps it in | |
| 26 | + * advanced-cache.php, and the template_redirect serve path stamps it in | |
| 27 | + * serve_not_modified(). The nginx and Apache static paths never emit it — | |
| 28 | + * no PHP runs there, so a consumer reads their `Last-Modified` instead. | |
| 29 | + * | |
| 30 | + * An add-on cannot supply this value, and edge_headers_for() strips it | |
| 31 | + * from the `xspeed_edge_cache_headers` result in every context rather | |
| 32 | + * than asking add-ons not to try. Nothing on the filter's side of the | |
| 33 | + * seam knows which file is being served: mark() resolves the filter with | |
| 34 | + * no file and no mtime, so an add-on has only time(), which is when the | |
| 35 | + * page was SERVED. A downstream purge verifier compares this stamp with | |
| 36 | + * the moment it asked for the purge, and a stamp that is always "now" | |
| 37 | + * makes every purge look like it worked — worse than sending nothing. | |
| 38 | + */ | |
| 39 | + public const BUILT_HEADER = 'X-XSpeed-Built'; | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * The headers that can grant an edge a lifetime: `Cache-Control`, | |
| 43 | + * `CDN-Cache-Control`, `Cloudflare-CDN-Cache-Control`, | |
| 44 | + * `Surrogate-Control`, `Edge-Control`. Matched on the header NAME. | |
| 45 | + * | |
| 46 | + * advanced-cache.php carries a copy of this and of the pattern below, | |
| 47 | + * because the class is not loaded when the drop-in runs. Change all four | |
| 48 | + * together; EdgeLifetimeEntryCapTest compares them. | |
| 49 | + */ | |
| 50 | + public const EDGE_LIFETIME_HEADER = '/(?:^|-)control$/i'; | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * A lifetime directive inside one of those headers, and its seconds. | |
| 54 | + * See cap_edge_lifetime(). | |
| 55 | + */ | |
| 56 | + public const EDGE_LIFETIME_DIRECTIVE = '/(?<![\w-])(max-age|s-maxage)\s*=\s*"?(\d+)"?/i'; | |
| 57 | + | |
| 58 | + /** Map of rules hash => the settings fingerprint that produced it. */ | |
| 59 | + public const RULES_INPUTS_OPTION = 'xspeed_rules_inputs'; | |
| 60 | + | |
| 61 | + /** | |
| 62 | + * User meta holding {hash, at}: the rules version this admin says they | |
| 63 | + * pasted into the server config. See rules_copied(). | |
| 64 | + */ | |
| 65 | + public const RULES_COPIED_META = 'xspeed_nginx_rules_copied'; | |
| 66 | + | |
| 67 | + /** How many rules versions the map above remembers. */ | |
| 68 | + private const RULES_INPUTS_KEPT = 10; | |
| 69 | + | |
| 70 | + /** | |
| 15 | 71 | * Output-buffer nesting level at which we opened our cache buffer, so |
| 16 | 72 | * `close_buffer()` can flush ONLY our buffer and never disturb a buffer |
| 17 | 73 | * another plugin pushed on top of (or below) ours. |
| 18 | 74 | * |
| @@ -20,8 +76,21 @@ | ||
| 20 | 76 | */ |
| 21 | 77 | private static $buffer_level = null; |
| 22 | 78 | |
| 23 | 79 | /** |
| 80 | + * Bytes freed by the current sweep, accumulated by sweep_delete(). | |
| 81 | + * | |
| 82 | + * A counter rather than a return value because the two sweeps that free | |
| 83 | + * the bytes — the flat glob loop and the recursive static walk — already | |
| 84 | + * report a FILE count, and `wp xspeed purge` needs both numbers from a | |
| 85 | + * single pass. Re-walking the tree to size it would double the I/O on | |
| 86 | + * exactly the caches large enough for the number to matter. | |
| 87 | + * | |
| 88 | + * @var int | |
| 89 | + */ | |
| 90 | + private static $sweep_bytes = 0; | |
| 91 | + | |
| 92 | + /** | |
| 24 | 93 | * The `X-XSpeed-Cache` value decided for this request, and — when the |
| 25 | 94 | * decision was BYPASS — the slug of the gate that made it. |
| 26 | 95 | * |
| 27 | 96 | * Recorded as well as sent so unit tests (CLI SAPI, where header() is a |
| @@ -32,8 +101,26 @@ | ||
| 32 | 101 | private static $status_header = ''; |
| 33 | 102 | private static $bypass_reason = ''; |
| 34 | 103 | |
| 35 | 104 | /** |
| 105 | + * Edge/CDN headers decided for this request, after sanitising. | |
| 106 | + * | |
| 107 | + * Same reason as $status_header: header() cannot be observed from the CLI | |
| 108 | + * SAPI, so the pairs we sent are recorded here too. | |
| 109 | + * | |
| 110 | + * @var array<string,string> | |
| 111 | + */ | |
| 112 | + private static $edge_headers = array(); | |
| 113 | + | |
| 114 | + /** | |
| 115 | + * This entry's edge headers when they differ from the site-wide bake, | |
| 116 | + * resolved once per store. Null until asked. | |
| 117 | + * | |
| 118 | + * @var array<string,string>|null | |
| 119 | + */ | |
| 120 | + private static $per_entry_edge = null; | |
| 121 | + | |
| 122 | + /** | |
| 36 | 123 | * Cache key whose write was deferred to shutdown because a render-time |
| 37 | 124 | * translation plugin's buffer wraps ours. Null on every ordinary request. |
| 38 | 125 | * |
| 39 | 126 | * @var string|null |
| @@ -62,8 +149,31 @@ | ||
| 62 | 149 | */ |
| 63 | 150 | private static $render_completed = false; |
| 64 | 151 | |
| 65 | 152 | /** |
| 153 | + * Minifier::purge_stamp() when this request's cache buffer opened. | |
| 154 | + * | |
| 155 | + * A render names its minified and combined files while it runs and is | |
| 156 | + * stored when it ends. A purge that deletes those files in between would | |
| 157 | + * otherwise leave a stored page linking files that are gone, for the | |
| 158 | + * whole TTL. The stamp changes whenever such a purge deletes anything, so | |
| 159 | + * a different value at store time means "do not store this one". | |
| 160 | + * | |
| 161 | + * Null when maybe_start_cache() opened no buffer. A direct call (tests, | |
| 162 | + * add-ons) has nothing to compare against and stores as before. | |
| 163 | + * | |
| 164 | + * @var string|null | |
| 165 | + */ | |
| 166 | + private static $asset_stamp_at_open = null; | |
| 167 | + | |
| 168 | + /** | |
| 169 | + * The snapshot above, carried to the deferred (translated) writer. | |
| 170 | + * | |
| 171 | + * @var string|null | |
| 172 | + */ | |
| 173 | + private static $deferred_asset_stamp = null; | |
| 174 | + | |
| 175 | + /** | |
| 66 | 176 | * Hooks that get an argument-aware handler instead of a blanket purge. |
| 67 | 177 | * |
| 68 | 178 | * Each fires on an ordinary visitor action — an order, a review, a |
| 69 | 179 | * registration — where purge_all() cannot see WHAT changed and so wiped |
| @@ -76,9 +186,12 @@ | ||
| 76 | 186 | * running alongside its replacement and silently undo #243. |
| 77 | 187 | */ |
| 78 | 188 | private const TARGETED_INVALIDATION_HOOKS = array( |
| 79 | 189 | 'save_post', |
| 190 | + 'before_delete_post', | |
| 191 | + 'trashed_post', | |
| 80 | 192 | 'comment_post', |
| 193 | + 'wp_set_comment_status', | |
| 81 | 194 | 'user_register', |
| 82 | 195 | 'profile_update', |
| 83 | 196 | ); |
| 84 | 197 | |
| @@ -142,9 +255,9 @@ | ||
| 142 | 255 | // rendered author bylines / term-archive pages. Without them, an edit |
| 143 | 256 | // left the matching endpoint (and archives) stale for the full TTL. |
| 144 | 257 | // (FBS-82408) |
| 145 | 258 | $invalidate_hooks = array( |
| 146 | - 'save_post', 'deleted_post', 'trashed_post', | |
| 259 | + 'save_post', 'before_delete_post', 'trashed_post', | |
| 147 | 260 | 'comment_post', 'wp_set_comment_status', |
| 148 | 261 | 'switch_theme', 'activated_plugin', 'deactivated_plugin', |
| 149 | 262 | // Users → /wp/v2/users + author archives. |
| 150 | 263 | 'profile_update', 'user_register', 'deleted_user', |
| @@ -174,12 +287,21 @@ | ||
| 174 | 287 | } |
| 175 | 288 | add_action( |
| 176 | 289 | $hook, |
| 177 | 290 | static function () use ( $hook ): void { |
| 178 | - self::purge_all( 'hook:' . $hook ); | |
| 291 | + self::purge_all( | |
| 292 | + 'hook:' . $hook, | |
| 293 | + null, | |
| 294 | + self::invalidation_for_hook( $hook ) | |
| 295 | + ); | |
| 179 | 296 | } |
| 180 | 297 | ); |
| 181 | - add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) ); | |
| 298 | + // No purge_minified() here. Minified and combined files are named | |
| 299 | + // by content, so a render after this purge links the same names | |
| 300 | + // when nothing changed and new names when something did. Deleting | |
| 301 | + // them only opened a window where cached and in-flight pages link | |
| 302 | + // files that are gone, and on a network it took every subsite's | |
| 303 | + // files with it. | |
| 182 | 304 | } |
| 183 | 305 | |
| 184 | 306 | // Updating a plugin, theme or core changes the markup and the assets |
| 185 | 307 | // a page is built from, but fires NONE of the hooks above: WordPress |
| @@ -190,11 +312,11 @@ | ||
| 190 | 312 | // |
| 191 | 313 | // The stale copy is not merely old, it is wrong in a way the user |
| 192 | 314 | // cannot see the cause of: they update a plugin to get a fix, the |
| 193 | 315 | // cache keeps serving the pre-fix HTML, and the update looks like it |
| 194 | - // did nothing. Minified assets do regenerate on their own (their key | |
| 195 | - // includes the source filemtime), which makes it worse rather than | |
| 196 | - // better — the cached pages still link the PREVIOUS hashes. | |
| 316 | + // did nothing. Minified assets do regenerate on their own (they are | |
| 317 | + // named by content), which makes it worse rather than better — the | |
| 318 | + // cached pages still link the PREVIOUS names. | |
| 197 | 319 | // |
| 198 | 320 | // Purge unconditionally on any completed update. Scoping it to |
| 199 | 321 | // "plugins that enqueue front-end assets" is not knowable here, and a |
| 200 | 322 | // cold cache after an update is the cheaper mistake. (#269) |
| @@ -219,9 +341,26 @@ | ||
| 219 | 341 | // alone cannot tell "added beside nothing" from "replaced live code". |
| 220 | 342 | // This filter fires only when the upgrader removed an existing copy, |
| 221 | 343 | // which is exactly the difference. Registered as a filter listener |
| 222 | 344 | // that returns its input untouched. (#303) |
| 223 | - add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 1 ); | |
| 345 | + add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 4 ); | |
| 346 | + // …but `upgrader_clear_destination` fires whenever the upgrader was | |
| 347 | + // ASKED to clear, not only when it removed something: | |
| 348 | + // WP_Upgrader::clear_destination() returns true early when the | |
| 349 | + // destination does not exist. Looked at before the delete, while the | |
| 350 | + // old copy is still on disk. (#303) | |
| 351 | + // | |
| 352 | + // PHP_INT_MAX, because the folder name is only final once every other | |
| 353 | + // listener has had its turn. Update libraries that normalise | |
| 354 | + // `plugin-1.2.3/` to `plugin/` (Plugin Update Checker, EDD Software | |
| 355 | + // Licensing, GitHub-sourced zips) rename the extracted directory on | |
| 356 | + // this same filter at priority 10 or later, and core derives the real | |
| 357 | + // destination from the FILTERED source. Measured at 10, a genuine | |
| 358 | + // replacement read as "nothing was there" and the stale cache stayed. | |
| 359 | + // note_cleared_destination() cross-checks the folder core actually | |
| 360 | + // cleared against the one measured here, for a renamer that runs | |
| 361 | + // later still. (#407 QA) | |
| 362 | + add_filter( 'upgrader_source_selection', array( __CLASS__, 'note_destination_state' ), PHP_INT_MAX, 4 ); | |
| 224 | 363 | // Unattended auto-updates are the case that matters most here: they |
| 225 | 364 | // land overnight with nobody around to purge by hand, which is the |
| 226 | 365 | // exact scenario the stale cache goes undiagnosed in. WordPress fires |
| 227 | 366 | // this INSTEAD of a per-item upgrader_process_complete for some |
| @@ -231,8 +370,15 @@ | ||
| 231 | 370 | // left $type empty, which read as "invalidating" and purged the whole |
| 232 | 371 | // cache for a language-pack-only run. Matches what LiteSpeed binds. |
| 233 | 372 | // (#298) |
| 234 | 373 | add_action( 'automatic_updates_complete', array( __CLASS__, 'purge_after_auto_updates' ), 10, 1 ); |
| 374 | + // A Customizer publish changes theme mods, Additional CSS, the site | |
| 375 | + // identity and widget areas, so markup and inline CSS change on every | |
| 376 | + // page. It fires none of the hooks above. theme_mods_* is an ordinary | |
| 377 | + // option, and Additional CSS is a non-viewable `custom_css` post that | |
| 378 | + // the save_post gate rightly ignores. Without this, cached pages kept | |
| 379 | + // the old header, colours and custom CSS for the whole TTL. | |
| 380 | + add_action( 'customize_save_after', array( __CLASS__, 'on_customize_save' ), 10, 0 ); | |
| 235 | 381 | // …except the four hooks above that fire on ordinary visitor actions. |
| 236 | 382 | // Attached bare, purge_all() can't see WHAT changed, so on a store |
| 237 | 383 | // every order, every product review and every checkout |
| 238 | 384 | // account-creation wiped 100% of the cache — all anonymous happy-path |
| @@ -245,21 +391,39 @@ | ||
| 245 | 391 | // so save_post fires either way. The gate therefore keys on POST-TYPE |
| 246 | 392 | // VIEWABILITY, not on storage mode — which fixes both modes at once, |
| 247 | 393 | // and generalises to Flamingo (#229) and Tutor LMS (#231) too. |
| 248 | 394 | remove_action( 'save_post', array( __CLASS__, 'purge_all' ) ); |
| 249 | - remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) ); | |
| 250 | 395 | add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 ); |
| 396 | + // Bracket every post save, so a WooCommerce product save that runs | |
| 397 | + // inside one (wp-admin's Update) can tell on_save_post() is about to | |
| 398 | + // clear the whole site for the same product. See purge_product(). | |
| 399 | + add_action( 'save_post', array( __CLASS__, 'on_post_save_start' ), 0, 2 ); | |
| 400 | + add_action( 'save_post', array( __CLASS__, 'on_post_save_end' ), PHP_INT_MAX, 1 ); | |
| 401 | + // The narrow purge runs here, after the REST API has set the terms | |
| 402 | + // (it sets them after `save_post`), with the post as it was before. | |
| 403 | + add_action( 'wp_after_insert_post', array( __CLASS__, 'on_after_insert_post' ), 10, 4 ); | |
| 404 | + add_action( 'shutdown', array( __CLASS__, 'flush_pending_saves' ), 1, 0 ); | |
| 405 | + add_action( 'update_option_sticky_posts', array( __CLASS__, 'on_sticky_posts_change' ), 10, 2 ); | |
| 406 | + // The first sticky post on a site creates the option instead. | |
| 407 | + add_action( 'add_option_sticky_posts', array( __CLASS__, 'on_sticky_posts_added' ), 10, 2 ); | |
| 408 | + add_action( 'pre_post_update', array( __CLASS__, 'on_pre_post_update' ), 10, 2 ); | |
| 409 | + add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 410 | + add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 411 | + // wp_delete_post() hands an attachment to wp_delete_attachment() and | |
| 412 | + // returns BEFORE before_delete_post fires, so deleting media reached | |
| 413 | + // neither hook above. Attachment pages are public and media appears in | |
| 414 | + // galleries, so that left cached pages showing a file that is gone. | |
| 415 | + // (dev caught this via `deleted_post`, which this branch replaced.) | |
| 416 | + add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 ); | |
| 251 | 417 | |
| 252 | 418 | remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) ); |
| 253 | - remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) ); | |
| 254 | 419 | add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 ); |
| 420 | + add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 ); | |
| 255 | 421 | |
| 256 | 422 | remove_action( 'user_register', array( __CLASS__, 'purge_all' ) ); |
| 257 | - remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) ); | |
| 258 | 423 | add_action( 'user_register', array( __CLASS__, 'on_user_change' ) ); |
| 259 | 424 | |
| 260 | 425 | remove_action( 'profile_update', array( __CLASS__, 'purge_all' ) ); |
| 261 | - remove_action( 'profile_update', array( 'XSpeed\\Minifier', 'purge_minified' ) ); | |
| 262 | 426 | add_action( 'profile_update', array( __CLASS__, 'on_user_change' ) ); |
| 263 | 427 | |
| 264 | 428 | // Product data lives in post meta and lookup tables, NOT in wp_posts, |
| 265 | 429 | // so WC_Product_Data_Store_CPT::update() takes a direct $wpdb->update() |
| @@ -275,13 +439,20 @@ | ||
| 275 | 439 | // disappears, and an order that reduces stock would leave the product |
| 276 | 440 | // page stale. |
| 277 | 441 | if ( class_exists( 'WooCommerce' ) ) { |
| 278 | 442 | foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) { |
| 279 | - add_action( $wc_hook, array( __CLASS__, 'purge_product' ) ); | |
| 443 | + add_action( $wc_hook, array( __CLASS__, 'on_product_saved' ) ); | |
| 280 | 444 | } |
| 281 | - // Direct stock writes bypass the CRUD entirely. | |
| 282 | - add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'purge_product_object' ) ); | |
| 283 | - add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'purge_product_object' ) ); | |
| 445 | + // wc_update_product_stock() writes the stock with SQL, saves the | |
| 446 | + // product (which fires woocommerce_update_product above), then | |
| 447 | + // fires *_set_stock. The *_before_set_stock actions open that | |
| 448 | + // write, so the *_set_stock that closes it can tell the save | |
| 449 | + // inside it already purged. With `$updating` set it skips the | |
| 450 | + // save, and *_set_stock is then the only purge. | |
| 451 | + add_action( 'woocommerce_product_before_set_stock', array( __CLASS__, 'on_product_stock_write' ) ); | |
| 452 | + add_action( 'woocommerce_variation_before_set_stock', array( __CLASS__, 'on_product_stock_write' ) ); | |
| 453 | + add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'on_product_stock_set' ) ); | |
| 454 | + add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'on_product_stock_set' ) ); | |
| 284 | 455 | add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) ); |
| 285 | 456 | add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) ); |
| 286 | 457 | } |
| 287 | 458 | |
| @@ -317,11 +488,11 @@ | ||
| 317 | 488 | // (Cache module). Keep this handler around for whatever still |
| 318 | 489 | // lives in the legacy blob (cache_enabled is special and goes |
| 319 | 490 | // through Cache::toggle anyway). |
| 320 | 491 | |
| 321 | - // Any settings change — purge caches so changes take effect. | |
| 492 | + // Any settings change — purge caches so changes take effect. Minified | |
| 493 | + // files are named by content, so there is nothing to delete for them. | |
| 322 | 494 | self::purge_all( 'settings change' ); |
| 323 | - Minifier::purge_minified(); | |
| 324 | 495 | } |
| 325 | 496 | |
| 326 | 497 | /** |
| 327 | 498 | * Modules whose settings cannot change rendered HTML, so a write to them |
| @@ -385,10 +556,9 @@ | ||
| 385 | 556 | if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) { |
| 386 | 557 | return; |
| 387 | 558 | } |
| 388 | 559 | |
| 389 | - // Guard against re-entry: purge_all() and purge_minified() can write | |
| 390 | - // options of their own (stats, timestamps), and a nested purge would | |
| 560 | + // Guard against re-entry: purge_all() can write options of its own (stats, timestamps), and a nested purge would | |
| 391 | 561 | // both waste work and risk recursing through this same hook. |
| 392 | 562 | static $purging = false; |
| 393 | 563 | if ( $purging ) { |
| 394 | 564 | return; |
| @@ -395,9 +565,8 @@ | ||
| 395 | 565 | } |
| 396 | 566 | $purging = true; |
| 397 | 567 | |
| 398 | 568 | self::purge_all( 'settings change' ); |
| 399 | - Minifier::purge_minified(); | |
| 400 | 569 | |
| 401 | 570 | $purging = false; |
| 402 | 571 | } |
| 403 | 572 | |
| @@ -411,15 +580,53 @@ | ||
| 411 | 580 | * along on `X-XSpeed-Reason`, but only under WP_DEBUG so production |
| 412 | 581 | * responses stay clean. Slugs are fixed per gate — never the matched |
| 413 | 582 | * pattern, cookie or user-agent, which would echo request input back. |
| 414 | 583 | * |
| 415 | - * @param string $value HIT (php) | MISS | BYPASS. | |
| 416 | - * @param string $reason Fixed slug naming the gate, for BYPASS only. | |
| 584 | + * @param string $value HIT (php) | MISS | BYPASS. | |
| 585 | + * @param string $reason Fixed slug naming the gate, for BYPASS only. | |
| 586 | + * @param int|null $lifetime_left On a HIT, the seconds the served entry has | |
| 587 | + * left when it has a lifetime of its own; | |
| 588 | + * null when it follows the site's. See | |
| 589 | + * entry_lifetime_left(). | |
| 417 | 590 | */ |
| 418 | - private static function mark( string $value, string $reason = '' ): void { | |
| 591 | + private static function mark( string $value, string $reason = '', ?int $lifetime_left = null ): void { | |
| 419 | 592 | self::$status_header = $value; |
| 420 | 593 | self::$bypass_reason = $reason; |
| 594 | + self::$edge_headers = array(); | |
| 421 | 595 | |
| 596 | + // A served-from-cache response may carry edge/CDN headers an add-on | |
| 597 | + // contributes — `CDN-Cache-Control`, `Cache-Tag` and friends. Only a | |
| 598 | + // HIT resolves them here. A BYPASS never does: the response was | |
| 599 | + // deliberately excluded from our cache, so telling a CDN to hold it | |
| 600 | + // for a month would cache at the edge exactly what we refused to | |
| 601 | + // cache here. A MISS never does either, stored or not: it is the | |
| 602 | + // first render, and the one most likely to be replaced once critical | |
| 603 | + // CSS and unused CSS have been generated. Pinning it at the edge pins | |
| 604 | + // the version xSpeed is about to improve on. The edge caches from the | |
| 605 | + // first HIT instead — one render later, and the right one. | |
| 606 | + // | |
| 607 | + // Resolved before the headers_sent() guard so the decision is | |
| 608 | + // recorded (and observable in tests) even on a request that can no | |
| 609 | + // longer send headers; only the emission below is conditional. | |
| 610 | + if ( 'HIT' === self::edge_status( $value ) ) { | |
| 611 | + self::$edge_headers = self::edge_headers_for( 'HIT' ); | |
| 612 | + } | |
| 613 | + | |
| 614 | + // Every status, not just a HIT. A page we declined to cache is the | |
| 615 | + // one an edge most needs telling about: it goes out naked today, and | |
| 616 | + // a CDN that stores HTML by default keeps somebody's cart. | |
| 617 | + // | |
| 618 | + // Resolved before the headers_sent() guard so the decision is | |
| 619 | + // recorded (and observable in tests) even on a request that can no | |
| 620 | + // longer send headers; only the emission below is conditional. | |
| 621 | + self::$edge_headers = self::edge_headers_for( self::edge_status( $value ), 'request', $reason ); | |
| 622 | + | |
| 623 | + // The same countdown the drop-in applies to this entry, so the two | |
| 624 | + // PHP serve paths tell the edge the same thing about one page. | |
| 625 | + if ( null !== $lifetime_left && 'HIT' === self::edge_status( $value ) ) { | |
| 626 | + self::$edge_headers = self::cap_edge_lifetime( self::$edge_headers, $lifetime_left ); | |
| 627 | + } | |
| 628 | + | |
| 422 | 629 | if ( headers_sent() ) { |
| 423 | 630 | return; |
| 424 | 631 | } |
| 425 | 632 | header( 'X-XSpeed-Cache: ' . $value ); |
| @@ -425,10 +632,354 @@ | ||
| 425 | 632 | header( 'X-XSpeed-Cache: ' . $value ); |
| 426 | 633 | if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) { |
| 427 | 634 | header( 'X-XSpeed-Reason: ' . $reason ); |
| 428 | 635 | } |
| 636 | + foreach ( self::$edge_headers as $name => $val ) { | |
| 637 | + header( $name . ': ' . $val ); | |
| 638 | + } | |
| 429 | 639 | } |
| 430 | 640 | |
| 641 | + /** | |
| 642 | + * Normalize an `X-XSpeed-Cache` value to the vocabulary the edge seam | |
| 643 | + * speaks. | |
| 644 | + * | |
| 645 | + * The header value carries which layer served the page (`HIT (php)`, | |
| 646 | + * `HIT (nginx)`, `HIT (static)`); nothing deciding what to tell a CDN | |
| 647 | + * cares, and making a caller match on three spellings of one outcome is | |
| 648 | + * how a rule ends up applied on two paths out of three. | |
| 649 | + */ | |
| 650 | + private static function edge_status( string $value ): string { | |
| 651 | + return 0 === strpos( $value, 'HIT' ) ? 'HIT' : $value; | |
| 652 | + } | |
| 653 | + | |
| 654 | + /** | |
| 655 | + * Hash a generated rule set so the rules can identify themselves. | |
| 656 | + * | |
| 657 | + * @param string[] $lines Every line of the rule set EXCEPT the marker. | |
| 658 | + */ | |
| 659 | + private static function rules_hash( array $lines ): string { | |
| 660 | + return substr( sha1( implode( "\n", $lines ) ), 0, 8 ); | |
| 661 | + } | |
| 662 | + | |
| 663 | + /** | |
| 664 | + * Insert the self-describing marker directive into a generated rule set. | |
| 665 | + * | |
| 666 | + * The hash is taken over the rule set WITHOUT this line, and that is the | |
| 667 | + * whole trick: hashing the finished text instead would mean the act of | |
| 668 | + * adding the marker changed the value the marker advertises, the probe | |
| 669 | + * would never see a match, and every correctly installed block would | |
| 670 | + * report itself out of date forever. The one property worth a test of its | |
| 671 | + * own — RewriteProbeRulesStateTest covers it. | |
| 672 | + * | |
| 673 | + * More than one template is allowed because Apache needs the same marker | |
| 674 | + * twice, under two environment-variable names (see rewrite_block_lines). | |
| 675 | + * All of them carry the SAME hash, taken over the marker-less rule set, so | |
| 676 | + * adding the second directive cannot change the value either advertises. | |
| 677 | + * | |
| 678 | + * @param string[] $lines The rule set, with no marker line in it. | |
| 679 | + * @param string[] $templates sprintf templates for the directives; `%s` is the hash. | |
| 680 | + * @param int $at Index to insert the marker at. | |
| 681 | + * @return string[] | |
| 682 | + */ | |
| 683 | + private static function with_rules_marker( array $lines, array $templates, int $at ): array { | |
| 684 | + $hash = self::rules_hash( $lines ); | |
| 685 | + $directives = array(); | |
| 686 | + foreach ( $templates as $template ) { | |
| 687 | + $directives[] = sprintf( $template, $hash ); | |
| 688 | + } | |
| 689 | + array_splice( $lines, $at, 0, $directives ); | |
| 690 | + return $lines; | |
| 691 | + } | |
| 692 | + | |
| 693 | + /** | |
| 694 | + * The rules hash the CURRENT settings generate, i.e. what a correctly | |
| 695 | + * installed rule set would be sending back. | |
| 696 | + * | |
| 697 | + * Read out of the generated artifact rather than recomputed, so there is | |
| 698 | + * exactly one definition of the hash and no way for the generator and the | |
| 699 | + * expectation to drift apart. Empty on a server whose fast path we do not | |
| 700 | + * generate rules for. | |
| 701 | + */ | |
| 702 | + public static function rules_marker_expected(): string { | |
| 703 | + $type = Server::type(); | |
| 704 | + if ( Server::NGINX === $type ) { | |
| 705 | + return self::extract_rules_marker( (string) self::nginx_snippet() ); | |
| 706 | + } | |
| 707 | + if ( Server::APACHE === $type ) { | |
| 708 | + return self::extract_rules_marker( implode( "\n", self::rewrite_block_lines() ) ); | |
| 709 | + } | |
| 710 | + return ''; | |
| 711 | + } | |
| 712 | + | |
| 713 | + /** Pull the marker value out of a generated rule set ('' when absent). */ | |
| 714 | + public static function extract_rules_marker( string $source ): string { | |
| 715 | + if ( preg_match( '/' . preg_quote( self::RULES_HEADER, '/' ) . ' "([0-9a-f]{8})"/', $source, $m ) ) { | |
| 716 | + return $m[1]; | |
| 717 | + } | |
| 718 | + return ''; | |
| 719 | + } | |
| 720 | + | |
| 721 | + /** | |
| 722 | + * Fingerprint of every setting that feeds the generated server rules. | |
| 723 | + * | |
| 724 | + * Values are hashed rather than stored: this map is written from a read | |
| 725 | + * path and has no business becoming a second copy of the user's exclusion | |
| 726 | + * lists. A per-key hash is enough — the point is to name WHICH setting | |
| 727 | + * moved between two rule versions, not to reconstruct the old value. | |
| 728 | + * | |
| 729 | + * @return array<string,string> | |
| 730 | + */ | |
| 731 | + public static function rules_inputs(): array { | |
| 732 | + $cache_opts = Settings_Manager::get( 'cache' ); | |
| 733 | + $fingerprint = static function ( $value ): string { | |
| 734 | + $flat = array(); | |
| 735 | + foreach ( (array) $value as $key => $item ) { | |
| 736 | + $flat[] = $key . '=' . ( is_scalar( $item ) ? (string) $item : '' ); | |
| 737 | + } | |
| 738 | + return substr( sha1( implode( "\x1f", $flat ) ), 0, 8 ); | |
| 739 | + }; | |
| 740 | + | |
| 741 | + return array( | |
| 742 | + 'excluded_cookies' => $fingerprint( $cache_opts['excluded_cookies'] ?? array() ), | |
| 743 | + 'bypass_user_agents' => $fingerprint( $cache_opts['bypass_user_agents'] ?? array() ), | |
| 744 | + 'excluded_urls' => $fingerprint( $cache_opts['excluded_urls'] ?? array() ), | |
| 745 | + 'edge_headers' => $fingerprint( self::edge_headers_for( 'HIT', 'rules' ) ), | |
| 746 | + 'static_dir' => $fingerprint( array( XSPEED_CACHE_STATIC_DIR ) ), | |
| 747 | + ); | |
| 748 | + } | |
| 749 | + | |
| 750 | + /** | |
| 751 | + * Remember which settings produced a rules version. | |
| 752 | + * | |
| 753 | + * Without this a stale verdict can only say "stale". With it, the hash the | |
| 754 | + * server sent back is a key into what the site's settings looked like when | |
| 755 | + * those rules were generated, so the dashboard can name what changed since | |
| 756 | + * the user last pasted. Bounded to the last few versions — the map exists | |
| 757 | + * to explain a recent drift, not to keep a history. | |
| 758 | + * | |
| 759 | + * @param array<string,string> $inputs Fingerprint from rules_inputs(). | |
| 760 | + */ | |
| 761 | + private static function remember_rules_inputs( string $hash, array $inputs ): void { | |
| 762 | + if ( '' === $hash ) { | |
| 763 | + return; | |
| 764 | + } | |
| 765 | + $map = get_option( self::RULES_INPUTS_OPTION, array() ); | |
| 766 | + if ( ! is_array( $map ) ) { | |
| 767 | + $map = array(); | |
| 768 | + } | |
| 769 | + if ( isset( $map[ $hash ] ) && $map[ $hash ] === $inputs ) { | |
| 770 | + return; | |
| 771 | + } | |
| 772 | + $map[ $hash ] = $inputs; | |
| 773 | + if ( count( $map ) > self::RULES_INPUTS_KEPT ) { | |
| 774 | + $map = array_slice( $map, -self::RULES_INPUTS_KEPT, null, true ); | |
| 775 | + } | |
| 776 | + update_option( self::RULES_INPUTS_OPTION, $map, false ); | |
| 777 | + } | |
| 778 | + | |
| 779 | + /** | |
| 780 | + * Which settings changed between the installed rules and the current ones. | |
| 781 | + * | |
| 782 | + * Empty when the installed version predates the map (nothing to compare | |
| 783 | + * against) — the caller then says "out of date" without naming a cause, | |
| 784 | + * which is honest. | |
| 785 | + * | |
| 786 | + * @param array<string,string> $current Fingerprint from rules_inputs(). | |
| 787 | + * @return string[] Setting keys. | |
| 788 | + */ | |
| 789 | + private static function changed_rules_inputs( string $observed, array $current ): array { | |
| 790 | + $map = get_option( self::RULES_INPUTS_OPTION, array() ); | |
| 791 | + if ( ! is_array( $map ) || ! isset( $map[ $observed ] ) || ! is_array( $map[ $observed ] ) ) { | |
| 792 | + return array(); | |
| 793 | + } | |
| 794 | + | |
| 795 | + $was = $map[ $observed ]; | |
| 796 | + $changed = array(); | |
| 797 | + foreach ( $current as $key => $value ) { | |
| 798 | + if ( ! array_key_exists( $key, $was ) || $was[ $key ] !== $value ) { | |
| 799 | + $changed[] = $key; | |
| 800 | + } | |
| 801 | + } | |
| 802 | + return $changed; | |
| 803 | + } | |
| 804 | + | |
| 805 | + /** | |
| 806 | + * Whether the rules the server is running are the rules these settings | |
| 807 | + * generate. | |
| 808 | + * | |
| 809 | + * Nothing in WordPress can read a hand-pasted nginx server block, so | |
| 810 | + * before this the dashboard could only guess — and guessed by telling | |
| 811 | + * everyone to re-paste after every settings change. The generated rules | |
| 812 | + * now carry their own hash and the probe reads it back: | |
| 813 | + * | |
| 814 | + * current — the marker matches what these settings generate. | |
| 815 | + * stale — a marker came back, from a different version of the rules. | |
| 816 | + * absent — the probe reached a verdict and saw no marker: either no | |
| 817 | + * rules are installed (the request fell through to PHP) or | |
| 818 | + * they predate the marker. Either way the fix is the same. | |
| 819 | + * unknown — the probe could not tell, or this server has no rule set we | |
| 820 | + * generate. | |
| 821 | + * | |
| 822 | + * A marker outranks the probe's own `active` verdict: the marker is direct | |
| 823 | + * evidence of which rules answered, while `active` is inferred from | |
| 824 | + * response shape. | |
| 825 | + * | |
| 826 | + * Scope: the static-cache rules only — nginx_snippet() on nginx, the | |
| 827 | + * .htaccess block on Apache. full_nginx_server_block() pastes those | |
| 828 | + * alongside other modules' directives, and a change to one of those does | |
| 829 | + * NOT move this hash. Hashing the aggregate would mean the marker inside | |
| 830 | + * the location block had to know the text it is embedded in, and the | |
| 831 | + * snippet would hash differently depending on which caller asked for it. | |
| 832 | + * Callers wording this for a human should say "cache rules", not "your | |
| 833 | + * nginx config". | |
| 834 | + * | |
| 835 | + * `copied` is the mirror of what this admin last pasted — see | |
| 836 | + * rules_copied(). It is always present, null included, because a client | |
| 837 | + * keys on the key existing to decide whether the server remembers at all. | |
| 838 | + * | |
| 839 | + * @param array $probe Raw result from probe_static_rewrite(). | |
| 840 | + * @return array{expected:string,observed:string,state:string,changed:string[],copied:array{hash:string,at:int}|null} | |
| 841 | + */ | |
| 842 | + public static function rules_state( array $probe ): array { | |
| 843 | + $expected = self::rules_marker_expected(); | |
| 844 | + $observed = (string) ( $probe['rules'] ?? '' ); | |
| 845 | + $copied = self::rules_copied(); | |
| 846 | + | |
| 847 | + if ( '' === $expected ) { | |
| 848 | + return array( | |
| 849 | + 'expected' => '', | |
| 850 | + 'observed' => $observed, | |
| 851 | + 'state' => 'unknown', | |
| 852 | + 'changed' => array(), | |
| 853 | + 'copied' => $copied, | |
| 854 | + ); | |
| 855 | + } | |
| 856 | + | |
| 857 | + $inputs = self::rules_inputs(); | |
| 858 | + self::remember_rules_inputs( $expected, $inputs ); | |
| 859 | + | |
| 860 | + if ( '' !== $observed ) { | |
| 861 | + return array( | |
| 862 | + 'expected' => $expected, | |
| 863 | + 'observed' => $observed, | |
| 864 | + 'state' => $observed === $expected ? 'current' : 'stale', | |
| 865 | + 'changed' => $observed === $expected ? array() : self::changed_rules_inputs( $observed, $inputs ), | |
| 866 | + 'copied' => $copied, | |
| 867 | + ); | |
| 868 | + } | |
| 869 | + | |
| 870 | + // `absent` is a claim about what is installed, so it takes a probe that | |
| 871 | + // completed a round trip and read the response headers. A result that | |
| 872 | + // is still pending, one whose loopback failed, one a CDN answered, and | |
| 873 | + // one that never got off the ground (no host in home_url, no writable | |
| 874 | + // probe dir — neither of which sets `inconclusive`, and both of which | |
| 875 | + // arrive here with no status code) all leave us knowing nothing. | |
| 876 | + if ( empty( $probe['code'] ) || ! empty( $probe['pending'] ) || ! empty( $probe['inconclusive'] ) ) { | |
| 877 | + return array( | |
| 878 | + 'expected' => $expected, | |
| 879 | + 'observed' => '', | |
| 880 | + 'state' => 'unknown', | |
| 881 | + 'changed' => array(), | |
| 882 | + 'copied' => $copied, | |
| 883 | + ); | |
| 884 | + } | |
| 885 | + | |
| 886 | + return array( | |
| 887 | + 'expected' => $expected, | |
| 888 | + 'observed' => '', | |
| 889 | + 'state' => 'absent', | |
| 890 | + 'changed' => array(), | |
| 891 | + 'copied' => $copied, | |
| 892 | + ); | |
| 893 | + } | |
| 894 | + | |
| 895 | + /** | |
| 896 | + * Which version of the rules this admin says they pasted into the server. | |
| 897 | + * | |
| 898 | + * Their claim, not evidence — the probe is the evidence, and where the | |
| 899 | + * probe can answer this is ignored. It exists for the `unknown` state: a | |
| 900 | + * host whose loopback is blocked, or one behind a CDN that answers the | |
| 901 | + * probe itself, where nothing can read back what is installed. There the | |
| 902 | + * only thing left to go on is that someone said they had done it, and | |
| 903 | + * without a record of that the panel asks every admin to paste the block | |
| 904 | + * again forever. | |
| 905 | + * | |
| 906 | + * Per user rather than per site, because it is a claim a person made. A | |
| 907 | + * second admin on the same site has not pasted anything and should not be | |
| 908 | + * told the work is done. Null when nobody has claimed this version, when | |
| 909 | + * the stored value is not a shape we wrote, or when there is no current | |
| 910 | + * user at all (WP-CLI, cron). | |
| 911 | + * | |
| 912 | + * @return array{hash:string,at:int}|null | |
| 913 | + */ | |
| 914 | + public static function rules_copied(): ?array { | |
| 915 | + if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'get_user_meta' ) ) { | |
| 916 | + return null; | |
| 917 | + } | |
| 918 | + $user_id = (int) get_current_user_id(); | |
| 919 | + if ( $user_id <= 0 ) { | |
| 920 | + return null; | |
| 921 | + } | |
| 922 | + | |
| 923 | + $stored = get_user_meta( $user_id, self::RULES_COPIED_META, true ); | |
| 924 | + if ( ! is_array( $stored ) ) { | |
| 925 | + return null; | |
| 926 | + } | |
| 927 | + | |
| 928 | + $hash = (string) ( $stored['hash'] ?? '' ); | |
| 929 | + $at = (int) ( $stored['at'] ?? 0 ); | |
| 930 | + if ( ! self::is_rules_hash( $hash ) || $at <= 0 ) { | |
| 931 | + return null; | |
| 932 | + } | |
| 933 | + | |
| 934 | + return array( | |
| 935 | + 'hash' => $hash, | |
| 936 | + 'at' => $at, | |
| 937 | + ); | |
| 938 | + } | |
| 939 | + | |
| 940 | + /** | |
| 941 | + * Record that this admin pasted the rules whose marker is $hash. | |
| 942 | + * | |
| 943 | + * Rejects anything that is not one of our markers rather than storing it, | |
| 944 | + * so the mirror can only ever hold a value rules_marker_expected() could | |
| 945 | + * also produce — a stored string that matches nothing would read as "a | |
| 946 | + * different version is installed" forever. | |
| 947 | + * | |
| 948 | + * @param string $hash The 8-hex rules marker the admin copied. | |
| 949 | + * @return array{hash:string,at:int}|null The stored record, or null if refused. | |
| 950 | + */ | |
| 951 | + public static function remember_rules_copied( string $hash ): ?array { | |
| 952 | + // Trimmed but not case-folded: extract_rules_marker() reads a marker | |
| 953 | + // back in lower case only, so an upper-case claim would never match | |
| 954 | + // anything the probe could observe. Refuse it rather than store a | |
| 955 | + // value that can only ever read as a different version. | |
| 956 | + $hash = trim( $hash ); | |
| 957 | + if ( ! self::is_rules_hash( $hash ) ) { | |
| 958 | + return null; | |
| 959 | + } | |
| 960 | + if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'update_user_meta' ) ) { | |
| 961 | + return null; | |
| 962 | + } | |
| 963 | + $user_id = (int) get_current_user_id(); | |
| 964 | + if ( $user_id <= 0 ) { | |
| 965 | + return null; | |
| 966 | + } | |
| 967 | + | |
| 968 | + $record = array( | |
| 969 | + 'hash' => $hash, | |
| 970 | + 'at' => time(), | |
| 971 | + ); | |
| 972 | + update_user_meta( $user_id, self::RULES_COPIED_META, $record ); | |
| 973 | + | |
| 974 | + return $record; | |
| 975 | + } | |
| 976 | + | |
| 977 | + /** Whether a string is shaped like one of our rules markers. */ | |
| 978 | + public static function is_rules_hash( string $hash ): bool { | |
| 979 | + return 1 === preg_match( '/^[0-9a-f]{8}$/', $hash ); | |
| 980 | + } | |
| 981 | + | |
| 431 | 982 | /** Record a bypass gate and answer "don't cache" in one statement. */ |
| 432 | 983 | private static function bypass( string $reason ): bool { |
| 433 | 984 | self::mark( 'BYPASS', $reason ); |
| 434 | 985 | return false; |
| @@ -444,8 +995,923 @@ | ||
| 444 | 995 | return self::$bypass_reason; |
| 445 | 996 | } |
| 446 | 997 | |
| 447 | 998 | /** |
| 999 | + * The edge/CDN pairs sent on this request ('' if none were). | |
| 1000 | + * | |
| 1001 | + * @return array<string,string> | |
| 1002 | + */ | |
| 1003 | + public static function edge_headers(): array { | |
| 1004 | + return self::$edge_headers; | |
| 1005 | + } | |
| 1006 | + | |
| 1007 | + /** | |
| 1008 | + * Bypass gates that do NOT ask a cache in front of us to stand down. | |
| 1009 | + * | |
| 1010 | + * Every other slug does. The split is the reason this reads the gate | |
| 1011 | + * rather than the status: a bypass usually means "this response is | |
| 1012 | + * personal, or someone decided this page is never stored", and an edge | |
| 1013 | + * holding one of those does precisely what we refused to do. These two | |
| 1014 | + * mean something else. | |
| 1015 | + * | |
| 1016 | + * `cache-disabled` is the user switching OUR page cache off. Nothing | |
| 1017 | + * about the page became personal. Sending `no-store` on every page of a | |
| 1018 | + * site whose owner chose a different cache would make a local toggle a | |
| 1019 | + * site-wide side effect on infrastructure we do not own. | |
| 1020 | + * | |
| 1021 | + * `non-frontend` is admin, REST, cron and AJAX. Not ours to describe: | |
| 1022 | + * WordPress already nocaches admin, and a REST caller sets its own | |
| 1023 | + * policy. | |
| 1024 | + */ | |
| 1025 | + private const HOLD_EXEMPT_BYPASS = array( 'cache-disabled', 'non-frontend' ); | |
| 1026 | + | |
| 1027 | + /** | |
| 1028 | + * Bypass gates that describe the SHAPE of the request rather than the | |
| 1029 | + * visitor or the page. | |
| 1030 | + * | |
| 1031 | + * These still hold, but only once we have evidence of an edge — the same | |
| 1032 | + * bar a MISS has to clear. The difference matters because the default | |
| 1033 | + * excluded-URL list contains `/feed/`, the sitemap and `/wp-json/`, and | |
| 1034 | + * `query-param` catches `?lang=fr`, `?paged=2`, and every page of a | |
| 1035 | + * plain-permalink site. | |
| 1036 | + * | |
| 1037 | + * xSpeed refuses those because IT cannot key on a query string, not | |
| 1038 | + * because the response is private. A CDN keys on the full URL and caches | |
| 1039 | + * them correctly. Holding them unconditionally would have meant every | |
| 1040 | + * default install stopped its feed and sitemap being edge-cached — a | |
| 1041 | + * performance regression shipped to sites that never had a CDN in the | |
| 1042 | + * first place, in the name of protecting them from one. | |
| 1043 | + * | |
| 1044 | + * The gates left out of this list are about the visitor (`logged-in`, | |
| 1045 | + * `excluded-cookie`) or are somebody stating outright that this page is | |
| 1046 | + * never to be stored (`donotcachepage`, `post-excluded`, `filtered`). | |
| 1047 | + * Those hold whether or not we can see an edge. | |
| 1048 | + */ | |
| 1049 | + private const REQUEST_SHAPE_BYPASS = array( 'query-param', 'non-get', 'user-agent' ); | |
| 1050 | + | |
| 1051 | + /** | |
| 1052 | + * Default exclusions that are about the site's plumbing, not its content. | |
| 1053 | + * | |
| 1054 | + * `excluded-url` covers two unlike things. The default list carries | |
| 1055 | + * `/cart`, `/checkout`, `/my-account` and `/wp-login` — personal pages, | |
| 1056 | + * and the reason this feature exists. It also carries the entries below: | |
| 1057 | + * feeds, sitemaps, the REST root, the front controller. Those are public, | |
| 1058 | + * cacheable, and hammered by pollers; a CDN keys on the full URL and | |
| 1059 | + * serves them correctly, so telling it to stop is a cost with no benefit. | |
| 1060 | + * | |
| 1061 | + * Matched as exact strings against the stored list, never as patterns | |
| 1062 | + * against the path. Three bugs came out of doing it the other way round: | |
| 1063 | + * `strpos( $uri, '/feed' )` matched `/my-account/feedback/`, reading the | |
| 1064 | + * whole URI let `/cart/?utm_source=/feed/` disguise a cart as a feed, and | |
| 1065 | + * a bare `index.php` — which is in this list, and which every URL contains | |
| 1066 | + * on an "almost pretty" permalink site — made every page on such a site | |
| 1067 | + * look personal. Comparing the LIST ENTRY rather than the path cannot make | |
| 1068 | + * any of those mistakes, and it keeps a pattern the site owner added | |
| 1069 | + * themselves on the personal side where it belongs. | |
| 1070 | + */ | |
| 1071 | + private const STRUCTURAL_EXCLUSIONS = array( | |
| 1072 | + '/wp-json/', | |
| 1073 | + '/xmlrpc.php', | |
| 1074 | + '~wp-.*\.php', | |
| 1075 | + '/feed/', | |
| 1076 | + 'index.php', | |
| 1077 | + '/robots.txt', | |
| 1078 | + // Both spellings, and no entry here is ever retired. This is a | |
| 1079 | + // RECOGNITION list, not a source of truth: it is matched against | |
| 1080 | + // whatever the site has STORED, and a site that saved its settings | |
| 1081 | + // before `~sitemap(_index)?\.xml` was widened to `sitemaps?` (for | |
| 1082 | + // SEOPress, which ships sitemaps.xml) still has the old string in | |
| 1083 | + // its option row. Dropping the old spelling when the default moved | |
| 1084 | + // would read every upgraded site's sitemap exclusion as somebody's | |
| 1085 | + // personal data and hold sitemaps off the CDN — the bug this whole | |
| 1086 | + // predicate exists to prevent, reintroduced by a rename. | |
| 1087 | + '~sitemaps?(_index)?\.xml', | |
| 1088 | + '~sitemap(_index)?\.xml', | |
| 1089 | + ); | |
| 1090 | + /** | |
| 1091 | + * Header names no edge instruction may ever carry. | |
| 1092 | + * | |
| 1093 | + * These describe the transfer, not the caching policy, and one wrong | |
| 1094 | + * value from a settings field is a white screen rather than a missing | |
| 1095 | + * optimization. | |
| 1096 | + */ | |
| 1097 | + private const NEVER_AN_EDGE_HEADER = array( | |
| 1098 | + 'content-length', | |
| 1099 | + 'content-encoding', | |
| 1100 | + 'content-type', | |
| 1101 | + 'transfer-encoding', | |
| 1102 | + 'set-cookie', | |
| 1103 | + 'location', | |
| 1104 | + 'x-xspeed-cache', | |
| 1105 | + 'x-xspeed-edge-hold', | |
| 1106 | + // `X-XSpeed-Built` says when THIS PAGE's HTML was generated, which is | |
| 1107 | + // what a downstream verifier compares against the moment it asked for | |
| 1108 | + // a purge. Free owns it and never takes it from a filter, in any | |
| 1109 | + // context — see the BUILT_HEADER docblock. | |
| 1110 | + // | |
| 1111 | + // Under `bake` a supplied value would be frozen into an artifact and | |
| 1112 | + // report when the artifact was written: identical on every page and | |
| 1113 | + // never moving. Under `request` it is no better, because the filter | |
| 1114 | + // runs from mark() with no file and no mtime in reach, so the only | |
| 1115 | + // value anything can produce there is time() — the moment of the | |
| 1116 | + // SERVE, not of the build. Both make every purge check pass. | |
| 1117 | + // | |
| 1118 | + // Free stamps the real value where it has the file: the drop-in and | |
| 1119 | + // serve_not_modified(), both from filemtime(). The static serve paths | |
| 1120 | + // run no PHP and carry no stamp at all; their `Last-Modified` is read | |
| 1121 | + // instead. | |
| 1122 | + 'x-xspeed-built', | |
| 1123 | + ); | |
| 1124 | + | |
| 1125 | + /** | |
| 1126 | + * Reasons that hold the edge off even when we detected nothing in front. | |
| 1127 | + * | |
| 1128 | + * `none` confidence means no evidence of a proxy, which is not proof | |
| 1129 | + * there is none — a transparent proxy and a host page cache both leave | |
| 1130 | + * the request untouched. So the question is what a wasted header costs | |
| 1131 | + * against what a missed one does, and the answer differs by reason. | |
| 1132 | + * | |
| 1133 | + * These two are correctness failures. A cart page stored by something we | |
| 1134 | + * could not see is the defect this exists to fix, and a mobile-split page | |
| 1135 | + * served to the wrong device is a wrong page rather than a slow one. | |
| 1136 | + * Ninety bytes on a response that was never cacheable is a cheap premium. | |
| 1137 | + * | |
| 1138 | + * `miss` and `pending` are performance hedges, and a hedge against a | |
| 1139 | + * cache that does not exist is noise on every first render. Skipping them | |
| 1140 | + * has a second benefit: because per_entry_edge_headers() compares `store` | |
| 1141 | + * against `bake`, a `pending` hold that never fires leaves the two | |
| 1142 | + * agreeing, which keeps the page on the static tree. | |
| 1143 | + * | |
| 1144 | + * `query-variant` waits for evidence as well. It is about purges. An edge | |
| 1145 | + * keeps a separate copy for every query string, and a purge of the plain | |
| 1146 | + * URL never reaches them. With no edge in front there are no copies. | |
| 1147 | + */ | |
| 1148 | + private const HOLD_WITHOUT_EVIDENCE = array( 'bypass', 'mobile-split' ); | |
| 1149 | + | |
| 1150 | + /** | |
| 1151 | + * True only while query_variant_edge_headers() bakes the drop-in's | |
| 1152 | + * answer for a URL no purge names. | |
| 1153 | + * | |
| 1154 | + * A bake has no request to read, so this is how it asks "what if this | |
| 1155 | + * HIT carried a param no purge names?". The drop-in answers that for | |
| 1156 | + * itself, per request. Never true outside that one call. | |
| 1157 | + * | |
| 1158 | + * @var bool | |
| 1159 | + */ | |
| 1160 | + private static $baking_query_variant = false; | |
| 1161 | + | |
| 1162 | + /** | |
| 1163 | + * Is a module still going to change this page after this response? | |
| 1164 | + * | |
| 1165 | + * Free itself never says yes — nothing in Free defers work past the | |
| 1166 | + * request. Minification and combining write their file and return its URL | |
| 1167 | + * inside the same render; the LCP preload is chosen by parsing the HTML | |
| 1168 | + * being sent. It is the question that matters to anything caching in | |
| 1169 | + * front of us, so Free asks it on their behalf and lets whoever owns the | |
| 1170 | + * deferred work answer. | |
| 1171 | + * | |
| 1172 | + * Answer TRUE while the work is outstanding for the page being served. | |
| 1173 | + * The cost of a false yes is one extra origin hit; the cost of a false no | |
| 1174 | + * is an un-optimized page pinned at the edge for the full lifetime, which | |
| 1175 | + * is the failure this exists to prevent — so when in doubt, say yes. | |
| 1176 | + * | |
| 1177 | + * Asked on a `request` only, and that boundary is the whole safety of it. | |
| 1178 | + * | |
| 1179 | + * A `bake` is generated once, in an admin or CLI request, and serves every | |
| 1180 | + * static HIT on the site; a per-page answer frozen into it would be wrong | |
| 1181 | + * for every other page. | |
| 1182 | + * | |
| 1183 | + * A `store` is worse, and cost a live site an afternoon. The pairs written | |
| 1184 | + * at store time go into the `.meta` sidecar, which the drop-in replays on | |
| 1185 | + * every later HIT — before plugins load, so nothing can re-ask this | |
| 1186 | + * question. A hold written there therefore outlives the state that caused | |
| 1187 | + * it, and the only thing that clears it is the page being stored again. On | |
| 1188 | + * a site where the deferred work never completes, every re-store re-pins | |
| 1189 | + * it, and the page is never edge-cacheable again. The symptom is a cache | |
| 1190 | + * HIT carrying `no-store` and `X-XSpeed-Edge-Hold: pending` on a page | |
| 1191 | + * whose deferred work finished long ago — the sidecar answering with | |
| 1192 | + * state nothing can re-ask. | |
| 1193 | + * | |
| 1194 | + * Holding the MISS is what this is for, and it is enough: that response is | |
| 1195 | + * the un-optimized one. The copy we then store is what an edge should | |
| 1196 | + * mirror, and when the work does land the module purges the page, which | |
| 1197 | + * reaches the edge. The purge is the correctness mechanism; this is only | |
| 1198 | + * meant to cover the single render before it. | |
| 1199 | + * | |
| 1200 | + * @param string $context `request`, `store` or `bake`. | |
| 1201 | + */ | |
| 1202 | + public static function edge_optimization_pending( string $context = 'request' ): bool { | |
| 1203 | + if ( 'request' !== $context ) { | |
| 1204 | + return false; | |
| 1205 | + } | |
| 1206 | + | |
| 1207 | + /** | |
| 1208 | + * Filter: xspeed_edge_optimization_pending | |
| 1209 | + * | |
| 1210 | + * @param bool $pending Whether deferred work will still change this page. | |
| 1211 | + */ | |
| 1212 | + return (bool) apply_filters( 'xspeed_edge_optimization_pending', false ); | |
| 1213 | + } | |
| 1214 | + | |
| 1215 | + /** | |
| 1216 | + * Does mobile cache split this URL into two renders? | |
| 1217 | + * | |
| 1218 | + * With `mobile_separate` on, Free keys its cache on device and serves a | |
| 1219 | + * different page to a phone than to a desktop at the SAME url. No CDN | |
| 1220 | + * varies on User-Agent, so an edge holding one of those renders serves it | |
| 1221 | + * to everyone: whichever device asked first decides what the other sees, | |
| 1222 | + * for the whole lifetime. A wrong page, not a slow one. | |
| 1223 | + * | |
| 1224 | + * Read from the stored option rather than through Settings_Manager: this | |
| 1225 | + * is consulted from the serve path, where the module registry may not | |
| 1226 | + * have run. | |
| 1227 | + */ | |
| 1228 | + private static function mobile_cache_splits_html(): bool { | |
| 1229 | + $stored = self::stored_cache_opts(); | |
| 1230 | + return ! empty( $stored['mobile_separate'] ); | |
| 1231 | + } | |
| 1232 | + | |
| 1233 | + /** | |
| 1234 | + * Is this cached page being served for a URL that no purge names? | |
| 1235 | + * | |
| 1236 | + * Free serves `/post?utm_source=x` from the entry stored for `/post`, | |
| 1237 | + * which is what the ignored-params list is for. An edge does not. It | |
| 1238 | + * keys on the full URL, so every query string becomes its own copy. A | |
| 1239 | + * purge that names `/post` clears one copy and leaves the others for the | |
| 1240 | + * whole edge lifetime. A newsletter link can then serve last month's | |
| 1241 | + * page. So such a HIT is held, and the edge keeps only URLs a purge can | |
| 1242 | + * name. See query_carries_unpurged_param() for which query strings count. | |
| 1243 | + * | |
| 1244 | + * A HIT only. A MISS is already held as `miss`, and a BYPASS never | |
| 1245 | + * reached the cache. And `request` only, because the query string belongs | |
| 1246 | + * to the request, not to the entry. One stored file answers `/post` and | |
| 1247 | + * every variant of it, so a hold written into its sidecar under `store` | |
| 1248 | + * would be replayed by the drop-in for the plain URL as well. A `bake` | |
| 1249 | + * says no unless query_variant_edge_headers() is asking for the drop-in. | |
| 1250 | + * | |
| 1251 | + * @param string $status `HIT`, `MISS` or `BYPASS`. | |
| 1252 | + * @param string $context `request`, `store` or `bake`. | |
| 1253 | + */ | |
| 1254 | + private static function serves_query_variant( string $status, string $context ): bool { | |
| 1255 | + if ( 'HIT' !== $status ) { | |
| 1256 | + return false; | |
| 1257 | + } | |
| 1258 | + if ( 'bake' === $context ) { | |
| 1259 | + return self::$baking_query_variant; | |
| 1260 | + } | |
| 1261 | + | |
| 1262 | + return 'request' === $context && self::query_carries_unpurged_param(); | |
| 1263 | + } | |
| 1264 | + | |
| 1265 | + /** | |
| 1266 | + * Does this request's query string carry a param that keeps its URL out | |
| 1267 | + * of every purge? | |
| 1268 | + * | |
| 1269 | + * Two kinds do. A param the cache key leaves out, which is anything on | |
| 1270 | + * the ignored-params list, judged by query_key_is_ignored() against the | |
| 1271 | + * same setting should_cache() reads. And the search and query-form feed | |
| 1272 | + * params (`s`, FEED_QUERY_PARAMS). Those have entries keyed their own | |
| 1273 | + * way, but they belong to opt-in caches whose URLs no purge names either. | |
| 1274 | + * | |
| 1275 | + * Any other param that reaches a HIT is part of the page's own address, | |
| 1276 | + * which a purge names, so it is not held. A plain-permalink route such | |
| 1277 | + * as `/?page_id=2` is the case this leaves alone. | |
| 1278 | + */ | |
| 1279 | + private static function query_carries_unpurged_param(): bool { | |
| 1280 | + $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- parsed for its keys only, as should_cache() does; never echoed or stored. | |
| 1281 | + if ( '' === trim( $query_raw ) ) { | |
| 1282 | + return false; | |
| 1283 | + } | |
| 1284 | + | |
| 1285 | + parse_str( $query_raw, $params ); | |
| 1286 | + $cache_opts = Settings_Manager::get( 'cache' ); | |
| 1287 | + $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array(); | |
| 1288 | + foreach ( array_keys( $params ) as $key ) { | |
| 1289 | + $key = (string) $key; | |
| 1290 | + if ( 's' === $key || in_array( $key, self::FEED_QUERY_PARAMS, true ) ) { | |
| 1291 | + return true; | |
| 1292 | + } | |
| 1293 | + if ( self::query_key_is_ignored( $key, $ignored ) ) { | |
| 1294 | + return true; | |
| 1295 | + } | |
| 1296 | + } | |
| 1297 | + | |
| 1298 | + return false; | |
| 1299 | + } | |
| 1300 | + | |
| 1301 | + /** | |
| 1302 | + * The edge answer the drop-in sends for a cached page served for a URL | |
| 1303 | + * no purge names, or an empty array when that answer is the same as the | |
| 1304 | + * plain URL's. | |
| 1305 | + * | |
| 1306 | + * The drop-in runs before plugins load and cannot ask edge_headers_for(), | |
| 1307 | + * so this is baked in next to the plain answer. The drop-in applies it | |
| 1308 | + * when a param in the query string matches the ignored-params list it | |
| 1309 | + * already reads (`.ignored-query-params`, written from the same setting | |
| 1310 | + * by sync_query_allowlist()). Search and feed params never reach it, | |
| 1311 | + * because it hands those requests to PHP. The nginx and Apache | |
| 1312 | + * rules need no copy. Both refuse any request with a query string | |
| 1313 | + * (`if ($args)`, `RewriteCond %{QUERY_STRING} ^$`), so a variant always | |
| 1314 | + * reaches PHP. | |
| 1315 | + * | |
| 1316 | + * Empty when the two answers agree. That covers no edge being known, | |
| 1317 | + * a filter vetoing the hold, and a site where every page is held | |
| 1318 | + * already (mobile-split). The drop-in then sends what it sends for the | |
| 1319 | + * plain URL, including any sidecar the page has. | |
| 1320 | + * | |
| 1321 | + * @return array<string,string> | |
| 1322 | + */ | |
| 1323 | + public static function query_variant_edge_headers(): array { | |
| 1324 | + $plain = self::edge_headers_for( 'HIT', 'bake' ); | |
| 1325 | + | |
| 1326 | + self::$baking_query_variant = true; | |
| 1327 | + try { | |
| 1328 | + $variant = self::edge_headers_for( 'HIT', 'bake' ); | |
| 1329 | + } finally { | |
| 1330 | + self::$baking_query_variant = false; | |
| 1331 | + } | |
| 1332 | + | |
| 1333 | + return $variant === $plain ? array() : $variant; | |
| 1334 | + } | |
| 1335 | + | |
| 1336 | + /** | |
| 1337 | + * Why, if at all, a cache in front of us should refuse to store this. | |
| 1338 | + * | |
| 1339 | + * @param string $status `HIT`, `MISS` or `BYPASS`. | |
| 1340 | + * @param string $context `request`, `store` or `bake`. | |
| 1341 | + * @param string $bypass_reason The gate slug, for BYPASS only. | |
| 1342 | + * @return string '' or one of bypass|bypass-shape|miss|mobile-split|pending|query-variant. | |
| 1343 | + */ | |
| 1344 | + private static function edge_hold_reason( string $status, string $context, string $bypass_reason ): string { | |
| 1345 | + $reason = ''; | |
| 1346 | + | |
| 1347 | + // The two exempt gates are answered before anything else, or a site | |
| 1348 | + // with Separate Mobile Cache on would keep holding after the page | |
| 1349 | + // cache was switched off — which is exactly the "a local toggle must | |
| 1350 | + // not become a site-wide side effect on infrastructure we do not own" | |
| 1351 | + // rule below, defeated by the ordering rather than by the logic. | |
| 1352 | + if ( 'BYPASS' === $status && in_array( $bypass_reason, self::HOLD_EXEMPT_BYPASS, true ) ) { | |
| 1353 | + /** This filter is documented below. */ | |
| 1354 | + return (string) apply_filters( 'xspeed_edge_hold_reason', '', $status, $context, $bypass_reason ); | |
| 1355 | + } | |
| 1356 | + | |
| 1357 | + // First, because it is the only reason true in every context: the | |
| 1358 | + // setting is a property of the site, not of one request, so it is the | |
| 1359 | + // one thing a baked artifact can honestly assert. | |
| 1360 | + // | |
| 1361 | + // It is also the only reason that holds a HIT — a response we DID | |
| 1362 | + // cache — and that is deliberate rather than an artefact of the | |
| 1363 | + // ordering. With mobile_separate on we key the cache by device and | |
| 1364 | + // serve different HTML to a phone than to a desktop at the same URL. | |
| 1365 | + // No CDN varies on User-Agent, so an edge holding one of those | |
| 1366 | + // renders serves it to everyone and whichever device asked first | |
| 1367 | + // decides what the other sees. Our copy is fine; theirs would be a | |
| 1368 | + // wrong page. The static path is switched off in this mode anyway | |
| 1369 | + // (static_rewrite_allowed()), so these hits come from the drop-in, | |
| 1370 | + // which carries the same baked answer. | |
| 1371 | + if ( self::mobile_cache_splits_html() ) { | |
| 1372 | + $reason = 'mobile-split'; | |
| 1373 | + } elseif ( 'BYPASS' === $status ) { | |
| 1374 | + $shaped = in_array( $bypass_reason, array( 'excluded-url', 'query-param' ), true ) | |
| 1375 | + ? ! self::path_is_a_personal_exclusion( $bypass_reason ) | |
| 1376 | + : in_array( $bypass_reason, self::REQUEST_SHAPE_BYPASS, true ); | |
| 1377 | + $reason = $shaped ? 'bypass-shape' : 'bypass'; | |
| 1378 | + } elseif ( self::edge_optimization_pending( $context ) ) { | |
| 1379 | + $reason = 'pending'; | |
| 1380 | + } elseif ( 'MISS' === $status ) { | |
| 1381 | + $reason = 'miss'; | |
| 1382 | + } elseif ( self::serves_query_variant( $status, $context ) ) { | |
| 1383 | + // Last, so it only adds a hold where there was none. A variant | |
| 1384 | + // still waiting for its CSS says `pending`, which is also true, | |
| 1385 | + // and the CSS modules are still asked on every HIT as before. | |
| 1386 | + $reason = 'query-variant'; | |
| 1387 | + } | |
| 1388 | + | |
| 1389 | + /** | |
| 1390 | + * Filter: xspeed_edge_hold_reason | |
| 1391 | + * | |
| 1392 | + * Return '' to veto a hold, or a reason string to force one. | |
| 1393 | + * | |
| 1394 | + * @param string $reason '' or bypass|bypass-shape|miss|mobile-split|pending|query-variant. | |
| 1395 | + * @param string $status `HIT`, `MISS` or `BYPASS`. | |
| 1396 | + * @param string $context `request`, `store` or `bake`. | |
| 1397 | + * @param string $bypass_reason The gate slug, for BYPASS only. | |
| 1398 | + */ | |
| 1399 | + return (string) apply_filters( 'xspeed_edge_hold_reason', $reason, $status, $context, $bypass_reason ); | |
| 1400 | + } | |
| 1401 | + | |
| 1402 | + /** | |
| 1403 | + * Was this page excluded because it is personal, or because it is | |
| 1404 | + * plumbing we cannot key a cache entry on? | |
| 1405 | + * | |
| 1406 | + * Answers by removing the structural defaults from the site's own | |
| 1407 | + * exclusion list and asking whether anything is left that matches. So a | |
| 1408 | + * feed matches only `/feed/` and comes back false; `/my-account/feedback/` | |
| 1409 | + * matches `/my-account` and comes back true; and on an "almost pretty" | |
| 1410 | + * permalink site, where every path contains `index.php`, an ordinary page | |
| 1411 | + * matches nothing else and is correctly treated as public. | |
| 1412 | + * | |
| 1413 | + * The path only, never the query string — a visitor writes that, and | |
| 1414 | + * `/cart/?utm_source=/feed/` must not be able to talk a cart out of its | |
| 1415 | + * hold. It is also what `should_cache()` matches the list against. | |
| 1416 | + * | |
| 1417 | + * Asked for a `query-param` bypass too, because the query gate runs | |
| 1418 | + * BEFORE the URL gate, so `/cart/?add-to-cart=12` reports `query-param` | |
| 1419 | + * and never reaches `excluded-url` at all. Which gate fired first says | |
| 1420 | + * nothing about whose data is on the page. | |
| 1421 | + */ | |
| 1422 | + private static function path_is_a_personal_exclusion( string $bypass_reason ): bool { | |
| 1423 | + $path = self::request_path(); | |
| 1424 | + if ( '' === $path ) { | |
| 1425 | + return false; | |
| 1426 | + } | |
| 1427 | + | |
| 1428 | + // Through Settings_Manager, not the raw option, because the schema's | |
| 1429 | + // default IS the structural list and a fresh install has never | |
| 1430 | + // written the option. Read raw, every site that has not visited the | |
| 1431 | + // settings screen looks like a site with no exclusions at all, takes | |
| 1432 | + // the contradiction branch below, and reports its feeds as personal. | |
| 1433 | + // | |
| 1434 | + // Safe here where `mobile_cache_splits_html()` is not: we are only | |
| 1435 | + // ever called with a bypass reason, and those come from | |
| 1436 | + // `should_cache()`, which resolved the same settings through | |
| 1437 | + // `Settings_Manager::get()` to produce them. | |
| 1438 | + $opts = Settings_Manager::get( 'cache' ); | |
| 1439 | + $excluded = is_array( $opts['excluded_urls'] ?? null ) ? $opts['excluded_urls'] : array(); | |
| 1440 | + if ( array() === $excluded ) { | |
| 1441 | + // An `excluded-url` bypass with no exclusion list is a | |
| 1442 | + // contradiction — something excluded the request and the list | |
| 1443 | + // cannot say what — so assume personal, because a wasted header | |
| 1444 | + // costs a little origin traffic while a missing one serves | |
| 1445 | + // somebody's basket to a stranger. A `query-param` bypass with an | |
| 1446 | + // empty list is just an ordinary page carrying a parameter, and | |
| 1447 | + // says nothing about the path at all. | |
| 1448 | + return 'excluded-url' === $bypass_reason; | |
| 1449 | + } | |
| 1450 | + | |
| 1451 | + $personal = array_values( | |
| 1452 | + array_filter( | |
| 1453 | + $excluded, | |
| 1454 | + static fn ( $pattern ) => ! in_array( (string) $pattern, self::STRUCTURAL_EXCLUSIONS, true ) | |
| 1455 | + ) | |
| 1456 | + ); | |
| 1457 | + | |
| 1458 | + return array() !== $personal && self::path_matches_exclusions( $personal, $path ); | |
| 1459 | + } | |
| 1460 | + | |
| 1461 | + /** | |
| 1462 | + * The edge/CDN headers to send on a response with this cache status. | |
| 1463 | + * | |
| 1464 | + * @param string $status `HIT`, `MISS` or `BYPASS`. | |
| 1465 | + * @param string $context `request` when resolved per request on the | |
| 1466 | + * PHP serve path, `store` when resolved for | |
| 1467 | + * one entry's sidecar, `bake` when resolved | |
| 1468 | + * once and frozen into the drop-in, `rules` | |
| 1469 | + * when resolved once for the nginx or Apache | |
| 1470 | + * static block. | |
| 1471 | + * @param string $bypass_reason The gate slug, for BYPASS only. | |
| 1472 | + * @return array<string,string> | |
| 1473 | + */ | |
| 1474 | + public static function edge_headers_for( string $status, string $context = 'request', string $bypass_reason = '' ): array { | |
| 1475 | + // `rules` is a bake in every respect but one: the cache-headers | |
| 1476 | + // filter is told it is answering for a pasted server rule. Such a | |
| 1477 | + // rule serves every static HIT for as long as it stays pasted, and | |
| 1478 | + // nothing regenerates it when the site's expiry or a page's own | |
| 1479 | + // lifetime (a nonce cap) changes, so a lifetime given there cannot | |
| 1480 | + // follow the page. The drop-in is rewritten by auto_heal(); a paste | |
| 1481 | + // is not. | |
| 1482 | + $filter_context = $context; | |
| 1483 | + if ( 'rules' === $context ) { | |
| 1484 | + $context = 'bake'; | |
| 1485 | + } | |
| 1486 | + | |
| 1487 | + $base = array(); | |
| 1488 | + if ( 'HIT' === $status ) { | |
| 1489 | + /** | |
| 1490 | + * Filter: xspeed_edge_cache_headers | |
| 1491 | + * | |
| 1492 | + * Response headers to add to a cached HTML response. The lifetime | |
| 1493 | + * is HIT-only: it is a promise that this copy is worth keeping, | |
| 1494 | + * and neither a first render nor a page we refused to cache is | |
| 1495 | + * one. The filter is also asked with `MISS` when a first render | |
| 1496 | + * is held, and only the `Cache-Tag` from that answer is sent, so | |
| 1497 | + * a purge can name a copy an edge stored despite the hold. | |
| 1498 | + * | |
| 1499 | + * The same filter feeds four regimes and `$context` says which. | |
| 1500 | + * On the PHP serve path it runs per request (`request`); at store | |
| 1501 | + * time it runs for one entry (`store`); when the drop-in is | |
| 1502 | + * generated it runs once (`bake`) and the result answers for | |
| 1503 | + * every drop-in HIT on the site; when the nginx or Apache static | |
| 1504 | + * block is generated it runs once (`rules`). Anything per-page — a | |
| 1505 | + * post id in a cache tag, say — must be skipped under `bake` and | |
| 1506 | + * `rules`. A server rule serves every static HIT for as long as it | |
| 1507 | + * stays pasted, so a lifetime here can't follow the page: send | |
| 1508 | + * none under `rules`. | |
| 1509 | + * | |
| 1510 | + * @param array<string,string> $headers Header name => value. | |
| 1511 | + * @param string $status `HIT`, or `MISS` when only the tag of a held first render is wanted. | |
| 1512 | + * @param string $context `request`, `store`, `bake` or `rules`. | |
| 1513 | + */ | |
| 1514 | + $base = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'HIT', $filter_context ) ); | |
| 1515 | + } | |
| 1516 | + | |
| 1517 | + $reason = self::edge_hold_reason( $status, $context, $bypass_reason ); | |
| 1518 | + if ( '' === $reason ) { | |
| 1519 | + return $base; | |
| 1520 | + } | |
| 1521 | + $detected = Edge_Provider::detect( $context ); | |
| 1522 | + if ( Edge_Provider::is_off( $detected ) ) { | |
| 1523 | + return $base; | |
| 1524 | + } | |
| 1525 | + if ( Edge_Provider::NONE === $detected['confidence'] | |
| 1526 | + && ! in_array( $reason, self::HOLD_WITHOUT_EVIDENCE, true ) ) { | |
| 1527 | + return $base; | |
| 1528 | + } | |
| 1529 | + | |
| 1530 | + $hold = Edge_Provider::hold_headers( $detected['provider'] ); | |
| 1531 | + | |
| 1532 | + /** | |
| 1533 | + * Filter: xspeed_edge_hold_headers | |
| 1534 | + * | |
| 1535 | + * The last word on what a hold INSTRUCTS. Runs before sanitising, so | |
| 1536 | + * a value that cannot be sent as a header is still dropped, and | |
| 1537 | + * before `X-XSpeed-Edge-Hold` is added, so it cannot rewrite the | |
| 1538 | + * reason xSpeed held the page for — that is a diagnosis, not an | |
| 1539 | + * instruction, and a forged one sends a reader after the wrong | |
| 1540 | + * module. | |
| 1541 | + * | |
| 1542 | + * @param array<string,string> $hold Header name => value. | |
| 1543 | + * @param array<string,string> $detected Provider, confidence, source. | |
| 1544 | + * @param string $reason Why the hold fired. | |
| 1545 | + * @param string $context `request`, `store` or `bake`. | |
| 1546 | + */ | |
| 1547 | + $hold = (array) apply_filters( 'xspeed_edge_hold_headers', $hold, $detected, $reason, $context ); | |
| 1548 | + | |
| 1549 | + // A hold replaces the lifetime rather than sitting beside it: the two | |
| 1550 | + // describe the same response and would contradict each other. The | |
| 1551 | + // cache tag survives, because a later purge still has to be able to | |
| 1552 | + // name whatever the edge picked up on its own terms. | |
| 1553 | + // | |
| 1554 | + // On a HIT the tag came with `$base`. A MISS has no `$base`, and that | |
| 1555 | + // is the case that mattered most: the first render after any purge. | |
| 1556 | + // An edge whose rule overrides the origin's lifetime stores it | |
| 1557 | + // despite the hold, and with no tag on it the next tag purge cannot | |
| 1558 | + // reach it, so an edit stayed stale for the edge's whole lifetime | |
| 1559 | + // (QA, 2026-09-23). So a held MISS asks the same filter for its tag | |
| 1560 | + // and keeps the tag alone; the lifetime in the answer is dropped, | |
| 1561 | + // because the hold is the instruction for this response. | |
| 1562 | + $tag = $base['Cache-Tag'] ?? ''; | |
| 1563 | + if ( '' === $tag && 'MISS' === $status ) { | |
| 1564 | + $tagged = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'MISS', $filter_context ) ); | |
| 1565 | + $tag = $tagged['Cache-Tag'] ?? ''; | |
| 1566 | + } | |
| 1567 | + if ( '' !== $tag ) { | |
| 1568 | + $hold['Cache-Tag'] = $tag; | |
| 1569 | + } | |
| 1570 | + | |
| 1571 | + // Never argue with a stronger answer WordPress already gave. It sends | |
| 1572 | + // `no-store, private` of its own accord on a logged-in, 404 or | |
| 1573 | + // password-protected response, from WP::send_headers() — which runs | |
| 1574 | + // before template_redirect, so it is already on the wire by the time | |
| 1575 | + // we get here. Ours is the weaker statement of the two; replacing it | |
| 1576 | + // would be a downgrade dressed as a fix. Only meaningful per request: | |
| 1577 | + // a bake has no response to inspect. | |
| 1578 | + if ( 'request' === $context && isset( $hold['Cache-Control'] ) && self::cache_control_already_stronger() ) { | |
| 1579 | + unset( $hold['Cache-Control'] ); | |
| 1580 | + } | |
| 1581 | + | |
| 1582 | + // A page we refused to cache must not carry a validator either. A | |
| 1583 | + // `Last-Modified` left on it invites a conditional request, and a | |
| 1584 | + // shared cache that gets a 304 back serves the copy it should not | |
| 1585 | + // have stored. Only on a bypass, and only per request: a MISS is | |
| 1586 | + // about to be stored by us, so its validator is ours to keep. | |
| 1587 | + if ( 'request' === $context && 'bypass' === $reason && ! headers_sent() ) { | |
| 1588 | + header_remove( 'Last-Modified' ); | |
| 1589 | + } | |
| 1590 | + | |
| 1591 | + $hold = self::sanitize_edge_headers( $hold ); | |
| 1592 | + | |
| 1593 | + // Name the reason in the hold set itself, rather than sending it | |
| 1594 | + // separately from mark(). | |
| 1595 | + // | |
| 1596 | + // "Why is my page not being cached at the edge?" is the question this | |
| 1597 | + // answers, and mark() could only answer it on the PHP serve path. The | |
| 1598 | + // other emitters send whatever this function returns and never ran | |
| 1599 | + // mark() at all — so the responses hardest to explain went out | |
| 1600 | + // carrying `no-store` with nothing beside it to say why. Chiefly the | |
| 1601 | + // drop-in, which serves from the `.meta` sidecar written under | |
| 1602 | + // `store` and from the literal baked under `bake`, before plugins | |
| 1603 | + // load and with no way to re-ask (the symptom | |
| 1604 | + // edge_optimization_pending() describes above). | |
| 1605 | + // | |
| 1606 | + // The nginx and Apache blocks are a third path in principle and | |
| 1607 | + // almost never in practice: they are only installed when | |
| 1608 | + // static_rewrite_allowed() is true, and the one reason a stock site | |
| 1609 | + // can hold in their bake is `mobile-split`, which is exactly what | |
| 1610 | + // makes that false. `query-variant` is baked for the drop-in alone, | |
| 1611 | + // since both blocks refuse a query string. They will carry a hold | |
| 1612 | + // where a site forces one through `xspeed_edge_hold_reason`, and | |
| 1613 | + // otherwise have none to carry. | |
| 1614 | + // | |
| 1615 | + // Added AFTER sanitising and banned in NEVER_AN_EDGE_HEADER, so | |
| 1616 | + // neither of the two filters above can forge a reason or suppress the | |
| 1617 | + // real one. | |
| 1618 | + // | |
| 1619 | + // Reduced to the slug CHARACTER CLASS, not checked against the six | |
| 1620 | + // slugs: `xspeed_edge_hold_reason` is documented as able to force a | |
| 1621 | + // reason, and a site that forces its own deserves to see it. What is | |
| 1622 | + // not negotiable is the shape, because this value reaches an | |
| 1623 | + // .htaccess and an nginx conf as well as a response header — so no | |
| 1624 | + // CR/LF, no `$`, no `%`, no `\`, and a length a config file can hold. | |
| 1625 | + $slug = preg_replace( '/[^a-z0-9-]/', '', strtolower( $reason ) ); | |
| 1626 | + if ( is_string( $slug ) && '' !== $slug ) { | |
| 1627 | + $hold['X-XSpeed-Edge-Hold'] = substr( $slug, 0, 32 ); | |
| 1628 | + } | |
| 1629 | + | |
| 1630 | + return $hold; | |
| 1631 | + } | |
| 1632 | + | |
| 1633 | + /** Has something already sent a Cache-Control at least as strict as ours? */ | |
| 1634 | + private static function cache_control_already_stronger(): bool { | |
| 1635 | + foreach ( headers_list() as $line ) { | |
| 1636 | + if ( 0 !== stripos( $line, 'cache-control:' ) ) { | |
| 1637 | + continue; | |
| 1638 | + } | |
| 1639 | + if ( preg_match( '/\b(?:no-store|private)\b/i', $line ) ) { | |
| 1640 | + return true; | |
| 1641 | + } | |
| 1642 | + } | |
| 1643 | + | |
| 1644 | + return false; | |
| 1645 | + } | |
| 1646 | + | |
| 1647 | + /** | |
| 1648 | + * Edge headers that belong to THIS page rather than to every page. | |
| 1649 | + * | |
| 1650 | + * `edge_headers_for('HIT','bake')` is the answer frozen into the drop-in | |
| 1651 | + * and the server rules: one set, serving the whole site. But the answer | |
| 1652 | + * for one URL can legitimately differ — a page whose deferred work is | |
| 1653 | + * still outstanding, say — and that answer has nowhere to live, because | |
| 1654 | + * the baked set is all the fast paths know about. | |
| 1655 | + * | |
| 1656 | + * So ask again in a `store` context, with the request still in scope, and | |
| 1657 | + * return the pairs only when they differ from the baked ones. Identical is | |
| 1658 | + * the overwhelmingly common case and writes nothing: pages do not pay a | |
| 1659 | + * sidecar for an answer the drop-in already has. | |
| 1660 | + * | |
| 1661 | + * Memoised because two callers ask within one store — the sidecar writer | |
| 1662 | + * and the static-tree guard — and the filters behind it are not required | |
| 1663 | + * to be cheap. | |
| 1664 | + * | |
| 1665 | + * A page with a lifetime of its own also gets its own pairs, whenever | |
| 1666 | + * they grant an edge any lifetime at all. The lifetime is cut to the | |
| 1667 | + * page's (see cap_edge_lifetime()), and the pairs are returned even when | |
| 1668 | + * the cut leaves them equal to the baked ones. Returning them keeps the | |
| 1669 | + * page off the static tree, and it has to: only the two PHP paths can | |
| 1670 | + * count a lifetime down as the copy ages. The web server sends the baked | |
| 1671 | + * value from whatever age the file has reached, so a page capped to its | |
| 1672 | + * nonce would reach the edge with hours of dead nonce still to serve. | |
| 1673 | + * | |
| 1674 | + * write_meta() resolves that lifetime and passes it on the first call. | |
| 1675 | + * The static-tree guards ask afterwards and read the memo. Both store | |
| 1676 | + * paths call write_meta() before their guard. | |
| 1677 | + * | |
| 1678 | + * @param int|null $own_ttl The entry's own lifetime in seconds, when it | |
| 1679 | + * differs from the site's (the sidecar `ttl`). | |
| 1680 | + * @return array<string,string> Empty when this page needs no override. | |
| 1681 | + */ | |
| 1682 | + private static function per_entry_edge_headers( ?int $own_ttl = null ): array { | |
| 1683 | + if ( is_array( self::$per_entry_edge ) ) { | |
| 1684 | + return self::$per_entry_edge; | |
| 1685 | + } | |
| 1686 | + $baked = self::edge_headers_for( 'HIT', 'bake' ); | |
| 1687 | + $request = self::edge_headers_for( 'HIT', 'store' ); | |
| 1688 | + if ( null !== $own_ttl && self::grants_edge_lifetime( $request ) ) { | |
| 1689 | + self::$per_entry_edge = self::cap_edge_lifetime( $request, $own_ttl ); | |
| 1690 | + return self::$per_entry_edge; | |
| 1691 | + } | |
| 1692 | + self::$per_entry_edge = ( $request === $baked ) ? array() : $request; | |
| 1693 | + | |
| 1694 | + return self::$per_entry_edge; | |
| 1695 | + } | |
| 1696 | + | |
| 1697 | + /** | |
| 1698 | + * Cut every lifetime an edge header grants down to `$seconds`. | |
| 1699 | + * | |
| 1700 | + * The lifetime in a HIT's pairs comes from `xspeed_edge_cache_headers` | |
| 1701 | + * and describes the site, so it says nothing of a page whose own lifetime | |
| 1702 | + * is shorter: one carrying a nonce (#236), one with a per-post expiry, | |
| 1703 | + * one a `xspeed_cache_max_age` filter shortened. xSpeed rebuilds that page | |
| 1704 | + * on time, and an edge holding it for the site's lifetime goes on serving | |
| 1705 | + * the old copy anyway. Nothing tells the edge when our copy expires, | |
| 1706 | + * because an expiry is not a purge. For a nonce page that is a broken | |
| 1707 | + * form for every visitor until the next purge. | |
| 1708 | + * | |
| 1709 | + * Only shortens. A `max-age` or `s-maxage` already under `$seconds` is | |
| 1710 | + * kept, so a hold's `s-maxage=0` stays 0. Only headers named `*-Control` | |
| 1711 | + * are touched; a `Cache-Tag` that happens to contain the text is not a | |
| 1712 | + * directive. | |
| 1713 | + * | |
| 1714 | + * @param array<string,string> $headers Header name => value. | |
| 1715 | + * @param int $seconds The most any of them may grant. | |
| 1716 | + * @return array<string,string> | |
| 1717 | + */ | |
| 1718 | + public static function cap_edge_lifetime( array $headers, int $seconds ): array { | |
| 1719 | + $seconds = max( 0, $seconds ); | |
| 1720 | + foreach ( $headers as $name => $value ) { | |
| 1721 | + if ( ! preg_match( self::EDGE_LIFETIME_HEADER, (string) $name ) ) { | |
| 1722 | + continue; | |
| 1723 | + } | |
| 1724 | + $capped = preg_replace_callback( | |
| 1725 | + self::EDGE_LIFETIME_DIRECTIVE, | |
| 1726 | + static function ( array $m ) use ( $seconds ): string { | |
| 1727 | + return $m[1] . '=' . min( (int) $m[2], $seconds ); | |
| 1728 | + }, | |
| 1729 | + (string) $value | |
| 1730 | + ); | |
| 1731 | + if ( is_string( $capped ) ) { | |
| 1732 | + $headers[ $name ] = $capped; | |
| 1733 | + } | |
| 1734 | + } | |
| 1735 | + | |
| 1736 | + return $headers; | |
| 1737 | + } | |
| 1738 | + | |
| 1739 | + /** | |
| 1740 | + * Does any `*-Control` header in the set let an edge keep the response? | |
| 1741 | + * | |
| 1742 | + * @param array<string,string> $headers Header name => value. | |
| 1743 | + */ | |
| 1744 | + private static function grants_edge_lifetime( array $headers ): bool { | |
| 1745 | + foreach ( $headers as $name => $value ) { | |
| 1746 | + if ( ! preg_match( self::EDGE_LIFETIME_HEADER, (string) $name ) ) { | |
| 1747 | + continue; | |
| 1748 | + } | |
| 1749 | + if ( preg_match_all( self::EDGE_LIFETIME_DIRECTIVE, (string) $value, $m ) ) { | |
| 1750 | + foreach ( $m[2] as $seconds ) { | |
| 1751 | + if ( (int) $seconds > 0 ) { | |
| 1752 | + return true; | |
| 1753 | + } | |
| 1754 | + } | |
| 1755 | + } | |
| 1756 | + } | |
| 1757 | + | |
| 1758 | + return false; | |
| 1759 | + } | |
| 1760 | + | |
| 1761 | + /** | |
| 1762 | + * Seconds a cached entry has left, when it has a lifetime of its own. | |
| 1763 | + * | |
| 1764 | + * Read from the sidecar `ttl`, which write_meta() records only when the | |
| 1765 | + * entry's lifetime differs from the site's. That is the drop-in's rule | |
| 1766 | + * too, so both PHP serve paths count down the same entries. Null for an | |
| 1767 | + * entry that follows the site's lifetime: what the edge is told about | |
| 1768 | + * those is the site's lifetime, unchanged. | |
| 1769 | + * | |
| 1770 | + * @param array<string,mixed> $meta The entry's sidecar, from read_meta(). | |
| 1771 | + * @param string $file The cached file being served. | |
| 1772 | + */ | |
| 1773 | + private static function entry_lifetime_left( array $meta, string $file ): ?int { | |
| 1774 | + $ttl = isset( $meta['ttl'] ) ? (int) $meta['ttl'] : 0; | |
| 1775 | + if ( $ttl < 1 ) { | |
| 1776 | + return null; | |
| 1777 | + } | |
| 1778 | + $mtime = file_exists( $file ) ? filemtime( $file ) : false; | |
| 1779 | + if ( false === $mtime ) { | |
| 1780 | + return null; | |
| 1781 | + } | |
| 1782 | + | |
| 1783 | + return max( 0, $ttl - ( time() - (int) $mtime ) ); | |
| 1784 | + } | |
| 1785 | + | |
| 1786 | + /** | |
| 1787 | + * Render baked pairs as a PHP array literal for the drop-in. | |
| 1788 | + * | |
| 1789 | + * Single-quoted literals with quotes escaped, because the result is | |
| 1790 | + * written into a PHP file that must still parse. Values reaching here | |
| 1791 | + * have already been through sanitize_edge_headers(), so neither name nor | |
| 1792 | + * value can carry a newline. | |
| 1793 | + * | |
| 1794 | + * @param array<string,string> $headers Name => value. | |
| 1795 | + */ | |
| 1796 | + private static function edge_headers_literal( array $headers ): string { | |
| 1797 | + if ( array() === $headers ) { | |
| 1798 | + return 'array()'; | |
| 1799 | + } | |
| 1800 | + // var_export(), not hand-rolled quoting. A single-quoted PHP string | |
| 1801 | + // escapes BOTH `'` and `\\`, and escaping only the first is how a | |
| 1802 | + // value ending in a backslash — `X-Foo: C:\path\` from the custom | |
| 1803 | + // headers box — leaves the literal unterminated. That file is | |
| 1804 | + // included on every request once WP_CACHE is on, so the result is a | |
| 1805 | + // parse error on the front end AND in wp-admin, with no way back | |
| 1806 | + // except deleting the file over SSH. | |
| 1807 | + $parts = array(); | |
| 1808 | + foreach ( $headers as $name => $value ) { | |
| 1809 | + $parts[] = var_export( (string) $name, true ) . ' => ' . var_export( (string) $value, true ); | |
| 1810 | + } | |
| 1811 | + | |
| 1812 | + return 'array( ' . implode( ', ', $parts ) . ' )'; | |
| 1813 | + } | |
| 1814 | + | |
| 1815 | + /** | |
| 1816 | + * Quote a header value for an nginx / Apache directive. | |
| 1817 | + * | |
| 1818 | + * Both accept a double-quoted string with backslash escapes, and both | |
| 1819 | + * refuse to load a config where the quoting is wrong — a mis-escaped | |
| 1820 | + * value takes the whole vhost down, not just this header. | |
| 1821 | + */ | |
| 1822 | + private static function quote_directive_value( string $value ): string { | |
| 1823 | + return str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value ); | |
| 1824 | + } | |
| 1825 | + | |
| 1826 | + /** | |
| 1827 | + * The same directive twice — once per name Apache can expose the | |
| 1828 | + * rewrite's environment variable under. | |
| 1829 | + * | |
| 1830 | + * `RewriteRule ... [E=XSPEED_STATIC_HIT:1]` in a per-directory context is | |
| 1831 | + * an INTERNAL REDIRECT: Apache re-enters the request with the substituted | |
| 1832 | + * path, and every variable set on the first pass is renamed with a | |
| 1833 | + * `REDIRECT_` prefix for the second. `env=XSPEED_STATIC_HIT` is evaluated | |
| 1834 | + * on that second pass, where nothing answers to that name any more, so | |
| 1835 | + * the directive never fires — dropping the headers from precisely the | |
| 1836 | + * responses they exist for. The rules marker rides on the same gate, so | |
| 1837 | + * the probe also read its own marker as missing and called correctly | |
| 1838 | + * installed rules stale. | |
| 1839 | + * | |
| 1840 | + * It cannot be written once: `env=` takes a single name with no | |
| 1841 | + * alternation, and `expr=` — which could express both — is not dependable | |
| 1842 | + * on LiteSpeed, which reads this same block. So both are emitted; the one | |
| 1843 | + * whose variable is unset on a given pass does nothing. | |
| 1844 | + * | |
| 1845 | + * @param string $directive The directive, without its `env=` clause. | |
| 1846 | + * @return string[] | |
| 1847 | + */ | |
| 1848 | + private static function static_hit_directives( string $directive ): array { | |
| 1849 | + return array( | |
| 1850 | + $directive . ' env=XSPEED_STATIC_HIT', | |
| 1851 | + $directive . ' env=REDIRECT_XSPEED_STATIC_HIT', | |
| 1852 | + ); | |
| 1853 | + } | |
| 1854 | + | |
| 1855 | + /** | |
| 1856 | + * Keep only pairs that can be sent as a header verbatim. | |
| 1857 | + * | |
| 1858 | + * These values reach three different emitters — PHP's header(), an nginx | |
| 1859 | + * `add_header` and an Apache `Header always set` — so a name with a space | |
| 1860 | + * or a value carrying CR/LF is not merely malformed, it is a | |
| 1861 | + * response-splitting vector in the first and a broken server config in | |
| 1862 | + * the other two. Names must be token-shaped; values lose CR/LF and are | |
| 1863 | + * dropped if nothing survives. | |
| 1864 | + * | |
| 1865 | + * @param array<mixed,mixed> $headers Raw pairs. | |
| 1866 | + * @return array<string,string> | |
| 1867 | + */ | |
| 1868 | + public static function sanitize_edge_headers( array $headers ): array { | |
| 1869 | + $clean = array(); | |
| 1870 | + foreach ( $headers as $name => $value ) { | |
| 1871 | + // Never let one of these through, whoever asked. They describe the | |
| 1872 | + // transfer rather than the caching policy, and getting one wrong | |
| 1873 | + // from a settings field is a white screen: `Content-Encoding: gzip` | |
| 1874 | + // on an uncompressed body, a `Content-Length` that disagrees with | |
| 1875 | + // the bytes. `X-XSpeed-Cache` is ours and a second copy would lie | |
| 1876 | + // to whoever reads it. | |
| 1877 | + if ( is_string( $name ) && in_array( strtolower( $name ), self::NEVER_AN_EDGE_HEADER, true ) ) { | |
| 1878 | + continue; | |
| 1879 | + } | |
| 1880 | + // `\z`, not `$`: PCRE's `$` also matches immediately BEFORE a | |
| 1881 | + // trailing newline, so "Cache-Tag\n" passes a `$` check and gets | |
| 1882 | + // concatenated raw into the generated .htaccess — splitting one | |
| 1883 | + // Header directive across two lines, which is a syntax error | |
| 1884 | + // Apache reports as a 500 on every request while `httpd -t` stays | |
| 1885 | + // green (.htaccess is parsed per request, not at load). | |
| 1886 | + if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9-]+\z/', $name ) ) { | |
| 1887 | + continue; | |
| 1888 | + } | |
| 1889 | + if ( ! is_string( $value ) && ! is_numeric( $value ) ) { | |
| 1890 | + continue; | |
| 1891 | + } | |
| 1892 | + $value = trim( str_replace( array( "\r", "\n" ), '', (string) $value ) ); | |
| 1893 | + if ( '' === $value ) { | |
| 1894 | + continue; | |
| 1895 | + } | |
| 1896 | + // `$` is a variable reference in an nginx string and `%` is a | |
| 1897 | + // format tag to Apache's mod_headers, which rejects an | |
| 1898 | + // unrecognised one — in .htaccess that is a 500 on every request | |
| 1899 | + // while `httpd -t` still reports OK, because .htaccess is parsed | |
| 1900 | + // per request. `\` escapes the quote in the PHP literal baked into | |
| 1901 | + // the drop-in. None of them can be escaped reliably in all three | |
| 1902 | + // places at once, and nothing a cache reads needs any of them, so | |
| 1903 | + // the value is dropped rather than mangled. | |
| 1904 | + if ( preg_match( '/[$%\\\\]/', $value ) ) { | |
| 1905 | + continue; | |
| 1906 | + } | |
| 1907 | + $clean[ $name ] = $value; | |
| 1908 | + } | |
| 1909 | + | |
| 1910 | + return $clean; | |
| 1911 | + } | |
| 1912 | + | |
| 1913 | + /** | |
| 448 | 1914 | * Bypass gates that describe THE VISITOR rather than THIS REQUEST. |
| 449 | 1915 | * |
| 450 | 1916 | * Only these may be recorded in the bypass cookie. A visitor-scoped |
| 451 | 1917 | * verdict stays true for the visitor's next request — they are still |
| @@ -506,9 +1972,19 @@ | ||
| 506 | 1972 | $key = self::cache_key(); |
| 507 | 1973 | $file = self::cache_file_for( $key ); |
| 508 | 1974 | |
| 509 | 1975 | if ( file_exists( $file ) && ! self::is_expired( $file ) ) { |
| 510 | - Hit_Counter::record_hit(); | |
| 1976 | + // Symmetric with the miss branch below: a bot, scanner or one of | |
| 1977 | + // xSpeed's own warm/benchmark requests that lands a HIT must not | |
| 1978 | + // inflate the ratio either — excluding only their misses would | |
| 1979 | + // shrink the denominator while their hits kept feeding the | |
| 1980 | + // numerator, making the displayed ratio MORE optimistic than | |
| 1981 | + // before the exclusion existed. | |
| 1982 | + if ( self::miss_is_excluded() ) { | |
| 1983 | + Hit_Counter::record_excluded(); | |
| 1984 | + } else { | |
| 1985 | + Hit_Counter::record_hit(); | |
| 1986 | + } | |
| 511 | 1987 | // Emit the HIT marker on THIS path too. The drop-in |
| 512 | 1988 | // (advanced-cache.php) sends "HIT (php)" and the nginx static |
| 513 | 1989 | // rewrite sends "HIT (nginx)", but this template_redirect |
| 514 | 1990 | // serve path — the one that runs when the drop-in isn't loaded |
| @@ -514,14 +1990,18 @@ | ||
| 514 | 1990 | // serve path — the one that runs when the drop-in isn't loaded |
| 515 | 1991 | // (e.g. WP_CACHE not true) — previously streamed the cached |
| 516 | 1992 | // file with NO marker, so a genuine HIT looked like a MISS in |
| 517 | 1993 | // the response headers. Same header + value as the drop-in. |
| 518 | - self::mark( 'HIT (php)' ); | |
| 1994 | + // | |
| 1995 | + // The sidecar is read first because the HIT's edge lifetime | |
| 1996 | + // depends on it: an entry with a lifetime of its own may not be | |
| 1997 | + // kept at the edge past it. See entry_lifetime_left(). | |
| 1998 | + $meta = self::read_meta( $key ); | |
| 1999 | + self::mark( 'HIT (php)', '', self::entry_lifetime_left( $meta, $file ) ); | |
| 519 | 2000 | // Replay stored response bits so the HIT matches the original: |
| 520 | 2001 | // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200 |
| 521 | 2002 | // status (a cached 404 must serve 404, not 200). No-op for |
| 522 | 2003 | // ordinary pages, which write no .meta. |
| 523 | - $meta = self::read_meta( $key ); | |
| 524 | 2004 | if ( ! headers_sent() ) { |
| 525 | 2005 | if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) { |
| 526 | 2006 | http_response_code( (int) $meta['status'] ); |
| 527 | 2007 | } |
| @@ -584,8 +2064,9 @@ | ||
| 584 | 2064 | // explicit shutdown close so the buffer lifecycle is visible to |
| 585 | 2065 | // reviewers and Plugin Check, instead of relying on PHP's implicit |
| 586 | 2066 | // request-end flush. We record our nesting level so close_buffer() |
| 587 | 2067 | // flushes ONLY the buffer we opened. |
| 2068 | + self::$asset_stamp_at_open = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_stamp() : null; | |
| 588 | 2069 | ob_start( array( __CLASS__, 'finalize_buffer' ) ); |
| 589 | 2070 | self::$buffer_level = ob_get_level(); |
| 590 | 2071 | |
| 591 | 2072 | add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 ); |
| @@ -688,8 +2169,11 @@ | ||
| 688 | 2169 | |
| 689 | 2170 | $completed = self::$render_completed; |
| 690 | 2171 | self::$render_completed = false; |
| 691 | 2172 | |
| 2173 | + $asset_stamp = self::$deferred_asset_stamp; | |
| 2174 | + self::$deferred_asset_stamp = null; | |
| 2175 | + | |
| 692 | 2176 | if ( null === $key ) { |
| 693 | 2177 | return; |
| 694 | 2178 | } |
| 695 | 2179 | |
| @@ -740,8 +2224,14 @@ | ||
| 740 | 2224 | if ( self::query_string_blocks_write() ) { |
| 741 | 2225 | return; |
| 742 | 2226 | } |
| 743 | 2227 | |
| 2228 | + // Same guard as finalize_buffer(): the translation plugin's buffer | |
| 2229 | + // adds time between render and store, not less. | |
| 2230 | + if ( self::assets_purged_since( $asset_stamp ) ) { | |
| 2231 | + return; | |
| 2232 | + } | |
| 2233 | + | |
| 744 | 2234 | $file = self::cache_file_for( $key ); |
| 745 | 2235 | // 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. |
| 746 | 2236 | file_put_contents( $file, $full, LOCK_EX ); |
| 747 | 2237 | |
| @@ -752,19 +2242,46 @@ | ||
| 752 | 2242 | |
| 753 | 2243 | // Static tree too, under the same gates finalize_buffer() applies — |
| 754 | 2244 | // otherwise deferring the write would silently cost translated pages |
| 755 | 2245 | // the web-server fast path and leave them on the slower drop-in. |
| 756 | - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) { | |
| 2246 | + // The static tree cannot replay a sidecar. A file served straight by | |
| 2247 | + // the web server carries the headers baked into the rule that serves | |
| 2248 | + // the whole site — the very answer this entry exists because it | |
| 2249 | + // disagreed with. Same reasoning as the status and content-type | |
| 2250 | + // cases: what the fast path cannot replay belongs on the drop-in path. | |
| 2251 | + if ( self::static_rewrite_allowed() | |
| 2252 | + && self::response_is_plain_html() | |
| 2253 | + && array() === self::per_entry_edge_headers() ) { | |
| 757 | 2254 | self::store_static( $full ); |
| 758 | 2255 | } |
| 759 | 2256 | } |
| 760 | 2257 | |
| 2258 | + /** | |
| 2259 | + * Did a purge delete minified or combined files after $stamp was taken? | |
| 2260 | + * | |
| 2261 | + * @param string|null $stamp Minifier::purge_stamp() when the render began, | |
| 2262 | + * or null when no snapshot was taken. | |
| 2263 | + */ | |
| 2264 | + private static function assets_purged_since( ?string $stamp ): bool { | |
| 2265 | + if ( null === $stamp || ! class_exists( '\\XSpeed\\Minifier' ) ) { | |
| 2266 | + return false; | |
| 2267 | + } | |
| 2268 | + return Minifier::purge_stamp() !== $stamp; | |
| 2269 | + } | |
| 2270 | + | |
| 761 | 2271 | public static function should_cache() { |
| 762 | 2272 | // Reset first: a single request only reaches this once (the sole |
| 763 | 2273 | // caller is maybe_start_cache()), but tests and any future caller |
| 764 | 2274 | // must never inherit the previous request's verdict. |
| 765 | - self::$status_header = ''; | |
| 766 | - self::$bypass_reason = ''; | |
| 2275 | + self::$status_header = ''; | |
| 2276 | + self::$bypass_reason = ''; | |
| 2277 | + self::$edge_headers = array(); | |
| 2278 | + self::$per_entry_edge = null; | |
| 2279 | + // Under PHP-FPM a process serves one request and this is moot. Under | |
| 2280 | + // a persistent worker runtime it is not: without it, an answer | |
| 2281 | + // resolved from one visitor's forgeable headers would be reused for | |
| 2282 | + // every later request the worker handles. | |
| 2283 | + Edge_Provider::forget(); | |
| 767 | 2284 | |
| 768 | 2285 | $opts = Settings::get(); |
| 769 | 2286 | if ( empty( $opts['cache_enabled'] ) ) { |
| 770 | 2287 | return self::bypass( 'cache-disabled' ); |
| @@ -815,8 +2332,20 @@ | ||
| 815 | 2332 | * @param bool $cache_feed Whether to cache this feed request. |
| 816 | 2333 | */ |
| 817 | 2334 | $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false ); |
| 818 | 2335 | |
| 2336 | + // WordPress's virtual robots.txt (and virtual favicon) are not HTML: | |
| 2337 | + // caching one runs it through the whole HTML pipeline, which stamped | |
| 2338 | + // the footer comment onto text/plain and let HTML minification | |
| 2339 | + // collapse robots.txt to a single line — a line-based format, so | |
| 2340 | + // every directive after the first was lost and crawlers read an | |
| 2341 | + // invalid file. No opt-in filter here: there is no correct way to | |
| 2342 | + // treat these as pages. (Reported live on a customer site.) | |
| 2343 | + if ( ( function_exists( 'is_robots' ) && is_robots() ) | |
| 2344 | + || ( function_exists( 'is_favicon' ) && is_favicon() ) ) { | |
| 2345 | + return self::bypass( 'non-html' ); | |
| 2346 | + } | |
| 2347 | + | |
| 819 | 2348 | // Query string handling: anything OUTSIDE the ignored-params |
| 820 | 2349 | // allow-list (utm_*, fbclid, gclid by default) means a unique |
| 821 | 2350 | // request that we don't want to share with the canonical cache |
| 822 | 2351 | // entry. Skip cache rather than poison the key. |
| @@ -840,9 +2369,9 @@ | ||
| 840 | 2369 | continue; |
| 841 | 2370 | } |
| 842 | 2371 | // Allow query-form feed params through when feed caching opted |
| 843 | 2372 | // this request in (?feed=rss2 / &withcomments=1 on feeds). |
| 844 | - if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) { | |
| 2373 | + if ( $cache_feed && in_array( $key, self::FEED_QUERY_PARAMS, true ) ) { | |
| 845 | 2374 | continue; |
| 846 | 2375 | } |
| 847 | 2376 | if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) { |
| 848 | 2377 | // Slug only — never the param name, which is attacker- |
| @@ -851,13 +2380,12 @@ | ||
| 851 | 2380 | } |
| 852 | 2381 | } |
| 853 | 2382 | } |
| 854 | 2383 | |
| 855 | - $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; | |
| 856 | - $path = (string) strtok( $request_uri, '?' ); | |
| 2384 | + $path = self::request_path(); | |
| 857 | 2385 | |
| 858 | 2386 | $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array(); |
| 859 | - if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) { | |
| 2387 | + if ( ! $cache_feed && self::path_matches_exclusions( $excluded_urls, $path ) ) { | |
| 860 | 2388 | return self::bypass( 'excluded-url' ); |
| 861 | 2389 | } |
| 862 | 2390 | |
| 863 | 2391 | // Cookie-based exclusion. We only check cookie NAMES (matching |
| @@ -1005,8 +2533,32 @@ | ||
| 1005 | 2533 | return '' !== trim( $query ); |
| 1006 | 2534 | } |
| 1007 | 2535 | |
| 1008 | 2536 | /** |
| 2537 | + * Whether this request's query string is empty or holds only params the | |
| 2538 | + * cache ignores (`ignored_query_params`, utm_* and the like), so the | |
| 2539 | + * page is the one the bare URL serves. | |
| 2540 | + * | |
| 2541 | + * The same test should_cache() applies, without its search and feed | |
| 2542 | + * exceptions: those are keyed apart from the bare URL. | |
| 2543 | + */ | |
| 2544 | + public static function query_has_only_ignored_params(): bool { | |
| 2545 | + $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- as in should_cache(): only keys are read, via preg_match. | |
| 2546 | + if ( '' === trim( (string) $query_raw ) ) { | |
| 2547 | + return true; | |
| 2548 | + } | |
| 2549 | + $cache_opts = Settings_Manager::get( 'cache' ); | |
| 2550 | + $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array(); | |
| 2551 | + parse_str( (string) $query_raw, $params ); | |
| 2552 | + foreach ( $params as $key => $_ ) { | |
| 2553 | + if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) { | |
| 2554 | + return false; | |
| 2555 | + } | |
| 2556 | + } | |
| 2557 | + return true; | |
| 2558 | + } | |
| 2559 | + | |
| 2560 | + /** | |
| 1009 | 2561 | * Would authoring a cache entry from THIS request file a query-string |
| 1010 | 2562 | * render under the bare URL? |
| 1011 | 2563 | * |
| 1012 | 2564 | * The one predicate both write sites ask, so they cannot drift. |
| @@ -1095,11 +2647,227 @@ | ||
| 1095 | 2647 | * `~utm_…` default vs `my_utm_source`. A param name that is genuinely |
| 1096 | 2648 | * unknown now bypasses the cache, which is the safe direction. |
| 1097 | 2649 | */ |
| 1098 | 2650 | private static function query_key_is_ignored( string $key, array $ignored ): bool { |
| 2651 | + if ( in_array( $key, self::NEVER_IGNORED_QUERY_PARAMS, true ) ) { | |
| 2652 | + return false; | |
| 2653 | + } | |
| 1099 | 2654 | return Glob_Matcher::any_match_name( $ignored, $key ); |
| 1100 | 2655 | } |
| 1101 | 2656 | |
| 2657 | + /** | |
| 2658 | + * Query params no ignored-params entry can match, glob or regex. | |
| 2659 | + * | |
| 2660 | + * A measuring request asks for the page as it is before optimisation | |
| 2661 | + * (`xspeed_css=off`), with a one-time value (`xspeed_nc`) so no cache | |
| 2662 | + * has a copy of it. The answer must be rendered for that request. A | |
| 2663 | + * list entry such as `xspeed_*` or `*` would make both params | |
| 2664 | + * decoration: the drop-in would serve the canonical, already-optimised | |
| 2665 | + * entry before any plugin loads, and the measurement would describe | |
| 2666 | + * the optimised page instead of the source. | |
| 2667 | + */ | |
| 2668 | + public const NEVER_IGNORED_QUERY_PARAMS = array( 'xspeed_css', 'xspeed_nc' ); | |
| 2669 | + | |
| 2670 | + /** | |
| 2671 | + * The params a query-form feed may carry (`/?feed=rss2`) once feed | |
| 2672 | + * caching has opted the request in. should_cache() lets them through, | |
| 2673 | + * and serves_query_variant() holds a HIT that carries one. | |
| 2674 | + */ | |
| 2675 | + private const FEED_QUERY_PARAMS = array( 'feed', 'withcomments', 'withoutcomments' ); | |
| 2676 | + | |
| 2677 | + /** | |
| 2678 | + * The one spelling of a URL path that every cache key is built from. | |
| 2679 | + * | |
| 2680 | + * WordPress stores a non-ASCII slug percent-encoded, so a page called | |
| 2681 | + * `關於我們` is requested as `/%e9%97%9c%e6%96%bc%e6%88%91%e5%80%91/`. The | |
| 2682 | + * same page also arrives as `/%E9%97%9C…/` (what a browser sends for an | |
| 2683 | + * address typed or pasted into it) and as raw UTF-8 bytes. WordPress | |
| 2684 | + * renders the same page for all three, so they get one key: escapes are | |
| 2685 | + * lowercased (WordPress's own spelling, so a permalink comes back | |
| 2686 | + * unchanged) and any byte outside printable ASCII is escaped the same way. | |
| 2687 | + * Null bytes are dropped, as the drop-in does. | |
| 2688 | + * | |
| 2689 | + * Nothing is decoded. `/ab%6Fut/` stays a separate entry from `/about/`. | |
| 2690 | + * Only spellings of the same bytes are merged, because merging spellings | |
| 2691 | + * WordPress may route differently would let one page be served for | |
| 2692 | + * another. A printable-ASCII path with no escapes comes back unchanged, | |
| 2693 | + * so ordinary pages keep the keys they had. | |
| 2694 | + * | |
| 2695 | + * The path used to go through sanitize_text_field(), which deletes every | |
| 2696 | + * `%XX` octet: `/第九屆-當代藝術-tagboat-award-特展/` was keyed as | |
| 2697 | + * `/--tagboat-award-/`, every page whose slug differed only in non-ASCII | |
| 2698 | + * characters shared one entry, and an all-non-ASCII slug became `//`, | |
| 2699 | + * which store_static() wrote over the home page's static file. | |
| 2700 | + * | |
| 2701 | + * The drop-in carries a copy of this transform, because it runs before | |
| 2702 | + * WordPress and this class load. Change both together. | |
| 2703 | + * | |
| 2704 | + * @param string $path A URL path, without the query string. | |
| 2705 | + */ | |
| 2706 | + public static function normalize_path( string $path ): string { | |
| 2707 | + return self::path_with_escape_case( $path, false ); | |
| 2708 | + } | |
| 2709 | + | |
| 2710 | + /** | |
| 2711 | + * The spellings of one path that a cache in front of the site may hold. | |
| 2712 | + * | |
| 2713 | + * xSpeed keys every escape spelling of a path as one entry | |
| 2714 | + * (normalize_path()). A CDN does not: Cloudflare keeps `/%E9%97%9C…/`, | |
| 2715 | + * the spelling a browser sends, apart from `/%e9%97%9c…/`, the spelling | |
| 2716 | + * of WordPress's own links. A purge that named one left the other | |
| 2717 | + * serving the old page until its edge lifetime ran out. | |
| 2718 | + * | |
| 2719 | + * Returns the path as given, then normalize_path()'s spelling (lower-case | |
| 2720 | + * escapes), then the same with upper-case escapes. Both are built from | |
| 2721 | + * the path the local sweep cleared, so a cache in front is never asked | |
| 2722 | + * about a page xSpeed did not purge. Duplicates drop out, so a path with | |
| 2723 | + * no escapes has one spelling. Trailing-slash forms are the caller's job. | |
| 2724 | + * | |
| 2725 | + * @param string $path A URL path, without the query string. | |
| 2726 | + * @return string[] | |
| 2727 | + */ | |
| 2728 | + public static function escape_spellings( string $path ): array { | |
| 2729 | + return array_values( | |
| 2730 | + array_unique( | |
| 2731 | + array( $path, self::path_with_escape_case( $path, false ), self::path_with_escape_case( $path, true ) ) | |
| 2732 | + ) | |
| 2733 | + ); | |
| 2734 | + } | |
| 2735 | + | |
| 2736 | + /** | |
| 2737 | + * normalize_path() with the escapes in the case asked for. | |
| 2738 | + * | |
| 2739 | + * @param string $path A URL path, without the query string. | |
| 2740 | + * @param bool $upper Upper-case escapes, or lower (the key's spelling). | |
| 2741 | + */ | |
| 2742 | + private static function path_with_escape_case( string $path, bool $upper ): string { | |
| 2743 | + return (string) preg_replace_callback( | |
| 2744 | + '/%[0-9a-fA-F]{2}|[^\x21-\x7E]/', | |
| 2745 | + static function ( array $m ) use ( $upper ): string { | |
| 2746 | + $escape = '%' === $m[0][0] ? $m[0] : sprintf( '%%%02x', ord( $m[0] ) ); | |
| 2747 | + return $upper ? strtoupper( $escape ) : strtolower( $escape ); | |
| 2748 | + }, | |
| 2749 | + str_replace( "\0", '', $path ) | |
| 2750 | + ); | |
| 2751 | + } | |
| 2752 | + | |
| 2753 | + /** | |
| 2754 | + * Percent-encode every byte 0x80 and up, lowercase hex. | |
| 2755 | + * | |
| 2756 | + * @param string $value A path or a whole URL. | |
| 2757 | + */ | |
| 2758 | + private static function encode_non_ascii( string $value ): string { | |
| 2759 | + return (string) preg_replace_callback( | |
| 2760 | + '/[\x80-\xff]/', | |
| 2761 | + static function ( array $m ): string { | |
| 2762 | + return '%' . bin2hex( $m[0] ); | |
| 2763 | + }, | |
| 2764 | + $value | |
| 2765 | + ); | |
| 2766 | + } | |
| 2767 | + | |
| 2768 | + /** | |
| 2769 | + * Where a URL path lives in the static tree, relative to the host | |
| 2770 | + * directory, with a leading slash and no trailing one ('' for the home | |
| 2771 | + * page). Null when the path must not be written there. | |
| 2772 | + * | |
| 2773 | + * Decoded, because that is what the web server looks up: nginx builds the | |
| 2774 | + * file name from `$uri` and Apache from `%{REQUEST_URI}`, and both hold | |
| 2775 | + * the decoded path. A file stored under the encoded name is never found. | |
| 2776 | + * | |
| 2777 | + * Decoding is only safe for the escapes WordPress itself writes, which | |
| 2778 | + * encode the bytes of non-ASCII letters (0x80 and up). An escaped ASCII | |
| 2779 | + * byte is refused, because the server decodes it before the lookup: | |
| 2780 | + * `%2F` would become a directory, `%2E%2E` a parent, and `%6F` would | |
| 2781 | + * make `/ab%6Fut/` and `/about/` share one file although they are | |
| 2782 | + * separate cache entries. The decoded path must also be valid UTF-8, with | |
| 2783 | + * no control byte, backslash or `%`, no `..` anywhere and no `.` | |
| 2784 | + * segment. A refused page is still cached by the drop-in; it only loses | |
| 2785 | + * the no-PHP path. | |
| 2786 | + * | |
| 2787 | + * @param string $path A URL path, without the query string. | |
| 2788 | + */ | |
| 2789 | + public static function static_path( string $path ): ?string { | |
| 2790 | + if ( '' === $path || false !== strpos( $path, "\0" ) ) { | |
| 2791 | + return null; | |
| 2792 | + } | |
| 2793 | + if ( preg_match_all( '/%([0-9a-fA-F]{2})/', $path, $escapes ) ) { | |
| 2794 | + foreach ( $escapes[1] as $hex ) { | |
| 2795 | + if ( hexdec( $hex ) < 0x80 ) { | |
| 2796 | + return null; | |
| 2797 | + } | |
| 2798 | + } | |
| 2799 | + } | |
| 2800 | + $decoded = rawurldecode( $path ); | |
| 2801 | + if ( 1 !== preg_match( '//u', $decoded ) || preg_match( '/[\x00-\x1f\x7f\\\\%]/', $decoded ) ) { | |
| 2802 | + return null; | |
| 2803 | + } | |
| 2804 | + $decoded = (string) preg_replace( '#/+#', '/', $decoded ); | |
| 2805 | + if ( false !== strpos( $decoded, '..' ) || preg_match( '#(^|/)\.(/|$)#', $decoded ) ) { | |
| 2806 | + return null; | |
| 2807 | + } | |
| 2808 | + return rtrim( $decoded, '/' ); | |
| 2809 | + } | |
| 2810 | + | |
| 2811 | + /** | |
| 2812 | + * The current request's path, normalized, without the query string. | |
| 2813 | + * | |
| 2814 | + * @param string $fallback Returned when the server sent no REQUEST_URI. | |
| 2815 | + */ | |
| 2816 | + private static function request_path( string $fallback = '' ): string { | |
| 2817 | + if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { | |
| 2818 | + return $fallback; | |
| 2819 | + } | |
| 2820 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalize_path() is the sanitizer. sanitize_text_field() deletes percent-encoded octets and broke every non-ASCII slug (see normalize_path()). The value is hashed, matched, or checked by static_path() before it touches the disk. | |
| 2821 | + $uri = (string) wp_unslash( $_SERVER['REQUEST_URI'] ); | |
| 2822 | + return self::normalize_path( (string) strtok( $uri, '?' ) ); | |
| 2823 | + } | |
| 2824 | + | |
| 2825 | + /** | |
| 2826 | + * An Excluded URLs entry with its escapes in lower case. | |
| 2827 | + * | |
| 2828 | + * The request path is matched in normalize_path()'s spelling, so an entry | |
| 2829 | + * copied from Chrome's address bar (`/%E8%81%AF…/`) never matched it. | |
| 2830 | + * Only `%XX` sequences change; the rest of the entry, including a `~` | |
| 2831 | + * regex, is left as typed. | |
| 2832 | + * | |
| 2833 | + * @param string $pattern One Excluded URLs entry. | |
| 2834 | + */ | |
| 2835 | + public static function normalize_exclusion( string $pattern ): string { | |
| 2836 | + return (string) preg_replace_callback( | |
| 2837 | + '/%[0-9a-fA-F]{2}/', | |
| 2838 | + static function ( array $m ): string { | |
| 2839 | + return strtolower( $m[0] ); | |
| 2840 | + }, | |
| 2841 | + $pattern | |
| 2842 | + ); | |
| 2843 | + } | |
| 2844 | + | |
| 2845 | + /** | |
| 2846 | + * Does an exclusion list match this path, in either spelling? | |
| 2847 | + * | |
| 2848 | + * An admin may paste an exclusion as `/購物車`, as WordPress spells it, or | |
| 2849 | + * as Chrome shows it in upper case. The entry's escapes are lowercased to | |
| 2850 | + * match the path, and the decoded path is tried too, which is also what | |
| 2851 | + * the nginx rule matches (`$uri` is decoded). | |
| 2852 | + * | |
| 2853 | + * @param string[] $patterns Exclusion entries. | |
| 2854 | + * @param string $path Output of normalize_path(). | |
| 2855 | + */ | |
| 2856 | + private static function path_matches_exclusions( array $patterns, string $path ): bool { | |
| 2857 | + $patterns = array_map( | |
| 2858 | + static function ( $pattern ): string { | |
| 2859 | + return self::normalize_exclusion( (string) $pattern ); | |
| 2860 | + }, | |
| 2861 | + $patterns | |
| 2862 | + ); | |
| 2863 | + if ( Glob_Matcher::any_match( $patterns, $path ) ) { | |
| 2864 | + return true; | |
| 2865 | + } | |
| 2866 | + $decoded = rawurldecode( $path ); | |
| 2867 | + return $decoded !== $path && Glob_Matcher::any_match( $patterns, $decoded ); | |
| 2868 | + } | |
| 2869 | + | |
| 1102 | 2870 | public static function cache_key() { |
| 1103 | 2871 | $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default'; |
| 1104 | 2872 | |
| 1105 | 2873 | // Cacheable 404s share ONE generic per-host entry — keying them by |
| @@ -1109,14 +2877,13 @@ | ||
| 1109 | 2877 | if ( self::should_cache_404() ) { |
| 1110 | 2878 | return md5( $host . '|404' ); |
| 1111 | 2879 | } |
| 1112 | 2880 | |
| 1113 | - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; | |
| 1114 | 2881 | // Strip the query string from the key so /post and /post?utm_*=… |
| 1115 | 2882 | // share the same cache entry. should_cache() above already |
| 1116 | 2883 | // rejected requests with non-ignored params, so by the time we |
| 1117 | 2884 | // build the key the only params left are safe to drop. |
| 1118 | - $uri = (string) strtok( $uri, '?' ); | |
| 2885 | + $uri = self::request_path( '/' ); | |
| 1119 | 2886 | |
| 1120 | 2887 | // Optional device bucket: when mobile_separate is on, mobile and |
| 1121 | 2888 | // desktop responses live in different cache files so themes that |
| 1122 | 2889 | // serve different HTML by device (AMP, WPtouch, Jetpack mobile) |
| @@ -1381,9 +3148,10 @@ | ||
| 1381 | 3148 | * exactly this blog's pages. |
| 1382 | 3149 | */ |
| 1383 | 3150 | public static function current_static_scope(): string { |
| 1384 | 3151 | // Same switch_to_blog() caveat as current_host_dir() — see current_host(). |
| 1385 | - $dir = self::host_dir( self::current_host() ); | |
| 3152 | + // Keep the port folded into the segment exactly as store_static() does. | |
| 3153 | + $dir = self::static_host_dir( self::current_host() ); | |
| 1386 | 3154 | if ( '' === $dir ) { |
| 1387 | 3155 | $dir = 'default'; |
| 1388 | 3156 | } |
| 1389 | 3157 | $path = self::site_path_raw(); |
| @@ -1396,10 +3164,9 @@ | ||
| 1396 | 3164 | * delete indiscriminately). |
| 1397 | 3165 | */ |
| 1398 | 3166 | public static function current_host_dir(): string { |
| 1399 | 3167 | $host = self::current_host(); |
| 1400 | - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; | |
| 1401 | - return self::site_bucket( $host, $uri ); | |
| 3168 | + return self::site_bucket( $host, self::request_path( '/' ) ); | |
| 1402 | 3169 | } |
| 1403 | 3170 | |
| 1404 | 3171 | /** |
| 1405 | 3172 | * The host the CURRENT blog is served from. |
| @@ -1755,8 +3522,19 @@ | ||
| 1755 | 3522 | * and returns true (caller should exit without a body). Returns false to |
| 1756 | 3523 | * proceed with a normal 200 body. Lets aggregators/browsers skip |
| 1757 | 3524 | * re-downloading an unchanged cached response. (FBS-82407 #5) |
| 1758 | 3525 | * |
| 3526 | + * Also stamps `X-XSpeed-Built` from the same mtime. This is the PHP serve | |
| 3527 | + * path's build time, and the only place on it that holds the file: mark() | |
| 3528 | + * has already run by the time we get here and resolved the edge-header | |
| 3529 | + * filter without one. Both are emitted, and a purge verifier prefers the | |
| 3530 | + * stamp: `Last-Modified` carries the same integer, but it is a generic | |
| 3531 | + * header that the origin-side cache layer such a verifier exists to | |
| 3532 | + * detect — or any proxy in between — may rewrite to its own store time, | |
| 3533 | + * which would turn a stale origin into a pass. Nothing but xSpeed's own | |
| 3534 | + * serve code writes `X-XSpeed-Built`, so a value older than the purge is | |
| 3535 | + * proof the origin answered with old HTML. | |
| 3536 | + * | |
| 1759 | 3537 | * @param string $file Absolute path to the cache .html file. |
| 1760 | 3538 | * @return bool True when a 304 was sent. |
| 1761 | 3539 | */ |
| 1762 | 3540 | public static function serve_not_modified( string $file ): bool { |
| @@ -1767,8 +3545,12 @@ | ||
| 1767 | 3545 | $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT'; |
| 1768 | 3546 | $etag = '"' . md5( $file . '|' . $mtime ) . '"'; |
| 1769 | 3547 | header( 'Last-Modified: ' . $last_modified ); |
| 1770 | 3548 | header( 'ETag: ' . $etag ); |
| 3549 | + // Before the 304 branch below, so a conditional request that gets a | |
| 3550 | + // bodyless 304 still carries the stamp. A verifier's fetch may well | |
| 3551 | + // be conditional, and a 304 with no build time reads as unverifiable. | |
| 3552 | + header( self::BUILT_HEADER . ': ' . $mtime ); | |
| 1771 | 3553 | |
| 1772 | 3554 | $ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : ''; |
| 1773 | 3555 | $inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : ''; |
| 1774 | 3556 | |
| @@ -1873,8 +3655,13 @@ | ||
| 1873 | 3655 | |
| 1874 | 3656 | $full = self::$accumulated; |
| 1875 | 3657 | self::$accumulated = ''; |
| 1876 | 3658 | |
| 3659 | + // Consumed here, on every final path, so a later call can never | |
| 3660 | + // compare against this request's snapshot. | |
| 3661 | + $asset_stamp = self::$asset_stamp_at_open; | |
| 3662 | + self::$asset_stamp_at_open = null; | |
| 3663 | + | |
| 1877 | 3664 | if ( strlen( $full ) < 255 ) { |
| 1878 | 3665 | return $buffer; |
| 1879 | 3666 | } |
| 1880 | 3667 | |
| @@ -1970,8 +3757,16 @@ | ||
| 1970 | 3757 | // deferred writer has always placed its own copy of the guard. |
| 1971 | 3758 | if ( self::query_string_blocks_write() ) { |
| 1972 | 3759 | return $buffer; |
| 1973 | 3760 | } |
| 3761 | + | |
| 3762 | + // A purge deleted minified or combined files while this page was | |
| 3763 | + // rendering, so it may link names that are gone. Serve it, but do | |
| 3764 | + // not store it; the next request renders against the files as they | |
| 3765 | + // are now. | |
| 3766 | + if ( self::assets_purged_since( $asset_stamp ) ) { | |
| 3767 | + return $buffer; | |
| 3768 | + } | |
| 1974 | 3769 | $file = self::cache_file_for( $key ); |
| 1975 | 3770 | |
| 1976 | 3771 | // A render-time translation plugin (TranslatePress) wraps our buffer, |
| 1977 | 3772 | // so the bytes we hold here are still UNTRANSLATED — its callback has |
| @@ -1979,9 +3774,10 @@ | ||
| 1979 | 3774 | // and bake in its internal #TRPLINKPROCESSED markers. Hand off to |
| 1980 | 3775 | // shutdown, where the outer buffer has already translated, and let |
| 1981 | 3776 | // the pass-through below deliver this request untouched. |
| 1982 | 3777 | if ( self::translation_plugin_active() ) { |
| 1983 | - self::$deferred_key = $key; | |
| 3778 | + self::$deferred_key = $key; | |
| 3779 | + self::$deferred_asset_stamp = $asset_stamp; | |
| 1984 | 3780 | // Reaching here means finalize_buffer() ran to completion: the |
| 1985 | 3781 | // status gate passed, should_cache() said yes, and PHP handed us |
| 1986 | 3782 | // the whole buffer. A wp_die() or exit() mid-render unwinds the |
| 1987 | 3783 | // buffer stack WITHOUT calling this callback, so the flag stays |
| @@ -1994,10 +3790,32 @@ | ||
| 1994 | 3790 | return $buffer; |
| 1995 | 3791 | } |
| 1996 | 3792 | |
| 1997 | 3793 | // 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. |
| 1998 | - file_put_contents( $file, $full, LOCK_EX ); | |
| 3794 | + $stored = file_put_contents( $file, $full, LOCK_EX ); | |
| 1999 | 3795 | |
| 3796 | + /* | |
| 3797 | + * No edge headers on a MISS. Not even a stored one. | |
| 3798 | + * | |
| 3799 | + * A MISS is the FIRST render, and it is the render most likely to be | |
| 3800 | + * replaced: critical CSS and unused CSS are generated after the fact | |
| 3801 | + * and applied to later requests, so the copy written here is the | |
| 3802 | + * pre-optimization one. Telling a CDN to hold it pins exactly the | |
| 3803 | + * version xSpeed is about to improve on — reported from a live site, | |
| 3804 | + * where Cloudflare had cached an unoptimized first render. | |
| 3805 | + * | |
| 3806 | + * `CDN-Cache-Control` is why it reaches the edge at all: Cloudflare | |
| 3807 | + * honours it as the CDN-targeted directive whatever its own cache | |
| 3808 | + * rules say, so an origin header is enough to pin HTML for the whole | |
| 3809 | + * TTL even where edge page caching is switched off. | |
| 3810 | + * | |
| 3811 | + * A HIT is the safe moment and the honest one: it means xSpeed is | |
| 3812 | + * serving its stored copy, that copy is what the edge would mirror, | |
| 3813 | + * and a later regeneration purges it — which reaches the edge through | |
| 3814 | + * the purge actions. So the edge caches from the second visitor on, | |
| 3815 | + * one render later than before and the right one. | |
| 3816 | + */ | |
| 3817 | + | |
| 2000 | 3818 | /** |
| 2001 | 3819 | * Fires after the flat hash cache file ({md5}.html) is written. |
| 2002 | 3820 | * |
| 2003 | 3821 | * Mirror of `xspeed_static_file_written` for the flat cache. The PHP |
| @@ -2033,9 +3851,16 @@ | ||
| 2033 | 3851 | // 200, FBS-82406) or a non-HTML content-type (a cached feed would go |
| 2034 | 3852 | // out as text/html, FBS-82407). The web server serves these .html files |
| 2035 | 3853 | // directly with no PHP, so there's no .meta replay — keep them on the |
| 2036 | 3854 | // drop-in / PHP path instead, which DOES replay status + content-type. |
| 2037 | - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) { | |
| 3855 | + // The static tree cannot replay a sidecar. A file served straight by | |
| 3856 | + // the web server carries the headers baked into the rule that serves | |
| 3857 | + // the whole site — the very answer this entry exists because it | |
| 3858 | + // disagreed with. Same reasoning as the status and content-type | |
| 3859 | + // cases: what the fast path cannot replay belongs on the drop-in path. | |
| 3860 | + if ( self::static_rewrite_allowed() | |
| 3861 | + && self::response_is_plain_html() | |
| 3862 | + && array() === self::per_entry_edge_headers() ) { | |
| 2038 | 3863 | self::store_static( $full ); |
| 2039 | 3864 | } |
| 2040 | 3865 | |
| 2041 | 3866 | return $buffer; |
| @@ -2047,12 +3872,13 @@ | ||
| 2047 | 3872 | * rewrite block points at this path so cache hits skip PHP |
| 2048 | 3873 | * entirely. Caller already minified/finalized $html. |
| 2049 | 3874 | * |
| 2050 | 3875 | * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist; |
| 2051 | - * $uri has its query string stripped, null bytes removed, '..' | |
| 2052 | - * sequences collapsed, and after concatenation we verify the | |
| 2053 | - * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before | |
| 2054 | - * any write. Anything off the happy path returns silently. | |
| 3876 | + * the path goes through static_path(), which decodes it the way the | |
| 3877 | + * web server will and refuses anything that could leave the host | |
| 3878 | + * directory or share a file with another page. After the directory is | |
| 3879 | + * created we verify its real path is inside XSPEED_CACHE_STATIC_DIR | |
| 3880 | + * before any write. Anything off the happy path returns silently. | |
| 2055 | 3881 | * |
| 2056 | 3882 | * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING |
| 2057 | 3883 | * else, and both generated rewrites refuse any request that carries a |
| 2058 | 3884 | * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache, |
| @@ -2115,15 +3941,13 @@ | ||
| 2115 | 3941 | } |
| 2116 | 3942 | $keys = array_slice( array_values( array_unique( $found ) ), 0, 10 ); |
| 2117 | 3943 | } |
| 2118 | 3944 | |
| 2119 | - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; | |
| 2120 | - | |
| 2121 | 3945 | set_transient( |
| 2122 | 3946 | self::STATIC_SKIP_TRANSIENT, |
| 2123 | 3947 | array( |
| 2124 | 3948 | 'reason' => $reason, |
| 2125 | - 'url' => (string) strtok( $uri, '?' ), | |
| 3949 | + 'url' => esc_url_raw( self::request_path() ), | |
| 2126 | 3950 | 'keys' => $keys, |
| 2127 | 3951 | 'at' => time(), |
| 2128 | 3952 | ), |
| 2129 | 3953 | HOUR_IN_SECONDS |
| @@ -2190,23 +4014,21 @@ | ||
| 2190 | 4014 | return; |
| 2191 | 4015 | } |
| 2192 | 4016 | |
| 2193 | 4017 | $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; |
| 2194 | - $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; | |
| 2195 | 4018 | $host = self::static_host_dir( $host ); |
| 2196 | - $uri = str_replace( "\0", '', $uri ); | |
| 2197 | - $uri = (string) strtok( $uri, '?' ); | |
| 4019 | + $uri = self::request_path(); | |
| 2198 | 4020 | if ( '' === $host || '' === $uri ) { |
| 2199 | 4021 | return; |
| 2200 | 4022 | } |
| 2201 | - // Collapse any traversal sequences before path resolution. | |
| 2202 | - $uri = preg_replace( '#/+#', '/', $uri ); | |
| 2203 | - if ( false !== strpos( $uri, '..' ) ) { | |
| 4023 | + // Decoded the way the web server will decode it, or refused. | |
| 4024 | + $rel = self::static_path( $uri ); | |
| 4025 | + if ( null === $rel ) { | |
| 2204 | 4026 | return; |
| 2205 | 4027 | } |
| 2206 | 4028 | |
| 2207 | 4029 | $base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ); |
| 2208 | - $dir = $base . '/' . $host . rtrim( $uri, '/' ); | |
| 4030 | + $dir = $base . '/' . $host . $rel; | |
| 2209 | 4031 | $file = $dir . '/index.html'; |
| 2210 | 4032 | |
| 2211 | 4033 | // Resolve the parent against the cache root to be sure the |
| 2212 | 4034 | // final path is inside our tree even if the OS does anything |
| @@ -2221,8 +4043,15 @@ | ||
| 2221 | 4043 | } |
| 2222 | 4044 | if ( ! is_dir( $dir ) ) { |
| 2223 | 4045 | return; |
| 2224 | 4046 | } |
| 4047 | + // static_path() already refuses every traversal spelling. This | |
| 4048 | + // catches a directory in the tree that resolves somewhere else. | |
| 4049 | + $dir_real = realpath( $dir ); | |
| 4050 | + $static_real = realpath( $base ); | |
| 4051 | + if ( false === $dir_real || false === $static_real || 0 !== strpos( $dir_real . '/', $static_real . '/' ) ) { | |
| 4052 | + return; | |
| 4053 | + } | |
| 2225 | 4054 | // 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. |
| 2226 | 4055 | $written = file_put_contents( $file, $html, LOCK_EX ); |
| 2227 | 4056 | |
| 2228 | 4057 | // A nonce-bearing page expires on the nonce's schedule, not the site's. |
| @@ -2566,8 +4395,23 @@ | ||
| 2566 | 4395 | if ( $ttl > 0 && $ttl !== $default_ttl ) { |
| 2567 | 4396 | $meta['ttl'] = $ttl; |
| 2568 | 4397 | } |
| 2569 | 4398 | |
| 4399 | + // This entry's edge headers, when they differ from the site-wide set | |
| 4400 | + // baked into the drop-in. The sidecar is the only channel that can | |
| 4401 | + // carry a per-page answer into the pre-boot fast path, and the drop-in | |
| 4402 | + // REPLACES the baked set with it rather than merging: the two describe | |
| 4403 | + // the same response, so merging would leave the baked lifetime in | |
| 4404 | + // place beside the hold meant to overrule it. | |
| 4405 | + // | |
| 4406 | + // The lifetime resolved above goes with it, under the same condition | |
| 4407 | + // as the `ttl` key: a page that does not follow the site's lifetime | |
| 4408 | + // may not be kept at the edge past its own either. | |
| 4409 | + $edge = self::per_entry_edge_headers( isset( $meta['ttl'] ) ? $ttl : null ); | |
| 4410 | + if ( array() !== $edge ) { | |
| 4411 | + $meta['edge_headers'] = $edge; | |
| 4412 | + } | |
| 4413 | + | |
| 2570 | 4414 | // Nothing to replay → no sidecar. |
| 2571 | 4415 | if ( empty( $meta ) ) { |
| 2572 | 4416 | return; |
| 2573 | 4417 | } |
| @@ -2611,8 +4455,101 @@ | ||
| 2611 | 4455 | * TTL — up to 30 days at the maximum lifetime. (#270 regression) |
| 2612 | 4456 | * |
| 2613 | 4457 | * @return string[] |
| 2614 | 4458 | */ |
| 4459 | + /** | |
| 4460 | + * Could this post change alter anything an anonymous visitor had cached? | |
| 4461 | + * | |
| 4462 | + * Deleting one post fired a full purge for the post AND for every stored | |
| 4463 | + * revision, because wp_delete_post() removes each revision through | |
| 4464 | + * wp_delete_post() again and every one of those fires before_delete_post | |
| 4465 | + * with post_type 'revision'. A post with six revisions cost seven whole- | |
| 4466 | + * site sweeps, each one also announcing to LiteSpeed, purging the object | |
| 4467 | + * cache network-wide on Redis, rewriting the stats option and running | |
| 4468 | + * every xspeed_after_purge_all listener -- including Pro's Cloudflare | |
| 4469 | + * purge, so seven API calls. Trashing cost two, via save_post and then | |
| 4470 | + * trashed_post. (QA #348) | |
| 4471 | + * | |
| 4472 | + * The check lives here, ahead of purge_all(), so one early return covers | |
| 4473 | + * the local sweep, the server-cache announcement and both action hooks. | |
| 4474 | + * It deliberately does NOT live inside purge_all(): a manual, CLI or | |
| 4475 | + * explicit caller asked for a purge and must get one. | |
| 4476 | + * | |
| 4477 | + * @param int $post_id Post being saved or removed. | |
| 4478 | + * @param mixed $post Post object when the hook passed one. | |
| 4479 | + * @param string $event 'save' or 'remove'. | |
| 4480 | + */ | |
| 4481 | + private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool { | |
| 4482 | + $post_id = (int) $post_id; | |
| 4483 | + | |
| 4484 | + // Only `save_post` and `before_delete_post` hand over a post object. | |
| 4485 | + // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING -- | |
| 4486 | + // so reaching for ->post_status on the second argument finds nothing | |
| 4487 | + // and the status rule below would never fire. Read the row instead. | |
| 4488 | + if ( ! is_object( $post ) && function_exists( 'get_post' ) ) { | |
| 4489 | + $post = get_post( $post_id ); | |
| 4490 | + } | |
| 4491 | + | |
| 4492 | + $type = is_object( $post ) && isset( $post->post_type ) | |
| 4493 | + ? (string) $post->post_type | |
| 4494 | + : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' ); | |
| 4495 | + if ( '' === $type ) { | |
| 4496 | + return false; | |
| 4497 | + } | |
| 4498 | + | |
| 4499 | + // A revision is a copy of content nobody can browse to. | |
| 4500 | + if ( 'revision' === $type ) { | |
| 4501 | + return false; | |
| 4502 | + } | |
| 4503 | + if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) { | |
| 4504 | + return false; | |
| 4505 | + } | |
| 4506 | + if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) { | |
| 4507 | + return false; | |
| 4508 | + } | |
| 4509 | + | |
| 4510 | + $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : ''; | |
| 4511 | + | |
| 4512 | + // Clicking "Add New" inserts an auto-draft and fires save_post. There | |
| 4513 | + // is nothing cached of a post that has never existed publicly. | |
| 4514 | + if ( 'auto-draft' === $status ) { | |
| 4515 | + return false; | |
| 4516 | + } | |
| 4517 | + | |
| 4518 | + // Unknown/!viewable → nothing anonymous can see changed, UNLESS the | |
| 4519 | + // type is itself part of how pages render (#270 regression). | |
| 4520 | + if ( function_exists( 'is_post_type_viewable' ) | |
| 4521 | + && ! is_post_type_viewable( $type ) | |
| 4522 | + && ! in_array( $type, self::presentation_post_types(), true ) | |
| 4523 | + ) { | |
| 4524 | + return false; | |
| 4525 | + } | |
| 4526 | + | |
| 4527 | + // Deleting something that was already invisible changes no cached | |
| 4528 | + // page: the transition that hid it purged at the time. This is what | |
| 4529 | + // makes emptying a trash of a hundred posts cost nothing rather than | |
| 4530 | + // a hundred full sweeps. | |
| 4531 | + // | |
| 4532 | + // It also collapses trashing to a single purge: wp_trash_post() fires | |
| 4533 | + // save_post first, where the post is genuinely disappearing from | |
| 4534 | + // listings and SHOULD purge, then trashed_post, by which point the | |
| 4535 | + // row reads 'trash' and is skipped. A status we cannot read, on a row | |
| 4536 | + // that still reports a type, means assume viewable -- erring toward | |
| 4537 | + // an extra purge, never toward serving a stale page. A row that is | |
| 4538 | + // gone entirely reports no type either and was refused above. | |
| 4539 | + // 'inherit' is an INTERNAL status in core, so is_post_status_viewable() | |
| 4540 | + // says no -- but an attachment carrying it is genuinely public. Judge | |
| 4541 | + // those on the post type alone, which is already checked above. | |
| 4542 | + if ( 'remove' === $event && '' !== $status && 'inherit' !== $status | |
| 4543 | + && function_exists( 'is_post_status_viewable' ) | |
| 4544 | + && ! is_post_status_viewable( $status ) | |
| 4545 | + ) { | |
| 4546 | + return false; | |
| 4547 | + } | |
| 4548 | + | |
| 4549 | + return true; | |
| 4550 | + } | |
| 4551 | + | |
| 2615 | 4552 | public static function presentation_post_types(): array { |
| 2616 | 4553 | $types = array( |
| 2617 | 4554 | 'wp_template', // Site Editor templates. |
| 2618 | 4555 | 'wp_template_part', // Header / footer / reusable parts. |
| @@ -2633,8 +4570,44 @@ | ||
| 2633 | 4570 | return (array) apply_filters( 'xspeed_presentation_post_types', $types ); |
| 2634 | 4571 | } |
| 2635 | 4572 | |
| 2636 | 4573 | /** |
| 4574 | + * Describe a broad hook invalidation for response-cache adapters. | |
| 4575 | + * | |
| 4576 | + * Term, menu, theme and plugin changes can alter navigation, archives or | |
| 4577 | + * markup across the site, so they require a site response-cache purge. | |
| 4578 | + * Content saves also require this scope while their local operation is a | |
| 4579 | + * complete bucket sweep. | |
| 4580 | + * | |
| 4581 | + * A new term is `content`, not `presentation`. It has no posts yet, so no | |
| 4582 | + * page renders it until a post is saved with it, and that save is its own | |
| 4583 | + * content purge. Classed as presentation, it cleared the host's whole | |
| 4584 | + * nginx cache every time a post was published with a tag that did not | |
| 4585 | + * exist yet, which is most publishing. Renaming or deleting a term stays | |
| 4586 | + * presentation: the new name shows on every post in the term, and Nginx | |
| 4587 | + * Helper purges only the homepage for either. (QA #448) | |
| 4588 | + * | |
| 4589 | + * @return array{scope:string,intent:string,urls:array<int,string>} | |
| 4590 | + */ | |
| 4591 | + private static function invalidation_for_hook( string $hook ): array { | |
| 4592 | + $presentation = array( | |
| 4593 | + 'switch_theme', | |
| 4594 | + 'activated_plugin', | |
| 4595 | + 'deactivated_plugin', | |
| 4596 | + 'edited_term', | |
| 4597 | + 'delete_term', | |
| 4598 | + 'wp_update_nav_menu', | |
| 4599 | + ); | |
| 4600 | + | |
| 4601 | + return array( | |
| 4602 | + 'scope' => 'site', | |
| 4603 | + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content', | |
| 4604 | + 'urls' => array(), | |
| 4605 | + ); | |
| 4606 | + } | |
| 4607 | + | |
| 4608 | + | |
| 4609 | + /** | |
| 2637 | 4610 | * save_post → purge only when the saved thing can appear on a cached page. |
| 2638 | 4611 | * |
| 2639 | 4612 | * Revisions and autosaves are never rendered. Non-viewable post types — |
| 2640 | 4613 | * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund` |
| @@ -2649,38 +4622,500 @@ | ||
| 2649 | 4622 | * @param int $post_id Saved post ID. |
| 2650 | 4623 | * @param \WP_Post $post Saved post object. |
| 2651 | 4624 | */ |
| 2652 | 4625 | public static function on_save_post( $post_id, $post = null ): void { |
| 2653 | - if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) { | |
| 4626 | + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) { | |
| 2654 | 4627 | return; |
| 2655 | 4628 | } |
| 2656 | - if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) { | |
| 4629 | + | |
| 4630 | + $post_type = is_object( $post ) && isset( $post->post_type ) | |
| 4631 | + ? (string) $post->post_type | |
| 4632 | + : (string) get_post_type( $post_id ); | |
| 4633 | + | |
| 4634 | + // Name the trigger rather than logging a bare numeric id — the old | |
| 4635 | + // wiring passed the post ID into $cause, so the log read | |
| 4636 | + // "Cache purged (46)" with no indication of what caused it. (#243) | |
| 4637 | + $presentation = in_array( $post_type, self::presentation_post_types(), true ); | |
| 4638 | + | |
| 4639 | + // Clearing only the affected pages needs the post's terms, and the | |
| 4640 | + // REST API sets those after `save_post`. Note the post and purge on | |
| 4641 | + // `wp_after_insert_post`; flush_pending_saves() covers a save that | |
| 4642 | + // never gets there. | |
| 4643 | + if ( ! $presentation && self::narrow_purge_applies( $post_type ) ) { | |
| 4644 | + self::$pending_saves[ (int) $post_id ] = true; | |
| 2657 | 4645 | return; |
| 2658 | 4646 | } |
| 2659 | 4647 | |
| 4648 | + self::purge_all( | |
| 4649 | + 'post:' . $post_type, | |
| 4650 | + null, | |
| 4651 | + array( | |
| 4652 | + // purge_all() sweeps every local response in this site's bucket. | |
| 4653 | + // Without dependency tracking, the server cache must match that | |
| 4654 | + // same boundary or unrelated pages can remain stale there. | |
| 4655 | + 'scope' => 'site', | |
| 4656 | + 'intent' => $presentation ? 'presentation' : 'content', | |
| 4657 | + 'urls' => array(), | |
| 4658 | + ) | |
| 4659 | + ); | |
| 4660 | + } | |
| 4661 | + | |
| 4662 | + /** | |
| 4663 | + * Reasons a change that would have cleared only its own pages cleared | |
| 4664 | + * the whole site, published as `fallback` in the purge context. | |
| 4665 | + * | |
| 4666 | + * - THEME_LIST: the change alters a post or comment list the theme draws | |
| 4667 | + * on pages the rules cannot name. | |
| 4668 | + * - LIMIT: the affected pages are more than Affected_Pages::LIMIT. | |
| 4669 | + * - PENDING: the save never reached `wp_after_insert_post`, so there was | |
| 4670 | + * no before-copy to work the old address out from. | |
| 4671 | + * - FILTER: `xspeed_purge_affected_pages` returned false. | |
| 4672 | + * - LISTING: the record of pages that run a post list of their own | |
| 4673 | + * (Listing_Pages) cannot answer: a page of the type went unrecorded at | |
| 4674 | + * its cap, more than Affected_Pages::LIMIT pages of the type would | |
| 4675 | + * have to be named, or the index does not read back. Or the pages it | |
| 4676 | + * named took the list past Affected_Pages::LIMIT. | |
| 4677 | + * | |
| 4678 | + * Every other purge publishes ''. A post type that always purges the | |
| 4679 | + * whole site (a WooCommerce product) is a choice, not a fallback, and | |
| 4680 | + * publishes '' too. | |
| 4681 | + */ | |
| 4682 | + public const FALLBACK_THEME_LIST = 'theme_list'; | |
| 4683 | + public const FALLBACK_LIMIT = 'limit'; | |
| 4684 | + public const FALLBACK_PENDING = 'pending'; | |
| 4685 | + public const FALLBACK_FILTER = 'filter'; | |
| 4686 | + public const FALLBACK_LISTING = 'listing'; | |
| 4687 | + | |
| 4688 | + /** | |
| 4689 | + * Posts noted by on_save_post() for a narrow purge, keyed by ID. | |
| 4690 | + * | |
| 4691 | + * @var array<int,bool> | |
| 4692 | + */ | |
| 4693 | + private static $pending_saves = array(); | |
| 4694 | + | |
| 4695 | + /** | |
| 4696 | + * Posts whose save purged their pages in this request, keyed by ID. That | |
| 4697 | + * purge named page 1 of the posts page, so a sticky change made after | |
| 4698 | + * it (the classic editor and Quick Edit stick after saving) has nothing | |
| 4699 | + * left to clear there. | |
| 4700 | + * | |
| 4701 | + * @var array<int,bool> | |
| 4702 | + */ | |
| 4703 | + private static $saves_purged = array(); | |
| 4704 | + | |
| 4705 | + /** | |
| 4706 | + * Purge the pages a save affected, once WordPress has finished saving. | |
| 4707 | + * | |
| 4708 | + * @param int $post_id Post ID. | |
| 4709 | + * @param \WP_Post $post Post as saved. | |
| 4710 | + * @param bool $update Whether an existing post was updated. | |
| 4711 | + * @param \WP_Post|null $post_before Post before the save, null when new. | |
| 4712 | + */ | |
| 4713 | + public static function on_after_insert_post( $post_id, $post = null, $update = false, $post_before = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook signature. | |
| 4714 | + $post_id = (int) $post_id; | |
| 4715 | + if ( ! isset( self::$pending_saves[ $post_id ] ) ) { | |
| 4716 | + return; | |
| 4717 | + } | |
| 4718 | + unset( self::$pending_saves[ $post_id ] ); | |
| 4719 | + if ( ! $post instanceof \WP_Post ) { | |
| 4720 | + $post = get_post( $post_id ); | |
| 4721 | + } | |
| 4722 | + if ( ! $post instanceof \WP_Post ) { | |
| 4723 | + return; | |
| 4724 | + } | |
| 4725 | + $post_before = $post_before instanceof \WP_Post ? $post_before : null; | |
| 4726 | + if ( self::repeats_block_editor_save( $post, $post_before ) ) { | |
| 4727 | + Affected_Pages::forget( $post_id ); | |
| 4728 | + return; | |
| 4729 | + } | |
| 4730 | + self::$saves_purged[ $post_id ] = true; | |
| 4731 | + self::purge_post_change( $post, $post_before, 'post:' . $post->post_type ); | |
| 4732 | + } | |
| 4733 | + | |
| 4734 | + /** Meta WordPress rewrites on every save, which says nothing about the page. */ | |
| 4735 | + private const SAVE_NOISE_META = array( '_edit_lock', '_edit_last', '_encloseme', '_pingme' ); | |
| 4736 | + | |
| 4737 | + /** | |
| 4738 | + * Whether this save repeats the block editor save that just purged. | |
| 4739 | + * | |
| 4740 | + * The block editor saves a post through REST, then, when the screen has | |
| 4741 | + * meta boxes (xSpeed's own Cache Rules box is one), posts them to | |
| 4742 | + * post.php?meta-box-loader=1, which saves the post a second time. Both | |
| 4743 | + * requests purged, so every save sent the same pages to every cache in | |
| 4744 | + * front twice. The REST save notes what it purged for; the meta box | |
| 4745 | + * request skips its purge when nothing a visitor sees has changed since: | |
| 4746 | + * the post's fields, its terms and its meta. A meta box that saved meta | |
| 4747 | + * the page prints (an SEO title, a custom field) still purges. | |
| 4748 | + * | |
| 4749 | + * @param \WP_Post $post Post as saved. | |
| 4750 | + * @param \WP_Post|null $before Post before the save. | |
| 4751 | + */ | |
| 4752 | + private static function repeats_block_editor_save( \WP_Post $post, ?\WP_Post $before ): bool { | |
| 4753 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only names the request; WordPress verified the save's own nonce. | |
| 4754 | + $meta_boxes = isset( $_GET['meta-box-loader'] ); | |
| 4755 | + $rest = defined( 'REST_REQUEST' ) && REST_REQUEST; | |
| 4756 | + if ( ! $meta_boxes && ! $rest ) { | |
| 4757 | + return false; | |
| 4758 | + } | |
| 4759 | + if ( ! Affected_Pages::is_public( $post ) && ! ( $before instanceof \WP_Post && Affected_Pages::is_public( $before ) ) ) { | |
| 4760 | + return false; | |
| 4761 | + } | |
| 4762 | + $key = 'xspeed_saved_' . (int) $post->ID; | |
| 4763 | + $fingerprint = self::save_fingerprint( $post ); | |
| 4764 | + if ( $meta_boxes ) { | |
| 4765 | + if ( get_transient( $key ) === $fingerprint ) { | |
| 4766 | + delete_transient( $key ); | |
| 4767 | + return true; | |
| 4768 | + } | |
| 4769 | + return false; | |
| 4770 | + } | |
| 4771 | + set_transient( $key, $fingerprint, 2 * MINUTE_IN_SECONDS ); | |
| 4772 | + return false; | |
| 4773 | + } | |
| 4774 | + | |
| 4775 | + /** | |
| 4776 | + * A hash of what a save can change that a visitor sees: the post's own | |
| 4777 | + * fields, its terms and its meta. | |
| 4778 | + * | |
| 4779 | + * @param \WP_Post $post Post as saved. | |
| 4780 | + */ | |
| 4781 | + private static function save_fingerprint( \WP_Post $post ): string { | |
| 4782 | + $fields = array(); | |
| 4783 | + foreach ( array( 'post_type', 'post_status', 'post_date', 'post_title', 'post_name', 'post_content', 'post_excerpt', 'post_parent', 'menu_order', 'post_author', 'post_password', 'comment_status' ) as $field ) { | |
| 4784 | + $fields[ $field ] = (string) ( $post->$field ?? '' ); | |
| 4785 | + } | |
| 4786 | + $terms = array(); | |
| 4787 | + $taxonomies = get_object_taxonomies( (string) $post->post_type ); | |
| 4788 | + if ( is_array( $taxonomies ) && array() !== $taxonomies ) { | |
| 4789 | + $ids = wp_get_object_terms( (int) $post->ID, $taxonomies, array( 'fields' => 'tt_ids' ) ); | |
| 4790 | + $terms = is_array( $ids ) ? array_map( 'intval', $ids ) : array(); | |
| 4791 | + sort( $terms ); | |
| 4792 | + } | |
| 4793 | + $meta = get_post_meta( (int) $post->ID ); | |
| 4794 | + $meta = is_array( $meta ) ? array_diff_key( $meta, array_flip( self::SAVE_NOISE_META ) ) : array(); | |
| 4795 | + ksort( $meta ); | |
| 4796 | + return md5( (string) wp_json_encode( array( $fields, $terms, $meta ) ) ); | |
| 4797 | + } | |
| 4798 | + | |
| 4799 | + /** | |
| 4800 | + * A save that never reached `wp_after_insert_post` still purges. | |
| 4801 | + * | |
| 4802 | + * `wp_insert_post()` called with `$fire_after_hooks = false` leaves that | |
| 4803 | + * hook to its caller, and a caller can fail to fire it. Without this, the | |
| 4804 | + * save would purge nothing at all. Site-wide, because there is no | |
| 4805 | + * before-copy to work out the old address from. | |
| 4806 | + */ | |
| 4807 | + public static function flush_pending_saves(): void { | |
| 4808 | + if ( array() === self::$pending_saves ) { | |
| 4809 | + return; | |
| 4810 | + } | |
| 4811 | + $ids = array_keys( self::$pending_saves ); | |
| 4812 | + self::$pending_saves = array(); | |
| 4813 | + self::purge_all( | |
| 4814 | + 'post:pending', | |
| 4815 | + null, | |
| 4816 | + array( | |
| 4817 | + 'scope' => 'site', | |
| 4818 | + 'intent' => 'content', | |
| 4819 | + 'urls' => array(), | |
| 4820 | + 'fallback' => self::FALLBACK_PENDING, | |
| 4821 | + ) | |
| 4822 | + ); | |
| 4823 | + foreach ( $ids as $id ) { | |
| 4824 | + Affected_Pages::forget( (int) $id ); | |
| 4825 | + } | |
| 4826 | + } | |
| 4827 | + | |
| 4828 | + /** | |
| 4829 | + * Before an update is written: note the post's neighbours, which a new | |
| 4830 | + * date or category takes it away from. Only for a post visitors can see | |
| 4831 | + * now, and only when the save will purge narrowly; that costs four | |
| 4832 | + * queries per update. | |
| 4833 | + * | |
| 4834 | + * @param int $post_id Post ID. | |
| 4835 | + * @param array $data Unused: the new values. | |
| 4836 | + */ | |
| 4837 | + public static function on_pre_post_update( $post_id, $data = array() ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- hook signature. | |
| 4838 | + $post = get_post( (int) $post_id ); | |
| 4839 | + if ( ! $post instanceof \WP_Post || ! Affected_Pages::is_public( $post ) ) { | |
| 4840 | + return; | |
| 4841 | + } | |
| 4842 | + if ( in_array( (string) $post->post_type, self::presentation_post_types(), true ) || ! self::narrow_purge_applies( (string) $post->post_type ) ) { | |
| 4843 | + return; | |
| 4844 | + } | |
| 4845 | + Affected_Pages::remember_old_neighbours( $post ); | |
| 4846 | + } | |
| 4847 | + | |
| 4848 | + /** | |
| 4849 | + * A post was stuck or unstuck. | |
| 4850 | + * | |
| 4851 | + * A theme list that puts sticky posts first (Twenty Twenty-Five's "More | |
| 4852 | + * posts" under every post) changes on every page that draws it, so the | |
| 4853 | + * whole site goes. The block editor changes stickiness inside the save, | |
| 4854 | + * and the save's own purge sees it then. The classic editor and Quick | |
| 4855 | + * Edit change it after the save has purged, which is why this hook | |
| 4856 | + * purges the posts no save is waiting on. | |
| 4857 | + * | |
| 4858 | + * @param mixed $old_value Sticky post IDs before. | |
| 4859 | + * @param mixed $value Sticky post IDs after. | |
| 4860 | + */ | |
| 4861 | + public static function on_sticky_posts_change( $old_value, $value ): void { | |
| 4862 | + $blog_page = false; | |
| 4863 | + foreach ( Affected_Pages::remember_sticky_change( $old_value, $value ) as $post_id ) { | |
| 4864 | + if ( isset( self::$pending_saves[ $post_id ] ) ) { | |
| 4865 | + continue; | |
| 4866 | + } | |
| 4867 | + $post = get_post( $post_id ); | |
| 4868 | + if ( ! $post instanceof \WP_Post || ! Affected_Pages::is_public( $post ) || ! self::narrow_purge_applies( (string) $post->post_type ) ) { | |
| 4869 | + continue; | |
| 4870 | + } | |
| 4871 | + if ( Affected_Pages::lists_show_sticky( (string) $post->post_type ) ) { | |
| 4872 | + self::purge_all( | |
| 4873 | + 'sticky:' . $post->post_type, | |
| 4874 | + null, | |
| 4875 | + array( | |
| 4876 | + 'scope' => 'site', | |
| 4877 | + 'intent' => 'content', | |
| 4878 | + 'urls' => array(), | |
| 4879 | + 'fallback' => self::FALLBACK_THEME_LIST, | |
| 4880 | + ) | |
| 4881 | + ); | |
| 4882 | + return; | |
| 4883 | + } | |
| 4884 | + // WordPress's own blog list puts sticky posts first on its first | |
| 4885 | + // page, whatever the theme draws, so a post stuck or unstuck by | |
| 4886 | + // code, WP-CLI or a plugin moves on that page. A save in this | |
| 4887 | + // request already cleared it. | |
| 4888 | + if ( 'post' === $post->post_type && ! isset( self::$saves_purged[ $post_id ] ) ) { | |
| 4889 | + $blog_page = true; | |
| 4890 | + } | |
| 4891 | + } | |
| 4892 | + if ( $blog_page ) { | |
| 4893 | + $url = Affected_Pages::posts_page_url(); | |
| 4894 | + if ( '' !== $url ) { | |
| 4895 | + self::purge_urls( array( $url ), 'sticky:post' ); | |
| 4896 | + } | |
| 4897 | + } | |
| 4898 | + } | |
| 4899 | + | |
| 4900 | + /** | |
| 4901 | + * The first post stuck on a site: WordPress creates `sticky_posts` | |
| 4902 | + * instead of updating it, so update_option_sticky_posts never fires. | |
| 4903 | + * | |
| 4904 | + * @param string $option Option name. | |
| 4905 | + * @param mixed $value Sticky post IDs. | |
| 4906 | + */ | |
| 4907 | + public static function on_sticky_posts_added( $option, $value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- hook signature. | |
| 4908 | + self::on_sticky_posts_change( array(), $value ); | |
| 4909 | + } | |
| 4910 | + | |
| 4911 | + /** | |
| 4912 | + * Whether a content change to this post type may clear only the pages | |
| 4913 | + * it affects, before looking at the post itself. | |
| 4914 | + * | |
| 4915 | + * Off when the setting is off, during an import (one full purge runs at | |
| 4916 | + * the end), and for post types whose lists the rules do not know: | |
| 4917 | + * WooCommerce products, whose shop and category pages purge_product() | |
| 4918 | + * already handles. | |
| 4919 | + * | |
| 4920 | + * @param string $post_type Post type. | |
| 4921 | + */ | |
| 4922 | + private static function narrow_purge_applies( string $post_type ): bool { | |
| 4923 | + if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) { | |
| 4924 | + return false; | |
| 4925 | + } | |
| 4926 | + $opts = Settings_Manager::get( 'cache' ); | |
| 4927 | + if ( empty( $opts['purge_affected_only'] ) ) { | |
| 4928 | + return false; | |
| 4929 | + } | |
| 4930 | + /** | |
| 4931 | + * Filter the post types whose saves always purge the whole site. | |
| 4932 | + * | |
| 4933 | + * @param string[] $types Post type slugs. | |
| 4934 | + */ | |
| 4935 | + $site_wide = (array) apply_filters( 'xspeed_site_wide_purge_post_types', array( 'product', 'product_variation' ) ); | |
| 4936 | + return ! in_array( $post_type, $site_wide, true ); | |
| 4937 | + } | |
| 4938 | + | |
| 4939 | + /** | |
| 4940 | + * Purge what a change to one post affects, or the whole site when the | |
| 4941 | + * affected pages cannot be listed safely. | |
| 4942 | + * | |
| 4943 | + * The pages are the ones the rules name (Affected_Pages::for_post()) | |
| 4944 | + * and the recorded pages whose own post lists the change may alter | |
| 4945 | + * (Listing_Pages::pages_for()). | |
| 4946 | + * | |
| 4947 | + * Site-wide when this change alters a post list the theme draws on | |
| 4948 | + * pages the rules cannot name (Affected_Pages::lists_changed_by()), when | |
| 4949 | + * the record of listing pages cannot answer, when the list is | |
| 4950 | + * longer than Affected_Pages::LIMIT, or when the filter says so. Nothing | |
| 4951 | + * when the post was not public before or after: no page anyone can see | |
| 4952 | + * changed. | |
| 4953 | + * | |
| 4954 | + * @param \WP_Post $post Post as it is now. | |
| 4955 | + * @param \WP_Post|null $before Post before the change. | |
| 4956 | + * @param string $cause Purge log cause. | |
| 4957 | + */ | |
| 4958 | + private static function purge_post_change( \WP_Post $post, ?\WP_Post $before, string $cause ): void { | |
| 4959 | + // Checked before any list is consulted. A draft save moves the | |
| 4960 | + // draft's date and can match a list of any kind, which would send a | |
| 4961 | + // change nobody can see to a site-wide purge. | |
| 4962 | + if ( ! Affected_Pages::is_public( $post ) && ! ( $before instanceof \WP_Post && Affected_Pages::is_public( $before ) ) ) { | |
| 4963 | + Affected_Pages::forget( (int) $post->ID ); | |
| 4964 | + return; | |
| 4965 | + } | |
| 4966 | + $fallback = ''; | |
| 4967 | + $urls = null; | |
| 4968 | + if ( Affected_Pages::lists_changed_by( $post, $before ) ) { | |
| 4969 | + $fallback = self::FALLBACK_THEME_LIST; | |
| 4970 | + } else { | |
| 4971 | + $urls = Affected_Pages::for_post( $post, $before ); | |
| 4972 | + // Read before forget(): a plain edit is judged on the terms the | |
| 4973 | + // save replaced. | |
| 4974 | + $listing = Listing_Pages::enabled() ? Listing_Pages::pages_for( $post, $before ) : array(); | |
| 4975 | + if ( null === $listing ) { | |
| 4976 | + $fallback = self::FALLBACK_LISTING; | |
| 4977 | + $urls = null; | |
| 4978 | + } else { | |
| 4979 | + // Pages the rules did not name. Nginx Helper's own purge does | |
| 4980 | + // not reach them either, so a fallback they cause has to say so. | |
| 4981 | + $extra = array_diff( $listing, $urls ); | |
| 4982 | + $urls = array_values( array_unique( array_merge( $urls, $listing ) ) ); | |
| 4983 | + if ( count( $urls ) > Affected_Pages::LIMIT ) { | |
| 4984 | + $fallback = array() === $extra ? self::FALLBACK_LIMIT : self::FALLBACK_LISTING; | |
| 4985 | + $urls = null; | |
| 4986 | + } | |
| 4987 | + } | |
| 4988 | + } | |
| 4989 | + Affected_Pages::forget( (int) $post->ID ); | |
| 4990 | + | |
| 4991 | + if ( is_array( $urls ) ) { | |
| 4992 | + /** | |
| 4993 | + * Filter the pages a post change clears. | |
| 4994 | + * | |
| 4995 | + * Return false to purge the whole site instead. | |
| 4996 | + * | |
| 4997 | + * @param string[]|false $urls Absolute URLs. | |
| 4998 | + * @param \WP_Post $post The post that changed. | |
| 4999 | + */ | |
| 5000 | + $urls = apply_filters( 'xspeed_purge_affected_pages', $urls, $post ); | |
| 5001 | + if ( ! is_array( $urls ) ) { | |
| 5002 | + $fallback = self::FALLBACK_FILTER; | |
| 5003 | + } | |
| 5004 | + } | |
| 5005 | + | |
| 5006 | + if ( ! is_array( $urls ) ) { | |
| 5007 | + self::purge_all( | |
| 5008 | + $cause, | |
| 5009 | + null, | |
| 5010 | + array( | |
| 5011 | + 'scope' => 'site', | |
| 5012 | + 'intent' => 'content', | |
| 5013 | + 'urls' => array(), | |
| 5014 | + 'fallback' => $fallback, | |
| 5015 | + ) | |
| 5016 | + ); | |
| 5017 | + return; | |
| 5018 | + } | |
| 5019 | + if ( array() !== $urls ) { | |
| 5020 | + self::purge_urls( $urls, $cause ); | |
| 5021 | + } | |
| 5022 | + } | |
| 5023 | + | |
| 5024 | + /** | |
| 5025 | + * Delete/trash invalidation while the post type is still available. | |
| 5026 | + * The local and server response-cache sweeps share the same site boundary. | |
| 5027 | + * | |
| 5028 | + * @param int $post_id Removed post ID. | |
| 5029 | + * @param object|null $post Post object supplied by core when available. | |
| 5030 | + */ | |
| 5031 | + public static function on_post_removed( $post_id, $post = null ): void { | |
| 5032 | + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) { | |
| 5033 | + return; | |
| 5034 | + } | |
| 5035 | + | |
| 2660 | 5036 | $post_type = is_object( $post ) && isset( $post->post_type ) |
| 2661 | 5037 | ? (string) $post->post_type |
| 2662 | 5038 | : (string) get_post_type( $post_id ); |
| 2663 | - if ( '' === $post_type ) { | |
| 5039 | + | |
| 5040 | + if ( ! in_array( $post_type, self::presentation_post_types(), true ) && self::narrow_purge_applies( $post_type ) ) { | |
| 5041 | + $object = $post instanceof \WP_Post ? $post : get_post( (int) $post_id ); | |
| 5042 | + if ( $object instanceof \WP_Post ) { | |
| 5043 | + if ( 'attachment' === $post_type ) { | |
| 5044 | + // Its own page and the post it is attached to. Pages that | |
| 5045 | + // show the file keep an <img> to a file that is gone either | |
| 5046 | + // way: a fresh render prints the same tag. | |
| 5047 | + $urls = array(); | |
| 5048 | + $link = get_permalink( $object ); | |
| 5049 | + if ( is_string( $link ) && '' !== $link ) { | |
| 5050 | + $urls[] = $link; | |
| 5051 | + } | |
| 5052 | + $parent = $object->post_parent > 0 ? get_post( (int) $object->post_parent ) : null; | |
| 5053 | + if ( $parent instanceof \WP_Post && 'publish' === $parent->post_status ) { | |
| 5054 | + $parent_link = get_permalink( $parent ); | |
| 5055 | + if ( is_string( $parent_link ) && '' !== $parent_link ) { | |
| 5056 | + $urls[] = $parent_link; | |
| 5057 | + } | |
| 5058 | + } | |
| 5059 | + if ( array() !== $urls ) { | |
| 5060 | + self::purge_urls( $urls, 'post-removed:attachment' ); | |
| 5061 | + } | |
| 5062 | + return; | |
| 5063 | + } | |
| 5064 | + // Still published here: `before_delete_post` runs before the | |
| 5065 | + // row goes. Trashing reaches the save path instead. | |
| 5066 | + self::purge_post_change( $object, null, 'post-removed:' . $post_type ); | |
| 5067 | + return; | |
| 5068 | + } | |
| 5069 | + } | |
| 5070 | + | |
| 5071 | + self::purge_all( | |
| 5072 | + 'post-removed:' . $post_type, | |
| 5073 | + null, | |
| 5074 | + array( | |
| 5075 | + 'scope' => 'site', | |
| 5076 | + // Match on_save_post: a presentation type changes how pages | |
| 5077 | + // render rather than what they say. | |
| 5078 | + 'intent' => in_array( $post_type, self::presentation_post_types(), true ) | |
| 5079 | + ? 'presentation' | |
| 5080 | + : 'content', | |
| 5081 | + 'urls' => array(), | |
| 5082 | + ) | |
| 5083 | + ); | |
| 5084 | + } | |
| 5085 | + | |
| 5086 | + /** Purge site responses when moderation changes visible comments. */ | |
| 5087 | + public static function on_comment_status( $comment_id, $status = '' ): void { | |
| 5088 | + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null; | |
| 5089 | + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0; | |
| 5090 | + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) { | |
| 2664 | 5091 | return; |
| 2665 | 5092 | } |
| 2666 | - | |
| 2667 | - // Unknown/!viewable → nothing anonymous can see changed, UNLESS the | |
| 2668 | - // type is itself part of how pages render (#270 regression). | |
| 2669 | - if ( function_exists( 'is_post_type_viewable' ) | |
| 2670 | - && ! is_post_type_viewable( $post_type ) | |
| 2671 | - && ! in_array( $post_type, self::presentation_post_types(), true ) | |
| 2672 | - ) { | |
| 5093 | + $url = get_permalink( $post_id ); | |
| 5094 | + if ( ! is_string( $url ) || '' === $url ) { | |
| 2673 | 5095 | return; |
| 2674 | 5096 | } |
| 2675 | - | |
| 2676 | - // Name the trigger rather than logging a bare numeric id — the old | |
| 2677 | - // wiring passed the post ID into $cause, so the log read | |
| 2678 | - // "Cache purged (46)" with no indication of what caused it. (#243) | |
| 2679 | - self::purge_all( 'post:' . $post_type ); | |
| 2680 | - if ( class_exists( '\XSpeed\Minifier' ) ) { | |
| 2681 | - Minifier::purge_minified(); | |
| 5097 | + $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null; | |
| 5098 | + $narrow = $post instanceof \WP_Post && self::narrow_purge_applies( (string) $post->post_type ); | |
| 5099 | + if ( $narrow && ! Affected_Pages::site_lists_comments() ) { | |
| 5100 | + $urls = Affected_Pages::for_comment( $post ); | |
| 5101 | + if ( array() !== $urls ) { | |
| 5102 | + self::purge_urls( $urls, 'comment-status:' . (string) $status ); | |
| 5103 | + } | |
| 5104 | + return; | |
| 2682 | 5105 | } |
| 5106 | + self::purge_all( | |
| 5107 | + 'comment-status:' . (string) $status, | |
| 5108 | + null, | |
| 5109 | + array( | |
| 5110 | + 'scope' => 'site', | |
| 5111 | + 'intent' => 'content', | |
| 5112 | + 'urls' => array(), | |
| 5113 | + // Narrow purges apply to this post, so the site-wide purge is | |
| 5114 | + // for a recent-comments list on pages the rules cannot name. | |
| 5115 | + 'fallback' => $narrow ? self::FALLBACK_THEME_LIST : '', | |
| 5116 | + ) | |
| 5117 | + ); | |
| 2683 | 5118 | } |
| 2684 | 5119 | |
| 2685 | 5120 | /** |
| 2686 | 5121 | * comment_post → purge just the commented-on URL, and only once the |
| @@ -2703,8 +5138,19 @@ | ||
| 2703 | 5138 | $post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0; |
| 2704 | 5139 | if ( $post_id < 1 ) { |
| 2705 | 5140 | return; |
| 2706 | 5141 | } |
| 5142 | + // With narrow purges on, the comment pages and comment feeds go too. | |
| 5143 | + // No site-wide fallback here: a visitor's comment must never be able | |
| 5144 | + // to clear the whole site (#243). | |
| 5145 | + $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null; | |
| 5146 | + if ( $post instanceof \WP_Post && self::narrow_purge_applies( (string) $post->post_type ) ) { | |
| 5147 | + $urls = Affected_Pages::for_comment( $post ); | |
| 5148 | + if ( array() !== $urls ) { | |
| 5149 | + self::purge_urls( $urls, 'comment' ); | |
| 5150 | + } | |
| 5151 | + return; | |
| 5152 | + } | |
| 2707 | 5153 | $url = get_permalink( $post_id ); |
| 2708 | 5154 | if ( is_string( $url ) && '' !== $url ) { |
| 2709 | 5155 | self::purge_url( $url, 'comment' ); |
| 2710 | 5156 | } |
| @@ -2765,8 +5211,18 @@ | ||
| 2765 | 5211 | if ( $parent > 0 ) { |
| 2766 | 5212 | $product_id = $parent; |
| 2767 | 5213 | } |
| 2768 | 5214 | |
| 5215 | + // wp-admin's Update saves the product through WooCommerce inside the | |
| 5216 | + // post's own save_post, and Cache::on_save_post() then clears the | |
| 5217 | + // whole site for it, since products are in | |
| 5218 | + // xspeed_site_wide_purge_post_types. Sending these pages first only | |
| 5219 | + // purged them twice. A price or stock change made without a post | |
| 5220 | + // save (an order, the REST API, `$product->save()`) still purges here. | |
| 5221 | + if ( self::site_wide_save_under_way( $product_id ) ) { | |
| 5222 | + return; | |
| 5223 | + } | |
| 5224 | + | |
| 2769 | 5225 | $urls = array(); |
| 2770 | 5226 | |
| 2771 | 5227 | $permalink = get_permalink( $product_id ); |
| 2772 | 5228 | if ( is_string( $permalink ) && '' !== $permalink ) { |
| @@ -2819,17 +5275,91 @@ | ||
| 2819 | 5275 | * @param int $product_id The product that changed. |
| 2820 | 5276 | */ |
| 2821 | 5277 | $urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id ); |
| 2822 | 5278 | |
| 2823 | - foreach ( array_unique( array_filter( $urls ) ) as $url ) { | |
| 2824 | - self::purge_url( (string) $url, 'product' ); | |
| 5279 | + $targets = array(); | |
| 5280 | + foreach ( $urls as $url ) { | |
| 5281 | + if ( is_scalar( $url ) && '' !== (string) $url ) { | |
| 5282 | + $targets[] = (string) $url; | |
| 5283 | + } | |
| 2825 | 5284 | } |
| 5285 | + if ( array() === $targets ) { | |
| 5286 | + return; | |
| 5287 | + } | |
| 5288 | + | |
| 5289 | + // One batch, as a post save sends: one purge-log row and one event | |
| 5290 | + // naming every page, each in the spelling WordPress links to. A | |
| 5291 | + // purge_url() per page published every page in both spellings, | |
| 5292 | + // which doubled the calls to Nginx Helper, and wrote no purge-log | |
| 5293 | + // row when xSpeed's own page cache was off and no file went. | |
| 5294 | + // purge_urls() leaves the object cache alone, which matters here: | |
| 5295 | + // this runs on every stock change at checkout. | |
| 5296 | + self::purge_urls( array_values( array_unique( $targets ) ), 'product', 'content' ); | |
| 2826 | 5297 | } |
| 2827 | 5298 | |
| 2828 | 5299 | /** |
| 5300 | + * Posts whose save_post is running in this request, keyed by ID, with | |
| 5301 | + * the post as saved. Set before any other save_post listener and cleared | |
| 5302 | + * after the last one. | |
| 5303 | + * | |
| 5304 | + * @var array<int,\WP_Post|null> | |
| 5305 | + */ | |
| 5306 | + private static $saves_under_way = array(); | |
| 5307 | + | |
| 5308 | + /** | |
| 5309 | + * A post save has started. | |
| 5310 | + * | |
| 5311 | + * @param int $post_id Post ID. | |
| 5312 | + * @param \WP_Post|null $post Post as saved. | |
| 5313 | + */ | |
| 5314 | + public static function on_post_save_start( $post_id, $post = null ): void { | |
| 5315 | + self::$saves_under_way[ (int) $post_id ] = is_object( $post ) ? $post : null; | |
| 5316 | + } | |
| 5317 | + | |
| 5318 | + /** | |
| 5319 | + * A post save has finished. | |
| 5320 | + * | |
| 5321 | + * @param int $post_id Post ID. | |
| 5322 | + */ | |
| 5323 | + public static function on_post_save_end( $post_id ): void { | |
| 5324 | + unset( self::$saves_under_way[ (int) $post_id ] ); | |
| 5325 | + } | |
| 5326 | + | |
| 5327 | + /** | |
| 5328 | + * Whether a save_post for this post is running now and will end in | |
| 5329 | + * on_save_post() clearing the whole site: the same checks it makes. | |
| 5330 | + * | |
| 5331 | + * @param int $post_id Post ID. | |
| 5332 | + */ | |
| 5333 | + private static function site_wide_save_under_way( int $post_id ): bool { | |
| 5334 | + if ( ! array_key_exists( $post_id, self::$saves_under_way ) ) { | |
| 5335 | + return false; | |
| 5336 | + } | |
| 5337 | + $post = self::$saves_under_way[ $post_id ]; | |
| 5338 | + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) { | |
| 5339 | + return false; | |
| 5340 | + } | |
| 5341 | + $post_type = is_object( $post ) && isset( $post->post_type ) | |
| 5342 | + ? (string) $post->post_type | |
| 5343 | + : (string) get_post_type( $post_id ); | |
| 5344 | + if ( in_array( $post_type, self::presentation_post_types(), true ) ) { | |
| 5345 | + return true; | |
| 5346 | + } | |
| 5347 | + return ! self::narrow_purge_applies( $post_type ); | |
| 5348 | + } | |
| 5349 | + | |
| 5350 | + /** Test seam: forget the post saves in progress. */ | |
| 5351 | + public static function reset_saves_under_way(): void { | |
| 5352 | + self::$saves_under_way = array(); | |
| 5353 | + } | |
| 5354 | + | |
| 5355 | + /** | |
| 2829 | 5356 | * Adapter for the WooCommerce stock actions that pass a product OBJECT |
| 2830 | 5357 | * where the status actions pass an ID. |
| 2831 | 5358 | * |
| 5359 | + * No longer wired to a hook (on_product_stock_set() is); kept because | |
| 5360 | + * it is public. | |
| 5361 | + * | |
| 2832 | 5362 | * @param object $product WC_Product (or variation). |
| 2833 | 5363 | */ |
| 2834 | 5364 | public static function purge_product_object( $product ): void { |
| 2835 | 5365 | self::purge_product( $product ); |
| @@ -2834,13 +5364,409 @@ | ||
| 2834 | 5364 | public static function purge_product_object( $product ): void { |
| 2835 | 5365 | self::purge_product( $product ); |
| 2836 | 5366 | } |
| 2837 | 5367 | |
| 5368 | + /** | |
| 5369 | + * Stock writes wc_update_product_stock() has opened in this request, | |
| 5370 | + * keyed by product ID: true once the save inside the write has purged. | |
| 5371 | + * | |
| 5372 | + * Entries are removed when the write's *_set_stock arrives, so this | |
| 5373 | + * holds only writes still in progress. | |
| 5374 | + * | |
| 5375 | + * @var array<int,bool> | |
| 5376 | + */ | |
| 5377 | + private static $stock_writes = array(); | |
| 5378 | + | |
| 5379 | + /** | |
| 5380 | + * A CRUD save of a product: purge its pages. | |
| 5381 | + * | |
| 5382 | + * Inside a wc_update_product_stock() write, this is the save that write | |
| 5383 | + * makes, so note that the write's pages are already purged. | |
| 5384 | + * | |
| 5385 | + * @param int|object $product Product ID (what WooCommerce passes) or WC_Product. | |
| 5386 | + */ | |
| 5387 | + public static function on_product_saved( $product ): void { | |
| 5388 | + $product_id = self::product_id_of( $product ); | |
| 5389 | + if ( isset( self::$stock_writes[ $product_id ] ) ) { | |
| 5390 | + self::$stock_writes[ $product_id ] = true; | |
| 5391 | + } | |
| 5392 | + self::purge_product( $product ); | |
| 5393 | + } | |
| 5394 | + | |
| 5395 | + /** | |
| 5396 | + * wc_update_product_stock() is about to write a product's stock. | |
| 5397 | + * | |
| 5398 | + * @param object $product WC_Product (or variation) whose stock changes. | |
| 5399 | + */ | |
| 5400 | + public static function on_product_stock_write( $product ): void { | |
| 5401 | + $product_id = self::product_id_of( $product ); | |
| 5402 | + if ( $product_id > 0 ) { | |
| 5403 | + self::$stock_writes[ $product_id ] = false; | |
| 5404 | + } | |
| 5405 | + } | |
| 5406 | + | |
| 5407 | + /** | |
| 5408 | + * A product's stock changed: purge its pages, unless the save inside | |
| 5409 | + * the same wc_update_product_stock() call already did. | |
| 5410 | + * | |
| 5411 | + * That save fires woocommerce_update_product for the same product a | |
| 5412 | + * moment earlier, with the stock already written, so purging again | |
| 5413 | + * here cleared the same pages twice in one call. Only that pairing is | |
| 5414 | + * skipped: the flag is set by the save inside this write and cleared | |
| 5415 | + * here, so the next write, a status change or a later save in the same | |
| 5416 | + * request purges as usual, and so does a write made with `$updating` | |
| 5417 | + * set, which skips the save. A *_set_stock that arrives without | |
| 5418 | + * *_before_set_stock (WooCommerce's data store fires one mid-save when | |
| 5419 | + * a CRUD save changes the quantity) is not part of a write and purges. | |
| 5420 | + * | |
| 5421 | + * @param object $product WC_Product (or variation). | |
| 5422 | + */ | |
| 5423 | + public static function on_product_stock_set( $product ): void { | |
| 5424 | + $product_id = self::product_id_of( $product ); | |
| 5425 | + $purged = ! empty( self::$stock_writes[ $product_id ] ); | |
| 5426 | + unset( self::$stock_writes[ $product_id ] ); | |
| 5427 | + if ( $purged ) { | |
| 5428 | + return; | |
| 5429 | + } | |
| 5430 | + self::purge_product( $product ); | |
| 5431 | + } | |
| 5432 | + | |
| 5433 | + /** Test seam: forget the stock writes in progress. */ | |
| 5434 | + public static function reset_stock_writes(): void { | |
| 5435 | + self::$stock_writes = array(); | |
| 5436 | + } | |
| 5437 | + | |
| 5438 | + /** | |
| 5439 | + * The ID of a product passed as an ID or as a WC_Product. | |
| 5440 | + * | |
| 5441 | + * @param int|object $product Product ID or WC_Product. | |
| 5442 | + */ | |
| 5443 | + private static function product_id_of( $product ): int { | |
| 5444 | + if ( is_object( $product ) ) { | |
| 5445 | + return method_exists( $product, 'get_id' ) ? (int) $product->get_id() : 0; | |
| 5446 | + } | |
| 5447 | + return is_numeric( $product ) ? (int) $product : 0; | |
| 5448 | + } | |
| 5449 | + | |
| 5450 | + /** | |
| 5451 | + * Re-entry guard for the purge-event contract. | |
| 5452 | + * | |
| 5453 | + * A listener on `xspeed_after_purge_url` legitimately purges its own | |
| 5454 | + * layer, and a server-cache or CDN adapter that calls back into xSpeed | |
| 5455 | + * while doing so re-enters this method — unbounded, because each pass | |
| 5456 | + * looks like a fresh purge. | |
| 5457 | + * | |
| 5458 | + * A single global flag stops too much: a nested purge of a DIFFERENT URL is | |
| 5459 | + * a real purge whose listeners must hear about it. But a per-request | |
| 5460 | + * "already published" set stops too much in the other direction — a | |
| 5461 | + * network purge loops every blog in one request, and on a subdirectory | |
| 5462 | + * network they share a host, so blogs 2..N would be silently skipped. It | |
| 5463 | + * also grows for the life of the process. | |
| 5464 | + * | |
| 5465 | + * So the guard tracks what is IN FLIGHT, not what has been published: a | |
| 5466 | + * target is marked while its own dispatch is on the stack and unmarked | |
| 5467 | + * when it returns. Re-entering the same target recurses, so it is refused; | |
| 5468 | + * purging the same URL again later is a new event and publishes. The set | |
| 5469 | + * is bounded by call depth rather than by how many URLs a request touches. | |
| 5470 | + * | |
| 5471 | + * @var array<string,bool> | |
| 5472 | + */ | |
| 5473 | + private static $purge_events_in_flight = array(); | |
| 5474 | + | |
| 5475 | + /** Monotonic count used to detect whether a delegated purge published. */ | |
| 5476 | + private static $purge_event_sequence = 0; | |
| 5477 | + | |
| 5478 | + /** | |
| 5479 | + * Publish a purge event exactly once, with bounded arguments. | |
| 5480 | + * | |
| 5481 | + * Deliberately carries only what an integration needs to invalidate its | |
| 5482 | + * own copy: the canonical URL (or null for a full purge), the site host, | |
| 5483 | + * the cause label, and how many files went. No filesystem paths, no cache | |
| 5484 | + * contents, no request headers, no user data. The URL query and caller- | |
| 5485 | + * supplied cause may nevertheless contain sensitive text, so listeners | |
| 5486 | + * must redact them in logs or unrelated destinations that do not need the | |
| 5487 | + * exact cache key. | |
| 5488 | + * | |
| 5489 | + * A listener that throws must not take the purge down with it: the files | |
| 5490 | + * are already gone by the time we get here, and an integration's bad day | |
| 5491 | + * is not a reason to report a failed purge to the caller. | |
| 5492 | + * | |
| 5493 | + * @param string $hook Hook name to emit. | |
| 5494 | + * @param array<string,mixed> $context Bounded context, see above. | |
| 5495 | + */ | |
| 5496 | + private static function dispatch_purge_event( string $hook, array $context ): void { | |
| 5497 | + if ( ! function_exists( 'do_action' ) ) { | |
| 5498 | + return; | |
| 5499 | + } | |
| 5500 | + // Every event carries `fallback`, so one handler can read both the | |
| 5501 | + // per-URL and the full-purge shape. '' unless a narrow purge fell | |
| 5502 | + // back to the whole site. | |
| 5503 | + $context['fallback'] = isset( $context['fallback'] ) && is_string( $context['fallback'] ) ? $context['fallback'] : ''; | |
| 5504 | + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' ) | |
| 5505 | + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' ); | |
| 5506 | + if ( isset( self::$purge_events_in_flight[ $target ] ) ) { | |
| 5507 | + return; | |
| 5508 | + } | |
| 5509 | + self::$purge_events_in_flight[ $target ] = true; | |
| 5510 | + ++self::$purge_event_sequence; | |
| 5511 | + | |
| 5512 | + // Our own integrations get their own try. Sharing one with the public | |
| 5513 | + // action below meant a listener on the extension seam could throw and | |
| 5514 | + // take the contract event down with it — the mirror of the failure | |
| 5515 | + // this separation exists to prevent. | |
| 5516 | + try { | |
| 5517 | + // Built-in server-cache integrations run FIRST, and by a direct | |
| 5518 | + // call rather than as listeners on the action below. | |
| 5519 | + // | |
| 5520 | + // WordPress stops dispatching an action's remaining callbacks when | |
| 5521 | + // one of them throws. As a listener, our LiteSpeed forwarding | |
| 5522 | + // would then be skipped by any unrelated third-party callback that | |
| 5523 | + // happened to be registered earlier and blew up — and the visible | |
| 5524 | + // result is the worst kind: xSpeed reports a successful purge while | |
| 5525 | + // the server keeps serving stale HTML. Shipped behaviour must not | |
| 5526 | + // be hostage to a listener's bug. | |
| 5527 | + self::forward_to_server_caches( $context ); | |
| 5528 | + } catch ( \Throwable $e ) { | |
| 5529 | + self::log_purge_listener_error( $hook, $e ); | |
| 5530 | + } | |
| 5531 | + | |
| 5532 | + // The edge xCloud provides, through its purge plugin. Direct for the | |
| 5533 | + // same reason, and in its own try so a failure here cannot cost the | |
| 5534 | + // host page caches above or the public action below. | |
| 5535 | + try { | |
| 5536 | + if ( class_exists( __NAMESPACE__ . '\\Managed_Edge_Purge' ) ) { | |
| 5537 | + Managed_Edge_Purge::forward( $context ); | |
| 5538 | + } | |
| 5539 | + } catch ( \Throwable $e ) { | |
| 5540 | + self::log_purge_listener_error( $hook, $e ); | |
| 5541 | + } | |
| 5542 | + | |
| 5543 | + try { | |
| 5544 | + self::do_action_isolated( $hook, $context ); | |
| 5545 | + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch | |
| 5546 | + // Swallow: see docblock. The purge succeeded regardless. | |
| 5547 | + self::log_purge_listener_error( $hook, $e ); | |
| 5548 | + } finally { | |
| 5549 | + unset( self::$purge_events_in_flight[ $target ] ); | |
| 5550 | + } | |
| 5551 | + } | |
| 5552 | + | |
| 5553 | + /** | |
| 5554 | + * Run every listener on a purge hook, isolating each from the others. | |
| 5555 | + * | |
| 5556 | + * `do_action()` dispatches callbacks in one loop, so the first one to | |
| 5557 | + * throw takes every LATER listener down with it. On a purge that meant a | |
| 5558 | + * failing CDN integration silently cancelled the ones queued behind it — | |
| 5559 | + * and because the throw was swallowed to keep the purge itself succeeding, | |
| 5560 | + * the user was told the clear worked while two edges were never touched. | |
| 5561 | + * Invisible unless WP_DEBUG happened to be on. (QA #348) | |
| 5562 | + * | |
| 5563 | + * Each callback gets its own try/catch here, so one integration's bad day | |
| 5564 | + * costs only that integration. Priority order is preserved. Falls back to | |
| 5565 | + * a plain `do_action()` when the filter registry is not the shape we | |
| 5566 | + * expect, so an unusual environment degrades to the old behaviour rather | |
| 5567 | + * than skipping listeners entirely. | |
| 5568 | + * | |
| 5569 | + * @param string $hook Hook name to emit. | |
| 5570 | + * @param mixed $arg Single argument passed to each listener. | |
| 5571 | + */ | |
| 5572 | + public static function do_action_isolated( string $hook, $arg ): void { | |
| 5573 | + global $wp_filter; | |
| 5574 | + | |
| 5575 | + // Walking $wp_filter by hand and calling each callback directly was the | |
| 5576 | + // obvious way to do this, and it was wrong: it bypasses WordPress, so | |
| 5577 | + // `current_filter()` came back empty, `did_action()` stayed at 0, the | |
| 5578 | + // `all` hook never fired, and Query Monitor and Debug Bar could not see | |
| 5579 | + // the very contract this class publishes. A shared handler branching on | |
| 5580 | + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3) | |
| 5581 | + // | |
| 5582 | + // So let do_action() dispatch — WordPress keeps its bookkeeping — and | |
| 5583 | + // isolate one level down instead: each registered callback is swapped | |
| 5584 | + // for a wrapper that runs it inside a try/catch. One listener throwing | |
| 5585 | + // then costs only that listener, which is the whole point, without | |
| 5586 | + // costing the hook its identity. | |
| 5587 | + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) { | |
| 5588 | + do_action( $hook, $arg ); | |
| 5589 | + return; | |
| 5590 | + } | |
| 5591 | + | |
| 5592 | + $hook_object = $wp_filter[ $hook ]; | |
| 5593 | + $original = $hook_object->callbacks; | |
| 5594 | + if ( ! is_array( $original ) || array() === $original ) { | |
| 5595 | + do_action( $hook, $arg ); | |
| 5596 | + return; | |
| 5597 | + } | |
| 5598 | + | |
| 5599 | + $wrapped = array(); | |
| 5600 | + $restorations = array(); | |
| 5601 | + foreach ( $original as $priority => $group ) { | |
| 5602 | + if ( ! is_array( $group ) ) { | |
| 5603 | + $wrapped[ $priority ] = $group; | |
| 5604 | + continue; | |
| 5605 | + } | |
| 5606 | + foreach ( $group as $id => $registered ) { | |
| 5607 | + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) { | |
| 5608 | + $wrapped[ $priority ][ $id ] = $registered; | |
| 5609 | + continue; | |
| 5610 | + } | |
| 5611 | + $callback = $registered['function']; | |
| 5612 | + $wrapper = static function ( ...$args ) use ( $callback, $hook ) { | |
| 5613 | + try { | |
| 5614 | + return $callback( ...$args ); | |
| 5615 | + } catch ( \Throwable $e ) { | |
| 5616 | + self::log_purge_listener_error( $hook, $e ); | |
| 5617 | + return null; | |
| 5618 | + } | |
| 5619 | + }; | |
| 5620 | + $wrapped[ $priority ][ $id ] = array( | |
| 5621 | + // Keep accepted_args: a listener registered for 0 or 1 | |
| 5622 | + // arguments must still be called the way it asked. | |
| 5623 | + 'accepted_args' => $registered['accepted_args'] ?? 1, | |
| 5624 | + 'function' => $wrapper, | |
| 5625 | + ); | |
| 5626 | + $restorations[ $priority ][ $id ] = array( | |
| 5627 | + 'original' => $registered, | |
| 5628 | + 'wrapper' => $wrapper, | |
| 5629 | + ); | |
| 5630 | + } | |
| 5631 | + } | |
| 5632 | + | |
| 5633 | + $hook_object->callbacks = $wrapped; | |
| 5634 | + try { | |
| 5635 | + do_action( $hook, $arg ); | |
| 5636 | + } finally { | |
| 5637 | + // Restore only wrappers still present. Native add/remove operations | |
| 5638 | + // performed by listeners must survive this temporary substitution. | |
| 5639 | + foreach ( $restorations as $priority => $group ) { | |
| 5640 | + foreach ( $group as $id => $restore ) { | |
| 5641 | + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null; | |
| 5642 | + if ( $current === $restore['wrapper'] ) { | |
| 5643 | + $hook_object->callbacks[ $priority ][ $id ] = $restore['original']; | |
| 5644 | + } | |
| 5645 | + } | |
| 5646 | + } | |
| 5647 | + } | |
| 5648 | + } | |
| 5649 | + | |
| 5650 | + /** | |
| 5651 | + * Name a listener that threw, under WP_DEBUG only. | |
| 5652 | + * | |
| 5653 | + * Gated like the rest of Free's diagnostics: a third-party listener | |
| 5654 | + * throwing on every purge must not fill a production log. | |
| 5655 | + */ | |
| 5656 | + private static function log_purge_listener_error( string $hook, \Throwable $e ): void { | |
| 5657 | + // An \Error — a TypeError from one of OUR listeners, say — is a bug | |
| 5658 | + // rather than a runtime condition a third party imposed on us, and | |
| 5659 | + // swallowing it silently in production turns it into a purge that | |
| 5660 | + // quietly stops working. Those are logged whatever WP_DEBUG says; | |
| 5661 | + // third-party \Exceptions stay gated so a noisy integration cannot | |
| 5662 | + // fill a production log. | |
| 5663 | + $always = $e instanceof \Error; | |
| 5664 | + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) { | |
| 5665 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge. | |
| 5666 | + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() ); | |
| 5667 | + } | |
| 5668 | + } | |
| 5669 | + | |
| 5670 | + /** | |
| 5671 | + * Test seam: clear the in-flight set left behind by an aborted dispatch, | |
| 5672 | + * and the record of which saves purged in this request. | |
| 5673 | + */ | |
| 5674 | + public static function reset_purge_events(): void { | |
| 5675 | + self::$purge_events_in_flight = array(); | |
| 5676 | + self::$purge_event_sequence = 0; | |
| 5677 | + self::$saves_purged = array(); | |
| 5678 | + } | |
| 5679 | + | |
| 5680 | + /** | |
| 5681 | + * Hand the purge to the caches we ship integrations for. | |
| 5682 | + * | |
| 5683 | + * Isolated from the public action on purpose — see dispatch_purge_event(). | |
| 5684 | + * Guarded so a missing class (a partial upgrade, a stripped build) cannot | |
| 5685 | + * turn a working purge into a fatal. | |
| 5686 | + * | |
| 5687 | + * @param array<string,mixed> $context Bounded purge context. | |
| 5688 | + */ | |
| 5689 | + private static function forward_to_server_caches( array $context ): void { | |
| 5690 | + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) { | |
| 5691 | + Server_Caches::forward( $context ); | |
| 5692 | + } | |
| 5693 | + } | |
| 5694 | + | |
| 5695 | + /** | |
| 5696 | + * `host[:port]` for a cache key, from a parsed URL. | |
| 5697 | + * | |
| 5698 | + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and | |
| 5699 | + * that carries `:8080` on any install not served from 80/443 — dropping it | |
| 5700 | + * computed a different md5, found no file, and reported "already cold" | |
| 5701 | + * while the page kept serving HIT. | |
| 5702 | + * | |
| 5703 | + * A port that is the DEFAULT for the scheme is dropped, though, because | |
| 5704 | + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for | |
| 5705 | + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file | |
| 5706 | + * stored under `site.com` — the same silent no-op in the other direction, | |
| 5707 | + * and the one QA hit passing a canonical URL with the port spelled out. | |
| 5708 | + * (QA #348) | |
| 5709 | + * | |
| 5710 | + * @param array<string,mixed> $parts Output of wp_parse_url(). | |
| 5711 | + */ | |
| 5712 | + private static function host_port_of( array $parts ): string { | |
| 5713 | + if ( ! isset( $parts['host'] ) ) { | |
| 5714 | + return ''; | |
| 5715 | + } | |
| 5716 | + $host = strtolower( (string) $parts['host'] ); | |
| 5717 | + if ( '' === $host || ! isset( $parts['port'] ) ) { | |
| 5718 | + return $host; | |
| 5719 | + } | |
| 5720 | + $port = (int) $parts['port']; | |
| 5721 | + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; | |
| 5722 | + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) { | |
| 5723 | + return $host; | |
| 5724 | + } | |
| 5725 | + return $host . ':' . $port; | |
| 5726 | + } | |
| 5727 | + | |
| 2838 | 5728 | public static function purge_url( string $url, string $cause = 'manual' ): int { |
| 5729 | + // A URL that names nothing is not a purge of everything. An empty or | |
| 5730 | + // blank string used to fall through to the home_url() default below | |
| 5731 | + // and clear the HOMEPAGE — so a third party calling | |
| 5732 | + // `purge_url( get_permalink( $id ) )` on a post whose permalink came | |
| 5733 | + // back empty silently purged the front page instead of nothing. The | |
| 5734 | + // CLI and the MCP tool reject empties before reaching this, so only | |
| 5735 | + // direct API callers were exposed, but they are exactly the audience | |
| 5736 | + // this public contract is for. (QA #348) | |
| 5737 | + if ( '' === trim( $url ) ) { | |
| 5738 | + return 0; | |
| 5739 | + } | |
| 5740 | + // parse_url() can turn raw UTF-8 into underscores (it depends on the | |
| 5741 | + // host's C library and locale), so a pasted `/關於我們/` is encoded | |
| 5742 | + // first. normalize_path() below gives the same result either way. | |
| 5743 | + $url = self::encode_non_ascii( $url ); | |
| 2839 | 5744 | $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. |
| 2840 | 5745 | if ( ! is_array( $parts ) ) { |
| 2841 | 5746 | return 0; |
| 2842 | 5747 | } |
| 5748 | + // Absolute URLs are accepted only for HTTP response caches. Schemes such | |
| 5749 | + // as ftp:, file: and javascript: can parse cleanly but do not name a page | |
| 5750 | + // xSpeed or a server response cache can invalidate. A leading-slash path | |
| 5751 | + // remains a supported site-relative target. | |
| 5752 | + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) { | |
| 5753 | + return 0; | |
| 5754 | + } | |
| 5755 | + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) { | |
| 5756 | + return 0; | |
| 5757 | + } | |
| 5758 | + // Reject a string that parsed but is not a URL we can act on: no | |
| 5759 | + // scheme AND no host AND no leading-slash path means something like | |
| 5760 | + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a | |
| 5761 | + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)` | |
| 5762 | + // — a nonsense tag sent to LiteSpeed for every malformed call. | |
| 5763 | + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) { | |
| 5764 | + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : ''; | |
| 5765 | + if ( '' === $raw || '/' !== $raw[0] ) { | |
| 5766 | + return 0; | |
| 5767 | + } | |
| 5768 | + } | |
| 2843 | 5769 | // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which |
| 2844 | 5770 | // carries `:8080` on any install not served from 80/443 — while |
| 2845 | 5771 | // parse_url() splits the port into its own component, so a purge that |
| 2846 | 5772 | // used the bare host computed a different md5, found no file, and |
| @@ -2846,19 +5772,34 @@ | ||
| 2846 | 5772 | // used the bare host computed a different md5, found no file, and |
| 2847 | 5773 | // reported "already cold". A silent no-op: the page kept serving HIT |
| 2848 | 5774 | // until its TTL ran out. Intranet installs, panel hosts on :8443 and |
| 2849 | 5775 | // proxies that forward `Host: site.com:8080` all hit this. |
| 2850 | - $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : ''; | |
| 2851 | - if ( '' !== $host && isset( $parts['port'] ) ) { | |
| 2852 | - $host .= ':' . (int) $parts['port']; | |
| 5776 | + // A scheme-less `site.test:443/page/` is a supported explicit-host | |
| 5777 | + // target. Infer a scheme only when it names THIS site's hostname: then | |
| 5778 | + // its explicit default port is the same origin and the same local cache | |
| 5779 | + // key. Never apply this to another host or to a non-default port. | |
| 5780 | + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) { | |
| 5781 | + $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. | |
| 5782 | + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] ) | |
| 5783 | + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] ) | |
| 5784 | + ) { | |
| 5785 | + $home_scheme = strtolower( (string) $home['scheme'] ); | |
| 5786 | + $port = (int) $parts['port']; | |
| 5787 | + $home_port = isset( $home['port'] ) | |
| 5788 | + ? (int) $home['port'] | |
| 5789 | + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) ); | |
| 5790 | + if ( $home_port === $port | |
| 5791 | + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) ) | |
| 5792 | + ) { | |
| 5793 | + $parts['scheme'] = $home_scheme; | |
| 5794 | + } | |
| 5795 | + } | |
| 2853 | 5796 | } |
| 5797 | + $host = self::host_port_of( $parts ); | |
| 2854 | 5798 | if ( '' === $host && function_exists( 'home_url' ) ) { |
| 2855 | 5799 | $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. |
| 2856 | - if ( is_array( $home ) && isset( $home['host'] ) ) { | |
| 2857 | - $host = strtolower( (string) $home['host'] ); | |
| 2858 | - if ( isset( $home['port'] ) ) { | |
| 2859 | - $host .= ':' . (int) $home['port']; | |
| 2860 | - } | |
| 5800 | + if ( is_array( $home ) ) { | |
| 5801 | + $host = self::host_port_of( $home ); | |
| 2861 | 5802 | } |
| 2862 | 5803 | } |
| 2863 | 5804 | if ( '' === $host ) { |
| 2864 | 5805 | return 0; |
| @@ -2867,15 +5808,20 @@ | ||
| 2867 | 5808 | $path = '/' . ltrim( $path, '/' ); |
| 2868 | 5809 | if ( false !== strpos( $path, '..' ) ) { |
| 2869 | 5810 | return 0; |
| 2870 | 5811 | } |
| 5812 | + // Same spelling cache_key() hashes, so a permalink, the same URL with | |
| 5813 | + // `%E7` capitals, and a pasted `/關於我們/` all find the entry. The | |
| 5814 | + // purge event below keeps the caller's spelling and adds the others | |
| 5815 | + // (see escape_spellings()). | |
| 5816 | + $key_path = self::normalize_path( $path ); | |
| 2871 | 5817 | |
| 2872 | 5818 | // The cache key preserves REQUEST_URI's trailing-slash form, so |
| 2873 | 5819 | // purge both. Root stays a single '/'. |
| 2874 | - $forms = array( $path ); | |
| 2875 | - if ( '/' !== $path ) { | |
| 2876 | - $forms[] = rtrim( $path, '/' ); | |
| 2877 | - $forms[] = rtrim( $path, '/' ) . '/'; | |
| 5820 | + $forms = array( $key_path ); | |
| 5821 | + if ( '/' !== $key_path ) { | |
| 5822 | + $forms[] = rtrim( $key_path, '/' ); | |
| 5823 | + $forms[] = rtrim( $key_path, '/' ) . '/'; | |
| 2878 | 5824 | } |
| 2879 | 5825 | $forms = array_unique( $forms ); |
| 2880 | 5826 | |
| 2881 | 5827 | /* |
| @@ -2911,12 +5857,15 @@ | ||
| 2911 | 5857 | } |
| 2912 | 5858 | } |
| 2913 | 5859 | |
| 2914 | 5860 | // Static tree (served directly by the nginx/.htaccess rewrite). |
| 2915 | - if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) { | |
| 5861 | + // The static tree stores decoded names (see static_path()). A path | |
| 5862 | + // static_path() refuses was never written there. | |
| 5863 | + $rel = self::static_path( $key_path ); | |
| 5864 | + if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) && null !== $rel ) { | |
| 2916 | 5865 | // Same transform the write used — `localhost:8080` files under |
| 2917 | 5866 | // `localhost8080`, so the bare host found nothing here either. |
| 2918 | - $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . ( '/' === $path ? '' : rtrim( $path, '/' ) ); | |
| 5867 | + $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . $rel; | |
| 2919 | 5868 | $file = $dir . '/index.html'; |
| 2920 | 5869 | if ( is_file( $file ) ) { |
| 2921 | 5870 | wp_delete_file( $file ); |
| 2922 | 5871 | ++$count; |
| @@ -2927,9 +5876,32 @@ | ||
| 2927 | 5876 | } |
| 2928 | 5877 | } |
| 2929 | 5878 | } |
| 2930 | 5879 | |
| 2931 | - if ( $count > 0 ) { | |
| 5880 | + /* | |
| 5881 | + * The per-URL purge event is published by `dispatch_purge_event()` | |
| 5882 | + * below, NOT here. | |
| 5883 | + * | |
| 5884 | + * This branch used to publish it itself, with a raw `do_action` and a | |
| 5885 | + * `{scope,url,urls,cause,count}` payload. #348 then landed the purge | |
| 5886 | + * contract on dev: a canonical URL, `removed` rather than `count`, | |
| 5887 | + * `intent`, per-listener isolation, and an in-flight guard so a | |
| 5888 | + * listener that purges its own layer cannot re-enter the event. | |
| 5889 | + * | |
| 5890 | + * Keeping both meant every per-URL purge fired TWICE, in two payload | |
| 5891 | + * shapes, and the raw call bypassed the guard — so a listener that | |
| 5892 | + * called back into `purge_url()` recursed until the process ran out | |
| 5893 | + * of memory. `CachePurgeEventContractTest:: | |
| 5894 | + * test_a_listener_re_purging_the_same_url_does_not_recurse` is what | |
| 5895 | + * caught it, and it arrived from dev with the contract it defends. | |
| 5896 | + * | |
| 5897 | + * The contract wins: it is a superset of what this published, and it | |
| 5898 | + * is what the LiteSpeed and nginx adapters are written against. | |
| 5899 | + */ | |
| 5900 | + | |
| 5901 | + // purge_url_reported() writes this row itself once the event has | |
| 5902 | + // gone out, so it can name the caches the purge was sent to. | |
| 5903 | + if ( $count > 0 && null === self::$url_batch && null === self::$reported_url_purge ) { | |
| 2932 | 5904 | Cache_Inventory::invalidate(); |
| 2933 | 5905 | Activity_Log::record( |
| 2934 | 5906 | 'cache_purge_url', |
| 2935 | 5907 | sprintf( |
| @@ -2942,14 +5914,384 @@ | ||
| 2942 | 5914 | Activity_Log::INFO |
| 2943 | 5915 | ); |
| 2944 | 5916 | } |
| 2945 | 5917 | |
| 5918 | + /** | |
| 5919 | + * Fires after one URL's cached copy has been purged. | |
| 5920 | + * | |
| 5921 | + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe | |
| 5922 | + * here to invalidate a cache xSpeed does not own — a server-level | |
| 5923 | + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for | |
| 5924 | + * the same URL. | |
| 5925 | + * | |
| 5926 | + * Only fires when the purge actually ran. A malformed URL, a URL with | |
| 5927 | + * no resolvable host, or a traversal attempt returns earlier and | |
| 5928 | + * publishes nothing, so a listener can treat this as "xSpeed purged | |
| 5929 | + * this URL" rather than "xSpeed was asked to". `removed` may legitimately | |
| 5930 | + * be 0: the URL was not in xSpeed's cache, which says nothing about | |
| 5931 | + * whether it is in yours. | |
| 5932 | + * | |
| 5933 | + * Fires at most once per purge. A listener that calls back into | |
| 5934 | + * xSpeed's purge API will not re-enter this event. | |
| 5935 | + * | |
| 5936 | + * @since 1.2.3 | |
| 5937 | + * | |
| 5938 | + * @param array $context { | |
| 5939 | + * Bounded description of the purge. URL queries and caller-supplied | |
| 5940 | + * causes can contain sensitive values and are not logging fields. | |
| 5941 | + * | |
| 5942 | + * @type string $url Canonical scheme://host/path[?query] of the purged URL. | |
| 5943 | + * The query is preserved because caches in front | |
| 5944 | + * commonly key on it; xSpeed's own sweep is | |
| 5945 | + * path-based, so `removed` describes that. | |
| 5946 | + * @type string $host Host (with port when non-standard). | |
| 5947 | + * @type string $path Path component, leading slash. | |
| 5948 | + * @type string $cause Short label for who asked. See purge_all(). | |
| 5949 | + * @type int $removed Number of cache files removed. | |
| 5950 | + * @type string $scope Actionable adapter scope: `urls`. | |
| 5951 | + * @type string $intent Why responses changed: `content`. | |
| 5952 | + * @type string[] $urls Exact response URLs to invalidate: the | |
| 5953 | + * canonical URL first, then the same URL | |
| 5954 | + * with the other trailing-slash spelling | |
| 5955 | + * (none for the root). A path with | |
| 5956 | + * percent-escapes adds the same pair with | |
| 5957 | + * the escapes in lower case and in upper | |
| 5958 | + * case, where those differ from it. | |
| 5959 | + * @type string $fallback Always '' on this event. Present so both | |
| 5960 | + * purge events share one shape; see | |
| 5961 | + * `xspeed_after_purge`. | |
| 5962 | + * } | |
| 5963 | + */ | |
| 5964 | + $query = isset( $parts['query'] ) ? (string) $parts['query'] : ''; | |
| 5965 | + $url_scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; | |
| 5966 | + $canonical_url = self::canonical_purge_url( $host, $path, $query, $url_scheme ); | |
| 5967 | + // Every spelling the local sweep above cleared, because a cache in | |
| 5968 | + // front keys each one on its own. Visitors reach a page as `/about/` | |
| 5969 | + // while a caller often passes `/about` (or the reverse), and a | |
| 5970 | + // non-ASCII page as `/%E9%97%9C…/` while get_permalink() passes | |
| 5971 | + // `/%e9%97%9c…/`. Publishing one spelling left the edge serving the | |
| 5972 | + // other, usually the one visitors actually use. | |
| 5973 | + $event_urls = array(); | |
| 5974 | + foreach ( self::escape_spellings( $path ) as $spelling ) { | |
| 5975 | + $event_urls[] = self::canonical_purge_url( $host, $spelling, $query, $url_scheme ); | |
| 5976 | + if ( '/' === $spelling ) { | |
| 5977 | + continue; | |
| 5978 | + } | |
| 5979 | + $other = '/' === substr( $spelling, -1 ) ? rtrim( $spelling, '/' ) : $spelling . '/'; | |
| 5980 | + if ( '' !== $other ) { | |
| 5981 | + $event_urls[] = self::canonical_purge_url( $host, $other, $query, $url_scheme ); | |
| 5982 | + } | |
| 5983 | + } | |
| 5984 | + // Inside purge_urls(): the batch publishes one event for every URL, | |
| 5985 | + // in the trailing-slash form it was given. The local sweep above still | |
| 5986 | + // took both forms. Escape spellings are still added, because the other | |
| 5987 | + // escape case does not redirect: a cache in front keeps the browser's | |
| 5988 | + // `%E9…` copy apart from the permalink's `%e9…` one. | |
| 5989 | + if ( null !== self::$url_batch ) { | |
| 5990 | + if ( '' === self::$url_batch['url'] ) { | |
| 5991 | + self::$url_batch['url'] = $canonical_url; | |
| 5992 | + self::$url_batch['host'] = $host; | |
| 5993 | + self::$url_batch['path'] = $path; | |
| 5994 | + } | |
| 5995 | + self::$url_batch['removed'] += $count; | |
| 5996 | + foreach ( self::escape_spellings( $path ) as $spelling ) { | |
| 5997 | + self::$url_batch['urls'][ self::canonical_purge_url( $host, $spelling, $query, $url_scheme ) ] = true; | |
| 5998 | + } | |
| 5999 | + return $count; | |
| 6000 | + } | |
| 6001 | + | |
| 6002 | + self::dispatch_purge_event( | |
| 6003 | + 'xspeed_after_purge_url', | |
| 6004 | + array( | |
| 6005 | + 'url' => $canonical_url, | |
| 6006 | + 'host' => $host, | |
| 6007 | + 'path' => $path, | |
| 6008 | + 'cause' => $cause, | |
| 6009 | + 'removed' => $count, | |
| 6010 | + 'scope' => 'urls', | |
| 6011 | + 'intent' => 'content', | |
| 6012 | + 'urls' => array_values( array_unique( $event_urls ) ), | |
| 6013 | + 'fallback' => '', | |
| 6014 | + ) | |
| 6015 | + ); | |
| 6016 | + | |
| 2946 | 6017 | return $count; |
| 2947 | 6018 | } |
| 2948 | 6019 | |
| 2949 | 6020 | /** |
| 2950 | - * Purge this site's cache. | |
| 6021 | + * The batch purge_urls() is collecting, or null outside one. | |
| 2951 | 6022 | * |
| 6023 | + * @var array{url:string,host:string,path:string,removed:int,urls:array<string,bool>}|null | |
| 6024 | + */ | |
| 6025 | + private static $url_batch = null; | |
| 6026 | + | |
| 6027 | + /** | |
| 6028 | + * The caches in front of PHP that took a purge while a report is open, | |
| 6029 | + * keyed by label, or null when no report is open. | |
| 6030 | + * | |
| 6031 | + * @var array<string,bool>|null | |
| 6032 | + */ | |
| 6033 | + private static $forward_report = null; | |
| 6034 | + | |
| 6035 | + /** | |
| 6036 | + * Note that a purge was handed to a cache in front of PHP: a server | |
| 6037 | + * cache, a host cache, a CDN or an edge. | |
| 6038 | + * | |
| 6039 | + * The built-in adapters call this when they accept a purge, whether | |
| 6040 | + * they send it now or queue it for the end of the request. A third-party | |
| 6041 | + * adapter listening on `xspeed_after_purge_url` may call it too, so an | |
| 6042 | + * operator's purge names it. Does nothing unless a caller opened a | |
| 6043 | + * report with purge_url_reported(). | |
| 6044 | + * | |
| 6045 | + * @param string $layer Name to show, such as "Nginx Helper". | |
| 6046 | + */ | |
| 6047 | + public static function note_purge_forwarded( string $layer ): void { | |
| 6048 | + if ( null === self::$forward_report || '' === trim( $layer ) ) { | |
| 6049 | + return; | |
| 6050 | + } | |
| 6051 | + self::$forward_report[ $layer ] = true; | |
| 6052 | + } | |
| 6053 | + | |
| 6054 | + /** | |
| 6055 | + * Run a purge and collect the caches in front of PHP it was sent to. | |
| 6056 | + * | |
| 6057 | + * @param callable $purge The purge to run. | |
| 6058 | + * @return array{result:mixed,forwarded:string[]} | |
| 6059 | + */ | |
| 6060 | + public static function report_forwarding( callable $purge ): array { | |
| 6061 | + $outer = self::$forward_report; | |
| 6062 | + self::$forward_report = array(); | |
| 6063 | + try { | |
| 6064 | + $result = $purge(); | |
| 6065 | + } finally { | |
| 6066 | + $forwarded = array_keys( (array) self::$forward_report ); | |
| 6067 | + self::$forward_report = null === $outer ? null : $outer + (array) self::$forward_report; | |
| 6068 | + } | |
| 6069 | + return array( | |
| 6070 | + 'result' => $result, | |
| 6071 | + 'forwarded' => $forwarded, | |
| 6072 | + ); | |
| 6073 | + } | |
| 6074 | + | |
| 6075 | + /** | |
| 6076 | + * Set while purge_url_reported() runs, so purge_url() leaves the | |
| 6077 | + * purge-log row to it. Null otherwise. | |
| 6078 | + * | |
| 6079 | + * @var bool|null | |
| 6080 | + */ | |
| 6081 | + private static $reported_url_purge = null; | |
| 6082 | + | |
| 6083 | + /** | |
| 6084 | + * Purge one URL for an operator, and say where the purge went. | |
| 6085 | + * | |
| 6086 | + * purge_url() writes a purge-log row only when it removed a local file, | |
| 6087 | + * because Pro's beacons call it on visitor requests. On a site whose | |
| 6088 | + * pages are held only by a server cache (xSpeed's page cache off, nginx | |
| 6089 | + * in front) an operator's purge then removed nothing locally, was sent | |
| 6090 | + * to the server cache, and left no row and an "already cold" answer. | |
| 6091 | + * This adds the row in that case. The CLI, the MCP tool (which runs the | |
| 6092 | + * CLI) and the admin "Purge this URL" link use it. | |
| 6093 | + * | |
| 6094 | + * @param string $url Absolute URL or site-relative path. | |
| 6095 | + * @param string $cause Who asked, for the purge log. | |
| 6096 | + * @return array{removed:int,forwarded:string[]} Files removed here, and | |
| 6097 | + * the caches the purge was sent to. | |
| 6098 | + */ | |
| 6099 | + public static function purge_url_reported( string $url, string $cause ): array { | |
| 6100 | + $outer = self::$reported_url_purge; | |
| 6101 | + self::$reported_url_purge = true; | |
| 6102 | + try { | |
| 6103 | + $run = self::report_forwarding( | |
| 6104 | + static function () use ( $url, $cause ): int { | |
| 6105 | + return self::purge_url( $url, $cause ); | |
| 6106 | + } | |
| 6107 | + ); | |
| 6108 | + } finally { | |
| 6109 | + self::$reported_url_purge = $outer; | |
| 6110 | + } | |
| 6111 | + $removed = (int) $run['result']; | |
| 6112 | + $forwarded = $run['forwarded']; | |
| 6113 | + | |
| 6114 | + if ( $removed > 0 ) { | |
| 6115 | + Cache_Inventory::invalidate(); | |
| 6116 | + $message = array() === $forwarded | |
| 6117 | + ? sprintf( | |
| 6118 | + /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */ | |
| 6119 | + __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ), | |
| 6120 | + $cause, | |
| 6121 | + $url, | |
| 6122 | + $removed | |
| 6123 | + ) | |
| 6124 | + : sprintf( | |
| 6125 | + /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed, 4: caches the purge was sent to. */ | |
| 6126 | + __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed, sent to %4$s', 'xspeed' ), | |
| 6127 | + $cause, | |
| 6128 | + $url, | |
| 6129 | + $removed, | |
| 6130 | + implode( ', ', $forwarded ) | |
| 6131 | + ); | |
| 6132 | + Activity_Log::record( 'cache_purge_url', $message, Activity_Log::INFO ); | |
| 6133 | + } elseif ( array() !== $forwarded ) { | |
| 6134 | + Activity_Log::record( | |
| 6135 | + 'cache_purge_url', | |
| 6136 | + sprintf( | |
| 6137 | + /* translators: 1: cause of the purge, 2: URL or path, 3: caches the purge was sent to. */ | |
| 6138 | + __( 'Purged one URL (%1$s): %2$s, 0 file(s) removed here, sent to %3$s', 'xspeed' ), | |
| 6139 | + $cause, | |
| 6140 | + $url, | |
| 6141 | + implode( ', ', $forwarded ) | |
| 6142 | + ), | |
| 6143 | + Activity_Log::INFO | |
| 6144 | + ); | |
| 6145 | + } | |
| 6146 | + | |
| 6147 | + return array( | |
| 6148 | + 'removed' => $removed, | |
| 6149 | + 'forwarded' => $forwarded, | |
| 6150 | + ); | |
| 6151 | + } | |
| 6152 | + | |
| 6153 | + /** | |
| 6154 | + * Purge several URLs as one purge: each URL's local copy goes as in | |
| 6155 | + * purge_url(), then ONE `xspeed_after_purge_url` event carries every URL. | |
| 6156 | + * | |
| 6157 | + * One event rather than one per URL, so the purge log gets one line, an | |
| 6158 | + * edge gets one batch, and a listener that counts purges counts one. The | |
| 6159 | + * event is the same contract purge_url() publishes: `scope` is `urls`, | |
| 6160 | + * `url` is the first URL, `urls` is all of them. | |
| 6161 | + * | |
| 6162 | + * Unlike purge_url(), `urls` holds each URL in the spelling it was given | |
| 6163 | + * and not the other trailing-slash spelling too. Callers pass WordPress's | |
| 6164 | + * own links (get_permalink(), get_term_link() and the like), which are | |
| 6165 | + * the spelling visitors reach; the other spelling redirects to it, so a | |
| 6166 | + * cache in front holds nothing stale under it. Both spellings doubled the | |
| 6167 | + * list: a typical save named 34 pages as 67 URLs, past xCloud's 50-URL | |
| 6168 | + * batch and the Hub's 30 per call. xSpeed's own sweep still removes both | |
| 6169 | + * spellings of each. The escape spellings (escape_spellings()) are still | |
| 6170 | + * listed, because `%E9…` does not redirect to `%e9…`; they add URLs only | |
| 6171 | + * for a path with percent-escapes. | |
| 6172 | + * | |
| 6173 | + * The object cache is left alone, as purge_url() leaves it. WordPress | |
| 6174 | + * already drops a post's own entries when the post, its terms or its | |
| 6175 | + * comments change (clean_post_cache() and the like), so a flush here | |
| 6176 | + * cleared nothing stale. What it did do: an approved visitor comment | |
| 6177 | + * emptied Redis or Memcached for the whole site, as often as a visitor | |
| 6178 | + * cared to comment. With a persistent object cache, transients live | |
| 6179 | + * there too, so a flush could also drop work a purge listener had just | |
| 6180 | + * queued in one. purge_all() still flushes. | |
| 6181 | + * | |
| 6182 | + * @param string[] $urls Absolute URLs or site-relative paths. | |
| 6183 | + * @param string $cause Who asked, for the purge log. | |
| 6184 | + * @param string $intent Why responses changed: `content` by default. | |
| 6185 | + * @return int Cache files removed. | |
| 6186 | + */ | |
| 6187 | + public static function purge_urls( array $urls, string $cause = 'manual', string $intent = 'content' ): int { | |
| 6188 | + $outer = self::$url_batch; | |
| 6189 | + self::$url_batch = array( | |
| 6190 | + 'url' => '', | |
| 6191 | + 'host' => '', | |
| 6192 | + 'path' => '', | |
| 6193 | + 'removed' => 0, | |
| 6194 | + 'urls' => array(), | |
| 6195 | + ); | |
| 6196 | + try { | |
| 6197 | + foreach ( array_unique( array_filter( $urls, 'is_string' ) ) as $url ) { | |
| 6198 | + self::purge_url( $url, $cause ); | |
| 6199 | + } | |
| 6200 | + } finally { | |
| 6201 | + $batch = self::$url_batch; | |
| 6202 | + self::$url_batch = $outer; | |
| 6203 | + } | |
| 6204 | + if ( '' === $batch['url'] ) { | |
| 6205 | + return 0; | |
| 6206 | + } | |
| 6207 | + | |
| 6208 | + Cache_Inventory::invalidate(); | |
| 6209 | + self::update_stats( array( 'last_purge' => time() ) ); | |
| 6210 | + // Same type as one URL's purge, so the purge log and the dashboard's | |
| 6211 | + // drill-down list it with the others. | |
| 6212 | + Activity_Log::record( | |
| 6213 | + 'cache_purge_url', | |
| 6214 | + sprintf( | |
| 6215 | + /* translators: 1: number of pages, 2: cause of the purge, 3: number of files removed. */ | |
| 6216 | + _n( 'Purged %1$d page (%2$s), %3$d file(s) removed', 'Purged %1$d pages (%2$s), %3$d file(s) removed', count( $urls ), 'xspeed' ), | |
| 6217 | + count( $urls ), | |
| 6218 | + $cause, | |
| 6219 | + $batch['removed'] | |
| 6220 | + ), | |
| 6221 | + Activity_Log::INFO | |
| 6222 | + ); | |
| 6223 | + | |
| 6224 | + self::dispatch_purge_event( | |
| 6225 | + 'xspeed_after_purge_url', | |
| 6226 | + array( | |
| 6227 | + 'url' => $batch['url'], | |
| 6228 | + 'host' => $batch['host'], | |
| 6229 | + 'path' => $batch['path'], | |
| 6230 | + 'cause' => $cause, | |
| 6231 | + 'removed' => $batch['removed'], | |
| 6232 | + 'scope' => 'urls', | |
| 6233 | + 'intent' => $intent, | |
| 6234 | + 'urls' => array_keys( $batch['urls'] ), | |
| 6235 | + 'fallback' => '', | |
| 6236 | + ) | |
| 6237 | + ); | |
| 6238 | + | |
| 6239 | + return $batch['removed']; | |
| 6240 | + } | |
| 6241 | + | |
| 6242 | + /** Host this site's purge is scoped to, for the purge-event context. */ | |
| 6243 | + private static function current_purge_host(): string { | |
| 6244 | + if ( ! function_exists( 'home_url' ) ) { | |
| 6245 | + return ''; | |
| 6246 | + } | |
| 6247 | + $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. | |
| 6248 | + if ( ! is_array( $home ) || empty( $home['host'] ) ) { | |
| 6249 | + return ''; | |
| 6250 | + } | |
| 6251 | + // Same default-port normalisation as purge_url(): a site whose | |
| 6252 | + // home_url() carries `:443` (normal behind a proxy) otherwise stamps | |
| 6253 | + // every full-purge event with a host that matches none of its own | |
| 6254 | + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348) | |
| 6255 | + return self::host_port_of( $home ); | |
| 6256 | + } | |
| 6257 | + | |
| 6258 | + /** | |
| 6259 | + * Rebuild the canonical URL a purge applied to. | |
| 6260 | + * | |
| 6261 | + * Built from the parts the purge itself used, so a listener is told the | |
| 6262 | + * URL we acted on rather than the string the caller happened to pass — | |
| 6263 | + * those differ whenever the caller supplied a site-relative path, a | |
| 6264 | + * different scheme, or a query string the cache key ignores. | |
| 6265 | + */ | |
| 6266 | + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string { | |
| 6267 | + // The purged URL's own scheme wins. purge_url() explicitly supports | |
| 6268 | + // cross-site purges (multisite, WP-CLI, cron), where composing the | |
| 6269 | + // current site's scheme onto another site's host builds a URL that was | |
| 6270 | + // never served — and a CDN listener then purges the wrong key and | |
| 6271 | + // reports success. | |
| 6272 | + if ( '' !== $url_scheme ) { | |
| 6273 | + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' ); | |
| 6274 | + } | |
| 6275 | + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http'; | |
| 6276 | + if ( function_exists( 'home_url' ) ) { | |
| 6277 | + $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. | |
| 6278 | + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) { | |
| 6279 | + $scheme = (string) $home['scheme']; | |
| 6280 | + } | |
| 6281 | + } | |
| 6282 | + // The query is carried even though OUR sweep above is path-based. | |
| 6283 | + // Caches in front commonly key on the full request line — LiteSpeed | |
| 6284 | + // tags `/shop/?page=2` separately from `/shop/` — so publishing the | |
| 6285 | + // bare path would have a listener confidently purge the wrong entry | |
| 6286 | + // and report success. Telling it exactly what was asked for lets it | |
| 6287 | + // act correctly; `removed` still describes only what WE removed. | |
| 6288 | + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' ); | |
| 6289 | + } | |
| 6290 | + | |
| 6291 | + /** | |
| 6292 | + * Sweep this site's cache files. | |
| 6293 | + * | |
| 2952 | 6294 | * On multisite every blog shares one cache directory, so an unscoped |
| 2953 | 6295 | * sweep here took the whole network cold — one subsite's settings save |
| 2954 | 6296 | * or post publish rebuilt every other site from PHP. Entries are stored |
| 2955 | 6297 | * per host (see host_dir()), and the sweep is scoped to match, so a |
| @@ -2954,16 +6296,27 @@ | ||
| 2954 | 6296 | * or post publish rebuilt every other site from PHP. Entries are stored |
| 2955 | 6297 | * per host (see host_dir()), and the sweep is scoped to match, so a |
| 2956 | 6298 | * purge originating on site-a leaves site-b's cache warm. (#6) |
| 2957 | 6299 | * |
| 2958 | - * @param string $cause Who asked, for the purge log. | |
| 2959 | - * @param string|null $host Host to purge. Defaults to the current site. | |
| 2960 | - * Pass '*' to sweep the ENTIRE tree — network | |
| 2961 | - * admin's "purge all sites", and the migration | |
| 2962 | - * of pre-#6 entries that sit in the tree root. | |
| 6300 | + * Clears the files only: the flat tree, the static tree, the REST | |
| 6301 | + * responses and the minified assets. The object-cache flush, the stats | |
| 6302 | + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract | |
| 6303 | + * event and the log entry live in purge_all(), which is still the entry | |
| 6304 | + * point for every existing caller. Split out so `wp xspeed purge` can | |
| 6305 | + * report the local sweep as one line item and the object cache as | |
| 6306 | + * another, each with its own status — see Purge_Runner. | |
| 6307 | + * | |
| 6308 | + * @param string|null $host Host to purge. Defaults to the current site. | |
| 6309 | + * Pass '*' to sweep the ENTIRE tree — network | |
| 6310 | + * admin's "purge all sites", and the migration | |
| 6311 | + * of pre-#6 entries that sit in the tree root. | |
| 6312 | + * @return array{pages:int,rest:int,assets:int,bytes:int} Entries removed | |
| 6313 | + * per store, and the bytes freed by the two file sweeps | |
| 6314 | + * that measure themselves. | |
| 2963 | 6315 | */ |
| 2964 | - public static function purge_all( string $cause = 'manual', ?string $host = null ) { | |
| 2965 | - $network_wide = ( '*' === $host ); | |
| 6316 | + public static function purge_local( ?string $host = null ): array { | |
| 6317 | + $network_wide = ( '*' === $host ); | |
| 6318 | + self::$sweep_bytes = 0; | |
| 2966 | 6319 | // The flat tree buckets by a flattened segment (host/a-b) while the |
| 2967 | 6320 | // static tree mirrors the URL (host/a/b), so they need separate |
| 2968 | 6321 | // scopes — see current_host_dir() vs current_static_scope(). |
| 2969 | 6322 | $static_scope = ''; |
| @@ -2972,9 +6325,10 @@ | ||
| 2972 | 6325 | $static_scope = $network_wide ? '' : self::current_static_scope(); |
| 2973 | 6326 | } else { |
| 2974 | 6327 | $dir = self::host_dir( $host ); |
| 2975 | 6328 | $scope = '' === $dir ? 'default' : $dir; |
| 2976 | - $static_scope = $scope; | |
| 6329 | + $static_dir = self::static_host_dir( $host ); | |
| 6330 | + $static_scope = '' === $static_dir ? 'default' : $static_dir; | |
| 2977 | 6331 | } |
| 2978 | 6332 | |
| 2979 | 6333 | $count = 0; |
| 2980 | 6334 | if ( is_dir( XSPEED_CACHE_DIR ) ) { |
| @@ -3024,9 +6378,9 @@ | ||
| 3024 | 6378 | $files = glob( $root . '/*.html' ); |
| 3025 | 6379 | if ( $files ) { |
| 3026 | 6380 | $count += count( $files ); |
| 3027 | 6381 | foreach ( $files as $f ) { |
| 3028 | - wp_delete_file( $f ); | |
| 6382 | + self::sweep_delete( $f ); | |
| 3029 | 6383 | } |
| 3030 | 6384 | } |
| 3031 | 6385 | // Remove the .meta sidecars (content-type for feeds/sitemaps) |
| 3032 | 6386 | // alongside their .html entries. Not counted — they're not |
| @@ -3033,9 +6387,9 @@ | ||
| 3033 | 6387 | // cache "pages", just per-entry metadata. |
| 3034 | 6388 | $meta = glob( $root . '/*.meta' ); |
| 3035 | 6389 | if ( $meta ) { |
| 3036 | 6390 | foreach ( $meta as $m ) { |
| 3037 | - wp_delete_file( $m ); | |
| 6391 | + self::sweep_delete( $m ); | |
| 3038 | 6392 | } |
| 3039 | 6393 | } |
| 3040 | 6394 | // Remove precompressed siblings (e.g. <key>.html.br from the Pro |
| 3041 | 6395 | // Brotli module). Not counted — same as .meta. Without this a |
| @@ -3043,9 +6397,9 @@ | ||
| 3043 | 6397 | // staleness window if precompression is later disabled. |
| 3044 | 6398 | $br = glob( $root . '/*.br' ); |
| 3045 | 6399 | if ( $br ) { |
| 3046 | 6400 | foreach ( $br as $b ) { |
| 3047 | - wp_delete_file( $b ); | |
| 6401 | + self::sweep_delete( $b ); | |
| 3048 | 6402 | } |
| 3049 | 6403 | } |
| 3050 | 6404 | // `*.br` does not match `*.br.size` — same reason as the flat-root |
| 3051 | 6405 | // sweep above: a size record outliving its body would later be |
| @@ -3052,9 +6406,9 @@ | ||
| 3052 | 6406 | // read against a different sibling's bytes. |
| 3053 | 6407 | $br_size = glob( $root . '/*.br.size' ); |
| 3054 | 6408 | if ( $br_size ) { |
| 3055 | 6409 | foreach ( $br_size as $b ) { |
| 3056 | - wp_delete_file( $b ); | |
| 6410 | + self::sweep_delete( $b ); | |
| 3057 | 6411 | } |
| 3058 | 6412 | } |
| 3059 | 6413 | } |
| 3060 | 6414 | } |
| @@ -3071,28 +6425,49 @@ | ||
| 3071 | 6425 | } |
| 3072 | 6426 | } |
| 3073 | 6427 | // REST response cache (cache/xspeed/rest/*.json) — same purge |
| 3074 | 6428 | // triggers (publish, settings change) invalidate it too. |
| 3075 | - $count += Rest_Cache::purge(); | |
| 6429 | + $rest = Rest_Cache::purge(); | |
| 6430 | + $count += $rest; | |
| 3076 | 6431 | |
| 3077 | - // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/). | |
| 3078 | - // purge_all is a full filesystem sweep and must clear these too, even | |
| 3079 | - // when the Minify module is currently disabled — orphaned min/ files | |
| 3080 | - // from a feature the user later turned off must still be removed, and | |
| 3081 | - // a stale combined-<hash>.css that the regenerated page no longer | |
| 3082 | - // references otherwise 404s and breaks the frontend. (FBS-83114/83116) | |
| 3083 | - if ( class_exists( '\\XSpeed\\Minifier' ) ) { | |
| 3084 | - Minifier::purge_minified(); | |
| 3085 | - } | |
| 6432 | + // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/) | |
| 6433 | + // go only on a NETWORK-wide sweep. | |
| 6434 | + // | |
| 6435 | + // They are named by content, so a site purge gains nothing by deleting | |
| 6436 | + // them: the next render links the same names for unchanged sources | |
| 6437 | + // and new names for changed ones. Deleting them did cost something. | |
| 6438 | + // A page rendered just before the purge and stored just after linked | |
| 6439 | + // files that were gone (unstyled until the TTL), and min/ is shared | |
| 6440 | + // by every blog, so one subsite's purge broke every other subsite's | |
| 6441 | + // cached pages. Orphans are Cache_GC's job; a network purge, an | |
| 6442 | + // update, the explicit assets purge and deactivation still clear the | |
| 6443 | + // tree, and the purge stamp stops a render in flight from being | |
| 6444 | + // stored against deleted files. | |
| 6445 | + $assets = ( $network_wide && class_exists( '\\XSpeed\\Minifier' ) ) ? Minifier::purge_minified() : 0; | |
| 3086 | 6446 | |
| 3087 | - // Persistent object cache (Redis / Memcached). Flush regardless of | |
| 3088 | - // whether the Object Cache module is currently enabled — a drop-in | |
| 3089 | - // installed earlier keeps serving until flushed. | |
| 3090 | - // | |
| 3091 | - // wp_cache_flush() is NETWORK-global: on multisite it would drop | |
| 3092 | - // every other site's object cache too, which is the same bug this | |
| 3093 | - // change fixes for the page cache. Prefer the blog-scoped flush | |
| 3094 | - // (WP 6.1+) unless we were explicitly asked to go network-wide. (#6) | |
| 6447 | + return array( | |
| 6448 | + 'pages' => $count - $rest, | |
| 6449 | + 'rest' => $rest, | |
| 6450 | + 'assets' => $assets, | |
| 6451 | + 'bytes' => self::$sweep_bytes, | |
| 6452 | + ); | |
| 6453 | + } | |
| 6454 | + | |
| 6455 | + /** | |
| 6456 | + * Flush the persistent object cache (Redis / Memcached). | |
| 6457 | + * | |
| 6458 | + * Runs regardless of whether the Object Cache module is currently | |
| 6459 | + * enabled — a drop-in installed earlier keeps serving until flushed. | |
| 6460 | + * | |
| 6461 | + * @param bool $network_wide Flush every blog's entries. wp_cache_flush() | |
| 6462 | + * is NETWORK-global, so on multisite the | |
| 6463 | + * default prefers the blog-scoped group flush | |
| 6464 | + * (WP 6.1+) — otherwise one site's purge drops | |
| 6465 | + * every other site's object cache, the same bug | |
| 6466 | + * #6 fixed for the page cache. | |
| 6467 | + * @return bool Whether a flush was actually performed. | |
| 6468 | + */ | |
| 6469 | + public static function flush_object_cache( bool $network_wide = false ): bool { | |
| 3095 | 6470 | if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) { |
| 3096 | 6471 | // Blog-scoped groups only; a shared/global group (site options, |
| 3097 | 6472 | // user meta) is intentionally left alone. |
| 3098 | 6473 | foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) { |
| @@ -3097,12 +6472,64 @@ | ||
| 3097 | 6472 | // user meta) is intentionally left alone. |
| 3098 | 6473 | foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) { |
| 3099 | 6474 | wp_cache_flush_group( $group ); |
| 3100 | 6475 | } |
| 3101 | - } elseif ( function_exists( 'wp_cache_flush' ) ) { | |
| 3102 | - wp_cache_flush(); | |
| 6476 | + return true; | |
| 3103 | 6477 | } |
| 6478 | + if ( function_exists( 'wp_cache_flush' ) ) { | |
| 6479 | + return (bool) wp_cache_flush(); | |
| 6480 | + } | |
| 6481 | + return false; | |
| 6482 | + } | |
| 3104 | 6483 | |
| 6484 | + /** | |
| 6485 | + * Purge this site's cache: the local sweep, then the object cache, then | |
| 6486 | + * the bookkeeping every caller expects (stats, `xspeed_after_purge_all`, | |
| 6487 | + * inventory invalidation, purge log). | |
| 6488 | + * | |
| 6489 | + * @param string $cause Who asked, for the purge log. | |
| 6490 | + * @param string|null $host See purge_local(). | |
| 6491 | + * @param array<string,mixed> $invalidation Public adapter policy. `scope` | |
| 6492 | + * is urls/site/network/none, | |
| 6493 | + * `intent` explains why, and | |
| 6494 | + * `urls` supplies exact targets. | |
| 6495 | + * @return int Page + REST entries removed. | |
| 6496 | + */ | |
| 6497 | + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) { | |
| 6498 | + $network_wide = ( '*' === $host ); | |
| 6499 | + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] ) | |
| 6500 | + ? $invalidation['scope'] | |
| 6501 | + : ( $network_wide ? 'network' : 'site' ); | |
| 6502 | + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) { | |
| 6503 | + $adapter_scope = $network_wide ? 'network' : 'site'; | |
| 6504 | + } | |
| 6505 | + if ( $network_wide ) { | |
| 6506 | + $adapter_scope = 'network'; | |
| 6507 | + } | |
| 6508 | + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent'] | |
| 6509 | + ? $invalidation['intent'] | |
| 6510 | + : 'complete'; | |
| 6511 | + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] ) | |
| 6512 | + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) ) | |
| 6513 | + : array(); | |
| 6514 | + // This method always sweeps a complete local bucket. A narrower adapter | |
| 6515 | + // announcement would claim unrelated local pages stayed warm when they | |
| 6516 | + // did not, leaving their server copies stale. Until purge_all() gains | |
| 6517 | + // dependency-aware local deletion, its response scope cannot be `urls`. | |
| 6518 | + if ( 'urls' === $adapter_scope ) { | |
| 6519 | + $adapter_scope = $network_wide ? 'network' : 'site'; | |
| 6520 | + } | |
| 6521 | + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) { | |
| 6522 | + $urls = array(); | |
| 6523 | + } | |
| 6524 | + | |
| 6525 | + $fallback = isset( $invalidation['fallback'] ) && is_string( $invalidation['fallback'] ) ? $invalidation['fallback'] : ''; | |
| 6526 | + | |
| 6527 | + $removed = self::purge_local( $host ); | |
| 6528 | + $count = $removed['pages'] + $removed['rest']; | |
| 6529 | + | |
| 6530 | + self::flush_object_cache( $network_wide ); | |
| 6531 | + | |
| 3105 | 6532 | self::update_stats( array( 'last_purge' => time() ) ); |
| 3106 | 6533 | |
| 3107 | 6534 | // Fire AFTER the local sweep so module listeners (Critical CSS, |
| 3108 | 6535 | // Unused CSS, Cloudflare edge purge) run — this action had three |
| @@ -3108,10 +6535,78 @@ | ||
| 3108 | 6535 | // Unused CSS, Cloudflare edge purge) run — this action had three |
| 3109 | 6536 | // registered listeners but was never emitted. Treat it as additive |
| 3110 | 6537 | // (CDN / edge invalidation), not the mechanism for clearing local |
| 3111 | 6538 | // files. (FBS-83114) |
| 3112 | - do_action( 'xspeed_after_purge_all', $cause ); | |
| 6539 | + // Wrapped: this action predates the purge-event contract and has its | |
| 6540 | + // own third-party listeners. One of them throwing used to abort | |
| 6541 | + // purge_all() here, which now also means the contract event below | |
| 6542 | + // never fires and a server cache keeps serving stale HTML. The local | |
| 6543 | + // sweep is already done by this point, so swallowing is strictly safer | |
| 6544 | + // than letting a listener decide the rest of the method runs. | |
| 6545 | + try { | |
| 6546 | + // Isolated per listener: one throwing used to cancel every | |
| 6547 | + // listener queued behind it — Critical CSS, Unused CSS and the | |
| 6548 | + // Cloudflare edge purge all hang off this hook. (QA #348) | |
| 6549 | + self::do_action_isolated( 'xspeed_after_purge_all', $cause ); | |
| 6550 | + } catch ( \Throwable $e ) { | |
| 6551 | + self::log_purge_listener_error( 'xspeed_after_purge_all', $e ); | |
| 6552 | + } | |
| 3113 | 6553 | |
| 6554 | + /** | |
| 6555 | + * Fires after a full purge, with the same bounded context shape as | |
| 6556 | + * `xspeed_after_purge_url`. | |
| 6557 | + * | |
| 6558 | + * Distinct from `xspeed_after_purge_all` on purpose. That action is | |
| 6559 | + * the long-standing internal signal — it passes a bare `$cause` string | |
| 6560 | + * and Free's own modules use it for local bookkeeping. This one is the | |
| 6561 | + * documented contract for OUTSIDE integrations: same argument shape as | |
| 6562 | + * the per-URL event, so a server-cache or CDN adapter can subscribe to | |
| 6563 | + * both with one handler and branch on a null `url`. | |
| 6564 | + * | |
| 6565 | + * Fires at most once per purge, and not at all when a listener's own | |
| 6566 | + * purge re-enters xSpeed. | |
| 6567 | + * | |
| 6568 | + * @since 1.2.3 | |
| 6569 | + * | |
| 6570 | + * @param array $context { | |
| 6571 | + * @type null $url Always null — a full purge has no single URL. | |
| 6572 | + * @type string $host Host swept, or '*' for the entire tree. | |
| 6573 | + * @type null $path Always null. | |
| 6574 | + * @type string $cause Short label for who asked. | |
| 6575 | + * @type int $removed Number of cache files removed. | |
| 6576 | + * @type string $scope Adapter action: urls/site/network/none. | |
| 6577 | + * @type string $intent content/presentation/complete or a caller-defined intent. | |
| 6578 | + * @type string[] $urls Exact targets when scope is urls. | |
| 6579 | + * @type string $fallback Why a change that would have cleared | |
| 6580 | + * only its own pages cleared the whole site | |
| 6581 | + * instead: `theme_list`, `limit`, `pending` | |
| 6582 | + * or `filter`. Empty for every other purge. | |
| 6583 | + * See the purge-event contract in | |
| 6584 | + * docs/guides/hooks-and-filters.md. | |
| 6585 | + * } | |
| 6586 | + */ | |
| 6587 | + self::dispatch_purge_event( | |
| 6588 | + 'xspeed_after_purge', | |
| 6589 | + array( | |
| 6590 | + 'url' => null, | |
| 6591 | + 'host' => null === $host ? self::current_purge_host() : (string) $host, | |
| 6592 | + 'path' => null, | |
| 6593 | + 'cause' => $cause, | |
| 6594 | + 'removed' => $count, | |
| 6595 | + 'scope' => $adapter_scope, | |
| 6596 | + 'intent' => $intent, | |
| 6597 | + 'urls' => $urls, | |
| 6598 | + 'fallback' => $fallback, | |
| 6599 | + ) | |
| 6600 | + ); | |
| 6601 | + | |
| 6602 | + /* | |
| 6603 | + * The generic event is published by the `dispatch_purge_event()` call | |
| 6604 | + * directly above, NOT here. Same supersession as in `purge_url()`: | |
| 6605 | + * this branch's raw `do_action` with a `count` payload predates #348's | |
| 6606 | + * contract, and keeping both made a full purge publish twice. | |
| 6607 | + */ | |
| 6608 | + | |
| 3114 | 6609 | // The list behind the "Cached pages" card is memoized for a minute; |
| 3115 | 6610 | // a purge has to drop it or the drill-down shows pages that no |
| 3116 | 6611 | // longer exist. |
| 3117 | 6612 | Cache_Inventory::invalidate(); |
| @@ -3210,8 +6705,13 @@ | ||
| 3210 | 6705 | * more than it needs to. A cold cache is the cheap direction, and the |
| 3211 | 6706 | * alternative — scoping the signal per upgrader — is not knowable from |
| 3212 | 6707 | * `upgrader_clear_destination`. |
| 3213 | 6708 | * |
| 6709 | + * The observed-destination signal is dropped here too. The two are read | |
| 6710 | + * together and have to expire together: leaving "the directory was not | |
| 6711 | + * there" behind would let a first install answer for whatever ran next | |
| 6712 | + * in the same request, and that one's mistake is a cache left stale. | |
| 6713 | + * | |
| 3214 | 6714 | * @return void |
| 3215 | 6715 | */ |
| 3216 | 6716 | public static function forget_cleared_destination(): void { |
| 3217 | 6717 | if ( self::$upgrade_dispatch_depth > 0 ) { |
| @@ -3219,12 +6719,84 @@ | ||
| 3219 | 6719 | } |
| 3220 | 6720 | |
| 3221 | 6721 | if ( 0 === self::$upgrade_dispatch_depth ) { |
| 3222 | 6722 | self::$upgrade_cleared_destination = false; |
| 6723 | + self::$upgrade_destination_existed = null; | |
| 6724 | + self::$upgrade_destination_folder = ''; | |
| 3223 | 6725 | } |
| 3224 | 6726 | } |
| 3225 | 6727 | |
| 3226 | 6728 | /** |
| 6729 | + * Whether the destination this run installs into was already there. | |
| 6730 | + * | |
| 6731 | + * Null while unknown — an upgrader whose target we cannot work out keeps | |
| 6732 | + * the old behaviour rather than being guessed at. | |
| 6733 | + * | |
| 6734 | + * @var bool|null | |
| 6735 | + */ | |
| 6736 | + private static $upgrade_destination_existed = null; | |
| 6737 | + | |
| 6738 | + /** | |
| 6739 | + * The folder name that answer was measured against, so the destination | |
| 6740 | + * WordPress reports on `upgrader_clear_destination` can be checked | |
| 6741 | + * against it. Empty when nothing was measured. | |
| 6742 | + * | |
| 6743 | + * @var string | |
| 6744 | + */ | |
| 6745 | + private static $upgrade_destination_folder = ''; | |
| 6746 | + | |
| 6747 | + /** | |
| 6748 | + * Note whether the package's destination exists, before it is cleared. | |
| 6749 | + * | |
| 6750 | + * `upgrader_source_selection` is the last hook that fires while the old | |
| 6751 | + * copy is still on disk, and the extracted source folder name is the | |
| 6752 | + * directory the package will install into. A pass-through listener: the | |
| 6753 | + * source is returned untouched. | |
| 6754 | + * | |
| 6755 | + * @param mixed $source Extracted package directory. | |
| 6756 | + * @param mixed $remote_src Unused; the package's remote source. | |
| 6757 | + * @param mixed $upgrader The upgrader instance, if one was supplied. | |
| 6758 | + * @param mixed $hook_extra Context supplied by the upgrader. | |
| 6759 | + * @return mixed The source, unchanged. | |
| 6760 | + */ | |
| 6761 | + public static function note_destination_state( $source, $remote_src = '', $upgrader = null, $hook_extra = array() ) { | |
| 6762 | + self::$upgrade_destination_existed = null; | |
| 6763 | + self::$upgrade_destination_folder = ''; | |
| 6764 | + | |
| 6765 | + $root = self::upgrade_destination_root( $upgrader, is_array( $hook_extra ) ? $hook_extra : array() ); | |
| 6766 | + if ( null !== $root && is_string( $source ) && '' !== $source ) { | |
| 6767 | + $folder = basename( rtrim( $source, '/\\' ) ); | |
| 6768 | + if ( '' !== $folder ) { | |
| 6769 | + self::$upgrade_destination_existed = is_dir( rtrim( $root, '/\\' ) . '/' . $folder ); | |
| 6770 | + self::$upgrade_destination_folder = $folder; | |
| 6771 | + } | |
| 6772 | + } | |
| 6773 | + | |
| 6774 | + return $source; | |
| 6775 | + } | |
| 6776 | + | |
| 6777 | + /** | |
| 6778 | + * Where a package of this kind installs to, or null if we cannot tell. | |
| 6779 | + * | |
| 6780 | + * @param mixed $upgrader The upgrader instance, if one was supplied. | |
| 6781 | + * @param array $hook_extra Context supplied by the upgrader. | |
| 6782 | + * @return string|null | |
| 6783 | + */ | |
| 6784 | + private static function upgrade_destination_root( $upgrader, array $hook_extra ): ?string { | |
| 6785 | + $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : ''; | |
| 6786 | + | |
| 6787 | + if ( 'plugin' === $type || $upgrader instanceof \Plugin_Upgrader ) { | |
| 6788 | + return defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : null; | |
| 6789 | + } | |
| 6790 | + | |
| 6791 | + if ( 'theme' === $type || $upgrader instanceof \Theme_Upgrader ) { | |
| 6792 | + return function_exists( 'get_theme_root' ) ? (string) get_theme_root() : null; | |
| 6793 | + } | |
| 6794 | + | |
| 6795 | + return null; | |
| 6796 | + } | |
| 6797 | + | |
| 6798 | + /** | |
| 3227 | 6799 | * Record that the upgrader cleared an existing destination. |
| 3228 | 6800 | * |
| 3229 | 6801 | * A pass-through listener on `upgrader_clear_destination`: WordPress only |
| 3230 | 6802 | * fires it when `clear_destination` was set AND something was there to |
| @@ -3233,13 +6805,28 @@ | ||
| 3233 | 6805 | * |
| 3234 | 6806 | * @param true|\WP_Error $removed Whether the destination was cleared. |
| 3235 | 6807 | * @return true|\WP_Error |
| 3236 | 6808 | */ |
| 3237 | - public static function note_cleared_destination( $removed ) { | |
| 6809 | + public static function note_cleared_destination( $removed, $local_destination = '', $remote_destination = '', $hook_extra = array() ) { | |
| 3238 | 6810 | if ( ! is_wp_error( $removed ) ) { |
| 3239 | 6811 | self::$upgrade_cleared_destination = true; |
| 3240 | 6812 | } |
| 3241 | 6813 | |
| 6814 | + // $remote_destination is the directory WordPress actually cleared, | |
| 6815 | + // derived from the source AFTER every `upgrader_source_selection` | |
| 6816 | + // listener ran. If its folder is not the one note_destination_state() | |
| 6817 | + // measured, a listener that ran after ours renamed the package, and | |
| 6818 | + // the "was it there?" answer is about a directory that was never going | |
| 6819 | + // to be written. Unknown is the answer that purges, so that is what it | |
| 6820 | + // becomes. Only the last segment is compared: over FTP the remote | |
| 6821 | + // path sits under the server's own root, not WP_PLUGIN_DIR. (#407 QA) | |
| 6822 | + if ( is_string( $remote_destination ) && '' !== $remote_destination ) { | |
| 6823 | + $cleared_folder = basename( rtrim( $remote_destination, '/\\' ) ); | |
| 6824 | + if ( '' !== $cleared_folder && $cleared_folder !== self::$upgrade_destination_folder ) { | |
| 6825 | + self::$upgrade_destination_existed = null; | |
| 6826 | + } | |
| 6827 | + } | |
| 6828 | + | |
| 3242 | 6829 | return $removed; |
| 3243 | 6830 | } |
| 3244 | 6831 | |
| 3245 | 6832 | /** |
| @@ -3353,8 +6940,18 @@ | ||
| 3353 | 6940 | if ( 'install' === $action && ! $cleared ) { |
| 3354 | 6941 | return false; |
| 3355 | 6942 | } |
| 3356 | 6943 | |
| 6944 | + // A cleared destination is only evidence of a replacement if there was | |
| 6945 | + // something in it. Core returns success from clear_destination() for a | |
| 6946 | + // destination that never existed, so a first-time install arrived here | |
| 6947 | + // looking exactly like an upload-and-replace and bought a cold cache | |
| 6948 | + // for a plugin that is not even active yet. Only acted on when we | |
| 6949 | + // positively know the directory was absent. | |
| 6950 | + if ( 'install' === $action && false === self::$upgrade_destination_existed ) { | |
| 6951 | + return false; | |
| 6952 | + } | |
| 6953 | + | |
| 3357 | 6954 | // 'translation' is the one update type that cannot change rendered |
| 3358 | 6955 | // markup. Anything else — including an empty type from a custom |
| 3359 | 6956 | // updater — is treated as cache-invalidating, because guessing wrong |
| 3360 | 6957 | // in that direction only costs a cold cache. |
| @@ -3378,10 +6975,10 @@ | ||
| 3378 | 6975 | * |
| 3379 | 6976 | * @return void |
| 3380 | 6977 | */ |
| 3381 | 6978 | private static function purge_for_upgrade(): void { |
| 6979 | + // Network-wide, so purge_local() also clears min/. | |
| 3382 | 6980 | self::purge_all( 'upgrade', '*' ); |
| 3383 | - Minifier::purge_minified(); | |
| 3384 | 6981 | } |
| 3385 | 6982 | |
| 3386 | 6983 | /** |
| 3387 | 6984 | * Purge after an unattended background update run. |
| @@ -3711,37 +7308,13 @@ | ||
| 3711 | 7308 | $count = self::purge_pages(); |
| 3712 | 7309 | self::update_stats( array( 'last_purge' => time() ) ); |
| 3713 | 7310 | Cache_Inventory::invalidate(); |
| 3714 | 7311 | self::record_partial_purge( 'page', $cause, $count ); |
| 7312 | + self::announce_purge( $cause, $count ); | |
| 3715 | 7313 | return $count; |
| 3716 | 7314 | |
| 3717 | 7315 | case 'assets': |
| 3718 | - if ( class_exists( '\\XSpeed\\Minifier' ) ) { | |
| 3719 | - Minifier::purge_minified(); | |
| 3720 | - } | |
| 3721 | - // Deleting min/ without clearing the pages that link it left | |
| 3722 | - // every cached page pointing at files that no longer exist. | |
| 3723 | - // WordPress answers the missing asset by 301-ing to its | |
| 3724 | - // pretty-permalink form and serving the 404 TEMPLATE as | |
| 3725 | - // `HTTP 200 text/html`, which the browser accepts as a | |
| 3726 | - // stylesheet and parses to zero rules — no console error, no | |
| 3727 | - // network failure, no 4xx anywhere in devtools. The pages | |
| 3728 | - // stayed broken for the rest of the TTL (7 days on | |
| 3729 | - // Aggressive, up to 30), and the admin who clicked could not | |
| 3730 | - // see it: they are logged in, so their own requests bypass | |
| 3731 | - // the page cache and re-render, regenerating the assets as a | |
| 3732 | - // side effect. Only anonymous visitors were served the stale | |
| 3733 | - // HTML. (#244) | |
| 3734 | - // | |
| 3735 | - // The assets are the pages' dependency, so invalidating them | |
| 3736 | - // invalidates the pages. Same invariant Cache_GC enforces | |
| 3737 | - // with is_referenced(): never leave a cached page pointing at | |
| 3738 | - // an asset that is gone. | |
| 3739 | - $count = self::purge_pages(); | |
| 3740 | - self::update_stats( array( 'last_purge' => time() ) ); | |
| 3741 | - Cache_Inventory::invalidate(); | |
| 3742 | - self::record_partial_purge( 'assets', $cause, $count ); | |
| 3743 | - return $count; | |
| 7316 | + return self::purge_assets( $cause ); | |
| 3744 | 7317 | |
| 3745 | 7318 | case 'object': |
| 3746 | 7319 | if ( function_exists( 'wp_cache_flush' ) ) { |
| 3747 | 7320 | wp_cache_flush(); |
| @@ -3751,8 +7324,9 @@ | ||
| 3751 | 7324 | |
| 3752 | 7325 | case 'rest': |
| 3753 | 7326 | $count = Rest_Cache::purge(); |
| 3754 | 7327 | self::record_partial_purge( 'REST responses', $cause, $count ); |
| 7328 | + self::announce_purge( $cause, $count ); | |
| 3755 | 7329 | return $count; |
| 3756 | 7330 | |
| 3757 | 7331 | default: |
| 3758 | 7332 | return self::purge_type_unhandled( $type, $cause ); |
| @@ -3759,8 +7333,121 @@ | ||
| 3759 | 7333 | } |
| 3760 | 7334 | } |
| 3761 | 7335 | |
| 3762 | 7336 | /** |
| 7337 | + * Purge this site's pages after a Customizer publish. | |
| 7338 | + * | |
| 7339 | + * A presentation change: every page's markup or inline CSS may differ, so | |
| 7340 | + * the whole site bucket goes, as for a template or global-styles edit. | |
| 7341 | + * min/ stays, because its files are named by content; this blog's | |
| 7342 | + * manifests are dropped so the next render re-reads every source rather | |
| 7343 | + * than trusting a signature. Bound with no arguments, because the hook | |
| 7344 | + * passes the WP_Customize_Manager, which purge_all() would take as its | |
| 7345 | + * cause. | |
| 7346 | + */ | |
| 7347 | + public static function on_customize_save(): void { | |
| 7348 | + self::purge_all( | |
| 7349 | + 'customizer', | |
| 7350 | + null, | |
| 7351 | + array( | |
| 7352 | + 'scope' => 'site', | |
| 7353 | + 'intent' => 'presentation', | |
| 7354 | + 'urls' => array(), | |
| 7355 | + ) | |
| 7356 | + ); | |
| 7357 | + if ( class_exists( '\\XSpeed\\Minifier' ) ) { | |
| 7358 | + Minifier::purge_manifests( Asset_Manifest::blog_id() ); | |
| 7359 | + } | |
| 7360 | + } | |
| 7361 | + | |
| 7362 | + /** | |
| 7363 | + * Purge minified and combined CSS/JS, and the pages that link them. | |
| 7364 | + * | |
| 7365 | + * Scope follows who shares what: | |
| 7366 | + * | |
| 7367 | + * - Single site: min/ is deleted outright. | |
| 7368 | + * - One site of a network: min/ is shared by every blog, so only this | |
| 7369 | + * blog's manifests go. Its next render re-reads each source and | |
| 7370 | + * rebuilds only what changed; no other blog loses a file it links. | |
| 7371 | + * Outputs nothing links any more are left to Cache_GC. | |
| 7372 | + * - $network: min/ and every blog's pages, network-wide. | |
| 7373 | + * | |
| 7374 | + * Deleting min/ without clearing the pages that link it left every | |
| 7375 | + * cached page pointing at files that no longer exist. WordPress answers | |
| 7376 | + * the missing asset by 301-ing to its pretty-permalink form and serving | |
| 7377 | + * the 404 TEMPLATE as `HTTP 200 text/html`, which the browser accepts as | |
| 7378 | + * a stylesheet and parses to zero rules — no console error, no network | |
| 7379 | + * failure, no 4xx anywhere in devtools. The pages stayed broken for the | |
| 7380 | + * rest of the TTL, and the admin who clicked could not see it: they are | |
| 7381 | + * logged in, so their own requests bypass the page cache. (#244) So the | |
| 7382 | + * pages go too, and when files were actually deleted the edge is told | |
| 7383 | + * through `xspeed_after_purge_all`, or it keeps serving HTML that links | |
| 7384 | + * them. | |
| 7385 | + * | |
| 7386 | + * The public `xspeed_after_purge` event carries intent `assets`: rendered | |
| 7387 | + * markup did not change, only asset files did. A listener that keeps | |
| 7388 | + * measurements of rendered pages (selectors, layout) can keep them. | |
| 7389 | + * | |
| 7390 | + * @param string $cause Who asked. | |
| 7391 | + * @param bool $network Purge every blog's pages and all of min/. | |
| 7392 | + * @return int Page entries removed. | |
| 7393 | + */ | |
| 7394 | + public static function purge_assets( string $cause, bool $network = false ): int { | |
| 7395 | + $deleted = 0; | |
| 7396 | + $rest = 0; | |
| 7397 | + if ( $network ) { | |
| 7398 | + // purge_local('*') clears min/ itself on a network-wide sweep. | |
| 7399 | + $removed = self::purge_local( '*' ); | |
| 7400 | + $count = (int) $removed['pages']; | |
| 7401 | + $rest = (int) $removed['rest']; | |
| 7402 | + $deleted = (int) $removed['assets']; | |
| 7403 | + } else { | |
| 7404 | + if ( class_exists( '\\XSpeed\\Minifier' ) && is_multisite() ) { | |
| 7405 | + Minifier::purge_manifests( Asset_Manifest::blog_id() ); | |
| 7406 | + } elseif ( class_exists( '\\XSpeed\\Minifier' ) ) { | |
| 7407 | + $deleted = Minifier::purge_minified(); | |
| 7408 | + } | |
| 7409 | + $count = self::purge_pages(); | |
| 7410 | + } | |
| 7411 | + | |
| 7412 | + self::update_stats( array( 'last_purge' => time() ) ); | |
| 7413 | + Cache_Inventory::invalidate(); | |
| 7414 | + self::record_partial_purge( 'assets', $cause, $count + $rest ); | |
| 7415 | + | |
| 7416 | + if ( $deleted > 0 || $network ) { | |
| 7417 | + try { | |
| 7418 | + self::do_action_isolated( 'xspeed_after_purge_all', $cause ); | |
| 7419 | + } catch ( \Throwable $e ) { | |
| 7420 | + self::log_purge_listener_error( 'xspeed_after_purge_all', $e ); | |
| 7421 | + } | |
| 7422 | + } | |
| 7423 | + | |
| 7424 | + if ( ! $network ) { | |
| 7425 | + self::announce_purge( $cause, $count, 'site', 'assets' ); | |
| 7426 | + } elseif ( function_exists( 'do_action' ) ) { | |
| 7427 | + try { | |
| 7428 | + self::dispatch_purge_event( | |
| 7429 | + 'xspeed_after_purge', | |
| 7430 | + array( | |
| 7431 | + 'url' => null, | |
| 7432 | + 'host' => '*', | |
| 7433 | + 'path' => null, | |
| 7434 | + 'cause' => $cause, | |
| 7435 | + 'removed' => $count + $rest, | |
| 7436 | + 'scope' => 'network', | |
| 7437 | + 'intent' => 'assets', | |
| 7438 | + 'urls' => array(), | |
| 7439 | + ) | |
| 7440 | + ); | |
| 7441 | + } catch ( \Throwable $e ) { | |
| 7442 | + self::log_purge_listener_error( 'xspeed_after_purge', $e ); | |
| 7443 | + } | |
| 7444 | + } | |
| 7445 | + | |
| 7446 | + return $count + $rest; | |
| 7447 | + } | |
| 7448 | + | |
| 7449 | + /** | |
| 3763 | 7450 | * Delete this site's cached pages from both the flat and static trees. |
| 3764 | 7451 | * |
| 3765 | 7452 | * Extracted so the `assets` purge can reuse it: minified assets are a |
| 3766 | 7453 | * dependency of the cached HTML, so clearing them must clear the pages |
| @@ -3805,15 +7492,101 @@ | ||
| 3805 | 7492 | * @param string $type Purge-type slug. |
| 3806 | 7493 | * @param string $cause Who asked. |
| 3807 | 7494 | */ |
| 3808 | 7495 | private static function purge_type_unhandled( string $type, string $cause ): int { |
| 3809 | - do_action( 'xspeed_purge_type_' . $type ); | |
| 7496 | + $event_sequence = self::$purge_event_sequence; | |
| 7497 | + $hook = 'xspeed_purge_type_' . $type; | |
| 7498 | + $has_handler = false !== has_action( $hook ); | |
| 7499 | + do_action( $hook ); | |
| 3810 | 7500 | self::record_partial_purge( $type, $cause, null ); |
| 3811 | 7501 | |
| 7502 | + // Announce, same as the types this class owns. Pro's "Purge Critical | |
| 7503 | + // CSS" and "Purge Unused CSS" arrive here, and they change what a | |
| 7504 | + // cached page CONTAINS — critical CSS is inlined into the HTML, so a | |
| 7505 | + // server cache goes on serving pages with the old styles baked in. | |
| 7506 | + // Fixing the three Free buttons and leaving these two silent left the | |
| 7507 | + // same hole for the tier most likely to be using both plugins. | |
| 7508 | + // (QA #348 round 2, issue 2) | |
| 7509 | + // | |
| 7510 | + // Unknown slugs must not turn into a site-wide purge merely because no | |
| 7511 | + // handler exists. These are the response-changing Pro types Free knows; | |
| 7512 | + // third parties can declare another through the filter. A registered | |
| 7513 | + // handler plus this explicit response scope is the handled signal. | |
| 7514 | + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none'; | |
| 7515 | + /** | |
| 7516 | + * Declare whether a handled custom purge type changes cached responses. | |
| 7517 | + * | |
| 7518 | + * @since 1.2.3 | |
| 7519 | + * @param string $scope site/network/none. | |
| 7520 | + * @param string $type Purge-type slug. | |
| 7521 | + */ | |
| 7522 | + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type ); | |
| 7523 | + if ( $has_handler | |
| 7524 | + && $event_sequence === self::$purge_event_sequence | |
| 7525 | + && in_array( $scope, array( 'site', 'network' ), true ) | |
| 7526 | + ) { | |
| 7527 | + self::announce_purge( $cause, 0, $scope, 'presentation' ); | |
| 7528 | + } | |
| 7529 | + | |
| 3812 | 7530 | return 0; |
| 3813 | 7531 | } |
| 3814 | 7532 | |
| 3815 | 7533 | /** |
| 7534 | + * Tell the server cache that a PARTIAL purge cleared cached responses. | |
| 7535 | + * | |
| 7536 | + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST | |
| 7537 | + * Cache" each delete cached RESPONSES for the whole site, so a cache in | |
| 7538 | + * front of PHP is now serving copies xSpeed has just thrown away. Only | |
| 7539 | + * "Purge All" announced itself, which left three of the four toolbar | |
| 7540 | + * buttons doing exactly what this contract exists to prevent: clearing | |
| 7541 | + * our copy while the server kept serving the stale one. The `assets` case | |
| 7542 | + * was the sharpest — it deletes the minified bundles too, so LiteSpeed | |
| 7543 | + * went on serving pages whose CSS and JS no longer exist. (QA #348) | |
| 7544 | + * | |
| 7545 | + * Sent as the full-purge shape (`url` null) because that is what happened: | |
| 7546 | + * every cached page for this site went, not one address. `object` is not | |
| 7547 | + * announced — flushing the object cache changes no rendered response a | |
| 7548 | + * server cache could be holding. | |
| 7549 | + * | |
| 7550 | + * Public because Purge_Runner sweeps the local files itself, through | |
| 7551 | + * purge_local(), rather than through purge_all() — so it has to announce | |
| 7552 | + * on its own behalf or `wp xspeed purge` and the dashboard button clear | |
| 7553 | + * our copy while LiteSpeed keeps serving the stale one. | |
| 7554 | + * | |
| 7555 | + * @param string $cause Who asked. | |
| 7556 | + * @param int $removed Entries removed locally. | |
| 7557 | + * @param string $scope Actionable adapter scope. | |
| 7558 | + * @param string $intent Reason rendered responses changed. | |
| 7559 | + */ | |
| 7560 | + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void { | |
| 7561 | + // Announcing is additive: the local sweep has already happened and | |
| 7562 | + // succeeded. Notification must never be able to turn a working purge | |
| 7563 | + // into a fatal, so anything the URL helpers do in an unusual context | |
| 7564 | + // (early boot, a drop-in, a bare test harness) is contained here | |
| 7565 | + // rather than propagating to the caller. | |
| 7566 | + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) { | |
| 7567 | + return; | |
| 7568 | + } | |
| 7569 | + try { | |
| 7570 | + self::dispatch_purge_event( | |
| 7571 | + 'xspeed_after_purge', | |
| 7572 | + array( | |
| 7573 | + 'url' => null, | |
| 7574 | + 'host' => self::current_purge_host(), | |
| 7575 | + 'path' => null, | |
| 7576 | + 'cause' => $cause, | |
| 7577 | + 'removed' => $removed, | |
| 7578 | + 'scope' => $scope, | |
| 7579 | + 'intent' => $intent, | |
| 7580 | + 'urls' => array(), | |
| 7581 | + ) | |
| 7582 | + ); | |
| 7583 | + } catch ( \Throwable $e ) { | |
| 7584 | + self::log_purge_listener_error( 'xspeed_after_purge', $e ); | |
| 7585 | + } | |
| 7586 | + } | |
| 7587 | + | |
| 7588 | + /** | |
| 3816 | 7589 | * Log a partial purge so the drill-down behind "Last purge" shows every |
| 3817 | 7590 | * clear, not only the full ones. Without this a site whose object cache |
| 3818 | 7591 | * is flushed on a schedule looks, from the log, like nothing happens. |
| 3819 | 7592 | * |
| @@ -3837,8 +7610,42 @@ | ||
| 3837 | 7610 | $count |
| 3838 | 7611 | ); |
| 3839 | 7612 | |
| 3840 | 7613 | Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO ); |
| 7614 | + | |
| 7615 | + /* | |
| 7616 | + * A partial purge is still a purge, and an edge in front of this site | |
| 7617 | + * has to hear about it. | |
| 7618 | + * | |
| 7619 | + * "Purge Page / Static Cache" is always visible in the admin bar and | |
| 7620 | + * clears every cached page for this site — the flat tree AND the | |
| 7621 | + * static tree the generated nginx/Apache rules serve directly. It | |
| 7622 | + * announced none of that: not `xspeed_after_purge_all`, which only | |
| 7623 | + * purge_all() fires, and not the scoped actions. A CDN told to hold | |
| 7624 | + * those pages went on serving the ones xSpeed had just deleted, for | |
| 7625 | + * whatever lifetime it was given. | |
| 7626 | + * | |
| 7627 | + * Scope is `site` only for the two types that clear rendered HTML. | |
| 7628 | + * An object-cache flush or a REST purge removes nothing an edge is | |
| 7629 | + * holding, and announcing those as a site purge would have a CDN | |
| 7630 | + * drop its whole cache every time a scheduled flush ran — worse than | |
| 7631 | + * the silence this replaces. A type registered by another plugin | |
| 7632 | + * through `xspeed_purge_types` is unknown here, so it says nothing | |
| 7633 | + * rather than guessing. | |
| 7634 | + * | |
| 7635 | + * Not gated on `$count`, for the reason spelled out in purge_url(): | |
| 7636 | + * an empty local tree is not evidence that the edge is empty, and | |
| 7637 | + * "Purge Page / Static Cache" with nothing left locally is precisely | |
| 7638 | + * what someone clicks when the page they are looking at is stale. | |
| 7639 | + * Answering that with silence made the button appear broken. | |
| 7640 | + * | |
| 7641 | + * That announcement is now `announce_purge()`'s, called beside this | |
| 7642 | + * by the same callers (`page`, `assets`, `REST responses`). This | |
| 7643 | + * function went back to being what its name says: a log entry. It | |
| 7644 | + * used to publish as well, which double-fired every partial purge and | |
| 7645 | + * — worse — announced `scope: none` for types #348's contract | |
| 7646 | + * requires to stay silent about. | |
| 7647 | + */ | |
| 3841 | 7648 | } |
| 3842 | 7649 | |
| 3843 | 7650 | /** |
| 3844 | 7651 | * Clear the static tree only, leaving the flat cache in place. |
| @@ -3863,8 +7670,24 @@ | ||
| 3863 | 7670 | * Returns the number of .html files removed so purge stats stay accurate |
| 3864 | 7671 | * across the flat + static caches — .br siblings are not counted |
| 3865 | 7672 | * (they're encodings of a page, not pages). |
| 3866 | 7673 | */ |
| 7674 | + /** | |
| 7675 | + * Delete a cache file, adding its size to the current sweep's byte | |
| 7676 | + * total. filesize() is silenced and re-checked because the file can | |
| 7677 | + * vanish between the glob and the unlink — a concurrent purge, or the | |
| 7678 | + * cache GC — and a warning there would be noise, not news. | |
| 7679 | + * | |
| 7680 | + * @param string $file Absolute path inside the cache tree. | |
| 7681 | + */ | |
| 7682 | + private static function sweep_delete( string $file ): void { | |
| 7683 | + $size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the file may be gone already; see docblock. | |
| 7684 | + if ( is_int( $size ) ) { | |
| 7685 | + self::$sweep_bytes += $size; | |
| 7686 | + } | |
| 7687 | + wp_delete_file( $file ); | |
| 7688 | + } | |
| 7689 | + | |
| 3867 | 7690 | private static function rmtree_html( string $dir ): int { |
| 3868 | 7691 | if ( ! is_dir( $dir ) ) { |
| 3869 | 7692 | return 0; |
| 3870 | 7693 | } |
| @@ -3888,9 +7711,9 @@ | ||
| 3888 | 7711 | @rmdir( $path ); |
| 3889 | 7712 | continue; |
| 3890 | 7713 | } |
| 3891 | 7714 | if ( substr( $entry, -5 ) === '.html' ) { |
| 3892 | - wp_delete_file( $path ); | |
| 7715 | + self::sweep_delete( $path ); | |
| 3893 | 7716 | ++$removed; |
| 3894 | 7717 | } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) { |
| 3895 | 7718 | // Precompressed sibling (index.html.br) and the record of its |
| 3896 | 7719 | // length. Remove both so a purge doesn't orphan stale Brotli |
| @@ -3895,9 +7718,9 @@ | ||
| 3895 | 7718 | // Precompressed sibling (index.html.br) and the record of its |
| 3896 | 7719 | // length. Remove both so a purge doesn't orphan stale Brotli |
| 3897 | 7720 | // bodies, or a size record that would later be read against a |
| 3898 | 7721 | // different sibling's bytes. Not counted. |
| 3899 | - wp_delete_file( $path ); | |
| 7722 | + self::sweep_delete( $path ); | |
| 3900 | 7723 | } |
| 3901 | 7724 | } |
| 3902 | 7725 | return $removed; |
| 3903 | 7726 | } |
| @@ -3994,8 +7817,25 @@ | ||
| 3994 | 7817 | // True when an edge cache (Cloudflare) fronts the origin, so hits are |
| 3995 | 7818 | // absorbed before reaching PHP. The dashboard labels the ratio |
| 3996 | 7819 | // "origin-layer only" instead of implying it's the full picture. (#118) |
| 3997 | 7820 | 'edge_cache' => self::edge_cache_detected(), |
| 7821 | + // LiteSpeed Static Fast Path (#509): the web server serves hits | |
| 7822 | + // with no PHP, no way to tag them, and no way to count them. The | |
| 7823 | + // dashboard labels the ratio as PHP-layer only so a low number | |
| 7824 | + // reads as the trade the user chose, not a fault. | |
| 7825 | + // | |
| 7826 | + // rewrite_installed() is part of the condition (QA on #513): when | |
| 7827 | + // the .htaccess write failed (read-only file), hits still take | |
| 7828 | + // the drop-in path and ARE counted — the disclosure would be the | |
| 7829 | + // opposite of the truth. Health carries the "block missing" | |
| 7830 | + // warning for that state; this flag only speaks when static | |
| 7831 | + // serving is genuinely in effect. | |
| 7832 | + 'static_hits_uncounted' => ( | |
| 7833 | + Server::LITESPEED === Server::type() | |
| 7834 | + && ! empty( Settings::get()['cache_enabled'] ) | |
| 7835 | + && self::static_rewrite_allowed() | |
| 7836 | + && self::rewrite_installed() | |
| 7837 | + ), | |
| 3998 | 7838 | /* |
| 3999 | 7839 | * Whether the page cache is actually SERVING, as opposed to |
| 4000 | 7840 | * switched on in settings. The hero read the setting alone and |
| 4001 | 7841 | * announced "Active — serving cached HTML"; a site whose |
| @@ -4011,14 +7851,46 @@ | ||
| 4011 | 7851 | * that state — the detector sweep behind it is far more work than |
| 4012 | 7852 | * a stats call should do on an ordinary healthy site. |
| 4013 | 7853 | */ |
| 4014 | 7854 | 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) ) |
| 4015 | - ? self::acquisition_blocker() | |
| 7855 | + ? ( self::acquisition_blocker() ?? self::not_serving_reason() ) | |
| 4016 | 7856 | : null, |
| 4017 | 7857 | ); |
| 4018 | 7858 | } |
| 4019 | 7859 | |
| 4020 | 7860 | /** |
| 7861 | + * Why the cache is not serving, when nothing REFUSES to enable it. | |
| 7862 | + * | |
| 7863 | + * acquisition_blocker() answers "may we take the field", and since a | |
| 7864 | + * foreign drop-in became takeable it answers null on a site where another | |
| 7865 | + * plugin is nonetheless holding that file. Intent and outcome still | |
| 7866 | + * disagree there, and the dashboard was left reporting the symptom -- not | |
| 7867 | + * serving -- with no reason under it, which is exactly the state a user | |
| 7868 | + * cannot act on. | |
| 7869 | + * | |
| 7870 | + * So this names the holder and says what to do: enabling takes it over. | |
| 7871 | + */ | |
| 7872 | + private static function not_serving_reason(): ?string { | |
| 7873 | + $owner = self::dropin_owner(); | |
| 7874 | + if ( self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner ) { | |
| 7875 | + return null; | |
| 7876 | + } | |
| 7877 | + | |
| 7878 | + if ( self::DROPIN_UNREADABLE === $owner ) { | |
| 7879 | + return __( 'advanced-cache.php cannot be read, so xSpeed cannot tell whose page cache is installed.', 'xspeed' ); | |
| 7880 | + } | |
| 7881 | + | |
| 7882 | + $label = Page_Cache_Detector::dropin_owner_label(); | |
| 7883 | + return $label | |
| 7884 | + ? sprintf( | |
| 7885 | + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */ | |
| 7886 | + __( '%s is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ), | |
| 7887 | + $label | |
| 7888 | + ) | |
| 7889 | + : __( 'Another plugin is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ); | |
| 7890 | + } | |
| 7891 | + | |
| 7892 | + /** | |
| 4021 | 7893 | * Whether the current request should be kept OUT of the cache hit/miss |
| 4022 | 7894 | * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect |
| 4023 | 7895 | * time, so is_404() is resolved. (#118) |
| 4024 | 7896 | */ |
| @@ -4025,8 +7897,13 @@ | ||
| 4025 | 7897 | private static function miss_is_excluded(): bool { |
| 4026 | 7898 | if ( function_exists( 'is_404' ) && is_404() ) { |
| 4027 | 7899 | return true; |
| 4028 | 7900 | } |
| 7901 | + // A marked request is ours whatever its UA says: a renamed warmer, | |
| 7902 | + // or a probe that has to send a browser's UA. | |
| 7903 | + if ( Self_Traffic::request_is_marked() ) { | |
| 7904 | + return true; | |
| 7905 | + } | |
| 4029 | 7906 | $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) |
| 4030 | 7907 | ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) ) |
| 4031 | 7908 | : ''; |
| 4032 | 7909 | return Hit_Counter::is_bot_ua( $ua ); |
| @@ -4032,16 +7909,20 @@ | ||
| 4032 | 7909 | return Hit_Counter::is_bot_ua( $ua ); |
| 4033 | 7910 | } |
| 4034 | 7911 | |
| 4035 | 7912 | /** |
| 4036 | - * Whether an edge cache fronts this origin. Today: the Cloudflare | |
| 4037 | - * integration is connected — so an unknown share of hits is served at the | |
| 4038 | - * edge and never counted here, making the origin ratio a partial view the | |
| 4039 | - * dashboard must label as such. (#118) | |
| 7913 | + * Whether an edge cache fronts this origin, so an unknown share of hits | |
| 7914 | + * is served there and never counted here — which makes the origin ratio a | |
| 7915 | + * partial view the dashboard has to label as such. (#118) | |
| 7916 | + * | |
| 7917 | + * This used to mean "the Cloudflare module is switched on", which answered | |
| 7918 | + * no for every site fronted by anything else, and no for a site on | |
| 7919 | + * Cloudflare that had never opened our Cloudflare panel. Both of those | |
| 7920 | + * sites had their ratio presented as the whole story. Edge_Provider knows | |
| 7921 | + * better and knows it per request, so ask it. | |
| 4040 | 7922 | */ |
| 4041 | 7923 | private static function edge_cache_detected(): bool { |
| 4042 | - $cf = get_option( 'xspeed_module_cloudflare', array() ); | |
| 4043 | - return is_array( $cf ) && ! empty( $cf['enabled'] ); | |
| 7924 | + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence']; | |
| 4044 | 7925 | } |
| 4045 | 7926 | |
| 4046 | 7927 | /** |
| 4047 | 7928 | * Apply the user's enable/disable choice. Called from the REST toggle |
| @@ -4072,9 +7953,9 @@ | ||
| 4072 | 7953 | * wp_config_writable: bool, |
| 4073 | 7954 | * manual_snippet: ?string |
| 4074 | 7955 | * } |
| 4075 | 7956 | */ |
| 4076 | - public static function toggle( $enable ) { | |
| 7957 | + public static function toggle( $enable, bool $consented = true ) { | |
| 4077 | 7958 | Page_Cache_Detector::invalidate(); |
| 4078 | 7959 | $expected = Page_Cache_Detector::inspect()['revision']; |
| 4079 | 7960 | /** Diagnostic seam; changing the expected revision can only force a safe refusal. */ |
| 4080 | 7961 | $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected ); |
| @@ -4081,8 +7962,9 @@ | ||
| 4081 | 7962 | $lock = self::page_cache_lock(); |
| 4082 | 7963 | if ( ! is_resource( $lock ) ) { |
| 4083 | 7964 | return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) ); |
| 4084 | 7965 | } |
| 7966 | + $changed = false; | |
| 4085 | 7967 | try { |
| 4086 | 7968 | Page_Cache_Detector::invalidate(); |
| 4087 | 7969 | $fresh = Page_Cache_Detector::inspect()['revision']; |
| 4088 | 7970 | if ( ! hash_equals( (string) $expected, (string) $fresh ) ) { |
| @@ -4087,18 +7969,88 @@ | ||
| 4087 | 7969 | $fresh = Page_Cache_Detector::inspect()['revision']; |
| 4088 | 7970 | if ( ! hash_equals( (string) $expected, (string) $fresh ) ) { |
| 4089 | 7971 | return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) ); |
| 4090 | 7972 | } |
| 4091 | - $state = self::toggle_unlocked( (bool) $enable ); | |
| 7973 | + $before = self::page_cache_fingerprint(); | |
| 7974 | + $state = self::toggle_unlocked( (bool) $enable, $consented ); | |
| 7975 | + $changed = empty( $state['blocked'] ) && self::page_cache_fingerprint() !== $before; | |
| 4092 | 7976 | return $state; |
| 4093 | 7977 | } finally { |
| 4094 | 7978 | flock( $lock, LOCK_UN ); |
| 4095 | 7979 | fclose( $lock ); |
| 7980 | + | |
| 7981 | + /* | |
| 7982 | + * Announce the change to anything caching in front of us. | |
| 7983 | + * | |
| 7984 | + * Turning the page cache on or off changes what every URL on this | |
| 7985 | + * site returns, and a CDN holding renders made under the old state | |
| 7986 | + * goes on serving them for their whole lifetime. Nothing told it. | |
| 7987 | + * OFF is the less obvious half and matters as much: the edge | |
| 7988 | + * otherwise keeps serving pages from a cache the site no longer | |
| 7989 | + * has. | |
| 7990 | + * | |
| 7991 | + * Only when something actually changed. A refused toggle wrote | |
| 7992 | + * nothing — that is the whole point of the refusal — and purging | |
| 7993 | + * after it would announce a change that did not happen. Nor does a | |
| 7994 | + * toggle that found the cache already in the state it asked for: | |
| 7995 | + * auto_heal() re-enables on every admin_init, and gating on "not | |
| 7996 | + * refused" alone made every wp-admin page load empty the page | |
| 7997 | + * cache and purge the edge (QA, 2026-09-23). The fingerprint is | |
| 7998 | + * compared instead, so restoring a stripped drop-in still counts. | |
| 7999 | + * | |
| 8000 | + * After the writes and OUTSIDE the lock. Before them, a purge | |
| 8001 | + * would repopulate from the state we are in the middle of leaving, | |
| 8002 | + * which is how a "purge didn't work" report is born; inside them, | |
| 8003 | + * it would hold single-occupancy ownership for the length of a | |
| 8004 | + * filesystem sweep. | |
| 8005 | + */ | |
| 8006 | + if ( $changed ) { | |
| 8007 | + /* | |
| 8008 | + * On shutdown, not inline. `purge_all()` sweeps the cache | |
| 8009 | + * directory and fires the purge actions, and a listener on | |
| 8010 | + * those can make an HTTP call to an edge — so running it here | |
| 8011 | + * would make the toggle as slow as the sweep and couple its | |
| 8012 | + * response to a third party. The Cloudflare Enterprise purge | |
| 8013 | + * queue already defers for the same reason. | |
| 8014 | + * | |
| 8015 | + * Still after the writes: shutdown runs at the end of THIS | |
| 8016 | + * request, with the new state on disk. | |
| 8017 | + */ | |
| 8018 | + add_action( | |
| 8019 | + 'shutdown', | |
| 8020 | + static function () { | |
| 8021 | + self::purge_all( 'page cache toggled' ); | |
| 8022 | + }, | |
| 8023 | + 20 | |
| 8024 | + ); | |
| 8025 | + } | |
| 4096 | 8026 | } |
| 4097 | 8027 | } |
| 4098 | 8028 | |
| 4099 | 8029 | /** Run the page-cache mutation while toggle() owns the scoped lock. */ |
| 4100 | - private static function toggle_unlocked( bool $enable ) { | |
| 8030 | + /** | |
| 8031 | + * @param bool $consented The user asked for this in the dashboard, so a | |
| 8032 | + * foreign drop-in may be taken over. False on the | |
| 8033 | + * unattended paths, which stand down instead. | |
| 8034 | + */ | |
| 8035 | + /** | |
| 8036 | + * What the page cache looks like on disk and in wp-config, as one string. | |
| 8037 | + * | |
| 8038 | + * `toggle()` compares it before and after its writes to tell a real ON/OFF | |
| 8039 | + * flip, or a repaired drop-in, from a call that found everything already | |
| 8040 | + * as asked. Only the latter must not announce a purge. | |
| 8041 | + */ | |
| 8042 | + private static function page_cache_fingerprint(): string { | |
| 8043 | + $dropin = self::read_file( WP_CONTENT_DIR . '/advanced-cache.php' ); | |
| 8044 | + return md5( | |
| 8045 | + ( null === $dropin ? "\0none" : md5( $dropin ) ) | |
| 8046 | + . '|' . self::wp_cache_define_state() | |
| 8047 | + . '|' . ( self::rewrite_installed() ? '1' : '0' ) | |
| 8048 | + . '|' . ( self::page_cache_operational() ? '1' : '0' ) | |
| 8049 | + ); | |
| 8050 | + } | |
| 8051 | + | |
| 8052 | + private static function toggle_unlocked( bool $enable, bool $consented = true ) { | |
| 4101 | 8053 | $enable = (bool) $enable; |
| 4102 | 8054 | |
| 4103 | 8055 | if ( $enable ) { |
| 4104 | 8056 | /* |
| @@ -4131,8 +8083,37 @@ | ||
| 4131 | 8083 | * on exactly the healthy sites this branch is about. |
| 4132 | 8084 | */ |
| 4133 | 8085 | $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner(); |
| 4134 | 8086 | $blocker = $reasserting ? null : self::acquisition_blocker(); |
| 8087 | + | |
| 8088 | + /* | |
| 8089 | + * Taking over another plugin's drop-in needs the user to have | |
| 8090 | + * asked for it. On the dashboard they did -- they clicked the | |
| 8091 | + * switch, having been told whose file it is. The UNATTENDED | |
| 8092 | + * callers have no such click: restore_dropin_if_enabled() runs | |
| 8093 | + * after a plugin update and auto_heal() on an admin page load, | |
| 8094 | + * both from nothing more than `cache_enabled` still being true. | |
| 8095 | + * | |
| 8096 | + * A competitor installed since that flag was set would have its | |
| 8097 | + * page cache seized by a background repair, which is the silent | |
| 8098 | + * acquisition this plugin refuses to perform. So those callers | |
| 8099 | + * pass $consented = false and stand down instead. | |
| 8100 | + */ | |
| 8101 | + if ( null === $blocker && ! $consented && self::DROPIN_FOREIGN === self::dropin_owner() ) { | |
| 8102 | + // Name the owner. This string is rendered by host plugins | |
| 8103 | + // through Host::enable_page_cache(), and an unnamed refusal | |
| 8104 | + // is what made every host invent its own explanation. | |
| 8105 | + $owner_label = Page_Cache_Detector::dropin_owner_label(); | |
| 8106 | + return self::blocked_toggle_state( | |
| 8107 | + $owner_label | |
| 8108 | + ? sprintf( | |
| 8109 | + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */ | |
| 8110 | + __( '%s owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ), | |
| 8111 | + $owner_label | |
| 8112 | + ) | |
| 8113 | + : __( 'Another plugin owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ) | |
| 8114 | + ); | |
| 8115 | + } | |
| 4135 | 8116 | if ( null !== $blocker ) { |
| 4136 | 8117 | Activity_Log::record( |
| 4137 | 8118 | 'cache_enable_blocked', |
| 4138 | 8119 | 'Cache not enabled — ' . $blocker, |
| @@ -4604,10 +8585,11 @@ | ||
| 4604 | 8585 | * they don't share a user at all. A default-umask 0644 file is then |
| 4605 | 8586 | * unwritable by nginx, the access_log write silently fails, and the |
| 4606 | 8587 | * dashboard shows a 0% hit ratio even though static HITs are serving. |
| 4607 | 8588 | * So we widen the dir to 0777 and the file to 0666 — group/other write — |
| 4608 | - * so whatever uid nginx runs as can append. (The file holds only HIT | |
| 4609 | - * request lines, no secrets.) | |
| 8589 | + * so whatever uid nginx runs as can append. The file holds HIT request | |
| 8590 | + * lines and must be protected like an access log: paths and queries can | |
| 8591 | + * contain sensitive values. | |
| 4610 | 8592 | */ |
| 4611 | 8593 | /** |
| 4612 | 8594 | * Directory holding the nginx hit log. Lives under uploads/, NOT the |
| 4613 | 8595 | * cache dir — uninstall.php and a cache purge both delete the cache |
| @@ -4621,11 +8603,22 @@ | ||
| 4621 | 8603 | * Falls back to the cache dir only if uploads is somehow unavailable. |
| 4622 | 8604 | */ |
| 4623 | 8605 | public static function hits_log_dir(): string { |
| 4624 | 8606 | if ( function_exists( 'wp_upload_dir' ) ) { |
| 8607 | + // One drop-in serves the whole network, so its hit log has one | |
| 8608 | + // home: the main site's uploads. Resolved per blog, the path | |
| 8609 | + // baked into the drop-in changed with whichever blog's admin | |
| 8610 | + // last ran auto_heal(), and each rewrite read as a page-cache | |
| 8611 | + // change and purged the site and the edge (QA, 2026-09-23). | |
| 8612 | + // A subsite's uploads are `<main uploads>/sites/<id>`, so the | |
| 8613 | + // suffix comes off rather than switching blogs to ask. | |
| 4625 | 8614 | $uploads = wp_upload_dir( null, false ); |
| 4626 | 8615 | if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) { |
| 4627 | - return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed'; | |
| 8616 | + $base = rtrim( (string) $uploads['basedir'], '/' ); | |
| 8617 | + if ( function_exists( 'is_multisite' ) && is_multisite() ) { | |
| 8618 | + $base = (string) preg_replace( '#/sites/\d+$#', '', $base ); | |
| 8619 | + } | |
| 8620 | + return $base . '/xspeed'; | |
| 4628 | 8621 | } |
| 4629 | 8622 | } |
| 4630 | 8623 | return XSPEED_CACHE_DIR; |
| 4631 | 8624 | } |
| @@ -4745,10 +8738,22 @@ | ||
| 4745 | 8738 | */ |
| 4746 | 8739 | public static function sync_query_allowlist(): void { |
| 4747 | 8740 | $file = XSPEED_CACHE_DIR . '/.ignored-query-params'; |
| 4748 | 8741 | |
| 4749 | - $opts = Settings_Manager::get( 'cache' ); | |
| 4750 | - $ignored = is_array( $opts['ignored_query_params'] ?? null ) ? $opts['ignored_query_params'] : array(); | |
| 8742 | + /* | |
| 8743 | + * Stored read, not Settings_Manager::get() — this runs from boot(), | |
| 8744 | + * before translation is legal (see stored_cache_opts()). | |
| 8745 | + * | |
| 8746 | + * A raw read applies no schema defaults, and this field's default is a | |
| 8747 | + * long tracking-parameter list, NOT empty. Falling back to array() | |
| 8748 | + * would strip that whole allow-list from the drop-in on any install | |
| 8749 | + * that has never saved the Cache panel. So fall back to the schema's | |
| 8750 | + * own default, read from the module without building its labels. | |
| 8751 | + */ | |
| 8752 | + $opts = self::stored_cache_opts(); | |
| 8753 | + $ignored = is_array( $opts['ignored_query_params'] ?? null ) | |
| 8754 | + ? $opts['ignored_query_params'] | |
| 8755 | + : \XSpeed\Modules\Cache\CacheModule::default_ignored_query_params(); | |
| 4751 | 8756 | |
| 4752 | 8757 | $parts = array(); |
| 4753 | 8758 | foreach ( $ignored as $pattern ) { |
| 4754 | 8759 | $pattern = trim( (string) $pattern ); |
| @@ -4782,9 +8787,12 @@ | ||
| 4782 | 8787 | if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) { |
| 4783 | 8788 | return; |
| 4784 | 8789 | } |
| 4785 | 8790 | |
| 4786 | - $payload = '(?:' . implode( '|', array_unique( $parts ) ) . ')'; | |
| 8791 | + // The drop-in anchors this as `^…$`, so the lookahead refuses the | |
| 8792 | + // never-ignored names whole, whatever entry would have matched them. | |
| 8793 | + $never = implode( '|', array_map( static fn ( $p ) => preg_quote( $p, '#' ), self::NEVER_IGNORED_QUERY_PARAMS ) ); | |
| 8794 | + $payload = '(?!(?:' . $never . ')$)(?:' . implode( '|', array_unique( $parts ) ) . ')'; | |
| 4787 | 8795 | |
| 4788 | 8796 | // Only write when the value actually changed. This runs from |
| 4789 | 8797 | // reconcile_mobile_separate() on CacheModule::boot(), so an |
| 4790 | 8798 | // unconditional write cost a file write and an exclusive lock on every |
| @@ -4799,12 +8807,54 @@ | ||
| 4799 | 8807 | // 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. |
| 4800 | 8808 | file_put_contents( $file, $payload, LOCK_EX ); |
| 4801 | 8809 | } |
| 4802 | 8810 | |
| 8811 | + /** | |
| 8812 | + * CacheModule's STORED settings, read straight from the option. | |
| 8813 | + * | |
| 8814 | + * `Settings_Manager::get( 'cache' )` builds CacheModule's settings schema, | |
| 8815 | + * whose labels are declared through `__()`. The reconcile chain below runs | |
| 8816 | + * from `CacheModule::boot()` on `plugins_loaded` — before | |
| 8817 | + * `after_setup_theme`, the point WordPress 6.7+ treats as safe to | |
| 8818 | + * translate — so going through the schema there fires | |
| 8819 | + * `_load_textdomain_just_in_time` on every request AND resolves the labels | |
| 8820 | + * against a domain that is not loaded yet. | |
| 8821 | + * | |
| 8822 | + * The callers here need stored values, not schema metadata, so a raw read | |
| 8823 | + * is equivalent. It applies NO defaults or coercion: read each key with a | |
| 8824 | + * fallback matching the schema's own default. | |
| 8825 | + * | |
| 8826 | + * @return array<string,mixed> | |
| 8827 | + */ | |
| 8828 | + private static function stored_cache_opts(): array { | |
| 8829 | + $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() ); | |
| 8830 | + return is_array( $stored ) ? $stored : array(); | |
| 8831 | + } | |
| 8832 | + | |
| 8833 | + /** | |
| 8834 | + * Strict truthiness for the LiteSpeed Static Fast Path opt-in. | |
| 8835 | + * | |
| 8836 | + * On non-LiteSpeed servers the key is out of the schema and carried by | |
| 8837 | + * preserved_keys(), so a REST/MCP write lands VERBATIM — QA on #513 | |
| 8838 | + * stored the string "false" on Apache and the fast path installed | |
| 8839 | + * itself the moment the site moved to LiteSpeed, because | |
| 8840 | + * empty("false") is false. Only an explicit, unambiguous "yes" may | |
| 8841 | + * enable a path that trades away hit tagging; any other value — | |
| 8842 | + * "false", "no", arbitrary junk — stays OFF, which is the default the | |
| 8843 | + * user never left. | |
| 8844 | + */ | |
| 8845 | + private static function litespeed_optin_enabled( $value ): bool { | |
| 8846 | + if ( true === $value || 1 === $value ) { | |
| 8847 | + return true; | |
| 8848 | + } | |
| 8849 | + return is_string( $value ) | |
| 8850 | + && in_array( strtolower( trim( $value ) ), array( '1', 'true', 'on', 'yes' ), true ); | |
| 8851 | + } | |
| 8852 | + | |
| 4803 | 8853 | public static function sync_mobile_flag( $enabled = null ): void { |
| 4804 | 8854 | if ( null === $enabled ) { |
| 4805 | - $opts = Settings_Manager::get( 'cache' ); | |
| 4806 | - $enabled = ! empty( $opts['mobile_separate'] ); | |
| 8855 | + $stored = self::stored_cache_opts(); | |
| 8856 | + $enabled = ! empty( $stored['mobile_separate'] ); | |
| 4807 | 8857 | } |
| 4808 | 8858 | $dir = XSPEED_CACHE_DIR; |
| 4809 | 8859 | $flag = $dir . '/.mobile-separate'; |
| 4810 | 8860 | if ( $enabled ) { |
| @@ -4908,9 +8958,10 @@ | ||
| 4908 | 8958 | // Read the setting from the SAME place static_rewrite_allowed() and |
| 4909 | 8959 | // sync_mobile_flag() do — the cache module's settings, not the |
| 4910 | 8960 | // top-level xspeed_options — or this marker would track a key that |
| 4911 | 8961 | // never changes and a real flip would go unnoticed. |
| 4912 | - $cache_opts = Settings_Manager::get( 'cache' ); | |
| 8962 | + // Stored read — this runs from boot(); see stored_cache_opts(). | |
| 8963 | + $cache_opts = self::stored_cache_opts(); | |
| 4913 | 8964 | $mobile_now = ! empty( $cache_opts['mobile_separate'] ); |
| 4914 | 8965 | $mobile_last = get_option( 'xspeed_last_mobile_separate', null ); |
| 4915 | 8966 | $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now ); |
| 4916 | 8967 | |
| @@ -4972,12 +9023,24 @@ | ||
| 4972 | 9023 | * the truth there. (Apache keeps the static fast path — it honors the |
| 4973 | 9024 | * header.) See maybe_emit_lscache_headers() for the paired LSCache |
| 4974 | 9025 | * stand-down that stops LiteSpeed's own module from shadowing the |
| 4975 | 9026 | * drop-in. |
| 9027 | + * | |
| 9028 | + * Opt-in (#509): `litespeed_static_rewrite` re-enables the fast path on | |
| 9029 | + * LiteSpeed for users who value raw TTFB over hit accounting. The trade | |
| 9030 | + * is stated in the setting's copy: statically served hits carry no | |
| 9031 | + * X-XSpeed-Cache header and are not counted (LiteSpeed logs the | |
| 9032 | + * original request line, so even the access-log scan cannot see | |
| 9033 | + * them — see Hit_Counter::collect_server_log_hits()). The drop-in | |
| 9034 | + * default above stays — nobody is surprised into an unverifiable cache. | |
| 4976 | 9035 | */ |
| 4977 | 9036 | public static function static_rewrite_allowed(): bool { |
| 4978 | - // LiteSpeed: drop-in serves hits (visible + counted) — see docblock. | |
| 4979 | - if ( Server::LITESPEED === Server::type() ) { | |
| 9037 | + // Stored read — reached from boot(); see stored_cache_opts(). | |
| 9038 | + $opts = self::stored_cache_opts(); | |
| 9039 | + // LiteSpeed: drop-in serves hits (visible + counted) unless the user | |
| 9040 | + // explicitly opted into the static fast path — see docblock. | |
| 9041 | + if ( Server::LITESPEED === Server::type() | |
| 9042 | + && ! self::litespeed_optin_enabled( $opts['litespeed_static_rewrite'] ?? false ) ) { | |
| 4980 | 9043 | return false; |
| 4981 | 9044 | } |
| 4982 | 9045 | // Apache without mod_headers is in EXACTLY the position LiteSpeed |
| 4983 | 9046 | // is in above: it can run the RewriteRule and serve the static |
| @@ -4991,9 +9054,8 @@ | ||
| 4991 | 9054 | // pinned at 0% on a working Apache cache.) |
| 4992 | 9055 | if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { |
| 4993 | 9056 | return false; |
| 4994 | 9057 | } |
| 4995 | - $opts = Settings_Manager::get( 'cache' ); | |
| 4996 | 9058 | return empty( $opts['mobile_separate'] ); |
| 4997 | 9059 | } |
| 4998 | 9060 | |
| 4999 | 9061 | /** |
| @@ -5035,10 +9097,17 @@ | ||
| 5035 | 9097 | * path is the pasted snippet and there is no .htaccess marker to find, so |
| 5036 | 9098 | * requiring one would report every correctly-configured nginx site as |
| 5037 | 9099 | * broken. |
| 5038 | 9100 | * |
| 9101 | + * Carries `rules` through as well. The dashboard bootstrap builds its own | |
| 9102 | + * payload and so had it; every other caller — POST /cache/recheck-rewrite, | |
| 9103 | + * `wp xspeed cache recheck-rewrite`, the recheck_rewrite_rules MCP tool — | |
| 9104 | + * came through here and got four keys, so the one surface a user reaches | |
| 9105 | + * AFTER pasting the block could not tell them whether the block took. An | |
| 9106 | + * agent driving the same fix could not read it at all. | |
| 9107 | + * | |
| 5039 | 9108 | * @param array $probe Raw result from probe_static_rewrite(). |
| 5040 | - * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string} | |
| 9109 | + * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string,rules:array} | |
| 5041 | 9110 | */ |
| 5042 | 9111 | public static function qualify_rewrite_probe( array $probe ): array { |
| 5043 | 9112 | $active = (bool) ( $probe['active'] ?? false ); |
| 5044 | 9113 | $inconclusive = (bool) ( $probe['inconclusive'] ?? false ); |
| @@ -5043,8 +9112,9 @@ | ||
| 5043 | 9112 | $active = (bool) ( $probe['active'] ?? false ); |
| 5044 | 9113 | $inconclusive = (bool) ( $probe['inconclusive'] ?? false ); |
| 5045 | 9114 | $reason = (string) ( $probe['reason'] ?? '' ); |
| 5046 | 9115 | $block_reason = self::static_rewrite_block_reason(); |
| 9116 | + $rules = self::rules_state( $probe ); | |
| 5047 | 9117 | |
| 5048 | 9118 | // Same observed-refusal check Health makes. This is the shared path for |
| 5049 | 9119 | // `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite — |
| 5050 | 9120 | // and, because a CLI command is automatically an MCP tool, for the |
| @@ -5078,8 +9148,9 @@ | ||
| 5078 | 9148 | 'active' => false, |
| 5079 | 9149 | 'inconclusive' => false, |
| 5080 | 9150 | 'reason' => 'Page caching is off, so there is no cache for the web server to serve.', |
| 5081 | 9151 | 'block_reason' => '', |
| 9152 | + 'rules' => $rules, | |
| 5082 | 9153 | ); |
| 5083 | 9154 | } |
| 5084 | 9155 | |
| 5085 | 9156 | // A known refusal outranks the probe, and also outranks |
| @@ -5095,8 +9166,9 @@ | ||
| 5095 | 9166 | 'active' => $active, |
| 5096 | 9167 | 'inconclusive' => $inconclusive, |
| 5097 | 9168 | 'reason' => $reason, |
| 5098 | 9169 | 'block_reason' => $block_reason, |
| 9170 | + 'rules' => $rules, | |
| 5099 | 9171 | ); |
| 5100 | 9172 | } |
| 5101 | 9173 | |
| 5102 | 9174 | /** |
| @@ -5111,8 +9183,10 @@ | ||
| 5111 | 9183 | case 'mobile_separate': |
| 5112 | 9184 | 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.'; |
| 5113 | 9185 | case 'no_mod_headers': |
| 5114 | 9186 | 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."; |
| 9187 | + case 'litespeed_dropin': | |
| 9188 | + 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.'; | |
| 5115 | 9189 | case 'skipped_nonce': |
| 5116 | 9190 | 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.'; |
| 5117 | 9191 | default: |
| 5118 | 9192 | return sprintf( 'The static rewrite is disabled (%s).', $code ); |
| @@ -5131,9 +9205,25 @@ | ||
| 5131 | 9205 | if ( empty( $opts['cache_enabled'] ) ) { |
| 5132 | 9206 | return ''; |
| 5133 | 9207 | } |
| 5134 | 9208 | if ( Server::LITESPEED === Server::type() ) { |
| 5135 | - return ''; // Intended on LiteSpeed — not a "block". | |
| 9209 | + // The opt-in is read RAW (stored_cache_opts), not through | |
| 9210 | + // Settings_Manager::get(): the schema's bool coercion is a PHP | |
| 9211 | + // cast, and (bool) "false" is true — so a junk string stored on | |
| 9212 | + // another server (where the key bypasses the schema) would come | |
| 9213 | + // back from the coercion layer as an ENABLE. Raw + the strict | |
| 9214 | + // parse below is the same read static_rewrite_allowed() makes, | |
| 9215 | + // so the two can't disagree either. (QA on #513) | |
| 9216 | + $stored = self::stored_cache_opts(); | |
| 9217 | + // The intended default — but no longer silent: with the opt-in | |
| 9218 | + // off, Health must be able to explain the PHP path and point at | |
| 9219 | + // the toggle instead of falling through to "reinstall the block" | |
| 9220 | + // advice that cannot work here. (#509) | |
| 9221 | + if ( ! self::litespeed_optin_enabled( $stored['litespeed_static_rewrite'] ?? false ) ) { | |
| 9222 | + return 'litespeed_dropin'; | |
| 9223 | + } | |
| 9224 | + $cache_opts = Settings_Manager::get( 'cache' ); | |
| 9225 | + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : ''; | |
| 5136 | 9226 | } |
| 5137 | 9227 | if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { |
| 5138 | 9228 | return 'no_mod_headers'; |
| 5139 | 9229 | } |
| @@ -5226,9 +9316,11 @@ | ||
| 5226 | 9316 | 'redirection' => 2, |
| 5227 | 9317 | // Bust any per-device cache so we compare freshly-rendered |
| 5228 | 9318 | // HTML, and pass the device UA the site would branch on. |
| 5229 | 9319 | 'user-agent' => $ua, |
| 5230 | - 'headers' => array( 'Cache-Control' => 'no-cache' ), | |
| 9320 | + // A real device UA by design, so only the header marks | |
| 9321 | + // this as ours to analytics and the hit ratio. | |
| 9322 | + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ), | |
| 5231 | 9323 | ) |
| 5232 | 9324 | ); |
| 5233 | 9325 | if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) { |
| 5234 | 9326 | return null; |
| @@ -5497,11 +9589,16 @@ | ||
| 5497 | 9589 | // while a page was cold — on a warm page nginx served the shared |
| 5498 | 9590 | // anonymous copy to carts, members and bypassed bots alike. The |
| 5499 | 9591 | // three historical names survive as a floor inside cookie_rule(). |
| 5500 | 9592 | // `~*` is case-insensitive, matching PHP's stripos()/glob checks. |
| 5501 | - $cache_opts = Settings_Manager::get( 'cache' ); | |
| 9593 | + // Stored read — reached from boot(); see stored_cache_opts(). The | |
| 9594 | + // fallbacks below mirror the schema's own defaults, which a raw read | |
| 9595 | + // does not apply. | |
| 9596 | + $cache_opts = self::stored_cache_opts(); | |
| 5502 | 9597 | $cookie_rule = Server_Rules::cookie_rule( |
| 5503 | - is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array() | |
| 9598 | + is_array( $cache_opts['excluded_cookies'] ?? null ) | |
| 9599 | + ? $cache_opts['excluded_cookies'] | |
| 9600 | + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXCLUDED_COOKIES | |
| 5504 | 9601 | ); |
| 5505 | 9602 | $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }'; |
| 5506 | 9603 | |
| 5507 | 9604 | $ua_rule = Server_Rules::user_agent_rule( |
| @@ -5562,10 +9659,41 @@ | ||
| 5562 | 9659 | // missing. So: hits are logged, and a user deleting the log can't take |
| 5563 | 9660 | // nginx down. |
| 5564 | 9661 | $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;'; |
| 5565 | 9662 | $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;'; |
| 9663 | + // Edge/CDN headers from the same seam the drop-in bakes. nginx serves | |
| 9664 | + // this path without ever starting PHP, so the answer cannot be | |
| 9665 | + // resolved per request — the pairs are resolved HERE, when the | |
| 9666 | + // snippet is generated, and a change of answer needs the snippet | |
| 9667 | + // regenerated and re-pasted to take effect. | |
| 9668 | + // | |
| 9669 | + // Skipped entirely when the static path is switched off. The only | |
| 9670 | + // reason that can fire under `bake` is mobile-split, and mobile-split | |
| 9671 | + // is also what switches the static path off — so the block would be | |
| 9672 | + // baked with a hold it can never serve, and would start serving it | |
| 9673 | + // the moment the setting is turned off and static files reappear, | |
| 9674 | + // until somebody regenerates and re-pastes. A rule that can only be | |
| 9675 | + // served once its premise is false is guaranteed to be stale. | |
| 9676 | + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'rules' ) : array() as $name => $value ) { | |
| 9677 | + $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;'; | |
| 9678 | + } | |
| 5566 | 9679 | $lines[] = '}'; |
| 5567 | - return implode( "\n", $lines ); | |
| 9680 | + | |
| 9681 | + // Stamp the block with a hash of itself. This is the only way to find | |
| 9682 | + // out what a user actually pasted: the block lives in a server config | |
| 9683 | + // WordPress cannot read, so until now the dashboard could not tell an | |
| 9684 | + // up-to-date paste from one made three settings changes ago, and | |
| 9685 | + // covered for that by telling everyone to re-paste after every save. | |
| 9686 | + // A hit through this location now carries the version that served it | |
| 9687 | + // and probe_static_rewrite() reads it back. See rules_state(). | |
| 9688 | + return implode( | |
| 9689 | + "\n", | |
| 9690 | + self::with_rules_marker( | |
| 9691 | + $lines, | |
| 9692 | + array( ' add_header ' . self::RULES_HEADER . ' "%s" always;' ), | |
| 9693 | + count( $lines ) - 1 | |
| 9694 | + ) | |
| 9695 | + ); | |
| 5568 | 9696 | } |
| 5569 | 9697 | |
| 5570 | 9698 | /** |
| 5571 | 9699 | * Aggregate every enabled module's nginx_directives() into one |
| @@ -5681,9 +9809,9 @@ | ||
| 5681 | 9809 | if ( empty( $opts['cache_enabled'] ) ) { |
| 5682 | 9810 | return false; |
| 5683 | 9811 | } |
| 5684 | 9812 | |
| 5685 | - $state = self::toggle( true ); | |
| 9813 | + $state = self::toggle( true, false ); | |
| 5686 | 9814 | // A refusal reports whether the cache SERVES, which on this path can |
| 5687 | 9815 | // be true for reasons that have nothing to do with this call — so a |
| 5688 | 9816 | // refusal would otherwise log "drop-in restored" for a restore that |
| 5689 | 9817 | // was declined. Restored means the transaction went through. |
| @@ -5723,9 +9851,9 @@ | ||
| 5723 | 9851 | if ( empty( $opts['cache_enabled'] ) ) { |
| 5724 | 9852 | return; |
| 5725 | 9853 | } |
| 5726 | 9854 | |
| 5727 | - $state = self::toggle( true ); | |
| 9855 | + $state = self::toggle( true, false ); | |
| 5728 | 9856 | // A refusal means something else now owns the page-cache field, or |
| 5729 | 9857 | // the write could not be verified. Either way this is not the moment |
| 5730 | 9858 | // to go on maintaining our rewrite block and log file. |
| 5731 | 9859 | if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) { |
| @@ -5856,9 +9984,9 @@ | ||
| 5856 | 9984 | // so the closing quote here cannot be escaped away. |
| 5857 | 9985 | $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]'; |
| 5858 | 9986 | } |
| 5859 | 9987 | |
| 5860 | - return array_merge( | |
| 9988 | + $block = array_merge( | |
| 5861 | 9989 | $lines, |
| 5862 | 9990 | array( |
| 5863 | 9991 | // Capture REQUEST_URI without its trailing slash into %1. |
| 5864 | 9992 | // store_static() writes `{host}{uri-without-trailing-slash}/index.html`, |
| @@ -5876,9 +10004,15 @@ | ||
| 5876 | 10004 | // `^` matches the empty string AND any non-empty path, so it |
| 5877 | 10005 | // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed |
| 5878 | 10006 | // 1.8: `.` → homepage served by PHP drop-in; `^` → served |
| 5879 | 10007 | // directly from the static file.) |
| 5880 | - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]', | |
| 10008 | + // `E=` tags the request the rewrite just served from the cache | |
| 10009 | + // tree. The edge headers below key off it instead of the file | |
| 10010 | + // name: <FilesMatch "\.html$"> in a document-root .htaccess | |
| 10011 | + // matches EVERY .html on the site — a hand-uploaded /promo.html, | |
| 10012 | + // a static export — and telling a CDN to hold those for the page | |
| 10013 | + // TTL would pin files xSpeed never wrote and cannot purge. | |
| 10014 | + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]', | |
| 5881 | 10015 | '</IfModule>', |
| 5882 | 10016 | // Mark the statically-served response as a cache HIT. |
| 5883 | 10017 | // |
| 5884 | 10018 | // A file served by the rewrite above bypasses PHP entirely, so |
| @@ -5900,11 +10034,53 @@ | ||
| 5900 | 10034 | '<IfModule mod_headers.c>', |
| 5901 | 10035 | ' <FilesMatch "\\.html$">', |
| 5902 | 10036 | ' Header always set X-XSpeed-Cache "HIT (static)"', |
| 5903 | 10037 | ' </FilesMatch>', |
| 5904 | - '</IfModule>', | |
| 5905 | 10038 | ) |
| 5906 | 10039 | ); |
| 10040 | + | |
| 10041 | + // Edge/CDN headers from the same seam the drop-in bakes. Like the | |
| 10042 | + // nginx snippet, the static rewrite answers without PHP, so the pairs | |
| 10043 | + // are resolved when the block is GENERATED rather than per request. | |
| 10044 | + // | |
| 10045 | + // `env=` rather than the `<FilesMatch>` scoping above, because these | |
| 10046 | + // must ride only on responses the rewrite produced. The X-XSpeed-Cache | |
| 10047 | + // marker stays filename-scoped: it is inert, and narrowing it would | |
| 10048 | + // change a header QA reads. | |
| 10049 | + // | |
| 10050 | + // Same reasoning as the nginx snippet: a bake hold can only come from | |
| 10051 | + // mobile-split, and mobile-split is what turns this path off, so a | |
| 10052 | + // hold baked here could only ever be served once its own premise had | |
| 10053 | + // stopped being true. | |
| 10054 | + $edge_lines = array(); | |
| 10055 | + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'rules' ) : array() as $edge_name => $edge_value ) { | |
| 10056 | + $edge_lines = array_merge( | |
| 10057 | + $edge_lines, | |
| 10058 | + self::static_hit_directives( | |
| 10059 | + ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"' | |
| 10060 | + ) | |
| 10061 | + ); | |
| 10062 | + } | |
| 10063 | + | |
| 10064 | + $block = array_merge( $block, $edge_lines, array( '</IfModule>' ) ); | |
| 10065 | + | |
| 10066 | + // Same self-describing marker as the nginx snippet. Apache's block is | |
| 10067 | + // written by us rather than pasted by hand, so it should never be out | |
| 10068 | + // of date — but "should" is what refresh_rewrite_if_installed() not | |
| 10069 | + // running looks like from the outside, and the probe can now say so | |
| 10070 | + // instead of assuming. `env=` keeps it on responses the rewrite | |
| 10071 | + // produced, so a hand-uploaded .html never claims to be our cache. | |
| 10072 | + // | |
| 10073 | + // Spliced in after the edge headers so the hash covers them: a | |
| 10074 | + // changed edge answer has to make an installed block report itself | |
| 10075 | + // stale, and a marker computed before they were appended would not | |
| 10076 | + // move. The splice point steps back over them and `</IfModule>` to | |
| 10077 | + // land inside `<FilesMatch>`, beside the X-XSpeed-Cache marker. | |
| 10078 | + return self::with_rules_marker( | |
| 10079 | + $block, | |
| 10080 | + self::static_hit_directives( ' Header always set ' . self::RULES_HEADER . ' "%s"' ), | |
| 10081 | + count( $block ) - count( $edge_lines ) - 2 | |
| 10082 | + ); | |
| 5907 | 10083 | } |
| 5908 | 10084 | |
| 5909 | 10085 | /** |
| 5910 | 10086 | * Active probe that confirms the web-server static-rewrite path is |
| @@ -5959,9 +10135,12 @@ | ||
| 5959 | 10135 | |
| 5960 | 10136 | $home = home_url( '/' ); |
| 5961 | 10137 | $host = (string) wp_parse_url( $home, PHP_URL_HOST ); |
| 5962 | 10138 | if ( '' === $host ) { |
| 5963 | - $result = array( 'active' => false, 'reason' => 'home_url has no host' ); | |
| 10139 | + // Environmental failure, not evidence the server config is wrong — | |
| 10140 | + // mark it inconclusive so Health surfaces say "could not verify" | |
| 10141 | + // instead of demanding a snippet paste. (#480) | |
| 10142 | + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'home_url has no host' ); | |
| 5964 | 10143 | set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 5965 | 10144 | return $result; |
| 5966 | 10145 | } |
| 5967 | 10146 | |
| @@ -5978,9 +10157,12 @@ | ||
| 5978 | 10157 | if ( ! file_exists( $probe_dir ) ) { |
| 5979 | 10158 | wp_mkdir_p( $probe_dir ); |
| 5980 | 10159 | } |
| 5981 | 10160 | if ( ! is_dir( $probe_dir ) ) { |
| 5982 | - $result = array( 'active' => false, 'reason' => 'cannot create probe dir' ); | |
| 10161 | + // A cache-dir permissions problem — the probe never ran, so this | |
| 10162 | + // says nothing about the nginx config. Inconclusive, not | |
| 10163 | + // "required". (#480) | |
| 10164 | + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'cannot create probe dir' ); | |
| 5983 | 10165 | set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 5984 | 10166 | return $result; |
| 5985 | 10167 | } |
| 5986 | 10168 | // 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. |
| @@ -6000,9 +10182,9 @@ | ||
| 6000 | 10182 | // don't repeat the wait every minute. |
| 6001 | 10183 | 'timeout' => 3, |
| 6002 | 10184 | 'sslverify' => ! $is_local, |
| 6003 | 10185 | 'redirection' => 0, |
| 6004 | - 'headers' => array( 'Cache-Control' => 'no-cache' ), | |
| 10186 | + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ), | |
| 6005 | 10187 | ) |
| 6006 | 10188 | ); |
| 6007 | 10189 | |
| 6008 | 10190 | // Best-effort cleanup so we don't accumulate probe dirs even |
| @@ -6038,8 +10220,17 @@ | ||
| 6038 | 10220 | $ua_php = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' ); |
| 6039 | 10221 | $has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' ) |
| 6040 | 10222 | || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' ); |
| 6041 | 10223 | $match = trim( $body ) === $nonce; |
| 10224 | + // Which version of our generated rules answered, if any. Only the | |
| 10225 | + // static path can set this — it is baked into the rules themselves — | |
| 10226 | + // so its presence is direct evidence about what is installed, and its | |
| 10227 | + // absence on a conclusive probe is evidence too. See rules_state(). | |
| 10228 | + // Validated to the shape we generate, so a proxy or another plugin | |
| 10229 | + // sending something else under this name cannot be mistaken for a | |
| 10230 | + // rules version and reported as "out of date". | |
| 10231 | + $rules_raw = trim( (string) wp_remote_retrieve_header( $resp, strtolower( self::RULES_HEADER ) ) ); | |
| 10232 | + $rules = preg_match( '/^[0-9a-f]{8}\z/', $rules_raw ) ? $rules_raw : ''; | |
| 6042 | 10233 | |
| 6043 | 10234 | // "Active" = the web server served our raw nonce bytes back |
| 6044 | 10235 | // AND emitted the static-serve markers (ETag / Last-Modified) |
| 6045 | 10236 | // AND didn't add an X-Powered-By: PHP header. All three are |
| @@ -6078,8 +10269,9 @@ | ||
| 6078 | 10269 | 'inconclusive' => $inconclusive, |
| 6079 | 10270 | 'reason' => $reason, |
| 6080 | 10271 | 'code' => $code, |
| 6081 | 10272 | 'php' => $ua_php, |
| 10273 | + 'rules' => $rules, | |
| 6082 | 10274 | ); |
| 6083 | 10275 | set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS ); |
| 6084 | 10276 | return $result; |
| 6085 | 10277 | } |
| @@ -6245,8 +10437,14 @@ | ||
| 6245 | 10437 | /** No drop-in installed. */ |
| 6246 | 10438 | public const DROPIN_NONE = 'none'; |
| 6247 | 10439 | /** A drop-in is installed and we could not read it. */ |
| 6248 | 10440 | public const DROPIN_UNREADABLE = 'unreadable'; |
| 10441 | + /** | |
| 10442 | + * Present but holding nothing -- empty, or whitespace only. WP Rocket | |
| 10443 | + * truncates advanced-cache.php to 0 bytes on deactivate, and calling that | |
| 10444 | + * FOREIGN made it a permanent blocker with no owner to ask. (#391) | |
| 10445 | + */ | |
| 10446 | + public const DROPIN_ABANDONED = 'abandoned'; | |
| 6249 | 10447 | |
| 6250 | 10448 | /** |
| 6251 | 10449 | * Who owns wp-content/advanced-cache.php right now. |
| 6252 | 10450 | * |
| @@ -6269,11 +10467,45 @@ | ||
| 6269 | 10467 | if ( null === $contents ) { |
| 6270 | 10468 | return self::DROPIN_UNREADABLE; |
| 6271 | 10469 | } |
| 6272 | 10470 | |
| 6273 | - return xspeed_has_canonical_dropin_signature( $contents ) | |
| 6274 | - ? self::DROPIN_XSPEED | |
| 6275 | - : self::DROPIN_FOREIGN; | |
| 10471 | + if ( xspeed_has_canonical_dropin_signature( $contents ) ) { | |
| 10472 | + return self::DROPIN_XSPEED; | |
| 10473 | + } | |
| 10474 | + | |
| 10475 | + // Nothing in the file means nothing owns it. Kept distinct from | |
| 10476 | + // FOREIGN so the acquisition gate can tell "someone else's cache" from | |
| 10477 | + // "a husk the last plugin left behind". (#391) | |
| 10478 | + if ( '' === trim( $contents ) ) { | |
| 10479 | + return self::DROPIN_ABANDONED; | |
| 10480 | + } | |
| 10481 | + | |
| 10482 | + /* | |
| 10483 | + * The other half of the same question, and it cannot be answered from | |
| 10484 | + * the bytes: a file we cannot attribute is a COMPETITOR only while | |
| 10485 | + * some page cache is actually running. With every candidate switched | |
| 10486 | + * off it is abandoned -- a hosting company's own cache, a hand-rolled | |
| 10487 | + * one, or a plugin that was deleted without cleaning up. | |
| 10488 | + * | |
| 10489 | + * Asking the detector rather than re-deriving it here is the point: | |
| 10490 | + * these two answers disagreeing is a split brain with a bad ending -- | |
| 10491 | + * acquisition_blocker() opens the gate, install_dropin() then refuses | |
| 10492 | + * on FOREIGN, and toggle() blames the filesystem for a write it never | |
| 10493 | + * attempted. One question, one answer. (#391, #393) | |
| 10494 | + */ | |
| 10495 | + if ( class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) { | |
| 10496 | + $owner = (string) ( Page_Cache_Detector::inspect()['dropin']['owner'] ?? '' ); | |
| 10497 | + | |
| 10498 | + // Attributable to a named plugin -> somebody's cache, whatever its | |
| 10499 | + // activation state. Only a file NOBODY can be shown to own, with | |
| 10500 | + // nothing running, is abandoned. | |
| 10501 | + if ( Page_Cache_Detector::OWNER_UNKNOWN === $owner | |
| 10502 | + && ! Page_Cache_Detector::another_page_cache_is_active() ) { | |
| 10503 | + return self::DROPIN_ABANDONED; | |
| 10504 | + } | |
| 10505 | + } | |
| 10506 | + | |
| 10507 | + return self::DROPIN_FOREIGN; | |
| 6276 | 10508 | } |
| 6277 | 10509 | |
| 6278 | 10510 | /** |
| 6279 | 10511 | * Why xSpeed must not install its page-cache artifacts right now, or null |
| @@ -6327,8 +10559,45 @@ | ||
| 6327 | 10559 | if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) { |
| 6328 | 10560 | continue; |
| 6329 | 10561 | } |
| 6330 | 10562 | /* |
| 10563 | + * Another plugin's drop-in is no longer a refusal. | |
| 10564 | + * | |
| 10565 | + * It used to be: whoever held advanced-cache.php kept it, and | |
| 10566 | + * enabling was blocked with "deactivate its page cache first". | |
| 10567 | + * That left a user who had asked for our cache with no way to get | |
| 10568 | + * it — on a live site the only exit was deleting a file over SSH, | |
| 10569 | + * and the message could not even say which of its two causes | |
| 10570 | + * applied ("is active OR owns advanced-cache.php"). | |
| 10571 | + * | |
| 10572 | + * Turning the page cache on is the instruction to serve pages | |
| 10573 | + * from cache, and that is not possible without this file. So we | |
| 10574 | + * take it, and the dashboard says whose file it is first — | |
| 10575 | + * dropin_disclosure() names the owner, the user confirms, and | |
| 10576 | + * install_dropin() writes ours over the top. | |
| 10577 | + * | |
| 10578 | + * A still-active competitor is deliberately NOT re-added as a | |
| 10579 | + * blocker below: it is caught by `active_page_cache`, which the | |
| 10580 | + * capability rule already downgrades to a note. Two page caches | |
| 10581 | + * installed at once is the user's call to make, not ours to | |
| 10582 | + * refuse — they just told us which one they want serving. | |
| 10583 | + * | |
| 10584 | + * UNREADABLE is the exception and stays a refusal: we cannot name | |
| 10585 | + * what we would destroy, and install_dropin() refuses it too, so | |
| 10586 | + * opening the gate here would only produce a failed write. | |
| 10587 | + */ | |
| 10588 | + $about_dropin_owner = in_array( | |
| 10589 | + $code, | |
| 10590 | + array( | |
| 10591 | + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN, | |
| 10592 | + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN, | |
| 10593 | + ), | |
| 10594 | + true | |
| 10595 | + ); | |
| 10596 | + if ( $about_dropin_owner && self::DROPIN_UNREADABLE !== $owner ) { | |
| 10597 | + continue; | |
| 10598 | + } | |
| 10599 | + /* | |
| 6331 | 10600 | * Capability is not possession. `active_page_cache` and |
| 6332 | 10601 | * `multiple_page_caches` both fire on a plugin that merely CAN |
| 6333 | 10602 | * cache pages — the detector cannot prove a competitor's page |
| 6334 | 10603 | * cache is off, so it counts it. As a warning that is right. As |
| @@ -6355,9 +10624,25 @@ | ||
| 6355 | 10624 | Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES, |
| 6356 | 10625 | ), |
| 6357 | 10626 | true |
| 6358 | 10627 | ); |
| 6359 | - if ( $about_capability && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE ), true ) ) { | |
| 10628 | + /* | |
| 10629 | + * FOREIGN belongs in this list now, and it is the whole point. | |
| 10630 | + * | |
| 10631 | + * The rule is still "capability is not possession": these two | |
| 10632 | + * blockers fire on any plugin that CAN cache pages, which the | |
| 10633 | + * detector cannot prove is switched off. What changed is that a | |
| 10634 | + * competitor holding the drop-in no longer stops us either — we | |
| 10635 | + * take the file, having said whose it is. So there is nothing | |
| 10636 | + * left for a merely-installed competitor to protect, and keeping | |
| 10637 | + * the refusal here would put back the dead end by another route: | |
| 10638 | + * "another page cache is active" on a site where the user has | |
| 10639 | + * just told us, by name, which cache they want serving. | |
| 10640 | + * | |
| 10641 | + * UNREADABLE is deliberately still absent — that one refuses. | |
| 10642 | + */ | |
| 10643 | + if ( $about_capability | |
| 10644 | + && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE, self::DROPIN_FOREIGN, self::DROPIN_ABANDONED ), true ) ) { | |
| 6360 | 10645 | continue; |
| 6361 | 10646 | } |
| 6362 | 10647 | if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) { |
| 6363 | 10648 | $others = self::other_page_cache_names( $blocker ); |
| @@ -6561,14 +10846,22 @@ | ||
| 6561 | 10846 | return false; |
| 6562 | 10847 | } |
| 6563 | 10848 | |
| 6564 | 10849 | /* |
| 6565 | - * Ownership first, before any of the work below. WordPress gives every | |
| 6566 | - * caching plugin the same single file, so a drop-in that is not ours is | |
| 6567 | - * another plugin's live cache — refuse rather than replace it. | |
| 10850 | + * A drop-in we cannot READ is the one thing still refused here. Not | |
| 10851 | + * because of who owns it — we no longer refuse on ownership — but | |
| 10852 | + * because an unreadable file is usually a permissions problem, and | |
| 10853 | + * writing over it would fail anyway or destroy something we were | |
| 10854 | + * never able to look at. | |
| 10855 | + * | |
| 10856 | + * Everything else is ours to take. Enabling the page cache IS the | |
| 10857 | + * user's instruction to serve the cache, and serving it means holding | |
| 10858 | + * advanced-cache.php; the dashboard says whose file it is replacing | |
| 10859 | + * before the click (Page_Cache_Detector::dropin_disclosure()), so the | |
| 10860 | + * takeover is consented rather than silent. | |
| 6568 | 10861 | */ |
| 6569 | 10862 | $owner = self::dropin_owner(); |
| 6570 | - if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) { | |
| 10863 | + if ( self::DROPIN_UNREADABLE === $owner ) { | |
| 6571 | 10864 | return false; |
| 6572 | 10865 | } |
| 6573 | 10866 | |
| 6574 | 10867 | global $wp_filesystem; |
| @@ -6623,8 +10916,16 @@ | ||
| 6623 | 10916 | '@@XSPEED_UA_RE@@', |
| 6624 | 10917 | str_replace( "'", "\\'", $ua_rule['regex'] ), |
| 6625 | 10918 | $source_contents |
| 6626 | 10919 | ); |
| 10920 | + // Which user agents must not count toward the hit ratio. Built here | |
| 10921 | + // because `xspeed_self_user_agents` is a filter the drop-in cannot | |
| 10922 | + // call. A renamed warmer is caught by Self_Traffic::HEADER instead. | |
| 10923 | + $source_contents = str_replace( | |
| 10924 | + '@@XSPEED_HIT_EXCLUDE_RE@@', | |
| 10925 | + str_replace( "'", "\\'", Hit_Counter::excluded_ua_regex() ), | |
| 10926 | + $source_contents | |
| 10927 | + ); | |
| 6627 | 10928 | |
| 6628 | 10929 | /* |
| 6629 | 10930 | * Ours or absent — the ownership gate at the top of this method ruled |
| 6630 | 10931 | * out everything else. The old code path that moved a foreign drop-in |
| @@ -6653,8 +10954,31 @@ | ||
| 6653 | 10954 | (string) ( $expiry_hours * HOUR_IN_SECONDS ), |
| 6654 | 10955 | $source_contents |
| 6655 | 10956 | ); |
| 6656 | 10957 | |
| 10958 | + // Bake the site-wide edge answer in. Resolved in a `bake` context, so | |
| 10959 | + // nothing per-page and nothing a request header vouched for can reach | |
| 10960 | + // it: a bake runs once, in an admin or CLI request, and answers for | |
| 10961 | + // every page on the site. A page that disagrees gets a sidecar | |
| 10962 | + // instead — see per_entry_edge_headers(). | |
| 10963 | + // | |
| 10964 | + // Re-baked on every cache settings save (see CacheModule::boot), | |
| 10965 | + // exactly like the cookie, user-agent and lifetime rules above. | |
| 10966 | + $source_contents = str_replace( | |
| 10967 | + "'@@XSPEED_EDGE_HEADERS@@'", | |
| 10968 | + self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ), | |
| 10969 | + $source_contents | |
| 10970 | + ); | |
| 10971 | + | |
| 10972 | + // And the answer for the same HIT served for a URL with an ignored | |
| 10973 | + // param in it, which the drop-in sends instead of the one above. An | |
| 10974 | + // empty array means the two agree. See query_variant_edge_headers(). | |
| 10975 | + $source_contents = str_replace( | |
| 10976 | + "'@@XSPEED_EDGE_QUERY_HOLD@@'", | |
| 10977 | + self::edge_headers_literal( self::query_variant_edge_headers() ), | |
| 10978 | + $source_contents | |
| 10979 | + ); | |
| 10980 | + | |
| 6657 | 10981 | if ( file_exists( $target ) ) { |
| 6658 | 10982 | $existing = $wp_filesystem->get_contents( $target ); |
| 6659 | 10983 | if ( is_string( $existing ) && $existing === $source_contents ) { |
| 6660 | 10984 | return true; |
| @@ -6660,11 +10984,50 @@ | ||
| 6660 | 10984 | return true; |
| 6661 | 10985 | } |
| 6662 | 10986 | } |
| 6663 | 10987 | |
| 6664 | - return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE ); | |
| 10988 | + $written = (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE ); | |
| 10989 | + if ( $written ) { | |
| 10990 | + self::forget_compiled_dropin( $target ); | |
| 10991 | + } | |
| 10992 | + return $written; | |
| 6665 | 10993 | } |
| 6666 | 10994 | |
| 10995 | + /** | |
| 10996 | + * Callable that drops a script's compiled copy. Replaced by tests only; | |
| 10997 | + * opcache_invalidate() is a PHP internal that cannot be stubbed. | |
| 10998 | + * | |
| 10999 | + * @var callable|null | |
| 11000 | + */ | |
| 11001 | + private static $opcache_invalidate = null; | |
| 11002 | + | |
| 11003 | + /** | |
| 11004 | + * Make PHP compile the new drop-in on the next request. | |
| 11005 | + * | |
| 11006 | + * opcache keeps the compiled drop-in and checks the file's timestamp at | |
| 11007 | + * most every `opcache.revalidate_freq` seconds (60 on lenzora.site), or | |
| 11008 | + * never with `validate_timestamps` off. So a re-bake, such as the one | |
| 11009 | + * that follows a change to "CDN or proxy in front", kept serving the old | |
| 11010 | + * edge headers for up to a minute, or until PHP restarted. | |
| 11011 | + * | |
| 11012 | + * This reaches the opcache of the PHP process doing the write, which is | |
| 11013 | + * the web server's own when the bake runs in a page or REST request. A | |
| 11014 | + * WP-CLI bake has its own opcache, so the web server still waits for its | |
| 11015 | + * next timestamp check there. | |
| 11016 | + * | |
| 11017 | + * @param string $target Absolute path of the drop-in just written. | |
| 11018 | + */ | |
| 11019 | + private static function forget_compiled_dropin( string $target ): void { | |
| 11020 | + $invalidate = self::$opcache_invalidate; | |
| 11021 | + if ( null === $invalidate ) { | |
| 11022 | + if ( ! function_exists( 'opcache_invalidate' ) ) { | |
| 11023 | + return; | |
| 11024 | + } | |
| 11025 | + $invalidate = 'opcache_invalidate'; | |
| 11026 | + } | |
| 11027 | + @call_user_func( $invalidate, $target, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted (opcache.restrict_api); nothing to do either way. | |
| 11028 | + } | |
| 11029 | + | |
| 6667 | 11030 | public static function remove_dropin() { |
| 6668 | 11031 | $target = WP_CONTENT_DIR . '/advanced-cache.php'; |
| 6669 | 11032 | if ( ! file_exists( $target ) ) { |
| 6670 | 11033 | return; |
| @@ -6896,10 +11259,59 @@ | ||
| 6896 | 11259 | 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ), |
| 6897 | 11260 | ) |
| 6898 | 11261 | ); |
| 6899 | 11262 | |
| 6900 | - foreach ( self::purge_types() as $slug => $type ) { | |
| 6901 | - if ( empty( $type['visible'] ) ) { | |
| 11263 | + // Settings first, then the two whole-errand actions (Purge All, | |
| 11264 | + // Purge this URL), then the per-type items. The order is the one WP | |
| 11265 | + // Rocket uses, and it front-loads what people open this menu for: | |
| 11266 | + // nobody reaches for "Purge Object Cache" as often as they reach for | |
| 11267 | + // the page they are looking at. | |
| 11268 | + $wp_admin_bar->add_node( | |
| 11269 | + array( | |
| 11270 | + 'id' => 'xspeed-purge-settings', | |
| 11271 | + 'parent' => 'xspeed-purge', | |
| 11272 | + 'title' => esc_html__( 'Settings', 'xspeed' ), | |
| 11273 | + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ), | |
| 11274 | + ) | |
| 11275 | + ); | |
| 11276 | + | |
| 11277 | + $types = self::purge_types(); | |
| 11278 | + | |
| 11279 | + // 'all' is rendered out of band so the single-URL item can sit | |
| 11280 | + // directly under it. A filter that reorders or drops it is honoured: | |
| 11281 | + // the loop below skips whatever was emitted here. | |
| 11282 | + $emitted = array(); | |
| 11283 | + if ( ! empty( $types['all']['visible'] ) ) { | |
| 11284 | + $wp_admin_bar->add_node( | |
| 11285 | + array( | |
| 11286 | + 'id' => 'xspeed-purge-all', | |
| 11287 | + 'parent' => 'xspeed-purge', | |
| 11288 | + 'title' => esc_html( $types['all']['label'] ), | |
| 11289 | + 'href' => self::purge_type_url( 'all' ), | |
| 11290 | + ) | |
| 11291 | + ); | |
| 11292 | + $emitted['all'] = true; | |
| 11293 | + } | |
| 11294 | + | |
| 11295 | + // Only when the current screen is about one thing — a front-end view, | |
| 11296 | + // or a published post's edit screen. On a list table or a settings | |
| 11297 | + // page there is nothing for "this" to mean, so the item stays hidden | |
| 11298 | + // rather than silently targeting the dashboard. Purge_Ui decides both | |
| 11299 | + // the label and the scope, which differ between the two contexts. | |
| 11300 | + $context = Purge_Ui::context_node(); | |
| 11301 | + if ( null !== $context ) { | |
| 11302 | + $wp_admin_bar->add_node( | |
| 11303 | + array( | |
| 11304 | + 'id' => 'xspeed-purge-this-url', | |
| 11305 | + 'parent' => 'xspeed-purge', | |
| 11306 | + 'title' => esc_html( $context['title'] ), | |
| 11307 | + 'href' => $context['href'], | |
| 11308 | + ) | |
| 11309 | + ); | |
| 11310 | + } | |
| 11311 | + | |
| 11312 | + foreach ( $types as $slug => $type ) { | |
| 11313 | + if ( empty( $type['visible'] ) || isset( $emitted[ $slug ] ) ) { | |
| 6902 | 11314 | continue; |
| 6903 | 11315 | } |
| 6904 | 11316 | $wp_admin_bar->add_node( |
| 6905 | 11317 | array( |
| @@ -6934,11 +11346,34 @@ | ||
| 6934 | 11346 | // Only honour known types; anything else falls back to a full purge. |
| 6935 | 11347 | if ( ! array_key_exists( $type, self::purge_types() ) ) { |
| 6936 | 11348 | $type = 'all'; |
| 6937 | 11349 | } |
| 11350 | + | |
| 11351 | + // Answer the browser BEFORE purging. "Purge All" fans out to the local | |
| 11352 | + // sweep, the object cache, CSS/edge listeners (outbound HTTP) and | |
| 11353 | + // third-party render caches, all in this one request — on a large site | |
| 11354 | + // that can outlive PHP-FPM's request_terminate_timeout, FPM kills the | |
| 11355 | + // worker mid-purge, and nginx answers the admin's click with a 502. | |
| 11356 | + // fastcgi_finish_request() exists on exactly those FPM setups: send | |
| 11357 | + // the redirect, close the connection, then keep purging in the same | |
| 11358 | + // process. Elsewhere (mod_php, CLI tests) fall back to purge-then- | |
| 11359 | + // redirect as before. | |
| 11360 | + $redirect = self::safe_purge_redirect( wp_get_referer() ); | |
| 11361 | + if ( function_exists( 'ignore_user_abort' ) ) { | |
| 11362 | + ignore_user_abort( true ); | |
| 11363 | + } | |
| 11364 | + if ( function_exists( 'set_time_limit' ) ) { | |
| 11365 | + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort under safe-mode-like restrictions. | |
| 11366 | + } | |
| 11367 | + if ( function_exists( 'fastcgi_finish_request' ) ) { | |
| 11368 | + wp_safe_redirect( $redirect ); | |
| 11369 | + fastcgi_finish_request(); | |
| 11370 | + self::purge_type( $type ); | |
| 11371 | + exit; | |
| 11372 | + } | |
| 11373 | + | |
| 6938 | 11374 | self::purge_type( $type ); |
| 6939 | - | |
| 6940 | - wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) ); | |
| 11375 | + wp_safe_redirect( $redirect ); | |
| 6941 | 11376 | exit; |
| 6942 | 11377 | } |
| 6943 | 11378 | |
| 6944 | 11379 | /** |