PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-cache.php +6374 -268 1.1.31.3.3 View file →
@@ -19,11 +19,158 @@
19 19 * @var int|null
20 20 */
21 21 private static $buffer_level = null;
22 22
23 + /**
24 + * Bytes freed by the current sweep, accumulated by sweep_delete().
25 + *
26 + * A counter rather than a return value because the two sweeps that free
27 + * the bytes — the flat glob loop and the recursive static walk — already
28 + * report a FILE count, and `wp xspeed purge` needs both numbers from a
29 + * single pass. Re-walking the tree to size it would double the I/O on
30 + * exactly the caches large enough for the number to matter.
31 + *
32 + * @var int
33 + */
34 + private static $sweep_bytes = 0;
35 +
36 + /**
37 + * The `X-XSpeed-Cache` value decided for this request, and — when the
38 + * decision was BYPASS — the slug of the gate that made it.
39 + *
40 + * Recorded as well as sent so unit tests (CLI SAPI, where header() is a
41 + * no-op and headers_sent() is meaningless) can assert on the decision.
42 + *
43 + * @var string
44 + */
45 + private static $status_header = '';
46 + private static $bypass_reason = '';
47 +
48 + /**
49 + * Edge/CDN headers decided for this request, after sanitising.
50 + *
51 + * Same reason as $status_header: header() cannot be observed from the CLI
52 + * SAPI, so the pairs we sent are recorded here too.
53 + *
54 + * @var array<string,string>
55 + */
56 + private static $edge_headers = array();
57 +
58 + /**
59 + * This entry's edge headers when they differ from the site-wide bake,
60 + * resolved once per store. Null until asked.
61 + *
62 + * @var array<string,string>|null
63 + */
64 + private static $per_entry_edge = null;
65 +
66 + /**
67 + * Cache key whose write was deferred to shutdown because a render-time
68 + * translation plugin's buffer wraps ours. Null on every ordinary request.
69 + *
70 + * @var string|null
71 + */
72 + private static $deferred_key = null;
73 +
74 + /**
75 + * Translated page HTML captured by the outer buffer, for the deferred
76 + * write. Only populated when a translation plugin is active.
77 + *
78 + * @var string
79 + */
80 + private static $translated_output = '';
81 +
82 + /**
83 + * Did finalize_buffer() run to completion on this request?
84 + *
85 + * The deferred translated write runs as a PHP shutdown function, which
86 + * fires after a `wp_die()` or a bare `exit()` exactly as it does after a
87 + * clean render. Only finalize_buffer() sets this, and only at the point
88 + * where it has the full buffer in hand — so an aborted render leaves it
89 + * false and the writer declines rather than caching a truncated page
90 + * under the real key.
91 + *
92 + * @var bool
93 + */
94 + private static $render_completed = false;
95 +
96 + /**
97 + * Hooks that get an argument-aware handler instead of a blanket purge.
98 + *
99 + * Each fires on an ordinary visitor action — an order, a review, a
100 + * registration — where purge_all() cannot see WHAT changed and so wiped
101 + * the whole cache on every one. They are re-bound further down to
102 + * handlers that inspect the payload first.
103 + *
104 + * Listed here so the generic invalidation loop skips them. It binds a
105 + * closure (to name the cause), and a closure cannot be unbound by the
106 + * remove_action() pairs below — binding one would leave the coarse purge
107 + * running alongside its replacement and silently undo #243.
108 + */
109 + private const TARGETED_INVALIDATION_HOOKS = array(
110 + 'save_post',
111 + 'before_delete_post',
112 + 'trashed_post',
113 + 'comment_post',
114 + 'wp_set_comment_status',
115 + 'user_register',
116 + 'profile_update',
117 + );
118 +
23 119 public function __construct() {
24 - add_action( 'template_redirect', array( $this, 'maybe_start_cache' ), 0 );
120 + /**
121 + * When the page-cache output buffer opens.
122 + *
123 + * Filterable because buffer ORDER decides what gets cached. PHP's
124 + * output buffers are LIFO: the last one opened is innermost, and its
125 + * callback runs first. A render-time translation plugin that opens
126 + * an outer buffer therefore translates AFTER we have already captured
127 + * and cached the raw HTML — see translation_buffer_compat().
128 + *
129 + * @param string $hook Hook to open the buffer on.
130 + * @param int $priority Priority for that hook.
131 + */
132 + $hook = (string) apply_filters( 'xspeed_cache_buffer_hook', 'template_redirect' );
133 + $priority = (int) apply_filters( 'xspeed_cache_buffer_priority', 0 );
134 + add_action( $hook, array( $this, 'maybe_start_cache' ), $priority );
25 135
136 + // When a render-time translation plugin is present, open one extra
137 + // buffer OUTSIDE its own so we can capture post-translation HTML.
138 + // TranslatePress opens on `init` priority 0, so we take a negative
139 + // priority to land outside it. This buffer only collects bytes for
140 + // the deferred cache write — it never modifies the response.
141 + add_action(
142 + 'init',
143 + static function () {
144 + if ( ! self::translation_plugin_active() ) {
145 + return;
146 + }
147 + // `init` fires on EVERY request type, and
148 + // translation_plugin_active() is a class_exists() check that
149 + // is true site-wide — so without this guard the buffer opened
150 + // on REST, admin-ajax, cron and WP-CLI too. None of those
151 + // reach template_redirect, so $deferred_key stays null and
152 + // the collected bytes are never released: a long-running
153 + // WP-CLI command copied every byte of its output into a
154 + // string that grew for the life of the process.
155 + if ( is_admin()
156 + || wp_doing_ajax()
157 + || wp_doing_cron()
158 + || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
159 + || ( defined( 'WP_CLI' ) && WP_CLI )
160 + || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
161 + return;
162 + }
163 + ob_start(
164 + static function ( $chunk ) {
165 + self::$translated_output .= $chunk;
166 + return $chunk;
167 + }
168 + );
169 + },
170 + (int) apply_filters( 'xspeed_translation_outer_buffer_priority', -100 )
171 + );
172 +
26 173 // Events that should invalidate cached output. Beyond posts/comments,
27 174 // this covers user and term changes — the REST cache can serve
28 175 // /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect
29 176 // rendered author bylines / term-archive pages. Without them, an edit
@@ -29,9 +176,9 @@
29 176 // rendered author bylines / term-archive pages. Without them, an edit
30 177 // left the matching endpoint (and archives) stale for the full TTL.
31 178 // (FBS-82408)
32 179 $invalidate_hooks = array(
33 - 'save_post', 'deleted_post', 'trashed_post',
180 + 'save_post', 'before_delete_post', 'trashed_post',
34 181 'comment_post', 'wp_set_comment_status',
35 182 'switch_theme', 'activated_plugin', 'deactivated_plugin',
36 183 // Users → /wp/v2/users + author archives.
37 184 'profile_update', 'user_register', 'deleted_user',
@@ -36,16 +183,177 @@
36 183 // Users → /wp/v2/users + author archives.
37 184 'profile_update', 'user_register', 'deleted_user',
38 185 // Terms → /wp/v2/{taxonomy} + term archives.
39 186 'created_term', 'edited_term', 'delete_term',
187 + // Menu structure changes (reorder, rename, assign to a location)
188 + // fire only here — the per-item `nav_menu_item` save_post does
189 + // not cover them. (#270 regression)
190 + 'wp_update_nav_menu',
40 191 );
41 192 foreach ( $invalidate_hooks as $hook ) {
42 - add_action( $hook, array( __CLASS__, 'purge_all' ) );
193 + // Name the hook in the cause rather than binding purge_all bare.
194 + // Bound bare, WordPress passes the action's own first argument
195 + // into $cause — a term id, a user id, a menu id — so the activity
196 + // feed read "Cache purged (12)" and told the user nothing about
197 + // what happened. (#270 QA round 2)
198 + //
199 + // The four hooks that get an argument-aware handler below
200 + // (save_post, comment_post, user_register, profile_update) are
201 + // deliberately NOT wired here: a closure cannot be unbound by
202 + // remove_action(), so binding one would leave the coarse purge in
203 + // place alongside its replacement and silently undo #243. Skipping
204 + // them is equivalent — each is re-added with its own handler, and
205 + // each of those names its own cause.
206 + if ( in_array( $hook, self::TARGETED_INVALIDATION_HOOKS, true ) ) {
207 + continue;
208 + }
209 + add_action(
210 + $hook,
211 + static function () use ( $hook ): void {
212 + self::purge_all(
213 + 'hook:' . $hook,
214 + null,
215 + self::invalidation_for_hook( $hook )
216 + );
217 + }
218 + );
43 219 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
44 220 }
45 221
222 + // Updating a plugin, theme or core changes the markup and the assets
223 + // a page is built from, but fires NONE of the hooks above: WordPress
224 + // does not deactivate and reactivate a plugin to update it, so
225 + // `activated_plugin` never runs and the cached HTML survives the
226 + // update untouched for the whole TTL — up to 7 days on the Aggressive
227 + // preset, 30 at the maximum.
228 + //
229 + // The stale copy is not merely old, it is wrong in a way the user
230 + // cannot see the cause of: they update a plugin to get a fix, the
231 + // cache keeps serving the pre-fix HTML, and the update looks like it
232 + // did nothing. Minified assets do regenerate on their own (their key
233 + // includes the source filemtime), which makes it worse rather than
234 + // better — the cached pages still link the PREVIOUS hashes.
235 + //
236 + // Purge unconditionally on any completed update. Scoping it to
237 + // "plugins that enqueue front-end assets" is not knowable here, and a
238 + // cold cache after an update is the cheaper mistake. (#269)
239 + add_action( 'upgrader_process_complete', array( __CLASS__, 'purge_after_upgrade' ), 10, 2 );
240 + // The replacement signal has to outlive OUR listener: add-ons read it
241 + // through upgrade_replaced_code() from their own priority-10 callbacks,
242 + // and consuming it inside purge_after_upgrade() meant whoever
243 + // registered second saw false. Cleared at the END of the dispatch
244 + // instead, once every listener has had its turn.
245 + //
246 + // Depth-counted, because this action NESTS. Core hangs
247 + // Language_Pack_Upgrader::async_upgrade() on it at priority 20
248 + // (wp-admin/includes/admin-filters.php), and that runs a whole
249 + // upgrader of its own, which fires this same action again. A flat
250 + // reset therefore fired while the OUTER dispatch was still running —
251 + // on any site with pending translations — and every listener after
252 + // priority 20 read the cleared signal as false. Which is the bug this
253 + // pair exists to fix, back again and harder to see. (#303)
254 + add_action( 'upgrader_process_complete', array( __CLASS__, 'note_upgrade_dispatch' ), PHP_INT_MIN );
255 + add_action( 'upgrader_process_complete', array( __CLASS__, 'forget_cleared_destination' ), PHP_INT_MAX );
256 + // WordPress labels an upload-and-replace as an INSTALL, so the action
257 + // alone cannot tell "added beside nothing" from "replaced live code".
258 + // This filter fires only when the upgrader removed an existing copy,
259 + // which is exactly the difference. Registered as a filter listener
260 + // that returns its input untouched. (#303)
261 + add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 1 );
262 + // Unattended auto-updates are the case that matters most here: they
263 + // land overnight with nobody around to purge by hand, which is the
264 + // exact scenario the stale cache goes undiagnosed in. WordPress fires
265 + // this INSTEAD of a per-item upgrader_process_complete for some
266 + // background runs. Its payload is a results array keyed by type
267 + // rather than a hook_extra, so it needs its own handler — passing it
268 + // to purge_after_upgrade() landed it in the unused $upgrader slot and
269 + // left $type empty, which read as "invalidating" and purged the whole
270 + // cache for a language-pack-only run. Matches what LiteSpeed binds.
271 + // (#298)
272 + add_action( 'automatic_updates_complete', array( __CLASS__, 'purge_after_auto_updates' ), 10, 1 );
273 + // …except the four hooks above that fire on ordinary visitor actions.
274 + // Attached bare, purge_all() can't see WHAT changed, so on a store
275 + // every order, every product review and every checkout
276 + // account-creation wiped 100% of the cache — all anonymous happy-path
277 + // actions, so the cache never reached steady state (#243). Measured:
278 + // 3 orders across 36 pageviews took the hit rate from 83% to 50% and
279 + // the average response from 23ms to 57ms.
280 + //
281 + // HPOS does NOT help: WooCommerce still writes a
282 + // `shop_order_placehold` row into wp_posts to reserve the order ID,
283 + // so save_post fires either way. The gate therefore keys on POST-TYPE
284 + // VIEWABILITY, not on storage mode — which fixes both modes at once,
285 + // and generalises to Flamingo (#229) and Tutor LMS (#231) too.
286 + remove_action( 'save_post', array( __CLASS__, 'purge_all' ) );
287 + remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
288 + add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 );
289 + add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
290 + add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
291 + // wp_delete_post() hands an attachment to wp_delete_attachment() and
292 + // returns BEFORE before_delete_post fires, so deleting media reached
293 + // neither hook above. Attachment pages are public and media appears in
294 + // galleries, so that left cached pages showing a file that is gone.
295 + // (dev caught this via `deleted_post`, which this branch replaced.)
296 + add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 );
297 +
298 + remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) );
299 + remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
300 + add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 );
301 + add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 );
302 +
303 + remove_action( 'user_register', array( __CLASS__, 'purge_all' ) );
304 + remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) );
305 + add_action( 'user_register', array( __CLASS__, 'on_user_change' ) );
306 +
307 + remove_action( 'profile_update', array( __CLASS__, 'purge_all' ) );
308 + remove_action( 'profile_update', array( 'XSpeed\\Minifier', 'purge_minified' ) );
309 + add_action( 'profile_update', array( __CLASS__, 'on_user_change' ) );
310 +
311 + // Product data lives in post meta and lookup tables, NOT in wp_posts,
312 + // so WC_Product_Data_Store_CPT::update() takes a direct $wpdb->update()
313 + // branch and save_post never fires. Anchoring invalidation on
314 + // save_post therefore missed 100% of commerce-relevant mutations: a
315 + // REST price change, wc_update_product_stock(), a CLI ->save(), and
316 + // every scheduled sale start/end left the product page, the shop and
317 + // the category archives serving the old price and stock for the full
318 + // lifetime — the store quoting one price and charging another (#242).
319 + //
320 + // This MUST ship with the gate above: once orders stop purging
321 + // everything, the accidental invalidation that was masking this
322 + // disappears, and an order that reduces stock would leave the product
323 + // page stale.
324 + if ( class_exists( 'WooCommerce' ) ) {
325 + foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) {
326 + add_action( $wc_hook, array( __CLASS__, 'purge_product' ) );
327 + }
328 + // Direct stock writes bypass the CRUD entirely.
329 + add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'purge_product_object' ) );
330 + add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'purge_product_object' ) );
331 + add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) );
332 + add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) );
333 + }
334 +
46 335 add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );
47 336
337 + // …and the same for every PER-MODULE option. The handler above only
338 + // ever watched the legacy `xspeed_options` blob, but every module has
339 + // since migrated to its own `xspeed_module_<slug>` option and no hook
340 + // followed — so changing Minify HTML, Lazy Load, Remove Query Strings
341 + // etc. left the cached HTML untouched until the TTL expired (24h by
342 + // default) and the feature read as broken. (#205)
343 + //
344 + // One central listener rather than a hook per module: it covers Pro
345 + // modules with no cross-repo change, and a new module can't forget to
346 + // wire it up.
347 + add_action( 'updated_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
348 + // `added_option` matters as much as `updated_option`: on a fresh install
349 + // a module's option doesn't exist yet, so the FIRST save of every panel
350 + // goes through add_option() and would otherwise skip the purge — the
351 + // original bug surviving one save per module. `deleted_option` covers a
352 + // reset-to-defaults, which changes rendered HTML just as much. (#205)
353 + add_action( 'added_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
354 + add_action( 'deleted_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
355 +
48 356 add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
49 357 add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
50 358 }
51 359
@@ -61,13 +369,844 @@
61 369 self::purge_all( 'settings change' );
62 370 Minifier::purge_minified();
63 371 }
64 372
373 + /**
374 + * Modules whose settings cannot change rendered HTML, so a write to them
375 + * doesn't warrant throwing away the page cache.
376 + *
377 + * The safe default is to purge: a module is listed here only when it is
378 + * clearly incapable of altering front-end output (diagnostics, the MCP
379 + * server, licensing/telemetry surfaces). When in doubt, leave it off the
380 + * list — a needless purge costs a re-render, a missed one makes the
381 + * feature look broken. (#205)
382 + *
383 + * @return string[] Module slugs.
384 + */
385 + public static function non_rendering_modules(): array {
386 + return (array) apply_filters(
387 + 'xspeed_non_rendering_modules',
388 + array(
389 + 'mcp', // AI endpoint — no front-end output.
390 + 'health', // diagnostics only.
391 + 'support', // support snapshot.
392 + 'score', // PageSpeed/GTmetrix runner.
393 + 'migration', // one-shot importer.
394 + 'settings', // import/export surface.
395 + 'cache-coverage', // read-only reporting.
396 + 'ai-privacy', // consent flags for AI surfaces.
397 + 'database', // DB cleanup schedule — no HTML impact.
398 + // Pro slugs — listed by name rather than by asking Pro, so
399 + // Free stays unaware of it. A Pro module absent here simply
400 + // purges, which is the safe default.
401 + 'license',
402 + 'pro_status',
403 + 'analytics',
404 + 'performance-health',
405 + 'recommendations',
406 + 'ai-provider',
407 + 'migration-pro',
408 + )
409 + );
410 + }
411 +
412 + /**
413 + * Purge when ANY module's settings option is written. (#205)
414 + *
415 + * Bound to `updated_option`, `added_option` and `deleted_option` — all three
416 + * fire for every option on the site, so the prefix test comes first and is
417 + * the cheap path for the ~99% of writes that aren't ours. All three pass the
418 + * option name first, which is why this can't hook purge_all() directly:
419 + * that takes $cause first, so every purge would be filed under a cause
420 + * literally named "xspeed_module_minify".
421 + *
422 + * @param string $option Option name that was just written or removed.
423 + */
424 + public static function on_module_settings_change( $option ): void {
425 + $option = (string) $option;
426 + $prefix = Settings_Manager::OPTION_PREFIX;
427 + if ( 0 !== strpos( $option, $prefix ) ) {
428 + return;
429 + }
430 +
431 + $slug = substr( $option, strlen( $prefix ) );
432 + if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) {
433 + return;
434 + }
435 +
436 + // Guard against re-entry: purge_all() and purge_minified() can write
437 + // options of their own (stats, timestamps), and a nested purge would
438 + // both waste work and risk recursing through this same hook.
439 + static $purging = false;
440 + if ( $purging ) {
441 + return;
442 + }
443 + $purging = true;
444 +
445 + self::purge_all( 'settings change' );
446 + Minifier::purge_minified();
447 +
448 + $purging = false;
449 + }
450 +
451 + /**
452 + * Stamp the request's cache decision on the response.
453 + *
454 + * `X-XSpeed-Cache` was only ever written on the serve-from-cache paths,
455 + * so a miss and a deliberate bypass both came back with no header at all
456 + * — indistinguishable from a `curl -I`, the first thing anyone reaches
457 + * for when a site "isn't caching" (issue #10). The reason slug rides
458 + * along on `X-XSpeed-Reason`, but only under WP_DEBUG so production
459 + * responses stay clean. Slugs are fixed per gate — never the matched
460 + * pattern, cookie or user-agent, which would echo request input back.
461 + *
462 + * @param string $value HIT (php) | MISS | BYPASS.
463 + * @param string $reason Fixed slug naming the gate, for BYPASS only.
464 + */
465 + private static function mark( string $value, string $reason = '' ): void {
466 + self::$status_header = $value;
467 + self::$bypass_reason = $reason;
468 +
469 + // Every status, not just a HIT. A page we declined to cache is the
470 + // one an edge most needs telling about: it goes out naked today, and
471 + // a CDN that stores HTML by default keeps somebody's cart.
472 + //
473 + // Resolved before the headers_sent() guard so the decision is
474 + // recorded (and observable in tests) even on a request that can no
475 + // longer send headers; only the emission below is conditional.
476 + self::$edge_headers = self::edge_headers_for( self::edge_status( $value ), 'request', $reason );
477 +
478 + if ( headers_sent() ) {
479 + return;
480 + }
481 + header( 'X-XSpeed-Cache: ' . $value );
482 + if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
483 + header( 'X-XSpeed-Reason: ' . $reason );
484 + }
485 + foreach ( self::$edge_headers as $name => $val ) {
486 + header( $name . ': ' . $val );
487 + }
488 + }
489 +
490 + /**
491 + * Normalize an `X-XSpeed-Cache` value to the vocabulary the edge seam
492 + * speaks.
493 + *
494 + * The header value carries which layer served the page (`HIT (php)`,
495 + * `HIT (nginx)`, `HIT (static)`); nothing deciding what to tell a CDN
496 + * cares, and making a caller match on three spellings of one outcome is
497 + * how a rule ends up applied on two paths out of three.
498 + */
499 + private static function edge_status( string $value ): string {
500 + return 0 === strpos( $value, 'HIT' ) ? 'HIT' : $value;
501 + }
502 +
503 + /** Record a bypass gate and answer "don't cache" in one statement. */
504 + private static function bypass( string $reason ): bool {
505 + self::mark( 'BYPASS', $reason );
506 + return false;
507 + }
508 +
509 + /** The X-XSpeed-Cache value decided for this request ('' if none yet). */
510 + public static function status_header(): string {
511 + return self::$status_header;
512 + }
513 +
514 + /** The bypass gate slug for this request ('' unless BYPASS). */
515 + public static function bypass_reason(): string {
516 + return self::$bypass_reason;
517 + }
518 +
519 + /**
520 + * The edge/CDN pairs sent on this request ('' if none were).
521 + *
522 + * @return array<string,string>
523 + */
524 + public static function edge_headers(): array {
525 + return self::$edge_headers;
526 + }
527 +
528 + /**
529 + * Bypass gates that do NOT ask a cache in front of us to stand down.
530 + *
531 + * Every other slug does. The split is the reason this reads the gate
532 + * rather than the status: a bypass usually means "this response is
533 + * personal, or someone decided this page is never stored", and an edge
534 + * holding one of those does precisely what we refused to do. These two
535 + * mean something else.
536 + *
537 + * `cache-disabled` is the user switching OUR page cache off. Nothing
538 + * about the page became personal. Sending `no-store` on every page of a
539 + * site whose owner chose a different cache would make a local toggle a
540 + * site-wide side effect on infrastructure we do not own.
541 + *
542 + * `non-frontend` is admin, REST, cron and AJAX. Not ours to describe:
543 + * WordPress already nocaches admin, and a REST caller sets its own
544 + * policy.
545 + */
546 + private const HOLD_EXEMPT_BYPASS = array( 'cache-disabled', 'non-frontend' );
547 +
548 + /**
549 + * Bypass gates that describe the SHAPE of the request rather than the
550 + * visitor or the page.
551 + *
552 + * These still hold, but only once we have evidence of an edge — the same
553 + * bar a MISS has to clear. The difference matters because the default
554 + * excluded-URL list contains `/feed/`, the sitemap and `/wp-json/`, and
555 + * `query-param` catches `?lang=fr`, `?paged=2`, and every page of a
556 + * plain-permalink site.
557 + *
558 + * xSpeed refuses those because IT cannot key on a query string, not
559 + * because the response is private. A CDN keys on the full URL and caches
560 + * them correctly. Holding them unconditionally would have meant every
561 + * default install stopped its feed and sitemap being edge-cached — a
562 + * performance regression shipped to sites that never had a CDN in the
563 + * first place, in the name of protecting them from one.
564 + *
565 + * The gates left out of this list are about the visitor (`logged-in`,
566 + * `excluded-cookie`) or are somebody stating outright that this page is
567 + * never to be stored (`donotcachepage`, `post-excluded`, `filtered`).
568 + * Those hold whether or not we can see an edge.
569 + */
570 + private const REQUEST_SHAPE_BYPASS = array( 'query-param', 'non-get', 'user-agent' );
571 +
572 + /**
573 + * Default exclusions that are about the site's plumbing, not its content.
574 + *
575 + * `excluded-url` covers two unlike things. The default list carries
576 + * `/cart`, `/checkout`, `/my-account` and `/wp-login` — personal pages,
577 + * and the reason this feature exists. It also carries the entries below:
578 + * feeds, sitemaps, the REST root, the front controller. Those are public,
579 + * cacheable, and hammered by pollers; a CDN keys on the full URL and
580 + * serves them correctly, so telling it to stop is a cost with no benefit.
581 + *
582 + * Matched as exact strings against the stored list, never as patterns
583 + * against the path. Three bugs came out of doing it the other way round:
584 + * `strpos( $uri, '/feed' )` matched `/my-account/feedback/`, reading the
585 + * whole URI let `/cart/?utm_source=/feed/` disguise a cart as a feed, and
586 + * a bare `index.php` — which is in this list, and which every URL contains
587 + * on an "almost pretty" permalink site — made every page on such a site
588 + * look personal. Comparing the LIST ENTRY rather than the path cannot make
589 + * any of those mistakes, and it keeps a pattern the site owner added
590 + * themselves on the personal side where it belongs.
591 + */
592 + private const STRUCTURAL_EXCLUSIONS = array(
593 + '/wp-json/',
594 + '/xmlrpc.php',
595 + '~wp-.*\.php',
596 + '/feed/',
597 + 'index.php',
598 + '/robots.txt',
599 + // Both spellings, and no entry here is ever retired. This is a
600 + // RECOGNITION list, not a source of truth: it is matched against
601 + // whatever the site has STORED, and a site that saved its settings
602 + // before `~sitemap(_index)?\.xml` was widened to `sitemaps?` (for
603 + // SEOPress, which ships sitemaps.xml) still has the old string in
604 + // its option row. Dropping the old spelling when the default moved
605 + // would read every upgraded site's sitemap exclusion as somebody's
606 + // personal data and hold sitemaps off the CDN — the bug this whole
607 + // predicate exists to prevent, reintroduced by a rename.
608 + '~sitemaps?(_index)?\.xml',
609 + '~sitemap(_index)?\.xml',
610 + );
611 + /**
612 + * Header names no edge instruction may ever carry.
613 + *
614 + * These describe the transfer, not the caching policy, and one wrong
615 + * value from a settings field is a white screen rather than a missing
616 + * optimization.
617 + */
618 + private const NEVER_AN_EDGE_HEADER = array(
619 + 'content-length',
620 + 'content-encoding',
621 + 'content-type',
622 + 'transfer-encoding',
623 + 'set-cookie',
624 + 'location',
625 + 'x-xspeed-cache',
626 + 'x-xspeed-edge-hold',
627 + );
628 +
629 + /**
630 + * Reasons that hold the edge off even when we detected nothing in front.
631 + *
632 + * `none` confidence means no evidence of a proxy, which is not proof
633 + * there is none — a transparent proxy and a host page cache both leave
634 + * the request untouched. So the question is what a wasted header costs
635 + * against what a missed one does, and the answer differs by reason.
636 + *
637 + * These two are correctness failures. A cart page stored by something we
638 + * could not see is the defect this exists to fix, and a mobile-split page
639 + * served to the wrong device is a wrong page rather than a slow one.
640 + * Ninety bytes on a response that was never cacheable is a cheap premium.
641 + *
642 + * `miss` and `pending` are performance hedges, and a hedge against a
643 + * cache that does not exist is noise on every first render. Skipping them
644 + * has a second benefit: because per_entry_edge_headers() compares `store`
645 + * against `bake`, a `pending` hold that never fires leaves the two
646 + * agreeing, which keeps the page on the static tree.
647 + */
648 + private const HOLD_WITHOUT_EVIDENCE = array( 'bypass', 'mobile-split' );
649 +
650 + /**
651 + * Is a module still going to change this page after this response?
652 + *
653 + * Free itself never says yes — nothing in Free defers work past the
654 + * request. Minification and combining write their file and return its URL
655 + * inside the same render; the LCP preload is chosen by parsing the HTML
656 + * being sent. It is the question that matters to anything caching in
657 + * front of us, so Free asks it on their behalf and lets whoever owns the
658 + * deferred work answer.
659 + *
660 + * Answer TRUE while the work is outstanding for the page being served.
661 + * The cost of a false yes is one extra origin hit; the cost of a false no
662 + * is an un-optimized page pinned at the edge for the full lifetime, which
663 + * is the failure this exists to prevent — so when in doubt, say yes.
664 + *
665 + * Asked on a `request` only, and that boundary is the whole safety of it.
666 + *
667 + * A `bake` is generated once, in an admin or CLI request, and serves every
668 + * static HIT on the site; a per-page answer frozen into it would be wrong
669 + * for every other page.
670 + *
671 + * A `store` is worse, and cost a live site an afternoon. The pairs written
672 + * at store time go into the `.meta` sidecar, which the drop-in replays on
673 + * every later HIT — before plugins load, so nothing can re-ask this
674 + * question. A hold written there therefore outlives the state that caused
675 + * it, and the only thing that clears it is the page being stored again. On
676 + * a site where the deferred work never completes, every re-store re-pins
677 + * it, and the page is never edge-cacheable again. The symptom is a cache
678 + * HIT carrying `no-store` and `X-XSpeed-Edge-Hold: pending` on a page
679 + * whose deferred work finished long ago — the sidecar answering with
680 + * state nothing can re-ask.
681 + *
682 + * Holding the MISS is what this is for, and it is enough: that response is
683 + * the un-optimized one. The copy we then store is what an edge should
684 + * mirror, and when the work does land the module purges the page, which
685 + * reaches the edge. The purge is the correctness mechanism; this is only
686 + * meant to cover the single render before it.
687 + *
688 + * @param string $context `request`, `store` or `bake`.
689 + */
690 + public static function edge_optimization_pending( string $context = 'request' ): bool {
691 + if ( 'request' !== $context ) {
692 + return false;
693 + }
694 +
695 + /**
696 + * Filter: xspeed_edge_optimization_pending
697 + *
698 + * @param bool $pending Whether deferred work will still change this page.
699 + */
700 + return (bool) apply_filters( 'xspeed_edge_optimization_pending', false );
701 + }
702 +
703 + /**
704 + * Does mobile cache split this URL into two renders?
705 + *
706 + * With `mobile_separate` on, Free keys its cache on device and serves a
707 + * different page to a phone than to a desktop at the SAME url. No CDN
708 + * varies on User-Agent, so an edge holding one of those renders serves it
709 + * to everyone: whichever device asked first decides what the other sees,
710 + * for the whole lifetime. A wrong page, not a slow one.
711 + *
712 + * Read from the stored option rather than through Settings_Manager: this
713 + * is consulted from the serve path, where the module registry may not
714 + * have run.
715 + */
716 + private static function mobile_cache_splits_html(): bool {
717 + $stored = self::stored_cache_opts();
718 + return ! empty( $stored['mobile_separate'] );
719 + }
720 +
721 + /**
722 + * Why, if at all, a cache in front of us should refuse to store this.
723 + *
724 + * @param string $status `HIT`, `MISS` or `BYPASS`.
725 + * @param string $context `request`, `store` or `bake`.
726 + * @param string $bypass_reason The gate slug, for BYPASS only.
727 + * @return string '' or one of bypass|bypass-shape|miss|mobile-split|pending.
728 + */
729 + private static function edge_hold_reason( string $status, string $context, string $bypass_reason ): string {
730 + $reason = '';
731 +
732 + // The two exempt gates are answered before anything else, or a site
733 + // with Separate Mobile Cache on would keep holding after the page
734 + // cache was switched off — which is exactly the "a local toggle must
735 + // not become a site-wide side effect on infrastructure we do not own"
736 + // rule below, defeated by the ordering rather than by the logic.
737 + if ( 'BYPASS' === $status && in_array( $bypass_reason, self::HOLD_EXEMPT_BYPASS, true ) ) {
738 + /** This filter is documented below. */
739 + return (string) apply_filters( 'xspeed_edge_hold_reason', '', $status, $context, $bypass_reason );
740 + }
741 +
742 + // First, because it is the only reason true in every context: the
743 + // setting is a property of the site, not of one request, so it is the
744 + // one thing a baked artifact can honestly assert.
745 + //
746 + // It is also the only reason that holds a HIT — a response we DID
747 + // cache — and that is deliberate rather than an artefact of the
748 + // ordering. With mobile_separate on we key the cache by device and
749 + // serve different HTML to a phone than to a desktop at the same URL.
750 + // No CDN varies on User-Agent, so an edge holding one of those
751 + // renders serves it to everyone and whichever device asked first
752 + // decides what the other sees. Our copy is fine; theirs would be a
753 + // wrong page. The static path is switched off in this mode anyway
754 + // (static_rewrite_allowed()), so these hits come from the drop-in,
755 + // which carries the same baked answer.
756 + if ( self::mobile_cache_splits_html() ) {
757 + $reason = 'mobile-split';
758 + } elseif ( 'BYPASS' === $status ) {
759 + $shaped = in_array( $bypass_reason, array( 'excluded-url', 'query-param' ), true )
760 + ? ! self::path_is_a_personal_exclusion( $bypass_reason )
761 + : in_array( $bypass_reason, self::REQUEST_SHAPE_BYPASS, true );
762 + $reason = $shaped ? 'bypass-shape' : 'bypass';
763 + } elseif ( self::edge_optimization_pending( $context ) ) {
764 + $reason = 'pending';
765 + } elseif ( 'MISS' === $status ) {
766 + $reason = 'miss';
767 + }
768 +
769 + /**
770 + * Filter: xspeed_edge_hold_reason
771 + *
772 + * Return '' to veto a hold, or a reason string to force one.
773 + *
774 + * @param string $reason '' or bypass|bypass-shape|miss|mobile-split|pending.
775 + * @param string $status `HIT`, `MISS` or `BYPASS`.
776 + * @param string $context `request`, `store` or `bake`.
777 + * @param string $bypass_reason The gate slug, for BYPASS only.
778 + */
779 + return (string) apply_filters( 'xspeed_edge_hold_reason', $reason, $status, $context, $bypass_reason );
780 + }
781 +
782 + /**
783 + * Was this page excluded because it is personal, or because it is
784 + * plumbing we cannot key a cache entry on?
785 + *
786 + * Answers by removing the structural defaults from the site's own
787 + * exclusion list and asking whether anything is left that matches. So a
788 + * feed matches only `/feed/` and comes back false; `/my-account/feedback/`
789 + * matches `/my-account` and comes back true; and on an "almost pretty"
790 + * permalink site, where every path contains `index.php`, an ordinary page
791 + * matches nothing else and is correctly treated as public.
792 + *
793 + * The path only, never the query string — a visitor writes that, and
794 + * `/cart/?utm_source=/feed/` must not be able to talk a cart out of its
795 + * hold. It is also what `should_cache()` matches the list against.
796 + *
797 + * Asked for a `query-param` bypass too, because the query gate runs
798 + * BEFORE the URL gate, so `/cart/?add-to-cart=12` reports `query-param`
799 + * and never reaches `excluded-url` at all. Which gate fired first says
800 + * nothing about whose data is on the page.
801 + */
802 + private static function path_is_a_personal_exclusion( string $bypass_reason ): bool {
803 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading the path of the request being served; there is no form here to nonce.
804 + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
805 + $path = (string) strtok( $uri, '?' );
806 + if ( '' === $path ) {
807 + return false;
808 + }
809 +
810 + // Through Settings_Manager, not the raw option, because the schema's
811 + // default IS the structural list and a fresh install has never
812 + // written the option. Read raw, every site that has not visited the
813 + // settings screen looks like a site with no exclusions at all, takes
814 + // the contradiction branch below, and reports its feeds as personal.
815 + //
816 + // Safe here where `mobile_cache_splits_html()` is not: we are only
817 + // ever called with a bypass reason, and those come from
818 + // `should_cache()`, which resolved the same settings through
819 + // `Settings_Manager::get()` to produce them.
820 + $opts = Settings_Manager::get( 'cache' );
821 + $excluded = is_array( $opts['excluded_urls'] ?? null ) ? $opts['excluded_urls'] : array();
822 + if ( array() === $excluded ) {
823 + // An `excluded-url` bypass with no exclusion list is a
824 + // contradiction — something excluded the request and the list
825 + // cannot say what — so assume personal, because a wasted header
826 + // costs a little origin traffic while a missing one serves
827 + // somebody's basket to a stranger. A `query-param` bypass with an
828 + // empty list is just an ordinary page carrying a parameter, and
829 + // says nothing about the path at all.
830 + return 'excluded-url' === $bypass_reason;
831 + }
832 +
833 + $personal = array_values(
834 + array_filter(
835 + $excluded,
836 + static fn ( $pattern ) => ! in_array( (string) $pattern, self::STRUCTURAL_EXCLUSIONS, true )
837 + )
838 + );
839 +
840 + return array() !== $personal && Glob_Matcher::any_match( $personal, $path );
841 + }
842 +
843 + /**
844 + * The edge/CDN headers to send on a response with this cache status.
845 + *
846 + * @param string $status `HIT`, `MISS` or `BYPASS`.
847 + * @param string $context `request` when resolved per request on the
848 + * PHP serve path, `store` when resolved for
849 + * one entry's sidecar, `bake` when resolved
850 + * once and frozen into an artifact.
851 + * @param string $bypass_reason The gate slug, for BYPASS only.
852 + * @return array<string,string>
853 + */
854 + public static function edge_headers_for( string $status, string $context = 'request', string $bypass_reason = '' ): array {
855 + $base = array();
856 + if ( 'HIT' === $status ) {
857 + /**
858 + * Filter: xspeed_edge_cache_headers
859 + *
860 + * Response headers to add to a cached HTML response. A HIT-only
861 + * contract: a lifetime is a promise that this copy is worth
862 + * keeping, and neither a first render nor a page we refused to
863 + * cache is one.
864 + *
865 + * The same filter feeds three regimes and `$context` says which.
866 + * On the PHP serve path it runs per request (`request`); at store
867 + * time it runs for one entry (`store`); when the drop-in or a
868 + * server rule is generated it runs once (`bake`) and the result
869 + * answers for every static HIT on the site. Anything per-page — a
870 + * post id in a cache tag, say — must be skipped under `bake`.
871 + *
872 + * @param array<string,string> $headers Header name => value.
873 + * @param string $status Always `HIT` here.
874 + * @param string $context `request`, `store` or `bake`.
875 + */
876 + $base = self::sanitize_edge_headers( (array) apply_filters( 'xspeed_edge_cache_headers', array(), 'HIT', $context ) );
877 + }
878 +
879 + $reason = self::edge_hold_reason( $status, $context, $bypass_reason );
880 + if ( '' === $reason ) {
881 + return $base;
882 + }
883 + $detected = Edge_Provider::detect( $context );
884 + if ( Edge_Provider::is_off( $detected ) ) {
885 + return $base;
886 + }
887 + if ( Edge_Provider::NONE === $detected['confidence']
888 + && ! in_array( $reason, self::HOLD_WITHOUT_EVIDENCE, true ) ) {
889 + return $base;
890 + }
891 +
892 + $hold = Edge_Provider::hold_headers( $detected['provider'] );
893 +
894 + /**
895 + * Filter: xspeed_edge_hold_headers
896 + *
897 + * The last word on what a hold INSTRUCTS. Runs before sanitising, so
898 + * a value that cannot be sent as a header is still dropped, and
899 + * before `X-XSpeed-Edge-Hold` is added, so it cannot rewrite the
900 + * reason xSpeed held the page for — that is a diagnosis, not an
901 + * instruction, and a forged one sends a reader after the wrong
902 + * module.
903 + *
904 + * @param array<string,string> $hold Header name => value.
905 + * @param array<string,string> $detected Provider, confidence, source.
906 + * @param string $reason Why the hold fired.
907 + * @param string $context `request`, `store` or `bake`.
908 + */
909 + $hold = (array) apply_filters( 'xspeed_edge_hold_headers', $hold, $detected, $reason, $context );
910 +
911 + // A hold replaces the lifetime rather than sitting beside it: the two
912 + // describe the same response and would contradict each other. The
913 + // cache tag survives, because a later purge still has to be able to
914 + // name whatever the edge picked up on its own terms.
915 + if ( isset( $base['Cache-Tag'] ) ) {
916 + $hold['Cache-Tag'] = $base['Cache-Tag'];
917 + }
918 +
919 + // Never argue with a stronger answer WordPress already gave. It sends
920 + // `no-store, private` of its own accord on a logged-in, 404 or
921 + // password-protected response, from WP::send_headers() — which runs
922 + // before template_redirect, so it is already on the wire by the time
923 + // we get here. Ours is the weaker statement of the two; replacing it
924 + // would be a downgrade dressed as a fix. Only meaningful per request:
925 + // a bake has no response to inspect.
926 + if ( 'request' === $context && isset( $hold['Cache-Control'] ) && self::cache_control_already_stronger() ) {
927 + unset( $hold['Cache-Control'] );
928 + }
929 +
930 + // A page we refused to cache must not carry a validator either. A
931 + // `Last-Modified` left on it invites a conditional request, and a
932 + // shared cache that gets a 304 back serves the copy it should not
933 + // have stored. Only on a bypass, and only per request: a MISS is
934 + // about to be stored by us, so its validator is ours to keep.
935 + if ( 'request' === $context && 'bypass' === $reason && ! headers_sent() ) {
936 + header_remove( 'Last-Modified' );
937 + }
938 +
939 + $hold = self::sanitize_edge_headers( $hold );
940 +
941 + // Name the reason in the hold set itself, rather than sending it
942 + // separately from mark().
943 + //
944 + // "Why is my page not being cached at the edge?" is the question this
945 + // answers, and mark() could only answer it on the PHP serve path. The
946 + // other emitters send whatever this function returns and never ran
947 + // mark() at all — so the responses hardest to explain went out
948 + // carrying `no-store` with nothing beside it to say why. Chiefly the
949 + // drop-in, which serves from the `.meta` sidecar written under
950 + // `store` and from the literal baked under `bake`, before plugins
951 + // load and with no way to re-ask (the symptom
952 + // edge_optimization_pending() describes above).
953 + //
954 + // The nginx and Apache blocks are a third path in principle and
955 + // almost never in practice: they are only installed when
956 + // static_rewrite_allowed() is true, and the one reason a stock site
957 + // can hold under `bake` is `mobile-split`, which is exactly what
958 + // makes that false. They will carry it where a site forces a hold
959 + // through `xspeed_edge_hold_reason`, and otherwise have no hold to
960 + // carry.
961 + //
962 + // Added AFTER sanitising and banned in NEVER_AN_EDGE_HEADER, so
963 + // neither of the two filters above can forge a reason or suppress the
964 + // real one.
965 + //
966 + // Reduced to the slug CHARACTER CLASS, not checked against the five
967 + // slugs: `xspeed_edge_hold_reason` is documented as able to force a
968 + // reason, and a site that forces its own deserves to see it. What is
969 + // not negotiable is the shape, because this value reaches an
970 + // .htaccess and an nginx conf as well as a response header — so no
971 + // CR/LF, no `$`, no `%`, no `\`, and a length a config file can hold.
972 + $slug = preg_replace( '/[^a-z0-9-]/', '', strtolower( $reason ) );
973 + if ( is_string( $slug ) && '' !== $slug ) {
974 + $hold['X-XSpeed-Edge-Hold'] = substr( $slug, 0, 32 );
975 + }
976 +
977 + return $hold;
978 + }
979 +
980 + /** Has something already sent a Cache-Control at least as strict as ours? */
981 + private static function cache_control_already_stronger(): bool {
982 + foreach ( headers_list() as $line ) {
983 + if ( 0 !== stripos( $line, 'cache-control:' ) ) {
984 + continue;
985 + }
986 + if ( preg_match( '/\b(?:no-store|private)\b/i', $line ) ) {
987 + return true;
988 + }
989 + }
990 +
991 + return false;
992 + }
993 +
994 + /**
995 + * Edge headers that belong to THIS page rather than to every page.
996 + *
997 + * `edge_headers_for('HIT','bake')` is the answer frozen into the drop-in
998 + * and the server rules: one set, serving the whole site. But the answer
999 + * for one URL can legitimately differ — a page whose deferred work is
1000 + * still outstanding, say — and that answer has nowhere to live, because
1001 + * the baked set is all the fast paths know about.
1002 + *
1003 + * So ask again in a `store` context, with the request still in scope, and
1004 + * return the pairs only when they differ from the baked ones. Identical is
1005 + * the overwhelmingly common case and writes nothing: pages do not pay a
1006 + * sidecar for an answer the drop-in already has.
1007 + *
1008 + * Memoised because two callers ask within one store — the sidecar writer
1009 + * and the static-tree guard — and the filters behind it are not required
1010 + * to be cheap.
1011 + *
1012 + * @return array<string,string> Empty when this page needs no override.
1013 + */
1014 + private static function per_entry_edge_headers(): array {
1015 + if ( is_array( self::$per_entry_edge ) ) {
1016 + return self::$per_entry_edge;
1017 + }
1018 + $baked = self::edge_headers_for( 'HIT', 'bake' );
1019 + $request = self::edge_headers_for( 'HIT', 'store' );
1020 + self::$per_entry_edge = ( $request === $baked ) ? array() : $request;
1021 +
1022 + return self::$per_entry_edge;
1023 + }
1024 +
1025 + /**
1026 + * Render baked pairs as a PHP array literal for the drop-in.
1027 + *
1028 + * Single-quoted literals with quotes escaped, because the result is
1029 + * written into a PHP file that must still parse. Values reaching here
1030 + * have already been through sanitize_edge_headers(), so neither name nor
1031 + * value can carry a newline.
1032 + *
1033 + * @param array<string,string> $headers Name => value.
1034 + */
1035 + private static function edge_headers_literal( array $headers ): string {
1036 + if ( array() === $headers ) {
1037 + return 'array()';
1038 + }
1039 + // var_export(), not hand-rolled quoting. A single-quoted PHP string
1040 + // escapes BOTH `'` and `\\`, and escaping only the first is how a
1041 + // value ending in a backslash — `X-Foo: C:\path\` from the custom
1042 + // headers box — leaves the literal unterminated. That file is
1043 + // included on every request once WP_CACHE is on, so the result is a
1044 + // parse error on the front end AND in wp-admin, with no way back
1045 + // except deleting the file over SSH.
1046 + $parts = array();
1047 + foreach ( $headers as $name => $value ) {
1048 + $parts[] = var_export( (string) $name, true ) . ' => ' . var_export( (string) $value, true );
1049 + }
1050 +
1051 + return 'array( ' . implode( ', ', $parts ) . ' )';
1052 + }
1053 +
1054 + /**
1055 + * Quote a header value for an nginx / Apache directive.
1056 + *
1057 + * Both accept a double-quoted string with backslash escapes, and both
1058 + * refuse to load a config where the quoting is wrong — a mis-escaped
1059 + * value takes the whole vhost down, not just this header.
1060 + */
1061 + private static function quote_directive_value( string $value ): string {
1062 + return str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value );
1063 + }
1064 +
1065 + /**
1066 + * The same directive twice — once per name Apache can expose the
1067 + * rewrite's environment variable under.
1068 + *
1069 + * `RewriteRule ... [E=XSPEED_STATIC_HIT:1]` in a per-directory context is
1070 + * an INTERNAL REDIRECT: Apache re-enters the request with the substituted
1071 + * path, and every variable set on the first pass is renamed with a
1072 + * `REDIRECT_` prefix for the second. `env=XSPEED_STATIC_HIT` is evaluated
1073 + * on that second pass, where nothing answers to that name any more, so
1074 + * the directive never fires — dropping the headers from precisely the
1075 + * responses they exist for.
1076 + *
1077 + * It cannot be written once: `env=` takes a single name with no
1078 + * alternation, and `expr=` — which could express both — is not dependable
1079 + * on LiteSpeed, which reads this same block. So both are emitted; the one
1080 + * whose variable is unset on a given pass does nothing.
1081 + *
1082 + * @param string $directive The directive, without its `env=` clause.
1083 + * @return string[]
1084 + */
1085 + private static function static_hit_directives( string $directive ): array {
1086 + return array(
1087 + $directive . ' env=XSPEED_STATIC_HIT',
1088 + $directive . ' env=REDIRECT_XSPEED_STATIC_HIT',
1089 + );
1090 + }
1091 +
1092 + /**
1093 + * Keep only pairs that can be sent as a header verbatim.
1094 + *
1095 + * These values reach three different emitters — PHP's header(), an nginx
1096 + * `add_header` and an Apache `Header always set` — so a name with a space
1097 + * or a value carrying CR/LF is not merely malformed, it is a
1098 + * response-splitting vector in the first and a broken server config in
1099 + * the other two. Names must be token-shaped; values lose CR/LF and are
1100 + * dropped if nothing survives.
1101 + *
1102 + * @param array<mixed,mixed> $headers Raw pairs.
1103 + * @return array<string,string>
1104 + */
1105 + public static function sanitize_edge_headers( array $headers ): array {
1106 + $clean = array();
1107 + foreach ( $headers as $name => $value ) {
1108 + // Never let one of these through, whoever asked. They describe the
1109 + // transfer rather than the caching policy, and getting one wrong
1110 + // from a settings field is a white screen: `Content-Encoding: gzip`
1111 + // on an uncompressed body, a `Content-Length` that disagrees with
1112 + // the bytes. `X-XSpeed-Cache` is ours and a second copy would lie
1113 + // to whoever reads it.
1114 + if ( is_string( $name ) && in_array( strtolower( $name ), self::NEVER_AN_EDGE_HEADER, true ) ) {
1115 + continue;
1116 + }
1117 + // `\z`, not `$`: PCRE's `$` also matches immediately BEFORE a
1118 + // trailing newline, so "Cache-Tag\n" passes a `$` check and gets
1119 + // concatenated raw into the generated .htaccess — splitting one
1120 + // Header directive across two lines, which is a syntax error
1121 + // Apache reports as a 500 on every request while `httpd -t` stays
1122 + // green (.htaccess is parsed per request, not at load).
1123 + if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9-]+\z/', $name ) ) {
1124 + continue;
1125 + }
1126 + if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
1127 + continue;
1128 + }
1129 + $value = trim( str_replace( array( "\r", "\n" ), '', (string) $value ) );
1130 + if ( '' === $value ) {
1131 + continue;
1132 + }
1133 + // `$` is a variable reference in an nginx string and `%` is a
1134 + // format tag to Apache's mod_headers, which rejects an
1135 + // unrecognised one — in .htaccess that is a 500 on every request
1136 + // while `httpd -t` still reports OK, because .htaccess is parsed
1137 + // per request. `\` escapes the quote in the PHP literal baked into
1138 + // the drop-in. None of them can be escaped reliably in all three
1139 + // places at once, and nothing a cache reads needs any of them, so
1140 + // the value is dropped rather than mangled.
1141 + if ( preg_match( '/[$%\\\\]/', $value ) ) {
1142 + continue;
1143 + }
1144 + $clean[ $name ] = $value;
1145 + }
1146 +
1147 + return $clean;
1148 + }
1149 +
1150 + /**
1151 + * Bypass gates that describe THE VISITOR rather than THIS REQUEST.
1152 + *
1153 + * Only these may be recorded in the bypass cookie. A visitor-scoped
1154 + * verdict stays true for the visitor's next request — they are still
1155 + * logged in, still hold a cart cookie — so the web server can act on
1156 + * it without booting PHP.
1157 + *
1158 + * Every other gate describes the request in front of us: its method,
1159 + * its URL, its query string, the client's user agent. Persisting one
1160 + * of those pins a visitor to the uncached path over a property that
1161 + * was never theirs to begin with. (#218)
1162 + */
1163 + private const VISITOR_SCOPED_BYPASS = array( 'logged-in', 'excluded-cookie' );
1164 +
1165 + /**
1166 + * Whether $reason describes the visitor (persist it) or merely this
1167 + * request (don't).
1168 + *
1169 + * Split out as a pure function because it is the whole decision behind
1170 + * the bypass cookie, and the cookie write itself (setcookie()) can't be
1171 + * asserted in a unit test.
1172 + */
1173 + public static function bypass_is_visitor_scoped( string $reason ): bool {
1174 + return in_array( $reason, self::VISITOR_SCOPED_BYPASS, true );
1175 + }
1176 +
65 1177 public function maybe_start_cache() {
66 1178 if ( ! self::should_cache() ) {
1179 + // PHP has just evaluated the FULL exclusion rule list — including
1180 + // the `~regex` patterns the server config can't express — and
1181 + // decided this response must not be served from cache. Record that
1182 + // verdict in the conventional bypass cookie so the web server can
1183 + // enforce it on subsequent requests without starting PHP.
1184 + //
1185 + // This is what stops most settings changes from needing an nginx
1186 + // reload: the config tests one fixed cookie name forever, and the
1187 + // rule list behind it can change freely.
1188 + //
1189 + // But ONLY when the verdict is about the visitor. A request-shape
1190 + // gate — `non-get` above all — says nothing about who is asking,
1191 + // and persisting it pinned that visitor to the uncached path for
1192 + // the rest of their session: one search-form POST, one comment,
1193 + // one `curl -I` from an uptime monitor, and every later GET
1194 + // bypassed. It could not self-heal either, because the bypass
1195 + // cookie is itself in excluded_cookies, so the next GET bypassed
1196 + // with `excluded-cookie` and landed right back here, where
1197 + // sync_bypass_cookie()'s no-change short-circuit left the cookie
1198 + // exactly where it was. (#218)
1199 + if ( self::bypass_is_visitor_scoped( self::bypass_reason() ) ) {
1200 + self::sync_bypass_cookie( true );
1201 + }
67 1202 return;
68 1203 }
69 1204
1205 + // Cacheable: clear any stale bypass cookie, or a visitor who once
1206 + // had a cart would keep skipping the fast path long after checkout.
1207 + self::sync_bypass_cookie( false );
1208 +
70 1209 $key = self::cache_key();
71 1210 $file = self::cache_file_for( $key );
72 1211
73 1212 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
@@ -78,11 +1217,9 @@
78 1217 // serve path — the one that runs when the drop-in isn't loaded
79 1218 // (e.g. WP_CACHE not true) — previously streamed the cached
80 1219 // file with NO marker, so a genuine HIT looked like a MISS in
81 1220 // the response headers. Same header + value as the drop-in.
82 - if ( ! headers_sent() ) {
83 - header( 'X-XSpeed-Cache: HIT (php)' );
84 - }
1221 + self::mark( 'HIT (php)' );
85 1222 // Replay stored response bits so the HIT matches the original:
86 1223 // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200
87 1224 // status (a cached 404 must serve 404, not 200). No-op for
88 1225 // ordinary pages, which write no .meta.
@@ -126,11 +1263,27 @@
126 1263 // Apache. See maybe_emit_lscache_headers() for the full rationale.
127 1264 self::maybe_emit_lscache_headers();
128 1265
129 1266 // We're about to render fresh + cache → miss for this request.
130 - Hit_Counter::record_miss();
1267 + // …UNLESS this request is a 404 or a known bot/scanner. Those reach the
1268 + // render path too, but counting them as cache misses makes the ratio
1269 + // meaningless — a wave of `/wp-x7.php` scanner 404s reads as a collapsing
1270 + // cache when nothing is wrong. Runs at template_redirect (priority 0), so
1271 + // is_404() is already resolved. Excluded requests are tallied separately
1272 + // for the "you absorbed N scanner hits" line, not dropped. (#118)
1273 + if ( self::miss_is_excluded() ) {
1274 + Hit_Counter::record_excluded();
1275 + } else {
1276 + Hit_Counter::record_miss();
1277 + }
131 1278
1279 + // Stamp it, so "eligible but not cached yet" is visibly different
1280 + // from "deliberately bypassed" (issue #10). Headers can't be sent
1281 + // after the body starts, so this has to happen here, not in
1282 + // finalize_buffer() — nothing has been output at template_redirect.
1283 + self::mark( 'MISS' );
132 1284
1285 +
133 1286 // WP < 6.9 fallback: ob_start() with a callback, paired with an
134 1287 // explicit shutdown close so the buffer lifecycle is visible to
135 1288 // reviewers and Plugin Check, instead of relying on PHP's implicit
136 1289 // request-end flush. We record our nesting level so close_buffer()
@@ -159,20 +1312,192 @@
159 1312 }
160 1313 self::$buffer_level = null;
161 1314 }
162 1315
1316 + /**
1317 + * Are we buffering this request?
1318 + *
1319 + * Asked by Css_Combine_Buffer, which needs the finished HTML but must not
1320 + * open a second buffer when this one is already going to hand it the page
1321 + * through `xspeed_cache_final_html`. False here means the request is not
1322 + * cacheable — cache off, excluded URL, logged in — and the combiner has to
1323 + * provide its own buffer or it silently stops working. (#195)
1324 + */
1325 + public static function is_buffering(): bool {
1326 + return null !== self::$buffer_level;
1327 + }
1328 +
1329 + /**
1330 + * Is a render-time translation plugin going to wrap our output buffer?
1331 + *
1332 + * TranslatePress opens its translation buffer on `init` priority 0. We
1333 + * open ours on `template_redirect`, which runs much later, so ours nests
1334 + * INSIDE theirs. PHP unwinds output buffers LIFO — innermost callback
1335 + * first — so `finalize_buffer()` saw the raw, pre-translation HTML and
1336 + * cached that, while the live visitor still got the translated bytes from
1337 + * TRP's outer buffer.
1338 + *
1339 + * Result: the first (MISS) visitor to /fr/some-page/ got correct French;
1340 + * every visitor after got English body text under a `lang="fr-FR"`
1341 + * document, plus TRP's internal `#TRPLINKPROCESSED` link markers, which
1342 + * TRP strips at the very end of its own buffer and which therefore leak
1343 + * into anything captured from inside it.
1344 + *
1345 + * Note the ordering cannot be fixed from TRP's side: its
1346 + * `trp_start_output_buffer_priority` filter only moves the PRIORITY on
1347 + * `init`, and `init` always fires before `template_redirect` whatever the
1348 + * priority. The buffer that has to move is ours.
1349 + *
1350 + * Detected by main class rather than plugin path, so a renamed directory
1351 + * or a bundled copy still matches.
1352 + */
1353 + public static function translation_plugin_active(): bool {
1354 + $active = class_exists( 'TRP_Translate_Press' );
1355 +
1356 + /**
1357 + * Whether to treat this request as wrapped by a translation buffer.
1358 + *
1359 + * Lets a site add another render-time translation plugin (or opt out)
1360 + * without patching the engine.
1361 + *
1362 + * @param bool $active
1363 + */
1364 + return (bool) apply_filters( 'xspeed_translation_plugin_active', $active );
1365 + }
1366 +
1367 + /**
1368 + * Write the cache file for a request whose output was wrapped by a
1369 + * render-time translation plugin.
1370 + *
1371 + * Registered as a PHP shutdown function (not a WP `shutdown` action) so
1372 + * it runs after PHP has unwound the output-buffer stack — by which point
1373 + * the translation plugin's callback has transformed the bytes and its
1374 + * internal markers are gone.
1375 + *
1376 + * finalize_buffer() has already applied the status gate, the
1377 + * xspeed_cache_final_html filter and HTML minification to the
1378 + * untranslated copy and then declined to write it. Here we re-run only
1379 + * what's needed on the translated bytes: minify, write, and fire the
1380 + * same downstream hooks so Brotli / static-tree listeners behave
1381 + * identically to the ordinary path.
1382 + */
1383 + public static function write_deferred_translated_cache(): void {
1384 + $key = self::$deferred_key;
1385 + self::$deferred_key = null;
1386 +
1387 + // Release the collected bytes BEFORE the early return, so the static
1388 + // is cleared on every path rather than only when a key survived.
1389 + $full = self::$translated_output;
1390 + self::$translated_output = '';
1391 +
1392 + $completed = self::$render_completed;
1393 + self::$render_completed = false;
1394 +
1395 + if ( null === $key ) {
1396 + return;
1397 + }
1398 +
1399 + // Did the render actually finish?
1400 + //
1401 + // This runs as a PHP shutdown function, which fires after a wp_die()
1402 + // or a bare exit() just as readily as after a clean render — but in
1403 + // those cases finalize_buffer() never returned, so the bytes we hold
1404 + // are a page that was cut off partway through. The length and
1405 + // TRPLINKPROCESSED checks below don't catch that: a fatal after the
1406 + // footer's translated markup is both over 255 bytes and free of TRP
1407 + // markers, i.e. truncated but entirely plausible. Caching it would
1408 + // freeze a half-rendered page under the real key for the full TTL.
1409 + //
1410 + // Serving this one URL uncached is the cheap failure; the corrupt
1411 + // cache entry is the expensive one.
1412 + if ( ! $completed ) {
1413 + return;
1414 + }
1415 +
1416 + if ( strlen( $full ) < 255 ) {
1417 + return;
1418 + }
1419 +
1420 + // Refuse to cache a copy still carrying the translation plugin's
1421 + // internal link markers. TRP strips these at the very end of its own
1422 + // buffer, so their presence means we captured too early — and a
1423 + // cached page containing them is SEO-visible damage. Better to serve
1424 + // this URL uncached than to freeze broken markup for the full TTL.
1425 + if ( false !== strpos( $full, 'TRPLINKPROCESSED' ) ) {
1426 + return;
1427 + }
1428 +
1429 + $minify_opts = Settings_Manager::get( 'minify' );
1430 + if ( ! empty( $minify_opts['minify_html'] ) ) {
1431 + $full = Minifier::minify_html( $full );
1432 + }
1433 + $full = self::signed( $full );
1434 +
1435 + // Per-site directory: on multisite every blog shares this tree, so
1436 + // entries are bucketed by host to keep one site's purge from
1437 + // sweeping the whole network. (#6)
1438 + self::ensure_host_dir();
1439 +
1440 + // Never author a cache entry from a request that carried a query
1441 + // string: cache_key() files it under the BARE url, so the params'
1442 + // render would be served to every clean-URL visitor (#241).
1443 + if ( self::query_string_blocks_write() ) {
1444 + return;
1445 + }
1446 +
1447 + $file = self::cache_file_for( $key );
1448 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; this runs on a frontend shutdown where it's unavailable.
1449 + file_put_contents( $file, $full, LOCK_EX );
1450 +
1451 + /** This action is documented in includes/class-cache.php */
1452 + do_action( 'xspeed_flat_file_written', $file, $full );
1453 +
1454 + self::write_meta( $key, $full );
1455 +
1456 + // Static tree too, under the same gates finalize_buffer() applies —
1457 + // otherwise deferring the write would silently cost translated pages
1458 + // the web-server fast path and leave them on the slower drop-in.
1459 + // The static tree cannot replay a sidecar. A file served straight by
1460 + // the web server carries the headers baked into the rule that serves
1461 + // the whole site — the very answer this entry exists because it
1462 + // disagreed with. Same reasoning as the status and content-type
1463 + // cases: what the fast path cannot replay belongs on the drop-in path.
1464 + if ( self::static_rewrite_allowed()
1465 + && self::response_is_plain_html()
1466 + && array() === self::per_entry_edge_headers() ) {
1467 + self::store_static( $full );
1468 + }
1469 + }
1470 +
163 1471 public static function should_cache() {
1472 + // Reset first: a single request only reaches this once (the sole
1473 + // caller is maybe_start_cache()), but tests and any future caller
1474 + // must never inherit the previous request's verdict.
1475 + self::$status_header = '';
1476 + self::$bypass_reason = '';
1477 + self::$edge_headers = array();
1478 + self::$per_entry_edge = null;
1479 + // Under PHP-FPM a process serves one request and this is moot. Under
1480 + // a persistent worker runtime it is not: without it, an answer
1481 + // resolved from one visitor's forgeable headers would be reused for
1482 + // every later request the worker handles.
1483 + Edge_Provider::forget();
1484 +
164 1485 $opts = Settings::get();
165 1486 if ( empty( $opts['cache_enabled'] ) ) {
166 - return false;
1487 + return self::bypass( 'cache-disabled' );
167 1488 }
168 1489
169 - if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
170 - return false;
1490 + if ( is_user_logged_in() ) {
1491 + return self::bypass( 'logged-in' );
171 1492 }
172 1493
1494 + if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
1495 + return self::bypass( 'non-frontend' );
1496 + }
1497 +
173 1498 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
174 - return false;
1499 + return self::bypass( 'donotcachepage' );
175 1500 }
176 1501
177 1502 // All exclusion knobs now owned by CacheModule.
178 1503 $cache_opts = Settings_Manager::get( 'cache' );
@@ -178,9 +1503,9 @@
178 1503 $cache_opts = Settings_Manager::get( 'cache' );
179 1504
180 1505 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
181 1506 if ( 'GET' !== $method ) {
182 - return false;
1507 + return self::bypass( 'non-get' );
183 1508 }
184 1509
185 1510 // Search-results requests carry a `s` query param, which the
186 1511 // query-string gate below would normally reject as "dynamic". An
@@ -207,8 +1532,20 @@
207 1532 * @param bool $cache_feed Whether to cache this feed request.
208 1533 */
209 1534 $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
210 1535
1536 + // WordPress's virtual robots.txt (and virtual favicon) are not HTML:
1537 + // caching one runs it through the whole HTML pipeline, which stamped
1538 + // the footer comment onto text/plain and let HTML minification
1539 + // collapse robots.txt to a single line — a line-based format, so
1540 + // every directive after the first was lost and crawlers read an
1541 + // invalid file. No opt-in filter here: there is no correct way to
1542 + // treat these as pages. (Reported live on a customer site.)
1543 + if ( ( function_exists( 'is_robots' ) && is_robots() )
1544 + || ( function_exists( 'is_favicon' ) && is_favicon() ) ) {
1545 + return self::bypass( 'non-html' );
1546 + }
1547 +
211 1548 // Query string handling: anything OUTSIDE the ignored-params
212 1549 // allow-list (utm_*, fbclid, gclid by default) means a unique
213 1550 // request that we don't want to share with the canonical cache
214 1551 // entry. Skip cache rather than poison the key.
@@ -236,9 +1573,11 @@
236 1573 if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) {
237 1574 continue;
238 1575 }
239 1576 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
240 - return false;
1577 + // Slug only — never the param name, which is attacker-
1578 + // controlled and would be reflected into a header.
1579 + return self::bypass( 'query-param' );
241 1580 }
242 1581 }
243 1582 }
244 1583
@@ -246,9 +1585,9 @@
246 1585 $path = (string) strtok( $request_uri, '?' );
247 1586
248 1587 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
249 1588 if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) {
250 - return false;
1589 + return self::bypass( 'excluded-url' );
251 1590 }
252 1591
253 1592 // Cookie-based exclusion. We only check cookie NAMES (matching
254 1593 // values would leak content-sensitive logic into the cache key
@@ -255,10 +1594,21 @@
255 1594 // rules); presence of any matching cookie name skips cache.
256 1595 $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
257 1596 if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
258 1597 foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
1598 + // Our own bypass cookie is a RECORD of a previous verdict, not
1599 + // evidence about this visitor, so it never gets a vote here.
1600 + // Letting it match made the verdict self-confirming: once set,
1601 + // it produced `excluded-cookie` forever, which re-set it, and
1602 + // no later request could ever re-evaluate the visitor on the
1603 + // rules that actually describe them. The web server still acts
1604 + // on the cookie without booting PHP; when PHP does boot it is
1605 + // authoritative and re-decides from scratch. (#218)
1606 + if ( Server_Rules::BYPASS_COOKIE === $cookie_name ) {
1607 + continue;
1608 + }
259 1609 if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
260 - return false;
1610 + return self::bypass( 'excluded-cookie' );
261 1611 }
262 1612 }
263 1613 }
264 1614
@@ -269,9 +1619,9 @@
269 1619 if ( ! empty( $bypass_uas ) ) {
270 1620 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
271 1621 foreach ( $bypass_uas as $needle ) {
272 1622 if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
273 - return false;
1623 + return self::bypass( 'user-agent' );
274 1624 }
275 1625 }
276 1626 }
277 1627
@@ -278,9 +1628,9 @@
278 1628 // Per-post override (Phase 3.4). Honored only on singular
279 1629 // post-context requests — archives / 404s / taxonomies use the
280 1630 // global policy above.
281 1631 if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
282 - return false;
1632 + return self::bypass( 'post-excluded' );
283 1633 }
284 1634
285 1635 /**
286 1636 * Final say on whether the current request is cacheable.
@@ -300,9 +1650,16 @@
300 1650 * advanced-cache.php.
301 1651 *
302 1652 * @param bool $should_cache Whether to cache the current request.
303 1653 */
304 - return (bool) apply_filters( 'xspeed_should_cache', true );
1654 + if ( ! apply_filters( 'xspeed_should_cache', true ) ) {
1655 + // One slug for every listener — a third-party callback name is
1656 + // not ours to put in a response header. Which listener vetoed is
1657 + // a WP_DEBUG-level question the filter itself can answer.
1658 + return self::bypass( 'filtered' );
1659 + }
1660 +
1661 + return true;
305 1662 }
306 1663
307 1664 /**
308 1665 * Whether the current request is a 404 we may cache.
@@ -346,8 +1703,75 @@
346 1703 * search_term() / cache_key()) so different searches stay distinct.
347 1704 * The xspeed-pro search cache flips the filter; Free never caches
348 1705 * search results on its own.
349 1706 */
1707 + /**
1708 + * Whether this response was rendered for a query string and therefore
1709 + * must not be STORED under the bare-URL key.
1710 + *
1711 + * should_cache() lets a request through when every key is on the
1712 + * `ignored_query_params` allow-list, and cache_key() then drops the
1713 + * query string so `/post` and `/post?utm_source=x` share one entry.
1714 + * Sharing on READ is the point of the allow-list and stays. Sharing on
1715 + * WRITE is a cache-poisoning vector: the response was rendered *with*
1716 + * those params, and WordPress reflects REQUEST_URI into form actions,
1717 + * share links, canonical helpers and plugin smart tags. One anonymous
1718 + * GET to a cold URL therefore freezes an attacker-chosen variant under
1719 + * the clean URL's key, served for the whole TTL by the drop-in and by
1720 + * the web server — neither of which runs these checks (issue #241).
1721 + *
1722 + * The allow-list keeps its benefit: a visitor arriving on
1723 + * `?utm_source=…` is still SERVED the canonical cached entry. Only the
1724 + * write is skipped, so the entry is authored by a clean request.
1725 + *
1726 + * This is the same reasoning as the `should_cache_search()` guard in
1727 + * store_static() (#191), generalised to the allow-listed params.
1728 + */
1729 + public static function request_has_query_string(): bool {
1730 + $query = isset( $_SERVER['QUERY_STRING'] )
1731 + ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only tested for emptiness; never echoed, stored or used as a path.
1732 + : '';
1733 +
1734 + return '' !== trim( $query );
1735 + }
1736 +
1737 + /**
1738 + * Would authoring a cache entry from THIS request file a query-string
1739 + * render under the bare URL?
1740 + *
1741 + * The one predicate both write sites ask, so they cannot drift.
1742 + *
1743 + * Two shapes are exempt because cache_key() does NOT drop their query —
1744 + * it folds the distinguishing part into the key, so each variant gets
1745 + * its own entry and none is filed under the bare URL:
1746 + *
1747 + * - searches, keyed by `|s=<term>` (#191)
1748 + * - feeds, keyed by `|feed=<type>` — `/?feed=rss2` is the ONLY feed URL
1749 + * core generates on plain permalinks, so treating it as poisonable
1750 + * made feed caching a no-op on exactly the sites that need it
1751 + *
1752 + * @return bool True when the write must be skipped.
1753 + */
1754 + public static function query_string_blocks_write(): bool {
1755 + if ( ! self::request_has_query_string() ) {
1756 + return false;
1757 + }
1758 +
1759 + if ( self::should_cache_search() ) {
1760 + return false;
1761 + }
1762 +
1763 + // Feed caching is opt-in, via the same filter should_cache() reads
1764 + // to admit the feed params in the first place.
1765 + if ( function_exists( 'is_feed' ) && is_feed()
1766 + && (bool) apply_filters( 'xspeed_should_cache_feed', false )
1767 + ) {
1768 + return false;
1769 + }
1770 +
1771 + return true;
1772 + }
1773 +
350 1774 public static function should_cache_search(): bool {
351 1775 if ( ! function_exists( 'is_search' ) || ! is_search() ) {
352 1776 return false;
353 1777 }
@@ -387,13 +1811,22 @@
387 1811 }
388 1812
389 1813 /**
390 1814 * Is this query-string key on the ignored-params allow-list? Supports
391 - * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`,
392 - * etc.) so users don't have to enumerate every UTM variant.
1815 + * globs (`utm_*` matches `utm_source`, `utm_medium`, etc.) so users
1816 + * don't have to enumerate every UTM variant, and `~regex`.
1817 + *
1818 + * Matching is whole-name, not "contains" — a param name is an
1819 + * identifier, not a path. Under the old contains match the shipped
1820 + * default `ref` also swallowed `preference`, `product_ref` and
1821 + * `referrer`: those params were dropped from the cache key, so
1822 + * `/shop?preference=1` was served — and, on a cold entry, WRITTEN as —
1823 + * `/shop`. Same for `_ga` vs `_gallery`, and for the unanchored
1824 + * `~utm_…` default vs `my_utm_source`. A param name that is genuinely
1825 + * unknown now bypasses the cache, which is the safe direction.
393 1826 */
394 1827 private static function query_key_is_ignored( string $key, array $ignored ): bool {
395 - return Glob_Matcher::any_match( $ignored, $key );
1828 + return Glob_Matcher::any_match_name( $ignored, $key );
396 1829 }
397 1830
398 1831 public static function cache_key() {
399 1832 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
@@ -469,10 +1902,301 @@
469 1902 }
470 1903 return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
471 1904 }
472 1905
1906 + /**
1907 + * Filesystem-safe directory name for a host, or '' when unusable.
1908 + *
1909 + * The charset MUST match the static tree (store_static()) and the
1910 + * drop-in's own copy, or the paths disagree about where an entry lives.
1911 + * The colon of `host:port` is stripped: it is legal in a Host header but
1912 + * not portable in a path.
1913 + *
1914 + * @param string $host Raw host, e.g. from HTTP_HOST.
1915 + * @return string Safe directory segment, or '' if nothing usable remains.
1916 + */
1917 + /**
1918 + * The host segment of the STATIC tree — `xspeed-static/<host>/…`, which
1919 + * the web server resolves without PHP.
1920 + *
1921 + * Different from host_dir(): here the port is folded INTO the segment
1922 + * (`localhost:8080` → `localhost8080`) rather than dropped, because the
1923 + * generated server rules have to reproduce this from their own variables
1924 + * and nginx's `$host` has no port to drop — see the `$xspeed_host`
1925 + * derivation in nginx_snippet(). Shared by the write and the purge so the
1926 + * two can't drift; when they did, purging a page on a ported host deleted
1927 + * nothing and the stale copy kept being served by the rewrite.
1928 + */
1929 + public static function static_host_dir( string $host ): string {
1930 + return (string) preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
1931 + }
1932 +
1933 + public static function host_dir( string $host ): string {
1934 + $host = str_replace( "\0", '', $host );
1935 + // Drop the port BEFORE filtering, or `example.com:8080` collapses to
1936 + // `example.com8080` — which both loses the boundary and could collide
1937 + // with a real host of that name.
1938 + $colon = strpos( $host, ':' );
1939 + if ( false !== $colon ) {
1940 + $host = substr( $host, 0, $colon );
1941 + }
1942 + $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
1943 + // Collapse any run of dots so no traversal sequence can survive the
1944 + // charset filter (`a/../b` would otherwise reduce to `a..b`).
1945 + $host = preg_replace( '/\.{2,}/', '.', (string) $host );
1946 + $host = trim( (string) $host, '.-' );
1947 + return '' === $host ? '' : $host;
1948 + }
1949 +
1950 + /**
1951 + * The per-site bucket a cache entry belongs to: `<host>` on a single
1952 + * site, `<host>/<path-prefix>` for a subdirectory multisite blog.
1953 + *
1954 + * On multisite every blog shares one cache directory, and a flat md5
1955 + * filename carries no clue which site wrote it — so purging one subsite
1956 + * swept the whole network cold. (#6)
1957 + *
1958 + * Host alone is NOT enough: a subdirectory network (the common layout)
1959 + * puts every blog on the same host, so `example.com/` and
1960 + * `example.com/siteb/` would share a bucket and keep purging each other.
1961 + * The path prefix is what separates them, and it is derivable from the
1962 + * REQUEST_URI alone — which matters because the drop-in must compute
1963 + * this identical value before WordPress (and get_blog_details()) exist.
1964 + *
1965 + * Subdomain and domain-mapped networks differ by host already, so they
1966 + * get a bare host bucket and are unaffected.
1967 + *
1968 + * @param string $host Raw host.
1969 + * @param string $uri Raw REQUEST_URI (query string is ignored).
1970 + * @return string Bucket path, always non-empty.
1971 + */
1972 + public static function site_bucket( string $host, string $uri ): string {
1973 + $dir = self::host_dir( $host );
1974 + if ( '' === $dir ) {
1975 + $dir = 'default';
1976 + }
1977 +
1978 + $prefix = self::site_path_prefix();
1979 + return '' === $prefix ? $dir : $dir . '/' . $prefix;
1980 + }
1981 +
1982 + /**
1983 + * The current blog's path prefix as a single safe segment ('' for the
1984 + * root blog or a non-multisite install). `/siteb/` becomes `siteb`;
1985 + * a nested `/a/b/` becomes `a-b` so the bucket stays one level deep.
1986 + *
1987 + * Written to a sidecar for the drop-in by sync_site_paths().
1988 + */
1989 + public static function site_path_prefix(): string {
1990 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
1991 + return '';
1992 + }
1993 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
1994 + return ''; // Hosts already differ; no prefix needed.
1995 + }
1996 + $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
1997 + return self::path_prefix_segment( $path );
1998 + }
1999 +
2000 + /**
2001 + * The bucket an arbitrary URL's cache entry lives in.
2002 + *
2003 + * `site_bucket()` answers for the CURRENT request; this answers for a URL
2004 + * that may belong to another blog entirely — which is what a per-URL purge
2005 + * is usually doing (WP-CLI, cron, the MCP tool, a network-admin action).
2006 + *
2007 + * The blog is resolved from the URL itself: on a subdirectory network
2008 + * `get_blog_details()` is asked which blog owns `<host><path>`, and its
2009 + * registered path becomes the prefix. Deriving the prefix from the URL's
2010 + * first path segment directly would be wrong — `/shop/` on the main blog
2011 + * is a page, not a subsite, and would send the purge into a bucket that
2012 + * does not exist. (QA B2 on #166)
2013 + *
2014 + * @param string $host Host of the URL being purged.
2015 + * @param string $path Path of the URL being purged.
2016 + * @return string Bucket path, always non-empty.
2017 + */
2018 + public static function bucket_for_url( string $host, string $path ): string {
2019 + $dir = self::host_dir( $host );
2020 + if ( '' === $dir ) {
2021 + $dir = 'default';
2022 + }
2023 +
2024 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
2025 + return $dir;
2026 + }
2027 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
2028 + return $dir; // Hosts already differ; no prefix.
2029 + }
2030 + if ( ! function_exists( 'get_blog_details' ) ) {
2031 + return $dir;
2032 + }
2033 +
2034 + // Longest registered blog path that prefixes this URL wins, so
2035 + // `/one/2026/post/` resolves to blog `/one/` and not to the root blog.
2036 + $blog = self::blog_for_path( $host, $path );
2037 + if ( null === $blog ) {
2038 + return $dir;
2039 + }
2040 + $prefix = self::path_prefix_segment( (string) $blog );
2041 + return '' === $prefix ? $dir : $dir . '/' . $prefix;
2042 + }
2043 +
2044 + /**
2045 + * The registered path of the blog that owns `<host><path>`, or null.
2046 + *
2047 + * Uses get_blog_details() with a domain/path pair rather than scanning
2048 + * every blog, so a large network costs one lookup per candidate segment
2049 + * instead of a full table read.
2050 + */
2051 + private static function blog_for_path( string $host, string $path ): ?string {
2052 + $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) );
2053 +
2054 + // Try the longest candidate first: /a/b/ before /a/ before /.
2055 + for ( $take = min( count( $segments ), 2 ); $take >= 1; $take-- ) {
2056 + $candidate = '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/';
2057 + $details = get_blog_details(
2058 + array(
2059 + 'domain' => $host,
2060 + 'path' => $candidate,
2061 + ),
2062 + false
2063 + );
2064 + if ( $details && ! empty( $details->path ) ) {
2065 + return (string) $details->path;
2066 + }
2067 + }
2068 + return null;
2069 + }
2070 +
2071 + /**
2072 + * Normalise a blog path ('/', '/siteb/', '/a/b/') into a single
2073 + * filesystem-safe segment. Shared with the drop-in's copy.
2074 + */
2075 + public static function path_prefix_segment( string $path ): string {
2076 + $path = trim( str_replace( "\0", '', $path ), '/' );
2077 + if ( '' === $path ) {
2078 + return '';
2079 + }
2080 + $path = preg_replace( '/[^a-zA-Z0-9._\-\/]/', '', $path );
2081 + $path = str_replace( '/', '-', (string) $path );
2082 + return trim( (string) $path, '.-' );
2083 + }
2084 +
2085 + /**
2086 + * The current blog's path as the static tree stores it — real slashes
2087 + * preserved, because that tree mirrors the URL
2088 + * (`xspeed-static/{host}{request_uri}/index.html`) rather than using a
2089 + * single flattened segment. '' for a root blog / single site.
2090 + */
2091 + public static function site_path_raw(): string {
2092 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
2093 + return '';
2094 + }
2095 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
2096 + return '';
2097 + }
2098 + $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
2099 + $path = trim( str_replace( "\0", '', $path ), '/' );
2100 + if ( '' === $path ) {
2101 + return '';
2102 + }
2103 + $path = preg_replace( '#[^a-zA-Z0-9._\-/]#', '', $path );
2104 + return trim( (string) $path, '/' );
2105 + }
2106 +
2107 + /**
2108 + * Static-tree root for the current site: `<host>` plus the blog's real
2109 + * path. Mirrors store_static()'s layout so a scoped purge deletes
2110 + * exactly this blog's pages.
2111 + */
2112 + public static function current_static_scope(): string {
2113 + // Same switch_to_blog() caveat as current_host_dir() — see current_host().
2114 + // Keep the port folded into the segment exactly as store_static() does.
2115 + $dir = self::static_host_dir( self::current_host() );
2116 + if ( '' === $dir ) {
2117 + $dir = 'default';
2118 + }
2119 + $path = self::site_path_raw();
2120 + return '' === $path ? $dir : $dir . '/' . $path;
2121 + }
2122 +
2123 + /**
2124 + * The bucket for the CURRENT request. Never empty, so an entry is never
2125 + * written to the tree root (which is what the unscoped sweeps used to
2126 + * delete indiscriminately).
2127 + */
2128 + public static function current_host_dir(): string {
2129 + $host = self::current_host();
2130 + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
2131 + return self::site_bucket( $host, $uri );
2132 + }
2133 +
2134 + /**
2135 + * The host the CURRENT blog is served from.
2136 + *
2137 + * Deliberately NOT just $_SERVER['HTTP_HOST']: inside a
2138 + * switch_to_blog() the request header still names whichever site is
2139 + * serving the admin screen, while the cache entries we want belong to
2140 + * the switched-to blog. On a subdomain network the host IS the bucket,
2141 + * so reading the header there would make Pro's per-site "purge this
2142 + * site" button clear the network admin's own cache instead — the very
2143 + * bug this scoping exists to fix, surviving in one topology.
2144 + *
2145 + * get_blog_details() follows the switch, so prefer it whenever we are
2146 + * on multisite, and fall back to the request header otherwise.
2147 + */
2148 + public static function current_host(): string {
2149 + if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_blog_details' ) ) {
2150 + $details = get_blog_details();
2151 + if ( $details && ! empty( $details->domain ) ) {
2152 + return (string) $details->domain;
2153 + }
2154 + }
2155 +
2156 + if ( isset( $_SERVER['HTTP_HOST'] ) ) {
2157 + return sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) );
2158 + }
2159 +
2160 + /*
2161 + * No request header — WP-CLI, or WP-Cron driven by system cron.
2162 + *
2163 + * Returning '' here made the bucket resolve to the literal `default`
2164 + * while HTTP requests were writing to `<host>/`, so a scheduled purge
2165 + * swept an empty directory and reported success, and get_stats()
2166 + * reported 0 cached pages on a site with a full cache. That is the
2167 + * normal setup on any host running DISABLE_WP_CRON, which is most of
2168 + * them. Fall back to the site's own registered host. (QA D4 on #166)
2169 + */
2170 + if ( function_exists( 'home_url' ) ) {
2171 + $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- early-boot fallback only.
2172 + if ( is_array( $parts ) && ! empty( $parts['host'] ) ) {
2173 + return (string) $parts['host'];
2174 + }
2175 + }
2176 +
2177 + return '';
2178 + }
2179 +
2180 + /**
2181 + * Ensure the current site's cache directory exists, with the silence
2182 + * index in both it and the shared root. Returns the directory.
2183 + */
2184 + public static function ensure_host_dir(): string {
2185 + $dir = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
2186 + if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
2187 + wp_mkdir_p( XSPEED_CACHE_DIR );
2188 + self::write_silence( XSPEED_CACHE_DIR );
2189 + }
2190 + if ( ! file_exists( $dir ) ) {
2191 + wp_mkdir_p( $dir );
2192 + self::write_silence( $dir );
2193 + }
2194 + return $dir;
2195 + }
2196 +
473 2197 public static function cache_file_for( $key ) {
474 - return XSPEED_CACHE_DIR . '/' . $key . '.html';
2198 + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.html';
475 2199 }
476 2200
477 2201 /**
478 2202 * If a precompressed Brotli sibling (`<file>.br`) exists and the client
@@ -503,8 +2227,11 @@
503 2227 $br = $file . '.br';
504 2228 if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) {
505 2229 return null;
506 2230 }
2231 + if ( ! self::brotli_sibling_is_usable( $file, $br ) ) {
2232 + return null; // fall through to the plain .html
2233 + }
507 2234 header( 'Content-Encoding: br' );
508 2235 header( 'Vary: Accept-Encoding', false );
509 2236 // The byte length changes for the compressed body — drop any
510 2237 // Content-Length the caller may have set so the stream isn't
@@ -513,8 +2240,217 @@
513 2240 return $br;
514 2241 }
515 2242
516 2243 /**
2244 + * Is a precompressed `.br` sibling safe to serve?
2245 + *
2246 + * Existence is not enough. The sibling is written with a plain
2247 + * file_put_contents() — no atomic rename — so a crash, a full disk, or a
2248 + * read that races the write leaves a TRUNCATED file behind. Serving that
2249 + * with `Content-Encoding: br` hands the browser a stream it cannot
2250 + * inflate: it renders nothing at all (document.body is null) and the
2251 + * navigation can hang. A 16-byte .br for a 172KB page reproduces it
2252 + * exactly. (#286)
2253 + *
2254 + * Brotli has no magic number, and no byte-level marker distinguishes a
2255 + * truncated stream from a short valid one (the ISLAST bit is bit-packed,
2256 + * not byte-aligned). So this checks only what CAN be known by stat:
2257 + *
2258 + * - Not empty. A zero-byte sibling is unambiguously broken.
2259 + * - Not older than the HTML. A stale sibling would serve the PREVIOUS
2260 + * revision of the page under the current entry's ETag.
2261 + *
2262 + * A size-RATIO floor was tried here and removed. Brotli's ratio is
2263 + * unbounded on repetitive input: a ~1 MB page of table rows or a product
2264 + * grid — the ordinary shape of a big generated page — compresses to
2265 + * about 0.04%, so a 2% floor rejected a perfectly good sibling and sent
2266 + * visitors the uncompressed page instead, silently. Measured: 963 KB of
2267 + * repeated markup → 89 bytes at q5 (0.009%). No floor can separate
2268 + * "impossibly small" from "extremely compressible" for arbitrary HTML.
2269 + *
2270 + * Truncation is prevented at the WRITE side instead — see
2271 + * write_atomic(), which the Brotli writer uses so a partial file is
2272 + * never visible under the final name. Detection at read time cannot be
2273 + * made correct; not creating the bad file can.
2274 + *
2275 + * Anything suspicious returns false and the caller streams the plain
2276 + * .html — slower, always correct. Serving an uninflatable body is worse
2277 + * than serving no compression at all.
2278 + *
2279 + * @param string $file Absolute path to the .html cache file.
2280 + * @param string $br Absolute path to its .br sibling.
2281 + * @return bool True when the sibling may be served.
2282 + */
2283 + /**
2284 + * Write a cache sidecar so a partial file is never visible.
2285 + *
2286 + * `file_put_contents()` truncates the target and then fills it, so any
2287 + * reader arriving mid-write — or any crash, full disk, or killed worker
2288 + * — leaves a SHORT file under the real name. For HTML that degrades to a
2289 + * clipped page; for a `.br` sibling it is worse, because a truncated
2290 + * brotli stream is not a short page but an UNINFLATABLE one: the browser
2291 + * renders nothing at all and the navigation can hang.
2292 + *
2293 + * Writing to a unique temp file in the same directory and renaming is
2294 + * atomic on POSIX, so readers see either the previous complete file or
2295 + * the new complete file, never a partial one. This is the half of #286
2296 + * that is actually fixable — a read-time heuristic cannot tell a
2297 + * truncated brotli stream from a very small valid one, but a truncated
2298 + * file that never becomes visible needs no detection.
2299 + *
2300 + * @param string $path Absolute destination path.
2301 + * @param string $contents Bytes to write.
2302 + * @return bool True when the destination now holds exactly $contents.
2303 + */
2304 + public static function write_atomic( string $path, string $contents ): bool {
2305 + $dir = dirname( $path );
2306 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- WP_Filesystem needs admin creds unavailable on a frontend cache write; this is our own cache dir.
2307 + if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) {
2308 + return false;
2309 + }
2310 +
2311 + // Same directory, so the rename stays on one filesystem — a rename
2312 + // across devices is a copy and loses atomicity.
2313 + $tmp = @tempnam( $dir, '.xspeed-tmp-' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a failure returns false and the caller skips the write.
2314 + if ( ! is_string( $tmp ) || '' === $tmp ) {
2315 + return false;
2316 + }
2317 +
2318 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem needs admin creds unavailable on a frontend cache write; target is our own cache dir.
2319 + $written = @file_put_contents( $tmp, $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- handled by the length check below.
2320 +
2321 + // A short write is exactly the failure this function exists to
2322 + // prevent, so verify the byte count before publishing the file.
2323 + if ( false === $written || $written !== strlen( $contents ) ) {
2324 + @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own temp file; non-fatal.
2325 + return false;
2326 + }
2327 +
2328 + // tempnam() creates the file 0600; cache files must stay readable by
2329 + // the web server, which may run as a different user.
2330 + @chmod( $tmp, defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod, WordPress.PHP.NoSilencedErrors.Discouraged -- the web server may run as another uid and must be able to read the published file; a chmod failure is not fatal.
2331 +
2332 + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged -- the atomic publish this function exists for; WP_Filesystem offers no atomic rename and needs admin creds.
2333 + if ( ! @rename( $tmp, $path ) ) {
2334 + @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup; non-fatal.
2335 + return false;
2336 + }
2337 +
2338 + return true;
2339 + }
2340 +
2341 + public static function brotli_sibling_is_usable( string $file, string $br ): bool {
2342 + $br_size = (int) @filesize( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a stat failure means "don't serve it", handled by the <= 0 check.
2343 + if ( $br_size <= 0 ) {
2344 + return false;
2345 + }
2346 +
2347 + $html_size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
2348 + if ( $html_size <= 0 ) {
2349 + return false;
2350 + }
2351 +
2352 + // A sibling older than the page it compresses is stale.
2353 + $br_mtime = (int) @filemtime( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
2354 + $html_mtime = (int) @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
2355 + if ( $br_mtime > 0 && $html_mtime > 0 && $br_mtime < $html_mtime ) {
2356 + return false;
2357 + }
2358 +
2359 + // The writer recorded how many bytes it produced. Where that record
2360 + // exists, truncation is a certainty rather than an inference: a
2361 + // stream shorter than its own declared length cannot inflate, and
2362 + // one that matches was published whole. This is what a size ratio
2363 + // could never be — brotli's ratio is unbounded on repetitive input,
2364 + // so a 0.01% sibling of a generated page is genuinely valid.
2365 + //
2366 + // Absent for a sibling written before this version, or by an add-on
2367 + // that writes the file directly. That case keeps the checks above
2368 + // and no more, which is where a pre-existing truncated file on a
2369 + // live site still slips through — write_atomic() stops NEW ones,
2370 + // but it cannot retroactively vouch for what is already on disk.
2371 + $expected = self::brotli_expected_size( $br );
2372 + if ( $expected > 0 && $br_size !== $expected ) {
2373 + return false;
2374 + }
2375 +
2376 + return true;
2377 + }
2378 +
2379 + /**
2380 + * Path of the sidecar recording a `.br` sibling's complete byte count.
2381 + *
2382 + * Kept beside the sibling as `<file>.html.br.size` rather than folded
2383 + * into the entry's `.meta`: the static tree the web server serves has no
2384 + * `.meta` at all, and the two trees must answer this question the same
2385 + * way. Every path that deletes a `.br` deletes this with it.
2386 + *
2387 + * @param string $br Absolute path to the `.br` sibling.
2388 + * @return string Absolute path to its size sidecar.
2389 + */
2390 + public static function brotli_size_sidecar( string $br ): string {
2391 + return $br . '.size';
2392 + }
2393 +
2394 + /**
2395 + * The byte count the writer recorded for a `.br` sibling, or 0 when no
2396 + * record exists (a sibling predating this version, or written by an
2397 + * add-on that bypassed write_brotli_sibling()).
2398 + *
2399 + * @param string $br Absolute path to the `.br` sibling.
2400 + * @return int Expected size in bytes, or 0 when unknown.
2401 + */
2402 + public static function brotli_expected_size( string $br ): int {
2403 + $sidecar = self::brotli_size_sidecar( $br );
2404 + if ( ! is_file( $sidecar ) ) {
2405 + return 0;
2406 + }
2407 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend HIT.
2408 + $raw = @file_get_contents( $sidecar ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable sidecar means "unknown", handled by the cast below.
2409 + return max( 0, (int) trim( (string) $raw ) );
2410 + }
2411 +
2412 + /**
2413 + * Publish a `.br` sibling together with the record of its own length.
2414 + *
2415 + * The single writer every producer of a `.br` should route through — the
2416 + * Pro Brotli module included. Publishing the body atomically stops a
2417 + * truncated file from ever becoming visible; recording the byte count
2418 + * lets the serve path prove wholeness for the files that already exist
2419 + * on disk when this ships.
2420 + *
2421 + * Order matters: the size sidecar is removed first and written last, so
2422 + * a reader arriving mid-update sees "no record" (checks above still
2423 + * apply) rather than the previous body's length against the new body.
2424 + *
2425 + * @param string $br Absolute path to the `.br` sibling to write.
2426 + * @param string $contents Compressed bytes.
2427 + * @return bool True when both the sibling and its size record are in place.
2428 + */
2429 + public static function write_brotli_sibling( string $br, string $contents ): bool {
2430 + $sidecar = self::brotli_size_sidecar( $br );
2431 + if ( is_file( $sidecar ) ) {
2432 + wp_delete_file( $sidecar );
2433 + }
2434 +
2435 + if ( ! self::write_atomic( $br, $contents ) ) {
2436 + return false;
2437 + }
2438 +
2439 + if ( self::write_atomic( $sidecar, (string) strlen( $contents ) ) ) {
2440 + return true;
2441 + }
2442 +
2443 + // The body landed but its length did not. That sibling is servable
2444 + // and unguarded — exactly the file this function exists to prevent —
2445 + // and the caller has no way to know. Withdraw it: a MISS costs one
2446 + // uncompressed response, where an unguarded sibling can cost a blank
2447 + // page for as long as the entry lives.
2448 + wp_delete_file( $br );
2449 + return false;
2450 + }
2451 +
2452 + /**
517 2453 * Sidecar metadata file for a cache entry. Holds response bits the HIT
518 2454 * path must replay — Content-Type (cached feeds → application/rss+xml,
519 2455 * sitemaps → text/xml) and status (a cached 404 must serve 404, not
520 2456 * 200). JSON, one tiny file per entry, written only when there's
@@ -520,17 +2456,19 @@
520 2456 * 200). JSON, one tiny file per entry, written only when there's
521 2457 * something non-default to replay.
522 2458 */
523 2459 public static function cache_meta_for( $key ) {
524 - return XSPEED_CACHE_DIR . '/' . $key . '.meta';
2460 + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.meta';
525 2461 }
526 2462
527 2463 /**
528 2464 * Read the .meta sidecar for a cache entry as an array, or [] if none.
529 - * Keys: 'content_type' (string), 'status' (int). Used on the HIT path
530 - * to replay them before streaming the file.
2465 + * Keys: 'content_type' (string), 'status' (int), 'ttl' (int seconds).
2466 + * Used on the HIT path to replay content-type/status before streaming
2467 + * the file, and by Cache_GC to age an entry by its own TTL rather than
2468 + * the global one — hence public.
531 2469 */
532 - private static function read_meta( $key ): array {
2470 + public static function read_meta( $key ): array {
533 2471 $meta_file = self::cache_meta_for( $key );
534 2472 if ( ! file_exists( $meta_file ) ) {
535 2473 return array();
536 2474 }
@@ -596,9 +2534,45 @@
596 2534 * @param int $max_age Computed max-age in seconds.
597 2535 */
598 2536 $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
599 2537
600 - return ( time() - filemtime( $file ) ) > $max_age;
2538 + // Honour the per-entry TTL the .meta sidecar carries, when it is
2539 + // SHORTER than what we just resolved. The sidecar records the TTL
2540 + // this specific entry was written under — a nonce cap (#236), a Pro
2541 + // feed/404 expiry — and the drop-in already reads it. is_expired()
2542 + // did not, so on the engine path a capped entry was still served for
2543 + // the full configured lifetime: exactly the stale nonce the cap
2544 + // exists to prevent. Only ever shortens, so an entry can never be
2545 + // kept alive past the configured maximum by a stale sidecar.
2546 + // Derive the sidecar from the FILE we were handed rather than
2547 + // recomputing cache_key(): callers legitimately ask about an entry
2548 + // that isn't the current request's (Cache_GC sweeps, Pro's warmer),
2549 + // and cache_key() would answer for the wrong one — besides needing a
2550 + // request context this function has no business requiring.
2551 + $meta_file = preg_replace( '/\.html$/', '.meta', (string) $file );
2552 + if ( is_string( $meta_file ) && $meta_file !== $file && is_readable( $meta_file ) ) {
2553 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend read.
2554 + $raw = file_get_contents( $meta_file );
2555 + $decoded = is_string( $raw ) ? json_decode( $raw, true ) : null;
2556 + if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) {
2557 + $entry_ttl = (int) $decoded['ttl'];
2558 + if ( $entry_ttl > 0 && ( $max_age < 1 || $entry_ttl < $max_age ) ) {
2559 + $max_age = $entry_ttl;
2560 + }
2561 + }
2562 + }
2563 +
2564 + // A missing file is "expired" — the caller should re-render. Guard
2565 + // filemtime() rather than letting it warn: callers legitimately ask
2566 + // about a file that isn't there (Pro's predictive warmer probes for
2567 + // freshness, and Cache_GC can collect an entry between the check and
2568 + // the read), and on a site with WP_DEBUG the warning is noise.
2569 + $mtime = file_exists( $file ) ? filemtime( $file ) : false;
2570 + if ( false === $mtime ) {
2571 + return true;
2572 + }
2573 +
2574 + return ( time() - (int) $mtime ) > $max_age;
601 2575 }
602 2576
603 2577 /**
604 2578 * Accumulator for the full response body across all output-handler phases.
@@ -683,19 +2657,74 @@
683 2657 $buffer = $full;
684 2658 }
685 2659 }
686 2660
687 - if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
688 - wp_mkdir_p( XSPEED_CACHE_DIR );
689 - self::write_silence( XSPEED_CACHE_DIR );
2661 + // AFTER minification on purpose — the HTML minifier strips comments,
2662 + // so signing earlier would erase the signature from every minified
2663 + // page. Baked into the cached bytes so all three serve paths (nginx
2664 + // static rewrite, .htaccess, the PHP drop-in) carry it identically.
2665 + $full = self::signed( $full );
2666 + if ( $single_chunk ) {
2667 + $buffer = $full;
690 2668 }
691 2669
692 - // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
693 - // where $key comes from md5() — guaranteed to be exactly 32 lowercase
694 - // hex chars, so no traversal sequence ('..', '/', null byte, etc.)
695 - // can appear. The write is therefore always inside XSPEED_CACHE_DIR.
696 - $key = self::cache_key();
2670 + // Per-site directory — see ensure_host_dir(). (#6)
2671 + self::ensure_host_dir();
2672 +
2673 + // Path safety: cache_file_for() builds
2674 + // `XSPEED_CACHE_DIR . '/' . <host> . '/' . $key . '.html'` where $key
2675 + // comes from md5() — guaranteed to be exactly 32 lowercase hex chars —
2676 + // and <host> is filtered by host_dir() to [A-Za-z0-9.-] with leading
2677 + // dots trimmed, so no traversal sequence ('..', '/', null byte, etc.)
2678 + // can appear in either segment. The write is therefore always inside
2679 + // XSPEED_CACHE_DIR.
2680 + $key = self::cache_key();
2681 +
2682 + // Query-string gate. should_cache() waved this request through
2683 + // because every param is on the ignored_query_params allow-list, and
2684 + // cache_key() drops the query so reads share the canonical entry.
2685 + // That sharing is safe on READ but not on WRITE: this response was
2686 + // rendered WITH the params, and WordPress reflects REQUEST_URI into
2687 + // form actions, share links and plugin smart tags — so storing it
2688 + // would serve an attacker-chosen variant under the clean URL for the
2689 + // whole TTL (#241).
2690 + //
2691 + // This sits BELOW the transforms deliberately. Returning above them
2692 + // also skipped xspeed_cache_final_html, and every listener disables
2693 + // its own fallback ob_start() when the page cache is on precisely
2694 + // because that filter is the shared transport — so a visitor
2695 + // arriving on ?utm_source=… was served HTML with no LCP preload, no
2696 + // preconnect, no CDN rewrite, no CSS combine and no HTML minify.
2697 + // That is the ad-click and newsletter cohort getting the least
2698 + // optimised page on the site. Only the WRITE is skipped, which is
2699 + // what this fix was always meant to do — and it is where the
2700 + // deferred writer has always placed its own copy of the guard.
2701 + if ( self::query_string_blocks_write() ) {
2702 + return $buffer;
2703 + }
697 2704 $file = self::cache_file_for( $key );
2705 +
2706 + // A render-time translation plugin (TranslatePress) wraps our buffer,
2707 + // so the bytes we hold here are still UNTRANSLATED — its callback has
2708 + // not run yet, and writing now would cache English under a French URL
2709 + // and bake in its internal #TRPLINKPROCESSED markers. Hand off to
2710 + // shutdown, where the outer buffer has already translated, and let
2711 + // the pass-through below deliver this request untouched.
2712 + if ( self::translation_plugin_active() ) {
2713 + self::$deferred_key = $key;
2714 + // Reaching here means finalize_buffer() ran to completion: the
2715 + // status gate passed, should_cache() said yes, and PHP handed us
2716 + // the whole buffer. A wp_die() or exit() mid-render unwinds the
2717 + // buffer stack WITHOUT calling this callback, so the flag stays
2718 + // false and the shutdown writer declines — see the guard there.
2719 + self::$render_completed = true;
2720 + // A PHP shutdown function, not a WP `shutdown` action: this must
2721 + // run after the output-buffer stack has unwound, and WP's
2722 + // shutdown action fires while our outer buffer is still open.
2723 + register_shutdown_function( array( __CLASS__, 'write_deferred_translated_cache' ) );
2724 + return $buffer;
2725 + }
2726 +
698 2727 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache writes happen on frontend requests where it's unavailable.
699 2728 file_put_contents( $file, $full, LOCK_EX );
700 2729
701 2730 /**
@@ -717,9 +2746,9 @@
717 2746 // Persist a non-default Content-Type so the HIT path can replay it
718 2747 // (cached feeds must serve application/rss+xml, not text/html).
719 2748 // Only written when the response set a content-type other than
720 2749 // the HTML default — pages don't pay for an extra file.
721 - self::write_meta( $key );
2750 + self::write_meta( $key, $full );
722 2751
723 2752 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
724 2753 // .htaccess rewrite block serves this file directly via the web
725 2754 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
@@ -734,9 +2763,16 @@
734 2763 // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
735 2764 // out as text/html, FBS-82407). The web server serves these .html files
736 2765 // directly with no PHP, so there's no .meta replay — keep them on the
737 2766 // drop-in / PHP path instead, which DOES replay status + content-type.
738 - if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
2767 + // The static tree cannot replay a sidecar. A file served straight by
2768 + // the web server carries the headers baked into the rule that serves
2769 + // the whole site — the very answer this entry exists because it
2770 + // disagreed with. Same reasoning as the status and content-type
2771 + // cases: what the fast path cannot replay belongs on the drop-in path.
2772 + if ( self::static_rewrite_allowed()
2773 + && self::response_is_plain_html()
2774 + && array() === self::per_entry_edge_headers() ) {
739 2775 self::store_static( $full );
740 2776 }
741 2777
742 2778 return $buffer;
@@ -752,13 +2788,149 @@
752 2788 * $uri has its query string stripped, null bytes removed, '..'
753 2789 * sequences collapsed, and after concatenation we verify the
754 2790 * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
755 2791 * any write. Anything off the happy path returns silently.
2792 + *
2793 + * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING
2794 + * else, and both generated rewrites refuse any request that carries a
2795 + * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache,
2796 + * `if ($args)` in nginx_snippet()). So a response may only be stored
2797 + * here when cache_key() adds no discriminator beyond `{host}{path}`:
2798 + * a query-keyed entry can never be *served* from here, only mis-served
2799 + * as the bare path. Any future opt-in that folds a query param into the
2800 + * key needs a guard below, exactly like the search one.
756 2801 */
2802 + /**
2803 + * Transient holding the most recent static-tree refusal.
2804 + *
2805 + * Short-lived on purpose: it describes what the last cacheable render
2806 + * actually did, so a stale entry would keep warning about a page whose
2807 + * nonces have since been removed. A site that still refuses simply
2808 + * rewrites it on the next render. (#372)
2809 + */
2810 + private const STATIC_SKIP_TRANSIENT = 'xspeed_static_skip';
2811 +
2812 + /**
2813 + * Remember why a page was kept out of the static tree, for Health.
2814 + *
2815 + * Records the URL, the reason, and — for the nonce case — the distinct
2816 + * nonce KEYS found, which is what makes the finding actionable: the names
2817 + * (`eael_login_nonce`, `post_grid_pagination_nonce`, …) trace straight back
2818 + * to the plugin emitting them, and it is usually a widget the site does not
2819 + * use on that page. Only key names are kept, never the nonce values.
2820 + *
2821 + * @param string $reason Machine-readable refusal reason.
2822 + * @param string $html The response, for extracting the nonce keys.
2823 + */
2824 + private static function note_static_skip( string $reason, string $html = '' ): void {
2825 + if ( ! function_exists( 'set_transient' ) ) {
2826 + return;
2827 + }
2828 +
2829 + $keys = array();
2830 + if ( 'nonce' === $reason && '' !== $html ) {
2831 + // Must recognise the SAME shapes response_has_nonce() refuses on,
2832 + // or a page is skipped and reported with no keys at all — which is
2833 + // most of them, since the plain `name="_wpnonce"` form field is the
2834 + // commonest shape by far and only the JSON one was handled here.
2835 + // The keys are the actionable half of the message, so a mismatch
2836 + // leaves the admin with bad news and nothing to act on.
2837 + //
2838 + // Both alternations capture the KEY only: each value pattern sits
2839 + // outside the capture group, so a nonce secret can never be stored.
2840 + $found = array();
2841 + if ( preg_match_all( '/name=["\']([a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*)["\']/i', $html, $m ) ) {
2842 + $found = array_merge( $found, $m[1] );
2843 + }
2844 + if ( preg_match_all( '/["\']([a-z0-9_\-]*nonce[a-z0-9_\-]*)["\']\s*:\s*["\'][a-f0-9]{8,}["\']/i', $html, $m ) ) {
2845 + $found = array_merge( $found, $m[1] );
2846 + }
2847 + // The query-arg shape (`?_wpnonce=…`) has no key name to report
2848 + // beyond the literal, so name it explicitly rather than reporting
2849 + // nothing for a page that was genuinely refused.
2850 + if ( preg_match( '/[?&]_wpnonce=/i', $html ) ) {
2851 + $found[] = '_wpnonce';
2852 + }
2853 + $keys = array_slice( array_values( array_unique( $found ) ), 0, 10 );
2854 + }
2855 +
2856 + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
2857 +
2858 + set_transient(
2859 + self::STATIC_SKIP_TRANSIENT,
2860 + array(
2861 + 'reason' => $reason,
2862 + 'url' => (string) strtok( $uri, '?' ),
2863 + 'keys' => $keys,
2864 + 'at' => time(),
2865 + ),
2866 + HOUR_IN_SECONDS
2867 + );
2868 + }
2869 +
2870 + /**
2871 + * The most recent static-tree refusal, or an empty array when there is none.
2872 + *
2873 + * @return array{reason:string,url:string,keys:string[],at:int}|array{}
2874 + */
2875 + public static function last_static_skip(): array {
2876 + $stored = function_exists( 'get_transient' ) ? get_transient( self::STATIC_SKIP_TRANSIENT ) : false;
2877 + return is_array( $stored ) && ! empty( $stored['reason'] ) ? $stored : array();
2878 + }
2879 +
757 2880 private static function store_static( string $html ): void {
2881 + // Search results are keyed by term in cache_key() (`|s=<term>`) but
2882 + // carry the *path* of whatever URL was searched from — for the usual
2883 + // `/?s=<term>` that path is `/`. Writing them here would file the
2884 + // results page as `{host}/index.html` and the web server would serve
2885 + // it to every visitor as the homepage: an unauthenticated visitor
2886 + // poisons the front page with one request. Searches stay on the
2887 + // drop-in, which replays the term-keyed entry correctly. (#191)
2888 + //
2889 + // This is a superset of the query-string check the exclusion gate
2890 + // does: it also covers `/?%73=<term>`, which decodes to the same
2891 + // search (the shape #109 fixed on the gate side).
2892 + if ( self::should_cache_search() ) {
2893 + return;
2894 + }
2895 +
2896 + // Same hazard for the allow-listed query params: store_static()
2897 + // strips the query and files the response under the bare path, which
2898 + // the web server then serves to every visitor of the clean URL with
2899 + // no PHP involved at all — so none of the engine's checks can catch
2900 + // it later (#241). The callers already gate on this, but the guard
2901 + // is repeated here because this tree is the most dangerous of the
2902 + // three write sites and must not depend on its callers.
2903 + if ( self::request_has_query_string() ) {
2904 + return;
2905 + }
2906 +
2907 + // A nonce-bearing page is served here with NO PHP: no TTL check and
2908 + // no .meta replay, so the per-entry cap that keeps the drop-in honest
2909 + // (#236) cannot reach a file once it is written. Only Cache_GC removes
2910 + // it, and until it does the page hands every visitor the same nonce —
2911 + // which, once that nonce dies, breaks every anonymous form on it.
2912 + //
2913 + // Refusing outright was the safe answer, and it cost every
2914 + // nonce-bearing page the static tree entirely: a site whose homepage
2915 + // carries one unused login nonce ran PHP on every request forever.
2916 + // The nonce's own remaining life is the better gate — the page is
2917 + // written and its deadline recorded below for GC to enforce.
2918 + //
2919 + // A nonce we cannot put a clock on is still refused, and that refusal
2920 + // is still recorded: it stays completely silent otherwise, because the
2921 + // drop-in answers HIT while Health reports the fast path active from a
2922 + // probe that writes its OWN file and never proves real pages reach the
2923 + // tree. (#372)
2924 + $nonce_ttl = self::response_has_nonce( $html ) ? self::nonce_capped_ttl( $html, 0 ) : 0;
2925 + if ( self::response_has_nonce( $html ) && $nonce_ttl < 1 ) {
2926 + self::note_static_skip( 'nonce', $html );
2927 + return;
2928 + }
2929 +
758 2930 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
759 2931 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
760 - $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
2932 + $host = self::static_host_dir( $host );
761 2933 $uri = str_replace( "\0", '', $uri );
762 2934 $uri = (string) strtok( $uri, '?' );
763 2935 if ( '' === $host || '' === $uri ) {
764 2936 return;
@@ -789,8 +2961,18 @@
789 2961 }
790 2962 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Same rationale as the flat-hash cache write above: WP_Filesystem isn't available on frontend requests, and the cache write must happen during shutdown.
791 2963 $written = file_put_contents( $file, $html, LOCK_EX );
792 2964
2965 + // A nonce-bearing page expires on the nonce's schedule, not the site's.
2966 + // Nothing reads this file at serve time — the web server hands over
2967 + // index.html without PHP — so the deadline is recorded beside it for
2968 + // GC, which is the only thing that can enforce it. Written before the
2969 + // action below so a listener that shells out cannot race the sweep.
2970 + if ( false !== $written && $nonce_ttl > 0 ) {
2971 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- same rationale as the write above.
2972 + file_put_contents( $dir . '/.xspeed-expires', (string) ( time() + $nonce_ttl ), LOCK_EX );
2973 + }
2974 +
793 2975 if ( false !== $written ) {
794 2976 /**
795 2977 * Fires after a static cache file (index.html) is written.
796 2978 *
@@ -842,9 +3024,236 @@
842 3024 }
843 3025 return true;
844 3026 }
845 3027
846 - private static function write_meta( string $key ): void {
3028 + /**
3029 + * Append the cache signature comment to a finished page.
3030 + *
3031 + * The plugin's one outward version signal: external scanners (the
3032 + * xspeedcache.com speed test among them) read it to detect xSpeed and
3033 + * its version on a cached page, the way other cache plugins sign their
3034 + * output. Callers apply it AFTER HTML minification — the minifier strips
3035 + * comments — and before every cache write, so all serve paths carry the
3036 + * same bytes.
3037 + *
3038 + * The generation time is baked in here, at write time, in UTC. It is the
3039 + * moment the cached bytes were produced — NOT the moment they were served
3040 + * — because all three serve paths replay the same stored file, and two of
3041 + * them (the nginx/`.htaccess` static rewrite) run no PHP at all and so
3042 + * could never stamp a serve-time value. Reading the age of a page is the
3043 + * point: `generated` plus the current clock tells you how stale it is.
3044 + * `gmdate()` (not `current_time()`) keeps the value comparable across
3045 + * sites regardless of the configured timezone.
3046 + *
3047 + * @param string $html Finished page HTML.
3048 + * @return string HTML with the signature appended (or unchanged when a
3049 + * filter removed it).
3050 + */
3051 + private static function signed( string $html ): string {
3052 + $version = defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : '';
3053 + $generated = gmdate( 'Y-m-d H:i:s' ) . ' UTC';
3054 + // The literal ' | xspeedcache.com' must survive intact, and what
3055 + // precedes it is where an edition suffix lands: Pro appends itself by
3056 + // str_replace()-ing on that exact token
3057 + // (Pro_Plugin::sign_cache_signature). So the stamp goes AFTER it —
3058 + // placed before, it sits between the version and the anchor and
3059 + // composes as "generated <date> + Pro v1.1.3".
3060 + $signature = sprintf(
3061 + '<!-- Page cached by xSpeed Cache v%s | xspeedcache.com | generated %s -->',
3062 + $version,
3063 + $generated
3064 + );
3065 +
3066 + /**
3067 + * Filter: xspeed_cache_signature
3068 + *
3069 + * The HTML comment appended to every cached page. Add-ons append
3070 + * their own edition/version here; white-label setups return '' to
3071 + * remove the comment entirely. Must remain a valid HTML comment (or
3072 + * an empty string) — it ships inside the cached body.
3073 + *
3074 + * @param string $signature The signature comment.
3075 + * @param string $version The plugin version baked into it.
3076 + * @param string $generated The write-time timestamp baked into it,
3077 + * formatted `Y-m-d H:i:s UTC`.
3078 + */
3079 + $signature = (string) apply_filters( 'xspeed_cache_signature', $signature, $version, $generated );
3080 + if ( '' === trim( $signature ) ) {
3081 + return $html;
3082 + }
3083 + return $html . "\n" . $signature;
3084 + }
3085 +
3086 + /**
3087 + * Does this response carry a WordPress nonce?
3088 + *
3089 + * Anonymous nonces depend only on the tick (user 0, empty session
3090 + * token), so they are identical for every visitor — which is exactly why
3091 + * they cache "successfully" and then fail silently once the tick moves.
3092 + *
3093 + * Matches any form field whose NAME contains "nonce" — `_wpnonce`,
3094 + * `_wpnonce_<action>`, Tutor's `_tutor_nonce`, CF7's `_wpcf7_nonce` and
3095 + * WooCommerce's `woocommerce-add-to-cart-nonce` (which does NOT start
3096 + * with an underscore, so a `_`-anchored pattern misses it) — plus the
3097 + * `_wpnonce=` form used in nonce-bearing URLs. Deliberately keyed on
3098 + * `name=` so prose, CSS classes and data attributes don't false-positive.
3099 + *
3100 + * @param string $html Rendered response body.
3101 + */
3102 + public static function response_has_nonce( string $html ): bool {
3103 + if ( '' === $html ) {
3104 + return false;
3105 + }
3106 +
3107 + /*
3108 + * Three shapes, because a nonce reaches the page in three ways:
3109 + *
3110 + * 1. A form field name — `_wpnonce`, `woocommerce-login-nonce`, and
3111 + * the GROUPED names form builders emit (`data[_wpnonce]`,
3112 + * `frm[nonce]`). The character class deliberately allows `[` and
3113 + * `]` so grouping does not hide the field: form builders are
3114 + * exactly the kind of plugin #236 is about, and a missed page
3115 + * keeps the old broken behaviour silently.
3116 + * 2. A query argument (`?_wpnonce=`) on a link.
3117 + * 3. A nonce handed to the page's own scripts rather than placed in
3118 + * a visible form — `wp_localize_script()` output and inline JSON
3119 + * both land as a `"nonce":"…"`-shaped pair.
3120 + */
3121 + return 1 === preg_match(
3122 + '/(name=["\'][a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*["\']'
3123 + . '|[?&]_wpnonce='
3124 + . '|["\'][a-z0-9_\-]*nonce[a-z0-9_\-]*["\']\s*:\s*["\'][a-f0-9]{8,}["\'])/i',
3125 + $html
3126 + );
3127 + }
3128 +
3129 + /**
3130 + * The TTL (seconds) a response may be cached for, capped to the nonce
3131 + * lifetime when it carries one.
3132 + *
3133 + * WordPress nonces are valid for at most `nonce_life` — 24h by default —
3134 + * because wp_verify_nonce() accepts the current tick and the previous
3135 + * one. Our own lifetime maximum is 720h and the shipped Aggressive
3136 + * preset is 168h, so on any site configured above 24h every anonymous
3137 + * front-end form carried a DEAD nonce for the majority of the cache's
3138 + * life and every submission was rejected — with the other plugin's error
3139 + * string ("Nonce not matched"), so the report never reached us (#236).
3140 + *
3141 + * `nonce_life` is the MAXIMUM a nonce can live, not the minimum, so it
3142 + * is the wrong number to cap with. wp_nonce_tick() buckets time into
3143 + * `nonce_life / 2` slices; a nonce minted x seconds into its bucket is
3144 + * valid for `nonce_life - x`, where x can be as large as a full bucket.
3145 + * Capping the entry at `nonce_life` therefore still served a dead nonce
3146 + * for up to half of every entry's life — 0-12h of each 24h entry,
3147 + * averaging 6h, re-rolled by every purge so it reads as intermittent.
3148 + * Capping at the guaranteed-valid remainder closes the window at every
3149 + * tick phase, at the cost of caching nonce-bearing pages for 12h rather
3150 + * than 24h.
3151 + *
3152 + * Capping is per-entry, so only nonce-bearing pages pay for it; the rest
3153 + * of the site keeps the configured lifetime.
3154 + *
3155 + * @param string $html Rendered response body.
3156 + * @param int $ttl Otherwise-resolved TTL in seconds.
3157 + * @return int TTL to actually use.
3158 + */
3159 + /**
3160 + * The nonce lifetime to cap against, in seconds.
3161 + *
3162 + * `nonce_life` is a TWO-argument filter in core:
3163 + *
3164 + * $nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action );
3165 + *
3166 + * Applying it with one argument is not merely incomplete — a callback
3167 + * that declares both parameters as required (the documented shape, and
3168 + * what a site branching per action must write) raises ArgumentCountError
3169 + * the moment we call it. That fatal lands in the shutdown cache write,
3170 + * so the visitor still sees a perfectly normal page while the sidecar is
3171 + * never written: the entry then keeps the FULL configured lifetime
3172 + * carrying a dead nonce, which is precisely the bug #236 set out to fix.
3173 + * Worse, the entry stays that way until a purge, even after the site
3174 + * removes whatever customised the lifetime.
3175 + *
3176 + * We are inspecting rendered markup, so we cannot know which action
3177 + * minted the nonce we found. Two consequences:
3178 + *
3179 + * 1. We pass `''` as the action. A per-action callback therefore sees
3180 + * the same "unknown action" value core itself passes when a nonce is
3181 + * created with no action, and can branch on it deliberately.
3182 + * 2. A page may carry nonces from SEVERAL actions with different
3183 + * lifetimes. The entry can only have one TTL, so the safe choice is
3184 + * the SHORTEST lifetime any action on the site resolves to — capping
3185 + * to a longer one would serve a dead nonce for the shorter action.
3186 + * Sites can narrow this with `xspeed_cache_nonce_life_actions`.
3187 + *
3188 + * @param string $html Response body being cached.
3189 + * @return int Nonce lifetime in seconds (0 = do not cap).
3190 + */
3191 + private static function nonce_life_seconds( string $html ): int {
3192 + /**
3193 + * Filter the nonce actions whose lifetimes are consulted when
3194 + * capping a cache entry.
3195 + *
3196 + * The default `''` is the "action unknown" case — we are reading
3197 + * rendered HTML, not minting a nonce. A site whose `nonce_life`
3198 + * callback shortens specific actions can list them here so the cap
3199 + * accounts for the shortest one that could appear on the page.
3200 + *
3201 + * @since 1.1.8
3202 + * @param string[] $actions Nonce actions to resolve.
3203 + * @param string $html The response body being cached.
3204 + */
3205 + $actions = (array) apply_filters( 'xspeed_cache_nonce_life_actions', array( '' ), $html );
3206 + if ( empty( $actions ) ) {
3207 + $actions = array( '' );
3208 + }
3209 +
3210 + $shortest = 0;
3211 + foreach ( $actions as $action ) {
3212 + // Both arguments, exactly as core passes them.
3213 + $life = (int) apply_filters( 'nonce_life', DAY_IN_SECONDS, (string) $action );
3214 + if ( $life < 1 ) {
3215 + continue;
3216 + }
3217 + if ( 0 === $shortest || $life < $shortest ) {
3218 + $shortest = $life;
3219 + }
3220 + }
3221 +
3222 + return $shortest;
3223 + }
3224 +
3225 + public static function nonce_capped_ttl( string $html, int $ttl ): int {
3226 + if ( ! self::response_has_nonce( $html ) ) {
3227 + return $ttl;
3228 + }
3229 +
3230 + $nonce_life = self::nonce_life_seconds( $html );
3231 + if ( $nonce_life < 1 ) {
3232 + return $ttl;
3233 + }
3234 +
3235 + // Half of nonce_life is the GUARANTEED-valid remainder — see above.
3236 + $guaranteed = max( 1, intdiv( $nonce_life, 2 ) );
3237 + $capped = ( $ttl > 0 ) ? min( $ttl, $guaranteed ) : $guaranteed;
3238 +
3239 + /**
3240 + * Filter the nonce-capped TTL for a cache entry.
3241 + *
3242 + * Escape hatch for a site whose nonce-shaped markup is decorative —
3243 + * return the uncapped $ttl to keep the configured lifetime. Most
3244 + * sites should leave this alone: serving a dead nonce breaks every
3245 + * anonymous form on the page.
3246 + *
3247 + * @param int $capped TTL after the nonce cap (seconds).
3248 + * @param int $ttl TTL before the cap (seconds).
3249 + * @param int $nonce_life Current nonce lifetime (seconds).
3250 + * @param string $html The response body being cached.
3251 + */
3252 + return (int) apply_filters( 'xspeed_cache_nonce_ttl_cap', $capped, $ttl, $nonce_life, $html );
3253 + }
3254 +
3255 + private static function write_meta( string $key, string $html = '' ): void {
847 3256 $content_type = '';
848 3257 foreach ( headers_list() as $header ) {
849 3258 if ( 0 === stripos( $header, 'content-type:' ) ) {
850 3259 $content_type = trim( substr( $header, strlen( 'content-type:' ) ) );
@@ -865,15 +3274,48 @@
865 3274 // call is_expired() / the xspeed_cache_max_age filter (they run before
866 3275 // WP), so persist the resolved max-age here whenever it differs from
867 3276 // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page
868 3277 // default. The fast paths read this to expire correctly. (FBS-82407)
869 - $opts = Settings_Manager::get( 'cache' );
870 - $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
871 - $ttl = (int) apply_filters( 'xspeed_cache_max_age', $default_ttl );
3278 + // This MUST resolve the TTL the same way is_expired() does, including
3279 + // the per-post override — the sidecar is the only channel that can
3280 + // carry a per-entry TTL into the pre-boot fast paths. Omitting the
3281 + // override here left an editor's "expire this post after 1h" visible
3282 + // to the engine but invisible to the drop-in, which kept serving the
3283 + // entry until the global lifetime elapsed (#240 AC#3). Handing the
3284 + // filter the same base as is_expired() also keeps a filter that
3285 + // SCALES its input (e.g. $max_age * 2) consistent between the two.
3286 + $opts = Settings_Manager::get( 'cache' );
3287 + $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
3288 + $max_age = $default_ttl;
3289 + $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
3290 + if ( null !== $post_override ) {
3291 + $max_age = $post_override;
3292 + }
3293 + /** This filter is documented in includes/class-cache.php */
3294 + $ttl = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
3295 +
3296 + // A response carrying a nonce may not outlive that nonce, however
3297 + // long the site's configured lifetime is (#236). This runs AFTER the
3298 + // max-age filter so it caps whatever the filter resolved rather than
3299 + // being overridden by it — a Pro module lengthening the TTL must not
3300 + // be able to reintroduce a dead nonce.
3301 + $ttl = self::nonce_capped_ttl( $html, $ttl );
3302 +
872 3303 if ( $ttl > 0 && $ttl !== $default_ttl ) {
873 3304 $meta['ttl'] = $ttl;
874 3305 }
875 3306
3307 + // This entry's edge headers, when they differ from the site-wide set
3308 + // baked into the drop-in. The sidecar is the only channel that can
3309 + // carry a per-page answer into the pre-boot fast path, and the drop-in
3310 + // REPLACES the baked set with it rather than merging: the two describe
3311 + // the same response, so merging would leave the baked lifetime in
3312 + // place beside the hold meant to overrule it.
3313 + $edge = self::per_entry_edge_headers();
3314 + if ( array() !== $edge ) {
3315 + $meta['edge_headers'] = $edge;
3316 + }
3317 +
876 3318 // Nothing to replay → no sidecar.
877 3319 if ( empty( $meta ) ) {
878 3320 return;
879 3321 }
@@ -903,17 +3345,747 @@
903 3345 * @param string $url Absolute URL, or site-relative path ("/about/").
904 3346 * @param string $cause Who asked, for the purge log. See purge_all().
905 3347 * @return int Number of cache files removed.
906 3348 */
3349 + /**
3350 + * Post types that are not "viewable" but ARE the presentation layer.
3351 + *
3352 + * `is_post_type_viewable()` answers "does this type have a front end of
3353 + * its own?" — which is the right question for `shop_order`, but the
3354 + * wrong one for the types core uses to render every OTHER page. A
3355 + * template part, a global-styles record, a navigation or a synced
3356 + * pattern has no permalink, yet editing one changes how the whole site
3357 + * looks. Gating purges on viewability alone meant a Site Editor save
3358 + * invalidated nothing and visitors kept the old design for the full
3359 + * TTL — up to 30 days at the maximum lifetime. (#270 regression)
3360 + *
3361 + * @return string[]
3362 + */
3363 + /**
3364 + * Could this post change alter anything an anonymous visitor had cached?
3365 + *
3366 + * Deleting one post fired a full purge for the post AND for every stored
3367 + * revision, because wp_delete_post() removes each revision through
3368 + * wp_delete_post() again and every one of those fires before_delete_post
3369 + * with post_type 'revision'. A post with six revisions cost seven whole-
3370 + * site sweeps, each one also announcing to LiteSpeed, purging the object
3371 + * cache network-wide on Redis, rewriting the stats option and running
3372 + * every xspeed_after_purge_all listener -- including Pro's Cloudflare
3373 + * purge, so seven API calls. Trashing cost two, via save_post and then
3374 + * trashed_post. (QA #348)
3375 + *
3376 + * The check lives here, ahead of purge_all(), so one early return covers
3377 + * the local sweep, the server-cache announcement and both action hooks.
3378 + * It deliberately does NOT live inside purge_all(): a manual, CLI or
3379 + * explicit caller asked for a purge and must get one.
3380 + *
3381 + * @param int $post_id Post being saved or removed.
3382 + * @param mixed $post Post object when the hook passed one.
3383 + * @param string $event 'save' or 'remove'.
3384 + */
3385 + private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool {
3386 + $post_id = (int) $post_id;
3387 +
3388 + // Only `save_post` and `before_delete_post` hand over a post object.
3389 + // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING --
3390 + // so reaching for ->post_status on the second argument finds nothing
3391 + // and the status rule below would never fire. Read the row instead.
3392 + if ( ! is_object( $post ) && function_exists( 'get_post' ) ) {
3393 + $post = get_post( $post_id );
3394 + }
3395 +
3396 + $type = is_object( $post ) && isset( $post->post_type )
3397 + ? (string) $post->post_type
3398 + : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' );
3399 + if ( '' === $type ) {
3400 + return false;
3401 + }
3402 +
3403 + // A revision is a copy of content nobody can browse to.
3404 + if ( 'revision' === $type ) {
3405 + return false;
3406 + }
3407 + if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
3408 + return false;
3409 + }
3410 + if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
3411 + return false;
3412 + }
3413 +
3414 + $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : '';
3415 +
3416 + // Clicking "Add New" inserts an auto-draft and fires save_post. There
3417 + // is nothing cached of a post that has never existed publicly.
3418 + if ( 'auto-draft' === $status ) {
3419 + return false;
3420 + }
3421 +
3422 + // Unknown/!viewable → nothing anonymous can see changed, UNLESS the
3423 + // type is itself part of how pages render (#270 regression).
3424 + if ( function_exists( 'is_post_type_viewable' )
3425 + && ! is_post_type_viewable( $type )
3426 + && ! in_array( $type, self::presentation_post_types(), true )
3427 + ) {
3428 + return false;
3429 + }
3430 +
3431 + // Deleting something that was already invisible changes no cached
3432 + // page: the transition that hid it purged at the time. This is what
3433 + // makes emptying a trash of a hundred posts cost nothing rather than
3434 + // a hundred full sweeps.
3435 + //
3436 + // It also collapses trashing to a single purge: wp_trash_post() fires
3437 + // save_post first, where the post is genuinely disappearing from
3438 + // listings and SHOULD purge, then trashed_post, by which point the
3439 + // row reads 'trash' and is skipped. A status we cannot read, on a row
3440 + // that still reports a type, means assume viewable -- erring toward
3441 + // an extra purge, never toward serving a stale page. A row that is
3442 + // gone entirely reports no type either and was refused above.
3443 + // 'inherit' is an INTERNAL status in core, so is_post_status_viewable()
3444 + // says no -- but an attachment carrying it is genuinely public. Judge
3445 + // those on the post type alone, which is already checked above.
3446 + if ( 'remove' === $event && '' !== $status && 'inherit' !== $status
3447 + && function_exists( 'is_post_status_viewable' )
3448 + && ! is_post_status_viewable( $status )
3449 + ) {
3450 + return false;
3451 + }
3452 +
3453 + return true;
3454 + }
3455 +
3456 + public static function presentation_post_types(): array {
3457 + $types = array(
3458 + 'wp_template', // Site Editor templates.
3459 + 'wp_template_part', // Header / footer / reusable parts.
3460 + 'wp_global_styles', // Colours, typography, spacing.
3461 + 'wp_navigation', // Navigation block menus.
3462 + 'nav_menu_item', // Classic menus.
3463 + 'wp_block', // Synced patterns / reusable blocks.
3464 + );
3465 +
3466 + /**
3467 + * Filter the non-viewable post types that still invalidate the cache.
3468 + *
3469 + * Add a type here when it has no front end of its own but changes
3470 + * how other pages render (a theme's own layout CPT, for example).
3471 + *
3472 + * @param string[] $types Post type slugs.
3473 + */
3474 + return (array) apply_filters( 'xspeed_presentation_post_types', $types );
3475 + }
3476 +
3477 + /**
3478 + * Describe a broad hook invalidation for response-cache adapters.
3479 + *
3480 + * Term, menu, theme and plugin changes can alter navigation, archives or
3481 + * markup across the site, so they require a site response-cache purge.
3482 + * Content saves also require this scope while their local operation is a
3483 + * complete bucket sweep.
3484 + *
3485 + * @return array{scope:string,intent:string,urls:array<int,string>}
3486 + */
3487 + private static function invalidation_for_hook( string $hook ): array {
3488 + $presentation = array(
3489 + 'switch_theme',
3490 + 'activated_plugin',
3491 + 'deactivated_plugin',
3492 + 'created_term',
3493 + 'edited_term',
3494 + 'delete_term',
3495 + 'wp_update_nav_menu',
3496 + );
3497 +
3498 + return array(
3499 + 'scope' => 'site',
3500 + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content',
3501 + 'urls' => array(),
3502 + );
3503 + }
3504 +
3505 +
3506 + /**
3507 + * save_post → purge only when the saved thing can appear on a cached page.
3508 + *
3509 + * Revisions and autosaves are never rendered. Non-viewable post types —
3510 + * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
3511 + * / `shop_coupon`, Flamingo's `flamingo_inbound` (#229), Tutor's
3512 + * `tutor_enrolled` (#231) — are invisible to anonymous visitors, so
3513 + * writing one changes nothing that is cached. (#243)
3514 + *
3515 + * The exception is the presentation types above, which are non-viewable
3516 + * yet render every page — they are allow-listed BEFORE the viewability
3517 + * test. (#270 regression)
3518 + *
3519 + * @param int $post_id Saved post ID.
3520 + * @param \WP_Post $post Saved post object.
3521 + */
3522 + public static function on_save_post( $post_id, $post = null ): void {
3523 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
3524 + return;
3525 + }
3526 +
3527 + $post_type = is_object( $post ) && isset( $post->post_type )
3528 + ? (string) $post->post_type
3529 + : (string) get_post_type( $post_id );
3530 +
3531 + // Name the trigger rather than logging a bare numeric id — the old
3532 + // wiring passed the post ID into $cause, so the log read
3533 + // "Cache purged (46)" with no indication of what caused it. (#243)
3534 + $presentation = in_array( $post_type, self::presentation_post_types(), true );
3535 + self::purge_all(
3536 + 'post:' . $post_type,
3537 + null,
3538 + array(
3539 + // purge_all() sweeps every local response in this site's bucket.
3540 + // Without dependency tracking, the server cache must match that
3541 + // same boundary or unrelated pages can remain stale there.
3542 + 'scope' => 'site',
3543 + 'intent' => $presentation ? 'presentation' : 'content',
3544 + 'urls' => array(),
3545 + )
3546 + );
3547 + if ( class_exists( '\XSpeed\Minifier' ) ) {
3548 + Minifier::purge_minified();
3549 + }
3550 + }
3551 +
3552 + /**
3553 + * Delete/trash invalidation while the post type is still available.
3554 + * The local and server response-cache sweeps share the same site boundary.
3555 + *
3556 + * @param int $post_id Removed post ID.
3557 + * @param object|null $post Post object supplied by core when available.
3558 + */
3559 + public static function on_post_removed( $post_id, $post = null ): void {
3560 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) {
3561 + return;
3562 + }
3563 +
3564 + $post_type = is_object( $post ) && isset( $post->post_type )
3565 + ? (string) $post->post_type
3566 + : (string) get_post_type( $post_id );
3567 +
3568 + self::purge_all(
3569 + 'post-removed:' . $post_type,
3570 + null,
3571 + array(
3572 + 'scope' => 'site',
3573 + // Match on_save_post: a presentation type changes how pages
3574 + // render rather than what they say.
3575 + 'intent' => in_array( $post_type, self::presentation_post_types(), true )
3576 + ? 'presentation'
3577 + : 'content',
3578 + 'urls' => array(),
3579 + )
3580 + );
3581 + }
3582 +
3583 + /** Purge site responses when moderation changes visible comments. */
3584 + public static function on_comment_status( $comment_id, $status = '' ): void {
3585 + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null;
3586 + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0;
3587 + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) {
3588 + return;
3589 + }
3590 + $url = get_permalink( $post_id );
3591 + if ( ! is_string( $url ) || '' === $url ) {
3592 + return;
3593 + }
3594 + self::purge_all(
3595 + 'comment-status:' . (string) $status,
3596 + null,
3597 + array(
3598 + 'scope' => 'site',
3599 + 'intent' => 'content',
3600 + 'urls' => array(),
3601 + )
3602 + );
3603 + }
3604 +
3605 + /**
3606 + * comment_post → purge just the commented-on URL, and only once the
3607 + * comment is actually visible.
3608 + *
3609 + * A comment held for moderation changes nothing on the front end, and an
3610 + * approved one changes exactly one page — not the whole site. Product
3611 + * reviews are comments and guest reviews are on by default, so under the
3612 + * old wiring any visitor could flush a store's entire cache, repeatedly,
3613 + * with no account. (#243)
3614 + *
3615 + * @param int $comment_id New comment ID.
3616 + * @param int|string $approved 1 when approved, 0 when held, 'spam'.
3617 + * @param array $data Comment data.
3618 + */
3619 + public static function on_comment_post( $comment_id, $approved = 0, $data = array() ): void {
3620 + if ( 1 !== (int) $approved ) {
3621 + return;
3622 + }
3623 + $post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0;
3624 + if ( $post_id < 1 ) {
3625 + return;
3626 + }
3627 + $url = get_permalink( $post_id );
3628 + if ( is_string( $url ) && '' !== $url ) {
3629 + self::purge_url( $url, 'comment' );
3630 + }
3631 + }
3632 +
3633 + /**
3634 + * user_register / profile_update → purge only when the user can author
3635 + * content that appears on the front end.
3636 + *
3637 + * A customer registering at checkout changes no rendered page, and cannot
3638 + * change an enqueued asset — so it must not purge the cache, and must not
3639 + * rebuild the minified bundles. Checkout account-creation fired FOUR
3640 + * full-site purges plus four purge_minified() runs in a single request
3641 + * before this gate. (#243)
3642 + *
3643 + * @param int $user_id Affected user.
3644 + */
3645 + public static function on_user_change( $user_id ): void {
3646 + $user = function_exists( 'get_userdata' ) ? get_userdata( (int) $user_id ) : null;
3647 + if ( ! $user ) {
3648 + return;
3649 + }
3650 +
3651 + // Only roles that can publish can change a rendered page. WooCommerce
3652 + // customers and WordPress subscribers cannot.
3653 + if ( ! user_can( $user, 'edit_posts' ) ) {
3654 + return;
3655 + }
3656 +
3657 + $url = get_author_posts_url( (int) $user_id );
3658 + if ( is_string( $url ) && '' !== $url ) {
3659 + self::purge_url( $url, 'user' );
3660 + }
3661 + }
3662 +
3663 + /**
3664 + * Purge everything a product's price / stock / sale state is rendered on.
3665 + *
3666 + * The product permalink is not enough: the shop archive and the product's
3667 + * category and tag archives render the same price and Sale! badge, and
3668 + * #242 reproduces all three going stale together.
3669 + *
3670 + * Accepts a product ID or a WC_Product. A variation resolves to its
3671 + * parent, which is the page that actually renders.
3672 + *
3673 + * @param int|object $product Product ID or WC_Product.
3674 + */
3675 + public static function purge_product( $product ): void {
3676 + $product_id = is_object( $product ) && method_exists( $product, 'get_id' )
3677 + ? (int) $product->get_id()
3678 + : (int) $product;
3679 + if ( $product_id < 1 ) {
3680 + return;
3681 + }
3682 +
3683 + // Variations are never rendered on their own URL.
3684 + $parent = (int) wp_get_post_parent_id( $product_id );
3685 + if ( $parent > 0 ) {
3686 + $product_id = $parent;
3687 + }
3688 +
3689 + $urls = array();
3690 +
3691 + $permalink = get_permalink( $product_id );
3692 + if ( is_string( $permalink ) && '' !== $permalink ) {
3693 + $urls[] = $permalink;
3694 + }
3695 +
3696 + // The shop archive.
3697 + if ( function_exists( 'wc_get_page_id' ) ) {
3698 + $shop_id = (int) wc_get_page_id( 'shop' );
3699 + if ( $shop_id > 0 ) {
3700 + $shop_url = get_permalink( $shop_id );
3701 + if ( is_string( $shop_url ) && '' !== $shop_url ) {
3702 + $urls[] = $shop_url;
3703 + }
3704 + }
3705 + }
3706 +
3707 + // Every category / tag archive this product appears on.
3708 + foreach ( array( 'product_cat', 'product_tag' ) as $taxonomy ) {
3709 + $terms = get_the_terms( $product_id, $taxonomy );
3710 + if ( ! is_array( $terms ) ) {
3711 + continue;
3712 + }
3713 + foreach ( $terms as $term ) {
3714 + $term_url = get_term_link( $term );
3715 + if ( is_string( $term_url ) && '' !== $term_url ) {
3716 + $urls[] = $term_url;
3717 + }
3718 + }
3719 + }
3720 +
3721 + // The front page, when it is not the shop page but still lists
3722 + // products (a block/shortcode storefront).
3723 + $front_id = (int) get_option( 'page_on_front' );
3724 + if ( $front_id > 0 ) {
3725 + $front_url = get_permalink( $front_id );
3726 + if ( is_string( $front_url ) && '' !== $front_url ) {
3727 + $urls[] = $front_url;
3728 + }
3729 + }
3730 +
3731 + /**
3732 + * Filter the URLs purged when a product changes.
3733 + *
3734 + * A storefront that renders products somewhere else — a landing page,
3735 + * a custom archive — can add its URLs here rather than falling back
3736 + * to purging the whole site.
3737 + *
3738 + * @param string[] $urls URLs about to be purged.
3739 + * @param int $product_id The product that changed.
3740 + */
3741 + $urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id );
3742 +
3743 + foreach ( array_unique( array_filter( $urls ) ) as $url ) {
3744 + self::purge_url( (string) $url, 'product' );
3745 + }
3746 + }
3747 +
3748 + /**
3749 + * Adapter for the WooCommerce stock actions that pass a product OBJECT
3750 + * where the status actions pass an ID.
3751 + *
3752 + * @param object $product WC_Product (or variation).
3753 + */
3754 + public static function purge_product_object( $product ): void {
3755 + self::purge_product( $product );
3756 + }
3757 +
3758 + /**
3759 + * Re-entry guard for the purge-event contract.
3760 + *
3761 + * A listener on `xspeed_after_purge_url` legitimately purges its own
3762 + * layer, and a server-cache or CDN adapter that calls back into xSpeed
3763 + * while doing so re-enters this method — unbounded, because each pass
3764 + * looks like a fresh purge.
3765 + *
3766 + * A single global flag stops too much: a nested purge of a DIFFERENT URL is
3767 + * a real purge whose listeners must hear about it. But a per-request
3768 + * "already published" set stops too much in the other direction — a
3769 + * network purge loops every blog in one request, and on a subdirectory
3770 + * network they share a host, so blogs 2..N would be silently skipped. It
3771 + * also grows for the life of the process.
3772 + *
3773 + * So the guard tracks what is IN FLIGHT, not what has been published: a
3774 + * target is marked while its own dispatch is on the stack and unmarked
3775 + * when it returns. Re-entering the same target recurses, so it is refused;
3776 + * purging the same URL again later is a new event and publishes. The set
3777 + * is bounded by call depth rather than by how many URLs a request touches.
3778 + *
3779 + * @var array<string,bool>
3780 + */
3781 + private static $purge_events_in_flight = array();
3782 +
3783 + /** Monotonic count used to detect whether a delegated purge published. */
3784 + private static $purge_event_sequence = 0;
3785 +
3786 + /**
3787 + * Publish a purge event exactly once, with bounded arguments.
3788 + *
3789 + * Deliberately carries only what an integration needs to invalidate its
3790 + * own copy: the canonical URL (or null for a full purge), the site host,
3791 + * the cause label, and how many files went. No filesystem paths, no cache
3792 + * contents, no request headers, no user data. The URL query and caller-
3793 + * supplied cause may nevertheless contain sensitive text, so listeners
3794 + * must redact them in logs or unrelated destinations that do not need the
3795 + * exact cache key.
3796 + *
3797 + * A listener that throws must not take the purge down with it: the files
3798 + * are already gone by the time we get here, and an integration's bad day
3799 + * is not a reason to report a failed purge to the caller.
3800 + *
3801 + * @param string $hook Hook name to emit.
3802 + * @param array<string,mixed> $context Bounded context, see above.
3803 + */
3804 + private static function dispatch_purge_event( string $hook, array $context ): void {
3805 + if ( ! function_exists( 'do_action' ) ) {
3806 + return;
3807 + }
3808 + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' )
3809 + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' );
3810 + if ( isset( self::$purge_events_in_flight[ $target ] ) ) {
3811 + return;
3812 + }
3813 + self::$purge_events_in_flight[ $target ] = true;
3814 + ++self::$purge_event_sequence;
3815 +
3816 + // Our own integrations get their own try. Sharing one with the public
3817 + // action below meant a listener on the extension seam could throw and
3818 + // take the contract event down with it — the mirror of the failure
3819 + // this separation exists to prevent.
3820 + try {
3821 + // Built-in server-cache integrations run FIRST, and by a direct
3822 + // call rather than as listeners on the action below.
3823 + //
3824 + // WordPress stops dispatching an action's remaining callbacks when
3825 + // one of them throws. As a listener, our LiteSpeed forwarding
3826 + // would then be skipped by any unrelated third-party callback that
3827 + // happened to be registered earlier and blew up — and the visible
3828 + // result is the worst kind: xSpeed reports a successful purge while
3829 + // the server keeps serving stale HTML. Shipped behaviour must not
3830 + // be hostage to a listener's bug.
3831 + self::forward_to_server_caches( $context );
3832 + } catch ( \Throwable $e ) {
3833 + self::log_purge_listener_error( $hook, $e );
3834 + }
3835 +
3836 + try {
3837 + self::do_action_isolated( $hook, $context );
3838 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
3839 + // Swallow: see docblock. The purge succeeded regardless.
3840 + self::log_purge_listener_error( $hook, $e );
3841 + } finally {
3842 + unset( self::$purge_events_in_flight[ $target ] );
3843 + }
3844 + }
3845 +
3846 + /**
3847 + * Run every listener on a purge hook, isolating each from the others.
3848 + *
3849 + * `do_action()` dispatches callbacks in one loop, so the first one to
3850 + * throw takes every LATER listener down with it. On a purge that meant a
3851 + * failing CDN integration silently cancelled the ones queued behind it —
3852 + * and because the throw was swallowed to keep the purge itself succeeding,
3853 + * the user was told the clear worked while two edges were never touched.
3854 + * Invisible unless WP_DEBUG happened to be on. (QA #348)
3855 + *
3856 + * Each callback gets its own try/catch here, so one integration's bad day
3857 + * costs only that integration. Priority order is preserved. Falls back to
3858 + * a plain `do_action()` when the filter registry is not the shape we
3859 + * expect, so an unusual environment degrades to the old behaviour rather
3860 + * than skipping listeners entirely.
3861 + *
3862 + * @param string $hook Hook name to emit.
3863 + * @param mixed $arg Single argument passed to each listener.
3864 + */
3865 + public static function do_action_isolated( string $hook, $arg ): void {
3866 + global $wp_filter;
3867 +
3868 + // Walking $wp_filter by hand and calling each callback directly was the
3869 + // obvious way to do this, and it was wrong: it bypasses WordPress, so
3870 + // `current_filter()` came back empty, `did_action()` stayed at 0, the
3871 + // `all` hook never fired, and Query Monitor and Debug Bar could not see
3872 + // the very contract this class publishes. A shared handler branching on
3873 + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3)
3874 + //
3875 + // So let do_action() dispatch — WordPress keeps its bookkeeping — and
3876 + // isolate one level down instead: each registered callback is swapped
3877 + // for a wrapper that runs it inside a try/catch. One listener throwing
3878 + // then costs only that listener, which is the whole point, without
3879 + // costing the hook its identity.
3880 + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) {
3881 + do_action( $hook, $arg );
3882 + return;
3883 + }
3884 +
3885 + $hook_object = $wp_filter[ $hook ];
3886 + $original = $hook_object->callbacks;
3887 + if ( ! is_array( $original ) || array() === $original ) {
3888 + do_action( $hook, $arg );
3889 + return;
3890 + }
3891 +
3892 + $wrapped = array();
3893 + $restorations = array();
3894 + foreach ( $original as $priority => $group ) {
3895 + if ( ! is_array( $group ) ) {
3896 + $wrapped[ $priority ] = $group;
3897 + continue;
3898 + }
3899 + foreach ( $group as $id => $registered ) {
3900 + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) {
3901 + $wrapped[ $priority ][ $id ] = $registered;
3902 + continue;
3903 + }
3904 + $callback = $registered['function'];
3905 + $wrapper = static function ( ...$args ) use ( $callback, $hook ) {
3906 + try {
3907 + return $callback( ...$args );
3908 + } catch ( \Throwable $e ) {
3909 + self::log_purge_listener_error( $hook, $e );
3910 + return null;
3911 + }
3912 + };
3913 + $wrapped[ $priority ][ $id ] = array(
3914 + // Keep accepted_args: a listener registered for 0 or 1
3915 + // arguments must still be called the way it asked.
3916 + 'accepted_args' => $registered['accepted_args'] ?? 1,
3917 + 'function' => $wrapper,
3918 + );
3919 + $restorations[ $priority ][ $id ] = array(
3920 + 'original' => $registered,
3921 + 'wrapper' => $wrapper,
3922 + );
3923 + }
3924 + }
3925 +
3926 + $hook_object->callbacks = $wrapped;
3927 + try {
3928 + do_action( $hook, $arg );
3929 + } finally {
3930 + // Restore only wrappers still present. Native add/remove operations
3931 + // performed by listeners must survive this temporary substitution.
3932 + foreach ( $restorations as $priority => $group ) {
3933 + foreach ( $group as $id => $restore ) {
3934 + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null;
3935 + if ( $current === $restore['wrapper'] ) {
3936 + $hook_object->callbacks[ $priority ][ $id ] = $restore['original'];
3937 + }
3938 + }
3939 + }
3940 + }
3941 + }
3942 +
3943 + /**
3944 + * Name a listener that threw, under WP_DEBUG only.
3945 + *
3946 + * Gated like the rest of Free's diagnostics: a third-party listener
3947 + * throwing on every purge must not fill a production log.
3948 + */
3949 + private static function log_purge_listener_error( string $hook, \Throwable $e ): void {
3950 + // An \Error — a TypeError from one of OUR listeners, say — is a bug
3951 + // rather than a runtime condition a third party imposed on us, and
3952 + // swallowing it silently in production turns it into a purge that
3953 + // quietly stops working. Those are logged whatever WP_DEBUG says;
3954 + // third-party \Exceptions stay gated so a noisy integration cannot
3955 + // fill a production log.
3956 + $always = $e instanceof \Error;
3957 + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) {
3958 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge.
3959 + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() );
3960 + }
3961 + }
3962 +
3963 + /** Test seam: clear the in-flight set left behind by an aborted dispatch. */
3964 + public static function reset_purge_events(): void {
3965 + self::$purge_events_in_flight = array();
3966 + self::$purge_event_sequence = 0;
3967 + }
3968 +
3969 + /**
3970 + * Hand the purge to the caches we ship integrations for.
3971 + *
3972 + * Isolated from the public action on purpose — see dispatch_purge_event().
3973 + * Guarded so a missing class (a partial upgrade, a stripped build) cannot
3974 + * turn a working purge into a fatal.
3975 + *
3976 + * @param array<string,mixed> $context Bounded purge context.
3977 + */
3978 + private static function forward_to_server_caches( array $context ): void {
3979 + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) {
3980 + Server_Caches::forward( $context );
3981 + }
3982 + }
3983 +
3984 + /**
3985 + * `host[:port]` for a cache key, from a parsed URL.
3986 + *
3987 + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and
3988 + * that carries `:8080` on any install not served from 80/443 — dropping it
3989 + * computed a different md5, found no file, and reported "already cold"
3990 + * while the page kept serving HIT.
3991 + *
3992 + * A port that is the DEFAULT for the scheme is dropped, though, because
3993 + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for
3994 + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file
3995 + * stored under `site.com` — the same silent no-op in the other direction,
3996 + * and the one QA hit passing a canonical URL with the port spelled out.
3997 + * (QA #348)
3998 + *
3999 + * @param array<string,mixed> $parts Output of wp_parse_url().
4000 + */
4001 + private static function host_port_of( array $parts ): string {
4002 + if ( ! isset( $parts['host'] ) ) {
4003 + return '';
4004 + }
4005 + $host = strtolower( (string) $parts['host'] );
4006 + if ( '' === $host || ! isset( $parts['port'] ) ) {
4007 + return $host;
4008 + }
4009 + $port = (int) $parts['port'];
4010 + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
4011 + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
4012 + return $host;
4013 + }
4014 + return $host . ':' . $port;
4015 + }
4016 +
907 4017 public static function purge_url( string $url, string $cause = 'manual' ): int {
4018 + // A URL that names nothing is not a purge of everything. An empty or
4019 + // blank string used to fall through to the home_url() default below
4020 + // and clear the HOMEPAGE — so a third party calling
4021 + // `purge_url( get_permalink( $id ) )` on a post whose permalink came
4022 + // back empty silently purged the front page instead of nothing. The
4023 + // CLI and the MCP tool reject empties before reaching this, so only
4024 + // direct API callers were exposed, but they are exactly the audience
4025 + // this public contract is for. (QA #348)
4026 + if ( '' === trim( $url ) ) {
4027 + return 0;
4028 + }
908 4029 $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.
909 4030 if ( ! is_array( $parts ) ) {
910 4031 return 0;
911 4032 }
912 - $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
4033 + // Absolute URLs are accepted only for HTTP response caches. Schemes such
4034 + // as ftp:, file: and javascript: can parse cleanly but do not name a page
4035 + // xSpeed or a server response cache can invalidate. A leading-slash path
4036 + // remains a supported site-relative target.
4037 + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
4038 + return 0;
4039 + }
4040 + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) {
4041 + return 0;
4042 + }
4043 + // Reject a string that parsed but is not a URL we can act on: no
4044 + // scheme AND no host AND no leading-slash path means something like
4045 + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a
4046 + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)`
4047 + // — a nonsense tag sent to LiteSpeed for every malformed call.
4048 + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) {
4049 + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : '';
4050 + if ( '' === $raw || '/' !== $raw[0] ) {
4051 + return 0;
4052 + }
4053 + }
4054 + // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
4055 + // carries `:8080` on any install not served from 80/443 — while
4056 + // parse_url() splits the port into its own component, so a purge that
4057 + // used the bare host computed a different md5, found no file, and
4058 + // reported "already cold". A silent no-op: the page kept serving HIT
4059 + // until its TTL ran out. Intranet installs, panel hosts on :8443 and
4060 + // proxies that forward `Host: site.com:8080` all hit this.
4061 + // A scheme-less `site.test:443/page/` is a supported explicit-host
4062 + // target. Infer a scheme only when it names THIS site's hostname: then
4063 + // its explicit default port is the same origin and the same local cache
4064 + // key. Never apply this to another host or to a non-default port.
4065 + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) {
4066 + $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.
4067 + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] )
4068 + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] )
4069 + ) {
4070 + $home_scheme = strtolower( (string) $home['scheme'] );
4071 + $port = (int) $parts['port'];
4072 + $home_port = isset( $home['port'] )
4073 + ? (int) $home['port']
4074 + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) );
4075 + if ( $home_port === $port
4076 + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) )
4077 + ) {
4078 + $parts['scheme'] = $home_scheme;
4079 + }
4080 + }
4081 + }
4082 + $host = self::host_port_of( $parts );
913 4083 if ( '' === $host && function_exists( 'home_url' ) ) {
914 4084 $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.
915 - $host = is_array( $home ) && isset( $home['host'] ) ? strtolower( (string) $home['host'] ) : '';
4085 + if ( is_array( $home ) ) {
4086 + $host = self::host_port_of( $home );
4087 + }
916 4088 }
917 4089 if ( '' === $host ) {
918 4090 return 0;
919 4091 }
@@ -931,19 +4103,34 @@
931 4103 $forms[] = rtrim( $path, '/' ) . '/';
932 4104 }
933 4105 $forms = array_unique( $forms );
934 4106
4107 + /*
4108 + * Entries live under the bucket they were written for, and this URL's
4109 + * site may not be the one serving THIS request (a cross-site purge on
4110 + * multisite, WP-CLI, or cron). Build the directory from the URL's own
4111 + * host AND path. (#6)
4112 + *
4113 + * Host alone is wrong on a subdirectory network: `store()` wrote to
4114 + * `<host>/<prefix>/`, so looking in `<host>/` found nothing and the
4115 + * call reported "already cold" while the page kept serving HIT — a
4116 + * false success, which is worse than an error. The prefix has to come
4117 + * from the URL being purged rather than from the current blog, because
4118 + * the caller is usually purging some OTHER site. (QA B2 on #166)
4119 + */
4120 + $base = XSPEED_CACHE_DIR . '/' . self::bucket_for_url( $host, $path );
4121 +
935 4122 $count = 0;
936 4123 foreach ( $forms as $uri ) {
937 4124 // '' = mobile_separate off; '|m' / '|d' = the device buckets.
938 4125 foreach ( array( '', '|m', '|d' ) as $device ) {
939 4126 $key = md5( $host . $uri . $device );
940 - $file = self::cache_file_for( $key );
4127 + $file = $base . '/' . $key . '.html';
941 4128 if ( is_file( $file ) ) {
942 4129 wp_delete_file( $file );
943 4130 ++$count;
944 4131 }
945 - foreach ( array( XSPEED_CACHE_DIR . '/' . $key . '.meta', $file . '.br' ) as $sidecar ) {
4132 + foreach ( array( $base . '/' . $key . '.meta', $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
946 4133 if ( is_file( $sidecar ) ) {
947 4134 wp_delete_file( $sidecar );
948 4135 }
949 4136 }
@@ -951,16 +4138,20 @@
951 4138 }
952 4139
953 4140 // Static tree (served directly by the nginx/.htaccess rewrite).
954 4141 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
955 - $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . $host . ( '/' === $path ? '' : rtrim( $path, '/' ) );
4142 + // Same transform the write used — `localhost:8080` files under
4143 + // `localhost8080`, so the bare host found nothing here either.
4144 + $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . ( '/' === $path ? '' : rtrim( $path, '/' ) );
956 4145 $file = $dir . '/index.html';
957 4146 if ( is_file( $file ) ) {
958 4147 wp_delete_file( $file );
959 4148 ++$count;
960 4149 }
961 - if ( is_file( $file . '.br' ) ) {
962 - wp_delete_file( $file . '.br' );
4150 + foreach ( array( $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
4151 + if ( is_file( $sidecar ) ) {
4152 + wp_delete_file( $sidecar );
4153 + }
963 4154 }
964 4155 }
965 4156
966 4157 if ( $count > 0 ) {
@@ -977,50 +4168,257 @@
977 4168 Activity_Log::INFO
978 4169 );
979 4170 }
980 4171
4172 + /**
4173 + * Fires after one URL's cached copy has been purged.
4174 + *
4175 + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe
4176 + * here to invalidate a cache xSpeed does not own — a server-level
4177 + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for
4178 + * the same URL.
4179 + *
4180 + * Only fires when the purge actually ran. A malformed URL, a URL with
4181 + * no resolvable host, or a traversal attempt returns earlier and
4182 + * publishes nothing, so a listener can treat this as "xSpeed purged
4183 + * this URL" rather than "xSpeed was asked to". `removed` may legitimately
4184 + * be 0: the URL was not in xSpeed's cache, which says nothing about
4185 + * whether it is in yours.
4186 + *
4187 + * Fires at most once per purge. A listener that calls back into
4188 + * xSpeed's purge API will not re-enter this event.
4189 + *
4190 + * @since 1.2.3
4191 + *
4192 + * @param array $context {
4193 + * Bounded description of the purge. URL queries and caller-supplied
4194 + * causes can contain sensitive values and are not logging fields.
4195 + *
4196 + * @type string $url Canonical scheme://host/path[?query] of the purged URL.
4197 + * The query is preserved because caches in front
4198 + * commonly key on it; xSpeed's own sweep is
4199 + * path-based, so `removed` describes that.
4200 + * @type string $host Host (with port when non-standard).
4201 + * @type string $path Path component, leading slash.
4202 + * @type string $cause Short label for who asked. See purge_all().
4203 + * @type int $removed Number of cache files removed.
4204 + * @type string $scope Actionable adapter scope: `urls`.
4205 + * @type string $intent Why responses changed: `content`.
4206 + * @type string[] $urls Exact response URLs to invalidate.
4207 + * }
4208 + */
4209 + $canonical_url = self::canonical_purge_url(
4210 + $host,
4211 + $path,
4212 + isset( $parts['query'] ) ? (string) $parts['query'] : '',
4213 + isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''
4214 + );
4215 + self::dispatch_purge_event(
4216 + 'xspeed_after_purge_url',
4217 + array(
4218 + 'url' => $canonical_url,
4219 + 'host' => $host,
4220 + 'path' => $path,
4221 + 'cause' => $cause,
4222 + 'removed' => $count,
4223 + 'scope' => 'urls',
4224 + 'intent' => 'content',
4225 + 'urls' => array( $canonical_url ),
4226 + )
4227 + );
4228 +
981 4229 return $count;
982 4230 }
983 4231
984 - public static function purge_all( string $cause = 'manual' ) {
4232 + /** Host this site's purge is scoped to, for the purge-event context. */
4233 + private static function current_purge_host(): string {
4234 + if ( ! function_exists( 'home_url' ) ) {
4235 + return '';
4236 + }
4237 + $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.
4238 + if ( ! is_array( $home ) || empty( $home['host'] ) ) {
4239 + return '';
4240 + }
4241 + // Same default-port normalisation as purge_url(): a site whose
4242 + // home_url() carries `:443` (normal behind a proxy) otherwise stamps
4243 + // every full-purge event with a host that matches none of its own
4244 + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348)
4245 + return self::host_port_of( $home );
4246 + }
4247 +
4248 + /**
4249 + * Rebuild the canonical URL a purge applied to.
4250 + *
4251 + * Built from the parts the purge itself used, so a listener is told the
4252 + * URL we acted on rather than the string the caller happened to pass —
4253 + * those differ whenever the caller supplied a site-relative path, a
4254 + * different scheme, or a query string the cache key ignores.
4255 + */
4256 + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string {
4257 + // The purged URL's own scheme wins. purge_url() explicitly supports
4258 + // cross-site purges (multisite, WP-CLI, cron), where composing the
4259 + // current site's scheme onto another site's host builds a URL that was
4260 + // never served — and a CDN listener then purges the wrong key and
4261 + // reports success.
4262 + if ( '' !== $url_scheme ) {
4263 + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
4264 + }
4265 + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http';
4266 + if ( function_exists( 'home_url' ) ) {
4267 + $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.
4268 + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) {
4269 + $scheme = (string) $home['scheme'];
4270 + }
4271 + }
4272 + // The query is carried even though OUR sweep above is path-based.
4273 + // Caches in front commonly key on the full request line — LiteSpeed
4274 + // tags `/shop/?page=2` separately from `/shop/` — so publishing the
4275 + // bare path would have a listener confidently purge the wrong entry
4276 + // and report success. Telling it exactly what was asked for lets it
4277 + // act correctly; `removed` still describes only what WE removed.
4278 + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
4279 + }
4280 +
4281 + /**
4282 + * Sweep this site's cache files.
4283 + *
4284 + * On multisite every blog shares one cache directory, so an unscoped
4285 + * sweep here took the whole network cold — one subsite's settings save
4286 + * or post publish rebuilt every other site from PHP. Entries are stored
4287 + * per host (see host_dir()), and the sweep is scoped to match, so a
4288 + * purge originating on site-a leaves site-b's cache warm. (#6)
4289 + *
4290 + * Clears the files only: the flat tree, the static tree, the REST
4291 + * responses and the minified assets. The object-cache flush, the stats
4292 + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract
4293 + * event and the log entry live in purge_all(), which is still the entry
4294 + * point for every existing caller. Split out so `wp xspeed purge` can
4295 + * report the local sweep as one line item and the object cache as
4296 + * another, each with its own status — see Purge_Runner.
4297 + *
4298 + * @param string|null $host Host to purge. Defaults to the current site.
4299 + * Pass '*' to sweep the ENTIRE tree — network
4300 + * admin's "purge all sites", and the migration
4301 + * of pre-#6 entries that sit in the tree root.
4302 + * @return array{pages:int,rest:int,assets:int,bytes:int} Entries removed
4303 + * per store, and the bytes freed by the two file sweeps
4304 + * that measure themselves.
4305 + */
4306 + public static function purge_local( ?string $host = null ): array {
4307 + $network_wide = ( '*' === $host );
4308 + self::$sweep_bytes = 0;
4309 + // The flat tree buckets by a flattened segment (host/a-b) while the
4310 + // static tree mirrors the URL (host/a/b), so they need separate
4311 + // scopes — see current_host_dir() vs current_static_scope().
4312 + $static_scope = '';
4313 + if ( null === $host || $network_wide ) {
4314 + $scope = $network_wide ? '' : self::current_host_dir();
4315 + $static_scope = $network_wide ? '' : self::current_static_scope();
4316 + } else {
4317 + $dir = self::host_dir( $host );
4318 + $scope = '' === $dir ? 'default' : $dir;
4319 + $static_dir = self::static_host_dir( $host );
4320 + $static_scope = '' === $static_dir ? 'default' : $static_dir;
4321 + }
4322 +
985 4323 $count = 0;
986 4324 if ( is_dir( XSPEED_CACHE_DIR ) ) {
987 - $files = glob( XSPEED_CACHE_DIR . '/*.html' );
988 - if ( $files ) {
989 - $count = count( $files );
990 - foreach ( $files as $f ) {
991 - wp_delete_file( $f );
4325 + // Scoped to one host directory, or the whole tree (including the
4326 + // legacy top-level entries written before #6) when network-wide.
4327 + /*
4328 + * Network-wide sweeps go TWO levels deep, not one. A subdirectory
4329 + * subsite's bucket is `<host>/<prefix>/`, so globbing only
4330 + * `<cache>/*` reached the main site and left every subsite's
4331 + * entries in place. (QA D5 on #166)
4332 + *
4333 + * A scoped purge also has to cover its own nested buckets: when
4334 + * the main blog of a subdirectory network purges, `<host>/` is its
4335 + * bucket and `<host>/one/` belongs to another blog — so the scoped
4336 + * branch deliberately does NOT descend, which is what keeps
4337 + * site-level purges isolated.
4338 + */
4339 + $roots = $network_wide
4340 + ? array_merge(
4341 + array( XSPEED_CACHE_DIR ),
4342 + array_filter( (array) glob( XSPEED_CACHE_DIR . '/*', GLOB_ONLYDIR ) ),
4343 + array_filter( (array) glob( XSPEED_CACHE_DIR . '/*/*', GLOB_ONLYDIR ) )
4344 + )
4345 + : array( XSPEED_CACHE_DIR . '/' . $scope );
4346 +
4347 + foreach ( $roots as $root ) {
4348 + /*
4349 + * min/ and rest/ are swept by their own purgers below; never
4350 + * treat them as host buckets.
4351 + *
4352 + * Checked on every path SEGMENT, not just the basename: now
4353 + * that the network-wide glob descends two levels it can reach
4354 + * `min/combined`, whose basename is `combined` and would sail
4355 + * past a basename-only test — deleting the combined
4356 + * stylesheets out from under the pages that link them.
4357 + */
4358 + if ( ! $network_wide || XSPEED_CACHE_DIR !== $root ) {
4359 + $relative = trim( str_replace( XSPEED_CACHE_DIR, '', (string) $root ), '/' );
4360 + $segments = '' === $relative ? array() : explode( '/', $relative );
4361 + if ( array_intersect( $segments, array( 'min', 'rest' ) ) ) {
4362 + continue;
4363 + }
992 4364 }
993 - }
994 - // Remove the .meta sidecars (content-type for feeds/sitemaps)
995 - // alongside their .html entries. Not counted — they're not
996 - // cache "pages", just per-entry metadata.
997 - $meta = glob( XSPEED_CACHE_DIR . '/*.meta' );
998 - if ( $meta ) {
999 - foreach ( $meta as $m ) {
1000 - wp_delete_file( $m );
4365 + if ( ! is_dir( $root ) ) {
4366 + continue;
1001 4367 }
1002 - }
1003 - // Remove precompressed siblings (e.g. <key>.html.br from the Pro
1004 - // Brotli module). Not counted — same as .meta. Without this a
1005 - // purge leaves stale .br bodies behind: disk bloat, and a
1006 - // staleness window if precompression is later disabled.
1007 - $br = glob( XSPEED_CACHE_DIR . '/*.br' );
1008 - if ( $br ) {
1009 - foreach ( $br as $b ) {
1010 - wp_delete_file( $b );
4368 + $files = glob( $root . '/*.html' );
4369 + if ( $files ) {
4370 + $count += count( $files );
4371 + foreach ( $files as $f ) {
4372 + self::sweep_delete( $f );
4373 + }
1011 4374 }
4375 + // Remove the .meta sidecars (content-type for feeds/sitemaps)
4376 + // alongside their .html entries. Not counted — they're not
4377 + // cache "pages", just per-entry metadata.
4378 + $meta = glob( $root . '/*.meta' );
4379 + if ( $meta ) {
4380 + foreach ( $meta as $m ) {
4381 + self::sweep_delete( $m );
4382 + }
4383 + }
4384 + // Remove precompressed siblings (e.g. <key>.html.br from the Pro
4385 + // Brotli module). Not counted — same as .meta. Without this a
4386 + // purge leaves stale .br bodies behind: disk bloat, and a
4387 + // staleness window if precompression is later disabled.
4388 + $br = glob( $root . '/*.br' );
4389 + if ( $br ) {
4390 + foreach ( $br as $b ) {
4391 + self::sweep_delete( $b );
4392 + }
4393 + }
4394 + // `*.br` does not match `*.br.size` — same reason as the flat-root
4395 + // sweep above: a size record outliving its body would later be
4396 + // read against a different sibling's bytes.
4397 + $br_size = glob( $root . '/*.br.size' );
4398 + if ( $br_size ) {
4399 + foreach ( $br_size as $b ) {
4400 + self::sweep_delete( $b );
4401 + }
4402 + }
1012 4403 }
1013 4404 }
1014 4405 // Static-cache tree purge — recursive because the layout is
1015 4406 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
1016 - // reach everything.
4407 + // reach everything. Already host-segmented, so scoping is just a
4408 + // matter of starting one level down.
1017 4409 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
1018 - $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
4410 + $static_root = $network_wide
4411 + ? XSPEED_CACHE_STATIC_DIR
4412 + : XSPEED_CACHE_STATIC_DIR . '/' . $static_scope;
4413 + if ( is_dir( $static_root ) ) {
4414 + $count += self::rmtree_html( $static_root );
4415 + }
1019 4416 }
1020 4417 // REST response cache (cache/xspeed/rest/*.json) — same purge
1021 4418 // triggers (publish, settings change) invalidate it too.
1022 - $count += Rest_Cache::purge();
4419 + $rest = Rest_Cache::purge();
4420 + $count += $rest;
1023 4421
1024 4422 // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
1025 4423 // purge_all is a full filesystem sweep and must clear these too, even
1026 4424 // when the Minify module is currently disabled — orphaned min/ files
@@ -1026,19 +4424,93 @@
1026 4424 // when the Minify module is currently disabled — orphaned min/ files
1027 4425 // from a feature the user later turned off must still be removed, and
1028 4426 // a stale combined-<hash>.css that the regenerated page no longer
1029 4427 // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
1030 - if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1031 - Minifier::purge_minified();
4428 + $assets = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_minified() : 0;
4429 +
4430 + return array(
4431 + 'pages' => $count - $rest,
4432 + 'rest' => $rest,
4433 + 'assets' => $assets,
4434 + 'bytes' => self::$sweep_bytes,
4435 + );
4436 + }
4437 +
4438 + /**
4439 + * Flush the persistent object cache (Redis / Memcached).
4440 + *
4441 + * Runs regardless of whether the Object Cache module is currently
4442 + * enabled — a drop-in installed earlier keeps serving until flushed.
4443 + *
4444 + * @param bool $network_wide Flush every blog's entries. wp_cache_flush()
4445 + * is NETWORK-global, so on multisite the
4446 + * default prefers the blog-scoped group flush
4447 + * (WP 6.1+) — otherwise one site's purge drops
4448 + * every other site's object cache, the same bug
4449 + * #6 fixed for the page cache.
4450 + * @return bool Whether a flush was actually performed.
4451 + */
4452 + public static function flush_object_cache( bool $network_wide = false ): bool {
4453 + if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) {
4454 + // Blog-scoped groups only; a shared/global group (site options,
4455 + // user meta) is intentionally left alone.
4456 + foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) {
4457 + wp_cache_flush_group( $group );
4458 + }
4459 + return true;
1032 4460 }
4461 + if ( function_exists( 'wp_cache_flush' ) ) {
4462 + return (bool) wp_cache_flush();
4463 + }
4464 + return false;
4465 + }
1033 4466
1034 - // Persistent object cache (Redis / Memcached). Flush regardless of
1035 - // whether the Object Cache module is currently enabled — a drop-in
1036 - // installed earlier keeps serving until flushed.
1037 - if ( function_exists( 'wp_cache_flush' ) ) {
1038 - wp_cache_flush();
4467 + /**
4468 + * Purge this site's cache: the local sweep, then the object cache, then
4469 + * the bookkeeping every caller expects (stats, `xspeed_after_purge_all`,
4470 + * inventory invalidation, purge log).
4471 + *
4472 + * @param string $cause Who asked, for the purge log.
4473 + * @param string|null $host See purge_local().
4474 + * @param array<string,mixed> $invalidation Public adapter policy. `scope`
4475 + * is urls/site/network/none,
4476 + * `intent` explains why, and
4477 + * `urls` supplies exact targets.
4478 + * @return int Page + REST entries removed.
4479 + */
4480 + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) {
4481 + $network_wide = ( '*' === $host );
4482 + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] )
4483 + ? $invalidation['scope']
4484 + : ( $network_wide ? 'network' : 'site' );
4485 + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) {
4486 + $adapter_scope = $network_wide ? 'network' : 'site';
1039 4487 }
4488 + if ( $network_wide ) {
4489 + $adapter_scope = 'network';
4490 + }
4491 + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent']
4492 + ? $invalidation['intent']
4493 + : 'complete';
4494 + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] )
4495 + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) )
4496 + : array();
4497 + // This method always sweeps a complete local bucket. A narrower adapter
4498 + // announcement would claim unrelated local pages stayed warm when they
4499 + // did not, leaving their server copies stale. Until purge_all() gains
4500 + // dependency-aware local deletion, its response scope cannot be `urls`.
4501 + if ( 'urls' === $adapter_scope ) {
4502 + $adapter_scope = $network_wide ? 'network' : 'site';
4503 + }
4504 + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) {
4505 + $urls = array();
4506 + }
1040 4507
4508 + $removed = self::purge_local( $host );
4509 + $count = $removed['pages'] + $removed['rest'];
4510 +
4511 + self::flush_object_cache( $network_wide );
4512 +
1041 4513 self::update_stats( array( 'last_purge' => time() ) );
1042 4514
1043 4515 // Fire AFTER the local sweep so module listeners (Critical CSS,
1044 4516 // Unused CSS, Cloudflare edge purge) run — this action had three
@@ -1044,10 +4516,64 @@
1044 4516 // Unused CSS, Cloudflare edge purge) run — this action had three
1045 4517 // registered listeners but was never emitted. Treat it as additive
1046 4518 // (CDN / edge invalidation), not the mechanism for clearing local
1047 4519 // files. (FBS-83114)
1048 - do_action( 'xspeed_after_purge_all', $cause );
4520 + // Wrapped: this action predates the purge-event contract and has its
4521 + // own third-party listeners. One of them throwing used to abort
4522 + // purge_all() here, which now also means the contract event below
4523 + // never fires and a server cache keeps serving stale HTML. The local
4524 + // sweep is already done by this point, so swallowing is strictly safer
4525 + // than letting a listener decide the rest of the method runs.
4526 + try {
4527 + // Isolated per listener: one throwing used to cancel every
4528 + // listener queued behind it — Critical CSS, Unused CSS and the
4529 + // Cloudflare edge purge all hang off this hook. (QA #348)
4530 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
4531 + } catch ( \Throwable $e ) {
4532 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
4533 + }
1049 4534
4535 + /**
4536 + * Fires after a full purge, with the same bounded context shape as
4537 + * `xspeed_after_purge_url`.
4538 + *
4539 + * Distinct from `xspeed_after_purge_all` on purpose. That action is
4540 + * the long-standing internal signal — it passes a bare `$cause` string
4541 + * and Free's own modules use it for local bookkeeping. This one is the
4542 + * documented contract for OUTSIDE integrations: same argument shape as
4543 + * the per-URL event, so a server-cache or CDN adapter can subscribe to
4544 + * both with one handler and branch on a null `url`.
4545 + *
4546 + * Fires at most once per purge, and not at all when a listener's own
4547 + * purge re-enters xSpeed.
4548 + *
4549 + * @since 1.2.3
4550 + *
4551 + * @param array $context {
4552 + * @type null $url Always null — a full purge has no single URL.
4553 + * @type string $host Host swept, or '*' for the entire tree.
4554 + * @type null $path Always null.
4555 + * @type string $cause Short label for who asked.
4556 + * @type int $removed Number of cache files removed.
4557 + * @type string $scope Adapter action: urls/site/network/none.
4558 + * @type string $intent content/presentation/complete or a caller-defined intent.
4559 + * @type string[] $urls Exact targets when scope is urls.
4560 + * }
4561 + */
4562 + self::dispatch_purge_event(
4563 + 'xspeed_after_purge',
4564 + array(
4565 + 'url' => null,
4566 + 'host' => null === $host ? self::current_purge_host() : (string) $host,
4567 + 'path' => null,
4568 + 'cause' => $cause,
4569 + 'removed' => $count,
4570 + 'scope' => $adapter_scope,
4571 + 'intent' => $intent,
4572 + 'urls' => $urls,
4573 + )
4574 + );
4575 +
1050 4576 // The list behind the "Cached pages" card is memoized for a minute;
1051 4577 // a purge has to drop it or the drill-down shows pages that no
1052 4578 // longer exist.
1053 4579 Cache_Inventory::invalidate();
@@ -1064,8 +4590,508 @@
1064 4590 return $count;
1065 4591 }
1066 4592
1067 4593 /**
4594 + * Purge everything after a plugin / theme / core update completes.
4595 + *
4596 + * Bound to `upgrader_process_complete`, which is the only hook an update
4597 + * fires — no activation hook runs, so without this the cached HTML (and
4598 + * the asset URLs baked into it) outlives the code that produced it.
4599 + *
4600 + * Runs for plugin, theme and core updates alike, including bulk runs and
4601 + * auto-updates, and purges the WHOLE network rather than the current
4602 + * site — see the call below. Translation updates are skipped: they
4603 + * change no markup a cached page depends on, and language packs update
4604 + * often enough that purging on them would keep a multilingual site
4605 + * permanently cold.
4606 + *
4607 + * Note this cannot be folded into the `$invalidate_hooks` loop above:
4608 + * that binds `purge_all` directly, and `purge_all( string $cause )` would
4609 + * then receive the WP_Upgrader instance as its cause.
4610 + *
4611 + * @param mixed $upgrader WP_Upgrader instance (unused).
4612 + * @param array $hook_extra Context for the completed operation.
4613 + * @return void
4614 + */
4615 + public static function purge_after_upgrade( $upgrader = null, $hook_extra = array() ) {
4616 + $cleared = self::$upgrade_cleared_destination;
4617 +
4618 + if ( ! self::upgrade_produced_something( $upgrader ) ) {
4619 + return;
4620 + }
4621 +
4622 + if ( ! self::upgrade_should_purge( is_array( $hook_extra ) ? $hook_extra : array(), $cleared ) ) {
4623 + return;
4624 + }
4625 +
4626 + self::purge_for_upgrade();
4627 + }
4628 +
4629 + /**
4630 + * Whether this request's upgrader removed an existing copy.
4631 + *
4632 + * @var bool
4633 + */
4634 + private static $upgrade_cleared_destination = false;
4635 +
4636 + /**
4637 + * How many `upgrader_process_complete` dispatches are on the stack.
4638 + *
4639 + * @var int
4640 + */
4641 + private static $upgrade_dispatch_depth = 0;
4642 +
4643 + /**
4644 + * Enter an `upgrader_process_complete` dispatch.
4645 + *
4646 + * Bound at PHP_INT_MIN, so it runs before any listener that might read
4647 + * the replacement signal. Public because it is a hook target.
4648 + *
4649 + * @return void
4650 + */
4651 + public static function note_upgrade_dispatch(): void {
4652 + ++self::$upgrade_dispatch_depth;
4653 + }
4654 +
4655 + /**
4656 + * Drop the replacement signal once every listener has read it.
4657 + *
4658 + * Bound at PHP_INT_MAX so a second upgrade in the same request starts
4659 + * clean, without taking the answer away from the add-on callbacks that
4660 + * run at the same priority as ours.
4661 + *
4662 + * Only the OUTERMOST dispatch clears it. A nested run — core's language
4663 + * pack upgrader, or any add-on that installs something from this hook —
4664 + * fires the action again, and clearing there would answer for a run that
4665 + * has not finished. Called directly (no dispatch on the stack) it still
4666 + * clears, which is what a test wants.
4667 + *
4668 + * Known limit: a nested run INHERITS the outer run's signal, because the
4669 + * only evidence we get is a filter that fires before the nested dispatch
4670 + * begins and carries no upgrader identity. So a fresh install performed
4671 + * from inside a replacement run reads as a replacement and purges once
4672 + * more than it needs to. A cold cache is the cheap direction, and the
4673 + * alternative — scoping the signal per upgrader — is not knowable from
4674 + * `upgrader_clear_destination`.
4675 + *
4676 + * @return void
4677 + */
4678 + public static function forget_cleared_destination(): void {
4679 + if ( self::$upgrade_dispatch_depth > 0 ) {
4680 + --self::$upgrade_dispatch_depth;
4681 + }
4682 +
4683 + if ( 0 === self::$upgrade_dispatch_depth ) {
4684 + self::$upgrade_cleared_destination = false;
4685 + }
4686 + }
4687 +
4688 + /**
4689 + * Record that the upgrader cleared an existing destination.
4690 + *
4691 + * A pass-through listener on `upgrader_clear_destination`: WordPress only
4692 + * fires it when `clear_destination` was set AND something was there to
4693 + * remove, which is the one signal that separates an upload-and-replace
4694 + * from a first-time install. The filtered value is returned untouched.
4695 + *
4696 + * @param true|\WP_Error $removed Whether the destination was cleared.
4697 + * @return true|\WP_Error
4698 + */
4699 + public static function note_cleared_destination( $removed ) {
4700 + if ( ! is_wp_error( $removed ) ) {
4701 + self::$upgrade_cleared_destination = true;
4702 + }
4703 +
4704 + return $removed;
4705 + }
4706 +
4707 + /**
4708 + * Did the completed run actually replace anything?
4709 + *
4710 + * `upgrader_process_complete` fires whether the run succeeded or failed —
4711 + * the failure branch in WP_Upgrader::run() only feeds the skin before the
4712 + * action fires. A run that installed nothing changed no markup, so purging
4713 + * for it is a cold cache bought for nothing.
4714 + *
4715 + * Deliberately conservative: this returns false ONLY when every result we
4716 + * can see is an error. An upgrader we cannot read, a mixed bulk run, or a
4717 + * missing result all fall through to purging, which is the safe direction
4718 + * everywhere else in this handler.
4719 + *
4720 + * @param mixed $upgrader WP_Upgrader instance, or anything else.
4721 + * @return bool
4722 + */
4723 + public static function upgrade_produced_something( $upgrader ): bool {
4724 + if ( ! is_object( $upgrader ) ) {
4725 + return true;
4726 + }
4727 +
4728 + // A bulk run collects one entry per item; `result` alone would only
4729 + // describe the last of them.
4730 + if ( isset( $upgrader->results ) && is_array( $upgrader->results ) && ! empty( $upgrader->results ) ) {
4731 + foreach ( $upgrader->results as $result ) {
4732 + if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
4733 + return true;
4734 + }
4735 + }
4736 + return false;
4737 + }
4738 +
4739 + if ( ! property_exists( $upgrader, 'result' ) ) {
4740 + return true;
4741 + }
4742 +
4743 + return ! is_wp_error( $upgrader->result ) && ! empty( $upgrader->result );
4744 + }
4745 +
4746 + /**
4747 + * Decide whether a completed operation invalidates the cache.
4748 + *
4749 + * Split out from the handler so the decision is testable on its own:
4750 + * purge_all() reaches straight for glob() and unlink(), which a unit test
4751 + * cannot observe honestly, while every rule that matters lives here.
4752 + *
4753 + * @param array $hook_extra Context for the completed operation.
4754 + * @return bool
4755 + */
4756 + public static function upgrade_should_purge( array $hook_extra, bool $destination_cleared = false ): bool {
4757 + if ( ! self::upgrade_replaced_code( $hook_extra, $destination_cleared ) ) {
4758 + return false;
4759 + }
4760 +
4761 + // An update to xSpeed ITSELF always purges, whatever the setting says.
4762 + // This plugin's own code is what rendered every cached page — the
4763 + // minifier, lazy-loader, resource hints and CDN rewriter all changed
4764 + // underneath it — so serving that HTML after an update means serving
4765 + // output from a version that no longer exists. Minified assets make it
4766 + // concrete rather than theoretical: their filenames are keyed on the
4767 + // source filemtime, so they regenerate under NEW hashes while the
4768 + // cached pages still link the old ones, and the page requests files
4769 + // that are no longer on disk. Offering an opt-out for that would be
4770 + // offering a broken site.
4771 + return self::upgrade_touches_xspeed( $hook_extra ) || self::purge_on_upgrade_enabled();
4772 + }
4773 +
4774 + /**
4775 + * Did this completed run replace code that renders pages?
4776 + *
4777 + * The half of the decision that has nothing to do with our settings: it
4778 + * asks only whether live code changed underneath the output we cached.
4779 + * Add-ons that keep their own derived artifacts — generated CSS, captured
4780 + * selectors, fingerprints — need the same answer and must not have to
4781 + * rebuild these rules, or they drift apart. Call it with the hook's own
4782 + * `$hook_extra`; the upload-and-replace signal is read from this request.
4783 + *
4784 + * Deliberately independent of the "Purge After Updates" setting. That
4785 + * setting governs the page cache, not whether an add-on's derived data is
4786 + * still valid.
4787 + *
4788 + * @param array $hook_extra Context for the completed operation.
4789 + * @param bool|null $destination_cleared Override the recorded signal; null reads this request's.
4790 + * @return bool
4791 + */
4792 + public static function upgrade_replaced_code( array $hook_extra, ?bool $destination_cleared = null ): bool {
4793 + $cleared = null === $destination_cleared ? self::$upgrade_cleared_destination : $destination_cleared;
4794 +
4795 + $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
4796 + $action = isset( $hook_extra['action'] ) ? (string) $hook_extra['action'] : '';
4797 +
4798 + // `upgrader_process_complete` fires for INSTALLS as well as updates.
4799 + // A freshly installed plugin is inactive and a freshly installed theme
4800 + // is not the active one, so neither can change a single rendered page
4801 + // — but the first cut of this handler purged the whole tree anyway, so
4802 + // evaluating three plugins in a row emptied the cache three times.
4803 + //
4804 + // 'install' alone is NOT enough to skip on, because WordPress reports
4805 + // an upload-and-replace as an install: `Plugin_Upgrader::install()`
4806 + // hardcodes `action => install` and `overwrite_package` does not change
4807 + // it, so "Replace current with uploaded" and `wp plugin install <zip>
4808 + // --force` both arrive here labelled install while genuinely replacing
4809 + // live code. That is how a plugin distributed as a zip is updated, and
4810 + // skipping it put back the stale markup this handler exists to clear.
4811 + //
4812 + // The distinguishing signal is whether the destination was cleared:
4813 + // WP_Upgrader only fires `upgrader_clear_destination` when it removed
4814 + // something that was already there. Installing beside nothing does not.
4815 + if ( 'install' === $action && ! $cleared ) {
4816 + return false;
4817 + }
4818 +
4819 + // 'translation' is the one update type that cannot change rendered
4820 + // markup. Anything else — including an empty type from a custom
4821 + // updater — is treated as cache-invalidating, because guessing wrong
4822 + // in that direction only costs a cold cache.
4823 + if ( 'translation' === $type ) {
4824 + return false;
4825 + }
4826 +
4827 + return true;
4828 + }
4829 +
4830 + /**
4831 + * Purge everything an update can invalidate.
4832 + *
4833 + * Network-wide ('*'), not the calling site's bucket. A plugin, theme or
4834 + * core update replaces code shared by EVERY site on the network, so a
4835 + * scoped purge would clear the site that happened to run the updater and
4836 + * leave every other subsite serving pre-update HTML for the whole TTL —
4837 + * the very bug this handler exists to fix, one level down. On single-site
4838 + * this is identical to the scoped call, since there is only ever one
4839 + * bucket.
4840 + *
4841 + * @return void
4842 + */
4843 + private static function purge_for_upgrade(): void {
4844 + self::purge_all( 'upgrade', '*' );
4845 + Minifier::purge_minified();
4846 + }
4847 +
4848 + /**
4849 + * Purge after an unattended background update run.
4850 + *
4851 + * `automatic_updates_complete` passes ONE argument, and it is not a
4852 + * hook_extra: it is WordPress's results array, keyed by what was updated
4853 + * ('core', 'plugin', 'theme', 'translation'). Handing it to
4854 + * purge_after_upgrade() put it in the unused $upgrader slot and left the
4855 + * type empty, so a night on which only a language pack updated purged
4856 + * every cached page — the exact case the translation exemption exists to
4857 + * prevent, and WordPress auto-updates language packs by default.
4858 + *
4859 + * @param array $results Update results, keyed by type.
4860 + * @return void
4861 + */
4862 + public static function purge_after_auto_updates( $results = array() ): void {
4863 + if ( ! self::auto_updates_should_purge( is_array( $results ) ? $results : array() ) ) {
4864 + return;
4865 + }
4866 +
4867 + self::purge_for_upgrade();
4868 + }
4869 +
4870 + /**
4871 + * Decide whether a background update run invalidates the cache.
4872 + *
4873 + * @param array $results Update results, keyed by type.
4874 + * @return bool
4875 + */
4876 + public static function auto_updates_should_purge( array $results ): bool {
4877 + // An unrecognisable payload is treated as invalidating, the same
4878 + // direction every other unknown takes here.
4879 + if ( empty( $results ) ) {
4880 + return true;
4881 + }
4882 +
4883 + // Failed items are listed alongside successful ones — WP_Automatic_Updater
4884 + // appends an entry whatever the outcome — and a night on which every
4885 + // update failed replaced no code, so it invalidates nothing.
4886 + $updated = array();
4887 + foreach ( $results as $type => $items ) {
4888 + if ( ! is_array( $items ) ) {
4889 + continue;
4890 + }
4891 + foreach ( $items as $item ) {
4892 + $result = is_object( $item ) && isset( $item->result ) ? $item->result : true;
4893 + if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
4894 + $updated[] = (string) $type;
4895 + break;
4896 + }
4897 + }
4898 + }
4899 +
4900 + if ( empty( $updated ) ) {
4901 + return false;
4902 + }
4903 +
4904 + // Nothing but language packs: a language pack changes no markup a
4905 + // cached page depends on, and purging on one would keep a multilingual
4906 + // site permanently cold.
4907 + if ( array( 'translation' ) === array_values( array_unique( $updated ) ) ) {
4908 + return false;
4909 + }
4910 +
4911 + return self::auto_updates_touch_xspeed( $results ) || self::purge_on_upgrade_enabled();
4912 + }
4913 +
4914 + /**
4915 + * Does a background run include one of our own plugins?
4916 + *
4917 + * Same rule as a foreground self-update, read out of the results array's
4918 + * shape instead of a hook_extra: each plugin entry carries the update
4919 + * object on `->item->plugin`.
4920 + *
4921 + * @param array $results Update results, keyed by type.
4922 + * @return bool
4923 + */
4924 + private static function auto_updates_touch_xspeed( array $results ): bool {
4925 + if ( empty( $results['plugin'] ) || ! is_array( $results['plugin'] ) ) {
4926 + return false;
4927 + }
4928 +
4929 + $ours = self::self_update_plugins();
4930 + foreach ( $results['plugin'] as $entry ) {
4931 + $item = is_object( $entry ) && isset( $entry->item ) ? $entry->item : null;
4932 + $file = is_object( $item ) && isset( $item->plugin ) ? (string) $item->plugin : '';
4933 + if ( '' !== $file && in_array( $file, $ours, true ) ) {
4934 + return true;
4935 + }
4936 + }
4937 +
4938 + return false;
4939 + }
4940 +
4941 + /**
4942 + * Is the "Purge After Updates" setting on?
4943 + *
4944 + * Gates THIRD-PARTY updates only — an xSpeed self-update ignores it, see
4945 + * purge_after_upgrade(). Defaults to true when the option has never been
4946 + * written, matching the schema default in CacheModule: an unset value on
4947 + * an existing install must not read as "the user turned this off".
4948 + *
4949 + * Unlike LiteSpeed, which ships the equivalent toggle OFF, this defaults
4950 + * ON — a cold cache costs one slow request, whereas stale HTML is a wrong
4951 + * page for up to the full TTL and the site owner has no way to tell why.
4952 + *
4953 + * @return bool
4954 + */
4955 + private static function purge_on_upgrade_enabled(): bool {
4956 + $opts = Settings_Manager::get( 'cache' );
4957 + return ! array_key_exists( 'purge_on_upgrade', $opts ) || ! empty( $opts['purge_on_upgrade'] );
4958 + }
4959 +
4960 + /**
4961 + * Does this completed update include xSpeed itself?
4962 + *
4963 + * Mirrors the payload shapes Plugin::maybe_restore_after_update() reads:
4964 + * a single update carries 'plugin', a bulk run carries 'plugins'.
4965 + *
4966 + * @param array $hook_extra Context for the completed operation.
4967 + * @return bool
4968 + */
4969 + private static function upgrade_touches_xspeed( array $hook_extra ): bool {
4970 + if ( ! isset( $hook_extra['type'] ) || 'plugin' !== $hook_extra['type'] ) {
4971 + return false;
4972 + }
4973 +
4974 + $updated = array();
4975 + if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
4976 + // Strings only: array_intersect() stringifies what it is given, so
4977 + // an object without __toString in a custom updater's payload would
4978 + // be a fatal rather than a miss.
4979 + $updated = array_filter( $hook_extra['plugins'], 'is_string' );
4980 + } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) {
4981 + $updated = array( $hook_extra['plugin'] );
4982 + }
4983 +
4984 + return (bool) array_intersect( self::self_update_plugins(), $updated );
4985 + }
4986 +
4987 + /**
4988 + * Plugin files whose update counts as an update to us.
4989 + *
4990 + * The self-update rule is "our own code rendered this cached HTML, so it
4991 + * must not survive the code being replaced". That is true of any add-on
4992 + * that writes into the same page: an add-on inlines critical CSS, rewrites
4993 + * stylesheet links and image URLs, and produces the compressed and static
4994 + * copies, so its update leaves exactly the stale markup this rule exists
4995 + * to clear. Free cannot name an add-on, so it asks instead.
4996 + *
4997 + * Filter: xspeed_self_update_plugins
4998 + *
4999 + * Add-ons add their own `plugin_basename( __FILE__ )`. Entries are matched
5000 + * against the plugin files WordPress reports for the completed update, so
5001 + * a value that is not a `dir/file.php` basename simply never matches.
5002 + *
5003 + * @param string[] $plugins Plugin basenames treated as our own.
5004 + * @return string[]
5005 + */
5006 + private static function self_update_plugins(): array {
5007 + $ours = array( plugin_basename( XSPEED_FILE ) );
5008 +
5009 + /** This filter is documented above. */
5010 + $filtered = apply_filters( 'xspeed_self_update_plugins', $ours );
5011 +
5012 + // Our own file is merged back afterwards rather than trusted to survive
5013 + // the round trip. A listener that returns null, a bare string, or a
5014 + // list it built from scratch would otherwise drop it, and the plugin
5015 + // would quietly stop exempting its OWN update from the setting — a
5016 + // failure no add-on author would think to test for.
5017 + $claimed = array_filter( is_array( $filtered ) ? $filtered : array(), 'is_string' );
5018 +
5019 + return array_values( array_unique( array_merge( $ours, array_filter( $claimed ) ) ) );
5020 + }
5021 +
5022 + /**
5023 + * Invalidate caches of RENDERED output owned by other plugins.
5024 + *
5025 + * purge_all() sweeps only what xSpeed wrote. A page builder that stores
5026 + * rendered HTML or generated CSS of its own — Elementor's element cache
5027 + * and `uploads/elementor/css/`, and the equivalents in Beaver / Divi /
5028 + * Bricks / Oxygen — keeps whatever asset URLs were current when it was
5029 + * written, and no xSpeed purge has ever reached it.
5030 + *
5031 + * That only matters for rewrites that happen DURING render rather than on
5032 + * the finished page. Minify, combine, lazy-load and resource hints all run
5033 + * on `xspeed_cache_final_html` or a `template_redirect` buffer — after the
5034 + * builder has already stored its copy — so nothing they emit can leak.
5035 + * The CDN module's `wp_get_attachment_url` filter is the one that can.
5036 + *
5037 + * Called ONLY from purges where asset URLs themselves can have changed
5038 + * (a CDN settings write, an explicit Purge All). NOT from purge_all(),
5039 + * which also runs on every post publish — regenerating every builder CSS
5040 + * file that often would cost more than it saves, and the builder already
5041 + * invalidates its own copy for the post being saved.
5042 + *
5043 + * @param string $cause Who asked. Threaded through to the listeners and
5044 + * the activity log.
5045 + * @return string[] Labels of the caches that were actually cleared.
5046 + */
5047 + public static function purge_render_caches( string $cause = 'manual' ): array {
5048 + /**
5049 + * Clear render caches belonging to other plugins.
5050 + *
5051 + * A listener does its own work and appends a human-readable label for
5052 + * what it cleared, so the activity log can name it. Returning
5053 + * `$cleared` unchanged means "nothing of mine is installed" and is the
5054 + * correct no-op — never a failure.
5055 + *
5056 + * Detect the owning plugin by class or constant, not by an
5057 + * `is_plugin_active()` path check: a renamed plugin folder must not
5058 + * silently disable the integration.
5059 + *
5060 + * @param string[] $cleared Labels of caches cleared so far.
5061 + * @param string $cause Why the purge is happening.
5062 + */
5063 + $cleared = (array) apply_filters( 'xspeed_purge_third_party_render_caches', array(), $cause );
5064 +
5065 + // Labels are strings destined for the activity feed. Anything else a
5066 + // third-party listener returns is dropped rather than coerced — a
5067 + // stray `0` or `null` in the log reads as a cache we cleared.
5068 + $cleared = array_values(
5069 + array_filter(
5070 + $cleared,
5071 + static function ( $label ) {
5072 + return is_string( $label ) && '' !== trim( $label );
5073 + }
5074 + )
5075 + );
5076 +
5077 + if ( ! $cleared ) {
5078 + return $cleared;
5079 + }
5080 +
5081 + // Logged separately from the page-cache purge above it. "I turned the
5082 + // CDN off and the images are still wrong" is only diagnosable if the
5083 + // feed says which OTHER plugin's cache was regenerated and when.
5084 + Activity_Log::record(
5085 + 'cache_purged',
5086 + sprintf( 'Render caches cleared (%s) — %s', $cause, implode( ', ', $cleared ) ),
5087 + Activity_Log::INFO
5088 + );
5089 +
5090 + return $cleared;
5091 + }
5092 +
5093 + /**
1068 5094 * The per-type purge menu, LiteSpeed-style. Each entry is a cache type
1069 5095 * the user can purge individually from the admin-bar dropdown. `visible`
1070 5096 * controls whether the item shows (active + licensed module only) — it
1071 5097 * NEVER limits Purge All, which always sweeps everything on disk.
@@ -1133,30 +5159,23 @@
1133 5159 */
1134 5160 public static function purge_type( string $type, string $cause = 'manual' ): int {
1135 5161 switch ( $type ) {
1136 5162 case 'all':
1137 - return self::purge_all( $cause );
5163 + $count = self::purge_all( $cause );
5164 + // "Purge All" is the user saying they don't trust anything
5165 + // stored anywhere — the one purge that should also reach
5166 + // caches of rendered output we don't own. purge_all() itself
5167 + // deliberately does NOT, because it also runs on every post
5168 + // publish. (See Render_Caches.)
5169 + self::purge_render_caches( $cause );
5170 + return $count;
1138 5171
1139 5172 case 'page':
1140 - $count = 0;
1141 - if ( is_dir( XSPEED_CACHE_DIR ) ) {
1142 - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.html' ) as $f ) {
1143 - wp_delete_file( $f );
1144 - ++$count;
1145 - }
1146 - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.meta' ) as $m ) {
1147 - wp_delete_file( $m );
1148 - }
1149 - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.br' ) as $b ) {
1150 - wp_delete_file( $b );
1151 - }
1152 - }
1153 - if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
1154 - $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
1155 - }
5173 + $count = self::purge_pages();
1156 5174 self::update_stats( array( 'last_purge' => time() ) );
1157 5175 Cache_Inventory::invalidate();
1158 5176 self::record_partial_purge( 'page', $cause, $count );
5177 + self::announce_purge( $cause, $count );
1159 5178 return $count;
1160 5179
1161 5180 case 'assets':
1162 5181 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
@@ -1161,10 +5180,32 @@
1161 5180 case 'assets':
1162 5181 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1163 5182 Minifier::purge_minified();
1164 5183 }
1165 - self::record_partial_purge( 'assets', $cause, null );
1166 - return 0;
5184 + // Deleting min/ without clearing the pages that link it left
5185 + // every cached page pointing at files that no longer exist.
5186 + // WordPress answers the missing asset by 301-ing to its
5187 + // pretty-permalink form and serving the 404 TEMPLATE as
5188 + // `HTTP 200 text/html`, which the browser accepts as a
5189 + // stylesheet and parses to zero rules — no console error, no
5190 + // network failure, no 4xx anywhere in devtools. The pages
5191 + // stayed broken for the rest of the TTL (7 days on
5192 + // Aggressive, up to 30), and the admin who clicked could not
5193 + // see it: they are logged in, so their own requests bypass
5194 + // the page cache and re-render, regenerating the assets as a
5195 + // side effect. Only anonymous visitors were served the stale
5196 + // HTML. (#244)
5197 + //
5198 + // The assets are the pages' dependency, so invalidating them
5199 + // invalidates the pages. Same invariant Cache_GC enforces
5200 + // with is_referenced(): never leave a cached page pointing at
5201 + // an asset that is gone.
5202 + $count = self::purge_pages();
5203 + self::update_stats( array( 'last_purge' => time() ) );
5204 + Cache_Inventory::invalidate();
5205 + self::record_partial_purge( 'assets', $cause, $count );
5206 + self::announce_purge( $cause, $count );
5207 + return $count;
1167 5208
1168 5209 case 'object':
1169 5210 if ( function_exists( 'wp_cache_flush' ) ) {
1170 5211 wp_cache_flush();
@@ -1174,19 +5215,156 @@
1174 5215
1175 5216 case 'rest':
1176 5217 $count = Rest_Cache::purge();
1177 5218 self::record_partial_purge( 'REST responses', $cause, $count );
5219 + self::announce_purge( $cause, $count );
1178 5220 return $count;
1179 5221
1180 5222 default:
1181 - // Pro / third-party type — let the owning module handle it.
1182 - do_action( 'xspeed_purge_type_' . $type );
1183 - self::record_partial_purge( $type, $cause, null );
1184 - return 0;
5223 + return self::purge_type_unhandled( $type, $cause );
1185 5224 }
1186 5225 }
1187 5226
1188 5227 /**
5228 + * Delete this site's cached pages from both the flat and static trees.
5229 + *
5230 + * Extracted so the `assets` purge can reuse it: minified assets are a
5231 + * dependency of the cached HTML, so clearing them must clear the pages
5232 + * too or the pages are left referencing deleted files (#244).
5233 + *
5234 + * @return int Number of page entries removed.
5235 + */
5236 + private static function purge_pages(): int {
5237 + $count = 0;
5238 + // Scoped to this site — see purge_all(). (#6)
5239 + $scope = self::current_host_dir();
5240 + $flat_root = XSPEED_CACHE_DIR . '/' . $scope;
5241 + if ( is_dir( $flat_root ) ) {
5242 + foreach ( (array) glob( $flat_root . '/*.html' ) as $f ) {
5243 + wp_delete_file( $f );
5244 + ++$count;
5245 + }
5246 + foreach ( (array) glob( $flat_root . '/*.meta' ) as $m ) {
5247 + wp_delete_file( $m );
5248 + }
5249 + foreach ( (array) glob( $flat_root . '/*.br' ) as $b ) {
5250 + wp_delete_file( $b );
5251 + }
5252 + // `*.br` does not match `*.br.size`; a size record outliving its
5253 + // body would later be read against a DIFFERENT sibling's bytes.
5254 + foreach ( (array) glob( $flat_root . '/*.br.size' ) as $b ) {
5255 + wp_delete_file( $b );
5256 + }
5257 + }
5258 + $static_root = XSPEED_CACHE_STATIC_DIR . '/' . self::current_static_scope();
5259 + if ( is_dir( $static_root ) ) {
5260 + $count += self::rmtree_html( $static_root );
5261 + }
5262 +
5263 + return $count;
5264 + }
5265 +
5266 + /**
5267 + * A purge type this class does not own — a Pro or third-party module
5268 + * registered it via the `xspeed_purge_types` filter, so hand it off.
5269 + *
5270 + * @param string $type Purge-type slug.
5271 + * @param string $cause Who asked.
5272 + */
5273 + private static function purge_type_unhandled( string $type, string $cause ): int {
5274 + $event_sequence = self::$purge_event_sequence;
5275 + $hook = 'xspeed_purge_type_' . $type;
5276 + $has_handler = false !== has_action( $hook );
5277 + do_action( $hook );
5278 + self::record_partial_purge( $type, $cause, null );
5279 +
5280 + // Announce, same as the types this class owns. Pro's "Purge Critical
5281 + // CSS" and "Purge Unused CSS" arrive here, and they change what a
5282 + // cached page CONTAINS — critical CSS is inlined into the HTML, so a
5283 + // server cache goes on serving pages with the old styles baked in.
5284 + // Fixing the three Free buttons and leaving these two silent left the
5285 + // same hole for the tier most likely to be using both plugins.
5286 + // (QA #348 round 2, issue 2)
5287 + //
5288 + // Unknown slugs must not turn into a site-wide purge merely because no
5289 + // handler exists. These are the response-changing Pro types Free knows;
5290 + // third parties can declare another through the filter. A registered
5291 + // handler plus this explicit response scope is the handled signal.
5292 + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none';
5293 + /**
5294 + * Declare whether a handled custom purge type changes cached responses.
5295 + *
5296 + * @since 1.2.3
5297 + * @param string $scope site/network/none.
5298 + * @param string $type Purge-type slug.
5299 + */
5300 + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type );
5301 + if ( $has_handler
5302 + && $event_sequence === self::$purge_event_sequence
5303 + && in_array( $scope, array( 'site', 'network' ), true )
5304 + ) {
5305 + self::announce_purge( $cause, 0, $scope, 'presentation' );
5306 + }
5307 +
5308 + return 0;
5309 + }
5310 +
5311 + /**
5312 + * Tell the server cache that a PARTIAL purge cleared cached responses.
5313 + *
5314 + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST
5315 + * Cache" each delete cached RESPONSES for the whole site, so a cache in
5316 + * front of PHP is now serving copies xSpeed has just thrown away. Only
5317 + * "Purge All" announced itself, which left three of the four toolbar
5318 + * buttons doing exactly what this contract exists to prevent: clearing
5319 + * our copy while the server kept serving the stale one. The `assets` case
5320 + * was the sharpest — it deletes the minified bundles too, so LiteSpeed
5321 + * went on serving pages whose CSS and JS no longer exist. (QA #348)
5322 + *
5323 + * Sent as the full-purge shape (`url` null) because that is what happened:
5324 + * every cached page for this site went, not one address. `object` is not
5325 + * announced — flushing the object cache changes no rendered response a
5326 + * server cache could be holding.
5327 + *
5328 + * Public because Purge_Runner sweeps the local files itself, through
5329 + * purge_local(), rather than through purge_all() — so it has to announce
5330 + * on its own behalf or `wp xspeed purge` and the dashboard button clear
5331 + * our copy while LiteSpeed keeps serving the stale one.
5332 + *
5333 + * @param string $cause Who asked.
5334 + * @param int $removed Entries removed locally.
5335 + * @param string $scope Actionable adapter scope.
5336 + * @param string $intent Reason rendered responses changed.
5337 + */
5338 + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void {
5339 + // Announcing is additive: the local sweep has already happened and
5340 + // succeeded. Notification must never be able to turn a working purge
5341 + // into a fatal, so anything the URL helpers do in an unusual context
5342 + // (early boot, a drop-in, a bare test harness) is contained here
5343 + // rather than propagating to the caller.
5344 + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) {
5345 + return;
5346 + }
5347 + try {
5348 + self::dispatch_purge_event(
5349 + 'xspeed_after_purge',
5350 + array(
5351 + 'url' => null,
5352 + 'host' => self::current_purge_host(),
5353 + 'path' => null,
5354 + 'cause' => $cause,
5355 + 'removed' => $removed,
5356 + 'scope' => $scope,
5357 + 'intent' => $intent,
5358 + 'urls' => array(),
5359 + )
5360 + );
5361 + } catch ( \Throwable $e ) {
5362 + self::log_purge_listener_error( 'xspeed_after_purge', $e );
5363 + }
5364 + }
5365 +
5366 + /**
1189 5367 * Log a partial purge so the drill-down behind "Last purge" shows every
1190 5368 * clear, not only the full ones. Without this a site whose object cache
1191 5369 * is flushed on a schedule looks, from the log, like nothing happens.
1192 5370 *
@@ -1213,8 +5391,24 @@
1213 5391 Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
1214 5392 }
1215 5393
1216 5394 /**
5395 + * Clear the static tree only, leaving the flat cache in place.
5396 + *
5397 + * A narrower purge_all() for the case where only the web-server tree can
5398 + * be wrong: its files are keyed by `{host}{path}` and nothing else, so a
5399 + * response filed under the wrong path poisons it while the flat cache —
5400 + * keyed by cache_key(), discriminators included — stays correct. Avoids
5401 + * throwing away Critical CSS, minified bundles and the object cache to
5402 + * fix a static-only problem.
5403 + *
5404 + * @return int Number of index.html files removed.
5405 + */
5406 + public static function purge_static_tree(): int {
5407 + return self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
5408 + }
5409 +
5410 + /**
1217 5411 * Recursively delete every `index.html` (and its precompressed
1218 5412 * `index.html.br` sibling, if the Pro Brotli module wrote one) plus
1219 5413 * empty directories inside the static-cache tree. Used by purge_all().
1220 5414 * Returns the number of .html files removed so purge stats stay accurate
@@ -1220,8 +5414,24 @@
1220 5414 * Returns the number of .html files removed so purge stats stay accurate
1221 5415 * across the flat + static caches — .br siblings are not counted
1222 5416 * (they're encodings of a page, not pages).
1223 5417 */
5418 + /**
5419 + * Delete a cache file, adding its size to the current sweep's byte
5420 + * total. filesize() is silenced and re-checked because the file can
5421 + * vanish between the glob and the unlink — a concurrent purge, or the
5422 + * cache GC — and a warning there would be noise, not news.
5423 + *
5424 + * @param string $file Absolute path inside the cache tree.
5425 + */
5426 + private static function sweep_delete( string $file ): void {
5427 + $size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the file may be gone already; see docblock.
5428 + if ( is_int( $size ) ) {
5429 + self::$sweep_bytes += $size;
5430 + }
5431 + wp_delete_file( $file );
5432 + }
5433 +
1224 5434 private static function rmtree_html( string $dir ): int {
1225 5435 if ( ! is_dir( $dir ) ) {
1226 5436 return 0;
1227 5437 }
@@ -1245,14 +5455,16 @@
1245 5455 @rmdir( $path );
1246 5456 continue;
1247 5457 }
1248 5458 if ( substr( $entry, -5 ) === '.html' ) {
1249 - wp_delete_file( $path );
5459 + self::sweep_delete( $path );
1250 5460 ++$removed;
1251 - } elseif ( substr( $entry, -3 ) === '.br' ) {
1252 - // Precompressed sibling (index.html.br). Remove it too so a
1253 - // purge doesn't orphan stale Brotli bodies. Not counted.
1254 - wp_delete_file( $path );
5461 + } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) {
5462 + // Precompressed sibling (index.html.br) and the record of its
5463 + // length. Remove both so a purge doesn't orphan stale Brotli
5464 + // bodies, or a size record that would later be read against a
5465 + // different sibling's bytes. Not counted.
5466 + self::sweep_delete( $path );
1255 5467 }
1256 5468 }
1257 5469 return $removed;
1258 5470 }
@@ -1269,25 +5481,42 @@
1269 5481 }
1270 5482 }
1271 5483
1272 5484 /**
5485 + * The raw xspeed_stats option as an array. Keys currently in use:
5486 + * 'last_purge', 'last_gc', 'gc_removed', 'gc_removed_total'.
5487 + */
5488 + public static function get_stats_option(): array {
5489 + $stats = get_option( 'xspeed_stats', array() );
5490 + return is_array( $stats ) ? $stats : array();
5491 + }
5492 +
5493 + /**
1273 5494 * Persist stats with autoload disabled — stats are only read in admin
1274 5495 * contexts, so there is no reason to inflate every frontend request's
1275 5496 * `wp_load_alloptions()` payload.
5497 + *
5498 + * MERGES into whatever is already stored. It used to overwrite, which
5499 + * was harmless while `last_purge` was the only key — with the GC keys
5500 + * alongside it, a purge would have wiped the GC history and vice versa.
1276 5501 */
1277 - private static function update_stats( array $stats ) {
1278 - if ( false === get_option( 'xspeed_stats' ) ) {
5502 + public static function update_stats( array $stats ) {
5503 + if ( false === get_option( 'xspeed_stats', false ) ) {
1279 5504 add_option( 'xspeed_stats', $stats, '', 'no' );
1280 5505 return;
1281 5506 }
1282 - update_option( 'xspeed_stats', $stats );
5507 + update_option( 'xspeed_stats', array_merge( self::get_stats_option(), $stats ) );
1283 5508 }
1284 5509
1285 5510 public static function get_stats() {
1286 5511 $count = 0;
1287 5512 $size = 0;
1288 - if ( is_dir( XSPEED_CACHE_DIR ) ) {
1289 - $files = glob( XSPEED_CACHE_DIR . '/*.html' );
5513 + // This site's entries only — on multisite the tree is shared, so an
5514 + // unscoped count reported the whole network's pages on every
5515 + // subsite's dashboard. (#6)
5516 + $flat_root = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
5517 + if ( is_dir( $flat_root ) ) {
5518 + $files = glob( $flat_root . '/*.html' );
1290 5519 if ( $files ) {
1291 5520 $count = count( $files );
1292 5521 foreach ( $files as $f ) {
1293 5522 $size += filesize( $f );
@@ -1310,8 +5539,12 @@
1310 5539 Hit_Counter::collect_server_log_hits();
1311 5540
1312 5541 $stats = get_option( 'xspeed_stats', array() );
1313 5542 $totals = Hit_Counter::totals_24h();
5543 + // One read of the ground truth for both fields below: it costs a
5544 + // stat of advanced-cache.php and a tokenize of wp-config.php, and
5545 + // this runs on every dashboard poll.
5546 + $serving = self::page_cache_operational();
1314 5547 return array(
1315 5548 'cached_pages' => $count,
1316 5549 'cache_size' => $size,
1317 5550 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
@@ -1320,12 +5553,101 @@
1320 5553 // CacheHero stat grid + the Health module's panel.
1321 5554 'hits_24h' => $totals['hits'],
1322 5555 'misses_24h' => $totals['misses'],
1323 5556 'hit_ratio' => $totals['ratio'],
5557 + // Requests kept OUT of the ratio (404s + bots) — surfaced as its own
5558 + // "absorbed N scanner/bot requests" line rather than distorting the
5559 + // cache-performance number. (#118)
5560 + 'excluded_24h' => $totals['excluded'],
5561 + // True when an edge cache (Cloudflare) fronts the origin, so hits are
5562 + // absorbed before reaching PHP. The dashboard labels the ratio
5563 + // "origin-layer only" instead of implying it's the full picture. (#118)
5564 + 'edge_cache' => self::edge_cache_detected(),
5565 + /*
5566 + * Whether the page cache is actually SERVING, as opposed to
5567 + * switched on in settings. The hero read the setting alone and
5568 + * announced "Active — serving cached HTML"; a site whose
5569 + * advanced-cache.php had been taken over by another cache plugin
5570 + * got that line while every response carried
5571 + * `X-XSpeed-Cache: BYPASS`. The setting is the user's intent;
5572 + * this is the outcome, and the dashboard needs both to explain
5573 + * the difference.
5574 + */
5575 + 'page_cache_serving' => $serving,
5576 + /*
5577 + * Why not, when intent and outcome disagree. Only computed in
5578 + * that state — the detector sweep behind it is far more work than
5579 + * a stats call should do on an ordinary healthy site.
5580 + */
5581 + 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) )
5582 + ? ( self::acquisition_blocker() ?? self::not_serving_reason() )
5583 + : null,
1324 5584 );
1325 5585 }
1326 5586
1327 5587 /**
5588 + * Why the cache is not serving, when nothing REFUSES to enable it.
5589 + *
5590 + * acquisition_blocker() answers "may we take the field", and since a
5591 + * foreign drop-in became takeable it answers null on a site where another
5592 + * plugin is nonetheless holding that file. Intent and outcome still
5593 + * disagree there, and the dashboard was left reporting the symptom -- not
5594 + * serving -- with no reason under it, which is exactly the state a user
5595 + * cannot act on.
5596 + *
5597 + * So this names the holder and says what to do: enabling takes it over.
5598 + */
5599 + private static function not_serving_reason(): ?string {
5600 + $owner = self::dropin_owner();
5601 + if ( self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner ) {
5602 + return null;
5603 + }
5604 +
5605 + if ( self::DROPIN_UNREADABLE === $owner ) {
5606 + return __( 'advanced-cache.php cannot be read, so xSpeed cannot tell whose page cache is installed.', 'xspeed' );
5607 + }
5608 +
5609 + $label = Page_Cache_Detector::dropin_owner_label();
5610 + return $label
5611 + ? sprintf(
5612 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
5613 + __( '%s is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ),
5614 + $label
5615 + )
5616 + : __( 'Another plugin is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' );
5617 + }
5618 +
5619 + /**
5620 + * Whether the current request should be kept OUT of the cache hit/miss
5621 + * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect
5622 + * time, so is_404() is resolved. (#118)
5623 + */
5624 + private static function miss_is_excluded(): bool {
5625 + if ( function_exists( 'is_404' ) && is_404() ) {
5626 + return true;
5627 + }
5628 + $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
5629 + ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) )
5630 + : '';
5631 + return Hit_Counter::is_bot_ua( $ua );
5632 + }
5633 +
5634 + /**
5635 + * Whether an edge cache fronts this origin, so an unknown share of hits
5636 + * is served there and never counted here — which makes the origin ratio a
5637 + * partial view the dashboard has to label as such. (#118)
5638 + *
5639 + * This used to mean "the Cloudflare module is switched on", which answered
5640 + * no for every site fronted by anything else, and no for a site on
5641 + * Cloudflare that had never opened our Cloudflare panel. Both of those
5642 + * sites had their ratio presented as the whole story. Edge_Provider knows
5643 + * better and knows it per request, so ask it.
5644 + */
5645 + private static function edge_cache_detected(): bool {
5646 + return Edge_Provider::NONE !== Edge_Provider::detect()['confidence'];
5647 + }
5648 +
5649 + /**
1328 5650 * Apply the user's enable/disable choice. Called from the REST toggle
1329 5651 * endpoint, which is gated by current_user_can( 'manage_options' ) and
1330 5652 * a verified REST nonce.
1331 5653 *
@@ -1336,11 +5658,19 @@
1336 5658 * already has cache_enabled = true is a different act and is handled
1337 5659 * by restore_dropin_if_enabled() on activation and auto_heal() at
1338 5660 * runtime; without it every plugin update silently un-caches the site.
1339 5661 *
5662 + * Enabling is gated on acquisition_blocker(): if another plugin owns the
5663 + * drop-in, or WP_CACHE is written in a form we must not rewrite, nothing
5664 + * is written and the returned state carries `blocked` + a reason the
5665 + * caller can show. Callers must persist `cache_enabled` from the returned
5666 + * `enabled`, never from what they asked for.
5667 + *
1340 5668 * @param bool $enable User's choice.
1341 5669 * @return array{
1342 5670 * enabled: bool,
5671 + * blocked: bool,
5672 + * blocked_reason: ?string,
1343 5673 * dropin_installed: bool,
1344 5674 * wp_cache_constant: bool,
1345 5675 * wp_config_writable: bool,
1346 5676 * manual_snippet: ?string
@@ -1345,29 +5675,228 @@
1345 5675 * wp_config_writable: bool,
1346 5676 * manual_snippet: ?string
1347 5677 * }
1348 5678 */
1349 - public static function toggle( $enable ) {
5679 + public static function toggle( $enable, bool $consented = true ) {
5680 + Page_Cache_Detector::invalidate();
5681 + $expected = Page_Cache_Detector::inspect()['revision'];
5682 + /** Diagnostic seam; changing the expected revision can only force a safe refusal. */
5683 + $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected );
5684 + $lock = self::page_cache_lock();
5685 + if ( ! is_resource( $lock ) ) {
5686 + return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) );
5687 + }
5688 + try {
5689 + Page_Cache_Detector::invalidate();
5690 + $fresh = Page_Cache_Detector::inspect()['revision'];
5691 + if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
5692 + return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) );
5693 + }
5694 + $state = self::toggle_unlocked( (bool) $enable, $consented );
5695 + return $state;
5696 + } finally {
5697 + flock( $lock, LOCK_UN );
5698 + fclose( $lock );
5699 + }
5700 + }
5701 +
5702 + /** Run the page-cache mutation while toggle() owns the scoped lock. */
5703 + /**
5704 + * @param bool $consented The user asked for this in the dashboard, so a
5705 + * foreign drop-in may be taken over. False on the
5706 + * unattended paths, which stand down instead.
5707 + */
5708 + private static function toggle_unlocked( bool $enable, bool $consented = true ) {
1350 5709 $enable = (bool) $enable;
1351 5710
1352 5711 if ( $enable ) {
1353 - $dropin_ok = self::install_dropin();
1354 - $wp_config_ok = self::set_wp_cache_constant( true );
5712 + /*
5713 + * Preflight. The drop-in and the WP_CACHE define are shared,
5714 + * single-occupancy state; if we do not own them, no part of this
5715 + * runs — not the drop-in, not wp-config.php, not the rewrite
5716 + * block. Refusing whole is the point: a partial enable leaves the
5717 + * site claiming a cache it cannot serve.
5718 + *
5719 + * Every caller routes through here (REST, onboarding, MCP, CLI,
5720 + * the optimize runner, Pro's migration), so the gate lives here
5721 + * rather than being re-implemented at each entry point.
5722 + *
5723 + * Except when there is nothing to acquire. A site where we
5724 + * already own the drop-in and are already serving is being asked
5725 + * to stay as it is, and the gate answers a different question —
5726 + * "is the field free to take" — which a merely ACTIVE competitor
5727 + * makes false. So "make sure caching is on", from an AI agent,
5728 + * the optimize runner or Pro's migration, came back as a refusal
5729 + * telling the user to deactivate a plugin on a site that was
5730 + * caching perfectly. The dashboard never saw it, because nobody
5731 + * presses Enable on a cache that is already enabled.
5732 + *
5733 + * Only the GATE is skipped. The writes below still run, and every
5734 + * one of them is individually idempotent — which matters, because
5735 + * this is the path CacheModule re-bakes the drop-in through when
5736 + * an exclusion rule or the TTL changes (#240, #251), and the path
5737 + * auto_heal() restores a stripped WP_CACHE through. Returning
5738 + * early here left both of those doing nothing at all, silently,
5739 + * on exactly the healthy sites this branch is about.
5740 + */
5741 + $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner();
5742 + $blocker = $reasserting ? null : self::acquisition_blocker();
5743 +
5744 + /*
5745 + * Taking over another plugin's drop-in needs the user to have
5746 + * asked for it. On the dashboard they did -- they clicked the
5747 + * switch, having been told whose file it is. The UNATTENDED
5748 + * callers have no such click: restore_dropin_if_enabled() runs
5749 + * after a plugin update and auto_heal() on an admin page load,
5750 + * both from nothing more than `cache_enabled` still being true.
5751 + *
5752 + * A competitor installed since that flag was set would have its
5753 + * page cache seized by a background repair, which is the silent
5754 + * acquisition this plugin refuses to perform. So those callers
5755 + * pass $consented = false and stand down instead.
5756 + */
5757 + if ( null === $blocker && ! $consented && self::DROPIN_FOREIGN === self::dropin_owner() ) {
5758 + // Name the owner. This string is rendered by host plugins
5759 + // through Host::enable_page_cache(), and an unnamed refusal
5760 + // is what made every host invent its own explanation.
5761 + $owner_label = Page_Cache_Detector::dropin_owner_label();
5762 + return self::blocked_toggle_state(
5763 + $owner_label
5764 + ? sprintf(
5765 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
5766 + __( '%s owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ),
5767 + $owner_label
5768 + )
5769 + : __( 'Another plugin owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' )
5770 + );
5771 + }
5772 + if ( null !== $blocker ) {
5773 + Activity_Log::record(
5774 + 'cache_enable_blocked',
5775 + 'Cache not enabled — ' . $blocker,
5776 + Activity_Log::WARN
5777 + );
5778 +
5779 + return self::blocked_toggle_state( $blocker );
5780 + }
5781 +
5782 + $dropin_path = WP_CONTENT_DIR . '/advanced-cache.php';
5783 + $config_path = self::wp_config_path();
5784 + $dropin_before = file_exists( $dropin_path ) ? self::read_file( $dropin_path ) : null;
5785 + $config_before = '' !== $config_path ? self::read_file( $config_path ) : null;
5786 + $dropin_ok = self::install_dropin();
5787 + if ( ! $dropin_ok ) {
5788 + $partial = self::read_file( $dropin_path );
5789 + if ( is_string( $partial ) && xspeed_has_canonical_dropin_signature( $partial ) ) {
5790 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $partial, $config_path, $config_before, null );
5791 + }
5792 + /*
5793 + * Preflight said the field was clear, so this is a filesystem
5794 + * failure (or a drop-in that appeared in between). Without the
5795 + * drop-in there is no cache to enable, and persisting
5796 + * cache_enabled anyway is what produced sites reporting a
5797 + * healthy cache while serving every request uncached.
5798 + */
5799 + $reason = __( 'Could not write wp-content/advanced-cache.php. Check filesystem permissions.', 'xspeed' );
5800 + Activity_Log::record(
5801 + 'cache_enable_blocked',
5802 + 'Cache not enabled — ' . $reason,
5803 + Activity_Log::WARN
5804 + );
5805 +
5806 + return array(
5807 + 'enabled' => false,
5808 + 'blocked' => true,
5809 + 'blocked_reason' => $reason,
5810 + 'dropin_installed' => false,
5811 + 'wp_cache_constant' => false,
5812 + 'rewrite_installed' => false,
5813 + 'wp_config_writable' => self::wp_config_writable(),
5814 + 'manual_snippet' => null,
5815 + 'nginx_snippet' => self::nginx_snippet(),
5816 + 'nginx_server_block' => self::full_nginx_server_block(),
5817 + );
5818 + }
5819 +
5820 + $dropin_written = self::read_file( $dropin_path );
5821 + self::set_wp_cache_constant( true );
5822 + $config_written = '' !== $config_path ? self::read_file( $config_path ) : null;
5823 + Page_Cache_Detector::invalidate();
5824 + $dropin_ours = self::DROPIN_XSPEED === self::dropin_owner();
5825 + $constant_state = self::wp_cache_define_state();
5826 + $constant_ok = 'true' === $constant_state;
5827 +
5828 + /*
5829 + * A wp-config.php we cannot write at all is a supported state, not
5830 + * a failed transaction. Plenty of managed hosts ship the file
5831 + * read-only; there the drop-in is ours and installed, the cache
5832 + * works the moment WP_CACHE exists, and the one line to paste
5833 + * comes back as `manual_snippet`. Rolling back instead left those
5834 + * hosts unable to turn the page cache on by any route — including
5835 + * when the user had already pasted the define, since the write
5836 + * fails on an unwritable file whatever value is already there.
5837 + *
5838 + * `undefined` ONLY. `false` looks eligible — this method would
5839 + * have rewritten it — but the snippet we hand back cannot work
5840 + * there: the file already says `define( 'WP_CACHE', false )`, the
5841 + * first define() call wins, and a user who pastes our line via
5842 + * FTP ends up with a cache that never serves AND a `duplicate`
5843 + * wp-config that blocks every future toggle in both directions.
5844 + * They have to edit the existing line, which means refusing here
5845 + * and saying so. `duplicate` and `dynamic` are refused by
5846 + * acquisition_blocker() before we get here, and if one appears in
5847 + * the race window it must still fail closed.
5848 + */
5849 + $manual_mode = ! $constant_ok
5850 + && 'undefined' === $constant_state
5851 + && ! self::can_write_wp_config();
5852 +
5853 + if ( ! $dropin_ours || ( ! $constant_ok && ! $manual_mode ) ) {
5854 + if ( ! self::can_write_wp_config() ) {
5855 + $reason = 'false' === $constant_state
5856 + ? __( "wp-config.php is not writable and already contains define( 'WP_CACHE', false ). Change that line to true — adding a second one would leave the cache off and block xSpeed from changing it again.", 'xspeed' )
5857 + : __( 'xSpeed could not verify the complete page-cache write, and wp-config.php is not writable. Its changes were rolled back.', 'xspeed' );
5858 + } else {
5859 + $reason = __( 'xSpeed could not verify the complete page-cache write. Its changes were rolled back.', 'xspeed' );
5860 + }
5861 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
5862 + return self::blocked_toggle_state( $reason );
5863 + }
5864 + $wp_config_ok = $constant_ok;
1355 5865 $rewrite_ok = self::install_rewrite();
1356 5866 self::ensure_hits_log_file();
1357 5867 self::sync_mobile_flag();
1358 5868 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
5869 + Settings::update( array( 'cache_enabled' => true ) );
5870 + if ( empty( Settings::get()['cache_enabled'] ) ) {
5871 + self::remove_rewrite();
5872 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
5873 + delete_option( 'xspeed_page_cache_ownership_receipt' );
5874 + return self::blocked_toggle_state( __( 'xSpeed could not save the page-cache setting. Its file changes were rolled back.', 'xspeed' ) );
5875 + }
1359 5876
1360 - Activity_Log::record(
1361 - 'cache_enabled_event',
1362 - $wp_config_ok
1363 - ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
1364 - : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
1365 - $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
1366 - );
5877 + /*
5878 + * Only when this call actually changed something. auto_heal() runs
5879 + * the enable transaction on every admin_init, and an unconditional
5880 + * entry filled the 50-slot log with identical "Cache enabled" lines
5881 + * within 50 wp-admin page loads, evicting every real event — plus
5882 + * an option write per admin request. The sentence is also false
5883 + * when nothing was installed.
5884 + */
5885 + if ( $dropin_written !== $dropin_before || $config_written !== $config_before ) {
5886 + Activity_Log::record(
5887 + 'cache_enabled_event',
5888 + $wp_config_ok
5889 + ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
5890 + : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
5891 + $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
5892 + );
5893 + }
1367 5894
1368 5895 return array(
1369 5896 'enabled' => true,
5897 + 'blocked' => false,
5898 + 'blocked_reason' => null,
1370 5899 'dropin_installed' => (bool) $dropin_ok,
1371 5900 'wp_cache_constant' => (bool) $wp_config_ok,
1372 5901 'rewrite_installed' => (bool) $rewrite_ok,
1373 5902 'wp_config_writable' => self::wp_config_writable(),
@@ -1380,26 +5909,168 @@
1380 5909 'nginx_server_block' => self::full_nginx_server_block(),
1381 5910 );
1382 5911 }
1383 5912
5913 + /*
5914 + * Whose advanced-cache.php is on disk decides how much of the disable
5915 + * below may run. Read it once, before anything is touched.
5916 + */
5917 + $owner = self::dropin_owner();
5918 + $not_ours = self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner;
5919 + if ( ! self::set_wp_cache_constant( false ) ) {
5920 + /*
5921 + * The mirror of the enable path. A wp-config.php nobody can write
5922 + * does not trap the user in a cache they turned off: WP_CACHE on
5923 + * its own does nothing once advanced-cache.php is gone, and core
5924 + * simply skips the missing drop-in. Refusing here left the
5925 + * read-only managed hosts able to enable the page cache and never
5926 + * able to disable it again.
5927 + *
5928 + * A drop-in that is not ours reaches the same conclusion by a
5929 + * different road. WP_CACHE is then the switch for THEIR cache, so
5930 + * set_wp_cache_constant() refuses it — correctly, and permanently,
5931 + * because nothing the user does to xSpeed will make that file ours
5932 + * again. Treating that refusal as a failed disable was a trap with
5933 + * no exit: install any competing cache plugin while xSpeed's cache
5934 + * was on, and xSpeed's toggle could never be turned off again,
5935 + * while the dashboard went on claiming a cache that was serving
5936 + * nothing. Turning xSpeed off is entirely within our own state —
5937 + * our setting, our rewrite block — so it proceeds, and their
5938 + * constant and their file are left exactly as they are.
5939 + */
5940 + /*
5941 + * Every reason set_wp_cache_constant() refuses is structural
5942 + * except one, and the exception is the only one worth blocking
5943 + * on. It will not touch a constant it cannot prove is ours; it
5944 + * will not rewrite a define it cannot read as a literal —
5945 + * duplicate, dynamic, or inside a conditional; and it cannot
5946 + * write a file the filesystem will not let it write. None of
5947 + * those improve on a retry, and all of them leave a WP_CACHE
5948 + * that does nothing once our drop-in is gone. What is left — our
5949 + * own constant, in a shape we can rewrite, in a file we can
5950 + * write, and the write still failed — is a real I/O failure, and
5951 + * that one still refuses so the user is not told a cache was
5952 + * turned off while it goes on serving.
5953 + *
5954 + * The proof, not the drop-in, is the test. A user who pasted our
5955 + * manual snippet on a locked-down host has a WP_CACHE line with
5956 + * no receipt on it; if their drop-in later goes missing, we can
5957 + * never prove that line is ours, so refusing left the toggle
5958 + * stuck on with no way out but enabling first and disabling
5959 + * again. Nothing loads a drop-in that is not there, so the line
5960 + * is inert either way and the disable proceeds without it.
5961 + */
5962 + $leave_it = ! self::wp_cache_define_is_ours_to_remove( $owner )
5963 + || ! in_array( self::wp_cache_define_state(), array( 'true', 'false', 'undefined' ), true )
5964 + || ! self::can_write_wp_config();
5965 + if ( ! $leave_it ) {
5966 + return self::blocked_toggle_state( __( 'xSpeed could not safely remove its WP_CACHE setting. The cache remains enabled.', 'xspeed' ) );
5967 + }
5968 + }
1384 5969 self::remove_dropin();
1385 - self::set_wp_cache_constant( false );
5970 + if ( self::DROPIN_XSPEED === self::dropin_owner() ) {
5971 + // Put WP_CACHE back, and say so if we could not. Reporting a
5972 + // hardcoded `enabled: true` here claimed a working cache on a
5973 + // site whose constant we had just failed to restore.
5974 + // Put WP_CACHE back, then read the outcome off disk rather than
5975 + // trusting the write's return value — a write can report failure
5976 + // for a value that was already correct, and the question the
5977 + // caller needs answered is whether the cache serves.
5978 + self::set_wp_cache_constant( true );
5979 + return self::blocked_toggle_state(
5980 + self::page_cache_operational()
5981 + ? __( 'xSpeed could not remove its page-cache drop-in. The cache remains enabled.', 'xspeed' )
5982 + : __( 'xSpeed could not remove its page-cache drop-in, and could not put WP_CACHE back. The cache is not serving; check wp-config.php before changing the page cache again.', 'xspeed' )
5983 + );
5984 + }
1386 5985 self::remove_rewrite();
5986 + /*
5987 + * The .htaccess block serves cached HTML straight off disk without
5988 + * ever reaching PHP, so a block we failed to remove keeps answering
5989 + * requests from a cache the user just turned off — and nothing else
5990 + * in this method can stop it. remove_rewrite() also returns false
5991 + * when there is no .htaccess to clean, which is the ordinary case,
5992 + * so ask the file rather than trust the return value.
5993 + */
5994 + if ( self::rewrite_installed() ) {
5995 + if ( $not_ours ) {
5996 + // Nothing to roll back — under a foreign drop-in this method
5997 + // removed no drop-in and wrote no constant, and it could not
5998 + // put either back if it wanted to. Say what is actually left.
5999 + return self::blocked_toggle_state( __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. Remove the xSpeed block from .htaccess by hand before turning the page cache off.', 'xspeed' ) );
6000 + }
6001 + /*
6002 + * Roll the disable back. Both calls can fail — a filesystem that
6003 + * would not let us remove the block may not let us write the
6004 + * drop-in either — and discarding their results reported an
6005 + * enabled cache over a site left with no drop-in and no
6006 + * constant. Fall through to the default state so the artifact
6007 + * fields are read from disk rather than asserted.
6008 + */
6009 + self::install_dropin();
6010 + self::set_wp_cache_constant( true );
6011 + // Both of those can fail — a filesystem that would not let us
6012 + // remove the block may not let us write the drop-in either — so
6013 + // the message follows what is on disk afterwards, not what the
6014 + // calls returned.
6015 + return self::blocked_toggle_state(
6016 + self::page_cache_operational()
6017 + ? __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. The cache remains enabled.', 'xspeed' )
6018 + : __( 'xSpeed could not remove its rewrite rules from .htaccess, and could not restore the drop-in it had just removed. The cache is not serving, and the site may still return stale cached pages until the xSpeed block is removed from .htaccess by hand.', 'xspeed' )
6019 + );
6020 + }
1387 6021 // Drop the device-bucket marker too — with the drop-in gone there's
1388 6022 // nothing left to read it, and leaving it behind would dirty a fresh
1389 6023 // re-enable (and leaks across test runs).
1390 6024 self::sync_mobile_flag( false );
6025 + Settings::update( array( 'cache_enabled' => false ) );
6026 + if ( ! empty( Settings::get()['cache_enabled'] ) ) {
6027 + if ( $not_ours ) {
6028 + // Same as above: there is nothing of ours on disk to restore.
6029 + return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state.', 'xspeed' ) );
6030 + }
6031 + self::install_dropin();
6032 + self::set_wp_cache_constant( true );
6033 + return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state. The page cache was restored.', 'xspeed' ) );
6034 + }
1391 6035
6036 + // A WP_CACHE we could not remove because wp-config.php is read-only
6037 + // is left behind deliberately (see above) — say so rather than
6038 + // reporting a constant that is still in the file as gone.
6039 + $constant_left = 'true' === self::wp_cache_define_state();
6040 + /*
6041 + * Say why the constant is still there, because there are now three
6042 + * different reasons and they call for different advice. Keyed off the
6043 + * same facts $leave_it was, so the log cannot drift from the decision
6044 + * it is describing — it did, briefly, and reported a wp-config.php as
6045 + * unwritable when the real reason was that we could not prove the
6046 + * line was ours.
6047 + */
6048 + if ( self::DROPIN_UNREADABLE === $owner ) {
6049 + $log_message = 'Cache disabled. advanced-cache.php could not be read, so it and the WP_CACHE setting were left untouched.';
6050 + } elseif ( $not_ours ) {
6051 + $log_message = 'Cache disabled. Another plugin owns advanced-cache.php, so its drop-in and its WP_CACHE setting were left untouched.';
6052 + } elseif ( ! $constant_left ) {
6053 + $log_message = 'Cache disabled. Drop-in removed.';
6054 + } elseif ( ! self::wp_cache_define_is_ours_to_remove( $owner ) ) {
6055 + $log_message = 'Cache disabled. WP_CACHE was left in place — it carries no proof xSpeed wrote it, and it does nothing without a drop-in.';
6056 + } elseif ( ! self::can_write_wp_config() ) {
6057 + $log_message = 'Cache disabled. Drop-in removed; wp-config.php not writable, so WP_CACHE was left in place (harmless without the drop-in).';
6058 + } else {
6059 + $log_message = 'Cache disabled. Drop-in removed; WP_CACHE was left in place (harmless without the drop-in).';
6060 + }
1392 6061 Activity_Log::record(
1393 6062 'cache_disabled_event',
1394 - 'Cache disabled. Drop-in removed.',
1395 - Activity_Log::INFO
6063 + $log_message,
6064 + $constant_left ? Activity_Log::WARN : Activity_Log::INFO
1396 6065 );
1397 6066
1398 6067 return array(
1399 6068 'enabled' => false,
6069 + 'blocked' => false,
6070 + 'blocked_reason' => null,
1400 6071 'dropin_installed' => false,
1401 - 'wp_cache_constant' => false,
6072 + 'wp_cache_constant' => $constant_left,
1402 6073 'rewrite_installed' => false,
1403 6074 'wp_config_writable' => self::wp_config_writable(),
1404 6075 'manual_snippet' => null,
1405 6076 'nginx_snippet' => self::nginx_snippet(),
@@ -1406,9 +6077,134 @@
1406 6077 'nginx_server_block' => self::full_nginx_server_block(),
1407 6078 );
1408 6079 }
1409 6080
6081 + /** Acquire the local lock that serializes page-cache ownership changes. */
6082 + private static function page_cache_lock() {
6083 + $path = WP_CONTENT_DIR . '/.xspeed-page-cache.lock';
6084 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.PHP.NoSilencedErrors.Discouraged -- flock requires a local handle; failure is a safe blocked result.
6085 + $lock = @fopen( $path, 'c+' );
6086 + if ( ! is_resource( $lock ) || ! flock( $lock, LOCK_EX ) ) {
6087 + return false;
6088 + }
6089 + return $lock;
6090 + }
6091 +
1410 6092 /**
6093 + * Build the stable response shape for a refused transaction.
6094 + *
6095 + * The artifact fields report what is ON DISK, not zeros. A refusal means
6096 + * xSpeed changed nothing — on a site already running our cache that is
6097 + * exactly the state where the drop-in and WP_CACHE are both still in
6098 + * place and still serving hits. Hardcoding false told the dashboard the
6099 + * cache had been dismantled every time a refusal was returned.
6100 + */
6101 + private static function blocked_toggle_state( string $reason ): array {
6102 + /*
6103 + * `enabled` answers ONE question: is the page cache operational right
6104 + * now. Not what was asked for, and not what the option says.
6105 + *
6106 + * WordPress loads advanced-cache.php only when WP_CACHE is truthy, so
6107 + * those two files together are the whole answer, and reading them is
6108 + * the only source that cannot go stale. Both of the alternatives were
6109 + * tried here and both produced wrong answers on real paths: a
6110 + * hardcoded false told a caller the cache had gone away on a site
6111 + * still serving hits, and the persisted setting told a caller the
6112 + * cache was healthy after a rollback had just removed the artifacts
6113 + * — the option is not written until the end of the transaction, so
6114 + * mid-transaction it is stale by construction.
6115 + *
6116 + * Deliberately not a parameter. Every branch that got to choose its
6117 + * own answer eventually chose wrong.
6118 + */
6119 + return array(
6120 + 'enabled' => self::page_cache_operational(),
6121 + 'blocked' => true,
6122 + 'blocked_reason' => $reason,
6123 + 'dropin_installed' => self::DROPIN_XSPEED === self::dropin_owner(),
6124 + 'wp_cache_constant' => 'true' === self::wp_cache_define_state(),
6125 + 'rewrite_installed' => self::rewrite_installed(),
6126 + 'wp_config_writable' => self::wp_config_writable(),
6127 + 'manual_snippet' => null,
6128 + 'nginx_snippet' => self::nginx_snippet(),
6129 + 'nginx_server_block' => self::full_nginx_server_block(),
6130 + );
6131 + }
6132 +
6133 + /**
6134 + * The wp-config.php line a user must paste, or null when none is needed.
6135 + *
6136 + * Non-null only where the drop-in is ours and WP_CACHE is not set to true
6137 + * in a file we can write — the read-only managed host. Everywhere else the
6138 + * constant is ours to manage and there is nothing to ask for.
6139 + */
6140 + public static function manual_wp_cache_snippet(): ?string {
6141 + if ( self::DROPIN_XSPEED !== self::dropin_owner() ) {
6142 + return null;
6143 + }
6144 + if ( 'true' === self::wp_cache_define_state() ) {
6145 + return null;
6146 + }
6147 + return self::wp_config_writable() ? null : "define( 'WP_CACHE', true );";
6148 + }
6149 +
6150 + /**
6151 + * Is the page cache serving right now?
6152 + *
6153 + * Two things decide it, and `WP_CACHE` is not one of them.
6154 + *
6155 + * xSpeed serves a cached page from `template_redirect` whenever the
6156 + * setting is on — see the `HIT (php)` mark on that path, which exists
6157 + * precisely for "the drop-in isn't loaded". `advanced-cache.php` and the
6158 + * `WP_CACHE` constant that loads it are the FAST path: they answer before
6159 + * WordPress boots, which is worth a lot of milliseconds and nothing at
6160 + * all to the question of whether pages are being served from cache.
6161 + *
6162 + * Conflating the two reported a dead cache over a live one. On a managed
6163 + * host with an unwritable wp-config.php — the exact case the manual
6164 + * snippet exists for — one card said "Your cache works on every request",
6165 + * "On, but not serving", "nothing will be cached until you add this line"
6166 + * and "hit ratio 67%", all at once, and told the user to edit a file they
6167 + * have no permission to write. The released 1.2.1 reported that site as
6168 + * active, correctly.
6169 + *
6170 + * So: the setting, and whether anyone else holds the drop-in. A foreign
6171 + * drop-in answers before WordPress loads us, so ours never runs and we
6172 + * genuinely are not serving. An unreadable one we must assume the same of.
6173 + * Everything else — our drop-in, or none at all — serves.
6174 + *
6175 + * Public because it is part of the host-plugin contract — see Host. A
6176 + * plugin that installed xSpeed needs to be able to say whether the cache
6177 + * it asked for is actually serving, and no combination of settings reads
6178 + * answers that.
6179 + */
6180 + public static function page_cache_operational(): bool {
6181 + $settings = Settings::get();
6182 + if ( empty( $settings['cache_enabled'] ) ) {
6183 + return false;
6184 + }
6185 + $owner = self::dropin_owner();
6186 + return self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner;
6187 + }
6188 +
6189 + /** Restore exact snapshots only while disk still matches our own write. */
6190 + private static function rollback_page_cache_artifacts( string $dropin_path, ?string $dropin_before, ?string $dropin_written, string $config_path, ?string $config_before, ?string $config_written ): void {
6191 + // Roll back only files that still carry xSpeed's just-written state.
6192 + if ( null !== $dropin_written && hash_equals( $dropin_written, (string) self::read_file( $dropin_path ) ) ) {
6193 + if ( null === $dropin_before ) {
6194 + wp_delete_file( $dropin_path );
6195 + } else {
6196 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
6197 + file_put_contents( $dropin_path, $dropin_before );
6198 + }
6199 + }
6200 + if ( '' !== $config_path && null !== $config_before && null !== $config_written && hash_equals( $config_written, (string) self::read_file( $config_path ) ) && self::wp_cache_receipt_matches_source( $config_written ) ) {
6201 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
6202 + file_put_contents( $config_path, $config_before );
6203 + }
6204 + }
6205 +
6206 + /**
1411 6207 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
1412 6208 * direct is_writable() under WordPress.WP.AlternativeFunctions.
1413 6209 */
1414 6210 private static function wp_config_writable() {
@@ -1445,10 +6241,11 @@
1445 6241 * they don't share a user at all. A default-umask 0644 file is then
1446 6242 * unwritable by nginx, the access_log write silently fails, and the
1447 6243 * dashboard shows a 0% hit ratio even though static HITs are serving.
1448 6244 * So we widen the dir to 0777 and the file to 0666 — group/other write —
1449 - * so whatever uid nginx runs as can append. (The file holds only HIT
1450 - * request lines, no secrets.)
6245 + * so whatever uid nginx runs as can append. The file holds HIT request
6246 + * lines and must be protected like an access log: paths and queries can
6247 + * contain sensitive values.
1451 6248 */
1452 6249 /**
1453 6250 * Directory holding the nginx hit log. Lives under uploads/, NOT the
1454 6251 * cache dir — uninstall.php and a cache purge both delete the cache
@@ -1491,12 +6288,195 @@
1491 6288 * and the fast pre-WP path was silently dead.
1492 6289 *
1493 6290 * @param bool|null $enabled Force a state; null reads the current setting.
1494 6291 */
6292 + /**
6293 + * Write the subdirectory-multisite path list the drop-in needs to work
6294 + * out which blog a request belongs to.
6295 + *
6296 + * The drop-in runs before WordPress, so it cannot call is_multisite()
6297 + * or get_blog_details(). It can only see REQUEST_URI — so we persist the
6298 + * network's blog paths (one per line, longest first) next to the cache
6299 + * files, exactly as sync_mobile_flag() persists the device flag. The
6300 + * drop-in prefix-matches the URI against that list to pick the same
6301 + * bucket Cache::current_host_dir() picks. (#6)
6302 + *
6303 + * No file is written for a single site or a subdomain network — there
6304 + * the host alone identifies the blog and the bucket carries no prefix.
6305 + */
6306 + public static function sync_site_paths(): void {
6307 + $file = XSPEED_CACHE_DIR . '/.site-paths';
6308 +
6309 + $needed = function_exists( 'is_multisite' ) && is_multisite()
6310 + && ( ! function_exists( 'is_subdomain_install' ) || ! is_subdomain_install() );
6311 +
6312 + if ( ! $needed ) {
6313 + if ( file_exists( $file ) ) {
6314 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
6315 + @unlink( $file );
6316 + }
6317 + return;
6318 + }
6319 +
6320 + if ( ! function_exists( 'get_sites' ) ) {
6321 + return;
6322 + }
6323 +
6324 + $paths = array();
6325 + foreach ( get_sites( array( 'number' => 0 ) ) as $site ) {
6326 + $prefix = self::path_prefix_segment( (string) $site->path );
6327 + if ( '' !== $prefix ) {
6328 + // Store the raw path so the drop-in can prefix-match a URI,
6329 + // alongside the segment it maps to.
6330 + $paths[ trim( (string) $site->path, '/' ) ] = $prefix;
6331 + }
6332 + }
6333 +
6334 + if ( empty( $paths ) ) {
6335 + if ( file_exists( $file ) ) {
6336 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- see above.
6337 + @unlink( $file );
6338 + }
6339 + return;
6340 + }
6341 +
6342 + // Longest path first so /a/b wins over /a.
6343 + uksort(
6344 + $paths,
6345 + static function ( $x, $y ) {
6346 + return strlen( (string) $y ) <=> strlen( (string) $x );
6347 + }
6348 + );
6349 +
6350 + $lines = array();
6351 + foreach ( $paths as $raw => $segment ) {
6352 + $lines[] = $raw . '|' . $segment;
6353 + }
6354 +
6355 + if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
6356 + return;
6357 + }
6358 + // 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.
6359 + file_put_contents( $file, implode( "\n", $lines ), LOCK_EX );
6360 + }
6361 +
6362 + /**
6363 + * Compile `ignored_query_params` into a regex the DROP-IN can use.
6364 + *
6365 + * Tracking traffic was cached but never served fast. should_cache()
6366 + * learned to allow `?utm_source=…` through and cache_key() strips the
6367 + * query, so `/post` and `/post?utm_source=x` share one entry — but the
6368 + * drop-in still bailed on ANY query string, so every visitor from an
6369 + * email or ad campaign paid a full WordPress boot to be handed a file
6370 + * that was already on disk. On a marketing site that is most of the
6371 + * paid traffic taking the slowest path. (#13)
6372 + *
6373 + * The drop-in runs before WordPress, so it cannot read the option or
6374 + * call Glob_Matcher. It gets a precompiled alternation instead, written
6375 + * next to the cache files exactly as sync_mobile_flag() writes the
6376 + * device flag. Regenerated whenever cache settings are saved.
6377 + *
6378 + * Only the KEYS matter: a param whose name is on the list contributes
6379 + * nothing to the response, so the entry keyed without it is correct.
6380 + * Anything not on the list means the drop-in must stand down and let
6381 + * PHP decide — the file is deleted rather than left stale when the
6382 + * list is empty, so a missing sidecar always fails safe.
6383 + */
6384 + public static function sync_query_allowlist(): void {
6385 + $file = XSPEED_CACHE_DIR . '/.ignored-query-params';
6386 +
6387 + /*
6388 + * Stored read, not Settings_Manager::get() — this runs from boot(),
6389 + * before translation is legal (see stored_cache_opts()).
6390 + *
6391 + * A raw read applies no schema defaults, and this field's default is a
6392 + * long tracking-parameter list, NOT empty. Falling back to array()
6393 + * would strip that whole allow-list from the drop-in on any install
6394 + * that has never saved the Cache panel. So fall back to the schema's
6395 + * own default, read from the module without building its labels.
6396 + */
6397 + $opts = self::stored_cache_opts();
6398 + $ignored = is_array( $opts['ignored_query_params'] ?? null )
6399 + ? $opts['ignored_query_params']
6400 + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_IGNORED_QUERY_PARAMS;
6401 +
6402 + $parts = array();
6403 + foreach ( $ignored as $pattern ) {
6404 + $pattern = trim( (string) $pattern );
6405 + if ( '' === $pattern ) {
6406 + continue;
6407 + }
6408 + if ( '~' === $pattern[0] ) {
6409 + // Raw regex, PHP-side dialect. Keep it — unlike a server
6410 + // config, the drop-in runs the same PCRE engine, so the
6411 + // pattern behaves identically. Anchored below with the rest.
6412 + $body = substr( $pattern, 1 );
6413 + if ( '' !== $body && false !== @preg_match( '#^(?:' . $body . ')$#', '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed user pattern must be dropped, not fatal.
6414 + $parts[] = $body;
6415 + }
6416 + continue;
6417 + }
6418 + // Glob semantics, same as Glob_Matcher: * is any run, ? is one.
6419 + $esc = preg_quote( $pattern, '#' );
6420 + $esc = str_replace( array( '\*', '\?' ), array( '.*', '.' ), $esc );
6421 + $parts[] = $esc;
6422 + }
6423 +
6424 + if ( empty( $parts ) ) {
6425 + if ( file_exists( $file ) ) {
6426 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
6427 + @unlink( $file );
6428 + }
6429 + return;
6430 + }
6431 +
6432 + if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
6433 + return;
6434 + }
6435 +
6436 + $payload = '(?:' . implode( '|', array_unique( $parts ) ) . ')';
6437 +
6438 + // Only write when the value actually changed. This runs from
6439 + // reconcile_mobile_separate() on CacheModule::boot(), so an
6440 + // unconditional write cost a file write and an exclusive lock on every
6441 + // request that boots WordPress — every MISS, every BYPASS, every admin
6442 + // screen, every REST call. sync_mobile_flag() below is the model: it
6443 + // touches the marker only when the setting flips.
6444 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- our own sidecar; an unreadable file falls through to the write below.
6445 + if ( is_readable( $file ) && (string) @file_get_contents( $file ) === $payload ) {
6446 + return;
6447 + }
6448 +
6449 + // 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.
6450 + file_put_contents( $file, $payload, LOCK_EX );
6451 + }
6452 +
6453 + /**
6454 + * CacheModule's STORED settings, read straight from the option.
6455 + *
6456 + * `Settings_Manager::get( 'cache' )` builds CacheModule's settings schema,
6457 + * whose labels are declared through `__()`. The reconcile chain below runs
6458 + * from `CacheModule::boot()` on `plugins_loaded` — before
6459 + * `after_setup_theme`, the point WordPress 6.7+ treats as safe to
6460 + * translate — so going through the schema there fires
6461 + * `_load_textdomain_just_in_time` on every request AND resolves the labels
6462 + * against a domain that is not loaded yet.
6463 + *
6464 + * The callers here need stored values, not schema metadata, so a raw read
6465 + * is equivalent. It applies NO defaults or coercion: read each key with a
6466 + * fallback matching the schema's own default.
6467 + *
6468 + * @return array<string,mixed>
6469 + */
6470 + private static function stored_cache_opts(): array {
6471 + $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() );
6472 + return is_array( $stored ) ? $stored : array();
6473 + }
6474 +
1495 6475 public static function sync_mobile_flag( $enabled = null ): void {
1496 6476 if ( null === $enabled ) {
1497 - $opts = Settings_Manager::get( 'cache' );
1498 - $enabled = ! empty( $opts['mobile_separate'] );
6477 + $stored = self::stored_cache_opts();
6478 + $enabled = ! empty( $stored['mobile_separate'] );
1499 6479 }
1500 6480 $dir = XSPEED_CACHE_DIR;
1501 6481 $flag = $dir . '/.mobile-separate';
1502 6482 if ( $enabled ) {
@@ -1562,8 +6542,16 @@
1562 6542 * reconcile, and toggle() handles install/teardown itself.
1563 6543 */
1564 6544 public static function reconcile_mobile_separate(): void {
1565 6545 self::sync_mobile_flag();
6546 + if ( defined( 'XSPEED_CACHE_DIR' ) ) {
6547 + // Keep the drop-in's view of the network's blog paths current — a
6548 + // site added or removed changes which bucket its URLs belong to. (#6)
6549 + self::sync_site_paths();
6550 + // Keep the drop-in's copy of the query allow-list current — a param
6551 + // added in settings must reach the fast path too. (#13)
6552 + self::sync_query_allowlist();
6553 + }
1566 6554
1567 6555 // The rewrite/static reconciliation below needs the plugin's path
1568 6556 // constants. They're absent in early-boot / unit-test contexts where
1569 6557 // only the drop-in flag matters — bail to the flag-only behavior then.
@@ -1579,8 +6567,31 @@
1579 6567
1580 6568 $rewrite_present = self::rewrite_installed();
1581 6569 $rewrite_wanted = self::static_rewrite_allowed();
1582 6570
6571 + // Did the thing that actually invalidates cache KEYS change?
6572 + // mobile_separate buckets entries as |d / |m, so flipping it makes
6573 + // stored entries mis-bucketed and they must go. A rewrite-state
6574 + // mismatch from anything else (e.g. mod_headers detection, a hand-
6575 + // edited .htaccess) changes no key at all — the same files are still
6576 + // valid, they're just served by PHP instead of by the web server.
6577 + // Purging there is what let one WP-CLI call wipe the whole cache on
6578 + // every bootstrap. (#138)
6579 + //
6580 + // Read the setting from the SAME place static_rewrite_allowed() and
6581 + // sync_mobile_flag() do — the cache module's settings, not the
6582 + // top-level xspeed_options — or this marker would track a key that
6583 + // never changes and a real flip would go unnoticed.
6584 + // Stored read — this runs from boot(); see stored_cache_opts().
6585 + $cache_opts = self::stored_cache_opts();
6586 + $mobile_now = ! empty( $cache_opts['mobile_separate'] );
6587 + $mobile_last = get_option( 'xspeed_last_mobile_separate', null );
6588 + $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now );
6589 +
6590 + if ( (string) (int) $mobile_now !== (string) $mobile_last ) {
6591 + update_option( 'xspeed_last_mobile_separate', $mobile_now ? '1' : '0', false );
6592 + }
6593 +
1583 6594 if ( $rewrite_present === $rewrite_wanted ) {
1584 6595 // Already consistent — nothing flipped, leave caches intact so a
1585 6596 // plain settings save (e.g. expiry change) doesn't blow the cache.
1586 6597 return;
@@ -1585,17 +6596,19 @@
1585 6596 // plain settings save (e.g. expiry change) doesn't blow the cache.
1586 6597 return;
1587 6598 }
1588 6599
1589 - // The setting flipped. Bring the rewrite into line and purge the
1590 - // now-misbucketed cache so the next request re-primes under the new
1591 - // device scheme.
6600 + // Bring the rewrite into line with what this server actually supports.
1592 6601 if ( $rewrite_wanted ) {
1593 6602 self::install_rewrite();
1594 6603 } else {
1595 6604 self::remove_rewrite();
1596 6605 }
1597 - self::purge_all( 'mobile_separate changed' );
6606 +
6607 + // Only discard cache contents when the device bucketing changed.
6608 + if ( $mobile_flipped ) {
6609 + self::purge_all( 'mobile_separate changed' );
6610 + }
1598 6611 }
1599 6612
1600 6613 /**
1601 6614 * Whether the server-level static-rewrite fast path may be used.
@@ -1651,9 +6664,10 @@
1651 6664 // pinned at 0% on a working Apache cache.)
1652 6665 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
1653 6666 return false;
1654 6667 }
1655 - $opts = Settings_Manager::get( 'cache' );
6668 + // Stored read — reached from boot(); see stored_cache_opts().
6669 + $opts = self::stored_cache_opts();
1656 6670 return empty( $opts['mobile_separate'] );
1657 6671 }
1658 6672
1659 6673 /**
@@ -1671,9 +6685,127 @@
1671 6685 * suppresses the reinstall, and auto_heal() strips the block again on
1672 6686 * the next admin page load. (Field report: Apache host with mod_headers
1673 6687 * unloaded sat on the slow path with no way to find out why.)
1674 6688 */
6689 + /**
6690 + * Qualify a raw probe result with what we already KNOW about config.
6691 + *
6692 + * probe_static_rewrite() writes its own file under the static-cache tree
6693 + * and fetches that, which succeeds whenever the web server can serve a
6694 + * static file at all — including when static_rewrite_allowed() is false
6695 + * and no real page is on the static path. So `active: true` on its own is
6696 + * not evidence that pages are being served statically.
6697 + *
6698 + * The reachable case is nginx with Separate Mobile Cache on: the snippet
6699 + * lives in the server block and we cannot remove it, pages are
6700 + * deliberately routed to the PHP drop-in, but the probe file is still
6701 + * served directly.
6702 + *
6703 + * The Health panel learned this in 88b4b50; the CLI, REST and MCP paths
6704 + * did not, so they kept reporting "active" in exactly that configuration.
6705 + * Rather than repeat the reasoning at each call site, they now all come
6706 + * through here.
6707 + *
6708 + * Deliberately does NOT consult rewrite_installed(): on nginx the fast
6709 + * path is the pasted snippet and there is no .htaccess marker to find, so
6710 + * requiring one would report every correctly-configured nginx site as
6711 + * broken.
6712 + *
6713 + * @param array $probe Raw result from probe_static_rewrite().
6714 + * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string}
6715 + */
6716 + public static function qualify_rewrite_probe( array $probe ): array {
6717 + $active = (bool) ( $probe['active'] ?? false );
6718 + $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
6719 + $reason = (string) ( $probe['reason'] ?? '' );
6720 + $block_reason = self::static_rewrite_block_reason();
6721 +
6722 + // Same observed-refusal check Health makes. This is the shared path for
6723 + // `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite —
6724 + // and, because a CLI command is automatically an MCP tool, for the
6725 + // AI-facing surface too. Leaving it out would have fixed the dashboard
6726 + // while the CLI kept answering that the fast path was active. (#372)
6727 + if ( '' === $block_reason ) {
6728 + $skip = self::last_static_skip();
6729 + if ( ! empty( $skip['reason'] ) ) {
6730 + $block_reason = 'skipped_' . (string) $skip['reason'];
6731 + }
6732 + }
6733 +
6734 + // With page caching off there is nothing to serve, so `active` can
6735 + // never be true here whatever the raw probe says. probe_static_rewrite()
6736 + // writes its OWN file under the static tree and fetches that, which
6737 + // succeeds whenever the server can serve a static file at all — and on
6738 + // nginx the snippet is server-level, so it keeps succeeding after the
6739 + // cache is switched off.
6740 + //
6741 + // block_reason() used to carry this meaning by accident: it returned
6742 + // 'mobile_separate' with caching off, and the refusal branch below
6743 + // forced active=false. Now that it correctly reports '' (nothing can
6744 + // block a fast path that isn't in use), this consumer has to state the
6745 + // condition itself — otherwise `wp xspeed cache recheck-rewrite` and
6746 + // POST /cache/recheck-rewrite claim "the web server is serving cache
6747 + // hits directly" on a site with no cache. That is a positive false
6748 + // claim rather than a nag, i.e. worse than the bug being fixed.
6749 + $cache_opts = Settings::get();
6750 + if ( empty( $cache_opts['cache_enabled'] ) ) {
6751 + return array(
6752 + 'active' => false,
6753 + 'inconclusive' => false,
6754 + 'reason' => 'Page caching is off, so there is no cache for the web server to serve.',
6755 + 'block_reason' => '',
6756 + );
6757 + }
6758 +
6759 + // A known refusal outranks the probe, and also outranks
6760 + // "inconclusive" — a blocked rewrite whose probe merely failed to
6761 + // complete is still definitely blocked.
6762 + if ( '' !== $block_reason ) {
6763 + $active = false;
6764 + $inconclusive = false;
6765 + $reason = self::block_reason_text( $block_reason );
6766 + }
6767 +
6768 + return array(
6769 + 'active' => $active,
6770 + 'inconclusive' => $inconclusive,
6771 + 'reason' => $reason,
6772 + 'block_reason' => $block_reason,
6773 + );
6774 + }
6775 +
6776 + /**
6777 + * Human-readable explanation for a static_rewrite_block_reason() code.
6778 + *
6779 + * Each one has to say what to DO about it: "mobile_separate" alone tells
6780 + * a user nothing, and the whole point of surfacing a refusal instead of
6781 + * the probe verdict is that it is actionable.
6782 + */
6783 + public static function block_reason_text( string $code ): string {
6784 + switch ( $code ) {
6785 + case 'mobile_separate':
6786 + 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.';
6787 + case 'no_mod_headers':
6788 + 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.";
6789 + case 'skipped_nonce':
6790 + 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.';
6791 + default:
6792 + return sprintf( 'The static rewrite is disabled (%s).', $code );
6793 + }
6794 + }
6795 +
1675 6796 public static function static_rewrite_block_reason(): string {
6797 + // Nothing can be blocking the fast path when there is no cache to
6798 + // serve from it. Without this the dashboard told users with page
6799 + // caching switched OFF that Separate Mobile Cache "is disabling
6800 + // faster static serving" — a fast path they were not using, about a
6801 + // cache that did not exist. Every caller of this is a user-facing
6802 + // explanation of why the rewrite is off, so "the cache is off" is
6803 + // the honest answer, and it is silence. (#108)
6804 + $opts = Settings::get();
6805 + if ( empty( $opts['cache_enabled'] ) ) {
6806 + return '';
6807 + }
1676 6808 if ( Server::LITESPEED === Server::type() ) {
1677 6809 return ''; // Intended on LiteSpeed — not a "block".
1678 6810 }
1679 6811 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
@@ -1678,10 +6810,10 @@
1678 6810 }
1679 6811 if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
1680 6812 return 'no_mod_headers';
1681 6813 }
1682 - $opts = Settings_Manager::get( 'cache' );
1683 - return ! empty( $opts['mobile_separate'] ) ? 'mobile_separate' : '';
6814 + $cache_opts = Settings_Manager::get( 'cache' );
6815 + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
1684 6816 }
1685 6817
1686 6818 /**
1687 6819 * Whether migration flagged Separate Mobile Cache for user review. Set by
@@ -1691,10 +6823,21 @@
1691 6823 * this flag so the dashboard can invite the user to turn it back on only
1692 6824 * if their site genuinely serves different HTML per device. (FBS-83145)
1693 6825 */
1694 6826 public static function mobile_separate_needs_review(): bool {
1695 - $opts = Settings_Manager::get( 'cache' );
1696 - return ! empty( $opts['mobile_separate_review'] );
6827 + // Same reasoning as static_rewrite_block_reason(): the invitation is
6828 + // "turn this back on if your site needs it, to regain the fast path",
6829 + // which is meaningless with page caching off — there is no fast path
6830 + // to regain, and the equality probe behind the prompt would fetch
6831 + // pages that aren't being cached. Gated here rather than at the two
6832 + // payload call sites (Admin + Rest_Api) so `enabled`, `blocking` and
6833 + // `needs_review` are consistently gated on the same condition. (#108)
6834 + $opts = Settings::get();
6835 + if ( empty( $opts['cache_enabled'] ) ) {
6836 + return false;
6837 + }
6838 + $cache_opts = Settings_Manager::get( 'cache' );
6839 + return ! empty( $cache_opts['mobile_separate_review'] );
1697 6840 }
1698 6841
1699 6842 /**
1700 6843 * Clear the review flag — called when the user has acted on the prompt
@@ -1795,18 +6938,111 @@
1795 6938 * handful of well-known noise sources and collapses whitespace, so a site
1796 6939 * that truly serves different markup per device still compares as different.
1797 6940 */
1798 6941 private static function normalize_html_for_diff( string $html ): string {
6942 + // Every rule here errs toward "they differ" being WRONG rather than
6943 + // "they match" being wrong: this check only ever tells a user it is
6944 + // SAFE to turn Separate Mobile Cache off, so a false "identical"
6945 + // would cost them device-specific output. The risk of being too
6946 + // conservative is milder but real — the useful answer never appears,
6947 + // and the feature's whole pitch ("we'll prove it's safe to turn
6948 + // off") silently never pays out. These close the gaps that made a
6949 + // mismatch effectively guaranteed on an ordinary WordPress site. (#108)
1799 6950 $patterns = array(
1800 - // WP nonces (data-nonce="...", _wpnonce=..., "nonce":"...").
1801 - '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-f0-9]{10}/i',
1802 - // Generic 10+ hex tokens (CSRF, cache-buster hashes, session ids).
1803 - '/\b[a-f0-9]{16,}\b/i',
6951 + // WP nonces in attribute or JSON form: data-nonce="…",
6952 + // _wpnonce=…, "nonce":"…". The `[:=]` adjacency below misses
6953 + // wp_nonce_field()'s own markup — `name="_wpnonce" value="ab…"`
6954 + // puts `value=` between the key and the token — which is the
6955 + // single most common nonce shape in WordPress, so that form is
6956 + // matched explicitly first.
6957 + '/name=["\']?(_wpnonce|_ajax_nonce)["\']?\s+value=["\']?[a-z0-9]{8,}/i',
6958 + // CSP nonces on script/style tags. Base64, so uppercase and
6959 + // +/= appear — the hex-only rules below can never match one,
6960 + // and a CSP-enabled site therefore differed on every fetch.
6961 + // MUST precede the generic nonce rule: that one stops at the
6962 + // first non-alphanumeric, leaving the rest of the token behind
6963 + // and the two responses still unequal.
6964 + // The quotes are optional so HTML5's legal unquoted attribute
6965 + // form (`<script nonce=AbCd+q/r=>`) is covered too — without
6966 + // that it fell through to the generic rule, which is the exact
6967 + // failure this rule exists to remove.
6968 + '/\bnonce=(["\'])?[A-Za-z0-9+\/=_-]{8,}(?(1)\1)/',
6969 + '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-z0-9]{8,}/i',
6970 + // Generic hex tokens: cache busters, session ids, md5/sha
6971 + // digests. Was 16+, which left an 11-15 char gap above the
6972 + // 10-char nonce rule.
6973 + //
6974 + // The token MUST contain at least one a-f letter. `[a-f0-9]`
6975 + // also matches every decimal digit, so a bare `{10,}` erased
6976 + // every 10+ digit INTEGER anywhere in the document — including
6977 + // visible body text. A page whose desktop and mobile HTML
6978 + // differed only by a per-device numeric id (an AdSense slot, an
6979 + // A/B bucket, an analytics property) then compared as identical,
6980 + // and the check told the user it was safe to switch off the very
6981 + // setting keeping that output correct — the one direction this
6982 + // function must never fail in. Decimal-only runs are left to the
6983 + // bounded epoch rule below, which is deliberately narrower.
6984 + //
6985 + // Known, accepted (QA R2): a token whose letters all fall in a-f
6986 + // reads as a digest, so a per-device `ABC1234567890` strips even
6987 + // though it is an id, not a hash. Deliberately left open — the
6988 + // alternatives all cost more than the bug:
6989 + //
6990 + // Token shape (lowercase-only, case-uniformity, a trailing
6991 + // letter) cannot separate it. `ABC1234567890` and
6992 + // `ABCDEF012345` — an uppercase digest this rule SHOULD strip —
6993 + // are both all-hex, uniformly cased, letters-then-digits.
6994 + // Each variant fixed the id only by sparing the digest.
6995 + //
6996 + // Letter density does separate them (23% letters vs 50%), but
6997 + // measured over 2000 md5/sha1/sha256 samples, requiring letters
6998 + // spread through the token leaves 21-67% of REAL digests
6999 + // unmatched depending on the window. Digest noise is most of
7000 + // what this function exists to remove, so that trade guts it.
7001 + //
7002 + // Context (protecting data-* attribute values from this rule)
7003 + // works for ids and still strips digests in URLs, classes and
7004 + // query strings — but regresses a CHANGING digest inside a
7005 + // non-nonce data-* attribute, and needs a two-pass
7006 + // hold/restore. Viable if R2 is ever worth pressing; its
7007 + // failure at least errs toward "differ".
7008 + //
7009 + // An A-F-only prefix on a per-device id is rare, and the earlier
7010 + // nonce rules already claim the data-nonce/_wpnonce shapes.
7011 + '/\b(?=[a-f0-9]{10,}\b)[0-9]*[a-f][a-f0-9]*\b/i',
1804 7012 // wp-generated unique ids (e.g. wp-block ids, aria ids).
1805 7013 '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i',
1806 7014 // ISO-ish timestamps + epoch-looking numbers in query strings.
1807 7015 '/\?ver=[0-9.]+/',
1808 7016 '/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/',
7017 + // Our own signature's generation stamp. The two fetches are
7018 + // sequential and each writes its own entry, so this differs on
7019 + // essentially every comparison — and it is space-separated, so
7020 + // the ISO rule above (which requires a literal `T`) never
7021 + // touches it. Without this the probe reports "differ" for every
7022 + // site and the "safe to turn Separate Mobile Cache off" verdict
7023 + // can never appear.
7024 + '/generated [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} UTC/',
7025 + // Raw epoch seconds. The two fetches are sequential, so any
7026 + // template printing time() guaranteed a mismatch.
7027 + //
7028 + // This is the ONLY rule that may strip a decimal-only run, so
7029 + // its bound is load-bearing rather than decorative — every digit
7030 + // it gives away is a class of per-device id it silently erases.
7031 + // `1[0-9]{9}` was too loose: it claimed the whole
7032 + // 1000000000-1999999999 range (2001-2033) to cover timestamps
7033 + // nobody serves, and took every 10-digit AdSense slot, order id
7034 + // and SKU beginning with 1 along with it — reproducing the exact
7035 + // false-"identical" verdict the hex rule above was tightened to
7036 + // stop. `1[6-9]` covers 2020-2033, which is the only span a live
7037 + // site can actually print, and collides with roughly a tenth as
7038 + // many ids.
7039 + //
7040 + // Not airtight — an id beginning 16-19 still collides. Closing
7041 + // that properly means scoping this to places a timestamp really
7042 + // appears (an attribute value, a query parameter, a JSON value)
7043 + // rather than bare body text; the bound is the cheap 90% of it.
7044 + '/\b1[6-9][0-9]{8}\b/',
1809 7045 );
1810 7046 $html = (string) preg_replace( $patterns, 'X', $html );
1811 7047 // Collapse all whitespace so trivial formatting differences don't count.
1812 7048 return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
@@ -1812,33 +7048,58 @@
1812 7048 return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
1813 7049 }
1814 7050
1815 7051 public static function ensure_hits_log_file(): bool {
1816 - // The HITs log exists ONLY so a server-level nginx `access_log`
1817 - // directive has a world-writable file to append to (see nginx_snippet()
1818 - // + Hit_Counter::collect_nginx_log_hits()). On Apache/LiteSpeed/managed
1819 - // hosts nothing writes it, so creating it — and, worse, chmod()-ing it
1820 - // world-writable — is pointless AND fails with "Operation not permitted"
1821 - // when PHP can't chmod files it doesn't own (a warning that surfaces in
1822 - // logs that capture @-suppressed errors). Skip the whole thing off nginx.
1823 - if ( Server::NGINX !== Server::type() ) {
1824 - return false;
1825 - }
7052 + // TWO writers append to this log, and an earlier fix conflated them:
7053 + //
7054 + // 1. nginx, via the server-level `access_log` directive in
7055 + // nginx_snippet() — a DIFFERENT uid, which is why the file needs
7056 + // to be world-writable there.
7057 + // 2. the PHP drop-in (advanced-cache.php), on EVERY server. A hit it
7058 + // serves bypasses WordPress entirely, so it can't call
7059 + // Hit_Counter::record_hit() — appending here is the only way that
7060 + // hit is ever counted.
7061 + //
7062 + // The nginx-only early return that used to sit at the top of this
7063 + // method was fixing something real: chmod() on a file PHP doesn't own
7064 + // raises "Operation not permitted", and off nginx that chmod buys
7065 + // nothing. But it took directory creation with it, so on LiteSpeed
7066 + // (which always serves via the drop-in), on Apache without mod_headers,
7067 + // and anywhere mobile_separate forces the drop-in path, writer 2 was
7068 + // appending to a file whose parent directory did not exist. The append
7069 + // is @-suppressed and documented as non-fatal, so every one of those
7070 + // hits vanished and the dashboard ratio sat at 0% forever.
7071 + //
7072 + // So: create the dir + file everywhere, and keep only the chmod gated
7073 + // to nginx.
1826 7074 $dir = self::hits_log_dir();
1827 7075 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
1828 7076 return false;
1829 7077 }
1830 - // Ensure the dir is traversable + writable by a different-uid nginx.
1831 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- nginx (a separate uid in multi-container setups) must be able to create/append the log; WP_Filesystem layers ownership overrides that defeat that intent.
1832 - @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
7078 +
7079 + $is_nginx = ( Server::NGINX === Server::type() );
7080 +
7081 + if ( $is_nginx ) {
7082 + // Ensure the dir is traversable + writable by a different-uid nginx.
7083 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- nginx (a separate uid in multi-container setups) must be able to create/append the log; WP_Filesystem layers ownership overrides that defeat that intent.
7084 + @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
7085 + }
7086 +
1833 7087 $path = self::hits_log_path();
1834 7088 if ( ! file_exists( $path ) ) {
1835 7089 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
1836 7090 @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
1837 7091 }
1838 - // World-writable so a different-uid nginx can append HIT lines.
1839 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
1840 - @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
7092 +
7093 + if ( $is_nginx ) {
7094 + // World-writable so a different-uid nginx can append HIT lines.
7095 + // Off nginx the drop-in appends as the same uid that owns the file,
7096 + // so this is unnecessary — and would emit the "Operation not
7097 + // permitted" warnings the old early return was added to silence.
7098 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
7099 + @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
7100 + }
7101 +
1841 7102 return file_exists( $path );
1842 7103 }
1843 7104
1844 7105 public static function nginx_snippet(): ?string {
@@ -1902,9 +7163,55 @@
1902 7163 $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host())
1903 7164 $lines[] = 'set $xspeed_no_cache "no-cache";';
1904 7165 $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
1905 7166 $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
1906 - $lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
7167 + // Cookie + user-agent exclusions, generated from the user's actual
7168 + // settings rather than a hardcoded list. Before this, the rule
7169 + // tested three fixed cookie names and no user agent at all, so
7170 + // every excluded_cookies / bypass_user_agents entry applied only
7171 + // while a page was cold — on a warm page nginx served the shared
7172 + // anonymous copy to carts, members and bypassed bots alike. The
7173 + // three historical names survive as a floor inside cookie_rule().
7174 + // `~*` is case-insensitive, matching PHP's stripos()/glob checks.
7175 + // Stored read — reached from boot(); see stored_cache_opts(). The
7176 + // fallbacks below mirror the schema's own defaults, which a raw read
7177 + // does not apply.
7178 + $cache_opts = self::stored_cache_opts();
7179 + $cookie_rule = Server_Rules::cookie_rule(
7180 + is_array( $cache_opts['excluded_cookies'] ?? null )
7181 + ? $cache_opts['excluded_cookies']
7182 + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXCLUDED_COOKIES
7183 + );
7184 + $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
7185 +
7186 + $ua_rule = Server_Rules::user_agent_rule(
7187 + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
7188 + );
7189 + // Emitted only when the list is non-empty — an empty alternation
7190 + // would compile to `(...)` matching every request and disable the
7191 + // fast path entirely.
7192 + if ( '' !== $ua_rule['regex'] ) {
7193 + $lines[] = 'if ($http_user_agent ~* "(' . $ua_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-ua"; }';
7194 + }
7195 +
7196 + // URL exclusions. Without this an excluded URL was only excluded
7197 + // while its page was cold: PHP won't write a static file for one, so
7198 + // there is usually nothing to serve — but a page cached BEFORE the
7199 + // rule was added still has its file on disk, and nginx serves it
7200 + // without ever asking PHP. The exclusion then does nothing until the
7201 + // next purge. (#169)
7202 + //
7203 + // Matched against $uri, not $request_uri: $uri is the decoded path
7204 + // without the query string, which is what Cache::should_cache()
7205 + // tests. Using $request_uri would make `/cart` fail to match
7206 + // `/cart?x=1` inconsistently with PHP. Same empty-regex guard as the
7207 + // UA rule above — an empty alternation matches everything.
7208 + $url_rule = Server_Rules::url_rule(
7209 + is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array()
7210 + );
7211 + if ( '' !== $url_rule['regex'] ) {
7212 + $lines[] = 'if ($uri ~* "(' . $url_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-url"; }';
7213 + }
1907 7214 $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
1908 7215 // Neither `add_header` nor `access_log` is allowed inside an `if{}`
1909 7216 // at server level (nginx rejects with "directive is not allowed
1910 7217 // here"). The logging therefore lives in a `location` block that
@@ -1934,8 +7241,24 @@
1934 7241 // missing. So: hits are logged, and a user deleting the log can't take
1935 7242 // nginx down.
1936 7243 $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
1937 7244 $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
7245 + // Edge/CDN headers from the same seam the drop-in bakes. nginx serves
7246 + // this path without ever starting PHP, so the answer cannot be
7247 + // resolved per request — the pairs are resolved HERE, when the
7248 + // snippet is generated, and a change of answer needs the snippet
7249 + // regenerated and re-pasted to take effect.
7250 + //
7251 + // Skipped entirely when the static path is switched off. The only
7252 + // reason that can fire under `bake` is mobile-split, and mobile-split
7253 + // is also what switches the static path off — so the block would be
7254 + // baked with a hold it can never serve, and would start serving it
7255 + // the moment the setting is turned off and static files reappear,
7256 + // until somebody regenerates and re-pastes. A rule that can only be
7257 + // served once its premise is false is guaranteed to be stale.
7258 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $name => $value ) {
7259 + $lines[] = ' add_header ' . $name . ' "' . self::quote_directive_value( $value ) . '" always;';
7260 + }
1938 7261 $lines[] = '}';
1939 7262 return implode( "\n", $lines );
1940 7263 }
1941 7264
@@ -2053,34 +7376,15 @@
2053 7376 if ( empty( $opts['cache_enabled'] ) ) {
2054 7377 return false;
2055 7378 }
2056 7379
2057 - $restored = false;
7380 + $state = self::toggle( true, false );
7381 + // A refusal reports whether the cache SERVES, which on this path can
7382 + // be true for reasons that have nothing to do with this call — so a
7383 + // refusal would otherwise log "drop-in restored" for a restore that
7384 + // was declined. Restored means the transaction went through.
7385 + $restored = empty( $state['blocked'] ) && ! empty( $state['enabled'] );
2058 7386
2059 - // Only (re)install when the drop-in is missing, foreign, or an
2060 - // older version of ours — never rewrite a current, healthy file.
2061 - $target = WP_CONTENT_DIR . '/advanced-cache.php';
2062 - $needs = true;
2063 - if ( file_exists( $target ) ) {
2064 - $contents = @file_get_contents( $target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; a failure just means we reinstall.
2065 - if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
2066 - $source = @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same.
2067 - $needs = self::dropin_version( $contents ) < self::dropin_version( is_string( $source ) ? $source : '' );
2068 - }
2069 - }
2070 - if ( $needs && self::install_dropin() ) {
2071 - $restored = true;
2072 - }
2073 -
2074 - // WP_CACHE lives in wp-config.php, which the upgrade doesn't touch —
2075 - // but a foreign cache plugin or a hand-edit can drop it, and without
2076 - // it core never loads the drop-in at all.
2077 - if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
2078 - if ( self::set_wp_cache_constant( true ) ) {
2079 - $restored = true;
2080 - }
2081 - }
2082 -
2083 7387 if ( $restored ) {
2084 7388 Activity_Log::record(
2085 7389 'cache_dropin_restored',
2086 7390 'Cache drop-in restored after a plugin update — caching was already enabled.',
@@ -2114,32 +7418,16 @@
2114 7418 if ( empty( $opts['cache_enabled'] ) ) {
2115 7419 return;
2116 7420 }
2117 7421
2118 - $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
2119 - $dropin_ours = false;
2120 - $dropin_stale = false;
2121 - if ( file_exists( $dropin_target ) ) {
2122 - $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2123 - $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
2124 - // Reinstall when OUR drop-in is an older version than the source —
2125 - // the marker alone can't distinguish an old copy from a new one, so
2126 - // a serve-logic change (e.g. the .meta read for 404s/feeds) would
2127 - // otherwise never reach existing cache-enabled sites until a manual
2128 - // cache toggle. (FBS-82406/82407)
2129 - if ( $dropin_ours ) {
2130 - $dropin_stale = self::dropin_version( (string) $contents ) < self::dropin_version( @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ) ?: '' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2131 - }
7422 + $state = self::toggle( true, false );
7423 + // A refusal means something else now owns the page-cache field, or
7424 + // the write could not be verified. Either way this is not the moment
7425 + // to go on maintaining our rewrite block and log file.
7426 + if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) {
7427 + return;
2132 7428 }
2133 7429
2134 - if ( ! $dropin_ours || $dropin_stale ) {
2135 - self::install_dropin();
2136 - }
2137 -
2138 - if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
2139 - self::set_wp_cache_constant( true );
2140 - }
2141 -
2142 7430 // Rewrite block goes last. It's what turns the static-cache
2143 7431 // tree into a PHP-bypass — every cache hit served by the web
2144 7432 // server directly. Without it we still cache, just at drop-in
2145 7433 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
@@ -2164,8 +7452,54 @@
2164 7452 self::ensure_hits_log_file();
2165 7453 }
2166 7454
2167 7455 /**
7456 + * Keep the generic bypass cookie in sync with PHP's caching verdict.
7457 + *
7458 + * The server config tests exactly one cookie name (Server_Rules::
7459 + * BYPASS_COOKIE) forever, and PHP decides what that name means. Adding
7460 + * a new excluded cookie therefore needs no config change and no nginx
7461 + * reload — the reason this exists.
7462 + *
7463 + * Session cookie (expiry 0) so it dies with the browser session, and
7464 + * deliberately NOT HttpOnly-sensitive: it carries no identity, only the
7465 + * boolean "don't serve this visitor a shared cached page".
7466 + *
7467 + * Honest limit: this can only ever help a visitor PHP has already seen
7468 + * once. A bot's first request to a warm page never reaches PHP, which
7469 + * is why user-agent rules are still written into the server config
7470 + * rather than relying on this.
7471 + *
7472 + * @param bool $bypass Whether this visitor must skip the cache.
7473 + */
7474 + private static function sync_bypass_cookie( bool $bypass ): void {
7475 + if ( headers_sent() ) {
7476 + return;
7477 + }
7478 +
7479 + $name = Server_Rules::BYPASS_COOKIE;
7480 + $has = isset( $_COOKIE[ $name ] );
7481 +
7482 + // Only touch the header when the state actually changes — a
7483 + // Set-Cookie on every request would make the response uncacheable
7484 + // for intermediary caches and add noise to every hit.
7485 + if ( $bypass === $has ) {
7486 + return;
7487 + }
7488 +
7489 + $path = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/';
7490 + $domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '';
7491 +
7492 + if ( $bypass ) {
7493 + setcookie( $name, '1', 0, $path, (string) $domain, is_ssl(), false );
7494 + $_COOKIE[ $name ] = '1';
7495 + } else {
7496 + setcookie( $name, '', time() - 3600, $path, (string) $domain, is_ssl(), false );
7497 + unset( $_COOKIE[ $name ] );
7498 + }
7499 + }
7500 +
7501 + /**
2168 7502 * Build the .htaccess rules that map cacheable requests to the
2169 7503 * static-cache tree. Conditions are deliberately strict: GET only,
2170 7504 * empty query string, no session/comment-author/post-password
2171 7505 * cookie, and the static file must exist on disk. Anything that
@@ -2182,14 +7516,46 @@
2182 7516 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
2183 7517 $rel = '/' . ltrim( $rel, '/' );
2184 7518 $rel = rtrim( $rel, '/' );
2185 7519
2186 - return array(
7520 + // Cookie + user-agent exclusions generated from the live settings.
7521 + // See the matching block in nginx_snippet() — same generator, same
7522 + // floor, so both servers enforce an identical policy. Apache reads
7523 + // .htaccess on every request and we already self-heal this file, so
7524 + // Apache/LiteSpeed users get the fix on upgrade with no action.
7525 + $cache_opts = Settings_Manager::get( 'cache' );
7526 + $cookie_rule = Server_Rules::cookie_rule(
7527 + is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
7528 + );
7529 + $ua_rule = Server_Rules::user_agent_rule(
7530 + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
7531 + );
7532 +
7533 + $lines = array(
2187 7534 '<IfModule mod_rewrite.c>',
2188 7535 ' RewriteEngine On',
2189 7536 ' RewriteCond %{REQUEST_METHOD} ^GET$',
2190 7537 ' RewriteCond %{QUERY_STRING} ^$',
2191 - ' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]',
7538 + ' RewriteCond %{HTTP_COOKIE} !(' . $cookie_rule['regex'] . ') [NC]',
7539 + );
7540 +
7541 + // Only emit the UA condition when there's something to match —
7542 + // `!()` would negate an always-true empty match and refuse every
7543 + // request, silently disabling the static path.
7544 + if ( '' !== $ua_rule['regex'] ) {
7545 + // Quoted, because RewriteCond is whitespace-delimited and real
7546 + // user-agent fragments contain spaces ("Mozilla/5.0 (compatible").
7547 + // Unquoted, a space adds an argument and Apache answers every
7548 + // request with a 500 — and because .htaccess is parsed per
7549 + // request, `httpd -t` still reports Syntax OK. Server_Rules has
7550 + // already excluded quotes and backslashes from the alternation,
7551 + // so the closing quote here cannot be escaped away.
7552 + $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
7553 + }
7554 +
7555 + $block = array_merge(
7556 + $lines,
7557 + array(
2192 7558 // Capture REQUEST_URI without its trailing slash into %1.
2193 7559 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
2194 7560 // so this normalization lets `/blog/` and `/blog` both hit
2195 7561 // the same cache file without producing the double-slash
@@ -2205,9 +7571,9 @@
2205 7571 // `^` matches the empty string AND any non-empty path, so it
2206 7572 // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
2207 7573 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
2208 7574 // directly from the static file.)
2209 - ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
7575 + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [E=XSPEED_STATIC_HIT:1,L]',
2210 7576 '</IfModule>',
2211 7577 // Mark the statically-served response as a cache HIT.
2212 7578 //
2213 7579 // A file served by the rewrite above bypasses PHP entirely, so
@@ -2229,10 +7595,32 @@
2229 7595 '<IfModule mod_headers.c>',
2230 7596 ' <FilesMatch "\\.html$">',
2231 7597 ' Header always set X-XSpeed-Cache "HIT (static)"',
2232 7598 ' </FilesMatch>',
2233 - '</IfModule>',
7599 + )
2234 7600 );
7601 +
7602 + // Edge/CDN headers from the same seam the drop-in bakes. Like the
7603 + // nginx snippet, the static rewrite answers without PHP, so the pairs
7604 + // are resolved when the block is GENERATED rather than per request.
7605 + //
7606 + // `env=` rather than the `<FilesMatch>` scoping above, because these
7607 + // must ride only on responses the rewrite produced. The marker header
7608 + // stays filename-scoped: it is inert, and narrowing it would change a
7609 + // header QA reads.
7610 + // Same reasoning as the nginx snippet: a bake hold can only come from
7611 + // mobile-split, and mobile-split is what turns this path off.
7612 + $edge_lines = array();
7613 + foreach ( self::static_rewrite_allowed() ? self::edge_headers_for( 'HIT', 'bake' ) : array() as $edge_name => $edge_value ) {
7614 + $edge_lines = array_merge(
7615 + $edge_lines,
7616 + self::static_hit_directives(
7617 + ' Header always set ' . $edge_name . ' "' . self::quote_directive_value( $edge_value ) . '"'
7618 + )
7619 + );
7620 + }
7621 +
7622 + return array_merge( $block, $edge_lines, array( '</IfModule>' ) );
2235 7623 }
2236 7624
2237 7625 /**
2238 7626 * Active probe that confirms the web-server static-rewrite path is
@@ -2464,12 +7852,49 @@
2464 7852 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
2465 7853 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
2466 7854 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
2467 7855
7856 + /*
7857 + * Nothing to change. auto_heal() runs the whole enable transaction on
7858 + * every admin_init and this is called unconditionally from it, so
7859 + * without this every wp-admin request truncated and rewrote .htaccess
7860 + * with byte-identical content. Apache reads that file without a lock,
7861 + * so the truncate window is a real 500 on a busy admin, and the churn
7862 + * trips host file-integrity monitors.
7863 + */
7864 + if ( $next === $existing ) {
7865 + return true;
7866 + }
7867 +
2468 7868 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- WP_Filesystem requires admin credentials we don't have here; toggle() runs in a REST request authorized by manage_options nonce. The target is the site's .htaccess (configuration file managed by WP core itself), not user data — wp_upload_dir() doesn't apply.
2469 7869 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
2470 7870 }
2471 7871
7872 + /**
7873 + * Rewrite the .htaccess block in place when — and only when — one is
7874 + * already installed.
7875 + *
7876 + * The block embeds the generated cookie / user-agent exclusion rules,
7877 + * so it goes stale the moment those settings change. install_rewrite()
7878 + * regenerates it from the live settings, but calling that unconditionally
7879 + * on every save would CREATE a block on sites that never enabled the
7880 + * static path — silently turning on server-level serving nobody asked
7881 + * for. So we refresh only what's already there.
7882 + *
7883 + * @return bool True when a block was present and rewritten.
7884 + */
7885 + public static function refresh_rewrite_if_installed(): bool {
7886 + $htaccess = ABSPATH . '.htaccess';
7887 + if ( ! file_exists( $htaccess ) ) {
7888 + return false;
7889 + }
7890 + $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; an unreadable file simply means nothing to refresh.
7891 + if ( ! is_string( $existing ) || false === strpos( $existing, '# BEGIN xSpeed Static Cache' ) ) {
7892 + return false;
7893 + }
7894 + return self::install_rewrite();
7895 + }
7896 +
2472 7897 public static function remove_rewrite(): bool {
2473 7898 $htaccess = ABSPATH . '.htaccess';
2474 7899 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
2475 7900 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
@@ -2490,9 +7915,21 @@
2490 7915 * follows it. Idempotent — returns the input unchanged if the
2491 7916 * marker isn't present.
2492 7917 */
2493 7918 private static function strip_marker_block( string $contents, string $marker ): string {
2494 - $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
7919 + /*
7920 + * The body may not contain another BEGIN for this marker.
7921 + *
7922 + * `.*?` is non-greedy but still spans anything, so an ORPHANED
7923 + * `# BEGIN xSpeed Static Cache` — an END line lost to a hand edit or
7924 + * a partial write — paired with the END of the NEXT block and deleted
7925 + * everything between them. On a site where the orphan sits above
7926 + * `# BEGIN WordPress`, that takes WordPress's own rewrite rules with
7927 + * it and every permalink 404s. Refusing to cross a second BEGIN makes
7928 + * the orphan a no-op instead of a site-wide outage.
7929 + */
7930 + $begin = '# BEGIN ' . preg_quote( $marker, '/' ) . '\b';
7931 + $pattern = '/' . $begin . '(?:(?!' . $begin . ').)*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
2495 7932 $out = preg_replace( $pattern, '', $contents );
2496 7933 return is_string( $out ) ? $out : $contents;
2497 7934 }
2498 7935
@@ -2516,8 +7953,417 @@
2516 7953 }
2517 7954 return 0;
2518 7955 }
2519 7956
7957 + /** The advanced-cache.php drop-in is ours. */
7958 + public const DROPIN_XSPEED = 'xspeed';
7959 + /** Someone else's drop-in is installed. */
7960 + public const DROPIN_FOREIGN = 'foreign';
7961 + /** No drop-in installed. */
7962 + public const DROPIN_NONE = 'none';
7963 + /** A drop-in is installed and we could not read it. */
7964 + public const DROPIN_UNREADABLE = 'unreadable';
7965 + /**
7966 + * Present but holding nothing -- empty, or whitespace only. WP Rocket
7967 + * truncates advanced-cache.php to 0 bytes on deactivate, and calling that
7968 + * FOREIGN made it a permanent blocker with no owner to ask. (#391)
7969 + */
7970 + public const DROPIN_ABANDONED = 'abandoned';
7971 +
7972 + /**
7973 + * Who owns wp-content/advanced-cache.php right now.
7974 + *
7975 + * WordPress gives every caching plugin the same single file to live in,
7976 + * so "is there a drop-in" and "is it ours" are completely different
7977 + * questions, and only the second one licenses a write. An unreadable
7978 + * drop-in is deliberately its own answer rather than folding into
7979 + * "foreign": we cannot even name what we would be destroying.
7980 + *
7981 + * @return string One of the DROPIN_* constants.
7982 + */
7983 + public static function dropin_owner(): string {
7984 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
7985 + $target = WP_CONTENT_DIR . '/advanced-cache.php';
7986 + if ( ! file_exists( $target ) ) {
7987 + return self::DROPIN_NONE;
7988 + }
7989 +
7990 + $contents = self::read_file( $target );
7991 + if ( null === $contents ) {
7992 + return self::DROPIN_UNREADABLE;
7993 + }
7994 +
7995 + if ( xspeed_has_canonical_dropin_signature( $contents ) ) {
7996 + return self::DROPIN_XSPEED;
7997 + }
7998 +
7999 + // Nothing in the file means nothing owns it. Kept distinct from
8000 + // FOREIGN so the acquisition gate can tell "someone else's cache" from
8001 + // "a husk the last plugin left behind". (#391)
8002 + if ( '' === trim( $contents ) ) {
8003 + return self::DROPIN_ABANDONED;
8004 + }
8005 +
8006 + /*
8007 + * The other half of the same question, and it cannot be answered from
8008 + * the bytes: a file we cannot attribute is a COMPETITOR only while
8009 + * some page cache is actually running. With every candidate switched
8010 + * off it is abandoned -- a hosting company's own cache, a hand-rolled
8011 + * one, or a plugin that was deleted without cleaning up.
8012 + *
8013 + * Asking the detector rather than re-deriving it here is the point:
8014 + * these two answers disagreeing is a split brain with a bad ending --
8015 + * acquisition_blocker() opens the gate, install_dropin() then refuses
8016 + * on FOREIGN, and toggle() blames the filesystem for a write it never
8017 + * attempted. One question, one answer. (#391, #393)
8018 + */
8019 + if ( class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) {
8020 + $owner = (string) ( Page_Cache_Detector::inspect()['dropin']['owner'] ?? '' );
8021 +
8022 + // Attributable to a named plugin -> somebody's cache, whatever its
8023 + // activation state. Only a file NOBODY can be shown to own, with
8024 + // nothing running, is abandoned.
8025 + if ( Page_Cache_Detector::OWNER_UNKNOWN === $owner
8026 + && ! Page_Cache_Detector::another_page_cache_is_active() ) {
8027 + return self::DROPIN_ABANDONED;
8028 + }
8029 + }
8030 +
8031 + return self::DROPIN_FOREIGN;
8032 + }
8033 +
8034 + /**
8035 + * Why xSpeed must not install its page-cache artifacts right now, or null
8036 + * when it may.
8037 + *
8038 + * This is the single gate in front of every write that touches shared
8039 + * state — the drop-in and the WP_CACHE define. Both are single-occupancy:
8040 + * whatever is there belongs to exactly one plugin, and taking it silently
8041 + * breaks that plugin's caching with no way back.
8042 + *
8043 + * Returns a user-facing string, so a REST caller can hand it straight to
8044 + * the dashboard instead of reporting a bare failure.
8045 + */
8046 + public static function acquisition_blocker(): ?string {
8047 + Page_Cache_Detector::invalidate();
8048 + $verdict = Page_Cache_Detector::classify();
8049 + $owner = self::dropin_owner();
8050 + // The reason we refuse, whether that reason already names a plugin,
8051 + // and every other page cache the detector counted anywhere in the
8052 + // verdict. See the tail of this method for why all three are needed.
8053 + $primary = null;
8054 + $primary_names = false;
8055 + $named = array();
8056 + foreach ( $verdict['blockers'] as $blocker ) {
8057 + $code = (string) ( $blocker['code'] ?? '' );
8058 + // The shared detector quite correctly reports xSpeed itself as a
8059 + // page-cache owner. That is not a competitor to this transaction.
8060 + //
8061 + // Except when the two disagree about the DROP-IN. The detector
8062 + // accepts our marker anywhere in a file's header; this plugin's
8063 + // own check requires it to open the header, because only this
8064 + // side authorizes overwriting and deleting. A foreign drop-in
8065 + // that merely carries our marker further down its header is
8066 + // attributed to us by the detector, and skipping it here dropped
8067 + // the refusal entirely — the write then failed on the stricter
8068 + // check and the user was told to go and fix file permissions.
8069 + // Where they disagree, believe the stricter one.
8070 + if ( self::PLUGIN_FILE === ( $blocker['plugin'] ?? null ) ) {
8071 + $about_dropin = in_array(
8072 + $code,
8073 + array(
8074 + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
8075 + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
8076 + ),
8077 + true
8078 + );
8079 + if ( ! $about_dropin || self::DROPIN_XSPEED === $owner ) {
8080 + continue;
8081 + }
8082 + }
8083 + if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) {
8084 + continue;
8085 + }
8086 + /*
8087 + * Another plugin's drop-in is no longer a refusal.
8088 + *
8089 + * It used to be: whoever held advanced-cache.php kept it, and
8090 + * enabling was blocked with "deactivate its page cache first".
8091 + * That left a user who had asked for our cache with no way to get
8092 + * it — on a live site the only exit was deleting a file over SSH,
8093 + * and the message could not even say which of its two causes
8094 + * applied ("is active OR owns advanced-cache.php").
8095 + *
8096 + * Turning the page cache on is the instruction to serve pages
8097 + * from cache, and that is not possible without this file. So we
8098 + * take it, and the dashboard says whose file it is first —
8099 + * dropin_disclosure() names the owner, the user confirms, and
8100 + * install_dropin() writes ours over the top.
8101 + *
8102 + * A still-active competitor is deliberately NOT re-added as a
8103 + * blocker below: it is caught by `active_page_cache`, which the
8104 + * capability rule already downgrades to a note. Two page caches
8105 + * installed at once is the user's call to make, not ours to
8106 + * refuse — they just told us which one they want serving.
8107 + *
8108 + * UNREADABLE is the exception and stays a refusal: we cannot name
8109 + * what we would destroy, and install_dropin() refuses it too, so
8110 + * opening the gate here would only produce a failed write.
8111 + */
8112 + $about_dropin_owner = in_array(
8113 + $code,
8114 + array(
8115 + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
8116 + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
8117 + ),
8118 + true
8119 + );
8120 + if ( $about_dropin_owner && self::DROPIN_UNREADABLE !== $owner ) {
8121 + continue;
8122 + }
8123 + /*
8124 + * Capability is not possession. `active_page_cache` and
8125 + * `multiple_page_caches` both fire on a plugin that merely CAN
8126 + * cache pages — the detector cannot prove a competitor's page
8127 + * cache is off, so it counts it. As a warning that is right. As
8128 + * a gate it refuses a write that takes nothing from anyone.
8129 + *
8130 + * This gate guards exactly two files: advanced-cache.php and the
8131 + * WP_CACHE define that loads it. A plugin that does not hold the
8132 + * drop-in has nothing here for us to overwrite, and one that does
8133 + * is already refused by `foreign_dropin` / `unknown_dropin` a few
8134 + * lines up. So when the field is ours or empty, an active
8135 + * competitor is a note, not a refusal.
8136 + *
8137 + * QA found this on a live OpenLiteSpeed site keeping LiteSpeed
8138 + * Cache for images and CDN with its page cache off, while xSpeed
8139 + * served the pages. One click of the off switch and it could not
8140 + * be turned back on: the only way out was deactivating LiteSpeed
8141 + * entirely, and the message told them to "deactivate its page
8142 + * cache" — which they already had.
8143 + */
8144 + $about_capability = in_array(
8145 + $code,
8146 + array(
8147 + Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE,
8148 + Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES,
8149 + ),
8150 + true
8151 + );
8152 + /*
8153 + * FOREIGN belongs in this list now, and it is the whole point.
8154 + *
8155 + * The rule is still "capability is not possession": these two
8156 + * blockers fire on any plugin that CAN cache pages, which the
8157 + * detector cannot prove is switched off. What changed is that a
8158 + * competitor holding the drop-in no longer stops us either — we
8159 + * take the file, having said whose it is. So there is nothing
8160 + * left for a merely-installed competitor to protect, and keeping
8161 + * the refusal here would put back the dead end by another route:
8162 + * "another page cache is active" on a site where the user has
8163 + * just told us, by name, which cache they want serving.
8164 + *
8165 + * UNREADABLE is deliberately still absent — that one refuses.
8166 + */
8167 + if ( $about_capability
8168 + && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE, self::DROPIN_FOREIGN, self::DROPIN_ABANDONED ), true ) ) {
8169 + continue;
8170 + }
8171 + if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) {
8172 + $others = self::other_page_cache_names( $blocker );
8173 + if ( array() === $others ) {
8174 + // We were the only owner counted — nothing to refuse —
8175 + // unless the list is missing entirely, which is an older
8176 + // detector copy we still must not talk past.
8177 + if ( null === $primary && array() === (array) ( $blocker['plugins'] ?? array() ) ) {
8178 + $primary = self::ownership_blocker_message( '', '' );
8179 + }
8180 + continue;
8181 + }
8182 + $named = array_values( array_unique( array_merge( $named, $others ) ) );
8183 + if ( null === $primary ) {
8184 + $primary = self::multiple_page_caches_message( $others );
8185 + $primary_names = true;
8186 + }
8187 + continue;
8188 + }
8189 + if ( null === $primary ) {
8190 + $label = (string) ( $blocker['label'] ?? '' );
8191 + $primary = self::ownership_blocker_message( $code, $label );
8192 + $primary_names = '' !== $label;
8193 + }
8194 + }
8195 +
8196 + if ( null === $primary ) {
8197 + return null;
8198 + }
8199 + /*
8200 + * The first blocker decides WHY we refuse; it does not always know
8201 + * WHO. The detector can only attribute a drop-in it recognises, and
8202 + * an unrecognised one produces "its owner cannot be proved" — the
8203 + * sentence a W3 Total Cache site used to get while a later blocker in
8204 + * the same verdict was holding the name "W3 Total Cache".
8205 + *
8206 + * So keep the reason and add the names, rather than swapping one for
8207 + * the other: the plugin the user must deal with is not necessarily
8208 + * the owner of the file we could not identify, and promoting the
8209 + * named blocker would have told them to deactivate a plugin that is
8210 + * not what is in their way.
8211 + */
8212 + if ( $primary_names || array() === $named ) {
8213 + return $primary;
8214 + }
8215 + if ( 1 === count( $named ) ) {
8216 + return sprintf(
8217 + /* translators: 1: the refusal reason, 2: a page-caching plugin's name. */
8218 + __( '%1$s %2$s is also active on this site — deactivate its page cache before enabling xSpeed.', 'xspeed' ),
8219 + $primary,
8220 + $named[0]
8221 + );
8222 + }
8223 + return sprintf(
8224 + /* translators: 1: the refusal reason, 2: comma-separated page-caching plugin names. */
8225 + __( '%1$s These page caches are also active on this site: %2$s. Deactivate them before enabling xSpeed.', 'xspeed' ),
8226 + $primary,
8227 + implode( ', ', $named )
8228 + );
8229 + }
8230 +
8231 + /** How xSpeed's own plugin file appears in the detector's catalog. */
8232 + private const PLUGIN_FILE = 'xspeed/xspeed.php';
8233 +
8234 + /**
8235 + * Name the OTHER page caches behind a `multiple_page_caches` refusal.
8236 + *
8237 + * This blocker has no single owner, so the detector leaves `plugin` and
8238 + * `label` null and hands over the full list instead. Left unhandled it
8239 + * fell through to the anonymous fallback sentence — and it is the blocker
8240 + * an ordinary site hits most: xSpeed counts toward "multiple", so the
8241 + * count reaches two the moment one other page-cache plugin is activated,
8242 + * even one that has not written a drop-in. A site running our cache that
8243 + * activated LiteSpeed could not re-enable it and was told only that "the
8244 + * page-cache field is occupied".
8245 + *
8246 + * Returns an empty list when xSpeed was the only owner counted, or when
8247 + * an older detector copy sent no list at all — the caller distinguishes
8248 + * the two by looking at `plugins`.
8249 + *
8250 + * @param array<string,mixed> $blocker One entry from Detector::classify().
8251 + * @return string[]
8252 + */
8253 + private static function other_page_cache_names( array $blocker ): array {
8254 + $plugins = array_values( (array) ( $blocker['plugins'] ?? array() ) );
8255 + $labels = array_values( (array) ( $blocker['labels'] ?? array() ) );
8256 +
8257 + $others = array();
8258 + foreach ( $plugins as $i => $plugin ) {
8259 + if ( self::PLUGIN_FILE === $plugin ) {
8260 + continue;
8261 + }
8262 + $others[] = isset( $labels[ $i ] ) && '' !== (string) $labels[ $i ]
8263 + ? (string) $labels[ $i ]
8264 + : (string) $plugin;
8265 + }
8266 + return array_values( array_unique( $others ) );
8267 + }
8268 +
8269 + /**
8270 + * The refusal sentence for a `multiple_page_caches` blocker.
8271 + *
8272 + * @param string[] $others Page caches other than xSpeed. Never empty.
8273 + */
8274 + private static function multiple_page_caches_message( array $others ): string {
8275 + if ( 1 === count( $others ) ) {
8276 + return self::ownership_blocker_message( Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE, $others[0] );
8277 + }
8278 + return sprintf(
8279 + /* translators: %s: comma-separated list of page-caching plugin names. */
8280 + __( 'More than one page cache is active on this site (%s). Turn off the other page caches before enabling xSpeed.', 'xspeed' ),
8281 + implode( ', ', $others )
8282 + );
8283 + }
8284 +
8285 + private static function ownership_blocker_message( string $code, string $label ): string {
8286 + if ( '' !== $label ) {
8287 + return sprintf( __( '%s is active or owns advanced-cache.php. Deactivate its page cache before enabling xSpeed.', 'xspeed' ), $label );
8288 + }
8289 + $messages = array(
8290 + 'wp_cache_orphaned' => __( 'WP_CACHE is true but no page-cache drop-in owner can be proved. xSpeed will not claim it.', 'xspeed' ),
8291 + 'wp_cache_duplicate' => __( 'wp-config.php defines WP_CACHE more than once. Remove the duplicate before enabling the cache.', 'xspeed' ),
8292 + 'wp_cache_dynamic' => __( 'WP_CACHE is set from an expression in wp-config.php. xSpeed will not rewrite it.', 'xspeed' ),
8293 + 'wp_cache_conditional' => __( 'WP_CACHE is defined inside a conditional in wp-config.php, so xSpeed cannot tell what it will be. Move it to a plain define before enabling the cache.', 'xspeed' ),
8294 + 'wp_config_unreadable' => __( 'wp-config.php cannot be read, so xSpeed cannot safely change page-cache ownership.', 'xspeed' ),
8295 + 'unknown_dropin' => __( 'advanced-cache.php is occupied but its owner cannot be proved. xSpeed will not replace it.', 'xspeed' ),
8296 + 'unreadable_dropin' => __( 'advanced-cache.php cannot be read, so xSpeed cannot prove its owner.', 'xspeed' ),
8297 + );
8298 + return $messages[ $code ] ?? __( 'The page-cache field is occupied or cannot be verified. xSpeed will not change it.', 'xspeed' );
8299 + }
8300 +
8301 + /**
8302 + * How WP_CACHE is written in wp-config.php, as opposed to what it
8303 + * evaluates to at runtime.
8304 + *
8305 + * The literal is what matters to a writer: a value behind an expression,
8306 + * or two competing defines, cannot be rewritten by a regex without
8307 + * guessing — and a wrong guess silently disables page caching (ours or
8308 + * someone else's) with no error anywhere.
8309 + *
8310 + * @return string undefined | true | false | duplicate | dynamic | conditional | unreadable
8311 + */
8312 + public static function wp_cache_define_state(): string {
8313 + $path = self::wp_config_path();
8314 + if ( '' === $path ) {
8315 + return 'unreadable';
8316 + }
8317 +
8318 + $config = self::read_file( $path );
8319 + if ( null === $config ) {
8320 + return 'unreadable';
8321 + }
8322 +
8323 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
8324 + $parsed = \xspeed_parse_wp_cache_defines( $config );
8325 + return $parsed['state'];
8326 + }
8327 +
8328 + /**
8329 + * Classify the captured right-hand side of a WP_CACHE define.
8330 + *
8331 + * Hosts and older tutorials write the value several ways —
8332 + * `1`, `'1'`, `TRUE` — and all of them are literals a rewrite can safely
8333 + * replace. Only a value we cannot evaluate by looking at it (a variable, a
8334 + * function call, a ternary) counts as dynamic, because that is the case
8335 + * where rewriting means guessing.
8336 + *
8337 + * @return string true | false | dynamic
8338 + */
8339 + private static function classify_wp_cache_literal( string $raw ): string {
8340 + $literal = strtolower( trim( $raw ) );
8341 + $literal = trim( $literal, "'\"" );
8342 +
8343 + if ( in_array( $literal, array( 'true', '1' ), true ) ) {
8344 + return 'true';
8345 + }
8346 + if ( in_array( $literal, array( 'false', '0', '', 'null' ), true ) ) {
8347 + return 'false';
8348 + }
8349 + return 'dynamic';
8350 + }
8351 +
8352 + /**
8353 + * Read a file for an ownership decision. Null on any failure — callers
8354 + * treat null as "unknown", never as "empty", because an empty string
8355 + * would read as "no marker found" and license an overwrite.
8356 + */
8357 + private static function read_file( string $path ): ?string {
8358 + if ( ! is_readable( $path ) ) {
8359 + return null;
8360 + }
8361 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Ownership check on a local file; WP_Filesystem would need credentials we must not prompt for here.
8362 + $contents = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed read is a valid answer ("unknown"), not an error to surface.
8363 + return is_string( $contents ) ? $contents : null;
8364 + }
8365 +
2520 8366 public static function install_dropin() {
2521 8367 $source = XSPEED_DIR . 'includes/advanced-cache.php';
2522 8368 $target = WP_CONTENT_DIR . '/advanced-cache.php';
2523 8369 if ( ! file_exists( $source ) ) {
@@ -2523,8 +8369,26 @@
2523 8369 if ( ! file_exists( $source ) ) {
2524 8370 return false;
2525 8371 }
2526 8372
8373 + /*
8374 + * A drop-in we cannot READ is the one thing still refused here. Not
8375 + * because of who owns it — we no longer refuse on ownership — but
8376 + * because an unreadable file is usually a permissions problem, and
8377 + * writing over it would fail anyway or destroy something we were
8378 + * never able to look at.
8379 + *
8380 + * Everything else is ours to take. Enabling the page cache IS the
8381 + * user's instruction to serve the cache, and serving it means holding
8382 + * advanced-cache.php; the dashboard says whose file it is replacing
8383 + * before the click (Page_Cache_Detector::dropin_disclosure()), so the
8384 + * takeover is consented rather than silent.
8385 + */
8386 + $owner = self::dropin_owner();
8387 + if ( self::DROPIN_UNREADABLE === $owner ) {
8388 + return false;
8389 + }
8390 +
2527 8391 global $wp_filesystem;
2528 8392 if ( ! function_exists( 'WP_Filesystem' ) ) {
2529 8393 require_once ABSPATH . 'wp-admin/includes/file.php';
2530 8394 }
@@ -2548,35 +8412,85 @@
2548 8412 str_replace( "'", "\\'", self::hits_log_path() ),
2549 8413 $source_contents
2550 8414 );
2551 8415
8416 + // Bake the cookie + user-agent exclusion rules in too. The drop-in
8417 + // runs before WordPress loads, so it cannot read the settings — and
8418 + // without them it served the shared anonymous page to any visitor
8419 + // PHP had not yet seen (a first-time cart visitor, a bypassed bot).
8420 + // The generic bypass cookie only covers repeat visitors; these two
8421 + // regexes are what make the FIRST request correct.
8422 + //
8423 + // Both are already fully escaped by Server_Rules, and each is
8424 + // embedded as a single-quoted PHP literal, so a settings value can
8425 + // neither break the drop-in's syntax nor execute.
8426 + $cache_opts = Settings_Manager::get( 'cache' );
8427 + $cookie_rule = Server_Rules::cookie_rule(
8428 + is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
8429 + );
8430 + $ua_rule = Server_Rules::user_agent_rule(
8431 + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
8432 + );
8433 +
8434 + $source_contents = str_replace(
8435 + '@@XSPEED_COOKIE_RE@@',
8436 + str_replace( "'", "\\'", $cookie_rule['regex'] ),
8437 + $source_contents
8438 + );
8439 + $source_contents = str_replace(
8440 + '@@XSPEED_UA_RE@@',
8441 + str_replace( "'", "\\'", $ua_rule['regex'] ),
8442 + $source_contents
8443 + );
8444 +
8445 + /*
8446 + * Ours or absent — the ownership gate at the top of this method ruled
8447 + * out everything else. The old code path that moved a foreign drop-in
8448 + * into uploads/xspeed-backups and wrote ours on top is gone: it
8449 + * disabled the other plugin's page cache the moment an xSpeed install
8450 + * ran, with nothing in its own UI to explain why.
8451 + */
8452 +
8453 + // Bake the configured cache lifetime in. The drop-in runs before
8454 + // WordPress loads, so it cannot read the option — it previously fell
8455 + // back to a hardcoded 86400 for every ordinary page, because
8456 + // write_meta() only emits a `ttl` sidecar when the value DIFFERS from
8457 + // the page default. That made the admin's "1 to 720 hours" control a
8458 + // no-op at the layer that actually answers the request: 12h served
8459 + // stale for up to 2x the configured lifetime, and 168h lost the fast
8460 + // path for 6 of every 7 days (issue #240).
8461 + //
8462 + // This is re-baked on every cache settings save (see CacheModule::boot),
8463 + // exactly like the cookie / user-agent rules above.
8464 + $expiry_hours = isset( $cache_opts['cache_expiry'] ) ? (int) $cache_opts['cache_expiry'] : 24;
8465 + if ( $expiry_hours < 1 || $expiry_hours > 720 ) {
8466 + $expiry_hours = 24;
8467 + }
8468 + $source_contents = str_replace(
8469 + '@@XSPEED_DEFAULT_TTL@@',
8470 + (string) ( $expiry_hours * HOUR_IN_SECONDS ),
8471 + $source_contents
8472 + );
8473 +
8474 + // Bake the site-wide edge answer in. Resolved in a `bake` context, so
8475 + // nothing per-page and nothing a request header vouched for can reach
8476 + // it: a bake runs once, in an admin or CLI request, and answers for
8477 + // every page on the site. A page that disagrees gets a sidecar
8478 + // instead — see per_entry_edge_headers().
8479 + //
8480 + // Re-baked on every cache settings save (see CacheModule::boot),
8481 + // exactly like the cookie, user-agent and lifetime rules above.
8482 + $source_contents = str_replace(
8483 + "'@@XSPEED_EDGE_HEADERS@@'",
8484 + self::edge_headers_literal( self::edge_headers_for( 'HIT', 'bake' ) ),
8485 + $source_contents
8486 + );
8487 +
2552 8488 if ( file_exists( $target ) ) {
2553 8489 $existing = $wp_filesystem->get_contents( $target );
2554 - $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
2555 -
2556 - if ( $is_xspeed ) {
2557 - if ( $existing === $source_contents ) {
2558 - return true;
2559 - }
2560 - return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
8490 + if ( is_string( $existing ) && $existing === $source_contents ) {
8491 + return true;
2561 8492 }
2562 -
2563 - // Foreign drop-in (e.g. left over from another cache plugin) — back it up
2564 - // before overwriting so the user can recover if needed. Uploads dir
2565 - // (not wp-content root) keeps the backup out of WordPress's reserved
2566 - // drop-in location.
2567 - $upload = wp_upload_dir( null, false );
2568 - $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
2569 - if ( $basedir ) {
2570 - if ( ! file_exists( $basedir ) ) {
2571 - wp_mkdir_p( $basedir );
2572 - self::write_silence( $basedir );
2573 - }
2574 - $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
2575 - $wp_filesystem->move( $target, $backup, true );
2576 - } else {
2577 - $wp_filesystem->delete( $target );
2578 - }
2579 8493 }
2580 8494
2581 8495 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
2582 8496 }
@@ -2596,58 +8510,201 @@
2596 8510 return;
2597 8511 }
2598 8512
2599 8513 $contents = $wp_filesystem->get_contents( $target );
2600 - if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
8514 + if ( is_string( $contents ) && xspeed_has_canonical_dropin_signature( $contents ) ) {
2601 8515 wp_delete_file( $target );
2602 8516 }
2603 8517 }
2604 8518
8519 + /**
8520 + * Where wp-config.php actually is.
8521 + *
8522 + * WordPress core supports the file one directory ABOVE ABSPATH, and
8523 + * plenty of installs use that layout. This used to look only in ABSPATH
8524 + * and bail, so on those sites the constant could never be written — while
8525 + * Health, which did fall back to the parent, reported the file writable
8526 + * and told the user to toggle the cache off and on. The advice could
8527 + * never work, and its fallback hint ("another plugin left WP_CACHE false
8528 + * behind") was wrong too: there was no define at all. (#19, QA on #174)
8529 + *
8530 + * Returns '' when no wp-config.php can be found in either location.
8531 + */
8532 + public static function wp_config_path(): string {
8533 + $candidates = array( ABSPATH . 'wp-config.php', dirname( ABSPATH ) . '/wp-config.php' );
8534 + foreach ( $candidates as $path ) {
8535 + if ( file_exists( $path ) ) {
8536 + return $path;
8537 + }
8538 + }
8539 + return '';
8540 + }
8541 +
8542 + /**
8543 + * Can we actually write the constant right now?
8544 + *
8545 + * This is the single oracle for that question — Health asks THIS rather
8546 + * than running its own `wp_is_writable()` test, so the message a user
8547 + * reads can never disagree with what the plugin will do. The two differed
8548 + * in both directions: on the path (above) and on the test itself, since
8549 + * an FTP/SSH WP_Filesystem transport can refuse a file that
8550 + * `wp_is_writable()` reports as writable. (#19, QA on #174)
8551 + */
8552 + public static function can_write_wp_config(): bool {
8553 + $wp_config = self::wp_config_path();
8554 + if ( '' === $wp_config ) {
8555 + return false;
8556 + }
8557 +
8558 + global $wp_filesystem;
8559 + if ( ! function_exists( 'WP_Filesystem' ) ) {
8560 + require_once ABSPATH . 'wp-admin/includes/file.php';
8561 + }
8562 + WP_Filesystem();
8563 + return (bool) ( $wp_filesystem && $wp_filesystem->is_writable( $wp_config ) );
8564 + }
8565 +
2605 8566 public static function set_wp_cache_constant( $enable ) {
2606 - $wp_config = ABSPATH . 'wp-config.php';
2607 - if ( ! file_exists( $wp_config ) ) {
8567 + $wp_config = self::wp_config_path();
8568 + if ( '' === $wp_config ) {
2608 8569 return false;
2609 8570 }
2610 8571
8572 + /*
8573 + * WP_CACHE belongs to whoever owns the drop-in — it is the switch that
8574 + * makes core load that one file. Editing it while someone else's
8575 + * drop-in is installed either turns THEIR cache on or off; either way
8576 + * it is a write to another plugin's state. So: no ownership, no edit.
8577 + */
8578 + $owner = self::dropin_owner();
8579 + if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
8580 + return false;
8581 + }
8582 +
8583 + $state = self::wp_cache_define_state();
8584 + if ( 'duplicate' === $state || 'dynamic' === $state ) {
8585 + // Two competing defines, or a value behind an expression. A regex
8586 + // rewrite here is a guess, and a wrong guess silently kills page
8587 + // caching with no error anywhere.
8588 + return false;
8589 + }
2611 8590 global $wp_filesystem;
2612 8591 if ( ! function_exists( 'WP_Filesystem' ) ) {
2613 8592 require_once ABSPATH . 'wp-admin/includes/file.php';
2614 8593 }
2615 8594 WP_Filesystem();
2616 - if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
8595 + if ( ! $wp_filesystem ) {
2617 8596 return false;
2618 8597 }
2619 8598
2620 8599 $config = $wp_filesystem->get_contents( $wp_config );
8600 + if ( ! is_string( $config ) ) {
8601 + return false;
8602 + }
8603 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
8604 + $marker = $enable ? self::wp_cache_receipt() : '';
8605 + $updated = xspeed_rewrite_wp_cache_define( $config, (bool) $enable, $marker );
8606 + if ( ! is_string( $updated ) ) {
8607 + return false;
8608 + }
2621 8609
2622 - if ( $enable ) {
2623 - // Own the constant. A previous caching plugin (e.g. WP Rocket sets
2624 - // it false on deactivate) can leave `define( 'WP_CACHE', false );`
2625 - // behind — presence alone is not enough, the VALUE must be true or
2626 - // WordPress never loads advanced-cache.php and our drop-in is dead.
2627 - if ( preg_match( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,/", $config ) ) {
2628 - $rewritten = preg_replace(
2629 - "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*[^)]*\\)\\s*;/",
2630 - "define( 'WP_CACHE', true );",
2631 - $config,
2632 - 1
2633 - );
2634 - // If an existing define was already `true`, the rewrite is a
2635 - // no-op string-wise; either way we end on WP_CACHE === true.
2636 - if ( null !== $rewritten ) {
2637 - $config = $rewritten;
2638 - }
2639 - } else {
2640 - $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
8610 + /*
8611 + * Removing a WP_CACHE line we cannot prove we wrote is somebody else's
8612 + * configuration, so a disable needs either our drop-in or our receipt.
8613 + *
8614 + * The test is on the REWRITE, not on the request: it used to run
8615 + * before the rewrite and refuse a disable that had nothing to remove.
8616 + * An ordinary site with no drop-in and no define — every fresh
8617 + * install — therefore failed to turn page caching off, so the
8618 + * onboarding wizard reported "setup needs attention" to every user who
8619 + * declined it and Migration reported the cache import as failed.
8620 + */
8621 + if ( ! $enable && $updated !== $config
8622 + && self::DROPIN_XSPEED !== $owner
8623 + && ! self::wp_cache_receipt_matches_source( $config ) ) {
8624 + return false;
8625 + }
8626 +
8627 + /*
8628 + * Nothing to write. auto_heal() runs the whole enable transaction on
8629 + * every admin_init, so without this every wp-admin request rewrote
8630 + * wp-config.php with byte-identical content: pointless disk churn
8631 + * that trips host file-integrity monitors and widens the window for
8632 + * a concurrent write on a busy admin.
8633 + *
8634 + * It is also what makes a correct WP_CACHE on a read-only
8635 + * wp-config.php succeed. A managed host that ships the file
8636 + * unwritable, on a site where the user already pasted the define,
8637 + * is in the state we wanted — the writability test below is about
8638 + * whether we can CHANGE the file, and there is nothing to change.
8639 + */
8640 + if ( $updated === $config ) {
8641 + if ( ! $enable ) {
8642 + // Our line is not in the file, so the receipt that proved we
8643 + // wrote it is stale — drop it on the same terms as a real
8644 + // removal, or uninstall keeps a claim on nothing.
8645 + delete_option( 'xspeed_page_cache_ownership_receipt' );
2641 8646 }
2642 - } else {
2643 - $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
8647 + return true;
2644 8648 }
2645 8649
2646 - return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
8650 + if ( ! $wp_filesystem->is_writable( $wp_config ) ) {
8651 + return false;
8652 + }
8653 + $written = (bool) $wp_filesystem->put_contents( $wp_config, $updated, FS_CHMOD_FILE );
8654 + if ( $written && ! $enable ) {
8655 + delete_option( 'xspeed_page_cache_ownership_receipt' );
8656 + }
8657 + return $written;
2647 8658 }
2648 8659
8660 + private static function wp_cache_receipt(): string {
8661 + $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
8662 + if ( is_string( $receipt ) && preg_match( '/^[a-f0-9]{32}$/', $receipt ) ) {
8663 + return $receipt;
8664 + }
8665 + $receipt = substr( hash( 'sha256', XSPEED_DIR . microtime( true ) . mt_rand() ), 0, 32 );
8666 + update_option( 'xspeed_page_cache_ownership_receipt', $receipt, false );
8667 + return $receipt;
8668 + }
8669 +
2649 8670 /**
8671 + * Is the WP_CACHE line in wp-config.php ours to REMOVE?
8672 + *
8673 + * Two different questions live here and only one of them matters. "Did we
8674 + * write it" is answered by our drop-in on disk or by our receipt comment
8675 + * beside the define. "Is it ours to remove" also asks what the line does
8676 + * NOW — and once a competitor owns advanced-cache.php, a line we wrote
8677 + * ourselves is the switch that loads THEIR drop-in. They had no reason to
8678 + * touch an already-true define, so our receipt is still sitting on it.
8679 + * Removing it there would stop their live page cache.
8680 + *
8681 + * So a foreign or unreadable owner is never ours to remove, whatever the
8682 + * receipt says, and the caller treats that as a reason to leave the line
8683 + * and get on with disabling our own cache — not as a reason to refuse.
8684 + */
8685 + private static function wp_cache_define_is_ours_to_remove( string $owner ): bool {
8686 + if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
8687 + return false;
8688 + }
8689 + if ( self::DROPIN_XSPEED === $owner ) {
8690 + return true;
8691 + }
8692 + $path = self::wp_config_path();
8693 + if ( '' === $path ) {
8694 + return false;
8695 + }
8696 + $config = self::read_file( $path );
8697 + return is_string( $config ) && self::wp_cache_receipt_matches_source( $config );
8698 + }
8699 +
8700 + private static function wp_cache_receipt_matches_source( string $source ): bool {
8701 + $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
8702 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
8703 + return xspeed_wp_cache_receipt_matches( $source, $receipt );
8704 + }
8705 +
8706 + /**
2650 8707 * Admin-bar purge menu — a parent node plus one child per visible cache
2651 8708 * type (LiteSpeed-style), instead of a single "Purge All" link. Each
2652 8709 * child posts to the same admin-post handler with its type slug. The
2653 8710 * per-type items only appear for active/licensed modules; "Purge All"
@@ -2670,10 +8727,59 @@
2670 8727 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
2671 8728 )
2672 8729 );
2673 8730
2674 - foreach ( self::purge_types() as $slug => $type ) {
2675 - if ( empty( $type['visible'] ) ) {
8731 + // Settings first, then the two whole-errand actions (Purge All,
8732 + // Purge this URL), then the per-type items. The order is the one WP
8733 + // Rocket uses, and it front-loads what people open this menu for:
8734 + // nobody reaches for "Purge Object Cache" as often as they reach for
8735 + // the page they are looking at.
8736 + $wp_admin_bar->add_node(
8737 + array(
8738 + 'id' => 'xspeed-purge-settings',
8739 + 'parent' => 'xspeed-purge',
8740 + 'title' => esc_html__( 'Settings', 'xspeed' ),
8741 + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
8742 + )
8743 + );
8744 +
8745 + $types = self::purge_types();
8746 +
8747 + // 'all' is rendered out of band so the single-URL item can sit
8748 + // directly under it. A filter that reorders or drops it is honoured:
8749 + // the loop below skips whatever was emitted here.
8750 + $emitted = array();
8751 + if ( ! empty( $types['all']['visible'] ) ) {
8752 + $wp_admin_bar->add_node(
8753 + array(
8754 + 'id' => 'xspeed-purge-all',
8755 + 'parent' => 'xspeed-purge',
8756 + 'title' => esc_html( $types['all']['label'] ),
8757 + 'href' => self::purge_type_url( 'all' ),
8758 + )
8759 + );
8760 + $emitted['all'] = true;
8761 + }
8762 +
8763 + // Only when the current screen is about one thing — a front-end view,
8764 + // or a published post's edit screen. On a list table or a settings
8765 + // page there is nothing for "this" to mean, so the item stays hidden
8766 + // rather than silently targeting the dashboard. Purge_Ui decides both
8767 + // the label and the scope, which differ between the two contexts.
8768 + $context = Purge_Ui::context_node();
8769 + if ( null !== $context ) {
8770 + $wp_admin_bar->add_node(
8771 + array(
8772 + 'id' => 'xspeed-purge-this-url',
8773 + 'parent' => 'xspeed-purge',
8774 + 'title' => esc_html( $context['title'] ),
8775 + 'href' => $context['href'],
8776 + )
8777 + );
8778 + }
8779 +
8780 + foreach ( $types as $slug => $type ) {
8781 + if ( empty( $type['visible'] ) || isset( $emitted[ $slug ] ) ) {
2676 8782 continue;
2677 8783 }
2678 8784 $wp_admin_bar->add_node(
2679 8785 array(