PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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 +5722 -250 1.1.21.3.2 View file →
@@ -19,11 +19,140 @@
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 + * Cache key whose write was deferred to shutdown because a render-time
50 + * translation plugin's buffer wraps ours. Null on every ordinary request.
51 + *
52 + * @var string|null
53 + */
54 + private static $deferred_key = null;
55 +
56 + /**
57 + * Translated page HTML captured by the outer buffer, for the deferred
58 + * write. Only populated when a translation plugin is active.
59 + *
60 + * @var string
61 + */
62 + private static $translated_output = '';
63 +
64 + /**
65 + * Did finalize_buffer() run to completion on this request?
66 + *
67 + * The deferred translated write runs as a PHP shutdown function, which
68 + * fires after a `wp_die()` or a bare `exit()` exactly as it does after a
69 + * clean render. Only finalize_buffer() sets this, and only at the point
70 + * where it has the full buffer in hand — so an aborted render leaves it
71 + * false and the writer declines rather than caching a truncated page
72 + * under the real key.
73 + *
74 + * @var bool
75 + */
76 + private static $render_completed = false;
77 +
78 + /**
79 + * Hooks that get an argument-aware handler instead of a blanket purge.
80 + *
81 + * Each fires on an ordinary visitor action — an order, a review, a
82 + * registration — where purge_all() cannot see WHAT changed and so wiped
83 + * the whole cache on every one. They are re-bound further down to
84 + * handlers that inspect the payload first.
85 + *
86 + * Listed here so the generic invalidation loop skips them. It binds a
87 + * closure (to name the cause), and a closure cannot be unbound by the
88 + * remove_action() pairs below — binding one would leave the coarse purge
89 + * running alongside its replacement and silently undo #243.
90 + */
91 + private const TARGETED_INVALIDATION_HOOKS = array(
92 + 'save_post',
93 + 'before_delete_post',
94 + 'trashed_post',
95 + 'comment_post',
96 + 'wp_set_comment_status',
97 + 'user_register',
98 + 'profile_update',
99 + );
100 +
23 101 public function __construct() {
24 - add_action( 'template_redirect', array( $this, 'maybe_start_cache' ), 0 );
102 + /**
103 + * When the page-cache output buffer opens.
104 + *
105 + * Filterable because buffer ORDER decides what gets cached. PHP's
106 + * output buffers are LIFO: the last one opened is innermost, and its
107 + * callback runs first. A render-time translation plugin that opens
108 + * an outer buffer therefore translates AFTER we have already captured
109 + * and cached the raw HTML — see translation_buffer_compat().
110 + *
111 + * @param string $hook Hook to open the buffer on.
112 + * @param int $priority Priority for that hook.
113 + */
114 + $hook = (string) apply_filters( 'xspeed_cache_buffer_hook', 'template_redirect' );
115 + $priority = (int) apply_filters( 'xspeed_cache_buffer_priority', 0 );
116 + add_action( $hook, array( $this, 'maybe_start_cache' ), $priority );
25 117
118 + // When a render-time translation plugin is present, open one extra
119 + // buffer OUTSIDE its own so we can capture post-translation HTML.
120 + // TranslatePress opens on `init` priority 0, so we take a negative
121 + // priority to land outside it. This buffer only collects bytes for
122 + // the deferred cache write — it never modifies the response.
123 + add_action(
124 + 'init',
125 + static function () {
126 + if ( ! self::translation_plugin_active() ) {
127 + return;
128 + }
129 + // `init` fires on EVERY request type, and
130 + // translation_plugin_active() is a class_exists() check that
131 + // is true site-wide — so without this guard the buffer opened
132 + // on REST, admin-ajax, cron and WP-CLI too. None of those
133 + // reach template_redirect, so $deferred_key stays null and
134 + // the collected bytes are never released: a long-running
135 + // WP-CLI command copied every byte of its output into a
136 + // string that grew for the life of the process.
137 + if ( is_admin()
138 + || wp_doing_ajax()
139 + || wp_doing_cron()
140 + || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
141 + || ( defined( 'WP_CLI' ) && WP_CLI )
142 + || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
143 + return;
144 + }
145 + ob_start(
146 + static function ( $chunk ) {
147 + self::$translated_output .= $chunk;
148 + return $chunk;
149 + }
150 + );
151 + },
152 + (int) apply_filters( 'xspeed_translation_outer_buffer_priority', -100 )
153 + );
154 +
26 155 // Events that should invalidate cached output. Beyond posts/comments,
27 156 // this covers user and term changes — the REST cache can serve
28 157 // /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect
29 158 // rendered author bylines / term-archive pages. Without them, an edit
@@ -29,9 +158,9 @@
29 158 // rendered author bylines / term-archive pages. Without them, an edit
30 159 // left the matching endpoint (and archives) stale for the full TTL.
31 160 // (FBS-82408)
32 161 $invalidate_hooks = array(
33 - 'save_post', 'deleted_post', 'trashed_post',
162 + 'save_post', 'before_delete_post', 'trashed_post',
34 163 'comment_post', 'wp_set_comment_status',
35 164 'switch_theme', 'activated_plugin', 'deactivated_plugin',
36 165 // Users → /wp/v2/users + author archives.
37 166 'profile_update', 'user_register', 'deleted_user',
@@ -36,16 +165,177 @@
36 165 // Users → /wp/v2/users + author archives.
37 166 'profile_update', 'user_register', 'deleted_user',
38 167 // Terms → /wp/v2/{taxonomy} + term archives.
39 168 'created_term', 'edited_term', 'delete_term',
169 + // Menu structure changes (reorder, rename, assign to a location)
170 + // fire only here — the per-item `nav_menu_item` save_post does
171 + // not cover them. (#270 regression)
172 + 'wp_update_nav_menu',
40 173 );
41 174 foreach ( $invalidate_hooks as $hook ) {
42 - add_action( $hook, array( __CLASS__, 'purge_all' ) );
175 + // Name the hook in the cause rather than binding purge_all bare.
176 + // Bound bare, WordPress passes the action's own first argument
177 + // into $cause — a term id, a user id, a menu id — so the activity
178 + // feed read "Cache purged (12)" and told the user nothing about
179 + // what happened. (#270 QA round 2)
180 + //
181 + // The four hooks that get an argument-aware handler below
182 + // (save_post, comment_post, user_register, profile_update) are
183 + // deliberately NOT wired here: a closure cannot be unbound by
184 + // remove_action(), so binding one would leave the coarse purge in
185 + // place alongside its replacement and silently undo #243. Skipping
186 + // them is equivalent — each is re-added with its own handler, and
187 + // each of those names its own cause.
188 + if ( in_array( $hook, self::TARGETED_INVALIDATION_HOOKS, true ) ) {
189 + continue;
190 + }
191 + add_action(
192 + $hook,
193 + static function () use ( $hook ): void {
194 + self::purge_all(
195 + 'hook:' . $hook,
196 + null,
197 + self::invalidation_for_hook( $hook )
198 + );
199 + }
200 + );
43 201 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
44 202 }
45 203
204 + // Updating a plugin, theme or core changes the markup and the assets
205 + // a page is built from, but fires NONE of the hooks above: WordPress
206 + // does not deactivate and reactivate a plugin to update it, so
207 + // `activated_plugin` never runs and the cached HTML survives the
208 + // update untouched for the whole TTL — up to 7 days on the Aggressive
209 + // preset, 30 at the maximum.
210 + //
211 + // The stale copy is not merely old, it is wrong in a way the user
212 + // cannot see the cause of: they update a plugin to get a fix, the
213 + // cache keeps serving the pre-fix HTML, and the update looks like it
214 + // did nothing. Minified assets do regenerate on their own (their key
215 + // includes the source filemtime), which makes it worse rather than
216 + // better — the cached pages still link the PREVIOUS hashes.
217 + //
218 + // Purge unconditionally on any completed update. Scoping it to
219 + // "plugins that enqueue front-end assets" is not knowable here, and a
220 + // cold cache after an update is the cheaper mistake. (#269)
221 + add_action( 'upgrader_process_complete', array( __CLASS__, 'purge_after_upgrade' ), 10, 2 );
222 + // The replacement signal has to outlive OUR listener: add-ons read it
223 + // through upgrade_replaced_code() from their own priority-10 callbacks,
224 + // and consuming it inside purge_after_upgrade() meant whoever
225 + // registered second saw false. Cleared at the END of the dispatch
226 + // instead, once every listener has had its turn.
227 + //
228 + // Depth-counted, because this action NESTS. Core hangs
229 + // Language_Pack_Upgrader::async_upgrade() on it at priority 20
230 + // (wp-admin/includes/admin-filters.php), and that runs a whole
231 + // upgrader of its own, which fires this same action again. A flat
232 + // reset therefore fired while the OUTER dispatch was still running —
233 + // on any site with pending translations — and every listener after
234 + // priority 20 read the cleared signal as false. Which is the bug this
235 + // pair exists to fix, back again and harder to see. (#303)
236 + add_action( 'upgrader_process_complete', array( __CLASS__, 'note_upgrade_dispatch' ), PHP_INT_MIN );
237 + add_action( 'upgrader_process_complete', array( __CLASS__, 'forget_cleared_destination' ), PHP_INT_MAX );
238 + // WordPress labels an upload-and-replace as an INSTALL, so the action
239 + // alone cannot tell "added beside nothing" from "replaced live code".
240 + // This filter fires only when the upgrader removed an existing copy,
241 + // which is exactly the difference. Registered as a filter listener
242 + // that returns its input untouched. (#303)
243 + add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 1 );
244 + // Unattended auto-updates are the case that matters most here: they
245 + // land overnight with nobody around to purge by hand, which is the
246 + // exact scenario the stale cache goes undiagnosed in. WordPress fires
247 + // this INSTEAD of a per-item upgrader_process_complete for some
248 + // background runs. Its payload is a results array keyed by type
249 + // rather than a hook_extra, so it needs its own handler — passing it
250 + // to purge_after_upgrade() landed it in the unused $upgrader slot and
251 + // left $type empty, which read as "invalidating" and purged the whole
252 + // cache for a language-pack-only run. Matches what LiteSpeed binds.
253 + // (#298)
254 + add_action( 'automatic_updates_complete', array( __CLASS__, 'purge_after_auto_updates' ), 10, 1 );
255 + // …except the four hooks above that fire on ordinary visitor actions.
256 + // Attached bare, purge_all() can't see WHAT changed, so on a store
257 + // every order, every product review and every checkout
258 + // account-creation wiped 100% of the cache — all anonymous happy-path
259 + // actions, so the cache never reached steady state (#243). Measured:
260 + // 3 orders across 36 pageviews took the hit rate from 83% to 50% and
261 + // the average response from 23ms to 57ms.
262 + //
263 + // HPOS does NOT help: WooCommerce still writes a
264 + // `shop_order_placehold` row into wp_posts to reserve the order ID,
265 + // so save_post fires either way. The gate therefore keys on POST-TYPE
266 + // VIEWABILITY, not on storage mode — which fixes both modes at once,
267 + // and generalises to Flamingo (#229) and Tutor LMS (#231) too.
268 + remove_action( 'save_post', array( __CLASS__, 'purge_all' ) );
269 + remove_action( 'save_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
270 + add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 );
271 + add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
272 + add_action( 'trashed_post', array( __CLASS__, 'on_post_removed' ), 10, 2 );
273 + // wp_delete_post() hands an attachment to wp_delete_attachment() and
274 + // returns BEFORE before_delete_post fires, so deleting media reached
275 + // neither hook above. Attachment pages are public and media appears in
276 + // galleries, so that left cached pages showing a file that is gone.
277 + // (dev caught this via `deleted_post`, which this branch replaced.)
278 + add_action( 'delete_attachment', array( __CLASS__, 'on_post_removed' ), 10, 2 );
279 +
280 + remove_action( 'comment_post', array( __CLASS__, 'purge_all' ) );
281 + remove_action( 'comment_post', array( 'XSpeed\\Minifier', 'purge_minified' ) );
282 + add_action( 'comment_post', array( __CLASS__, 'on_comment_post' ), 10, 3 );
283 + add_action( 'wp_set_comment_status', array( __CLASS__, 'on_comment_status' ), 10, 2 );
284 +
285 + remove_action( 'user_register', array( __CLASS__, 'purge_all' ) );
286 + remove_action( 'user_register', array( 'XSpeed\\Minifier', 'purge_minified' ) );
287 + add_action( 'user_register', array( __CLASS__, 'on_user_change' ) );
288 +
289 + remove_action( 'profile_update', array( __CLASS__, 'purge_all' ) );
290 + remove_action( 'profile_update', array( 'XSpeed\\Minifier', 'purge_minified' ) );
291 + add_action( 'profile_update', array( __CLASS__, 'on_user_change' ) );
292 +
293 + // Product data lives in post meta and lookup tables, NOT in wp_posts,
294 + // so WC_Product_Data_Store_CPT::update() takes a direct $wpdb->update()
295 + // branch and save_post never fires. Anchoring invalidation on
296 + // save_post therefore missed 100% of commerce-relevant mutations: a
297 + // REST price change, wc_update_product_stock(), a CLI ->save(), and
298 + // every scheduled sale start/end left the product page, the shop and
299 + // the category archives serving the old price and stock for the full
300 + // lifetime — the store quoting one price and charging another (#242).
301 + //
302 + // This MUST ship with the gate above: once orders stop purging
303 + // everything, the accidental invalidation that was masking this
304 + // disappears, and an order that reduces stock would leave the product
305 + // page stale.
306 + if ( class_exists( 'WooCommerce' ) ) {
307 + foreach ( array( 'woocommerce_update_product', 'woocommerce_new_product' ) as $wc_hook ) {
308 + add_action( $wc_hook, array( __CLASS__, 'purge_product' ) );
309 + }
310 + // Direct stock writes bypass the CRUD entirely.
311 + add_action( 'woocommerce_product_set_stock', array( __CLASS__, 'purge_product_object' ) );
312 + add_action( 'woocommerce_variation_set_stock', array( __CLASS__, 'purge_product_object' ) );
313 + add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'purge_product' ) );
314 + add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'purge_product' ) );
315 + }
316 +
46 317 add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );
47 318
319 + // …and the same for every PER-MODULE option. The handler above only
320 + // ever watched the legacy `xspeed_options` blob, but every module has
321 + // since migrated to its own `xspeed_module_<slug>` option and no hook
322 + // followed — so changing Minify HTML, Lazy Load, Remove Query Strings
323 + // etc. left the cached HTML untouched until the TTL expired (24h by
324 + // default) and the feature read as broken. (#205)
325 + //
326 + // One central listener rather than a hook per module: it covers Pro
327 + // modules with no cross-repo change, and a new module can't forget to
328 + // wire it up.
329 + add_action( 'updated_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
330 + // `added_option` matters as much as `updated_option`: on a fresh install
331 + // a module's option doesn't exist yet, so the FIRST save of every panel
332 + // goes through add_option() and would otherwise skip the purge — the
333 + // original bug surviving one save per module. `deleted_option` covers a
334 + // reset-to-defaults, which changes rendered HTML just as much. (#205)
335 + add_action( 'added_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
336 + add_action( 'deleted_option', array( __CLASS__, 'on_module_settings_change' ), 10, 1 );
337 +
48 338 add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
49 339 add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
50 340 }
51 341
@@ -61,13 +351,188 @@
61 351 self::purge_all( 'settings change' );
62 352 Minifier::purge_minified();
63 353 }
64 354
355 + /**
356 + * Modules whose settings cannot change rendered HTML, so a write to them
357 + * doesn't warrant throwing away the page cache.
358 + *
359 + * The safe default is to purge: a module is listed here only when it is
360 + * clearly incapable of altering front-end output (diagnostics, the MCP
361 + * server, licensing/telemetry surfaces). When in doubt, leave it off the
362 + * list — a needless purge costs a re-render, a missed one makes the
363 + * feature look broken. (#205)
364 + *
365 + * @return string[] Module slugs.
366 + */
367 + public static function non_rendering_modules(): array {
368 + return (array) apply_filters(
369 + 'xspeed_non_rendering_modules',
370 + array(
371 + 'mcp', // AI endpoint — no front-end output.
372 + 'health', // diagnostics only.
373 + 'support', // support snapshot.
374 + 'score', // PageSpeed/GTmetrix runner.
375 + 'migration', // one-shot importer.
376 + 'settings', // import/export surface.
377 + 'cache-coverage', // read-only reporting.
378 + 'ai-privacy', // consent flags for AI surfaces.
379 + 'database', // DB cleanup schedule — no HTML impact.
380 + // Pro slugs — listed by name rather than by asking Pro, so
381 + // Free stays unaware of it. A Pro module absent here simply
382 + // purges, which is the safe default.
383 + 'license',
384 + 'pro_status',
385 + 'analytics',
386 + 'performance-health',
387 + 'recommendations',
388 + 'ai-provider',
389 + 'migration-pro',
390 + )
391 + );
392 + }
393 +
394 + /**
395 + * Purge when ANY module's settings option is written. (#205)
396 + *
397 + * Bound to `updated_option`, `added_option` and `deleted_option` — all three
398 + * fire for every option on the site, so the prefix test comes first and is
399 + * the cheap path for the ~99% of writes that aren't ours. All three pass the
400 + * option name first, which is why this can't hook purge_all() directly:
401 + * that takes $cause first, so every purge would be filed under a cause
402 + * literally named "xspeed_module_minify".
403 + *
404 + * @param string $option Option name that was just written or removed.
405 + */
406 + public static function on_module_settings_change( $option ): void {
407 + $option = (string) $option;
408 + $prefix = Settings_Manager::OPTION_PREFIX;
409 + if ( 0 !== strpos( $option, $prefix ) ) {
410 + return;
411 + }
412 +
413 + $slug = substr( $option, strlen( $prefix ) );
414 + if ( '' === $slug || in_array( $slug, self::non_rendering_modules(), true ) ) {
415 + return;
416 + }
417 +
418 + // Guard against re-entry: purge_all() and purge_minified() can write
419 + // options of their own (stats, timestamps), and a nested purge would
420 + // both waste work and risk recursing through this same hook.
421 + static $purging = false;
422 + if ( $purging ) {
423 + return;
424 + }
425 + $purging = true;
426 +
427 + self::purge_all( 'settings change' );
428 + Minifier::purge_minified();
429 +
430 + $purging = false;
431 + }
432 +
433 + /**
434 + * Stamp the request's cache decision on the response.
435 + *
436 + * `X-XSpeed-Cache` was only ever written on the serve-from-cache paths,
437 + * so a miss and a deliberate bypass both came back with no header at all
438 + * — indistinguishable from a `curl -I`, the first thing anyone reaches
439 + * for when a site "isn't caching" (issue #10). The reason slug rides
440 + * along on `X-XSpeed-Reason`, but only under WP_DEBUG so production
441 + * responses stay clean. Slugs are fixed per gate — never the matched
442 + * pattern, cookie or user-agent, which would echo request input back.
443 + *
444 + * @param string $value HIT (php) | MISS | BYPASS.
445 + * @param string $reason Fixed slug naming the gate, for BYPASS only.
446 + */
447 + private static function mark( string $value, string $reason = '' ): void {
448 + self::$status_header = $value;
449 + self::$bypass_reason = $reason;
450 +
451 + if ( headers_sent() ) {
452 + return;
453 + }
454 + header( 'X-XSpeed-Cache: ' . $value );
455 + if ( '' !== $reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
456 + header( 'X-XSpeed-Reason: ' . $reason );
457 + }
458 + }
459 +
460 + /** Record a bypass gate and answer "don't cache" in one statement. */
461 + private static function bypass( string $reason ): bool {
462 + self::mark( 'BYPASS', $reason );
463 + return false;
464 + }
465 +
466 + /** The X-XSpeed-Cache value decided for this request ('' if none yet). */
467 + public static function status_header(): string {
468 + return self::$status_header;
469 + }
470 +
471 + /** The bypass gate slug for this request ('' unless BYPASS). */
472 + public static function bypass_reason(): string {
473 + return self::$bypass_reason;
474 + }
475 +
476 + /**
477 + * Bypass gates that describe THE VISITOR rather than THIS REQUEST.
478 + *
479 + * Only these may be recorded in the bypass cookie. A visitor-scoped
480 + * verdict stays true for the visitor's next request — they are still
481 + * logged in, still hold a cart cookie — so the web server can act on
482 + * it without booting PHP.
483 + *
484 + * Every other gate describes the request in front of us: its method,
485 + * its URL, its query string, the client's user agent. Persisting one
486 + * of those pins a visitor to the uncached path over a property that
487 + * was never theirs to begin with. (#218)
488 + */
489 + private const VISITOR_SCOPED_BYPASS = array( 'logged-in', 'excluded-cookie' );
490 +
491 + /**
492 + * Whether $reason describes the visitor (persist it) or merely this
493 + * request (don't).
494 + *
495 + * Split out as a pure function because it is the whole decision behind
496 + * the bypass cookie, and the cookie write itself (setcookie()) can't be
497 + * asserted in a unit test.
498 + */
499 + public static function bypass_is_visitor_scoped( string $reason ): bool {
500 + return in_array( $reason, self::VISITOR_SCOPED_BYPASS, true );
501 + }
502 +
65 503 public function maybe_start_cache() {
66 504 if ( ! self::should_cache() ) {
505 + // PHP has just evaluated the FULL exclusion rule list — including
506 + // the `~regex` patterns the server config can't express — and
507 + // decided this response must not be served from cache. Record that
508 + // verdict in the conventional bypass cookie so the web server can
509 + // enforce it on subsequent requests without starting PHP.
510 + //
511 + // This is what stops most settings changes from needing an nginx
512 + // reload: the config tests one fixed cookie name forever, and the
513 + // rule list behind it can change freely.
514 + //
515 + // But ONLY when the verdict is about the visitor. A request-shape
516 + // gate — `non-get` above all — says nothing about who is asking,
517 + // and persisting it pinned that visitor to the uncached path for
518 + // the rest of their session: one search-form POST, one comment,
519 + // one `curl -I` from an uptime monitor, and every later GET
520 + // bypassed. It could not self-heal either, because the bypass
521 + // cookie is itself in excluded_cookies, so the next GET bypassed
522 + // with `excluded-cookie` and landed right back here, where
523 + // sync_bypass_cookie()'s no-change short-circuit left the cookie
524 + // exactly where it was. (#218)
525 + if ( self::bypass_is_visitor_scoped( self::bypass_reason() ) ) {
526 + self::sync_bypass_cookie( true );
527 + }
67 528 return;
68 529 }
69 530
531 + // Cacheable: clear any stale bypass cookie, or a visitor who once
532 + // had a cart would keep skipping the fast path long after checkout.
533 + self::sync_bypass_cookie( false );
534 +
70 535 $key = self::cache_key();
71 536 $file = self::cache_file_for( $key );
72 537
73 538 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
@@ -78,11 +543,9 @@
78 543 // serve path — the one that runs when the drop-in isn't loaded
79 544 // (e.g. WP_CACHE not true) — previously streamed the cached
80 545 // file with NO marker, so a genuine HIT looked like a MISS in
81 546 // the response headers. Same header + value as the drop-in.
82 - if ( ! headers_sent() ) {
83 - header( 'X-XSpeed-Cache: HIT (php)' );
84 - }
547 + self::mark( 'HIT (php)' );
85 548 // Replay stored response bits so the HIT matches the original:
86 549 // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200
87 550 // status (a cached 404 must serve 404, not 200). No-op for
88 551 // ordinary pages, which write no .meta.
@@ -126,11 +589,27 @@
126 589 // Apache. See maybe_emit_lscache_headers() for the full rationale.
127 590 self::maybe_emit_lscache_headers();
128 591
129 592 // We're about to render fresh + cache → miss for this request.
130 - Hit_Counter::record_miss();
593 + // …UNLESS this request is a 404 or a known bot/scanner. Those reach the
594 + // render path too, but counting them as cache misses makes the ratio
595 + // meaningless — a wave of `/wp-x7.php` scanner 404s reads as a collapsing
596 + // cache when nothing is wrong. Runs at template_redirect (priority 0), so
597 + // is_404() is already resolved. Excluded requests are tallied separately
598 + // for the "you absorbed N scanner hits" line, not dropped. (#118)
599 + if ( self::miss_is_excluded() ) {
600 + Hit_Counter::record_excluded();
601 + } else {
602 + Hit_Counter::record_miss();
603 + }
131 604
605 + // Stamp it, so "eligible but not cached yet" is visibly different
606 + // from "deliberately bypassed" (issue #10). Headers can't be sent
607 + // after the body starts, so this has to happen here, not in
608 + // finalize_buffer() — nothing has been output at template_redirect.
609 + self::mark( 'MISS' );
132 610
611 +
133 612 // WP < 6.9 fallback: ob_start() with a callback, paired with an
134 613 // explicit shutdown close so the buffer lifecycle is visible to
135 614 // reviewers and Plugin Check, instead of relying on PHP's implicit
136 615 // request-end flush. We record our nesting level so close_buffer()
@@ -159,20 +638,178 @@
159 638 }
160 639 self::$buffer_level = null;
161 640 }
162 641
642 + /**
643 + * Are we buffering this request?
644 + *
645 + * Asked by Css_Combine_Buffer, which needs the finished HTML but must not
646 + * open a second buffer when this one is already going to hand it the page
647 + * through `xspeed_cache_final_html`. False here means the request is not
648 + * cacheable — cache off, excluded URL, logged in — and the combiner has to
649 + * provide its own buffer or it silently stops working. (#195)
650 + */
651 + public static function is_buffering(): bool {
652 + return null !== self::$buffer_level;
653 + }
654 +
655 + /**
656 + * Is a render-time translation plugin going to wrap our output buffer?
657 + *
658 + * TranslatePress opens its translation buffer on `init` priority 0. We
659 + * open ours on `template_redirect`, which runs much later, so ours nests
660 + * INSIDE theirs. PHP unwinds output buffers LIFO — innermost callback
661 + * first — so `finalize_buffer()` saw the raw, pre-translation HTML and
662 + * cached that, while the live visitor still got the translated bytes from
663 + * TRP's outer buffer.
664 + *
665 + * Result: the first (MISS) visitor to /fr/some-page/ got correct French;
666 + * every visitor after got English body text under a `lang="fr-FR"`
667 + * document, plus TRP's internal `#TRPLINKPROCESSED` link markers, which
668 + * TRP strips at the very end of its own buffer and which therefore leak
669 + * into anything captured from inside it.
670 + *
671 + * Note the ordering cannot be fixed from TRP's side: its
672 + * `trp_start_output_buffer_priority` filter only moves the PRIORITY on
673 + * `init`, and `init` always fires before `template_redirect` whatever the
674 + * priority. The buffer that has to move is ours.
675 + *
676 + * Detected by main class rather than plugin path, so a renamed directory
677 + * or a bundled copy still matches.
678 + */
679 + public static function translation_plugin_active(): bool {
680 + $active = class_exists( 'TRP_Translate_Press' );
681 +
682 + /**
683 + * Whether to treat this request as wrapped by a translation buffer.
684 + *
685 + * Lets a site add another render-time translation plugin (or opt out)
686 + * without patching the engine.
687 + *
688 + * @param bool $active
689 + */
690 + return (bool) apply_filters( 'xspeed_translation_plugin_active', $active );
691 + }
692 +
693 + /**
694 + * Write the cache file for a request whose output was wrapped by a
695 + * render-time translation plugin.
696 + *
697 + * Registered as a PHP shutdown function (not a WP `shutdown` action) so
698 + * it runs after PHP has unwound the output-buffer stack — by which point
699 + * the translation plugin's callback has transformed the bytes and its
700 + * internal markers are gone.
701 + *
702 + * finalize_buffer() has already applied the status gate, the
703 + * xspeed_cache_final_html filter and HTML minification to the
704 + * untranslated copy and then declined to write it. Here we re-run only
705 + * what's needed on the translated bytes: minify, write, and fire the
706 + * same downstream hooks so Brotli / static-tree listeners behave
707 + * identically to the ordinary path.
708 + */
709 + public static function write_deferred_translated_cache(): void {
710 + $key = self::$deferred_key;
711 + self::$deferred_key = null;
712 +
713 + // Release the collected bytes BEFORE the early return, so the static
714 + // is cleared on every path rather than only when a key survived.
715 + $full = self::$translated_output;
716 + self::$translated_output = '';
717 +
718 + $completed = self::$render_completed;
719 + self::$render_completed = false;
720 +
721 + if ( null === $key ) {
722 + return;
723 + }
724 +
725 + // Did the render actually finish?
726 + //
727 + // This runs as a PHP shutdown function, which fires after a wp_die()
728 + // or a bare exit() just as readily as after a clean render — but in
729 + // those cases finalize_buffer() never returned, so the bytes we hold
730 + // are a page that was cut off partway through. The length and
731 + // TRPLINKPROCESSED checks below don't catch that: a fatal after the
732 + // footer's translated markup is both over 255 bytes and free of TRP
733 + // markers, i.e. truncated but entirely plausible. Caching it would
734 + // freeze a half-rendered page under the real key for the full TTL.
735 + //
736 + // Serving this one URL uncached is the cheap failure; the corrupt
737 + // cache entry is the expensive one.
738 + if ( ! $completed ) {
739 + return;
740 + }
741 +
742 + if ( strlen( $full ) < 255 ) {
743 + return;
744 + }
745 +
746 + // Refuse to cache a copy still carrying the translation plugin's
747 + // internal link markers. TRP strips these at the very end of its own
748 + // buffer, so their presence means we captured too early — and a
749 + // cached page containing them is SEO-visible damage. Better to serve
750 + // this URL uncached than to freeze broken markup for the full TTL.
751 + if ( false !== strpos( $full, 'TRPLINKPROCESSED' ) ) {
752 + return;
753 + }
754 +
755 + $minify_opts = Settings_Manager::get( 'minify' );
756 + if ( ! empty( $minify_opts['minify_html'] ) ) {
757 + $full = Minifier::minify_html( $full );
758 + }
759 + $full = self::signed( $full );
760 +
761 + // Per-site directory: on multisite every blog shares this tree, so
762 + // entries are bucketed by host to keep one site's purge from
763 + // sweeping the whole network. (#6)
764 + self::ensure_host_dir();
765 +
766 + // Never author a cache entry from a request that carried a query
767 + // string: cache_key() files it under the BARE url, so the params'
768 + // render would be served to every clean-URL visitor (#241).
769 + if ( self::query_string_blocks_write() ) {
770 + return;
771 + }
772 +
773 + $file = self::cache_file_for( $key );
774 + // 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.
775 + file_put_contents( $file, $full, LOCK_EX );
776 +
777 + /** This action is documented in includes/class-cache.php */
778 + do_action( 'xspeed_flat_file_written', $file, $full );
779 +
780 + self::write_meta( $key, $full );
781 +
782 + // Static tree too, under the same gates finalize_buffer() applies —
783 + // otherwise deferring the write would silently cost translated pages
784 + // the web-server fast path and leave them on the slower drop-in.
785 + if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
786 + self::store_static( $full );
787 + }
788 + }
789 +
163 790 public static function should_cache() {
791 + // Reset first: a single request only reaches this once (the sole
792 + // caller is maybe_start_cache()), but tests and any future caller
793 + // must never inherit the previous request's verdict.
794 + self::$status_header = '';
795 + self::$bypass_reason = '';
796 +
164 797 $opts = Settings::get();
165 798 if ( empty( $opts['cache_enabled'] ) ) {
166 - return false;
799 + return self::bypass( 'cache-disabled' );
167 800 }
168 801
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;
802 + if ( is_user_logged_in() ) {
803 + return self::bypass( 'logged-in' );
171 804 }
172 805
806 + if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
807 + return self::bypass( 'non-frontend' );
808 + }
809 +
173 810 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
174 - return false;
811 + return self::bypass( 'donotcachepage' );
175 812 }
176 813
177 814 // All exclusion knobs now owned by CacheModule.
178 815 $cache_opts = Settings_Manager::get( 'cache' );
@@ -178,9 +815,9 @@
178 815 $cache_opts = Settings_Manager::get( 'cache' );
179 816
180 817 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
181 818 if ( 'GET' !== $method ) {
182 - return false;
819 + return self::bypass( 'non-get' );
183 820 }
184 821
185 822 // Search-results requests carry a `s` query param, which the
186 823 // query-string gate below would normally reject as "dynamic". An
@@ -211,9 +848,19 @@
211 848 // Query string handling: anything OUTSIDE the ignored-params
212 849 // allow-list (utm_*, fbclid, gclid by default) means a unique
213 850 // request that we don't want to share with the canonical cache
214 851 // entry. Skip cache rather than poison the key.
215 - $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '';
852 + //
853 + // Parse the RAW query string, NOT a sanitize_text_field() copy:
854 + // that filter strips percent-encoded octets (%XX), so `?%73=…`
855 + // would lose its `s` key here while WordPress still decodes it to
856 + // a search request — the gate would wave the request through and
857 + // cache_key() would file the search page under the bare URL,
858 + // letting an attacker poison the homepage cache with `/?%73=<spam>`.
859 + // parse_str() does its own urldecoding, matching WP's own parse, and
860 + // only the KEYS are used below (fed to Glob_Matcher → preg_match,
861 + // never echoed or executed), so no sanitization is needed here.
862 + $query_raw = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- see note above: parse_str() urldecodes to match WP; only keys are consumed, via preg_match, never output.
216 863 if ( '' !== $query_raw ) {
217 864 $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
218 865 parse_str( $query_raw, $params );
219 866 foreach ( $params as $key => $_ ) {
@@ -226,9 +873,11 @@
226 873 if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) {
227 874 continue;
228 875 }
229 876 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
230 - return false;
877 + // Slug only — never the param name, which is attacker-
878 + // controlled and would be reflected into a header.
879 + return self::bypass( 'query-param' );
231 880 }
232 881 }
233 882 }
234 883
@@ -236,9 +885,9 @@
236 885 $path = (string) strtok( $request_uri, '?' );
237 886
238 887 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
239 888 if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) {
240 - return false;
889 + return self::bypass( 'excluded-url' );
241 890 }
242 891
243 892 // Cookie-based exclusion. We only check cookie NAMES (matching
244 893 // values would leak content-sensitive logic into the cache key
@@ -245,10 +894,21 @@
245 894 // rules); presence of any matching cookie name skips cache.
246 895 $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
247 896 if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
248 897 foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
898 + // Our own bypass cookie is a RECORD of a previous verdict, not
899 + // evidence about this visitor, so it never gets a vote here.
900 + // Letting it match made the verdict self-confirming: once set,
901 + // it produced `excluded-cookie` forever, which re-set it, and
902 + // no later request could ever re-evaluate the visitor on the
903 + // rules that actually describe them. The web server still acts
904 + // on the cookie without booting PHP; when PHP does boot it is
905 + // authoritative and re-decides from scratch. (#218)
906 + if ( Server_Rules::BYPASS_COOKIE === $cookie_name ) {
907 + continue;
908 + }
249 909 if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
250 - return false;
910 + return self::bypass( 'excluded-cookie' );
251 911 }
252 912 }
253 913 }
254 914
@@ -259,9 +919,9 @@
259 919 if ( ! empty( $bypass_uas ) ) {
260 920 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
261 921 foreach ( $bypass_uas as $needle ) {
262 922 if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
263 - return false;
923 + return self::bypass( 'user-agent' );
264 924 }
265 925 }
266 926 }
267 927
@@ -268,9 +928,9 @@
268 928 // Per-post override (Phase 3.4). Honored only on singular
269 929 // post-context requests — archives / 404s / taxonomies use the
270 930 // global policy above.
271 931 if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
272 - return false;
932 + return self::bypass( 'post-excluded' );
273 933 }
274 934
275 935 /**
276 936 * Final say on whether the current request is cacheable.
@@ -290,9 +950,16 @@
290 950 * advanced-cache.php.
291 951 *
292 952 * @param bool $should_cache Whether to cache the current request.
293 953 */
294 - return (bool) apply_filters( 'xspeed_should_cache', true );
954 + if ( ! apply_filters( 'xspeed_should_cache', true ) ) {
955 + // One slug for every listener — a third-party callback name is
956 + // not ours to put in a response header. Which listener vetoed is
957 + // a WP_DEBUG-level question the filter itself can answer.
958 + return self::bypass( 'filtered' );
959 + }
960 +
961 + return true;
295 962 }
296 963
297 964 /**
298 965 * Whether the current request is a 404 we may cache.
@@ -336,8 +1003,75 @@
336 1003 * search_term() / cache_key()) so different searches stay distinct.
337 1004 * The xspeed-pro search cache flips the filter; Free never caches
338 1005 * search results on its own.
339 1006 */
1007 + /**
1008 + * Whether this response was rendered for a query string and therefore
1009 + * must not be STORED under the bare-URL key.
1010 + *
1011 + * should_cache() lets a request through when every key is on the
1012 + * `ignored_query_params` allow-list, and cache_key() then drops the
1013 + * query string so `/post` and `/post?utm_source=x` share one entry.
1014 + * Sharing on READ is the point of the allow-list and stays. Sharing on
1015 + * WRITE is a cache-poisoning vector: the response was rendered *with*
1016 + * those params, and WordPress reflects REQUEST_URI into form actions,
1017 + * share links, canonical helpers and plugin smart tags. One anonymous
1018 + * GET to a cold URL therefore freezes an attacker-chosen variant under
1019 + * the clean URL's key, served for the whole TTL by the drop-in and by
1020 + * the web server — neither of which runs these checks (issue #241).
1021 + *
1022 + * The allow-list keeps its benefit: a visitor arriving on
1023 + * `?utm_source=…` is still SERVED the canonical cached entry. Only the
1024 + * write is skipped, so the entry is authored by a clean request.
1025 + *
1026 + * This is the same reasoning as the `should_cache_search()` guard in
1027 + * store_static() (#191), generalised to the allow-listed params.
1028 + */
1029 + public static function request_has_query_string(): bool {
1030 + $query = isset( $_SERVER['QUERY_STRING'] )
1031 + ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only tested for emptiness; never echoed, stored or used as a path.
1032 + : '';
1033 +
1034 + return '' !== trim( $query );
1035 + }
1036 +
1037 + /**
1038 + * Would authoring a cache entry from THIS request file a query-string
1039 + * render under the bare URL?
1040 + *
1041 + * The one predicate both write sites ask, so they cannot drift.
1042 + *
1043 + * Two shapes are exempt because cache_key() does NOT drop their query —
1044 + * it folds the distinguishing part into the key, so each variant gets
1045 + * its own entry and none is filed under the bare URL:
1046 + *
1047 + * - searches, keyed by `|s=<term>` (#191)
1048 + * - feeds, keyed by `|feed=<type>` — `/?feed=rss2` is the ONLY feed URL
1049 + * core generates on plain permalinks, so treating it as poisonable
1050 + * made feed caching a no-op on exactly the sites that need it
1051 + *
1052 + * @return bool True when the write must be skipped.
1053 + */
1054 + public static function query_string_blocks_write(): bool {
1055 + if ( ! self::request_has_query_string() ) {
1056 + return false;
1057 + }
1058 +
1059 + if ( self::should_cache_search() ) {
1060 + return false;
1061 + }
1062 +
1063 + // Feed caching is opt-in, via the same filter should_cache() reads
1064 + // to admit the feed params in the first place.
1065 + if ( function_exists( 'is_feed' ) && is_feed()
1066 + && (bool) apply_filters( 'xspeed_should_cache_feed', false )
1067 + ) {
1068 + return false;
1069 + }
1070 +
1071 + return true;
1072 + }
1073 +
340 1074 public static function should_cache_search(): bool {
341 1075 if ( ! function_exists( 'is_search' ) || ! is_search() ) {
342 1076 return false;
343 1077 }
@@ -377,13 +1111,22 @@
377 1111 }
378 1112
379 1113 /**
380 1114 * Is this query-string key on the ignored-params allow-list? Supports
381 - * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`,
382 - * etc.) so users don't have to enumerate every UTM variant.
1115 + * globs (`utm_*` matches `utm_source`, `utm_medium`, etc.) so users
1116 + * don't have to enumerate every UTM variant, and `~regex`.
1117 + *
1118 + * Matching is whole-name, not "contains" — a param name is an
1119 + * identifier, not a path. Under the old contains match the shipped
1120 + * default `ref` also swallowed `preference`, `product_ref` and
1121 + * `referrer`: those params were dropped from the cache key, so
1122 + * `/shop?preference=1` was served — and, on a cold entry, WRITTEN as —
1123 + * `/shop`. Same for `_ga` vs `_gallery`, and for the unanchored
1124 + * `~utm_…` default vs `my_utm_source`. A param name that is genuinely
1125 + * unknown now bypasses the cache, which is the safe direction.
383 1126 */
384 1127 private static function query_key_is_ignored( string $key, array $ignored ): bool {
385 - return Glob_Matcher::any_match( $ignored, $key );
1128 + return Glob_Matcher::any_match_name( $ignored, $key );
386 1129 }
387 1130
388 1131 public static function cache_key() {
389 1132 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
@@ -459,10 +1202,301 @@
459 1202 }
460 1203 return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
461 1204 }
462 1205
1206 + /**
1207 + * Filesystem-safe directory name for a host, or '' when unusable.
1208 + *
1209 + * The charset MUST match the static tree (store_static()) and the
1210 + * drop-in's own copy, or the paths disagree about where an entry lives.
1211 + * The colon of `host:port` is stripped: it is legal in a Host header but
1212 + * not portable in a path.
1213 + *
1214 + * @param string $host Raw host, e.g. from HTTP_HOST.
1215 + * @return string Safe directory segment, or '' if nothing usable remains.
1216 + */
1217 + /**
1218 + * The host segment of the STATIC tree — `xspeed-static/<host>/…`, which
1219 + * the web server resolves without PHP.
1220 + *
1221 + * Different from host_dir(): here the port is folded INTO the segment
1222 + * (`localhost:8080` → `localhost8080`) rather than dropped, because the
1223 + * generated server rules have to reproduce this from their own variables
1224 + * and nginx's `$host` has no port to drop — see the `$xspeed_host`
1225 + * derivation in nginx_snippet(). Shared by the write and the purge so the
1226 + * two can't drift; when they did, purging a page on a ported host deleted
1227 + * nothing and the stale copy kept being served by the rewrite.
1228 + */
1229 + public static function static_host_dir( string $host ): string {
1230 + return (string) preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
1231 + }
1232 +
1233 + public static function host_dir( string $host ): string {
1234 + $host = str_replace( "\0", '', $host );
1235 + // Drop the port BEFORE filtering, or `example.com:8080` collapses to
1236 + // `example.com8080` — which both loses the boundary and could collide
1237 + // with a real host of that name.
1238 + $colon = strpos( $host, ':' );
1239 + if ( false !== $colon ) {
1240 + $host = substr( $host, 0, $colon );
1241 + }
1242 + $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
1243 + // Collapse any run of dots so no traversal sequence can survive the
1244 + // charset filter (`a/../b` would otherwise reduce to `a..b`).
1245 + $host = preg_replace( '/\.{2,}/', '.', (string) $host );
1246 + $host = trim( (string) $host, '.-' );
1247 + return '' === $host ? '' : $host;
1248 + }
1249 +
1250 + /**
1251 + * The per-site bucket a cache entry belongs to: `<host>` on a single
1252 + * site, `<host>/<path-prefix>` for a subdirectory multisite blog.
1253 + *
1254 + * On multisite every blog shares one cache directory, and a flat md5
1255 + * filename carries no clue which site wrote it — so purging one subsite
1256 + * swept the whole network cold. (#6)
1257 + *
1258 + * Host alone is NOT enough: a subdirectory network (the common layout)
1259 + * puts every blog on the same host, so `example.com/` and
1260 + * `example.com/siteb/` would share a bucket and keep purging each other.
1261 + * The path prefix is what separates them, and it is derivable from the
1262 + * REQUEST_URI alone — which matters because the drop-in must compute
1263 + * this identical value before WordPress (and get_blog_details()) exist.
1264 + *
1265 + * Subdomain and domain-mapped networks differ by host already, so they
1266 + * get a bare host bucket and are unaffected.
1267 + *
1268 + * @param string $host Raw host.
1269 + * @param string $uri Raw REQUEST_URI (query string is ignored).
1270 + * @return string Bucket path, always non-empty.
1271 + */
1272 + public static function site_bucket( string $host, string $uri ): string {
1273 + $dir = self::host_dir( $host );
1274 + if ( '' === $dir ) {
1275 + $dir = 'default';
1276 + }
1277 +
1278 + $prefix = self::site_path_prefix();
1279 + return '' === $prefix ? $dir : $dir . '/' . $prefix;
1280 + }
1281 +
1282 + /**
1283 + * The current blog's path prefix as a single safe segment ('' for the
1284 + * root blog or a non-multisite install). `/siteb/` becomes `siteb`;
1285 + * a nested `/a/b/` becomes `a-b` so the bucket stays one level deep.
1286 + *
1287 + * Written to a sidecar for the drop-in by sync_site_paths().
1288 + */
1289 + public static function site_path_prefix(): string {
1290 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
1291 + return '';
1292 + }
1293 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
1294 + return ''; // Hosts already differ; no prefix needed.
1295 + }
1296 + $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
1297 + return self::path_prefix_segment( $path );
1298 + }
1299 +
1300 + /**
1301 + * The bucket an arbitrary URL's cache entry lives in.
1302 + *
1303 + * `site_bucket()` answers for the CURRENT request; this answers for a URL
1304 + * that may belong to another blog entirely — which is what a per-URL purge
1305 + * is usually doing (WP-CLI, cron, the MCP tool, a network-admin action).
1306 + *
1307 + * The blog is resolved from the URL itself: on a subdirectory network
1308 + * `get_blog_details()` is asked which blog owns `<host><path>`, and its
1309 + * registered path becomes the prefix. Deriving the prefix from the URL's
1310 + * first path segment directly would be wrong — `/shop/` on the main blog
1311 + * is a page, not a subsite, and would send the purge into a bucket that
1312 + * does not exist. (QA B2 on #166)
1313 + *
1314 + * @param string $host Host of the URL being purged.
1315 + * @param string $path Path of the URL being purged.
1316 + * @return string Bucket path, always non-empty.
1317 + */
1318 + public static function bucket_for_url( string $host, string $path ): string {
1319 + $dir = self::host_dir( $host );
1320 + if ( '' === $dir ) {
1321 + $dir = 'default';
1322 + }
1323 +
1324 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
1325 + return $dir;
1326 + }
1327 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
1328 + return $dir; // Hosts already differ; no prefix.
1329 + }
1330 + if ( ! function_exists( 'get_blog_details' ) ) {
1331 + return $dir;
1332 + }
1333 +
1334 + // Longest registered blog path that prefixes this URL wins, so
1335 + // `/one/2026/post/` resolves to blog `/one/` and not to the root blog.
1336 + $blog = self::blog_for_path( $host, $path );
1337 + if ( null === $blog ) {
1338 + return $dir;
1339 + }
1340 + $prefix = self::path_prefix_segment( (string) $blog );
1341 + return '' === $prefix ? $dir : $dir . '/' . $prefix;
1342 + }
1343 +
1344 + /**
1345 + * The registered path of the blog that owns `<host><path>`, or null.
1346 + *
1347 + * Uses get_blog_details() with a domain/path pair rather than scanning
1348 + * every blog, so a large network costs one lookup per candidate segment
1349 + * instead of a full table read.
1350 + */
1351 + private static function blog_for_path( string $host, string $path ): ?string {
1352 + $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) );
1353 +
1354 + // Try the longest candidate first: /a/b/ before /a/ before /.
1355 + for ( $take = min( count( $segments ), 2 ); $take >= 1; $take-- ) {
1356 + $candidate = '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/';
1357 + $details = get_blog_details(
1358 + array(
1359 + 'domain' => $host,
1360 + 'path' => $candidate,
1361 + ),
1362 + false
1363 + );
1364 + if ( $details && ! empty( $details->path ) ) {
1365 + return (string) $details->path;
1366 + }
1367 + }
1368 + return null;
1369 + }
1370 +
1371 + /**
1372 + * Normalise a blog path ('/', '/siteb/', '/a/b/') into a single
1373 + * filesystem-safe segment. Shared with the drop-in's copy.
1374 + */
1375 + public static function path_prefix_segment( string $path ): string {
1376 + $path = trim( str_replace( "\0", '', $path ), '/' );
1377 + if ( '' === $path ) {
1378 + return '';
1379 + }
1380 + $path = preg_replace( '/[^a-zA-Z0-9._\-\/]/', '', $path );
1381 + $path = str_replace( '/', '-', (string) $path );
1382 + return trim( (string) $path, '.-' );
1383 + }
1384 +
1385 + /**
1386 + * The current blog's path as the static tree stores it — real slashes
1387 + * preserved, because that tree mirrors the URL
1388 + * (`xspeed-static/{host}{request_uri}/index.html`) rather than using a
1389 + * single flattened segment. '' for a root blog / single site.
1390 + */
1391 + public static function site_path_raw(): string {
1392 + if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
1393 + return '';
1394 + }
1395 + if ( function_exists( 'is_subdomain_install' ) && is_subdomain_install() ) {
1396 + return '';
1397 + }
1398 + $path = function_exists( 'get_blog_details' ) ? (string) get_blog_details()->path : '/';
1399 + $path = trim( str_replace( "\0", '', $path ), '/' );
1400 + if ( '' === $path ) {
1401 + return '';
1402 + }
1403 + $path = preg_replace( '#[^a-zA-Z0-9._\-/]#', '', $path );
1404 + return trim( (string) $path, '/' );
1405 + }
1406 +
1407 + /**
1408 + * Static-tree root for the current site: `<host>` plus the blog's real
1409 + * path. Mirrors store_static()'s layout so a scoped purge deletes
1410 + * exactly this blog's pages.
1411 + */
1412 + public static function current_static_scope(): string {
1413 + // Same switch_to_blog() caveat as current_host_dir() — see current_host().
1414 + // Keep the port folded into the segment exactly as store_static() does.
1415 + $dir = self::static_host_dir( self::current_host() );
1416 + if ( '' === $dir ) {
1417 + $dir = 'default';
1418 + }
1419 + $path = self::site_path_raw();
1420 + return '' === $path ? $dir : $dir . '/' . $path;
1421 + }
1422 +
1423 + /**
1424 + * The bucket for the CURRENT request. Never empty, so an entry is never
1425 + * written to the tree root (which is what the unscoped sweeps used to
1426 + * delete indiscriminately).
1427 + */
1428 + public static function current_host_dir(): string {
1429 + $host = self::current_host();
1430 + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
1431 + return self::site_bucket( $host, $uri );
1432 + }
1433 +
1434 + /**
1435 + * The host the CURRENT blog is served from.
1436 + *
1437 + * Deliberately NOT just $_SERVER['HTTP_HOST']: inside a
1438 + * switch_to_blog() the request header still names whichever site is
1439 + * serving the admin screen, while the cache entries we want belong to
1440 + * the switched-to blog. On a subdomain network the host IS the bucket,
1441 + * so reading the header there would make Pro's per-site "purge this
1442 + * site" button clear the network admin's own cache instead — the very
1443 + * bug this scoping exists to fix, surviving in one topology.
1444 + *
1445 + * get_blog_details() follows the switch, so prefer it whenever we are
1446 + * on multisite, and fall back to the request header otherwise.
1447 + */
1448 + public static function current_host(): string {
1449 + if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_blog_details' ) ) {
1450 + $details = get_blog_details();
1451 + if ( $details && ! empty( $details->domain ) ) {
1452 + return (string) $details->domain;
1453 + }
1454 + }
1455 +
1456 + if ( isset( $_SERVER['HTTP_HOST'] ) ) {
1457 + return sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) );
1458 + }
1459 +
1460 + /*
1461 + * No request header — WP-CLI, or WP-Cron driven by system cron.
1462 + *
1463 + * Returning '' here made the bucket resolve to the literal `default`
1464 + * while HTTP requests were writing to `<host>/`, so a scheduled purge
1465 + * swept an empty directory and reported success, and get_stats()
1466 + * reported 0 cached pages on a site with a full cache. That is the
1467 + * normal setup on any host running DISABLE_WP_CRON, which is most of
1468 + * them. Fall back to the site's own registered host. (QA D4 on #166)
1469 + */
1470 + if ( function_exists( 'home_url' ) ) {
1471 + $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.
1472 + if ( is_array( $parts ) && ! empty( $parts['host'] ) ) {
1473 + return (string) $parts['host'];
1474 + }
1475 + }
1476 +
1477 + return '';
1478 + }
1479 +
1480 + /**
1481 + * Ensure the current site's cache directory exists, with the silence
1482 + * index in both it and the shared root. Returns the directory.
1483 + */
1484 + public static function ensure_host_dir(): string {
1485 + $dir = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
1486 + if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
1487 + wp_mkdir_p( XSPEED_CACHE_DIR );
1488 + self::write_silence( XSPEED_CACHE_DIR );
1489 + }
1490 + if ( ! file_exists( $dir ) ) {
1491 + wp_mkdir_p( $dir );
1492 + self::write_silence( $dir );
1493 + }
1494 + return $dir;
1495 + }
1496 +
463 1497 public static function cache_file_for( $key ) {
464 - return XSPEED_CACHE_DIR . '/' . $key . '.html';
1498 + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.html';
465 1499 }
466 1500
467 1501 /**
468 1502 * If a precompressed Brotli sibling (`<file>.br`) exists and the client
@@ -493,8 +1527,11 @@
493 1527 $br = $file . '.br';
494 1528 if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) {
495 1529 return null;
496 1530 }
1531 + if ( ! self::brotli_sibling_is_usable( $file, $br ) ) {
1532 + return null; // fall through to the plain .html
1533 + }
497 1534 header( 'Content-Encoding: br' );
498 1535 header( 'Vary: Accept-Encoding', false );
499 1536 // The byte length changes for the compressed body — drop any
500 1537 // Content-Length the caller may have set so the stream isn't
@@ -503,8 +1540,217 @@
503 1540 return $br;
504 1541 }
505 1542
506 1543 /**
1544 + * Is a precompressed `.br` sibling safe to serve?
1545 + *
1546 + * Existence is not enough. The sibling is written with a plain
1547 + * file_put_contents() — no atomic rename — so a crash, a full disk, or a
1548 + * read that races the write leaves a TRUNCATED file behind. Serving that
1549 + * with `Content-Encoding: br` hands the browser a stream it cannot
1550 + * inflate: it renders nothing at all (document.body is null) and the
1551 + * navigation can hang. A 16-byte .br for a 172KB page reproduces it
1552 + * exactly. (#286)
1553 + *
1554 + * Brotli has no magic number, and no byte-level marker distinguishes a
1555 + * truncated stream from a short valid one (the ISLAST bit is bit-packed,
1556 + * not byte-aligned). So this checks only what CAN be known by stat:
1557 + *
1558 + * - Not empty. A zero-byte sibling is unambiguously broken.
1559 + * - Not older than the HTML. A stale sibling would serve the PREVIOUS
1560 + * revision of the page under the current entry's ETag.
1561 + *
1562 + * A size-RATIO floor was tried here and removed. Brotli's ratio is
1563 + * unbounded on repetitive input: a ~1 MB page of table rows or a product
1564 + * grid — the ordinary shape of a big generated page — compresses to
1565 + * about 0.04%, so a 2% floor rejected a perfectly good sibling and sent
1566 + * visitors the uncompressed page instead, silently. Measured: 963 KB of
1567 + * repeated markup → 89 bytes at q5 (0.009%). No floor can separate
1568 + * "impossibly small" from "extremely compressible" for arbitrary HTML.
1569 + *
1570 + * Truncation is prevented at the WRITE side instead — see
1571 + * write_atomic(), which the Brotli writer uses so a partial file is
1572 + * never visible under the final name. Detection at read time cannot be
1573 + * made correct; not creating the bad file can.
1574 + *
1575 + * Anything suspicious returns false and the caller streams the plain
1576 + * .html — slower, always correct. Serving an uninflatable body is worse
1577 + * than serving no compression at all.
1578 + *
1579 + * @param string $file Absolute path to the .html cache file.
1580 + * @param string $br Absolute path to its .br sibling.
1581 + * @return bool True when the sibling may be served.
1582 + */
1583 + /**
1584 + * Write a cache sidecar so a partial file is never visible.
1585 + *
1586 + * `file_put_contents()` truncates the target and then fills it, so any
1587 + * reader arriving mid-write — or any crash, full disk, or killed worker
1588 + * — leaves a SHORT file under the real name. For HTML that degrades to a
1589 + * clipped page; for a `.br` sibling it is worse, because a truncated
1590 + * brotli stream is not a short page but an UNINFLATABLE one: the browser
1591 + * renders nothing at all and the navigation can hang.
1592 + *
1593 + * Writing to a unique temp file in the same directory and renaming is
1594 + * atomic on POSIX, so readers see either the previous complete file or
1595 + * the new complete file, never a partial one. This is the half of #286
1596 + * that is actually fixable — a read-time heuristic cannot tell a
1597 + * truncated brotli stream from a very small valid one, but a truncated
1598 + * file that never becomes visible needs no detection.
1599 + *
1600 + * @param string $path Absolute destination path.
1601 + * @param string $contents Bytes to write.
1602 + * @return bool True when the destination now holds exactly $contents.
1603 + */
1604 + public static function write_atomic( string $path, string $contents ): bool {
1605 + $dir = dirname( $path );
1606 + // 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.
1607 + if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) {
1608 + return false;
1609 + }
1610 +
1611 + // Same directory, so the rename stays on one filesystem — a rename
1612 + // across devices is a copy and loses atomicity.
1613 + $tmp = @tempnam( $dir, '.xspeed-tmp-' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a failure returns false and the caller skips the write.
1614 + if ( ! is_string( $tmp ) || '' === $tmp ) {
1615 + return false;
1616 + }
1617 +
1618 + // 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.
1619 + $written = @file_put_contents( $tmp, $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- handled by the length check below.
1620 +
1621 + // A short write is exactly the failure this function exists to
1622 + // prevent, so verify the byte count before publishing the file.
1623 + if ( false === $written || $written !== strlen( $contents ) ) {
1624 + @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own temp file; non-fatal.
1625 + return false;
1626 + }
1627 +
1628 + // tempnam() creates the file 0600; cache files must stay readable by
1629 + // the web server, which may run as a different user.
1630 + @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.
1631 +
1632 + // 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.
1633 + if ( ! @rename( $tmp, $path ) ) {
1634 + @unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup; non-fatal.
1635 + return false;
1636 + }
1637 +
1638 + return true;
1639 + }
1640 +
1641 + public static function brotli_sibling_is_usable( string $file, string $br ): bool {
1642 + $br_size = (int) @filesize( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a stat failure means "don't serve it", handled by the <= 0 check.
1643 + if ( $br_size <= 0 ) {
1644 + return false;
1645 + }
1646 +
1647 + $html_size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
1648 + if ( $html_size <= 0 ) {
1649 + return false;
1650 + }
1651 +
1652 + // A sibling older than the page it compresses is stale.
1653 + $br_mtime = (int) @filemtime( $br ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
1654 + $html_mtime = (int) @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- as above.
1655 + if ( $br_mtime > 0 && $html_mtime > 0 && $br_mtime < $html_mtime ) {
1656 + return false;
1657 + }
1658 +
1659 + // The writer recorded how many bytes it produced. Where that record
1660 + // exists, truncation is a certainty rather than an inference: a
1661 + // stream shorter than its own declared length cannot inflate, and
1662 + // one that matches was published whole. This is what a size ratio
1663 + // could never be — brotli's ratio is unbounded on repetitive input,
1664 + // so a 0.01% sibling of a generated page is genuinely valid.
1665 + //
1666 + // Absent for a sibling written before this version, or by an add-on
1667 + // that writes the file directly. That case keeps the checks above
1668 + // and no more, which is where a pre-existing truncated file on a
1669 + // live site still slips through — write_atomic() stops NEW ones,
1670 + // but it cannot retroactively vouch for what is already on disk.
1671 + $expected = self::brotli_expected_size( $br );
1672 + if ( $expected > 0 && $br_size !== $expected ) {
1673 + return false;
1674 + }
1675 +
1676 + return true;
1677 + }
1678 +
1679 + /**
1680 + * Path of the sidecar recording a `.br` sibling's complete byte count.
1681 + *
1682 + * Kept beside the sibling as `<file>.html.br.size` rather than folded
1683 + * into the entry's `.meta`: the static tree the web server serves has no
1684 + * `.meta` at all, and the two trees must answer this question the same
1685 + * way. Every path that deletes a `.br` deletes this with it.
1686 + *
1687 + * @param string $br Absolute path to the `.br` sibling.
1688 + * @return string Absolute path to its size sidecar.
1689 + */
1690 + public static function brotli_size_sidecar( string $br ): string {
1691 + return $br . '.size';
1692 + }
1693 +
1694 + /**
1695 + * The byte count the writer recorded for a `.br` sibling, or 0 when no
1696 + * record exists (a sibling predating this version, or written by an
1697 + * add-on that bypassed write_brotli_sibling()).
1698 + *
1699 + * @param string $br Absolute path to the `.br` sibling.
1700 + * @return int Expected size in bytes, or 0 when unknown.
1701 + */
1702 + public static function brotli_expected_size( string $br ): int {
1703 + $sidecar = self::brotli_size_sidecar( $br );
1704 + if ( ! is_file( $sidecar ) ) {
1705 + return 0;
1706 + }
1707 + // 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.
1708 + $raw = @file_get_contents( $sidecar ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable sidecar means "unknown", handled by the cast below.
1709 + return max( 0, (int) trim( (string) $raw ) );
1710 + }
1711 +
1712 + /**
1713 + * Publish a `.br` sibling together with the record of its own length.
1714 + *
1715 + * The single writer every producer of a `.br` should route through — the
1716 + * Pro Brotli module included. Publishing the body atomically stops a
1717 + * truncated file from ever becoming visible; recording the byte count
1718 + * lets the serve path prove wholeness for the files that already exist
1719 + * on disk when this ships.
1720 + *
1721 + * Order matters: the size sidecar is removed first and written last, so
1722 + * a reader arriving mid-update sees "no record" (checks above still
1723 + * apply) rather than the previous body's length against the new body.
1724 + *
1725 + * @param string $br Absolute path to the `.br` sibling to write.
1726 + * @param string $contents Compressed bytes.
1727 + * @return bool True when both the sibling and its size record are in place.
1728 + */
1729 + public static function write_brotli_sibling( string $br, string $contents ): bool {
1730 + $sidecar = self::brotli_size_sidecar( $br );
1731 + if ( is_file( $sidecar ) ) {
1732 + wp_delete_file( $sidecar );
1733 + }
1734 +
1735 + if ( ! self::write_atomic( $br, $contents ) ) {
1736 + return false;
1737 + }
1738 +
1739 + if ( self::write_atomic( $sidecar, (string) strlen( $contents ) ) ) {
1740 + return true;
1741 + }
1742 +
1743 + // The body landed but its length did not. That sibling is servable
1744 + // and unguarded — exactly the file this function exists to prevent —
1745 + // and the caller has no way to know. Withdraw it: a MISS costs one
1746 + // uncompressed response, where an unguarded sibling can cost a blank
1747 + // page for as long as the entry lives.
1748 + wp_delete_file( $br );
1749 + return false;
1750 + }
1751 +
1752 + /**
507 1753 * Sidecar metadata file for a cache entry. Holds response bits the HIT
508 1754 * path must replay — Content-Type (cached feeds → application/rss+xml,
509 1755 * sitemaps → text/xml) and status (a cached 404 must serve 404, not
510 1756 * 200). JSON, one tiny file per entry, written only when there's
@@ -510,17 +1756,19 @@
510 1756 * 200). JSON, one tiny file per entry, written only when there's
511 1757 * something non-default to replay.
512 1758 */
513 1759 public static function cache_meta_for( $key ) {
514 - return XSPEED_CACHE_DIR . '/' . $key . '.meta';
1760 + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.meta';
515 1761 }
516 1762
517 1763 /**
518 1764 * Read the .meta sidecar for a cache entry as an array, or [] if none.
519 - * Keys: 'content_type' (string), 'status' (int). Used on the HIT path
520 - * to replay them before streaming the file.
1765 + * Keys: 'content_type' (string), 'status' (int), 'ttl' (int seconds).
1766 + * Used on the HIT path to replay content-type/status before streaming
1767 + * the file, and by Cache_GC to age an entry by its own TTL rather than
1768 + * the global one — hence public.
521 1769 */
522 - private static function read_meta( $key ): array {
1770 + public static function read_meta( $key ): array {
523 1771 $meta_file = self::cache_meta_for( $key );
524 1772 if ( ! file_exists( $meta_file ) ) {
525 1773 return array();
526 1774 }
@@ -586,9 +1834,45 @@
586 1834 * @param int $max_age Computed max-age in seconds.
587 1835 */
588 1836 $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
589 1837
590 - return ( time() - filemtime( $file ) ) > $max_age;
1838 + // Honour the per-entry TTL the .meta sidecar carries, when it is
1839 + // SHORTER than what we just resolved. The sidecar records the TTL
1840 + // this specific entry was written under — a nonce cap (#236), a Pro
1841 + // feed/404 expiry — and the drop-in already reads it. is_expired()
1842 + // did not, so on the engine path a capped entry was still served for
1843 + // the full configured lifetime: exactly the stale nonce the cap
1844 + // exists to prevent. Only ever shortens, so an entry can never be
1845 + // kept alive past the configured maximum by a stale sidecar.
1846 + // Derive the sidecar from the FILE we were handed rather than
1847 + // recomputing cache_key(): callers legitimately ask about an entry
1848 + // that isn't the current request's (Cache_GC sweeps, Pro's warmer),
1849 + // and cache_key() would answer for the wrong one — besides needing a
1850 + // request context this function has no business requiring.
1851 + $meta_file = preg_replace( '/\.html$/', '.meta', (string) $file );
1852 + if ( is_string( $meta_file ) && $meta_file !== $file && is_readable( $meta_file ) ) {
1853 + // 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.
1854 + $raw = file_get_contents( $meta_file );
1855 + $decoded = is_string( $raw ) ? json_decode( $raw, true ) : null;
1856 + if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) {
1857 + $entry_ttl = (int) $decoded['ttl'];
1858 + if ( $entry_ttl > 0 && ( $max_age < 1 || $entry_ttl < $max_age ) ) {
1859 + $max_age = $entry_ttl;
1860 + }
1861 + }
1862 + }
1863 +
1864 + // A missing file is "expired" — the caller should re-render. Guard
1865 + // filemtime() rather than letting it warn: callers legitimately ask
1866 + // about a file that isn't there (Pro's predictive warmer probes for
1867 + // freshness, and Cache_GC can collect an entry between the check and
1868 + // the read), and on a site with WP_DEBUG the warning is noise.
1869 + $mtime = file_exists( $file ) ? filemtime( $file ) : false;
1870 + if ( false === $mtime ) {
1871 + return true;
1872 + }
1873 +
1874 + return ( time() - (int) $mtime ) > $max_age;
591 1875 }
592 1876
593 1877 /**
594 1878 * Accumulator for the full response body across all output-handler phases.
@@ -673,19 +1957,74 @@
673 1957 $buffer = $full;
674 1958 }
675 1959 }
676 1960
677 - if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
678 - wp_mkdir_p( XSPEED_CACHE_DIR );
679 - self::write_silence( XSPEED_CACHE_DIR );
1961 + // AFTER minification on purpose — the HTML minifier strips comments,
1962 + // so signing earlier would erase the signature from every minified
1963 + // page. Baked into the cached bytes so all three serve paths (nginx
1964 + // static rewrite, .htaccess, the PHP drop-in) carry it identically.
1965 + $full = self::signed( $full );
1966 + if ( $single_chunk ) {
1967 + $buffer = $full;
680 1968 }
681 1969
682 - // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
683 - // where $key comes from md5() — guaranteed to be exactly 32 lowercase
684 - // hex chars, so no traversal sequence ('..', '/', null byte, etc.)
685 - // can appear. The write is therefore always inside XSPEED_CACHE_DIR.
686 - $key = self::cache_key();
1970 + // Per-site directory — see ensure_host_dir(). (#6)
1971 + self::ensure_host_dir();
1972 +
1973 + // Path safety: cache_file_for() builds
1974 + // `XSPEED_CACHE_DIR . '/' . <host> . '/' . $key . '.html'` where $key
1975 + // comes from md5() — guaranteed to be exactly 32 lowercase hex chars —
1976 + // and <host> is filtered by host_dir() to [A-Za-z0-9.-] with leading
1977 + // dots trimmed, so no traversal sequence ('..', '/', null byte, etc.)
1978 + // can appear in either segment. The write is therefore always inside
1979 + // XSPEED_CACHE_DIR.
1980 + $key = self::cache_key();
1981 +
1982 + // Query-string gate. should_cache() waved this request through
1983 + // because every param is on the ignored_query_params allow-list, and
1984 + // cache_key() drops the query so reads share the canonical entry.
1985 + // That sharing is safe on READ but not on WRITE: this response was
1986 + // rendered WITH the params, and WordPress reflects REQUEST_URI into
1987 + // form actions, share links and plugin smart tags — so storing it
1988 + // would serve an attacker-chosen variant under the clean URL for the
1989 + // whole TTL (#241).
1990 + //
1991 + // This sits BELOW the transforms deliberately. Returning above them
1992 + // also skipped xspeed_cache_final_html, and every listener disables
1993 + // its own fallback ob_start() when the page cache is on precisely
1994 + // because that filter is the shared transport — so a visitor
1995 + // arriving on ?utm_source=… was served HTML with no LCP preload, no
1996 + // preconnect, no CDN rewrite, no CSS combine and no HTML minify.
1997 + // That is the ad-click and newsletter cohort getting the least
1998 + // optimised page on the site. Only the WRITE is skipped, which is
1999 + // what this fix was always meant to do — and it is where the
2000 + // deferred writer has always placed its own copy of the guard.
2001 + if ( self::query_string_blocks_write() ) {
2002 + return $buffer;
2003 + }
687 2004 $file = self::cache_file_for( $key );
2005 +
2006 + // A render-time translation plugin (TranslatePress) wraps our buffer,
2007 + // so the bytes we hold here are still UNTRANSLATED — its callback has
2008 + // not run yet, and writing now would cache English under a French URL
2009 + // and bake in its internal #TRPLINKPROCESSED markers. Hand off to
2010 + // shutdown, where the outer buffer has already translated, and let
2011 + // the pass-through below deliver this request untouched.
2012 + if ( self::translation_plugin_active() ) {
2013 + self::$deferred_key = $key;
2014 + // Reaching here means finalize_buffer() ran to completion: the
2015 + // status gate passed, should_cache() said yes, and PHP handed us
2016 + // the whole buffer. A wp_die() or exit() mid-render unwinds the
2017 + // buffer stack WITHOUT calling this callback, so the flag stays
2018 + // false and the shutdown writer declines — see the guard there.
2019 + self::$render_completed = true;
2020 + // A PHP shutdown function, not a WP `shutdown` action: this must
2021 + // run after the output-buffer stack has unwound, and WP's
2022 + // shutdown action fires while our outer buffer is still open.
2023 + register_shutdown_function( array( __CLASS__, 'write_deferred_translated_cache' ) );
2024 + return $buffer;
2025 + }
2026 +
688 2027 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache writes happen on frontend requests where it's unavailable.
689 2028 file_put_contents( $file, $full, LOCK_EX );
690 2029
691 2030 /**
@@ -707,9 +2046,9 @@
707 2046 // Persist a non-default Content-Type so the HIT path can replay it
708 2047 // (cached feeds must serve application/rss+xml, not text/html).
709 2048 // Only written when the response set a content-type other than
710 2049 // the HTML default — pages don't pay for an extra file.
711 - self::write_meta( $key );
2050 + self::write_meta( $key, $full );
712 2051
713 2052 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
714 2053 // .htaccess rewrite block serves this file directly via the web
715 2054 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
@@ -742,13 +2081,149 @@
742 2081 * $uri has its query string stripped, null bytes removed, '..'
743 2082 * sequences collapsed, and after concatenation we verify the
744 2083 * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
745 2084 * any write. Anything off the happy path returns silently.
2085 + *
2086 + * INVARIANT — the static tree is keyed by `{host}{path}` and NOTHING
2087 + * else, and both generated rewrites refuse any request that carries a
2088 + * query string at all (`RewriteCond %{QUERY_STRING} ^$` on Apache,
2089 + * `if ($args)` in nginx_snippet()). So a response may only be stored
2090 + * here when cache_key() adds no discriminator beyond `{host}{path}`:
2091 + * a query-keyed entry can never be *served* from here, only mis-served
2092 + * as the bare path. Any future opt-in that folds a query param into the
2093 + * key needs a guard below, exactly like the search one.
746 2094 */
2095 + /**
2096 + * Transient holding the most recent static-tree refusal.
2097 + *
2098 + * Short-lived on purpose: it describes what the last cacheable render
2099 + * actually did, so a stale entry would keep warning about a page whose
2100 + * nonces have since been removed. A site that still refuses simply
2101 + * rewrites it on the next render. (#372)
2102 + */
2103 + private const STATIC_SKIP_TRANSIENT = 'xspeed_static_skip';
2104 +
2105 + /**
2106 + * Remember why a page was kept out of the static tree, for Health.
2107 + *
2108 + * Records the URL, the reason, and — for the nonce case — the distinct
2109 + * nonce KEYS found, which is what makes the finding actionable: the names
2110 + * (`eael_login_nonce`, `post_grid_pagination_nonce`, …) trace straight back
2111 + * to the plugin emitting them, and it is usually a widget the site does not
2112 + * use on that page. Only key names are kept, never the nonce values.
2113 + *
2114 + * @param string $reason Machine-readable refusal reason.
2115 + * @param string $html The response, for extracting the nonce keys.
2116 + */
2117 + private static function note_static_skip( string $reason, string $html = '' ): void {
2118 + if ( ! function_exists( 'set_transient' ) ) {
2119 + return;
2120 + }
2121 +
2122 + $keys = array();
2123 + if ( 'nonce' === $reason && '' !== $html ) {
2124 + // Must recognise the SAME shapes response_has_nonce() refuses on,
2125 + // or a page is skipped and reported with no keys at all — which is
2126 + // most of them, since the plain `name="_wpnonce"` form field is the
2127 + // commonest shape by far and only the JSON one was handled here.
2128 + // The keys are the actionable half of the message, so a mismatch
2129 + // leaves the admin with bad news and nothing to act on.
2130 + //
2131 + // Both alternations capture the KEY only: each value pattern sits
2132 + // outside the capture group, so a nonce secret can never be stored.
2133 + $found = array();
2134 + if ( preg_match_all( '/name=["\']([a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*)["\']/i', $html, $m ) ) {
2135 + $found = array_merge( $found, $m[1] );
2136 + }
2137 + if ( preg_match_all( '/["\']([a-z0-9_\-]*nonce[a-z0-9_\-]*)["\']\s*:\s*["\'][a-f0-9]{8,}["\']/i', $html, $m ) ) {
2138 + $found = array_merge( $found, $m[1] );
2139 + }
2140 + // The query-arg shape (`?_wpnonce=…`) has no key name to report
2141 + // beyond the literal, so name it explicitly rather than reporting
2142 + // nothing for a page that was genuinely refused.
2143 + if ( preg_match( '/[?&]_wpnonce=/i', $html ) ) {
2144 + $found[] = '_wpnonce';
2145 + }
2146 + $keys = array_slice( array_values( array_unique( $found ) ), 0, 10 );
2147 + }
2148 +
2149 + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
2150 +
2151 + set_transient(
2152 + self::STATIC_SKIP_TRANSIENT,
2153 + array(
2154 + 'reason' => $reason,
2155 + 'url' => (string) strtok( $uri, '?' ),
2156 + 'keys' => $keys,
2157 + 'at' => time(),
2158 + ),
2159 + HOUR_IN_SECONDS
2160 + );
2161 + }
2162 +
2163 + /**
2164 + * The most recent static-tree refusal, or an empty array when there is none.
2165 + *
2166 + * @return array{reason:string,url:string,keys:string[],at:int}|array{}
2167 + */
2168 + public static function last_static_skip(): array {
2169 + $stored = function_exists( 'get_transient' ) ? get_transient( self::STATIC_SKIP_TRANSIENT ) : false;
2170 + return is_array( $stored ) && ! empty( $stored['reason'] ) ? $stored : array();
2171 + }
2172 +
747 2173 private static function store_static( string $html ): void {
2174 + // Search results are keyed by term in cache_key() (`|s=<term>`) but
2175 + // carry the *path* of whatever URL was searched from — for the usual
2176 + // `/?s=<term>` that path is `/`. Writing them here would file the
2177 + // results page as `{host}/index.html` and the web server would serve
2178 + // it to every visitor as the homepage: an unauthenticated visitor
2179 + // poisons the front page with one request. Searches stay on the
2180 + // drop-in, which replays the term-keyed entry correctly. (#191)
2181 + //
2182 + // This is a superset of the query-string check the exclusion gate
2183 + // does: it also covers `/?%73=<term>`, which decodes to the same
2184 + // search (the shape #109 fixed on the gate side).
2185 + if ( self::should_cache_search() ) {
2186 + return;
2187 + }
2188 +
2189 + // Same hazard for the allow-listed query params: store_static()
2190 + // strips the query and files the response under the bare path, which
2191 + // the web server then serves to every visitor of the clean URL with
2192 + // no PHP involved at all — so none of the engine's checks can catch
2193 + // it later (#241). The callers already gate on this, but the guard
2194 + // is repeated here because this tree is the most dangerous of the
2195 + // three write sites and must not depend on its callers.
2196 + if ( self::request_has_query_string() ) {
2197 + return;
2198 + }
2199 +
2200 + // A nonce-bearing page is served here with NO PHP: no TTL check and
2201 + // no .meta replay, so the per-entry cap that keeps the drop-in honest
2202 + // (#236) cannot reach a file once it is written. Only Cache_GC removes
2203 + // it, and until it does the page hands every visitor the same nonce —
2204 + // which, once that nonce dies, breaks every anonymous form on it.
2205 + //
2206 + // Refusing outright was the safe answer, and it cost every
2207 + // nonce-bearing page the static tree entirely: a site whose homepage
2208 + // carries one unused login nonce ran PHP on every request forever.
2209 + // The nonce's own remaining life is the better gate — the page is
2210 + // written and its deadline recorded below for GC to enforce.
2211 + //
2212 + // A nonce we cannot put a clock on is still refused, and that refusal
2213 + // is still recorded: it stays completely silent otherwise, because the
2214 + // drop-in answers HIT while Health reports the fast path active from a
2215 + // probe that writes its OWN file and never proves real pages reach the
2216 + // tree. (#372)
2217 + $nonce_ttl = self::response_has_nonce( $html ) ? self::nonce_capped_ttl( $html, 0 ) : 0;
2218 + if ( self::response_has_nonce( $html ) && $nonce_ttl < 1 ) {
2219 + self::note_static_skip( 'nonce', $html );
2220 + return;
2221 + }
2222 +
748 2223 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
749 2224 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
750 - $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
2225 + $host = self::static_host_dir( $host );
751 2226 $uri = str_replace( "\0", '', $uri );
752 2227 $uri = (string) strtok( $uri, '?' );
753 2228 if ( '' === $host || '' === $uri ) {
754 2229 return;
@@ -779,8 +2254,18 @@
779 2254 }
780 2255 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Same rationale as the flat-hash cache write above: WP_Filesystem isn't available on frontend requests, and the cache write must happen during shutdown.
781 2256 $written = file_put_contents( $file, $html, LOCK_EX );
782 2257
2258 + // A nonce-bearing page expires on the nonce's schedule, not the site's.
2259 + // Nothing reads this file at serve time — the web server hands over
2260 + // index.html without PHP — so the deadline is recorded beside it for
2261 + // GC, which is the only thing that can enforce it. Written before the
2262 + // action below so a listener that shells out cannot race the sweep.
2263 + if ( false !== $written && $nonce_ttl > 0 ) {
2264 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- same rationale as the write above.
2265 + file_put_contents( $dir . '/.xspeed-expires', (string) ( time() + $nonce_ttl ), LOCK_EX );
2266 + }
2267 +
783 2268 if ( false !== $written ) {
784 2269 /**
785 2270 * Fires after a static cache file (index.html) is written.
786 2271 *
@@ -832,9 +2317,236 @@
832 2317 }
833 2318 return true;
834 2319 }
835 2320
836 - private static function write_meta( string $key ): void {
2321 + /**
2322 + * Append the cache signature comment to a finished page.
2323 + *
2324 + * The plugin's one outward version signal: external scanners (the
2325 + * xspeedcache.com speed test among them) read it to detect xSpeed and
2326 + * its version on a cached page, the way other cache plugins sign their
2327 + * output. Callers apply it AFTER HTML minification — the minifier strips
2328 + * comments — and before every cache write, so all serve paths carry the
2329 + * same bytes.
2330 + *
2331 + * The generation time is baked in here, at write time, in UTC. It is the
2332 + * moment the cached bytes were produced — NOT the moment they were served
2333 + * — because all three serve paths replay the same stored file, and two of
2334 + * them (the nginx/`.htaccess` static rewrite) run no PHP at all and so
2335 + * could never stamp a serve-time value. Reading the age of a page is the
2336 + * point: `generated` plus the current clock tells you how stale it is.
2337 + * `gmdate()` (not `current_time()`) keeps the value comparable across
2338 + * sites regardless of the configured timezone.
2339 + *
2340 + * @param string $html Finished page HTML.
2341 + * @return string HTML with the signature appended (or unchanged when a
2342 + * filter removed it).
2343 + */
2344 + private static function signed( string $html ): string {
2345 + $version = defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : '';
2346 + $generated = gmdate( 'Y-m-d H:i:s' ) . ' UTC';
2347 + // The literal ' | xspeedcache.com' must survive intact, and what
2348 + // precedes it is where an edition suffix lands: Pro appends itself by
2349 + // str_replace()-ing on that exact token
2350 + // (Pro_Plugin::sign_cache_signature). So the stamp goes AFTER it —
2351 + // placed before, it sits between the version and the anchor and
2352 + // composes as "generated <date> + Pro v1.1.3".
2353 + $signature = sprintf(
2354 + '<!-- Page cached by xSpeed Cache v%s | xspeedcache.com | generated %s -->',
2355 + $version,
2356 + $generated
2357 + );
2358 +
2359 + /**
2360 + * Filter: xspeed_cache_signature
2361 + *
2362 + * The HTML comment appended to every cached page. Add-ons append
2363 + * their own edition/version here; white-label setups return '' to
2364 + * remove the comment entirely. Must remain a valid HTML comment (or
2365 + * an empty string) — it ships inside the cached body.
2366 + *
2367 + * @param string $signature The signature comment.
2368 + * @param string $version The plugin version baked into it.
2369 + * @param string $generated The write-time timestamp baked into it,
2370 + * formatted `Y-m-d H:i:s UTC`.
2371 + */
2372 + $signature = (string) apply_filters( 'xspeed_cache_signature', $signature, $version, $generated );
2373 + if ( '' === trim( $signature ) ) {
2374 + return $html;
2375 + }
2376 + return $html . "\n" . $signature;
2377 + }
2378 +
2379 + /**
2380 + * Does this response carry a WordPress nonce?
2381 + *
2382 + * Anonymous nonces depend only on the tick (user 0, empty session
2383 + * token), so they are identical for every visitor — which is exactly why
2384 + * they cache "successfully" and then fail silently once the tick moves.
2385 + *
2386 + * Matches any form field whose NAME contains "nonce" — `_wpnonce`,
2387 + * `_wpnonce_<action>`, Tutor's `_tutor_nonce`, CF7's `_wpcf7_nonce` and
2388 + * WooCommerce's `woocommerce-add-to-cart-nonce` (which does NOT start
2389 + * with an underscore, so a `_`-anchored pattern misses it) — plus the
2390 + * `_wpnonce=` form used in nonce-bearing URLs. Deliberately keyed on
2391 + * `name=` so prose, CSS classes and data attributes don't false-positive.
2392 + *
2393 + * @param string $html Rendered response body.
2394 + */
2395 + public static function response_has_nonce( string $html ): bool {
2396 + if ( '' === $html ) {
2397 + return false;
2398 + }
2399 +
2400 + /*
2401 + * Three shapes, because a nonce reaches the page in three ways:
2402 + *
2403 + * 1. A form field name — `_wpnonce`, `woocommerce-login-nonce`, and
2404 + * the GROUPED names form builders emit (`data[_wpnonce]`,
2405 + * `frm[nonce]`). The character class deliberately allows `[` and
2406 + * `]` so grouping does not hide the field: form builders are
2407 + * exactly the kind of plugin #236 is about, and a missed page
2408 + * keeps the old broken behaviour silently.
2409 + * 2. A query argument (`?_wpnonce=`) on a link.
2410 + * 3. A nonce handed to the page's own scripts rather than placed in
2411 + * a visible form — `wp_localize_script()` output and inline JSON
2412 + * both land as a `"nonce":"…"`-shaped pair.
2413 + */
2414 + return 1 === preg_match(
2415 + '/(name=["\'][a-z0-9_\-\[\]]*nonce[a-z0-9_\-\[\]]*["\']'
2416 + . '|[?&]_wpnonce='
2417 + . '|["\'][a-z0-9_\-]*nonce[a-z0-9_\-]*["\']\s*:\s*["\'][a-f0-9]{8,}["\'])/i',
2418 + $html
2419 + );
2420 + }
2421 +
2422 + /**
2423 + * The TTL (seconds) a response may be cached for, capped to the nonce
2424 + * lifetime when it carries one.
2425 + *
2426 + * WordPress nonces are valid for at most `nonce_life` — 24h by default —
2427 + * because wp_verify_nonce() accepts the current tick and the previous
2428 + * one. Our own lifetime maximum is 720h and the shipped Aggressive
2429 + * preset is 168h, so on any site configured above 24h every anonymous
2430 + * front-end form carried a DEAD nonce for the majority of the cache's
2431 + * life and every submission was rejected — with the other plugin's error
2432 + * string ("Nonce not matched"), so the report never reached us (#236).
2433 + *
2434 + * `nonce_life` is the MAXIMUM a nonce can live, not the minimum, so it
2435 + * is the wrong number to cap with. wp_nonce_tick() buckets time into
2436 + * `nonce_life / 2` slices; a nonce minted x seconds into its bucket is
2437 + * valid for `nonce_life - x`, where x can be as large as a full bucket.
2438 + * Capping the entry at `nonce_life` therefore still served a dead nonce
2439 + * for up to half of every entry's life — 0-12h of each 24h entry,
2440 + * averaging 6h, re-rolled by every purge so it reads as intermittent.
2441 + * Capping at the guaranteed-valid remainder closes the window at every
2442 + * tick phase, at the cost of caching nonce-bearing pages for 12h rather
2443 + * than 24h.
2444 + *
2445 + * Capping is per-entry, so only nonce-bearing pages pay for it; the rest
2446 + * of the site keeps the configured lifetime.
2447 + *
2448 + * @param string $html Rendered response body.
2449 + * @param int $ttl Otherwise-resolved TTL in seconds.
2450 + * @return int TTL to actually use.
2451 + */
2452 + /**
2453 + * The nonce lifetime to cap against, in seconds.
2454 + *
2455 + * `nonce_life` is a TWO-argument filter in core:
2456 + *
2457 + * $nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action );
2458 + *
2459 + * Applying it with one argument is not merely incomplete — a callback
2460 + * that declares both parameters as required (the documented shape, and
2461 + * what a site branching per action must write) raises ArgumentCountError
2462 + * the moment we call it. That fatal lands in the shutdown cache write,
2463 + * so the visitor still sees a perfectly normal page while the sidecar is
2464 + * never written: the entry then keeps the FULL configured lifetime
2465 + * carrying a dead nonce, which is precisely the bug #236 set out to fix.
2466 + * Worse, the entry stays that way until a purge, even after the site
2467 + * removes whatever customised the lifetime.
2468 + *
2469 + * We are inspecting rendered markup, so we cannot know which action
2470 + * minted the nonce we found. Two consequences:
2471 + *
2472 + * 1. We pass `''` as the action. A per-action callback therefore sees
2473 + * the same "unknown action" value core itself passes when a nonce is
2474 + * created with no action, and can branch on it deliberately.
2475 + * 2. A page may carry nonces from SEVERAL actions with different
2476 + * lifetimes. The entry can only have one TTL, so the safe choice is
2477 + * the SHORTEST lifetime any action on the site resolves to — capping
2478 + * to a longer one would serve a dead nonce for the shorter action.
2479 + * Sites can narrow this with `xspeed_cache_nonce_life_actions`.
2480 + *
2481 + * @param string $html Response body being cached.
2482 + * @return int Nonce lifetime in seconds (0 = do not cap).
2483 + */
2484 + private static function nonce_life_seconds( string $html ): int {
2485 + /**
2486 + * Filter the nonce actions whose lifetimes are consulted when
2487 + * capping a cache entry.
2488 + *
2489 + * The default `''` is the "action unknown" case — we are reading
2490 + * rendered HTML, not minting a nonce. A site whose `nonce_life`
2491 + * callback shortens specific actions can list them here so the cap
2492 + * accounts for the shortest one that could appear on the page.
2493 + *
2494 + * @since 1.1.8
2495 + * @param string[] $actions Nonce actions to resolve.
2496 + * @param string $html The response body being cached.
2497 + */
2498 + $actions = (array) apply_filters( 'xspeed_cache_nonce_life_actions', array( '' ), $html );
2499 + if ( empty( $actions ) ) {
2500 + $actions = array( '' );
2501 + }
2502 +
2503 + $shortest = 0;
2504 + foreach ( $actions as $action ) {
2505 + // Both arguments, exactly as core passes them.
2506 + $life = (int) apply_filters( 'nonce_life', DAY_IN_SECONDS, (string) $action );
2507 + if ( $life < 1 ) {
2508 + continue;
2509 + }
2510 + if ( 0 === $shortest || $life < $shortest ) {
2511 + $shortest = $life;
2512 + }
2513 + }
2514 +
2515 + return $shortest;
2516 + }
2517 +
2518 + public static function nonce_capped_ttl( string $html, int $ttl ): int {
2519 + if ( ! self::response_has_nonce( $html ) ) {
2520 + return $ttl;
2521 + }
2522 +
2523 + $nonce_life = self::nonce_life_seconds( $html );
2524 + if ( $nonce_life < 1 ) {
2525 + return $ttl;
2526 + }
2527 +
2528 + // Half of nonce_life is the GUARANTEED-valid remainder — see above.
2529 + $guaranteed = max( 1, intdiv( $nonce_life, 2 ) );
2530 + $capped = ( $ttl > 0 ) ? min( $ttl, $guaranteed ) : $guaranteed;
2531 +
2532 + /**
2533 + * Filter the nonce-capped TTL for a cache entry.
2534 + *
2535 + * Escape hatch for a site whose nonce-shaped markup is decorative —
2536 + * return the uncapped $ttl to keep the configured lifetime. Most
2537 + * sites should leave this alone: serving a dead nonce breaks every
2538 + * anonymous form on the page.
2539 + *
2540 + * @param int $capped TTL after the nonce cap (seconds).
2541 + * @param int $ttl TTL before the cap (seconds).
2542 + * @param int $nonce_life Current nonce lifetime (seconds).
2543 + * @param string $html The response body being cached.
2544 + */
2545 + return (int) apply_filters( 'xspeed_cache_nonce_ttl_cap', $capped, $ttl, $nonce_life, $html );
2546 + }
2547 +
2548 + private static function write_meta( string $key, string $html = '' ): void {
837 2549 $content_type = '';
838 2550 foreach ( headers_list() as $header ) {
839 2551 if ( 0 === stripos( $header, 'content-type:' ) ) {
840 2552 $content_type = trim( substr( $header, strlen( 'content-type:' ) ) );
@@ -855,11 +2567,33 @@
855 2567 // call is_expired() / the xspeed_cache_max_age filter (they run before
856 2568 // WP), so persist the resolved max-age here whenever it differs from
857 2569 // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page
858 2570 // default. The fast paths read this to expire correctly. (FBS-82407)
859 - $opts = Settings_Manager::get( 'cache' );
860 - $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
861 - $ttl = (int) apply_filters( 'xspeed_cache_max_age', $default_ttl );
2571 + // This MUST resolve the TTL the same way is_expired() does, including
2572 + // the per-post override — the sidecar is the only channel that can
2573 + // carry a per-entry TTL into the pre-boot fast paths. Omitting the
2574 + // override here left an editor's "expire this post after 1h" visible
2575 + // to the engine but invisible to the drop-in, which kept serving the
2576 + // entry until the global lifetime elapsed (#240 AC#3). Handing the
2577 + // filter the same base as is_expired() also keeps a filter that
2578 + // SCALES its input (e.g. $max_age * 2) consistent between the two.
2579 + $opts = Settings_Manager::get( 'cache' );
2580 + $default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
2581 + $max_age = $default_ttl;
2582 + $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
2583 + if ( null !== $post_override ) {
2584 + $max_age = $post_override;
2585 + }
2586 + /** This filter is documented in includes/class-cache.php */
2587 + $ttl = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
2588 +
2589 + // A response carrying a nonce may not outlive that nonce, however
2590 + // long the site's configured lifetime is (#236). This runs AFTER the
2591 + // max-age filter so it caps whatever the filter resolved rather than
2592 + // being overridden by it — a Pro module lengthening the TTL must not
2593 + // be able to reintroduce a dead nonce.
2594 + $ttl = self::nonce_capped_ttl( $html, $ttl );
2595 +
862 2596 if ( $ttl > 0 && $ttl !== $default_ttl ) {
863 2597 $meta['ttl'] = $ttl;
864 2598 }
865 2599
@@ -893,17 +2627,747 @@
893 2627 * @param string $url Absolute URL, or site-relative path ("/about/").
894 2628 * @param string $cause Who asked, for the purge log. See purge_all().
895 2629 * @return int Number of cache files removed.
896 2630 */
2631 + /**
2632 + * Post types that are not "viewable" but ARE the presentation layer.
2633 + *
2634 + * `is_post_type_viewable()` answers "does this type have a front end of
2635 + * its own?" — which is the right question for `shop_order`, but the
2636 + * wrong one for the types core uses to render every OTHER page. A
2637 + * template part, a global-styles record, a navigation or a synced
2638 + * pattern has no permalink, yet editing one changes how the whole site
2639 + * looks. Gating purges on viewability alone meant a Site Editor save
2640 + * invalidated nothing and visitors kept the old design for the full
2641 + * TTL — up to 30 days at the maximum lifetime. (#270 regression)
2642 + *
2643 + * @return string[]
2644 + */
2645 + /**
2646 + * Could this post change alter anything an anonymous visitor had cached?
2647 + *
2648 + * Deleting one post fired a full purge for the post AND for every stored
2649 + * revision, because wp_delete_post() removes each revision through
2650 + * wp_delete_post() again and every one of those fires before_delete_post
2651 + * with post_type 'revision'. A post with six revisions cost seven whole-
2652 + * site sweeps, each one also announcing to LiteSpeed, purging the object
2653 + * cache network-wide on Redis, rewriting the stats option and running
2654 + * every xspeed_after_purge_all listener -- including Pro's Cloudflare
2655 + * purge, so seven API calls. Trashing cost two, via save_post and then
2656 + * trashed_post. (QA #348)
2657 + *
2658 + * The check lives here, ahead of purge_all(), so one early return covers
2659 + * the local sweep, the server-cache announcement and both action hooks.
2660 + * It deliberately does NOT live inside purge_all(): a manual, CLI or
2661 + * explicit caller asked for a purge and must get one.
2662 + *
2663 + * @param int $post_id Post being saved or removed.
2664 + * @param mixed $post Post object when the hook passed one.
2665 + * @param string $event 'save' or 'remove'.
2666 + */
2667 + private static function post_change_is_cacheable_content( $post_id, $post, string $event ): bool {
2668 + $post_id = (int) $post_id;
2669 +
2670 + // Only `save_post` and `before_delete_post` hand over a post object.
2671 + // `trashed_post` passes ( $post_id, $previous_status ) -- a STRING --
2672 + // so reaching for ->post_status on the second argument finds nothing
2673 + // and the status rule below would never fire. Read the row instead.
2674 + if ( ! is_object( $post ) && function_exists( 'get_post' ) ) {
2675 + $post = get_post( $post_id );
2676 + }
2677 +
2678 + $type = is_object( $post ) && isset( $post->post_type )
2679 + ? (string) $post->post_type
2680 + : (string) ( function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '' );
2681 + if ( '' === $type ) {
2682 + return false;
2683 + }
2684 +
2685 + // A revision is a copy of content nobody can browse to.
2686 + if ( 'revision' === $type ) {
2687 + return false;
2688 + }
2689 + if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
2690 + return false;
2691 + }
2692 + if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
2693 + return false;
2694 + }
2695 +
2696 + $status = is_object( $post ) && isset( $post->post_status ) ? (string) $post->post_status : '';
2697 +
2698 + // Clicking "Add New" inserts an auto-draft and fires save_post. There
2699 + // is nothing cached of a post that has never existed publicly.
2700 + if ( 'auto-draft' === $status ) {
2701 + return false;
2702 + }
2703 +
2704 + // Unknown/!viewable → nothing anonymous can see changed, UNLESS the
2705 + // type is itself part of how pages render (#270 regression).
2706 + if ( function_exists( 'is_post_type_viewable' )
2707 + && ! is_post_type_viewable( $type )
2708 + && ! in_array( $type, self::presentation_post_types(), true )
2709 + ) {
2710 + return false;
2711 + }
2712 +
2713 + // Deleting something that was already invisible changes no cached
2714 + // page: the transition that hid it purged at the time. This is what
2715 + // makes emptying a trash of a hundred posts cost nothing rather than
2716 + // a hundred full sweeps.
2717 + //
2718 + // It also collapses trashing to a single purge: wp_trash_post() fires
2719 + // save_post first, where the post is genuinely disappearing from
2720 + // listings and SHOULD purge, then trashed_post, by which point the
2721 + // row reads 'trash' and is skipped. A status we cannot read, on a row
2722 + // that still reports a type, means assume viewable -- erring toward
2723 + // an extra purge, never toward serving a stale page. A row that is
2724 + // gone entirely reports no type either and was refused above.
2725 + // 'inherit' is an INTERNAL status in core, so is_post_status_viewable()
2726 + // says no -- but an attachment carrying it is genuinely public. Judge
2727 + // those on the post type alone, which is already checked above.
2728 + if ( 'remove' === $event && '' !== $status && 'inherit' !== $status
2729 + && function_exists( 'is_post_status_viewable' )
2730 + && ! is_post_status_viewable( $status )
2731 + ) {
2732 + return false;
2733 + }
2734 +
2735 + return true;
2736 + }
2737 +
2738 + public static function presentation_post_types(): array {
2739 + $types = array(
2740 + 'wp_template', // Site Editor templates.
2741 + 'wp_template_part', // Header / footer / reusable parts.
2742 + 'wp_global_styles', // Colours, typography, spacing.
2743 + 'wp_navigation', // Navigation block menus.
2744 + 'nav_menu_item', // Classic menus.
2745 + 'wp_block', // Synced patterns / reusable blocks.
2746 + );
2747 +
2748 + /**
2749 + * Filter the non-viewable post types that still invalidate the cache.
2750 + *
2751 + * Add a type here when it has no front end of its own but changes
2752 + * how other pages render (a theme's own layout CPT, for example).
2753 + *
2754 + * @param string[] $types Post type slugs.
2755 + */
2756 + return (array) apply_filters( 'xspeed_presentation_post_types', $types );
2757 + }
2758 +
2759 + /**
2760 + * Describe a broad hook invalidation for response-cache adapters.
2761 + *
2762 + * Term, menu, theme and plugin changes can alter navigation, archives or
2763 + * markup across the site, so they require a site response-cache purge.
2764 + * Content saves also require this scope while their local operation is a
2765 + * complete bucket sweep.
2766 + *
2767 + * @return array{scope:string,intent:string,urls:array<int,string>}
2768 + */
2769 + private static function invalidation_for_hook( string $hook ): array {
2770 + $presentation = array(
2771 + 'switch_theme',
2772 + 'activated_plugin',
2773 + 'deactivated_plugin',
2774 + 'created_term',
2775 + 'edited_term',
2776 + 'delete_term',
2777 + 'wp_update_nav_menu',
2778 + );
2779 +
2780 + return array(
2781 + 'scope' => 'site',
2782 + 'intent' => in_array( $hook, $presentation, true ) ? 'presentation' : 'content',
2783 + 'urls' => array(),
2784 + );
2785 + }
2786 +
2787 +
2788 + /**
2789 + * save_post → purge only when the saved thing can appear on a cached page.
2790 + *
2791 + * Revisions and autosaves are never rendered. Non-viewable post types —
2792 + * WooCommerce's `shop_order` / `shop_order_placehold` / `shop_order_refund`
2793 + * / `shop_coupon`, Flamingo's `flamingo_inbound` (#229), Tutor's
2794 + * `tutor_enrolled` (#231) — are invisible to anonymous visitors, so
2795 + * writing one changes nothing that is cached. (#243)
2796 + *
2797 + * The exception is the presentation types above, which are non-viewable
2798 + * yet render every page — they are allow-listed BEFORE the viewability
2799 + * test. (#270 regression)
2800 + *
2801 + * @param int $post_id Saved post ID.
2802 + * @param \WP_Post $post Saved post object.
2803 + */
2804 + public static function on_save_post( $post_id, $post = null ): void {
2805 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'save' ) ) {
2806 + return;
2807 + }
2808 +
2809 + $post_type = is_object( $post ) && isset( $post->post_type )
2810 + ? (string) $post->post_type
2811 + : (string) get_post_type( $post_id );
2812 +
2813 + // Name the trigger rather than logging a bare numeric id — the old
2814 + // wiring passed the post ID into $cause, so the log read
2815 + // "Cache purged (46)" with no indication of what caused it. (#243)
2816 + $presentation = in_array( $post_type, self::presentation_post_types(), true );
2817 + self::purge_all(
2818 + 'post:' . $post_type,
2819 + null,
2820 + array(
2821 + // purge_all() sweeps every local response in this site's bucket.
2822 + // Without dependency tracking, the server cache must match that
2823 + // same boundary or unrelated pages can remain stale there.
2824 + 'scope' => 'site',
2825 + 'intent' => $presentation ? 'presentation' : 'content',
2826 + 'urls' => array(),
2827 + )
2828 + );
2829 + if ( class_exists( '\XSpeed\Minifier' ) ) {
2830 + Minifier::purge_minified();
2831 + }
2832 + }
2833 +
2834 + /**
2835 + * Delete/trash invalidation while the post type is still available.
2836 + * The local and server response-cache sweeps share the same site boundary.
2837 + *
2838 + * @param int $post_id Removed post ID.
2839 + * @param object|null $post Post object supplied by core when available.
2840 + */
2841 + public static function on_post_removed( $post_id, $post = null ): void {
2842 + if ( ! self::post_change_is_cacheable_content( $post_id, $post, 'remove' ) ) {
2843 + return;
2844 + }
2845 +
2846 + $post_type = is_object( $post ) && isset( $post->post_type )
2847 + ? (string) $post->post_type
2848 + : (string) get_post_type( $post_id );
2849 +
2850 + self::purge_all(
2851 + 'post-removed:' . $post_type,
2852 + null,
2853 + array(
2854 + 'scope' => 'site',
2855 + // Match on_save_post: a presentation type changes how pages
2856 + // render rather than what they say.
2857 + 'intent' => in_array( $post_type, self::presentation_post_types(), true )
2858 + ? 'presentation'
2859 + : 'content',
2860 + 'urls' => array(),
2861 + )
2862 + );
2863 + }
2864 +
2865 + /** Purge site responses when moderation changes visible comments. */
2866 + public static function on_comment_status( $comment_id, $status = '' ): void {
2867 + $comment = function_exists( 'get_comment' ) ? get_comment( (int) $comment_id ) : null;
2868 + $post_id = is_object( $comment ) && isset( $comment->comment_post_ID ) ? (int) $comment->comment_post_ID : 0;
2869 + if ( $post_id < 1 || ! function_exists( 'get_permalink' ) ) {
2870 + return;
2871 + }
2872 + $url = get_permalink( $post_id );
2873 + if ( ! is_string( $url ) || '' === $url ) {
2874 + return;
2875 + }
2876 + self::purge_all(
2877 + 'comment-status:' . (string) $status,
2878 + null,
2879 + array(
2880 + 'scope' => 'site',
2881 + 'intent' => 'content',
2882 + 'urls' => array(),
2883 + )
2884 + );
2885 + }
2886 +
2887 + /**
2888 + * comment_post → purge just the commented-on URL, and only once the
2889 + * comment is actually visible.
2890 + *
2891 + * A comment held for moderation changes nothing on the front end, and an
2892 + * approved one changes exactly one page — not the whole site. Product
2893 + * reviews are comments and guest reviews are on by default, so under the
2894 + * old wiring any visitor could flush a store's entire cache, repeatedly,
2895 + * with no account. (#243)
2896 + *
2897 + * @param int $comment_id New comment ID.
2898 + * @param int|string $approved 1 when approved, 0 when held, 'spam'.
2899 + * @param array $data Comment data.
2900 + */
2901 + public static function on_comment_post( $comment_id, $approved = 0, $data = array() ): void {
2902 + if ( 1 !== (int) $approved ) {
2903 + return;
2904 + }
2905 + $post_id = is_array( $data ) && isset( $data['comment_post_ID'] ) ? (int) $data['comment_post_ID'] : 0;
2906 + if ( $post_id < 1 ) {
2907 + return;
2908 + }
2909 + $url = get_permalink( $post_id );
2910 + if ( is_string( $url ) && '' !== $url ) {
2911 + self::purge_url( $url, 'comment' );
2912 + }
2913 + }
2914 +
2915 + /**
2916 + * user_register / profile_update → purge only when the user can author
2917 + * content that appears on the front end.
2918 + *
2919 + * A customer registering at checkout changes no rendered page, and cannot
2920 + * change an enqueued asset — so it must not purge the cache, and must not
2921 + * rebuild the minified bundles. Checkout account-creation fired FOUR
2922 + * full-site purges plus four purge_minified() runs in a single request
2923 + * before this gate. (#243)
2924 + *
2925 + * @param int $user_id Affected user.
2926 + */
2927 + public static function on_user_change( $user_id ): void {
2928 + $user = function_exists( 'get_userdata' ) ? get_userdata( (int) $user_id ) : null;
2929 + if ( ! $user ) {
2930 + return;
2931 + }
2932 +
2933 + // Only roles that can publish can change a rendered page. WooCommerce
2934 + // customers and WordPress subscribers cannot.
2935 + if ( ! user_can( $user, 'edit_posts' ) ) {
2936 + return;
2937 + }
2938 +
2939 + $url = get_author_posts_url( (int) $user_id );
2940 + if ( is_string( $url ) && '' !== $url ) {
2941 + self::purge_url( $url, 'user' );
2942 + }
2943 + }
2944 +
2945 + /**
2946 + * Purge everything a product's price / stock / sale state is rendered on.
2947 + *
2948 + * The product permalink is not enough: the shop archive and the product's
2949 + * category and tag archives render the same price and Sale! badge, and
2950 + * #242 reproduces all three going stale together.
2951 + *
2952 + * Accepts a product ID or a WC_Product. A variation resolves to its
2953 + * parent, which is the page that actually renders.
2954 + *
2955 + * @param int|object $product Product ID or WC_Product.
2956 + */
2957 + public static function purge_product( $product ): void {
2958 + $product_id = is_object( $product ) && method_exists( $product, 'get_id' )
2959 + ? (int) $product->get_id()
2960 + : (int) $product;
2961 + if ( $product_id < 1 ) {
2962 + return;
2963 + }
2964 +
2965 + // Variations are never rendered on their own URL.
2966 + $parent = (int) wp_get_post_parent_id( $product_id );
2967 + if ( $parent > 0 ) {
2968 + $product_id = $parent;
2969 + }
2970 +
2971 + $urls = array();
2972 +
2973 + $permalink = get_permalink( $product_id );
2974 + if ( is_string( $permalink ) && '' !== $permalink ) {
2975 + $urls[] = $permalink;
2976 + }
2977 +
2978 + // The shop archive.
2979 + if ( function_exists( 'wc_get_page_id' ) ) {
2980 + $shop_id = (int) wc_get_page_id( 'shop' );
2981 + if ( $shop_id > 0 ) {
2982 + $shop_url = get_permalink( $shop_id );
2983 + if ( is_string( $shop_url ) && '' !== $shop_url ) {
2984 + $urls[] = $shop_url;
2985 + }
2986 + }
2987 + }
2988 +
2989 + // Every category / tag archive this product appears on.
2990 + foreach ( array( 'product_cat', 'product_tag' ) as $taxonomy ) {
2991 + $terms = get_the_terms( $product_id, $taxonomy );
2992 + if ( ! is_array( $terms ) ) {
2993 + continue;
2994 + }
2995 + foreach ( $terms as $term ) {
2996 + $term_url = get_term_link( $term );
2997 + if ( is_string( $term_url ) && '' !== $term_url ) {
2998 + $urls[] = $term_url;
2999 + }
3000 + }
3001 + }
3002 +
3003 + // The front page, when it is not the shop page but still lists
3004 + // products (a block/shortcode storefront).
3005 + $front_id = (int) get_option( 'page_on_front' );
3006 + if ( $front_id > 0 ) {
3007 + $front_url = get_permalink( $front_id );
3008 + if ( is_string( $front_url ) && '' !== $front_url ) {
3009 + $urls[] = $front_url;
3010 + }
3011 + }
3012 +
3013 + /**
3014 + * Filter the URLs purged when a product changes.
3015 + *
3016 + * A storefront that renders products somewhere else — a landing page,
3017 + * a custom archive — can add its URLs here rather than falling back
3018 + * to purging the whole site.
3019 + *
3020 + * @param string[] $urls URLs about to be purged.
3021 + * @param int $product_id The product that changed.
3022 + */
3023 + $urls = (array) apply_filters( 'xspeed_purge_product_urls', $urls, $product_id );
3024 +
3025 + foreach ( array_unique( array_filter( $urls ) ) as $url ) {
3026 + self::purge_url( (string) $url, 'product' );
3027 + }
3028 + }
3029 +
3030 + /**
3031 + * Adapter for the WooCommerce stock actions that pass a product OBJECT
3032 + * where the status actions pass an ID.
3033 + *
3034 + * @param object $product WC_Product (or variation).
3035 + */
3036 + public static function purge_product_object( $product ): void {
3037 + self::purge_product( $product );
3038 + }
3039 +
3040 + /**
3041 + * Re-entry guard for the purge-event contract.
3042 + *
3043 + * A listener on `xspeed_after_purge_url` legitimately purges its own
3044 + * layer, and a server-cache or CDN adapter that calls back into xSpeed
3045 + * while doing so re-enters this method — unbounded, because each pass
3046 + * looks like a fresh purge.
3047 + *
3048 + * A single global flag stops too much: a nested purge of a DIFFERENT URL is
3049 + * a real purge whose listeners must hear about it. But a per-request
3050 + * "already published" set stops too much in the other direction — a
3051 + * network purge loops every blog in one request, and on a subdirectory
3052 + * network they share a host, so blogs 2..N would be silently skipped. It
3053 + * also grows for the life of the process.
3054 + *
3055 + * So the guard tracks what is IN FLIGHT, not what has been published: a
3056 + * target is marked while its own dispatch is on the stack and unmarked
3057 + * when it returns. Re-entering the same target recurses, so it is refused;
3058 + * purging the same URL again later is a new event and publishes. The set
3059 + * is bounded by call depth rather than by how many URLs a request touches.
3060 + *
3061 + * @var array<string,bool>
3062 + */
3063 + private static $purge_events_in_flight = array();
3064 +
3065 + /** Monotonic count used to detect whether a delegated purge published. */
3066 + private static $purge_event_sequence = 0;
3067 +
3068 + /**
3069 + * Publish a purge event exactly once, with bounded arguments.
3070 + *
3071 + * Deliberately carries only what an integration needs to invalidate its
3072 + * own copy: the canonical URL (or null for a full purge), the site host,
3073 + * the cause label, and how many files went. No filesystem paths, no cache
3074 + * contents, no request headers, no user data. The URL query and caller-
3075 + * supplied cause may nevertheless contain sensitive text, so listeners
3076 + * must redact them in logs or unrelated destinations that do not need the
3077 + * exact cache key.
3078 + *
3079 + * A listener that throws must not take the purge down with it: the files
3080 + * are already gone by the time we get here, and an integration's bad day
3081 + * is not a reason to report a failed purge to the caller.
3082 + *
3083 + * @param string $hook Hook name to emit.
3084 + * @param array<string,mixed> $context Bounded context, see above.
3085 + */
3086 + private static function dispatch_purge_event( string $hook, array $context ): void {
3087 + if ( ! function_exists( 'do_action' ) ) {
3088 + return;
3089 + }
3090 + $target = $hook . '|' . ( isset( $context['url'] ) ? (string) $context['url'] : '' )
3091 + . '|' . ( isset( $context['host'] ) ? (string) $context['host'] : '' );
3092 + if ( isset( self::$purge_events_in_flight[ $target ] ) ) {
3093 + return;
3094 + }
3095 + self::$purge_events_in_flight[ $target ] = true;
3096 + ++self::$purge_event_sequence;
3097 +
3098 + // Our own integrations get their own try. Sharing one with the public
3099 + // action below meant a listener on the extension seam could throw and
3100 + // take the contract event down with it — the mirror of the failure
3101 + // this separation exists to prevent.
3102 + try {
3103 + // Built-in server-cache integrations run FIRST, and by a direct
3104 + // call rather than as listeners on the action below.
3105 + //
3106 + // WordPress stops dispatching an action's remaining callbacks when
3107 + // one of them throws. As a listener, our LiteSpeed forwarding
3108 + // would then be skipped by any unrelated third-party callback that
3109 + // happened to be registered earlier and blew up — and the visible
3110 + // result is the worst kind: xSpeed reports a successful purge while
3111 + // the server keeps serving stale HTML. Shipped behaviour must not
3112 + // be hostage to a listener's bug.
3113 + self::forward_to_server_caches( $context );
3114 + } catch ( \Throwable $e ) {
3115 + self::log_purge_listener_error( $hook, $e );
3116 + }
3117 +
3118 + try {
3119 + self::do_action_isolated( $hook, $context );
3120 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
3121 + // Swallow: see docblock. The purge succeeded regardless.
3122 + self::log_purge_listener_error( $hook, $e );
3123 + } finally {
3124 + unset( self::$purge_events_in_flight[ $target ] );
3125 + }
3126 + }
3127 +
3128 + /**
3129 + * Run every listener on a purge hook, isolating each from the others.
3130 + *
3131 + * `do_action()` dispatches callbacks in one loop, so the first one to
3132 + * throw takes every LATER listener down with it. On a purge that meant a
3133 + * failing CDN integration silently cancelled the ones queued behind it —
3134 + * and because the throw was swallowed to keep the purge itself succeeding,
3135 + * the user was told the clear worked while two edges were never touched.
3136 + * Invisible unless WP_DEBUG happened to be on. (QA #348)
3137 + *
3138 + * Each callback gets its own try/catch here, so one integration's bad day
3139 + * costs only that integration. Priority order is preserved. Falls back to
3140 + * a plain `do_action()` when the filter registry is not the shape we
3141 + * expect, so an unusual environment degrades to the old behaviour rather
3142 + * than skipping listeners entirely.
3143 + *
3144 + * @param string $hook Hook name to emit.
3145 + * @param mixed $arg Single argument passed to each listener.
3146 + */
3147 + public static function do_action_isolated( string $hook, $arg ): void {
3148 + global $wp_filter;
3149 +
3150 + // Walking $wp_filter by hand and calling each callback directly was the
3151 + // obvious way to do this, and it was wrong: it bypasses WordPress, so
3152 + // `current_filter()` came back empty, `did_action()` stayed at 0, the
3153 + // `all` hook never fired, and Query Monitor and Debug Bar could not see
3154 + // the very contract this class publishes. A shared handler branching on
3155 + // current_filter() picked the wrong branch. (QA #348 round 2, issue 3)
3156 + //
3157 + // So let do_action() dispatch — WordPress keeps its bookkeeping — and
3158 + // isolate one level down instead: each registered callback is swapped
3159 + // for a wrapper that runs it inside a try/catch. One listener throwing
3160 + // then costs only that listener, which is the whole point, without
3161 + // costing the hook its identity.
3162 + if ( ! isset( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof \WP_Hook ) ) {
3163 + do_action( $hook, $arg );
3164 + return;
3165 + }
3166 +
3167 + $hook_object = $wp_filter[ $hook ];
3168 + $original = $hook_object->callbacks;
3169 + if ( ! is_array( $original ) || array() === $original ) {
3170 + do_action( $hook, $arg );
3171 + return;
3172 + }
3173 +
3174 + $wrapped = array();
3175 + $restorations = array();
3176 + foreach ( $original as $priority => $group ) {
3177 + if ( ! is_array( $group ) ) {
3178 + $wrapped[ $priority ] = $group;
3179 + continue;
3180 + }
3181 + foreach ( $group as $id => $registered ) {
3182 + if ( ! isset( $registered['function'] ) || ! is_callable( $registered['function'] ) ) {
3183 + $wrapped[ $priority ][ $id ] = $registered;
3184 + continue;
3185 + }
3186 + $callback = $registered['function'];
3187 + $wrapper = static function ( ...$args ) use ( $callback, $hook ) {
3188 + try {
3189 + return $callback( ...$args );
3190 + } catch ( \Throwable $e ) {
3191 + self::log_purge_listener_error( $hook, $e );
3192 + return null;
3193 + }
3194 + };
3195 + $wrapped[ $priority ][ $id ] = array(
3196 + // Keep accepted_args: a listener registered for 0 or 1
3197 + // arguments must still be called the way it asked.
3198 + 'accepted_args' => $registered['accepted_args'] ?? 1,
3199 + 'function' => $wrapper,
3200 + );
3201 + $restorations[ $priority ][ $id ] = array(
3202 + 'original' => $registered,
3203 + 'wrapper' => $wrapper,
3204 + );
3205 + }
3206 + }
3207 +
3208 + $hook_object->callbacks = $wrapped;
3209 + try {
3210 + do_action( $hook, $arg );
3211 + } finally {
3212 + // Restore only wrappers still present. Native add/remove operations
3213 + // performed by listeners must survive this temporary substitution.
3214 + foreach ( $restorations as $priority => $group ) {
3215 + foreach ( $group as $id => $restore ) {
3216 + $current = $hook_object->callbacks[ $priority ][ $id ]['function'] ?? null;
3217 + if ( $current === $restore['wrapper'] ) {
3218 + $hook_object->callbacks[ $priority ][ $id ] = $restore['original'];
3219 + }
3220 + }
3221 + }
3222 + }
3223 + }
3224 +
3225 + /**
3226 + * Name a listener that threw, under WP_DEBUG only.
3227 + *
3228 + * Gated like the rest of Free's diagnostics: a third-party listener
3229 + * throwing on every purge must not fill a production log.
3230 + */
3231 + private static function log_purge_listener_error( string $hook, \Throwable $e ): void {
3232 + // An \Error — a TypeError from one of OUR listeners, say — is a bug
3233 + // rather than a runtime condition a third party imposed on us, and
3234 + // swallowing it silently in production turns it into a purge that
3235 + // quietly stops working. Those are logged whatever WP_DEBUG says;
3236 + // third-party \Exceptions stay gated so a noisy integration cannot
3237 + // fill a production log.
3238 + $always = $e instanceof \Error;
3239 + if ( ( $always || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) && function_exists( 'error_log' ) ) {
3240 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- names a third-party listener that threw during a purge.
3241 + error_log( '[xspeed] a ' . $hook . ' listener threw: ' . $e->getMessage() );
3242 + }
3243 + }
3244 +
3245 + /** Test seam: clear the in-flight set left behind by an aborted dispatch. */
3246 + public static function reset_purge_events(): void {
3247 + self::$purge_events_in_flight = array();
3248 + self::$purge_event_sequence = 0;
3249 + }
3250 +
3251 + /**
3252 + * Hand the purge to the caches we ship integrations for.
3253 + *
3254 + * Isolated from the public action on purpose — see dispatch_purge_event().
3255 + * Guarded so a missing class (a partial upgrade, a stripped build) cannot
3256 + * turn a working purge into a fatal.
3257 + *
3258 + * @param array<string,mixed> $context Bounded purge context.
3259 + */
3260 + private static function forward_to_server_caches( array $context ): void {
3261 + if ( class_exists( __NAMESPACE__ . '\\Server_Caches' ) ) {
3262 + Server_Caches::forward( $context );
3263 + }
3264 + }
3265 +
3266 + /**
3267 + * `host[:port]` for a cache key, from a parsed URL.
3268 + *
3269 + * The port is kept, because `cache_key()` hashes the raw `HTTP_HOST` and
3270 + * that carries `:8080` on any install not served from 80/443 — dropping it
3271 + * computed a different md5, found no file, and reported "already cold"
3272 + * while the page kept serving HIT.
3273 + *
3274 + * A port that is the DEFAULT for the scheme is dropped, though, because
3275 + * `HTTP_HOST` does not carry one: a browser sends `Host: site.com` for
3276 + * `https://site.com:443/`. Keeping it hashed `site.com:443` against a file
3277 + * stored under `site.com` — the same silent no-op in the other direction,
3278 + * and the one QA hit passing a canonical URL with the port spelled out.
3279 + * (QA #348)
3280 + *
3281 + * @param array<string,mixed> $parts Output of wp_parse_url().
3282 + */
3283 + private static function host_port_of( array $parts ): string {
3284 + if ( ! isset( $parts['host'] ) ) {
3285 + return '';
3286 + }
3287 + $host = strtolower( (string) $parts['host'] );
3288 + if ( '' === $host || ! isset( $parts['port'] ) ) {
3289 + return $host;
3290 + }
3291 + $port = (int) $parts['port'];
3292 + $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
3293 + if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
3294 + return $host;
3295 + }
3296 + return $host . ':' . $port;
3297 + }
3298 +
897 3299 public static function purge_url( string $url, string $cause = 'manual' ): int {
3300 + // A URL that names nothing is not a purge of everything. An empty or
3301 + // blank string used to fall through to the home_url() default below
3302 + // and clear the HOMEPAGE — so a third party calling
3303 + // `purge_url( get_permalink( $id ) )` on a post whose permalink came
3304 + // back empty silently purged the front page instead of nothing. The
3305 + // CLI and the MCP tool reject empties before reaching this, so only
3306 + // direct API callers were exposed, but they are exactly the audience
3307 + // this public contract is for. (QA #348)
3308 + if ( '' === trim( $url ) ) {
3309 + return 0;
3310 + }
898 3311 $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.
899 3312 if ( ! is_array( $parts ) ) {
900 3313 return 0;
901 3314 }
902 - $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
3315 + // Absolute URLs are accepted only for HTTP response caches. Schemes such
3316 + // as ftp:, file: and javascript: can parse cleanly but do not name a page
3317 + // xSpeed or a server response cache can invalidate. A leading-slash path
3318 + // remains a supported site-relative target.
3319 + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( (string) $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
3320 + return 0;
3321 + }
3322 + if ( isset( $parts['scheme'] ) && empty( $parts['host'] ) ) {
3323 + return 0;
3324 + }
3325 + // Reject a string that parsed but is not a URL we can act on: no
3326 + // scheme AND no host AND no leading-slash path means something like
3327 + // `ht!tp://[[[` or a bare word, which parse_url() hands back as a
3328 + // relative "path". Forwarding that produced `purge_url(/ht!tp://[[[)`
3329 + // — a nonsense tag sent to LiteSpeed for every malformed call.
3330 + if ( ! isset( $parts['scheme'] ) && ! isset( $parts['host'] ) ) {
3331 + $raw = isset( $parts['path'] ) ? (string) $parts['path'] : '';
3332 + if ( '' === $raw || '/' !== $raw[0] ) {
3333 + return 0;
3334 + }
3335 + }
3336 + // Keep the port. `cache_key()` hashes the raw `HTTP_HOST`, which
3337 + // carries `:8080` on any install not served from 80/443 — while
3338 + // parse_url() splits the port into its own component, so a purge that
3339 + // used the bare host computed a different md5, found no file, and
3340 + // reported "already cold". A silent no-op: the page kept serving HIT
3341 + // until its TTL ran out. Intranet installs, panel hosts on :8443 and
3342 + // proxies that forward `Host: site.com:8080` all hit this.
3343 + // A scheme-less `site.test:443/page/` is a supported explicit-host
3344 + // target. Infer a scheme only when it names THIS site's hostname: then
3345 + // its explicit default port is the same origin and the same local cache
3346 + // key. Never apply this to another host or to a non-default port.
3347 + if ( ! isset( $parts['scheme'] ) && isset( $parts['host'], $parts['port'] ) && function_exists( 'home_url' ) ) {
3348 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above.
3349 + if ( is_array( $home ) && ! empty( $home['host'] ) && ! empty( $home['scheme'] )
3350 + && strtolower( (string) $home['host'] ) === strtolower( (string) $parts['host'] )
3351 + ) {
3352 + $home_scheme = strtolower( (string) $home['scheme'] );
3353 + $port = (int) $parts['port'];
3354 + $home_port = isset( $home['port'] )
3355 + ? (int) $home['port']
3356 + : ( 'https' === $home_scheme ? 443 : ( 'http' === $home_scheme ? 80 : 0 ) );
3357 + if ( $home_port === $port
3358 + && ( ( 'https' === $home_scheme && 443 === $port ) || ( 'http' === $home_scheme && 80 === $port ) )
3359 + ) {
3360 + $parts['scheme'] = $home_scheme;
3361 + }
3362 + }
3363 + }
3364 + $host = self::host_port_of( $parts );
903 3365 if ( '' === $host && function_exists( 'home_url' ) ) {
904 3366 $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.
905 - $host = is_array( $home ) && isset( $home['host'] ) ? strtolower( (string) $home['host'] ) : '';
3367 + if ( is_array( $home ) ) {
3368 + $host = self::host_port_of( $home );
3369 + }
906 3370 }
907 3371 if ( '' === $host ) {
908 3372 return 0;
909 3373 }
@@ -921,19 +3385,34 @@
921 3385 $forms[] = rtrim( $path, '/' ) . '/';
922 3386 }
923 3387 $forms = array_unique( $forms );
924 3388
3389 + /*
3390 + * Entries live under the bucket they were written for, and this URL's
3391 + * site may not be the one serving THIS request (a cross-site purge on
3392 + * multisite, WP-CLI, or cron). Build the directory from the URL's own
3393 + * host AND path. (#6)
3394 + *
3395 + * Host alone is wrong on a subdirectory network: `store()` wrote to
3396 + * `<host>/<prefix>/`, so looking in `<host>/` found nothing and the
3397 + * call reported "already cold" while the page kept serving HIT — a
3398 + * false success, which is worse than an error. The prefix has to come
3399 + * from the URL being purged rather than from the current blog, because
3400 + * the caller is usually purging some OTHER site. (QA B2 on #166)
3401 + */
3402 + $base = XSPEED_CACHE_DIR . '/' . self::bucket_for_url( $host, $path );
3403 +
925 3404 $count = 0;
926 3405 foreach ( $forms as $uri ) {
927 3406 // '' = mobile_separate off; '|m' / '|d' = the device buckets.
928 3407 foreach ( array( '', '|m', '|d' ) as $device ) {
929 3408 $key = md5( $host . $uri . $device );
930 - $file = self::cache_file_for( $key );
3409 + $file = $base . '/' . $key . '.html';
931 3410 if ( is_file( $file ) ) {
932 3411 wp_delete_file( $file );
933 3412 ++$count;
934 3413 }
935 - foreach ( array( XSPEED_CACHE_DIR . '/' . $key . '.meta', $file . '.br' ) as $sidecar ) {
3414 + foreach ( array( $base . '/' . $key . '.meta', $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
936 3415 if ( is_file( $sidecar ) ) {
937 3416 wp_delete_file( $sidecar );
938 3417 }
939 3418 }
@@ -941,16 +3420,20 @@
941 3420 }
942 3421
943 3422 // Static tree (served directly by the nginx/.htaccess rewrite).
944 3423 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
945 - $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . $host . ( '/' === $path ? '' : rtrim( $path, '/' ) );
3424 + // Same transform the write used — `localhost:8080` files under
3425 + // `localhost8080`, so the bare host found nothing here either.
3426 + $dir = rtrim( XSPEED_CACHE_STATIC_DIR, '/' ) . '/' . self::static_host_dir( $host ) . ( '/' === $path ? '' : rtrim( $path, '/' ) );
946 3427 $file = $dir . '/index.html';
947 3428 if ( is_file( $file ) ) {
948 3429 wp_delete_file( $file );
949 3430 ++$count;
950 3431 }
951 - if ( is_file( $file . '.br' ) ) {
952 - wp_delete_file( $file . '.br' );
3432 + foreach ( array( $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
3433 + if ( is_file( $sidecar ) ) {
3434 + wp_delete_file( $sidecar );
3435 + }
953 3436 }
954 3437 }
955 3438
956 3439 if ( $count > 0 ) {
@@ -967,50 +3450,257 @@
967 3450 Activity_Log::INFO
968 3451 );
969 3452 }
970 3453
3454 + /**
3455 + * Fires after one URL's cached copy has been purged.
3456 + *
3457 + * The single-URL counterpart to `xspeed_after_purge_all`. Subscribe
3458 + * here to invalidate a cache xSpeed does not own — a server-level
3459 + * cache such as LiteSpeed's LSCache, a reverse proxy, or a CDN — for
3460 + * the same URL.
3461 + *
3462 + * Only fires when the purge actually ran. A malformed URL, a URL with
3463 + * no resolvable host, or a traversal attempt returns earlier and
3464 + * publishes nothing, so a listener can treat this as "xSpeed purged
3465 + * this URL" rather than "xSpeed was asked to". `removed` may legitimately
3466 + * be 0: the URL was not in xSpeed's cache, which says nothing about
3467 + * whether it is in yours.
3468 + *
3469 + * Fires at most once per purge. A listener that calls back into
3470 + * xSpeed's purge API will not re-enter this event.
3471 + *
3472 + * @since 1.2.3
3473 + *
3474 + * @param array $context {
3475 + * Bounded description of the purge. URL queries and caller-supplied
3476 + * causes can contain sensitive values and are not logging fields.
3477 + *
3478 + * @type string $url Canonical scheme://host/path[?query] of the purged URL.
3479 + * The query is preserved because caches in front
3480 + * commonly key on it; xSpeed's own sweep is
3481 + * path-based, so `removed` describes that.
3482 + * @type string $host Host (with port when non-standard).
3483 + * @type string $path Path component, leading slash.
3484 + * @type string $cause Short label for who asked. See purge_all().
3485 + * @type int $removed Number of cache files removed.
3486 + * @type string $scope Actionable adapter scope: `urls`.
3487 + * @type string $intent Why responses changed: `content`.
3488 + * @type string[] $urls Exact response URLs to invalidate.
3489 + * }
3490 + */
3491 + $canonical_url = self::canonical_purge_url(
3492 + $host,
3493 + $path,
3494 + isset( $parts['query'] ) ? (string) $parts['query'] : '',
3495 + isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''
3496 + );
3497 + self::dispatch_purge_event(
3498 + 'xspeed_after_purge_url',
3499 + array(
3500 + 'url' => $canonical_url,
3501 + 'host' => $host,
3502 + 'path' => $path,
3503 + 'cause' => $cause,
3504 + 'removed' => $count,
3505 + 'scope' => 'urls',
3506 + 'intent' => 'content',
3507 + 'urls' => array( $canonical_url ),
3508 + )
3509 + );
3510 +
971 3511 return $count;
972 3512 }
973 3513
974 - public static function purge_all( string $cause = 'manual' ) {
3514 + /** Host this site's purge is scoped to, for the purge-event context. */
3515 + private static function current_purge_host(): string {
3516 + if ( ! function_exists( 'home_url' ) ) {
3517 + return '';
3518 + }
3519 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- host only.
3520 + if ( ! is_array( $home ) || empty( $home['host'] ) ) {
3521 + return '';
3522 + }
3523 + // Same default-port normalisation as purge_url(): a site whose
3524 + // home_url() carries `:443` (normal behind a proxy) otherwise stamps
3525 + // every full-purge event with a host that matches none of its own
3526 + // URLs, so the LiteSpeed forward stood down site-wide. (QA #348)
3527 + return self::host_port_of( $home );
3528 + }
3529 +
3530 + /**
3531 + * Rebuild the canonical URL a purge applied to.
3532 + *
3533 + * Built from the parts the purge itself used, so a listener is told the
3534 + * URL we acted on rather than the string the caller happened to pass —
3535 + * those differ whenever the caller supplied a site-relative path, a
3536 + * different scheme, or a query string the cache key ignores.
3537 + */
3538 + private static function canonical_purge_url( string $host, string $path, string $query = '', string $url_scheme = '' ): string {
3539 + // The purged URL's own scheme wins. purge_url() explicitly supports
3540 + // cross-site purges (multisite, WP-CLI, cron), where composing the
3541 + // current site's scheme onto another site's host builds a URL that was
3542 + // never served — and a CDN listener then purges the wrong key and
3543 + // reports success.
3544 + if ( '' !== $url_scheme ) {
3545 + return $url_scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
3546 + }
3547 + $scheme = function_exists( 'is_ssl' ) && is_ssl() ? 'https' : 'http';
3548 + if ( function_exists( 'home_url' ) ) {
3549 + $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( home_url( '/' ) ) : parse_url( home_url( '/' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- scheme only.
3550 + if ( is_array( $home ) && ! empty( $home['scheme'] ) ) {
3551 + $scheme = (string) $home['scheme'];
3552 + }
3553 + }
3554 + // The query is carried even though OUR sweep above is path-based.
3555 + // Caches in front commonly key on the full request line — LiteSpeed
3556 + // tags `/shop/?page=2` separately from `/shop/` — so publishing the
3557 + // bare path would have a listener confidently purge the wrong entry
3558 + // and report success. Telling it exactly what was asked for lets it
3559 + // act correctly; `removed` still describes only what WE removed.
3560 + return $scheme . '://' . $host . $path . ( '' !== $query ? '?' . $query : '' );
3561 + }
3562 +
3563 + /**
3564 + * Sweep this site's cache files.
3565 + *
3566 + * On multisite every blog shares one cache directory, so an unscoped
3567 + * sweep here took the whole network cold — one subsite's settings save
3568 + * or post publish rebuilt every other site from PHP. Entries are stored
3569 + * per host (see host_dir()), and the sweep is scoped to match, so a
3570 + * purge originating on site-a leaves site-b's cache warm. (#6)
3571 + *
3572 + * Clears the files only: the flat tree, the static tree, the REST
3573 + * responses and the minified assets. The object-cache flush, the stats
3574 + * update, `xspeed_after_purge_all`, the `xspeed_after_purge` contract
3575 + * event and the log entry live in purge_all(), which is still the entry
3576 + * point for every existing caller. Split out so `wp xspeed purge` can
3577 + * report the local sweep as one line item and the object cache as
3578 + * another, each with its own status — see Purge_Runner.
3579 + *
3580 + * @param string|null $host Host to purge. Defaults to the current site.
3581 + * Pass '*' to sweep the ENTIRE tree — network
3582 + * admin's "purge all sites", and the migration
3583 + * of pre-#6 entries that sit in the tree root.
3584 + * @return array{pages:int,rest:int,assets:int,bytes:int} Entries removed
3585 + * per store, and the bytes freed by the two file sweeps
3586 + * that measure themselves.
3587 + */
3588 + public static function purge_local( ?string $host = null ): array {
3589 + $network_wide = ( '*' === $host );
3590 + self::$sweep_bytes = 0;
3591 + // The flat tree buckets by a flattened segment (host/a-b) while the
3592 + // static tree mirrors the URL (host/a/b), so they need separate
3593 + // scopes — see current_host_dir() vs current_static_scope().
3594 + $static_scope = '';
3595 + if ( null === $host || $network_wide ) {
3596 + $scope = $network_wide ? '' : self::current_host_dir();
3597 + $static_scope = $network_wide ? '' : self::current_static_scope();
3598 + } else {
3599 + $dir = self::host_dir( $host );
3600 + $scope = '' === $dir ? 'default' : $dir;
3601 + $static_dir = self::static_host_dir( $host );
3602 + $static_scope = '' === $static_dir ? 'default' : $static_dir;
3603 + }
3604 +
975 3605 $count = 0;
976 3606 if ( is_dir( XSPEED_CACHE_DIR ) ) {
977 - $files = glob( XSPEED_CACHE_DIR . '/*.html' );
978 - if ( $files ) {
979 - $count = count( $files );
980 - foreach ( $files as $f ) {
981 - wp_delete_file( $f );
3607 + // Scoped to one host directory, or the whole tree (including the
3608 + // legacy top-level entries written before #6) when network-wide.
3609 + /*
3610 + * Network-wide sweeps go TWO levels deep, not one. A subdirectory
3611 + * subsite's bucket is `<host>/<prefix>/`, so globbing only
3612 + * `<cache>/*` reached the main site and left every subsite's
3613 + * entries in place. (QA D5 on #166)
3614 + *
3615 + * A scoped purge also has to cover its own nested buckets: when
3616 + * the main blog of a subdirectory network purges, `<host>/` is its
3617 + * bucket and `<host>/one/` belongs to another blog — so the scoped
3618 + * branch deliberately does NOT descend, which is what keeps
3619 + * site-level purges isolated.
3620 + */
3621 + $roots = $network_wide
3622 + ? array_merge(
3623 + array( XSPEED_CACHE_DIR ),
3624 + array_filter( (array) glob( XSPEED_CACHE_DIR . '/*', GLOB_ONLYDIR ) ),
3625 + array_filter( (array) glob( XSPEED_CACHE_DIR . '/*/*', GLOB_ONLYDIR ) )
3626 + )
3627 + : array( XSPEED_CACHE_DIR . '/' . $scope );
3628 +
3629 + foreach ( $roots as $root ) {
3630 + /*
3631 + * min/ and rest/ are swept by their own purgers below; never
3632 + * treat them as host buckets.
3633 + *
3634 + * Checked on every path SEGMENT, not just the basename: now
3635 + * that the network-wide glob descends two levels it can reach
3636 + * `min/combined`, whose basename is `combined` and would sail
3637 + * past a basename-only test — deleting the combined
3638 + * stylesheets out from under the pages that link them.
3639 + */
3640 + if ( ! $network_wide || XSPEED_CACHE_DIR !== $root ) {
3641 + $relative = trim( str_replace( XSPEED_CACHE_DIR, '', (string) $root ), '/' );
3642 + $segments = '' === $relative ? array() : explode( '/', $relative );
3643 + if ( array_intersect( $segments, array( 'min', 'rest' ) ) ) {
3644 + continue;
3645 + }
982 3646 }
983 - }
984 - // Remove the .meta sidecars (content-type for feeds/sitemaps)
985 - // alongside their .html entries. Not counted — they're not
986 - // cache "pages", just per-entry metadata.
987 - $meta = glob( XSPEED_CACHE_DIR . '/*.meta' );
988 - if ( $meta ) {
989 - foreach ( $meta as $m ) {
990 - wp_delete_file( $m );
3647 + if ( ! is_dir( $root ) ) {
3648 + continue;
991 3649 }
992 - }
993 - // Remove precompressed siblings (e.g. <key>.html.br from the Pro
994 - // Brotli module). Not counted — same as .meta. Without this a
995 - // purge leaves stale .br bodies behind: disk bloat, and a
996 - // staleness window if precompression is later disabled.
997 - $br = glob( XSPEED_CACHE_DIR . '/*.br' );
998 - if ( $br ) {
999 - foreach ( $br as $b ) {
1000 - wp_delete_file( $b );
3650 + $files = glob( $root . '/*.html' );
3651 + if ( $files ) {
3652 + $count += count( $files );
3653 + foreach ( $files as $f ) {
3654 + self::sweep_delete( $f );
3655 + }
1001 3656 }
3657 + // Remove the .meta sidecars (content-type for feeds/sitemaps)
3658 + // alongside their .html entries. Not counted — they're not
3659 + // cache "pages", just per-entry metadata.
3660 + $meta = glob( $root . '/*.meta' );
3661 + if ( $meta ) {
3662 + foreach ( $meta as $m ) {
3663 + self::sweep_delete( $m );
3664 + }
3665 + }
3666 + // Remove precompressed siblings (e.g. <key>.html.br from the Pro
3667 + // Brotli module). Not counted — same as .meta. Without this a
3668 + // purge leaves stale .br bodies behind: disk bloat, and a
3669 + // staleness window if precompression is later disabled.
3670 + $br = glob( $root . '/*.br' );
3671 + if ( $br ) {
3672 + foreach ( $br as $b ) {
3673 + self::sweep_delete( $b );
3674 + }
3675 + }
3676 + // `*.br` does not match `*.br.size` — same reason as the flat-root
3677 + // sweep above: a size record outliving its body would later be
3678 + // read against a different sibling's bytes.
3679 + $br_size = glob( $root . '/*.br.size' );
3680 + if ( $br_size ) {
3681 + foreach ( $br_size as $b ) {
3682 + self::sweep_delete( $b );
3683 + }
3684 + }
1002 3685 }
1003 3686 }
1004 3687 // Static-cache tree purge — recursive because the layout is
1005 3688 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
1006 - // reach everything.
3689 + // reach everything. Already host-segmented, so scoping is just a
3690 + // matter of starting one level down.
1007 3691 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
1008 - $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
3692 + $static_root = $network_wide
3693 + ? XSPEED_CACHE_STATIC_DIR
3694 + : XSPEED_CACHE_STATIC_DIR . '/' . $static_scope;
3695 + if ( is_dir( $static_root ) ) {
3696 + $count += self::rmtree_html( $static_root );
3697 + }
1009 3698 }
1010 3699 // REST response cache (cache/xspeed/rest/*.json) — same purge
1011 3700 // triggers (publish, settings change) invalidate it too.
1012 - $count += Rest_Cache::purge();
3701 + $rest = Rest_Cache::purge();
3702 + $count += $rest;
1013 3703
1014 3704 // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
1015 3705 // purge_all is a full filesystem sweep and must clear these too, even
1016 3706 // when the Minify module is currently disabled — orphaned min/ files
@@ -1016,19 +3706,93 @@
1016 3706 // when the Minify module is currently disabled — orphaned min/ files
1017 3707 // from a feature the user later turned off must still be removed, and
1018 3708 // a stale combined-<hash>.css that the regenerated page no longer
1019 3709 // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
1020 - if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1021 - Minifier::purge_minified();
3710 + $assets = class_exists( '\\XSpeed\\Minifier' ) ? Minifier::purge_minified() : 0;
3711 +
3712 + return array(
3713 + 'pages' => $count - $rest,
3714 + 'rest' => $rest,
3715 + 'assets' => $assets,
3716 + 'bytes' => self::$sweep_bytes,
3717 + );
3718 + }
3719 +
3720 + /**
3721 + * Flush the persistent object cache (Redis / Memcached).
3722 + *
3723 + * Runs regardless of whether the Object Cache module is currently
3724 + * enabled — a drop-in installed earlier keeps serving until flushed.
3725 + *
3726 + * @param bool $network_wide Flush every blog's entries. wp_cache_flush()
3727 + * is NETWORK-global, so on multisite the
3728 + * default prefers the blog-scoped group flush
3729 + * (WP 6.1+) — otherwise one site's purge drops
3730 + * every other site's object cache, the same bug
3731 + * #6 fixed for the page cache.
3732 + * @return bool Whether a flush was actually performed.
3733 + */
3734 + public static function flush_object_cache( bool $network_wide = false ): bool {
3735 + if ( ! $network_wide && is_multisite() && function_exists( 'wp_cache_flush_group' ) && function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) {
3736 + // Blog-scoped groups only; a shared/global group (site options,
3737 + // user meta) is intentionally left alone.
3738 + foreach ( array( 'options', 'posts', 'terms', 'post_meta', 'comment' ) as $group ) {
3739 + wp_cache_flush_group( $group );
3740 + }
3741 + return true;
1022 3742 }
3743 + if ( function_exists( 'wp_cache_flush' ) ) {
3744 + return (bool) wp_cache_flush();
3745 + }
3746 + return false;
3747 + }
1023 3748
1024 - // Persistent object cache (Redis / Memcached). Flush regardless of
1025 - // whether the Object Cache module is currently enabled — a drop-in
1026 - // installed earlier keeps serving until flushed.
1027 - if ( function_exists( 'wp_cache_flush' ) ) {
1028 - wp_cache_flush();
3749 + /**
3750 + * Purge this site's cache: the local sweep, then the object cache, then
3751 + * the bookkeeping every caller expects (stats, `xspeed_after_purge_all`,
3752 + * inventory invalidation, purge log).
3753 + *
3754 + * @param string $cause Who asked, for the purge log.
3755 + * @param string|null $host See purge_local().
3756 + * @param array<string,mixed> $invalidation Public adapter policy. `scope`
3757 + * is urls/site/network/none,
3758 + * `intent` explains why, and
3759 + * `urls` supplies exact targets.
3760 + * @return int Page + REST entries removed.
3761 + */
3762 + public static function purge_all( string $cause = 'manual', ?string $host = null, array $invalidation = array() ) {
3763 + $network_wide = ( '*' === $host );
3764 + $adapter_scope = isset( $invalidation['scope'] ) && is_string( $invalidation['scope'] )
3765 + ? $invalidation['scope']
3766 + : ( $network_wide ? 'network' : 'site' );
3767 + if ( ! in_array( $adapter_scope, array( 'urls', 'site', 'network', 'none' ), true ) ) {
3768 + $adapter_scope = $network_wide ? 'network' : 'site';
1029 3769 }
3770 + if ( $network_wide ) {
3771 + $adapter_scope = 'network';
3772 + }
3773 + $intent = isset( $invalidation['intent'] ) && is_string( $invalidation['intent'] ) && '' !== $invalidation['intent']
3774 + ? $invalidation['intent']
3775 + : 'complete';
3776 + $urls = isset( $invalidation['urls'] ) && is_array( $invalidation['urls'] )
3777 + ? array_values( array_unique( array_filter( $invalidation['urls'], 'is_string' ) ) )
3778 + : array();
3779 + // This method always sweeps a complete local bucket. A narrower adapter
3780 + // announcement would claim unrelated local pages stayed warm when they
3781 + // did not, leaving their server copies stale. Until purge_all() gains
3782 + // dependency-aware local deletion, its response scope cannot be `urls`.
3783 + if ( 'urls' === $adapter_scope ) {
3784 + $adapter_scope = $network_wide ? 'network' : 'site';
3785 + }
3786 + if ( 'site' === $adapter_scope || 'network' === $adapter_scope || 'none' === $adapter_scope ) {
3787 + $urls = array();
3788 + }
1030 3789
3790 + $removed = self::purge_local( $host );
3791 + $count = $removed['pages'] + $removed['rest'];
3792 +
3793 + self::flush_object_cache( $network_wide );
3794 +
1031 3795 self::update_stats( array( 'last_purge' => time() ) );
1032 3796
1033 3797 // Fire AFTER the local sweep so module listeners (Critical CSS,
1034 3798 // Unused CSS, Cloudflare edge purge) run — this action had three
@@ -1034,10 +3798,64 @@
1034 3798 // Unused CSS, Cloudflare edge purge) run — this action had three
1035 3799 // registered listeners but was never emitted. Treat it as additive
1036 3800 // (CDN / edge invalidation), not the mechanism for clearing local
1037 3801 // files. (FBS-83114)
1038 - do_action( 'xspeed_after_purge_all', $cause );
3802 + // Wrapped: this action predates the purge-event contract and has its
3803 + // own third-party listeners. One of them throwing used to abort
3804 + // purge_all() here, which now also means the contract event below
3805 + // never fires and a server cache keeps serving stale HTML. The local
3806 + // sweep is already done by this point, so swallowing is strictly safer
3807 + // than letting a listener decide the rest of the method runs.
3808 + try {
3809 + // Isolated per listener: one throwing used to cancel every
3810 + // listener queued behind it — Critical CSS, Unused CSS and the
3811 + // Cloudflare edge purge all hang off this hook. (QA #348)
3812 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
3813 + } catch ( \Throwable $e ) {
3814 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
3815 + }
1039 3816
3817 + /**
3818 + * Fires after a full purge, with the same bounded context shape as
3819 + * `xspeed_after_purge_url`.
3820 + *
3821 + * Distinct from `xspeed_after_purge_all` on purpose. That action is
3822 + * the long-standing internal signal — it passes a bare `$cause` string
3823 + * and Free's own modules use it for local bookkeeping. This one is the
3824 + * documented contract for OUTSIDE integrations: same argument shape as
3825 + * the per-URL event, so a server-cache or CDN adapter can subscribe to
3826 + * both with one handler and branch on a null `url`.
3827 + *
3828 + * Fires at most once per purge, and not at all when a listener's own
3829 + * purge re-enters xSpeed.
3830 + *
3831 + * @since 1.2.3
3832 + *
3833 + * @param array $context {
3834 + * @type null $url Always null — a full purge has no single URL.
3835 + * @type string $host Host swept, or '*' for the entire tree.
3836 + * @type null $path Always null.
3837 + * @type string $cause Short label for who asked.
3838 + * @type int $removed Number of cache files removed.
3839 + * @type string $scope Adapter action: urls/site/network/none.
3840 + * @type string $intent content/presentation/complete or a caller-defined intent.
3841 + * @type string[] $urls Exact targets when scope is urls.
3842 + * }
3843 + */
3844 + self::dispatch_purge_event(
3845 + 'xspeed_after_purge',
3846 + array(
3847 + 'url' => null,
3848 + 'host' => null === $host ? self::current_purge_host() : (string) $host,
3849 + 'path' => null,
3850 + 'cause' => $cause,
3851 + 'removed' => $count,
3852 + 'scope' => $adapter_scope,
3853 + 'intent' => $intent,
3854 + 'urls' => $urls,
3855 + )
3856 + );
3857 +
1040 3858 // The list behind the "Cached pages" card is memoized for a minute;
1041 3859 // a purge has to drop it or the drill-down shows pages that no
1042 3860 // longer exist.
1043 3861 Cache_Inventory::invalidate();
@@ -1054,8 +3872,508 @@
1054 3872 return $count;
1055 3873 }
1056 3874
1057 3875 /**
3876 + * Purge everything after a plugin / theme / core update completes.
3877 + *
3878 + * Bound to `upgrader_process_complete`, which is the only hook an update
3879 + * fires — no activation hook runs, so without this the cached HTML (and
3880 + * the asset URLs baked into it) outlives the code that produced it.
3881 + *
3882 + * Runs for plugin, theme and core updates alike, including bulk runs and
3883 + * auto-updates, and purges the WHOLE network rather than the current
3884 + * site — see the call below. Translation updates are skipped: they
3885 + * change no markup a cached page depends on, and language packs update
3886 + * often enough that purging on them would keep a multilingual site
3887 + * permanently cold.
3888 + *
3889 + * Note this cannot be folded into the `$invalidate_hooks` loop above:
3890 + * that binds `purge_all` directly, and `purge_all( string $cause )` would
3891 + * then receive the WP_Upgrader instance as its cause.
3892 + *
3893 + * @param mixed $upgrader WP_Upgrader instance (unused).
3894 + * @param array $hook_extra Context for the completed operation.
3895 + * @return void
3896 + */
3897 + public static function purge_after_upgrade( $upgrader = null, $hook_extra = array() ) {
3898 + $cleared = self::$upgrade_cleared_destination;
3899 +
3900 + if ( ! self::upgrade_produced_something( $upgrader ) ) {
3901 + return;
3902 + }
3903 +
3904 + if ( ! self::upgrade_should_purge( is_array( $hook_extra ) ? $hook_extra : array(), $cleared ) ) {
3905 + return;
3906 + }
3907 +
3908 + self::purge_for_upgrade();
3909 + }
3910 +
3911 + /**
3912 + * Whether this request's upgrader removed an existing copy.
3913 + *
3914 + * @var bool
3915 + */
3916 + private static $upgrade_cleared_destination = false;
3917 +
3918 + /**
3919 + * How many `upgrader_process_complete` dispatches are on the stack.
3920 + *
3921 + * @var int
3922 + */
3923 + private static $upgrade_dispatch_depth = 0;
3924 +
3925 + /**
3926 + * Enter an `upgrader_process_complete` dispatch.
3927 + *
3928 + * Bound at PHP_INT_MIN, so it runs before any listener that might read
3929 + * the replacement signal. Public because it is a hook target.
3930 + *
3931 + * @return void
3932 + */
3933 + public static function note_upgrade_dispatch(): void {
3934 + ++self::$upgrade_dispatch_depth;
3935 + }
3936 +
3937 + /**
3938 + * Drop the replacement signal once every listener has read it.
3939 + *
3940 + * Bound at PHP_INT_MAX so a second upgrade in the same request starts
3941 + * clean, without taking the answer away from the add-on callbacks that
3942 + * run at the same priority as ours.
3943 + *
3944 + * Only the OUTERMOST dispatch clears it. A nested run — core's language
3945 + * pack upgrader, or any add-on that installs something from this hook —
3946 + * fires the action again, and clearing there would answer for a run that
3947 + * has not finished. Called directly (no dispatch on the stack) it still
3948 + * clears, which is what a test wants.
3949 + *
3950 + * Known limit: a nested run INHERITS the outer run's signal, because the
3951 + * only evidence we get is a filter that fires before the nested dispatch
3952 + * begins and carries no upgrader identity. So a fresh install performed
3953 + * from inside a replacement run reads as a replacement and purges once
3954 + * more than it needs to. A cold cache is the cheap direction, and the
3955 + * alternative — scoping the signal per upgrader — is not knowable from
3956 + * `upgrader_clear_destination`.
3957 + *
3958 + * @return void
3959 + */
3960 + public static function forget_cleared_destination(): void {
3961 + if ( self::$upgrade_dispatch_depth > 0 ) {
3962 + --self::$upgrade_dispatch_depth;
3963 + }
3964 +
3965 + if ( 0 === self::$upgrade_dispatch_depth ) {
3966 + self::$upgrade_cleared_destination = false;
3967 + }
3968 + }
3969 +
3970 + /**
3971 + * Record that the upgrader cleared an existing destination.
3972 + *
3973 + * A pass-through listener on `upgrader_clear_destination`: WordPress only
3974 + * fires it when `clear_destination` was set AND something was there to
3975 + * remove, which is the one signal that separates an upload-and-replace
3976 + * from a first-time install. The filtered value is returned untouched.
3977 + *
3978 + * @param true|\WP_Error $removed Whether the destination was cleared.
3979 + * @return true|\WP_Error
3980 + */
3981 + public static function note_cleared_destination( $removed ) {
3982 + if ( ! is_wp_error( $removed ) ) {
3983 + self::$upgrade_cleared_destination = true;
3984 + }
3985 +
3986 + return $removed;
3987 + }
3988 +
3989 + /**
3990 + * Did the completed run actually replace anything?
3991 + *
3992 + * `upgrader_process_complete` fires whether the run succeeded or failed —
3993 + * the failure branch in WP_Upgrader::run() only feeds the skin before the
3994 + * action fires. A run that installed nothing changed no markup, so purging
3995 + * for it is a cold cache bought for nothing.
3996 + *
3997 + * Deliberately conservative: this returns false ONLY when every result we
3998 + * can see is an error. An upgrader we cannot read, a mixed bulk run, or a
3999 + * missing result all fall through to purging, which is the safe direction
4000 + * everywhere else in this handler.
4001 + *
4002 + * @param mixed $upgrader WP_Upgrader instance, or anything else.
4003 + * @return bool
4004 + */
4005 + public static function upgrade_produced_something( $upgrader ): bool {
4006 + if ( ! is_object( $upgrader ) ) {
4007 + return true;
4008 + }
4009 +
4010 + // A bulk run collects one entry per item; `result` alone would only
4011 + // describe the last of them.
4012 + if ( isset( $upgrader->results ) && is_array( $upgrader->results ) && ! empty( $upgrader->results ) ) {
4013 + foreach ( $upgrader->results as $result ) {
4014 + if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
4015 + return true;
4016 + }
4017 + }
4018 + return false;
4019 + }
4020 +
4021 + if ( ! property_exists( $upgrader, 'result' ) ) {
4022 + return true;
4023 + }
4024 +
4025 + return ! is_wp_error( $upgrader->result ) && ! empty( $upgrader->result );
4026 + }
4027 +
4028 + /**
4029 + * Decide whether a completed operation invalidates the cache.
4030 + *
4031 + * Split out from the handler so the decision is testable on its own:
4032 + * purge_all() reaches straight for glob() and unlink(), which a unit test
4033 + * cannot observe honestly, while every rule that matters lives here.
4034 + *
4035 + * @param array $hook_extra Context for the completed operation.
4036 + * @return bool
4037 + */
4038 + public static function upgrade_should_purge( array $hook_extra, bool $destination_cleared = false ): bool {
4039 + if ( ! self::upgrade_replaced_code( $hook_extra, $destination_cleared ) ) {
4040 + return false;
4041 + }
4042 +
4043 + // An update to xSpeed ITSELF always purges, whatever the setting says.
4044 + // This plugin's own code is what rendered every cached page — the
4045 + // minifier, lazy-loader, resource hints and CDN rewriter all changed
4046 + // underneath it — so serving that HTML after an update means serving
4047 + // output from a version that no longer exists. Minified assets make it
4048 + // concrete rather than theoretical: their filenames are keyed on the
4049 + // source filemtime, so they regenerate under NEW hashes while the
4050 + // cached pages still link the old ones, and the page requests files
4051 + // that are no longer on disk. Offering an opt-out for that would be
4052 + // offering a broken site.
4053 + return self::upgrade_touches_xspeed( $hook_extra ) || self::purge_on_upgrade_enabled();
4054 + }
4055 +
4056 + /**
4057 + * Did this completed run replace code that renders pages?
4058 + *
4059 + * The half of the decision that has nothing to do with our settings: it
4060 + * asks only whether live code changed underneath the output we cached.
4061 + * Add-ons that keep their own derived artifacts — generated CSS, captured
4062 + * selectors, fingerprints — need the same answer and must not have to
4063 + * rebuild these rules, or they drift apart. Call it with the hook's own
4064 + * `$hook_extra`; the upload-and-replace signal is read from this request.
4065 + *
4066 + * Deliberately independent of the "Purge After Updates" setting. That
4067 + * setting governs the page cache, not whether an add-on's derived data is
4068 + * still valid.
4069 + *
4070 + * @param array $hook_extra Context for the completed operation.
4071 + * @param bool|null $destination_cleared Override the recorded signal; null reads this request's.
4072 + * @return bool
4073 + */
4074 + public static function upgrade_replaced_code( array $hook_extra, ?bool $destination_cleared = null ): bool {
4075 + $cleared = null === $destination_cleared ? self::$upgrade_cleared_destination : $destination_cleared;
4076 +
4077 + $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : '';
4078 + $action = isset( $hook_extra['action'] ) ? (string) $hook_extra['action'] : '';
4079 +
4080 + // `upgrader_process_complete` fires for INSTALLS as well as updates.
4081 + // A freshly installed plugin is inactive and a freshly installed theme
4082 + // is not the active one, so neither can change a single rendered page
4083 + // — but the first cut of this handler purged the whole tree anyway, so
4084 + // evaluating three plugins in a row emptied the cache three times.
4085 + //
4086 + // 'install' alone is NOT enough to skip on, because WordPress reports
4087 + // an upload-and-replace as an install: `Plugin_Upgrader::install()`
4088 + // hardcodes `action => install` and `overwrite_package` does not change
4089 + // it, so "Replace current with uploaded" and `wp plugin install <zip>
4090 + // --force` both arrive here labelled install while genuinely replacing
4091 + // live code. That is how a plugin distributed as a zip is updated, and
4092 + // skipping it put back the stale markup this handler exists to clear.
4093 + //
4094 + // The distinguishing signal is whether the destination was cleared:
4095 + // WP_Upgrader only fires `upgrader_clear_destination` when it removed
4096 + // something that was already there. Installing beside nothing does not.
4097 + if ( 'install' === $action && ! $cleared ) {
4098 + return false;
4099 + }
4100 +
4101 + // 'translation' is the one update type that cannot change rendered
4102 + // markup. Anything else — including an empty type from a custom
4103 + // updater — is treated as cache-invalidating, because guessing wrong
4104 + // in that direction only costs a cold cache.
4105 + if ( 'translation' === $type ) {
4106 + return false;
4107 + }
4108 +
4109 + return true;
4110 + }
4111 +
4112 + /**
4113 + * Purge everything an update can invalidate.
4114 + *
4115 + * Network-wide ('*'), not the calling site's bucket. A plugin, theme or
4116 + * core update replaces code shared by EVERY site on the network, so a
4117 + * scoped purge would clear the site that happened to run the updater and
4118 + * leave every other subsite serving pre-update HTML for the whole TTL —
4119 + * the very bug this handler exists to fix, one level down. On single-site
4120 + * this is identical to the scoped call, since there is only ever one
4121 + * bucket.
4122 + *
4123 + * @return void
4124 + */
4125 + private static function purge_for_upgrade(): void {
4126 + self::purge_all( 'upgrade', '*' );
4127 + Minifier::purge_minified();
4128 + }
4129 +
4130 + /**
4131 + * Purge after an unattended background update run.
4132 + *
4133 + * `automatic_updates_complete` passes ONE argument, and it is not a
4134 + * hook_extra: it is WordPress's results array, keyed by what was updated
4135 + * ('core', 'plugin', 'theme', 'translation'). Handing it to
4136 + * purge_after_upgrade() put it in the unused $upgrader slot and left the
4137 + * type empty, so a night on which only a language pack updated purged
4138 + * every cached page — the exact case the translation exemption exists to
4139 + * prevent, and WordPress auto-updates language packs by default.
4140 + *
4141 + * @param array $results Update results, keyed by type.
4142 + * @return void
4143 + */
4144 + public static function purge_after_auto_updates( $results = array() ): void {
4145 + if ( ! self::auto_updates_should_purge( is_array( $results ) ? $results : array() ) ) {
4146 + return;
4147 + }
4148 +
4149 + self::purge_for_upgrade();
4150 + }
4151 +
4152 + /**
4153 + * Decide whether a background update run invalidates the cache.
4154 + *
4155 + * @param array $results Update results, keyed by type.
4156 + * @return bool
4157 + */
4158 + public static function auto_updates_should_purge( array $results ): bool {
4159 + // An unrecognisable payload is treated as invalidating, the same
4160 + // direction every other unknown takes here.
4161 + if ( empty( $results ) ) {
4162 + return true;
4163 + }
4164 +
4165 + // Failed items are listed alongside successful ones — WP_Automatic_Updater
4166 + // appends an entry whatever the outcome — and a night on which every
4167 + // update failed replaced no code, so it invalidates nothing.
4168 + $updated = array();
4169 + foreach ( $results as $type => $items ) {
4170 + if ( ! is_array( $items ) ) {
4171 + continue;
4172 + }
4173 + foreach ( $items as $item ) {
4174 + $result = is_object( $item ) && isset( $item->result ) ? $item->result : true;
4175 + if ( ! is_wp_error( $result ) && ! empty( $result ) ) {
4176 + $updated[] = (string) $type;
4177 + break;
4178 + }
4179 + }
4180 + }
4181 +
4182 + if ( empty( $updated ) ) {
4183 + return false;
4184 + }
4185 +
4186 + // Nothing but language packs: a language pack changes no markup a
4187 + // cached page depends on, and purging on one would keep a multilingual
4188 + // site permanently cold.
4189 + if ( array( 'translation' ) === array_values( array_unique( $updated ) ) ) {
4190 + return false;
4191 + }
4192 +
4193 + return self::auto_updates_touch_xspeed( $results ) || self::purge_on_upgrade_enabled();
4194 + }
4195 +
4196 + /**
4197 + * Does a background run include one of our own plugins?
4198 + *
4199 + * Same rule as a foreground self-update, read out of the results array's
4200 + * shape instead of a hook_extra: each plugin entry carries the update
4201 + * object on `->item->plugin`.
4202 + *
4203 + * @param array $results Update results, keyed by type.
4204 + * @return bool
4205 + */
4206 + private static function auto_updates_touch_xspeed( array $results ): bool {
4207 + if ( empty( $results['plugin'] ) || ! is_array( $results['plugin'] ) ) {
4208 + return false;
4209 + }
4210 +
4211 + $ours = self::self_update_plugins();
4212 + foreach ( $results['plugin'] as $entry ) {
4213 + $item = is_object( $entry ) && isset( $entry->item ) ? $entry->item : null;
4214 + $file = is_object( $item ) && isset( $item->plugin ) ? (string) $item->plugin : '';
4215 + if ( '' !== $file && in_array( $file, $ours, true ) ) {
4216 + return true;
4217 + }
4218 + }
4219 +
4220 + return false;
4221 + }
4222 +
4223 + /**
4224 + * Is the "Purge After Updates" setting on?
4225 + *
4226 + * Gates THIRD-PARTY updates only — an xSpeed self-update ignores it, see
4227 + * purge_after_upgrade(). Defaults to true when the option has never been
4228 + * written, matching the schema default in CacheModule: an unset value on
4229 + * an existing install must not read as "the user turned this off".
4230 + *
4231 + * Unlike LiteSpeed, which ships the equivalent toggle OFF, this defaults
4232 + * ON — a cold cache costs one slow request, whereas stale HTML is a wrong
4233 + * page for up to the full TTL and the site owner has no way to tell why.
4234 + *
4235 + * @return bool
4236 + */
4237 + private static function purge_on_upgrade_enabled(): bool {
4238 + $opts = Settings_Manager::get( 'cache' );
4239 + return ! array_key_exists( 'purge_on_upgrade', $opts ) || ! empty( $opts['purge_on_upgrade'] );
4240 + }
4241 +
4242 + /**
4243 + * Does this completed update include xSpeed itself?
4244 + *
4245 + * Mirrors the payload shapes Plugin::maybe_restore_after_update() reads:
4246 + * a single update carries 'plugin', a bulk run carries 'plugins'.
4247 + *
4248 + * @param array $hook_extra Context for the completed operation.
4249 + * @return bool
4250 + */
4251 + private static function upgrade_touches_xspeed( array $hook_extra ): bool {
4252 + if ( ! isset( $hook_extra['type'] ) || 'plugin' !== $hook_extra['type'] ) {
4253 + return false;
4254 + }
4255 +
4256 + $updated = array();
4257 + if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
4258 + // Strings only: array_intersect() stringifies what it is given, so
4259 + // an object without __toString in a custom updater's payload would
4260 + // be a fatal rather than a miss.
4261 + $updated = array_filter( $hook_extra['plugins'], 'is_string' );
4262 + } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) {
4263 + $updated = array( $hook_extra['plugin'] );
4264 + }
4265 +
4266 + return (bool) array_intersect( self::self_update_plugins(), $updated );
4267 + }
4268 +
4269 + /**
4270 + * Plugin files whose update counts as an update to us.
4271 + *
4272 + * The self-update rule is "our own code rendered this cached HTML, so it
4273 + * must not survive the code being replaced". That is true of any add-on
4274 + * that writes into the same page: an add-on inlines critical CSS, rewrites
4275 + * stylesheet links and image URLs, and produces the compressed and static
4276 + * copies, so its update leaves exactly the stale markup this rule exists
4277 + * to clear. Free cannot name an add-on, so it asks instead.
4278 + *
4279 + * Filter: xspeed_self_update_plugins
4280 + *
4281 + * Add-ons add their own `plugin_basename( __FILE__ )`. Entries are matched
4282 + * against the plugin files WordPress reports for the completed update, so
4283 + * a value that is not a `dir/file.php` basename simply never matches.
4284 + *
4285 + * @param string[] $plugins Plugin basenames treated as our own.
4286 + * @return string[]
4287 + */
4288 + private static function self_update_plugins(): array {
4289 + $ours = array( plugin_basename( XSPEED_FILE ) );
4290 +
4291 + /** This filter is documented above. */
4292 + $filtered = apply_filters( 'xspeed_self_update_plugins', $ours );
4293 +
4294 + // Our own file is merged back afterwards rather than trusted to survive
4295 + // the round trip. A listener that returns null, a bare string, or a
4296 + // list it built from scratch would otherwise drop it, and the plugin
4297 + // would quietly stop exempting its OWN update from the setting — a
4298 + // failure no add-on author would think to test for.
4299 + $claimed = array_filter( is_array( $filtered ) ? $filtered : array(), 'is_string' );
4300 +
4301 + return array_values( array_unique( array_merge( $ours, array_filter( $claimed ) ) ) );
4302 + }
4303 +
4304 + /**
4305 + * Invalidate caches of RENDERED output owned by other plugins.
4306 + *
4307 + * purge_all() sweeps only what xSpeed wrote. A page builder that stores
4308 + * rendered HTML or generated CSS of its own — Elementor's element cache
4309 + * and `uploads/elementor/css/`, and the equivalents in Beaver / Divi /
4310 + * Bricks / Oxygen — keeps whatever asset URLs were current when it was
4311 + * written, and no xSpeed purge has ever reached it.
4312 + *
4313 + * That only matters for rewrites that happen DURING render rather than on
4314 + * the finished page. Minify, combine, lazy-load and resource hints all run
4315 + * on `xspeed_cache_final_html` or a `template_redirect` buffer — after the
4316 + * builder has already stored its copy — so nothing they emit can leak.
4317 + * The CDN module's `wp_get_attachment_url` filter is the one that can.
4318 + *
4319 + * Called ONLY from purges where asset URLs themselves can have changed
4320 + * (a CDN settings write, an explicit Purge All). NOT from purge_all(),
4321 + * which also runs on every post publish — regenerating every builder CSS
4322 + * file that often would cost more than it saves, and the builder already
4323 + * invalidates its own copy for the post being saved.
4324 + *
4325 + * @param string $cause Who asked. Threaded through to the listeners and
4326 + * the activity log.
4327 + * @return string[] Labels of the caches that were actually cleared.
4328 + */
4329 + public static function purge_render_caches( string $cause = 'manual' ): array {
4330 + /**
4331 + * Clear render caches belonging to other plugins.
4332 + *
4333 + * A listener does its own work and appends a human-readable label for
4334 + * what it cleared, so the activity log can name it. Returning
4335 + * `$cleared` unchanged means "nothing of mine is installed" and is the
4336 + * correct no-op — never a failure.
4337 + *
4338 + * Detect the owning plugin by class or constant, not by an
4339 + * `is_plugin_active()` path check: a renamed plugin folder must not
4340 + * silently disable the integration.
4341 + *
4342 + * @param string[] $cleared Labels of caches cleared so far.
4343 + * @param string $cause Why the purge is happening.
4344 + */
4345 + $cleared = (array) apply_filters( 'xspeed_purge_third_party_render_caches', array(), $cause );
4346 +
4347 + // Labels are strings destined for the activity feed. Anything else a
4348 + // third-party listener returns is dropped rather than coerced — a
4349 + // stray `0` or `null` in the log reads as a cache we cleared.
4350 + $cleared = array_values(
4351 + array_filter(
4352 + $cleared,
4353 + static function ( $label ) {
4354 + return is_string( $label ) && '' !== trim( $label );
4355 + }
4356 + )
4357 + );
4358 +
4359 + if ( ! $cleared ) {
4360 + return $cleared;
4361 + }
4362 +
4363 + // Logged separately from the page-cache purge above it. "I turned the
4364 + // CDN off and the images are still wrong" is only diagnosable if the
4365 + // feed says which OTHER plugin's cache was regenerated and when.
4366 + Activity_Log::record(
4367 + 'cache_purged',
4368 + sprintf( 'Render caches cleared (%s) — %s', $cause, implode( ', ', $cleared ) ),
4369 + Activity_Log::INFO
4370 + );
4371 +
4372 + return $cleared;
4373 + }
4374 +
4375 + /**
1058 4376 * The per-type purge menu, LiteSpeed-style. Each entry is a cache type
1059 4377 * the user can purge individually from the admin-bar dropdown. `visible`
1060 4378 * controls whether the item shows (active + licensed module only) — it
1061 4379 * NEVER limits Purge All, which always sweeps everything on disk.
@@ -1123,30 +4441,23 @@
1123 4441 */
1124 4442 public static function purge_type( string $type, string $cause = 'manual' ): int {
1125 4443 switch ( $type ) {
1126 4444 case 'all':
1127 - return self::purge_all( $cause );
4445 + $count = self::purge_all( $cause );
4446 + // "Purge All" is the user saying they don't trust anything
4447 + // stored anywhere — the one purge that should also reach
4448 + // caches of rendered output we don't own. purge_all() itself
4449 + // deliberately does NOT, because it also runs on every post
4450 + // publish. (See Render_Caches.)
4451 + self::purge_render_caches( $cause );
4452 + return $count;
1128 4453
1129 4454 case 'page':
1130 - $count = 0;
1131 - if ( is_dir( XSPEED_CACHE_DIR ) ) {
1132 - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.html' ) as $f ) {
1133 - wp_delete_file( $f );
1134 - ++$count;
1135 - }
1136 - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.meta' ) as $m ) {
1137 - wp_delete_file( $m );
1138 - }
1139 - foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.br' ) as $b ) {
1140 - wp_delete_file( $b );
1141 - }
1142 - }
1143 - if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
1144 - $count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
1145 - }
4455 + $count = self::purge_pages();
1146 4456 self::update_stats( array( 'last_purge' => time() ) );
1147 4457 Cache_Inventory::invalidate();
1148 4458 self::record_partial_purge( 'page', $cause, $count );
4459 + self::announce_purge( $cause, $count );
1149 4460 return $count;
1150 4461
1151 4462 case 'assets':
1152 4463 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
@@ -1151,10 +4462,32 @@
1151 4462 case 'assets':
1152 4463 if ( class_exists( '\\XSpeed\\Minifier' ) ) {
1153 4464 Minifier::purge_minified();
1154 4465 }
1155 - self::record_partial_purge( 'assets', $cause, null );
1156 - return 0;
4466 + // Deleting min/ without clearing the pages that link it left
4467 + // every cached page pointing at files that no longer exist.
4468 + // WordPress answers the missing asset by 301-ing to its
4469 + // pretty-permalink form and serving the 404 TEMPLATE as
4470 + // `HTTP 200 text/html`, which the browser accepts as a
4471 + // stylesheet and parses to zero rules — no console error, no
4472 + // network failure, no 4xx anywhere in devtools. The pages
4473 + // stayed broken for the rest of the TTL (7 days on
4474 + // Aggressive, up to 30), and the admin who clicked could not
4475 + // see it: they are logged in, so their own requests bypass
4476 + // the page cache and re-render, regenerating the assets as a
4477 + // side effect. Only anonymous visitors were served the stale
4478 + // HTML. (#244)
4479 + //
4480 + // The assets are the pages' dependency, so invalidating them
4481 + // invalidates the pages. Same invariant Cache_GC enforces
4482 + // with is_referenced(): never leave a cached page pointing at
4483 + // an asset that is gone.
4484 + $count = self::purge_pages();
4485 + self::update_stats( array( 'last_purge' => time() ) );
4486 + Cache_Inventory::invalidate();
4487 + self::record_partial_purge( 'assets', $cause, $count );
4488 + self::announce_purge( $cause, $count );
4489 + return $count;
1157 4490
1158 4491 case 'object':
1159 4492 if ( function_exists( 'wp_cache_flush' ) ) {
1160 4493 wp_cache_flush();
@@ -1164,19 +4497,156 @@
1164 4497
1165 4498 case 'rest':
1166 4499 $count = Rest_Cache::purge();
1167 4500 self::record_partial_purge( 'REST responses', $cause, $count );
4501 + self::announce_purge( $cause, $count );
1168 4502 return $count;
1169 4503
1170 4504 default:
1171 - // Pro / third-party type — let the owning module handle it.
1172 - do_action( 'xspeed_purge_type_' . $type );
1173 - self::record_partial_purge( $type, $cause, null );
1174 - return 0;
4505 + return self::purge_type_unhandled( $type, $cause );
1175 4506 }
1176 4507 }
1177 4508
1178 4509 /**
4510 + * Delete this site's cached pages from both the flat and static trees.
4511 + *
4512 + * Extracted so the `assets` purge can reuse it: minified assets are a
4513 + * dependency of the cached HTML, so clearing them must clear the pages
4514 + * too or the pages are left referencing deleted files (#244).
4515 + *
4516 + * @return int Number of page entries removed.
4517 + */
4518 + private static function purge_pages(): int {
4519 + $count = 0;
4520 + // Scoped to this site — see purge_all(). (#6)
4521 + $scope = self::current_host_dir();
4522 + $flat_root = XSPEED_CACHE_DIR . '/' . $scope;
4523 + if ( is_dir( $flat_root ) ) {
4524 + foreach ( (array) glob( $flat_root . '/*.html' ) as $f ) {
4525 + wp_delete_file( $f );
4526 + ++$count;
4527 + }
4528 + foreach ( (array) glob( $flat_root . '/*.meta' ) as $m ) {
4529 + wp_delete_file( $m );
4530 + }
4531 + foreach ( (array) glob( $flat_root . '/*.br' ) as $b ) {
4532 + wp_delete_file( $b );
4533 + }
4534 + // `*.br` does not match `*.br.size`; a size record outliving its
4535 + // body would later be read against a DIFFERENT sibling's bytes.
4536 + foreach ( (array) glob( $flat_root . '/*.br.size' ) as $b ) {
4537 + wp_delete_file( $b );
4538 + }
4539 + }
4540 + $static_root = XSPEED_CACHE_STATIC_DIR . '/' . self::current_static_scope();
4541 + if ( is_dir( $static_root ) ) {
4542 + $count += self::rmtree_html( $static_root );
4543 + }
4544 +
4545 + return $count;
4546 + }
4547 +
4548 + /**
4549 + * A purge type this class does not own — a Pro or third-party module
4550 + * registered it via the `xspeed_purge_types` filter, so hand it off.
4551 + *
4552 + * @param string $type Purge-type slug.
4553 + * @param string $cause Who asked.
4554 + */
4555 + private static function purge_type_unhandled( string $type, string $cause ): int {
4556 + $event_sequence = self::$purge_event_sequence;
4557 + $hook = 'xspeed_purge_type_' . $type;
4558 + $has_handler = false !== has_action( $hook );
4559 + do_action( $hook );
4560 + self::record_partial_purge( $type, $cause, null );
4561 +
4562 + // Announce, same as the types this class owns. Pro's "Purge Critical
4563 + // CSS" and "Purge Unused CSS" arrive here, and they change what a
4564 + // cached page CONTAINS — critical CSS is inlined into the HTML, so a
4565 + // server cache goes on serving pages with the old styles baked in.
4566 + // Fixing the three Free buttons and leaving these two silent left the
4567 + // same hole for the tier most likely to be using both plugins.
4568 + // (QA #348 round 2, issue 2)
4569 + //
4570 + // Unknown slugs must not turn into a site-wide purge merely because no
4571 + // handler exists. These are the response-changing Pro types Free knows;
4572 + // third parties can declare another through the filter. A registered
4573 + // handler plus this explicit response scope is the handled signal.
4574 + $scope = in_array( $type, array( 'critical-css', 'unused-css' ), true ) ? 'site' : 'none';
4575 + /**
4576 + * Declare whether a handled custom purge type changes cached responses.
4577 + *
4578 + * @since 1.2.3
4579 + * @param string $scope site/network/none.
4580 + * @param string $type Purge-type slug.
4581 + */
4582 + $scope = (string) apply_filters( 'xspeed_purge_type_response_scope', $scope, $type );
4583 + if ( $has_handler
4584 + && $event_sequence === self::$purge_event_sequence
4585 + && in_array( $scope, array( 'site', 'network' ), true )
4586 + ) {
4587 + self::announce_purge( $cause, 0, $scope, 'presentation' );
4588 + }
4589 +
4590 + return 0;
4591 + }
4592 +
4593 + /**
4594 + * Tell the server cache that a PARTIAL purge cleared cached responses.
4595 + *
4596 + * "Purge Page / Static Cache", "Purge CSS / JS Cache" and "Purge REST
4597 + * Cache" each delete cached RESPONSES for the whole site, so a cache in
4598 + * front of PHP is now serving copies xSpeed has just thrown away. Only
4599 + * "Purge All" announced itself, which left three of the four toolbar
4600 + * buttons doing exactly what this contract exists to prevent: clearing
4601 + * our copy while the server kept serving the stale one. The `assets` case
4602 + * was the sharpest — it deletes the minified bundles too, so LiteSpeed
4603 + * went on serving pages whose CSS and JS no longer exist. (QA #348)
4604 + *
4605 + * Sent as the full-purge shape (`url` null) because that is what happened:
4606 + * every cached page for this site went, not one address. `object` is not
4607 + * announced — flushing the object cache changes no rendered response a
4608 + * server cache could be holding.
4609 + *
4610 + * Public because Purge_Runner sweeps the local files itself, through
4611 + * purge_local(), rather than through purge_all() — so it has to announce
4612 + * on its own behalf or `wp xspeed purge` and the dashboard button clear
4613 + * our copy while LiteSpeed keeps serving the stale one.
4614 + *
4615 + * @param string $cause Who asked.
4616 + * @param int $removed Entries removed locally.
4617 + * @param string $scope Actionable adapter scope.
4618 + * @param string $intent Reason rendered responses changed.
4619 + */
4620 + public static function announce_purge( string $cause, int $removed, string $scope = 'site', string $intent = 'complete' ): void {
4621 + // Announcing is additive: the local sweep has already happened and
4622 + // succeeded. Notification must never be able to turn a working purge
4623 + // into a fatal, so anything the URL helpers do in an unusual context
4624 + // (early boot, a drop-in, a bare test harness) is contained here
4625 + // rather than propagating to the caller.
4626 + if ( ! function_exists( 'home_url' ) || ! function_exists( 'do_action' ) ) {
4627 + return;
4628 + }
4629 + try {
4630 + self::dispatch_purge_event(
4631 + 'xspeed_after_purge',
4632 + array(
4633 + 'url' => null,
4634 + 'host' => self::current_purge_host(),
4635 + 'path' => null,
4636 + 'cause' => $cause,
4637 + 'removed' => $removed,
4638 + 'scope' => $scope,
4639 + 'intent' => $intent,
4640 + 'urls' => array(),
4641 + )
4642 + );
4643 + } catch ( \Throwable $e ) {
4644 + self::log_purge_listener_error( 'xspeed_after_purge', $e );
4645 + }
4646 + }
4647 +
4648 + /**
1179 4649 * Log a partial purge so the drill-down behind "Last purge" shows every
1180 4650 * clear, not only the full ones. Without this a site whose object cache
1181 4651 * is flushed on a schedule looks, from the log, like nothing happens.
1182 4652 *
@@ -1203,8 +4673,24 @@
1203 4673 Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
1204 4674 }
1205 4675
1206 4676 /**
4677 + * Clear the static tree only, leaving the flat cache in place.
4678 + *
4679 + * A narrower purge_all() for the case where only the web-server tree can
4680 + * be wrong: its files are keyed by `{host}{path}` and nothing else, so a
4681 + * response filed under the wrong path poisons it while the flat cache —
4682 + * keyed by cache_key(), discriminators included — stays correct. Avoids
4683 + * throwing away Critical CSS, minified bundles and the object cache to
4684 + * fix a static-only problem.
4685 + *
4686 + * @return int Number of index.html files removed.
4687 + */
4688 + public static function purge_static_tree(): int {
4689 + return self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
4690 + }
4691 +
4692 + /**
1207 4693 * Recursively delete every `index.html` (and its precompressed
1208 4694 * `index.html.br` sibling, if the Pro Brotli module wrote one) plus
1209 4695 * empty directories inside the static-cache tree. Used by purge_all().
1210 4696 * Returns the number of .html files removed so purge stats stay accurate
@@ -1210,8 +4696,24 @@
1210 4696 * Returns the number of .html files removed so purge stats stay accurate
1211 4697 * across the flat + static caches — .br siblings are not counted
1212 4698 * (they're encodings of a page, not pages).
1213 4699 */
4700 + /**
4701 + * Delete a cache file, adding its size to the current sweep's byte
4702 + * total. filesize() is silenced and re-checked because the file can
4703 + * vanish between the glob and the unlink — a concurrent purge, or the
4704 + * cache GC — and a warning there would be noise, not news.
4705 + *
4706 + * @param string $file Absolute path inside the cache tree.
4707 + */
4708 + private static function sweep_delete( string $file ): void {
4709 + $size = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- the file may be gone already; see docblock.
4710 + if ( is_int( $size ) ) {
4711 + self::$sweep_bytes += $size;
4712 + }
4713 + wp_delete_file( $file );
4714 + }
4715 +
1214 4716 private static function rmtree_html( string $dir ): int {
1215 4717 if ( ! is_dir( $dir ) ) {
1216 4718 return 0;
1217 4719 }
@@ -1235,14 +4737,16 @@
1235 4737 @rmdir( $path );
1236 4738 continue;
1237 4739 }
1238 4740 if ( substr( $entry, -5 ) === '.html' ) {
1239 - wp_delete_file( $path );
4741 + self::sweep_delete( $path );
1240 4742 ++$removed;
1241 - } elseif ( substr( $entry, -3 ) === '.br' ) {
1242 - // Precompressed sibling (index.html.br). Remove it too so a
1243 - // purge doesn't orphan stale Brotli bodies. Not counted.
1244 - wp_delete_file( $path );
4743 + } elseif ( substr( $entry, -3 ) === '.br' || substr( $entry, -8 ) === '.br.size' ) {
4744 + // Precompressed sibling (index.html.br) and the record of its
4745 + // length. Remove both so a purge doesn't orphan stale Brotli
4746 + // bodies, or a size record that would later be read against a
4747 + // different sibling's bytes. Not counted.
4748 + self::sweep_delete( $path );
1245 4749 }
1246 4750 }
1247 4751 return $removed;
1248 4752 }
@@ -1259,25 +4763,42 @@
1259 4763 }
1260 4764 }
1261 4765
1262 4766 /**
4767 + * The raw xspeed_stats option as an array. Keys currently in use:
4768 + * 'last_purge', 'last_gc', 'gc_removed', 'gc_removed_total'.
4769 + */
4770 + public static function get_stats_option(): array {
4771 + $stats = get_option( 'xspeed_stats', array() );
4772 + return is_array( $stats ) ? $stats : array();
4773 + }
4774 +
4775 + /**
1263 4776 * Persist stats with autoload disabled — stats are only read in admin
1264 4777 * contexts, so there is no reason to inflate every frontend request's
1265 4778 * `wp_load_alloptions()` payload.
4779 + *
4780 + * MERGES into whatever is already stored. It used to overwrite, which
4781 + * was harmless while `last_purge` was the only key — with the GC keys
4782 + * alongside it, a purge would have wiped the GC history and vice versa.
1266 4783 */
1267 - private static function update_stats( array $stats ) {
1268 - if ( false === get_option( 'xspeed_stats' ) ) {
4784 + public static function update_stats( array $stats ) {
4785 + if ( false === get_option( 'xspeed_stats', false ) ) {
1269 4786 add_option( 'xspeed_stats', $stats, '', 'no' );
1270 4787 return;
1271 4788 }
1272 - update_option( 'xspeed_stats', $stats );
4789 + update_option( 'xspeed_stats', array_merge( self::get_stats_option(), $stats ) );
1273 4790 }
1274 4791
1275 4792 public static function get_stats() {
1276 4793 $count = 0;
1277 4794 $size = 0;
1278 - if ( is_dir( XSPEED_CACHE_DIR ) ) {
1279 - $files = glob( XSPEED_CACHE_DIR . '/*.html' );
4795 + // This site's entries only — on multisite the tree is shared, so an
4796 + // unscoped count reported the whole network's pages on every
4797 + // subsite's dashboard. (#6)
4798 + $flat_root = XSPEED_CACHE_DIR . '/' . self::current_host_dir();
4799 + if ( is_dir( $flat_root ) ) {
4800 + $files = glob( $flat_root . '/*.html' );
1280 4801 if ( $files ) {
1281 4802 $count = count( $files );
1282 4803 foreach ( $files as $f ) {
1283 4804 $size += filesize( $f );
@@ -1300,8 +4821,12 @@
1300 4821 Hit_Counter::collect_server_log_hits();
1301 4822
1302 4823 $stats = get_option( 'xspeed_stats', array() );
1303 4824 $totals = Hit_Counter::totals_24h();
4825 + // One read of the ground truth for both fields below: it costs a
4826 + // stat of advanced-cache.php and a tokenize of wp-config.php, and
4827 + // this runs on every dashboard poll.
4828 + $serving = self::page_cache_operational();
1304 4829 return array(
1305 4830 'cached_pages' => $count,
1306 4831 'cache_size' => $size,
1307 4832 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
@@ -1310,21 +4835,120 @@
1310 4835 // CacheHero stat grid + the Health module's panel.
1311 4836 'hits_24h' => $totals['hits'],
1312 4837 'misses_24h' => $totals['misses'],
1313 4838 'hit_ratio' => $totals['ratio'],
4839 + // Requests kept OUT of the ratio (404s + bots) — surfaced as its own
4840 + // "absorbed N scanner/bot requests" line rather than distorting the
4841 + // cache-performance number. (#118)
4842 + 'excluded_24h' => $totals['excluded'],
4843 + // True when an edge cache (Cloudflare) fronts the origin, so hits are
4844 + // absorbed before reaching PHP. The dashboard labels the ratio
4845 + // "origin-layer only" instead of implying it's the full picture. (#118)
4846 + 'edge_cache' => self::edge_cache_detected(),
4847 + /*
4848 + * Whether the page cache is actually SERVING, as opposed to
4849 + * switched on in settings. The hero read the setting alone and
4850 + * announced "Active — serving cached HTML"; a site whose
4851 + * advanced-cache.php had been taken over by another cache plugin
4852 + * got that line while every response carried
4853 + * `X-XSpeed-Cache: BYPASS`. The setting is the user's intent;
4854 + * this is the outcome, and the dashboard needs both to explain
4855 + * the difference.
4856 + */
4857 + 'page_cache_serving' => $serving,
4858 + /*
4859 + * Why not, when intent and outcome disagree. Only computed in
4860 + * that state — the detector sweep behind it is far more work than
4861 + * a stats call should do on an ordinary healthy site.
4862 + */
4863 + 'page_cache_blocked_reason' => ( ! $serving && ! empty( Settings::get()['cache_enabled'] ) )
4864 + ? ( self::acquisition_blocker() ?? self::not_serving_reason() )
4865 + : null,
1314 4866 );
1315 4867 }
1316 4868
1317 4869 /**
1318 - * Apply the user's enable/disable choice. Called only from the REST
1319 - * toggle endpoint, which is gated by current_user_can( 'manage_options' )
1320 - * and a verified REST nonce. This is the only place the drop-in and
1321 - * the WP_CACHE constant are written — they MUST NOT happen on
1322 - * register_activation_hook (WordPress.org review requirement).
4870 + * Why the cache is not serving, when nothing REFUSES to enable it.
1323 4871 *
4872 + * acquisition_blocker() answers "may we take the field", and since a
4873 + * foreign drop-in became takeable it answers null on a site where another
4874 + * plugin is nonetheless holding that file. Intent and outcome still
4875 + * disagree there, and the dashboard was left reporting the symptom -- not
4876 + * serving -- with no reason under it, which is exactly the state a user
4877 + * cannot act on.
4878 + *
4879 + * So this names the holder and says what to do: enabling takes it over.
4880 + */
4881 + private static function not_serving_reason(): ?string {
4882 + $owner = self::dropin_owner();
4883 + if ( self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner ) {
4884 + return null;
4885 + }
4886 +
4887 + if ( self::DROPIN_UNREADABLE === $owner ) {
4888 + return __( 'advanced-cache.php cannot be read, so xSpeed cannot tell whose page cache is installed.', 'xspeed' );
4889 + }
4890 +
4891 + $label = Page_Cache_Detector::dropin_owner_label();
4892 + return $label
4893 + ? sprintf(
4894 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
4895 + __( '%s is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' ),
4896 + $label
4897 + )
4898 + : __( 'Another plugin is serving the page cache. Turn the xSpeed cache off and on again to take it over.', 'xspeed' );
4899 + }
4900 +
4901 + /**
4902 + * Whether the current request should be kept OUT of the cache hit/miss
4903 + * ratio: a genuine 404, or a known bot / scanner. Runs at template_redirect
4904 + * time, so is_404() is resolved. (#118)
4905 + */
4906 + private static function miss_is_excluded(): bool {
4907 + if ( function_exists( 'is_404' ) && is_404() ) {
4908 + return true;
4909 + }
4910 + $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
4911 + ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) )
4912 + : '';
4913 + return Hit_Counter::is_bot_ua( $ua );
4914 + }
4915 +
4916 + /**
4917 + * Whether an edge cache fronts this origin. Today: the Cloudflare
4918 + * integration is connected — so an unknown share of hits is served at the
4919 + * edge and never counted here, making the origin ratio a partial view the
4920 + * dashboard must label as such. (#118)
4921 + */
4922 + private static function edge_cache_detected(): bool {
4923 + $cf = get_option( 'xspeed_module_cloudflare', array() );
4924 + return is_array( $cf ) && ! empty( $cf['enabled'] );
4925 + }
4926 +
4927 + /**
4928 + * Apply the user's enable/disable choice. Called from the REST toggle
4929 + * endpoint, which is gated by current_user_can( 'manage_options' ) and
4930 + * a verified REST nonce.
4931 + *
4932 + * This is the only path that ENABLES caching — a drop-in is never
4933 + * created for a user who hasn't opted in, which is the guideline that
4934 + * matters (a plugin must not install drop-ins or edit wp-config.php
4935 + * on a fresh activation). RESTORING the drop-in for a site that
4936 + * already has cache_enabled = true is a different act and is handled
4937 + * by restore_dropin_if_enabled() on activation and auto_heal() at
4938 + * runtime; without it every plugin update silently un-caches the site.
4939 + *
4940 + * Enabling is gated on acquisition_blocker(): if another plugin owns the
4941 + * drop-in, or WP_CACHE is written in a form we must not rewrite, nothing
4942 + * is written and the returned state carries `blocked` + a reason the
4943 + * caller can show. Callers must persist `cache_enabled` from the returned
4944 + * `enabled`, never from what they asked for.
4945 + *
1324 4946 * @param bool $enable User's choice.
1325 4947 * @return array{
1326 4948 * enabled: bool,
4949 + * blocked: bool,
4950 + * blocked_reason: ?string,
1327 4951 * dropin_installed: bool,
1328 4952 * wp_cache_constant: bool,
1329 4953 * wp_config_writable: bool,
1330 4954 * manual_snippet: ?string
@@ -1329,29 +4953,228 @@
1329 4953 * wp_config_writable: bool,
1330 4954 * manual_snippet: ?string
1331 4955 * }
1332 4956 */
1333 - public static function toggle( $enable ) {
4957 + public static function toggle( $enable, bool $consented = true ) {
4958 + Page_Cache_Detector::invalidate();
4959 + $expected = Page_Cache_Detector::inspect()['revision'];
4960 + /** Diagnostic seam; changing the expected revision can only force a safe refusal. */
4961 + $expected = (string) apply_filters( 'xspeed_page_cache_expected_revision', $expected );
4962 + $lock = self::page_cache_lock();
4963 + if ( ! is_resource( $lock ) ) {
4964 + return self::blocked_toggle_state( __( 'Could not lock page-cache ownership. Try again.', 'xspeed' ) );
4965 + }
4966 + try {
4967 + Page_Cache_Detector::invalidate();
4968 + $fresh = Page_Cache_Detector::inspect()['revision'];
4969 + if ( ! hash_equals( (string) $expected, (string) $fresh ) ) {
4970 + return self::blocked_toggle_state( __( 'Page-cache ownership changed while xSpeed was checking it. Nothing was changed; try again.', 'xspeed' ) );
4971 + }
4972 + $state = self::toggle_unlocked( (bool) $enable, $consented );
4973 + return $state;
4974 + } finally {
4975 + flock( $lock, LOCK_UN );
4976 + fclose( $lock );
4977 + }
4978 + }
4979 +
4980 + /** Run the page-cache mutation while toggle() owns the scoped lock. */
4981 + /**
4982 + * @param bool $consented The user asked for this in the dashboard, so a
4983 + * foreign drop-in may be taken over. False on the
4984 + * unattended paths, which stand down instead.
4985 + */
4986 + private static function toggle_unlocked( bool $enable, bool $consented = true ) {
1334 4987 $enable = (bool) $enable;
1335 4988
1336 4989 if ( $enable ) {
1337 - $dropin_ok = self::install_dropin();
1338 - $wp_config_ok = self::set_wp_cache_constant( true );
4990 + /*
4991 + * Preflight. The drop-in and the WP_CACHE define are shared,
4992 + * single-occupancy state; if we do not own them, no part of this
4993 + * runs — not the drop-in, not wp-config.php, not the rewrite
4994 + * block. Refusing whole is the point: a partial enable leaves the
4995 + * site claiming a cache it cannot serve.
4996 + *
4997 + * Every caller routes through here (REST, onboarding, MCP, CLI,
4998 + * the optimize runner, Pro's migration), so the gate lives here
4999 + * rather than being re-implemented at each entry point.
5000 + *
5001 + * Except when there is nothing to acquire. A site where we
5002 + * already own the drop-in and are already serving is being asked
5003 + * to stay as it is, and the gate answers a different question —
5004 + * "is the field free to take" — which a merely ACTIVE competitor
5005 + * makes false. So "make sure caching is on", from an AI agent,
5006 + * the optimize runner or Pro's migration, came back as a refusal
5007 + * telling the user to deactivate a plugin on a site that was
5008 + * caching perfectly. The dashboard never saw it, because nobody
5009 + * presses Enable on a cache that is already enabled.
5010 + *
5011 + * Only the GATE is skipped. The writes below still run, and every
5012 + * one of them is individually idempotent — which matters, because
5013 + * this is the path CacheModule re-bakes the drop-in through when
5014 + * an exclusion rule or the TTL changes (#240, #251), and the path
5015 + * auto_heal() restores a stripped WP_CACHE through. Returning
5016 + * early here left both of those doing nothing at all, silently,
5017 + * on exactly the healthy sites this branch is about.
5018 + */
5019 + $reasserting = self::page_cache_operational() && self::DROPIN_XSPEED === self::dropin_owner();
5020 + $blocker = $reasserting ? null : self::acquisition_blocker();
5021 +
5022 + /*
5023 + * Taking over another plugin's drop-in needs the user to have
5024 + * asked for it. On the dashboard they did -- they clicked the
5025 + * switch, having been told whose file it is. The UNATTENDED
5026 + * callers have no such click: restore_dropin_if_enabled() runs
5027 + * after a plugin update and auto_heal() on an admin page load,
5028 + * both from nothing more than `cache_enabled` still being true.
5029 + *
5030 + * A competitor installed since that flag was set would have its
5031 + * page cache seized by a background repair, which is the silent
5032 + * acquisition this plugin refuses to perform. So those callers
5033 + * pass $consented = false and stand down instead.
5034 + */
5035 + if ( null === $blocker && ! $consented && self::DROPIN_FOREIGN === self::dropin_owner() ) {
5036 + // Name the owner. This string is rendered by host plugins
5037 + // through Host::enable_page_cache(), and an unnamed refusal
5038 + // is what made every host invent its own explanation.
5039 + $owner_label = Page_Cache_Detector::dropin_owner_label();
5040 + return self::blocked_toggle_state(
5041 + $owner_label
5042 + ? sprintf(
5043 + /* translators: %s: the page-caching plugin that owns advanced-cache.php. */
5044 + __( '%s owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' ),
5045 + $owner_label
5046 + )
5047 + : __( 'Another plugin owns advanced-cache.php, so xSpeed left it alone. Enable the cache from the xSpeed dashboard to take it over.', 'xspeed' )
5048 + );
5049 + }
5050 + if ( null !== $blocker ) {
5051 + Activity_Log::record(
5052 + 'cache_enable_blocked',
5053 + 'Cache not enabled — ' . $blocker,
5054 + Activity_Log::WARN
5055 + );
5056 +
5057 + return self::blocked_toggle_state( $blocker );
5058 + }
5059 +
5060 + $dropin_path = WP_CONTENT_DIR . '/advanced-cache.php';
5061 + $config_path = self::wp_config_path();
5062 + $dropin_before = file_exists( $dropin_path ) ? self::read_file( $dropin_path ) : null;
5063 + $config_before = '' !== $config_path ? self::read_file( $config_path ) : null;
5064 + $dropin_ok = self::install_dropin();
5065 + if ( ! $dropin_ok ) {
5066 + $partial = self::read_file( $dropin_path );
5067 + if ( is_string( $partial ) && xspeed_has_canonical_dropin_signature( $partial ) ) {
5068 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $partial, $config_path, $config_before, null );
5069 + }
5070 + /*
5071 + * Preflight said the field was clear, so this is a filesystem
5072 + * failure (or a drop-in that appeared in between). Without the
5073 + * drop-in there is no cache to enable, and persisting
5074 + * cache_enabled anyway is what produced sites reporting a
5075 + * healthy cache while serving every request uncached.
5076 + */
5077 + $reason = __( 'Could not write wp-content/advanced-cache.php. Check filesystem permissions.', 'xspeed' );
5078 + Activity_Log::record(
5079 + 'cache_enable_blocked',
5080 + 'Cache not enabled — ' . $reason,
5081 + Activity_Log::WARN
5082 + );
5083 +
5084 + return array(
5085 + 'enabled' => false,
5086 + 'blocked' => true,
5087 + 'blocked_reason' => $reason,
5088 + 'dropin_installed' => false,
5089 + 'wp_cache_constant' => false,
5090 + 'rewrite_installed' => false,
5091 + 'wp_config_writable' => self::wp_config_writable(),
5092 + 'manual_snippet' => null,
5093 + 'nginx_snippet' => self::nginx_snippet(),
5094 + 'nginx_server_block' => self::full_nginx_server_block(),
5095 + );
5096 + }
5097 +
5098 + $dropin_written = self::read_file( $dropin_path );
5099 + self::set_wp_cache_constant( true );
5100 + $config_written = '' !== $config_path ? self::read_file( $config_path ) : null;
5101 + Page_Cache_Detector::invalidate();
5102 + $dropin_ours = self::DROPIN_XSPEED === self::dropin_owner();
5103 + $constant_state = self::wp_cache_define_state();
5104 + $constant_ok = 'true' === $constant_state;
5105 +
5106 + /*
5107 + * A wp-config.php we cannot write at all is a supported state, not
5108 + * a failed transaction. Plenty of managed hosts ship the file
5109 + * read-only; there the drop-in is ours and installed, the cache
5110 + * works the moment WP_CACHE exists, and the one line to paste
5111 + * comes back as `manual_snippet`. Rolling back instead left those
5112 + * hosts unable to turn the page cache on by any route — including
5113 + * when the user had already pasted the define, since the write
5114 + * fails on an unwritable file whatever value is already there.
5115 + *
5116 + * `undefined` ONLY. `false` looks eligible — this method would
5117 + * have rewritten it — but the snippet we hand back cannot work
5118 + * there: the file already says `define( 'WP_CACHE', false )`, the
5119 + * first define() call wins, and a user who pastes our line via
5120 + * FTP ends up with a cache that never serves AND a `duplicate`
5121 + * wp-config that blocks every future toggle in both directions.
5122 + * They have to edit the existing line, which means refusing here
5123 + * and saying so. `duplicate` and `dynamic` are refused by
5124 + * acquisition_blocker() before we get here, and if one appears in
5125 + * the race window it must still fail closed.
5126 + */
5127 + $manual_mode = ! $constant_ok
5128 + && 'undefined' === $constant_state
5129 + && ! self::can_write_wp_config();
5130 +
5131 + if ( ! $dropin_ours || ( ! $constant_ok && ! $manual_mode ) ) {
5132 + if ( ! self::can_write_wp_config() ) {
5133 + $reason = 'false' === $constant_state
5134 + ? __( "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' )
5135 + : __( 'xSpeed could not verify the complete page-cache write, and wp-config.php is not writable. Its changes were rolled back.', 'xspeed' );
5136 + } else {
5137 + $reason = __( 'xSpeed could not verify the complete page-cache write. Its changes were rolled back.', 'xspeed' );
5138 + }
5139 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
5140 + return self::blocked_toggle_state( $reason );
5141 + }
5142 + $wp_config_ok = $constant_ok;
1339 5143 $rewrite_ok = self::install_rewrite();
1340 5144 self::ensure_hits_log_file();
1341 5145 self::sync_mobile_flag();
1342 5146 $snippet = $wp_config_ok ? null : "define( 'WP_CACHE', true );";
5147 + Settings::update( array( 'cache_enabled' => true ) );
5148 + if ( empty( Settings::get()['cache_enabled'] ) ) {
5149 + self::remove_rewrite();
5150 + self::rollback_page_cache_artifacts( $dropin_path, $dropin_before, $dropin_written, $config_path, $config_before, $config_written );
5151 + delete_option( 'xspeed_page_cache_ownership_receipt' );
5152 + return self::blocked_toggle_state( __( 'xSpeed could not save the page-cache setting. Its file changes were rolled back.', 'xspeed' ) );
5153 + }
1343 5154
1344 - Activity_Log::record(
1345 - 'cache_enabled_event',
1346 - $wp_config_ok
1347 - ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
1348 - : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
1349 - $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
1350 - );
5155 + /*
5156 + * Only when this call actually changed something. auto_heal() runs
5157 + * the enable transaction on every admin_init, and an unconditional
5158 + * entry filled the 50-slot log with identical "Cache enabled" lines
5159 + * within 50 wp-admin page loads, evicting every real event — plus
5160 + * an option write per admin request. The sentence is also false
5161 + * when nothing was installed.
5162 + */
5163 + if ( $dropin_written !== $dropin_before || $config_written !== $config_before ) {
5164 + Activity_Log::record(
5165 + 'cache_enabled_event',
5166 + $wp_config_ok
5167 + ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
5168 + : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
5169 + $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
5170 + );
5171 + }
1351 5172
1352 5173 return array(
1353 5174 'enabled' => true,
5175 + 'blocked' => false,
5176 + 'blocked_reason' => null,
1354 5177 'dropin_installed' => (bool) $dropin_ok,
1355 5178 'wp_cache_constant' => (bool) $wp_config_ok,
1356 5179 'rewrite_installed' => (bool) $rewrite_ok,
1357 5180 'wp_config_writable' => self::wp_config_writable(),
@@ -1364,26 +5187,168 @@
1364 5187 'nginx_server_block' => self::full_nginx_server_block(),
1365 5188 );
1366 5189 }
1367 5190
5191 + /*
5192 + * Whose advanced-cache.php is on disk decides how much of the disable
5193 + * below may run. Read it once, before anything is touched.
5194 + */
5195 + $owner = self::dropin_owner();
5196 + $not_ours = self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner;
5197 + if ( ! self::set_wp_cache_constant( false ) ) {
5198 + /*
5199 + * The mirror of the enable path. A wp-config.php nobody can write
5200 + * does not trap the user in a cache they turned off: WP_CACHE on
5201 + * its own does nothing once advanced-cache.php is gone, and core
5202 + * simply skips the missing drop-in. Refusing here left the
5203 + * read-only managed hosts able to enable the page cache and never
5204 + * able to disable it again.
5205 + *
5206 + * A drop-in that is not ours reaches the same conclusion by a
5207 + * different road. WP_CACHE is then the switch for THEIR cache, so
5208 + * set_wp_cache_constant() refuses it — correctly, and permanently,
5209 + * because nothing the user does to xSpeed will make that file ours
5210 + * again. Treating that refusal as a failed disable was a trap with
5211 + * no exit: install any competing cache plugin while xSpeed's cache
5212 + * was on, and xSpeed's toggle could never be turned off again,
5213 + * while the dashboard went on claiming a cache that was serving
5214 + * nothing. Turning xSpeed off is entirely within our own state —
5215 + * our setting, our rewrite block — so it proceeds, and their
5216 + * constant and their file are left exactly as they are.
5217 + */
5218 + /*
5219 + * Every reason set_wp_cache_constant() refuses is structural
5220 + * except one, and the exception is the only one worth blocking
5221 + * on. It will not touch a constant it cannot prove is ours; it
5222 + * will not rewrite a define it cannot read as a literal —
5223 + * duplicate, dynamic, or inside a conditional; and it cannot
5224 + * write a file the filesystem will not let it write. None of
5225 + * those improve on a retry, and all of them leave a WP_CACHE
5226 + * that does nothing once our drop-in is gone. What is left — our
5227 + * own constant, in a shape we can rewrite, in a file we can
5228 + * write, and the write still failed — is a real I/O failure, and
5229 + * that one still refuses so the user is not told a cache was
5230 + * turned off while it goes on serving.
5231 + *
5232 + * The proof, not the drop-in, is the test. A user who pasted our
5233 + * manual snippet on a locked-down host has a WP_CACHE line with
5234 + * no receipt on it; if their drop-in later goes missing, we can
5235 + * never prove that line is ours, so refusing left the toggle
5236 + * stuck on with no way out but enabling first and disabling
5237 + * again. Nothing loads a drop-in that is not there, so the line
5238 + * is inert either way and the disable proceeds without it.
5239 + */
5240 + $leave_it = ! self::wp_cache_define_is_ours_to_remove( $owner )
5241 + || ! in_array( self::wp_cache_define_state(), array( 'true', 'false', 'undefined' ), true )
5242 + || ! self::can_write_wp_config();
5243 + if ( ! $leave_it ) {
5244 + return self::blocked_toggle_state( __( 'xSpeed could not safely remove its WP_CACHE setting. The cache remains enabled.', 'xspeed' ) );
5245 + }
5246 + }
1368 5247 self::remove_dropin();
1369 - self::set_wp_cache_constant( false );
5248 + if ( self::DROPIN_XSPEED === self::dropin_owner() ) {
5249 + // Put WP_CACHE back, and say so if we could not. Reporting a
5250 + // hardcoded `enabled: true` here claimed a working cache on a
5251 + // site whose constant we had just failed to restore.
5252 + // Put WP_CACHE back, then read the outcome off disk rather than
5253 + // trusting the write's return value — a write can report failure
5254 + // for a value that was already correct, and the question the
5255 + // caller needs answered is whether the cache serves.
5256 + self::set_wp_cache_constant( true );
5257 + return self::blocked_toggle_state(
5258 + self::page_cache_operational()
5259 + ? __( 'xSpeed could not remove its page-cache drop-in. The cache remains enabled.', 'xspeed' )
5260 + : __( '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' )
5261 + );
5262 + }
1370 5263 self::remove_rewrite();
5264 + /*
5265 + * The .htaccess block serves cached HTML straight off disk without
5266 + * ever reaching PHP, so a block we failed to remove keeps answering
5267 + * requests from a cache the user just turned off — and nothing else
5268 + * in this method can stop it. remove_rewrite() also returns false
5269 + * when there is no .htaccess to clean, which is the ordinary case,
5270 + * so ask the file rather than trust the return value.
5271 + */
5272 + if ( self::rewrite_installed() ) {
5273 + if ( $not_ours ) {
5274 + // Nothing to roll back — under a foreign drop-in this method
5275 + // removed no drop-in and wrote no constant, and it could not
5276 + // put either back if it wanted to. Say what is actually left.
5277 + 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' ) );
5278 + }
5279 + /*
5280 + * Roll the disable back. Both calls can fail — a filesystem that
5281 + * would not let us remove the block may not let us write the
5282 + * drop-in either — and discarding their results reported an
5283 + * enabled cache over a site left with no drop-in and no
5284 + * constant. Fall through to the default state so the artifact
5285 + * fields are read from disk rather than asserted.
5286 + */
5287 + self::install_dropin();
5288 + self::set_wp_cache_constant( true );
5289 + // Both of those can fail — a filesystem that would not let us
5290 + // remove the block may not let us write the drop-in either — so
5291 + // the message follows what is on disk afterwards, not what the
5292 + // calls returned.
5293 + return self::blocked_toggle_state(
5294 + self::page_cache_operational()
5295 + ? __( 'xSpeed could not remove its rewrite rules from .htaccess, which would keep serving cached pages. The cache remains enabled.', 'xspeed' )
5296 + : __( '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' )
5297 + );
5298 + }
1371 5299 // Drop the device-bucket marker too — with the drop-in gone there's
1372 5300 // nothing left to read it, and leaving it behind would dirty a fresh
1373 5301 // re-enable (and leaks across test runs).
1374 5302 self::sync_mobile_flag( false );
5303 + Settings::update( array( 'cache_enabled' => false ) );
5304 + if ( ! empty( Settings::get()['cache_enabled'] ) ) {
5305 + if ( $not_ours ) {
5306 + // Same as above: there is nothing of ours on disk to restore.
5307 + return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state.', 'xspeed' ) );
5308 + }
5309 + self::install_dropin();
5310 + self::set_wp_cache_constant( true );
5311 + return self::blocked_toggle_state( __( 'xSpeed could not save the disabled state. The page cache was restored.', 'xspeed' ) );
5312 + }
1375 5313
5314 + // A WP_CACHE we could not remove because wp-config.php is read-only
5315 + // is left behind deliberately (see above) — say so rather than
5316 + // reporting a constant that is still in the file as gone.
5317 + $constant_left = 'true' === self::wp_cache_define_state();
5318 + /*
5319 + * Say why the constant is still there, because there are now three
5320 + * different reasons and they call for different advice. Keyed off the
5321 + * same facts $leave_it was, so the log cannot drift from the decision
5322 + * it is describing — it did, briefly, and reported a wp-config.php as
5323 + * unwritable when the real reason was that we could not prove the
5324 + * line was ours.
5325 + */
5326 + if ( self::DROPIN_UNREADABLE === $owner ) {
5327 + $log_message = 'Cache disabled. advanced-cache.php could not be read, so it and the WP_CACHE setting were left untouched.';
5328 + } elseif ( $not_ours ) {
5329 + $log_message = 'Cache disabled. Another plugin owns advanced-cache.php, so its drop-in and its WP_CACHE setting were left untouched.';
5330 + } elseif ( ! $constant_left ) {
5331 + $log_message = 'Cache disabled. Drop-in removed.';
5332 + } elseif ( ! self::wp_cache_define_is_ours_to_remove( $owner ) ) {
5333 + $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.';
5334 + } elseif ( ! self::can_write_wp_config() ) {
5335 + $log_message = 'Cache disabled. Drop-in removed; wp-config.php not writable, so WP_CACHE was left in place (harmless without the drop-in).';
5336 + } else {
5337 + $log_message = 'Cache disabled. Drop-in removed; WP_CACHE was left in place (harmless without the drop-in).';
5338 + }
1376 5339 Activity_Log::record(
1377 5340 'cache_disabled_event',
1378 - 'Cache disabled. Drop-in removed.',
1379 - Activity_Log::INFO
5341 + $log_message,
5342 + $constant_left ? Activity_Log::WARN : Activity_Log::INFO
1380 5343 );
1381 5344
1382 5345 return array(
1383 5346 'enabled' => false,
5347 + 'blocked' => false,
5348 + 'blocked_reason' => null,
1384 5349 'dropin_installed' => false,
1385 - 'wp_cache_constant' => false,
5350 + 'wp_cache_constant' => $constant_left,
1386 5351 'rewrite_installed' => false,
1387 5352 'wp_config_writable' => self::wp_config_writable(),
1388 5353 'manual_snippet' => null,
1389 5354 'nginx_snippet' => self::nginx_snippet(),
@@ -1390,9 +5355,134 @@
1390 5355 'nginx_server_block' => self::full_nginx_server_block(),
1391 5356 );
1392 5357 }
1393 5358
5359 + /** Acquire the local lock that serializes page-cache ownership changes. */
5360 + private static function page_cache_lock() {
5361 + $path = WP_CONTENT_DIR . '/.xspeed-page-cache.lock';
5362 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.PHP.NoSilencedErrors.Discouraged -- flock requires a local handle; failure is a safe blocked result.
5363 + $lock = @fopen( $path, 'c+' );
5364 + if ( ! is_resource( $lock ) || ! flock( $lock, LOCK_EX ) ) {
5365 + return false;
5366 + }
5367 + return $lock;
5368 + }
5369 +
1394 5370 /**
5371 + * Build the stable response shape for a refused transaction.
5372 + *
5373 + * The artifact fields report what is ON DISK, not zeros. A refusal means
5374 + * xSpeed changed nothing — on a site already running our cache that is
5375 + * exactly the state where the drop-in and WP_CACHE are both still in
5376 + * place and still serving hits. Hardcoding false told the dashboard the
5377 + * cache had been dismantled every time a refusal was returned.
5378 + */
5379 + private static function blocked_toggle_state( string $reason ): array {
5380 + /*
5381 + * `enabled` answers ONE question: is the page cache operational right
5382 + * now. Not what was asked for, and not what the option says.
5383 + *
5384 + * WordPress loads advanced-cache.php only when WP_CACHE is truthy, so
5385 + * those two files together are the whole answer, and reading them is
5386 + * the only source that cannot go stale. Both of the alternatives were
5387 + * tried here and both produced wrong answers on real paths: a
5388 + * hardcoded false told a caller the cache had gone away on a site
5389 + * still serving hits, and the persisted setting told a caller the
5390 + * cache was healthy after a rollback had just removed the artifacts
5391 + * — the option is not written until the end of the transaction, so
5392 + * mid-transaction it is stale by construction.
5393 + *
5394 + * Deliberately not a parameter. Every branch that got to choose its
5395 + * own answer eventually chose wrong.
5396 + */
5397 + return array(
5398 + 'enabled' => self::page_cache_operational(),
5399 + 'blocked' => true,
5400 + 'blocked_reason' => $reason,
5401 + 'dropin_installed' => self::DROPIN_XSPEED === self::dropin_owner(),
5402 + 'wp_cache_constant' => 'true' === self::wp_cache_define_state(),
5403 + 'rewrite_installed' => self::rewrite_installed(),
5404 + 'wp_config_writable' => self::wp_config_writable(),
5405 + 'manual_snippet' => null,
5406 + 'nginx_snippet' => self::nginx_snippet(),
5407 + 'nginx_server_block' => self::full_nginx_server_block(),
5408 + );
5409 + }
5410 +
5411 + /**
5412 + * The wp-config.php line a user must paste, or null when none is needed.
5413 + *
5414 + * Non-null only where the drop-in is ours and WP_CACHE is not set to true
5415 + * in a file we can write — the read-only managed host. Everywhere else the
5416 + * constant is ours to manage and there is nothing to ask for.
5417 + */
5418 + public static function manual_wp_cache_snippet(): ?string {
5419 + if ( self::DROPIN_XSPEED !== self::dropin_owner() ) {
5420 + return null;
5421 + }
5422 + if ( 'true' === self::wp_cache_define_state() ) {
5423 + return null;
5424 + }
5425 + return self::wp_config_writable() ? null : "define( 'WP_CACHE', true );";
5426 + }
5427 +
5428 + /**
5429 + * Is the page cache serving right now?
5430 + *
5431 + * Two things decide it, and `WP_CACHE` is not one of them.
5432 + *
5433 + * xSpeed serves a cached page from `template_redirect` whenever the
5434 + * setting is on — see the `HIT (php)` mark on that path, which exists
5435 + * precisely for "the drop-in isn't loaded". `advanced-cache.php` and the
5436 + * `WP_CACHE` constant that loads it are the FAST path: they answer before
5437 + * WordPress boots, which is worth a lot of milliseconds and nothing at
5438 + * all to the question of whether pages are being served from cache.
5439 + *
5440 + * Conflating the two reported a dead cache over a live one. On a managed
5441 + * host with an unwritable wp-config.php — the exact case the manual
5442 + * snippet exists for — one card said "Your cache works on every request",
5443 + * "On, but not serving", "nothing will be cached until you add this line"
5444 + * and "hit ratio 67%", all at once, and told the user to edit a file they
5445 + * have no permission to write. The released 1.2.1 reported that site as
5446 + * active, correctly.
5447 + *
5448 + * So: the setting, and whether anyone else holds the drop-in. A foreign
5449 + * drop-in answers before WordPress loads us, so ours never runs and we
5450 + * genuinely are not serving. An unreadable one we must assume the same of.
5451 + * Everything else — our drop-in, or none at all — serves.
5452 + *
5453 + * Public because it is part of the host-plugin contract — see Host. A
5454 + * plugin that installed xSpeed needs to be able to say whether the cache
5455 + * it asked for is actually serving, and no combination of settings reads
5456 + * answers that.
5457 + */
5458 + public static function page_cache_operational(): bool {
5459 + $settings = Settings::get();
5460 + if ( empty( $settings['cache_enabled'] ) ) {
5461 + return false;
5462 + }
5463 + $owner = self::dropin_owner();
5464 + return self::DROPIN_FOREIGN !== $owner && self::DROPIN_UNREADABLE !== $owner;
5465 + }
5466 +
5467 + /** Restore exact snapshots only while disk still matches our own write. */
5468 + 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 {
5469 + // Roll back only files that still carry xSpeed's just-written state.
5470 + if ( null !== $dropin_written && hash_equals( $dropin_written, (string) self::read_file( $dropin_path ) ) ) {
5471 + if ( null === $dropin_before ) {
5472 + wp_delete_file( $dropin_path );
5473 + } else {
5474 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
5475 + file_put_contents( $dropin_path, $dropin_before );
5476 + }
5477 + }
5478 + 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 ) ) {
5479 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Exact compare-and-swap rollback under the scoped lock.
5480 + file_put_contents( $config_path, $config_before );
5481 + }
5482 + }
5483 +
5484 + /**
1395 5485 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
1396 5486 * direct is_writable() under WordPress.WP.AlternativeFunctions.
1397 5487 */
1398 5488 private static function wp_config_writable() {
@@ -1429,10 +5519,11 @@
1429 5519 * they don't share a user at all. A default-umask 0644 file is then
1430 5520 * unwritable by nginx, the access_log write silently fails, and the
1431 5521 * dashboard shows a 0% hit ratio even though static HITs are serving.
1432 5522 * So we widen the dir to 0777 and the file to 0666 — group/other write —
1433 - * so whatever uid nginx runs as can append. (The file holds only HIT
1434 - * request lines, no secrets.)
5523 + * so whatever uid nginx runs as can append. The file holds HIT request
5524 + * lines and must be protected like an access log: paths and queries can
5525 + * contain sensitive values.
1435 5526 */
1436 5527 /**
1437 5528 * Directory holding the nginx hit log. Lives under uploads/, NOT the
1438 5529 * cache dir — uninstall.php and a cache purge both delete the cache
@@ -1475,12 +5566,195 @@
1475 5566 * and the fast pre-WP path was silently dead.
1476 5567 *
1477 5568 * @param bool|null $enabled Force a state; null reads the current setting.
1478 5569 */
5570 + /**
5571 + * Write the subdirectory-multisite path list the drop-in needs to work
5572 + * out which blog a request belongs to.
5573 + *
5574 + * The drop-in runs before WordPress, so it cannot call is_multisite()
5575 + * or get_blog_details(). It can only see REQUEST_URI — so we persist the
5576 + * network's blog paths (one per line, longest first) next to the cache
5577 + * files, exactly as sync_mobile_flag() persists the device flag. The
5578 + * drop-in prefix-matches the URI against that list to pick the same
5579 + * bucket Cache::current_host_dir() picks. (#6)
5580 + *
5581 + * No file is written for a single site or a subdomain network — there
5582 + * the host alone identifies the blog and the bucket carries no prefix.
5583 + */
5584 + public static function sync_site_paths(): void {
5585 + $file = XSPEED_CACHE_DIR . '/.site-paths';
5586 +
5587 + $needed = function_exists( 'is_multisite' ) && is_multisite()
5588 + && ( ! function_exists( 'is_subdomain_install' ) || ! is_subdomain_install() );
5589 +
5590 + if ( ! $needed ) {
5591 + if ( file_exists( $file ) ) {
5592 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
5593 + @unlink( $file );
5594 + }
5595 + return;
5596 + }
5597 +
5598 + if ( ! function_exists( 'get_sites' ) ) {
5599 + return;
5600 + }
5601 +
5602 + $paths = array();
5603 + foreach ( get_sites( array( 'number' => 0 ) ) as $site ) {
5604 + $prefix = self::path_prefix_segment( (string) $site->path );
5605 + if ( '' !== $prefix ) {
5606 + // Store the raw path so the drop-in can prefix-match a URI,
5607 + // alongside the segment it maps to.
5608 + $paths[ trim( (string) $site->path, '/' ) ] = $prefix;
5609 + }
5610 + }
5611 +
5612 + if ( empty( $paths ) ) {
5613 + if ( file_exists( $file ) ) {
5614 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- see above.
5615 + @unlink( $file );
5616 + }
5617 + return;
5618 + }
5619 +
5620 + // Longest path first so /a/b wins over /a.
5621 + uksort(
5622 + $paths,
5623 + static function ( $x, $y ) {
5624 + return strlen( (string) $y ) <=> strlen( (string) $x );
5625 + }
5626 + );
5627 +
5628 + $lines = array();
5629 + foreach ( $paths as $raw => $segment ) {
5630 + $lines[] = $raw . '|' . $segment;
5631 + }
5632 +
5633 + if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
5634 + return;
5635 + }
5636 + // 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.
5637 + file_put_contents( $file, implode( "\n", $lines ), LOCK_EX );
5638 + }
5639 +
5640 + /**
5641 + * Compile `ignored_query_params` into a regex the DROP-IN can use.
5642 + *
5643 + * Tracking traffic was cached but never served fast. should_cache()
5644 + * learned to allow `?utm_source=…` through and cache_key() strips the
5645 + * query, so `/post` and `/post?utm_source=x` share one entry — but the
5646 + * drop-in still bailed on ANY query string, so every visitor from an
5647 + * email or ad campaign paid a full WordPress boot to be handed a file
5648 + * that was already on disk. On a marketing site that is most of the
5649 + * paid traffic taking the slowest path. (#13)
5650 + *
5651 + * The drop-in runs before WordPress, so it cannot read the option or
5652 + * call Glob_Matcher. It gets a precompiled alternation instead, written
5653 + * next to the cache files exactly as sync_mobile_flag() writes the
5654 + * device flag. Regenerated whenever cache settings are saved.
5655 + *
5656 + * Only the KEYS matter: a param whose name is on the list contributes
5657 + * nothing to the response, so the entry keyed without it is correct.
5658 + * Anything not on the list means the drop-in must stand down and let
5659 + * PHP decide — the file is deleted rather than left stale when the
5660 + * list is empty, so a missing sidecar always fails safe.
5661 + */
5662 + public static function sync_query_allowlist(): void {
5663 + $file = XSPEED_CACHE_DIR . '/.ignored-query-params';
5664 +
5665 + /*
5666 + * Stored read, not Settings_Manager::get() — this runs from boot(),
5667 + * before translation is legal (see stored_cache_opts()).
5668 + *
5669 + * A raw read applies no schema defaults, and this field's default is a
5670 + * long tracking-parameter list, NOT empty. Falling back to array()
5671 + * would strip that whole allow-list from the drop-in on any install
5672 + * that has never saved the Cache panel. So fall back to the schema's
5673 + * own default, read from the module without building its labels.
5674 + */
5675 + $opts = self::stored_cache_opts();
5676 + $ignored = is_array( $opts['ignored_query_params'] ?? null )
5677 + ? $opts['ignored_query_params']
5678 + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_IGNORED_QUERY_PARAMS;
5679 +
5680 + $parts = array();
5681 + foreach ( $ignored as $pattern ) {
5682 + $pattern = trim( (string) $pattern );
5683 + if ( '' === $pattern ) {
5684 + continue;
5685 + }
5686 + if ( '~' === $pattern[0] ) {
5687 + // Raw regex, PHP-side dialect. Keep it — unlike a server
5688 + // config, the drop-in runs the same PCRE engine, so the
5689 + // pattern behaves identically. Anchored below with the rest.
5690 + $body = substr( $pattern, 1 );
5691 + if ( '' !== $body && false !== @preg_match( '#^(?:' . $body . ')$#', '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed user pattern must be dropped, not fatal.
5692 + $parts[] = $body;
5693 + }
5694 + continue;
5695 + }
5696 + // Glob semantics, same as Glob_Matcher: * is any run, ? is one.
5697 + $esc = preg_quote( $pattern, '#' );
5698 + $esc = str_replace( array( '\*', '\?' ), array( '.*', '.' ), $esc );
5699 + $parts[] = $esc;
5700 + }
5701 +
5702 + if ( empty( $parts ) ) {
5703 + if ( file_exists( $file ) ) {
5704 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
5705 + @unlink( $file );
5706 + }
5707 + return;
5708 + }
5709 +
5710 + if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) {
5711 + return;
5712 + }
5713 +
5714 + $payload = '(?:' . implode( '|', array_unique( $parts ) ) . ')';
5715 +
5716 + // Only write when the value actually changed. This runs from
5717 + // reconcile_mobile_separate() on CacheModule::boot(), so an
5718 + // unconditional write cost a file write and an exclusive lock on every
5719 + // request that boots WordPress — every MISS, every BYPASS, every admin
5720 + // screen, every REST call. sync_mobile_flag() below is the model: it
5721 + // touches the marker only when the setting flips.
5722 + // 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.
5723 + if ( is_readable( $file ) && (string) @file_get_contents( $file ) === $payload ) {
5724 + return;
5725 + }
5726 +
5727 + // 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.
5728 + file_put_contents( $file, $payload, LOCK_EX );
5729 + }
5730 +
5731 + /**
5732 + * CacheModule's STORED settings, read straight from the option.
5733 + *
5734 + * `Settings_Manager::get( 'cache' )` builds CacheModule's settings schema,
5735 + * whose labels are declared through `__()`. The reconcile chain below runs
5736 + * from `CacheModule::boot()` on `plugins_loaded` — before
5737 + * `after_setup_theme`, the point WordPress 6.7+ treats as safe to
5738 + * translate — so going through the schema there fires
5739 + * `_load_textdomain_just_in_time` on every request AND resolves the labels
5740 + * against a domain that is not loaded yet.
5741 + *
5742 + * The callers here need stored values, not schema metadata, so a raw read
5743 + * is equivalent. It applies NO defaults or coercion: read each key with a
5744 + * fallback matching the schema's own default.
5745 + *
5746 + * @return array<string,mixed>
5747 + */
5748 + private static function stored_cache_opts(): array {
5749 + $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() );
5750 + return is_array( $stored ) ? $stored : array();
5751 + }
5752 +
1479 5753 public static function sync_mobile_flag( $enabled = null ): void {
1480 5754 if ( null === $enabled ) {
1481 - $opts = Settings_Manager::get( 'cache' );
1482 - $enabled = ! empty( $opts['mobile_separate'] );
5755 + $stored = self::stored_cache_opts();
5756 + $enabled = ! empty( $stored['mobile_separate'] );
1483 5757 }
1484 5758 $dir = XSPEED_CACHE_DIR;
1485 5759 $flag = $dir . '/.mobile-separate';
1486 5760 if ( $enabled ) {
@@ -1546,8 +5820,16 @@
1546 5820 * reconcile, and toggle() handles install/teardown itself.
1547 5821 */
1548 5822 public static function reconcile_mobile_separate(): void {
1549 5823 self::sync_mobile_flag();
5824 + if ( defined( 'XSPEED_CACHE_DIR' ) ) {
5825 + // Keep the drop-in's view of the network's blog paths current — a
5826 + // site added or removed changes which bucket its URLs belong to. (#6)
5827 + self::sync_site_paths();
5828 + // Keep the drop-in's copy of the query allow-list current — a param
5829 + // added in settings must reach the fast path too. (#13)
5830 + self::sync_query_allowlist();
5831 + }
1550 5832
1551 5833 // The rewrite/static reconciliation below needs the plugin's path
1552 5834 // constants. They're absent in early-boot / unit-test contexts where
1553 5835 // only the drop-in flag matters — bail to the flag-only behavior then.
@@ -1563,8 +5845,31 @@
1563 5845
1564 5846 $rewrite_present = self::rewrite_installed();
1565 5847 $rewrite_wanted = self::static_rewrite_allowed();
1566 5848
5849 + // Did the thing that actually invalidates cache KEYS change?
5850 + // mobile_separate buckets entries as |d / |m, so flipping it makes
5851 + // stored entries mis-bucketed and they must go. A rewrite-state
5852 + // mismatch from anything else (e.g. mod_headers detection, a hand-
5853 + // edited .htaccess) changes no key at all — the same files are still
5854 + // valid, they're just served by PHP instead of by the web server.
5855 + // Purging there is what let one WP-CLI call wipe the whole cache on
5856 + // every bootstrap. (#138)
5857 + //
5858 + // Read the setting from the SAME place static_rewrite_allowed() and
5859 + // sync_mobile_flag() do — the cache module's settings, not the
5860 + // top-level xspeed_options — or this marker would track a key that
5861 + // never changes and a real flip would go unnoticed.
5862 + // Stored read — this runs from boot(); see stored_cache_opts().
5863 + $cache_opts = self::stored_cache_opts();
5864 + $mobile_now = ! empty( $cache_opts['mobile_separate'] );
5865 + $mobile_last = get_option( 'xspeed_last_mobile_separate', null );
5866 + $mobile_flipped = ( null !== $mobile_last && (bool) (int) $mobile_last !== $mobile_now );
5867 +
5868 + if ( (string) (int) $mobile_now !== (string) $mobile_last ) {
5869 + update_option( 'xspeed_last_mobile_separate', $mobile_now ? '1' : '0', false );
5870 + }
5871 +
1567 5872 if ( $rewrite_present === $rewrite_wanted ) {
1568 5873 // Already consistent — nothing flipped, leave caches intact so a
1569 5874 // plain settings save (e.g. expiry change) doesn't blow the cache.
1570 5875 return;
@@ -1569,17 +5874,19 @@
1569 5874 // plain settings save (e.g. expiry change) doesn't blow the cache.
1570 5875 return;
1571 5876 }
1572 5877
1573 - // The setting flipped. Bring the rewrite into line and purge the
1574 - // now-misbucketed cache so the next request re-primes under the new
1575 - // device scheme.
5878 + // Bring the rewrite into line with what this server actually supports.
1576 5879 if ( $rewrite_wanted ) {
1577 5880 self::install_rewrite();
1578 5881 } else {
1579 5882 self::remove_rewrite();
1580 5883 }
1581 - self::purge_all( 'mobile_separate changed' );
5884 +
5885 + // Only discard cache contents when the device bucketing changed.
5886 + if ( $mobile_flipped ) {
5887 + self::purge_all( 'mobile_separate changed' );
5888 + }
1582 5889 }
1583 5890
1584 5891 /**
1585 5892 * Whether the server-level static-rewrite fast path may be used.
@@ -1603,10 +5910,12 @@
1603 5910 * `.htaccess` equivalent of nginx's per-location `access_log` to record
1604 5911 * the hit. The result was a cache that worked but was invisible: no HIT
1605 5912 * header and a hit-ratio frozen near 0%. Every OTHER server gives the
1606 5913 * user a visible HIT header + a counted hit (nginx via add_header +
1607 - * access_log in its snippet; Apache via .htaccess mod_headers, which it
1608 - * honors). To keep LiteSpeed CONSISTENT with the rest, we route its hits
5914 + * access_log in its snippet; Apache via the `<IfModule mod_headers.c>`
5915 + * block in rewrite_block_lines(), WHEN that module is loaded — when it is
5916 + * not, Apache takes this same drop-in fallback). To keep LiteSpeed
5917 + * CONSISTENT with the rest, we route its hits
1609 5918 * through the PHP drop-in instead — the drop-in emits
1610 5919 * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the
1611 5920 * observable behavior the other servers get. The cost is the drop-in's
1612 5921 * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in
@@ -1620,9 +5929,23 @@
1620 5929 // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
1621 5930 if ( Server::LITESPEED === Server::type() ) {
1622 5931 return false;
1623 5932 }
1624 - $opts = Settings_Manager::get( 'cache' );
5933 + // Apache without mod_headers is in EXACTLY the position LiteSpeed
5934 + // is in above: it can run the RewriteRule and serve the static
5935 + // file, but it cannot stamp `X-XSpeed-Cache` on the response, so
5936 + // the hit is invisible to the user and uncountable by
5937 + // Hit_Counter. The docblock above used to assert Apache "honors
5938 + // mod_headers" and left it on the fast path unconditionally —
5939 + // true only when the module is actually loaded. Fall back to the
5940 + // drop-in when it isn't, trading ~10ms of TTFB for a hit that
5941 + // shows up in the header and the ratio. (Field report: hit ratio
5942 + // pinned at 0% on a working Apache cache.)
5943 + if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
5944 + return false;
5945 + }
5946 + // Stored read — reached from boot(); see stored_cache_opts().
5947 + $opts = self::stored_cache_opts();
1625 5948 return empty( $opts['mobile_separate'] );
1626 5949 }
1627 5950
1628 5951 /**
@@ -1628,17 +5951,147 @@
1628 5951 /**
1629 5952 * Why the device-blind static rewrite is NOT installed, when it isn't.
1630 5953 * Returns 'mobile_separate' when Separate Mobile Cache is the blocker
1631 5954 * (the static file is one-per-URL, so it can't coexist with per-device
1632 - * buckets), '' otherwise. Lets the dashboard explain the slow path
1633 - * instead of silently falling back to PHP serving. (FBS-83145)
5955 + * buckets), 'no_mod_headers' when Apache can't stamp the HIT header,
5956 + * '' otherwise. Lets the dashboard explain the slow path instead of
5957 + * silently falling back to PHP serving. (FBS-83145)
5958 + *
5959 + * Every refusal in static_rewrite_allowed() that is NOT self-explanatory
5960 + * must have a branch here. Otherwise the Health card falls through to
5961 + * "Block missing — toggle Enable Cache off and on to reinstall it",
5962 + * advice that cannot work: the same condition that suppressed the write
5963 + * suppresses the reinstall, and auto_heal() strips the block again on
5964 + * the next admin page load. (Field report: Apache host with mod_headers
5965 + * unloaded sat on the slow path with no way to find out why.)
1634 5966 */
5967 + /**
5968 + * Qualify a raw probe result with what we already KNOW about config.
5969 + *
5970 + * probe_static_rewrite() writes its own file under the static-cache tree
5971 + * and fetches that, which succeeds whenever the web server can serve a
5972 + * static file at all — including when static_rewrite_allowed() is false
5973 + * and no real page is on the static path. So `active: true` on its own is
5974 + * not evidence that pages are being served statically.
5975 + *
5976 + * The reachable case is nginx with Separate Mobile Cache on: the snippet
5977 + * lives in the server block and we cannot remove it, pages are
5978 + * deliberately routed to the PHP drop-in, but the probe file is still
5979 + * served directly.
5980 + *
5981 + * The Health panel learned this in 88b4b50; the CLI, REST and MCP paths
5982 + * did not, so they kept reporting "active" in exactly that configuration.
5983 + * Rather than repeat the reasoning at each call site, they now all come
5984 + * through here.
5985 + *
5986 + * Deliberately does NOT consult rewrite_installed(): on nginx the fast
5987 + * path is the pasted snippet and there is no .htaccess marker to find, so
5988 + * requiring one would report every correctly-configured nginx site as
5989 + * broken.
5990 + *
5991 + * @param array $probe Raw result from probe_static_rewrite().
5992 + * @return array{active:bool,inconclusive:bool,reason:string,block_reason:string}
5993 + */
5994 + public static function qualify_rewrite_probe( array $probe ): array {
5995 + $active = (bool) ( $probe['active'] ?? false );
5996 + $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
5997 + $reason = (string) ( $probe['reason'] ?? '' );
5998 + $block_reason = self::static_rewrite_block_reason();
5999 +
6000 + // Same observed-refusal check Health makes. This is the shared path for
6001 + // `wp xspeed cache recheck-rewrite` and POST /cache/recheck-rewrite —
6002 + // and, because a CLI command is automatically an MCP tool, for the
6003 + // AI-facing surface too. Leaving it out would have fixed the dashboard
6004 + // while the CLI kept answering that the fast path was active. (#372)
6005 + if ( '' === $block_reason ) {
6006 + $skip = self::last_static_skip();
6007 + if ( ! empty( $skip['reason'] ) ) {
6008 + $block_reason = 'skipped_' . (string) $skip['reason'];
6009 + }
6010 + }
6011 +
6012 + // With page caching off there is nothing to serve, so `active` can
6013 + // never be true here whatever the raw probe says. probe_static_rewrite()
6014 + // writes its OWN file under the static tree and fetches that, which
6015 + // succeeds whenever the server can serve a static file at all — and on
6016 + // nginx the snippet is server-level, so it keeps succeeding after the
6017 + // cache is switched off.
6018 + //
6019 + // block_reason() used to carry this meaning by accident: it returned
6020 + // 'mobile_separate' with caching off, and the refusal branch below
6021 + // forced active=false. Now that it correctly reports '' (nothing can
6022 + // block a fast path that isn't in use), this consumer has to state the
6023 + // condition itself — otherwise `wp xspeed cache recheck-rewrite` and
6024 + // POST /cache/recheck-rewrite claim "the web server is serving cache
6025 + // hits directly" on a site with no cache. That is a positive false
6026 + // claim rather than a nag, i.e. worse than the bug being fixed.
6027 + $cache_opts = Settings::get();
6028 + if ( empty( $cache_opts['cache_enabled'] ) ) {
6029 + return array(
6030 + 'active' => false,
6031 + 'inconclusive' => false,
6032 + 'reason' => 'Page caching is off, so there is no cache for the web server to serve.',
6033 + 'block_reason' => '',
6034 + );
6035 + }
6036 +
6037 + // A known refusal outranks the probe, and also outranks
6038 + // "inconclusive" — a blocked rewrite whose probe merely failed to
6039 + // complete is still definitely blocked.
6040 + if ( '' !== $block_reason ) {
6041 + $active = false;
6042 + $inconclusive = false;
6043 + $reason = self::block_reason_text( $block_reason );
6044 + }
6045 +
6046 + return array(
6047 + 'active' => $active,
6048 + 'inconclusive' => $inconclusive,
6049 + 'reason' => $reason,
6050 + 'block_reason' => $block_reason,
6051 + );
6052 + }
6053 +
6054 + /**
6055 + * Human-readable explanation for a static_rewrite_block_reason() code.
6056 + *
6057 + * Each one has to say what to DO about it: "mobile_separate" alone tells
6058 + * a user nothing, and the whole point of surfacing a refusal instead of
6059 + * the probe verdict is that it is actionable.
6060 + */
6061 + public static function block_reason_text( string $code ): string {
6062 + switch ( $code ) {
6063 + case 'mobile_separate':
6064 + 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.';
6065 + case 'no_mod_headers':
6066 + 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.";
6067 + case 'skipped_nonce':
6068 + 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.';
6069 + default:
6070 + return sprintf( 'The static rewrite is disabled (%s).', $code );
6071 + }
6072 + }
6073 +
1635 6074 public static function static_rewrite_block_reason(): string {
6075 + // Nothing can be blocking the fast path when there is no cache to
6076 + // serve from it. Without this the dashboard told users with page
6077 + // caching switched OFF that Separate Mobile Cache "is disabling
6078 + // faster static serving" — a fast path they were not using, about a
6079 + // cache that did not exist. Every caller of this is a user-facing
6080 + // explanation of why the rewrite is off, so "the cache is off" is
6081 + // the honest answer, and it is silence. (#108)
6082 + $opts = Settings::get();
6083 + if ( empty( $opts['cache_enabled'] ) ) {
6084 + return '';
6085 + }
1636 6086 if ( Server::LITESPEED === Server::type() ) {
1637 6087 return ''; // Intended on LiteSpeed — not a "block".
1638 6088 }
1639 - $opts = Settings_Manager::get( 'cache' );
1640 - return ! empty( $opts['mobile_separate'] ) ? 'mobile_separate' : '';
6089 + if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) {
6090 + return 'no_mod_headers';
6091 + }
6092 + $cache_opts = Settings_Manager::get( 'cache' );
6093 + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : '';
1641 6094 }
1642 6095
1643 6096 /**
1644 6097 * Whether migration flagged Separate Mobile Cache for user review. Set by
@@ -1648,10 +6101,21 @@
1648 6101 * this flag so the dashboard can invite the user to turn it back on only
1649 6102 * if their site genuinely serves different HTML per device. (FBS-83145)
1650 6103 */
1651 6104 public static function mobile_separate_needs_review(): bool {
1652 - $opts = Settings_Manager::get( 'cache' );
1653 - return ! empty( $opts['mobile_separate_review'] );
6105 + // Same reasoning as static_rewrite_block_reason(): the invitation is
6106 + // "turn this back on if your site needs it, to regain the fast path",
6107 + // which is meaningless with page caching off — there is no fast path
6108 + // to regain, and the equality probe behind the prompt would fetch
6109 + // pages that aren't being cached. Gated here rather than at the two
6110 + // payload call sites (Admin + Rest_Api) so `enabled`, `blocking` and
6111 + // `needs_review` are consistently gated on the same condition. (#108)
6112 + $opts = Settings::get();
6113 + if ( empty( $opts['cache_enabled'] ) ) {
6114 + return false;
6115 + }
6116 + $cache_opts = Settings_Manager::get( 'cache' );
6117 + return ! empty( $cache_opts['mobile_separate_review'] );
1654 6118 }
1655 6119
1656 6120 /**
1657 6121 * Clear the review flag — called when the user has acted on the prompt
@@ -1752,18 +6216,111 @@
1752 6216 * handful of well-known noise sources and collapses whitespace, so a site
1753 6217 * that truly serves different markup per device still compares as different.
1754 6218 */
1755 6219 private static function normalize_html_for_diff( string $html ): string {
6220 + // Every rule here errs toward "they differ" being WRONG rather than
6221 + // "they match" being wrong: this check only ever tells a user it is
6222 + // SAFE to turn Separate Mobile Cache off, so a false "identical"
6223 + // would cost them device-specific output. The risk of being too
6224 + // conservative is milder but real — the useful answer never appears,
6225 + // and the feature's whole pitch ("we'll prove it's safe to turn
6226 + // off") silently never pays out. These close the gaps that made a
6227 + // mismatch effectively guaranteed on an ordinary WordPress site. (#108)
1756 6228 $patterns = array(
1757 - // WP nonces (data-nonce="...", _wpnonce=..., "nonce":"...").
1758 - '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-f0-9]{10}/i',
1759 - // Generic 10+ hex tokens (CSRF, cache-buster hashes, session ids).
1760 - '/\b[a-f0-9]{16,}\b/i',
6229 + // WP nonces in attribute or JSON form: data-nonce="…",
6230 + // _wpnonce=…, "nonce":"…". The `[:=]` adjacency below misses
6231 + // wp_nonce_field()'s own markup — `name="_wpnonce" value="ab…"`
6232 + // puts `value=` between the key and the token — which is the
6233 + // single most common nonce shape in WordPress, so that form is
6234 + // matched explicitly first.
6235 + '/name=["\']?(_wpnonce|_ajax_nonce)["\']?\s+value=["\']?[a-z0-9]{8,}/i',
6236 + // CSP nonces on script/style tags. Base64, so uppercase and
6237 + // +/= appear — the hex-only rules below can never match one,
6238 + // and a CSP-enabled site therefore differed on every fetch.
6239 + // MUST precede the generic nonce rule: that one stops at the
6240 + // first non-alphanumeric, leaving the rest of the token behind
6241 + // and the two responses still unequal.
6242 + // The quotes are optional so HTML5's legal unquoted attribute
6243 + // form (`<script nonce=AbCd+q/r=>`) is covered too — without
6244 + // that it fell through to the generic rule, which is the exact
6245 + // failure this rule exists to remove.
6246 + '/\bnonce=(["\'])?[A-Za-z0-9+\/=_-]{8,}(?(1)\1)/',
6247 + '/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-z0-9]{8,}/i',
6248 + // Generic hex tokens: cache busters, session ids, md5/sha
6249 + // digests. Was 16+, which left an 11-15 char gap above the
6250 + // 10-char nonce rule.
6251 + //
6252 + // The token MUST contain at least one a-f letter. `[a-f0-9]`
6253 + // also matches every decimal digit, so a bare `{10,}` erased
6254 + // every 10+ digit INTEGER anywhere in the document — including
6255 + // visible body text. A page whose desktop and mobile HTML
6256 + // differed only by a per-device numeric id (an AdSense slot, an
6257 + // A/B bucket, an analytics property) then compared as identical,
6258 + // and the check told the user it was safe to switch off the very
6259 + // setting keeping that output correct — the one direction this
6260 + // function must never fail in. Decimal-only runs are left to the
6261 + // bounded epoch rule below, which is deliberately narrower.
6262 + //
6263 + // Known, accepted (QA R2): a token whose letters all fall in a-f
6264 + // reads as a digest, so a per-device `ABC1234567890` strips even
6265 + // though it is an id, not a hash. Deliberately left open — the
6266 + // alternatives all cost more than the bug:
6267 + //
6268 + // Token shape (lowercase-only, case-uniformity, a trailing
6269 + // letter) cannot separate it. `ABC1234567890` and
6270 + // `ABCDEF012345` — an uppercase digest this rule SHOULD strip —
6271 + // are both all-hex, uniformly cased, letters-then-digits.
6272 + // Each variant fixed the id only by sparing the digest.
6273 + //
6274 + // Letter density does separate them (23% letters vs 50%), but
6275 + // measured over 2000 md5/sha1/sha256 samples, requiring letters
6276 + // spread through the token leaves 21-67% of REAL digests
6277 + // unmatched depending on the window. Digest noise is most of
6278 + // what this function exists to remove, so that trade guts it.
6279 + //
6280 + // Context (protecting data-* attribute values from this rule)
6281 + // works for ids and still strips digests in URLs, classes and
6282 + // query strings — but regresses a CHANGING digest inside a
6283 + // non-nonce data-* attribute, and needs a two-pass
6284 + // hold/restore. Viable if R2 is ever worth pressing; its
6285 + // failure at least errs toward "differ".
6286 + //
6287 + // An A-F-only prefix on a per-device id is rare, and the earlier
6288 + // nonce rules already claim the data-nonce/_wpnonce shapes.
6289 + '/\b(?=[a-f0-9]{10,}\b)[0-9]*[a-f][a-f0-9]*\b/i',
1761 6290 // wp-generated unique ids (e.g. wp-block ids, aria ids).
1762 6291 '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i',
1763 6292 // ISO-ish timestamps + epoch-looking numbers in query strings.
1764 6293 '/\?ver=[0-9.]+/',
1765 6294 '/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/',
6295 + // Our own signature's generation stamp. The two fetches are
6296 + // sequential and each writes its own entry, so this differs on
6297 + // essentially every comparison — and it is space-separated, so
6298 + // the ISO rule above (which requires a literal `T`) never
6299 + // touches it. Without this the probe reports "differ" for every
6300 + // site and the "safe to turn Separate Mobile Cache off" verdict
6301 + // can never appear.
6302 + '/generated [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} UTC/',
6303 + // Raw epoch seconds. The two fetches are sequential, so any
6304 + // template printing time() guaranteed a mismatch.
6305 + //
6306 + // This is the ONLY rule that may strip a decimal-only run, so
6307 + // its bound is load-bearing rather than decorative — every digit
6308 + // it gives away is a class of per-device id it silently erases.
6309 + // `1[0-9]{9}` was too loose: it claimed the whole
6310 + // 1000000000-1999999999 range (2001-2033) to cover timestamps
6311 + // nobody serves, and took every 10-digit AdSense slot, order id
6312 + // and SKU beginning with 1 along with it — reproducing the exact
6313 + // false-"identical" verdict the hex rule above was tightened to
6314 + // stop. `1[6-9]` covers 2020-2033, which is the only span a live
6315 + // site can actually print, and collides with roughly a tenth as
6316 + // many ids.
6317 + //
6318 + // Not airtight — an id beginning 16-19 still collides. Closing
6319 + // that properly means scoping this to places a timestamp really
6320 + // appears (an attribute value, a query parameter, a JSON value)
6321 + // rather than bare body text; the bound is the cheap 90% of it.
6322 + '/\b1[6-9][0-9]{8}\b/',
1766 6323 );
1767 6324 $html = (string) preg_replace( $patterns, 'X', $html );
1768 6325 // Collapse all whitespace so trivial formatting differences don't count.
1769 6326 return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
@@ -1769,33 +6326,58 @@
1769 6326 return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
1770 6327 }
1771 6328
1772 6329 public static function ensure_hits_log_file(): bool {
1773 - // The HITs log exists ONLY so a server-level nginx `access_log`
1774 - // directive has a world-writable file to append to (see nginx_snippet()
1775 - // + Hit_Counter::collect_nginx_log_hits()). On Apache/LiteSpeed/managed
1776 - // hosts nothing writes it, so creating it — and, worse, chmod()-ing it
1777 - // world-writable — is pointless AND fails with "Operation not permitted"
1778 - // when PHP can't chmod files it doesn't own (a warning that surfaces in
1779 - // logs that capture @-suppressed errors). Skip the whole thing off nginx.
1780 - if ( Server::NGINX !== Server::type() ) {
1781 - return false;
1782 - }
6330 + // TWO writers append to this log, and an earlier fix conflated them:
6331 + //
6332 + // 1. nginx, via the server-level `access_log` directive in
6333 + // nginx_snippet() — a DIFFERENT uid, which is why the file needs
6334 + // to be world-writable there.
6335 + // 2. the PHP drop-in (advanced-cache.php), on EVERY server. A hit it
6336 + // serves bypasses WordPress entirely, so it can't call
6337 + // Hit_Counter::record_hit() — appending here is the only way that
6338 + // hit is ever counted.
6339 + //
6340 + // The nginx-only early return that used to sit at the top of this
6341 + // method was fixing something real: chmod() on a file PHP doesn't own
6342 + // raises "Operation not permitted", and off nginx that chmod buys
6343 + // nothing. But it took directory creation with it, so on LiteSpeed
6344 + // (which always serves via the drop-in), on Apache without mod_headers,
6345 + // and anywhere mobile_separate forces the drop-in path, writer 2 was
6346 + // appending to a file whose parent directory did not exist. The append
6347 + // is @-suppressed and documented as non-fatal, so every one of those
6348 + // hits vanished and the dashboard ratio sat at 0% forever.
6349 + //
6350 + // So: create the dir + file everywhere, and keep only the chmod gated
6351 + // to nginx.
1783 6352 $dir = self::hits_log_dir();
1784 6353 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
1785 6354 return false;
1786 6355 }
1787 - // Ensure the dir is traversable + writable by a different-uid nginx.
1788 - // 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.
1789 - @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
6356 +
6357 + $is_nginx = ( Server::NGINX === Server::type() );
6358 +
6359 + if ( $is_nginx ) {
6360 + // Ensure the dir is traversable + writable by a different-uid nginx.
6361 + // 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.
6362 + @chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
6363 + }
6364 +
1790 6365 $path = self::hits_log_path();
1791 6366 if ( ! file_exists( $path ) ) {
1792 6367 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
1793 6368 @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
1794 6369 }
1795 - // World-writable so a different-uid nginx can append HIT lines.
1796 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
1797 - @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
6370 +
6371 + if ( $is_nginx ) {
6372 + // World-writable so a different-uid nginx can append HIT lines.
6373 + // Off nginx the drop-in appends as the same uid that owns the file,
6374 + // so this is unnecessary — and would emit the "Operation not
6375 + // permitted" warnings the old early return was added to silence.
6376 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
6377 + @chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
6378 + }
6379 +
1798 6380 return file_exists( $path );
1799 6381 }
1800 6382
1801 6383 public static function nginx_snippet(): ?string {
@@ -1859,9 +6441,55 @@
1859 6441 $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host())
1860 6442 $lines[] = 'set $xspeed_no_cache "no-cache";';
1861 6443 $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
1862 6444 $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
1863 - $lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
6445 + // Cookie + user-agent exclusions, generated from the user's actual
6446 + // settings rather than a hardcoded list. Before this, the rule
6447 + // tested three fixed cookie names and no user agent at all, so
6448 + // every excluded_cookies / bypass_user_agents entry applied only
6449 + // while a page was cold — on a warm page nginx served the shared
6450 + // anonymous copy to carts, members and bypassed bots alike. The
6451 + // three historical names survive as a floor inside cookie_rule().
6452 + // `~*` is case-insensitive, matching PHP's stripos()/glob checks.
6453 + // Stored read — reached from boot(); see stored_cache_opts(). The
6454 + // fallbacks below mirror the schema's own defaults, which a raw read
6455 + // does not apply.
6456 + $cache_opts = self::stored_cache_opts();
6457 + $cookie_rule = Server_Rules::cookie_rule(
6458 + is_array( $cache_opts['excluded_cookies'] ?? null )
6459 + ? $cache_opts['excluded_cookies']
6460 + : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXCLUDED_COOKIES
6461 + );
6462 + $lines[] = 'if ($http_cookie ~* "(' . $cookie_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
6463 +
6464 + $ua_rule = Server_Rules::user_agent_rule(
6465 + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
6466 + );
6467 + // Emitted only when the list is non-empty — an empty alternation
6468 + // would compile to `(...)` matching every request and disable the
6469 + // fast path entirely.
6470 + if ( '' !== $ua_rule['regex'] ) {
6471 + $lines[] = 'if ($http_user_agent ~* "(' . $ua_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-ua"; }';
6472 + }
6473 +
6474 + // URL exclusions. Without this an excluded URL was only excluded
6475 + // while its page was cold: PHP won't write a static file for one, so
6476 + // there is usually nothing to serve — but a page cached BEFORE the
6477 + // rule was added still has its file on disk, and nginx serves it
6478 + // without ever asking PHP. The exclusion then does nothing until the
6479 + // next purge. (#169)
6480 + //
6481 + // Matched against $uri, not $request_uri: $uri is the decoded path
6482 + // without the query string, which is what Cache::should_cache()
6483 + // tests. Using $request_uri would make `/cart` fail to match
6484 + // `/cart?x=1` inconsistently with PHP. Same empty-regex guard as the
6485 + // UA rule above — an empty alternation matches everything.
6486 + $url_rule = Server_Rules::url_rule(
6487 + is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array()
6488 + );
6489 + if ( '' !== $url_rule['regex'] ) {
6490 + $lines[] = 'if ($uri ~* "(' . $url_rule['regex'] . ')") { set $xspeed_no_cache "$xspeed_no_cache-url"; }';
6491 + }
1864 6492 $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
1865 6493 // Neither `add_header` nor `access_log` is allowed inside an `if{}`
1866 6494 // at server level (nginx rejects with "directive is not allowed
1867 6495 // here"). The logging therefore lives in a `location` block that
@@ -1975,8 +6603,62 @@
1975 6603 header( 'X-LiteSpeed-Cache-Control: no-cache' );
1976 6604 }
1977 6605
1978 6606 /**
6607 + * Restore the drop-in + WP_CACHE constant for a site that had caching
6608 + * ON before this activation — and ONLY for such a site.
6609 + *
6610 + * WordPress runs an upgrade as deactivate → wipe plugin files →
6611 + * install → activate. The wipe takes advanced-cache.php with it, so
6612 + * without this the site serves 100% uncached from the moment the
6613 + * update finishes until the next authenticated wp-admin page load
6614 + * (auto_heal() is on admin_init). On a site whose admin logs in
6615 + * rarely that window is hours or days of silent cache loss, while
6616 + * the dashboard still reports cache_enabled = true. (FBS field
6617 + * report against 1.1.2 / Pro 1.0.5.)
6618 + *
6619 + * The `cache_enabled` guard is the whole contract: a FRESH install
6620 + * has the option unset, so activation writes nothing and the user
6621 + * still opts in explicitly through Cache::toggle() via the
6622 + * /cache/toggle REST endpoint. We only ever put back state the user
6623 + * already chose — repair, never a new install path. This is what
6624 + * keeps us on the right side of the "don't create drop-ins the user
6625 + * didn't ask for" guideline while matching what WP Rocket, W3 Total
6626 + * Cache and WP Super Cache all do on activation.
6627 + *
6628 + * @return bool True when a restore was performed.
6629 + */
6630 + public static function restore_dropin_if_enabled(): bool {
6631 + if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
6632 + return false;
6633 + }
6634 +
6635 + // The user's saved choice. Absent/false on a fresh install => no
6636 + // drop-in is written and nothing touches wp-config.php.
6637 + $opts = get_option( 'xspeed_options', array() );
6638 + if ( empty( $opts['cache_enabled'] ) ) {
6639 + return false;
6640 + }
6641 +
6642 + $state = self::toggle( true, false );
6643 + // A refusal reports whether the cache SERVES, which on this path can
6644 + // be true for reasons that have nothing to do with this call — so a
6645 + // refusal would otherwise log "drop-in restored" for a restore that
6646 + // was declined. Restored means the transaction went through.
6647 + $restored = empty( $state['blocked'] ) && ! empty( $state['enabled'] );
6648 +
6649 + if ( $restored ) {
6650 + Activity_Log::record(
6651 + 'cache_dropin_restored',
6652 + 'Cache drop-in restored after a plugin update — caching was already enabled.',
6653 + Activity_Log::SUCCESS
6654 + );
6655 + }
6656 +
6657 + return $restored;
6658 + }
6659 +
6660 + /**
1979 6661 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
1980 6662 * saved choice. Runs on admin_init. Cheap when nothing's wrong
1981 6663 * (one option read + a handful of file_exists / defined checks);
1982 6664 * writes only when state has drifted (typical cause: plugin
@@ -1998,32 +6680,16 @@
1998 6680 if ( empty( $opts['cache_enabled'] ) ) {
1999 6681 return;
2000 6682 }
2001 6683
2002 - $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
2003 - $dropin_ours = false;
2004 - $dropin_stale = false;
2005 - if ( file_exists( $dropin_target ) ) {
2006 - $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2007 - $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
2008 - // Reinstall when OUR drop-in is an older version than the source —
2009 - // the marker alone can't distinguish an old copy from a new one, so
2010 - // a serve-logic change (e.g. the .meta read for 404s/feeds) would
2011 - // otherwise never reach existing cache-enabled sites until a manual
2012 - // cache toggle. (FBS-82406/82407)
2013 - if ( $dropin_ours ) {
2014 - $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
2015 - }
6684 + $state = self::toggle( true, false );
6685 + // A refusal means something else now owns the page-cache field, or
6686 + // the write could not be verified. Either way this is not the moment
6687 + // to go on maintaining our rewrite block and log file.
6688 + if ( ! empty( $state['blocked'] ) || empty( $state['enabled'] ) ) {
6689 + return;
2016 6690 }
2017 6691
2018 - if ( ! $dropin_ours || $dropin_stale ) {
2019 - self::install_dropin();
2020 - }
2021 -
2022 - if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
2023 - self::set_wp_cache_constant( true );
2024 - }
2025 -
2026 6692 // Rewrite block goes last. It's what turns the static-cache
2027 6693 // tree into a PHP-bypass — every cache hit served by the web
2028 6694 // server directly. Without it we still cache, just at drop-in
2029 6695 // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
@@ -2048,8 +6714,54 @@
2048 6714 self::ensure_hits_log_file();
2049 6715 }
2050 6716
2051 6717 /**
6718 + * Keep the generic bypass cookie in sync with PHP's caching verdict.
6719 + *
6720 + * The server config tests exactly one cookie name (Server_Rules::
6721 + * BYPASS_COOKIE) forever, and PHP decides what that name means. Adding
6722 + * a new excluded cookie therefore needs no config change and no nginx
6723 + * reload — the reason this exists.
6724 + *
6725 + * Session cookie (expiry 0) so it dies with the browser session, and
6726 + * deliberately NOT HttpOnly-sensitive: it carries no identity, only the
6727 + * boolean "don't serve this visitor a shared cached page".
6728 + *
6729 + * Honest limit: this can only ever help a visitor PHP has already seen
6730 + * once. A bot's first request to a warm page never reaches PHP, which
6731 + * is why user-agent rules are still written into the server config
6732 + * rather than relying on this.
6733 + *
6734 + * @param bool $bypass Whether this visitor must skip the cache.
6735 + */
6736 + private static function sync_bypass_cookie( bool $bypass ): void {
6737 + if ( headers_sent() ) {
6738 + return;
6739 + }
6740 +
6741 + $name = Server_Rules::BYPASS_COOKIE;
6742 + $has = isset( $_COOKIE[ $name ] );
6743 +
6744 + // Only touch the header when the state actually changes — a
6745 + // Set-Cookie on every request would make the response uncacheable
6746 + // for intermediary caches and add noise to every hit.
6747 + if ( $bypass === $has ) {
6748 + return;
6749 + }
6750 +
6751 + $path = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/';
6752 + $domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '';
6753 +
6754 + if ( $bypass ) {
6755 + setcookie( $name, '1', 0, $path, (string) $domain, is_ssl(), false );
6756 + $_COOKIE[ $name ] = '1';
6757 + } else {
6758 + setcookie( $name, '', time() - 3600, $path, (string) $domain, is_ssl(), false );
6759 + unset( $_COOKIE[ $name ] );
6760 + }
6761 + }
6762 +
6763 + /**
2052 6764 * Build the .htaccess rules that map cacheable requests to the
2053 6765 * static-cache tree. Conditions are deliberately strict: GET only,
2054 6766 * empty query string, no session/comment-author/post-password
2055 6767 * cookie, and the static file must exist on disk. Anything that
@@ -2066,14 +6778,46 @@
2066 6778 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
2067 6779 $rel = '/' . ltrim( $rel, '/' );
2068 6780 $rel = rtrim( $rel, '/' );
2069 6781
2070 - return array(
6782 + // Cookie + user-agent exclusions generated from the live settings.
6783 + // See the matching block in nginx_snippet() — same generator, same
6784 + // floor, so both servers enforce an identical policy. Apache reads
6785 + // .htaccess on every request and we already self-heal this file, so
6786 + // Apache/LiteSpeed users get the fix on upgrade with no action.
6787 + $cache_opts = Settings_Manager::get( 'cache' );
6788 + $cookie_rule = Server_Rules::cookie_rule(
6789 + is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
6790 + );
6791 + $ua_rule = Server_Rules::user_agent_rule(
6792 + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
6793 + );
6794 +
6795 + $lines = array(
2071 6796 '<IfModule mod_rewrite.c>',
2072 6797 ' RewriteEngine On',
2073 6798 ' RewriteCond %{REQUEST_METHOD} ^GET$',
2074 6799 ' RewriteCond %{QUERY_STRING} ^$',
2075 - ' RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]',
6800 + ' RewriteCond %{HTTP_COOKIE} !(' . $cookie_rule['regex'] . ') [NC]',
6801 + );
6802 +
6803 + // Only emit the UA condition when there's something to match —
6804 + // `!()` would negate an always-true empty match and refuse every
6805 + // request, silently disabling the static path.
6806 + if ( '' !== $ua_rule['regex'] ) {
6807 + // Quoted, because RewriteCond is whitespace-delimited and real
6808 + // user-agent fragments contain spaces ("Mozilla/5.0 (compatible").
6809 + // Unquoted, a space adds an argument and Apache answers every
6810 + // request with a 500 — and because .htaccess is parsed per
6811 + // request, `httpd -t` still reports Syntax OK. Server_Rules has
6812 + // already excluded quotes and backslashes from the alternation,
6813 + // so the closing quote here cannot be escaped away.
6814 + $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!(' . $ua_rule['regex'] . ')" [NC]';
6815 + }
6816 +
6817 + return array_merge(
6818 + $lines,
6819 + array(
2076 6820 // Capture REQUEST_URI without its trailing slash into %1.
2077 6821 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
2078 6822 // so this normalization lets `/blog/` and `/blog` both hit
2079 6823 // the same cache file without producing the double-slash
@@ -2091,8 +6835,32 @@
2091 6835 // 1.8: `.` → homepage served by PHP drop-in; `^` → served
2092 6836 // directly from the static file.)
2093 6837 ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
2094 6838 '</IfModule>',
6839 + // Mark the statically-served response as a cache HIT.
6840 + //
6841 + // A file served by the rewrite above bypasses PHP entirely, so
6842 + // this directive is the ONLY thing that can identify it as
6843 + // cached — both for the user reading response headers and for
6844 + // Hit_Counter, which reconciles static hits from the access
6845 + // log. Without it the cache works perfectly and reports a 0%
6846 + // hit ratio, which reads as "the plugin is broken". (Field
6847 + // report against 1.1.2: homepage served byte-identical from
6848 + // the static tree, no X-XSpeed-Cache header on any response.)
6849 + //
6850 + // `always` so the header is set on the 200 from the rewritten
6851 + // file, not only on the successful-response table. The
6852 + // <IfModule> guard keeps a server without mod_headers from
6853 + // 500ing on an unknown directive — on such a host the header
6854 + // is silently dropped, which is exactly why
6855 + // static_rewrite_allowed() refuses the static path there and
6856 + // routes hits through the drop-in instead.
6857 + '<IfModule mod_headers.c>',
6858 + ' <FilesMatch "\\.html$">',
6859 + ' Header always set X-XSpeed-Cache "HIT (static)"',
6860 + ' </FilesMatch>',
6861 + '</IfModule>',
6862 + )
2095 6863 );
2096 6864 }
2097 6865
2098 6866 /**
@@ -2325,12 +7093,49 @@
2325 7093 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
2326 7094 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
2327 7095 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
2328 7096
7097 + /*
7098 + * Nothing to change. auto_heal() runs the whole enable transaction on
7099 + * every admin_init and this is called unconditionally from it, so
7100 + * without this every wp-admin request truncated and rewrote .htaccess
7101 + * with byte-identical content. Apache reads that file without a lock,
7102 + * so the truncate window is a real 500 on a busy admin, and the churn
7103 + * trips host file-integrity monitors.
7104 + */
7105 + if ( $next === $existing ) {
7106 + return true;
7107 + }
7108 +
2329 7109 // 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.
2330 7110 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
2331 7111 }
2332 7112
7113 + /**
7114 + * Rewrite the .htaccess block in place when — and only when — one is
7115 + * already installed.
7116 + *
7117 + * The block embeds the generated cookie / user-agent exclusion rules,
7118 + * so it goes stale the moment those settings change. install_rewrite()
7119 + * regenerates it from the live settings, but calling that unconditionally
7120 + * on every save would CREATE a block on sites that never enabled the
7121 + * static path — silently turning on server-level serving nobody asked
7122 + * for. So we refresh only what's already there.
7123 + *
7124 + * @return bool True when a block was present and rewritten.
7125 + */
7126 + public static function refresh_rewrite_if_installed(): bool {
7127 + $htaccess = ABSPATH . '.htaccess';
7128 + if ( ! file_exists( $htaccess ) ) {
7129 + return false;
7130 + }
7131 + $existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort read; an unreadable file simply means nothing to refresh.
7132 + if ( ! is_string( $existing ) || false === strpos( $existing, '# BEGIN xSpeed Static Cache' ) ) {
7133 + return false;
7134 + }
7135 + return self::install_rewrite();
7136 + }
7137 +
2333 7138 public static function remove_rewrite(): bool {
2334 7139 $htaccess = ABSPATH . '.htaccess';
2335 7140 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
2336 7141 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
@@ -2351,9 +7156,21 @@
2351 7156 * follows it. Idempotent — returns the input unchanged if the
2352 7157 * marker isn't present.
2353 7158 */
2354 7159 private static function strip_marker_block( string $contents, string $marker ): string {
2355 - $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
7160 + /*
7161 + * The body may not contain another BEGIN for this marker.
7162 + *
7163 + * `.*?` is non-greedy but still spans anything, so an ORPHANED
7164 + * `# BEGIN xSpeed Static Cache` — an END line lost to a hand edit or
7165 + * a partial write — paired with the END of the NEXT block and deleted
7166 + * everything between them. On a site where the orphan sits above
7167 + * `# BEGIN WordPress`, that takes WordPress's own rewrite rules with
7168 + * it and every permalink 404s. Refusing to cross a second BEGIN makes
7169 + * the orphan a no-op instead of a site-wide outage.
7170 + */
7171 + $begin = '# BEGIN ' . preg_quote( $marker, '/' ) . '\b';
7172 + $pattern = '/' . $begin . '(?:(?!' . $begin . ').)*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
2356 7173 $out = preg_replace( $pattern, '', $contents );
2357 7174 return is_string( $out ) ? $out : $contents;
2358 7175 }
2359 7176
@@ -2377,8 +7194,417 @@
2377 7194 }
2378 7195 return 0;
2379 7196 }
2380 7197
7198 + /** The advanced-cache.php drop-in is ours. */
7199 + public const DROPIN_XSPEED = 'xspeed';
7200 + /** Someone else's drop-in is installed. */
7201 + public const DROPIN_FOREIGN = 'foreign';
7202 + /** No drop-in installed. */
7203 + public const DROPIN_NONE = 'none';
7204 + /** A drop-in is installed and we could not read it. */
7205 + public const DROPIN_UNREADABLE = 'unreadable';
7206 + /**
7207 + * Present but holding nothing -- empty, or whitespace only. WP Rocket
7208 + * truncates advanced-cache.php to 0 bytes on deactivate, and calling that
7209 + * FOREIGN made it a permanent blocker with no owner to ask. (#391)
7210 + */
7211 + public const DROPIN_ABANDONED = 'abandoned';
7212 +
7213 + /**
7214 + * Who owns wp-content/advanced-cache.php right now.
7215 + *
7216 + * WordPress gives every caching plugin the same single file to live in,
7217 + * so "is there a drop-in" and "is it ours" are completely different
7218 + * questions, and only the second one licenses a write. An unreadable
7219 + * drop-in is deliberately its own answer rather than folding into
7220 + * "foreign": we cannot even name what we would be destroying.
7221 + *
7222 + * @return string One of the DROPIN_* constants.
7223 + */
7224 + public static function dropin_owner(): string {
7225 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
7226 + $target = WP_CONTENT_DIR . '/advanced-cache.php';
7227 + if ( ! file_exists( $target ) ) {
7228 + return self::DROPIN_NONE;
7229 + }
7230 +
7231 + $contents = self::read_file( $target );
7232 + if ( null === $contents ) {
7233 + return self::DROPIN_UNREADABLE;
7234 + }
7235 +
7236 + if ( xspeed_has_canonical_dropin_signature( $contents ) ) {
7237 + return self::DROPIN_XSPEED;
7238 + }
7239 +
7240 + // Nothing in the file means nothing owns it. Kept distinct from
7241 + // FOREIGN so the acquisition gate can tell "someone else's cache" from
7242 + // "a husk the last plugin left behind". (#391)
7243 + if ( '' === trim( $contents ) ) {
7244 + return self::DROPIN_ABANDONED;
7245 + }
7246 +
7247 + /*
7248 + * The other half of the same question, and it cannot be answered from
7249 + * the bytes: a file we cannot attribute is a COMPETITOR only while
7250 + * some page cache is actually running. With every candidate switched
7251 + * off it is abandoned -- a hosting company's own cache, a hand-rolled
7252 + * one, or a plugin that was deleted without cleaning up.
7253 + *
7254 + * Asking the detector rather than re-deriving it here is the point:
7255 + * these two answers disagreeing is a split brain with a bad ending --
7256 + * acquisition_blocker() opens the gate, install_dropin() then refuses
7257 + * on FOREIGN, and toggle() blames the filesystem for a write it never
7258 + * attempted. One question, one answer. (#391, #393)
7259 + */
7260 + if ( class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) {
7261 + $owner = (string) ( Page_Cache_Detector::inspect()['dropin']['owner'] ?? '' );
7262 +
7263 + // Attributable to a named plugin -> somebody's cache, whatever its
7264 + // activation state. Only a file NOBODY can be shown to own, with
7265 + // nothing running, is abandoned.
7266 + if ( Page_Cache_Detector::OWNER_UNKNOWN === $owner
7267 + && ! Page_Cache_Detector::another_page_cache_is_active() ) {
7268 + return self::DROPIN_ABANDONED;
7269 + }
7270 + }
7271 +
7272 + return self::DROPIN_FOREIGN;
7273 + }
7274 +
7275 + /**
7276 + * Why xSpeed must not install its page-cache artifacts right now, or null
7277 + * when it may.
7278 + *
7279 + * This is the single gate in front of every write that touches shared
7280 + * state — the drop-in and the WP_CACHE define. Both are single-occupancy:
7281 + * whatever is there belongs to exactly one plugin, and taking it silently
7282 + * breaks that plugin's caching with no way back.
7283 + *
7284 + * Returns a user-facing string, so a REST caller can hand it straight to
7285 + * the dashboard instead of reporting a bare failure.
7286 + */
7287 + public static function acquisition_blocker(): ?string {
7288 + Page_Cache_Detector::invalidate();
7289 + $verdict = Page_Cache_Detector::classify();
7290 + $owner = self::dropin_owner();
7291 + // The reason we refuse, whether that reason already names a plugin,
7292 + // and every other page cache the detector counted anywhere in the
7293 + // verdict. See the tail of this method for why all three are needed.
7294 + $primary = null;
7295 + $primary_names = false;
7296 + $named = array();
7297 + foreach ( $verdict['blockers'] as $blocker ) {
7298 + $code = (string) ( $blocker['code'] ?? '' );
7299 + // The shared detector quite correctly reports xSpeed itself as a
7300 + // page-cache owner. That is not a competitor to this transaction.
7301 + //
7302 + // Except when the two disagree about the DROP-IN. The detector
7303 + // accepts our marker anywhere in a file's header; this plugin's
7304 + // own check requires it to open the header, because only this
7305 + // side authorizes overwriting and deleting. A foreign drop-in
7306 + // that merely carries our marker further down its header is
7307 + // attributed to us by the detector, and skipping it here dropped
7308 + // the refusal entirely — the write then failed on the stricter
7309 + // check and the user was told to go and fix file permissions.
7310 + // Where they disagree, believe the stricter one.
7311 + if ( self::PLUGIN_FILE === ( $blocker['plugin'] ?? null ) ) {
7312 + $about_dropin = in_array(
7313 + $code,
7314 + array(
7315 + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
7316 + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
7317 + ),
7318 + true
7319 + );
7320 + if ( ! $about_dropin || self::DROPIN_XSPEED === $owner ) {
7321 + continue;
7322 + }
7323 + }
7324 + if ( Page_Cache_Detector::BLOCKER_WP_CACHE_ORPHANED === $code && self::DROPIN_XSPEED === $owner ) {
7325 + continue;
7326 + }
7327 + /*
7328 + * Another plugin's drop-in is no longer a refusal.
7329 + *
7330 + * It used to be: whoever held advanced-cache.php kept it, and
7331 + * enabling was blocked with "deactivate its page cache first".
7332 + * That left a user who had asked for our cache with no way to get
7333 + * it — on a live site the only exit was deleting a file over SSH,
7334 + * and the message could not even say which of its two causes
7335 + * applied ("is active OR owns advanced-cache.php").
7336 + *
7337 + * Turning the page cache on is the instruction to serve pages
7338 + * from cache, and that is not possible without this file. So we
7339 + * take it, and the dashboard says whose file it is first —
7340 + * dropin_disclosure() names the owner, the user confirms, and
7341 + * install_dropin() writes ours over the top.
7342 + *
7343 + * A still-active competitor is deliberately NOT re-added as a
7344 + * blocker below: it is caught by `active_page_cache`, which the
7345 + * capability rule already downgrades to a note. Two page caches
7346 + * installed at once is the user's call to make, not ours to
7347 + * refuse — they just told us which one they want serving.
7348 + *
7349 + * UNREADABLE is the exception and stays a refusal: we cannot name
7350 + * what we would destroy, and install_dropin() refuses it too, so
7351 + * opening the gate here would only produce a failed write.
7352 + */
7353 + $about_dropin_owner = in_array(
7354 + $code,
7355 + array(
7356 + Page_Cache_Detector::BLOCKER_FOREIGN_DROPIN,
7357 + Page_Cache_Detector::BLOCKER_UNKNOWN_DROPIN,
7358 + ),
7359 + true
7360 + );
7361 + if ( $about_dropin_owner && self::DROPIN_UNREADABLE !== $owner ) {
7362 + continue;
7363 + }
7364 + /*
7365 + * Capability is not possession. `active_page_cache` and
7366 + * `multiple_page_caches` both fire on a plugin that merely CAN
7367 + * cache pages — the detector cannot prove a competitor's page
7368 + * cache is off, so it counts it. As a warning that is right. As
7369 + * a gate it refuses a write that takes nothing from anyone.
7370 + *
7371 + * This gate guards exactly two files: advanced-cache.php and the
7372 + * WP_CACHE define that loads it. A plugin that does not hold the
7373 + * drop-in has nothing here for us to overwrite, and one that does
7374 + * is already refused by `foreign_dropin` / `unknown_dropin` a few
7375 + * lines up. So when the field is ours or empty, an active
7376 + * competitor is a note, not a refusal.
7377 + *
7378 + * QA found this on a live OpenLiteSpeed site keeping LiteSpeed
7379 + * Cache for images and CDN with its page cache off, while xSpeed
7380 + * served the pages. One click of the off switch and it could not
7381 + * be turned back on: the only way out was deactivating LiteSpeed
7382 + * entirely, and the message told them to "deactivate its page
7383 + * cache" — which they already had.
7384 + */
7385 + $about_capability = in_array(
7386 + $code,
7387 + array(
7388 + Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE,
7389 + Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES,
7390 + ),
7391 + true
7392 + );
7393 + /*
7394 + * FOREIGN belongs in this list now, and it is the whole point.
7395 + *
7396 + * The rule is still "capability is not possession": these two
7397 + * blockers fire on any plugin that CAN cache pages, which the
7398 + * detector cannot prove is switched off. What changed is that a
7399 + * competitor holding the drop-in no longer stops us either — we
7400 + * take the file, having said whose it is. So there is nothing
7401 + * left for a merely-installed competitor to protect, and keeping
7402 + * the refusal here would put back the dead end by another route:
7403 + * "another page cache is active" on a site where the user has
7404 + * just told us, by name, which cache they want serving.
7405 + *
7406 + * UNREADABLE is deliberately still absent — that one refuses.
7407 + */
7408 + if ( $about_capability
7409 + && in_array( $owner, array( self::DROPIN_XSPEED, self::DROPIN_NONE, self::DROPIN_FOREIGN, self::DROPIN_ABANDONED ), true ) ) {
7410 + continue;
7411 + }
7412 + if ( Page_Cache_Detector::BLOCKER_MULTIPLE_PAGE_CACHES === $code ) {
7413 + $others = self::other_page_cache_names( $blocker );
7414 + if ( array() === $others ) {
7415 + // We were the only owner counted — nothing to refuse —
7416 + // unless the list is missing entirely, which is an older
7417 + // detector copy we still must not talk past.
7418 + if ( null === $primary && array() === (array) ( $blocker['plugins'] ?? array() ) ) {
7419 + $primary = self::ownership_blocker_message( '', '' );
7420 + }
7421 + continue;
7422 + }
7423 + $named = array_values( array_unique( array_merge( $named, $others ) ) );
7424 + if ( null === $primary ) {
7425 + $primary = self::multiple_page_caches_message( $others );
7426 + $primary_names = true;
7427 + }
7428 + continue;
7429 + }
7430 + if ( null === $primary ) {
7431 + $label = (string) ( $blocker['label'] ?? '' );
7432 + $primary = self::ownership_blocker_message( $code, $label );
7433 + $primary_names = '' !== $label;
7434 + }
7435 + }
7436 +
7437 + if ( null === $primary ) {
7438 + return null;
7439 + }
7440 + /*
7441 + * The first blocker decides WHY we refuse; it does not always know
7442 + * WHO. The detector can only attribute a drop-in it recognises, and
7443 + * an unrecognised one produces "its owner cannot be proved" — the
7444 + * sentence a W3 Total Cache site used to get while a later blocker in
7445 + * the same verdict was holding the name "W3 Total Cache".
7446 + *
7447 + * So keep the reason and add the names, rather than swapping one for
7448 + * the other: the plugin the user must deal with is not necessarily
7449 + * the owner of the file we could not identify, and promoting the
7450 + * named blocker would have told them to deactivate a plugin that is
7451 + * not what is in their way.
7452 + */
7453 + if ( $primary_names || array() === $named ) {
7454 + return $primary;
7455 + }
7456 + if ( 1 === count( $named ) ) {
7457 + return sprintf(
7458 + /* translators: 1: the refusal reason, 2: a page-caching plugin's name. */
7459 + __( '%1$s %2$s is also active on this site — deactivate its page cache before enabling xSpeed.', 'xspeed' ),
7460 + $primary,
7461 + $named[0]
7462 + );
7463 + }
7464 + return sprintf(
7465 + /* translators: 1: the refusal reason, 2: comma-separated page-caching plugin names. */
7466 + __( '%1$s These page caches are also active on this site: %2$s. Deactivate them before enabling xSpeed.', 'xspeed' ),
7467 + $primary,
7468 + implode( ', ', $named )
7469 + );
7470 + }
7471 +
7472 + /** How xSpeed's own plugin file appears in the detector's catalog. */
7473 + private const PLUGIN_FILE = 'xspeed/xspeed.php';
7474 +
7475 + /**
7476 + * Name the OTHER page caches behind a `multiple_page_caches` refusal.
7477 + *
7478 + * This blocker has no single owner, so the detector leaves `plugin` and
7479 + * `label` null and hands over the full list instead. Left unhandled it
7480 + * fell through to the anonymous fallback sentence — and it is the blocker
7481 + * an ordinary site hits most: xSpeed counts toward "multiple", so the
7482 + * count reaches two the moment one other page-cache plugin is activated,
7483 + * even one that has not written a drop-in. A site running our cache that
7484 + * activated LiteSpeed could not re-enable it and was told only that "the
7485 + * page-cache field is occupied".
7486 + *
7487 + * Returns an empty list when xSpeed was the only owner counted, or when
7488 + * an older detector copy sent no list at all — the caller distinguishes
7489 + * the two by looking at `plugins`.
7490 + *
7491 + * @param array<string,mixed> $blocker One entry from Detector::classify().
7492 + * @return string[]
7493 + */
7494 + private static function other_page_cache_names( array $blocker ): array {
7495 + $plugins = array_values( (array) ( $blocker['plugins'] ?? array() ) );
7496 + $labels = array_values( (array) ( $blocker['labels'] ?? array() ) );
7497 +
7498 + $others = array();
7499 + foreach ( $plugins as $i => $plugin ) {
7500 + if ( self::PLUGIN_FILE === $plugin ) {
7501 + continue;
7502 + }
7503 + $others[] = isset( $labels[ $i ] ) && '' !== (string) $labels[ $i ]
7504 + ? (string) $labels[ $i ]
7505 + : (string) $plugin;
7506 + }
7507 + return array_values( array_unique( $others ) );
7508 + }
7509 +
7510 + /**
7511 + * The refusal sentence for a `multiple_page_caches` blocker.
7512 + *
7513 + * @param string[] $others Page caches other than xSpeed. Never empty.
7514 + */
7515 + private static function multiple_page_caches_message( array $others ): string {
7516 + if ( 1 === count( $others ) ) {
7517 + return self::ownership_blocker_message( Page_Cache_Detector::BLOCKER_ACTIVE_PAGE_CACHE, $others[0] );
7518 + }
7519 + return sprintf(
7520 + /* translators: %s: comma-separated list of page-caching plugin names. */
7521 + __( 'More than one page cache is active on this site (%s). Turn off the other page caches before enabling xSpeed.', 'xspeed' ),
7522 + implode( ', ', $others )
7523 + );
7524 + }
7525 +
7526 + private static function ownership_blocker_message( string $code, string $label ): string {
7527 + if ( '' !== $label ) {
7528 + return sprintf( __( '%s is active or owns advanced-cache.php. Deactivate its page cache before enabling xSpeed.', 'xspeed' ), $label );
7529 + }
7530 + $messages = array(
7531 + 'wp_cache_orphaned' => __( 'WP_CACHE is true but no page-cache drop-in owner can be proved. xSpeed will not claim it.', 'xspeed' ),
7532 + 'wp_cache_duplicate' => __( 'wp-config.php defines WP_CACHE more than once. Remove the duplicate before enabling the cache.', 'xspeed' ),
7533 + 'wp_cache_dynamic' => __( 'WP_CACHE is set from an expression in wp-config.php. xSpeed will not rewrite it.', 'xspeed' ),
7534 + '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' ),
7535 + 'wp_config_unreadable' => __( 'wp-config.php cannot be read, so xSpeed cannot safely change page-cache ownership.', 'xspeed' ),
7536 + 'unknown_dropin' => __( 'advanced-cache.php is occupied but its owner cannot be proved. xSpeed will not replace it.', 'xspeed' ),
7537 + 'unreadable_dropin' => __( 'advanced-cache.php cannot be read, so xSpeed cannot prove its owner.', 'xspeed' ),
7538 + );
7539 + return $messages[ $code ] ?? __( 'The page-cache field is occupied or cannot be verified. xSpeed will not change it.', 'xspeed' );
7540 + }
7541 +
7542 + /**
7543 + * How WP_CACHE is written in wp-config.php, as opposed to what it
7544 + * evaluates to at runtime.
7545 + *
7546 + * The literal is what matters to a writer: a value behind an expression,
7547 + * or two competing defines, cannot be rewritten by a regex without
7548 + * guessing — and a wrong guess silently disables page caching (ours or
7549 + * someone else's) with no error anywhere.
7550 + *
7551 + * @return string undefined | true | false | duplicate | dynamic | conditional | unreadable
7552 + */
7553 + public static function wp_cache_define_state(): string {
7554 + $path = self::wp_config_path();
7555 + if ( '' === $path ) {
7556 + return 'unreadable';
7557 + }
7558 +
7559 + $config = self::read_file( $path );
7560 + if ( null === $config ) {
7561 + return 'unreadable';
7562 + }
7563 +
7564 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
7565 + $parsed = \xspeed_parse_wp_cache_defines( $config );
7566 + return $parsed['state'];
7567 + }
7568 +
7569 + /**
7570 + * Classify the captured right-hand side of a WP_CACHE define.
7571 + *
7572 + * Hosts and older tutorials write the value several ways —
7573 + * `1`, `'1'`, `TRUE` — and all of them are literals a rewrite can safely
7574 + * replace. Only a value we cannot evaluate by looking at it (a variable, a
7575 + * function call, a ternary) counts as dynamic, because that is the case
7576 + * where rewriting means guessing.
7577 + *
7578 + * @return string true | false | dynamic
7579 + */
7580 + private static function classify_wp_cache_literal( string $raw ): string {
7581 + $literal = strtolower( trim( $raw ) );
7582 + $literal = trim( $literal, "'\"" );
7583 +
7584 + if ( in_array( $literal, array( 'true', '1' ), true ) ) {
7585 + return 'true';
7586 + }
7587 + if ( in_array( $literal, array( 'false', '0', '', 'null' ), true ) ) {
7588 + return 'false';
7589 + }
7590 + return 'dynamic';
7591 + }
7592 +
7593 + /**
7594 + * Read a file for an ownership decision. Null on any failure — callers
7595 + * treat null as "unknown", never as "empty", because an empty string
7596 + * would read as "no marker found" and license an overwrite.
7597 + */
7598 + private static function read_file( string $path ): ?string {
7599 + if ( ! is_readable( $path ) ) {
7600 + return null;
7601 + }
7602 + // 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.
7603 + $contents = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed read is a valid answer ("unknown"), not an error to surface.
7604 + return is_string( $contents ) ? $contents : null;
7605 + }
7606 +
2381 7607 public static function install_dropin() {
2382 7608 $source = XSPEED_DIR . 'includes/advanced-cache.php';
2383 7609 $target = WP_CONTENT_DIR . '/advanced-cache.php';
2384 7610 if ( ! file_exists( $source ) ) {
@@ -2384,8 +7610,26 @@
2384 7610 if ( ! file_exists( $source ) ) {
2385 7611 return false;
2386 7612 }
2387 7613
7614 + /*
7615 + * A drop-in we cannot READ is the one thing still refused here. Not
7616 + * because of who owns it — we no longer refuse on ownership — but
7617 + * because an unreadable file is usually a permissions problem, and
7618 + * writing over it would fail anyway or destroy something we were
7619 + * never able to look at.
7620 + *
7621 + * Everything else is ours to take. Enabling the page cache IS the
7622 + * user's instruction to serve the cache, and serving it means holding
7623 + * advanced-cache.php; the dashboard says whose file it is replacing
7624 + * before the click (Page_Cache_Detector::dropin_disclosure()), so the
7625 + * takeover is consented rather than silent.
7626 + */
7627 + $owner = self::dropin_owner();
7628 + if ( self::DROPIN_UNREADABLE === $owner ) {
7629 + return false;
7630 + }
7631 +
2388 7632 global $wp_filesystem;
2389 7633 if ( ! function_exists( 'WP_Filesystem' ) ) {
2390 7634 require_once ABSPATH . 'wp-admin/includes/file.php';
2391 7635 }
@@ -2409,35 +7653,71 @@
2409 7653 str_replace( "'", "\\'", self::hits_log_path() ),
2410 7654 $source_contents
2411 7655 );
2412 7656
7657 + // Bake the cookie + user-agent exclusion rules in too. The drop-in
7658 + // runs before WordPress loads, so it cannot read the settings — and
7659 + // without them it served the shared anonymous page to any visitor
7660 + // PHP had not yet seen (a first-time cart visitor, a bypassed bot).
7661 + // The generic bypass cookie only covers repeat visitors; these two
7662 + // regexes are what make the FIRST request correct.
7663 + //
7664 + // Both are already fully escaped by Server_Rules, and each is
7665 + // embedded as a single-quoted PHP literal, so a settings value can
7666 + // neither break the drop-in's syntax nor execute.
7667 + $cache_opts = Settings_Manager::get( 'cache' );
7668 + $cookie_rule = Server_Rules::cookie_rule(
7669 + is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array()
7670 + );
7671 + $ua_rule = Server_Rules::user_agent_rule(
7672 + is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array()
7673 + );
7674 +
7675 + $source_contents = str_replace(
7676 + '@@XSPEED_COOKIE_RE@@',
7677 + str_replace( "'", "\\'", $cookie_rule['regex'] ),
7678 + $source_contents
7679 + );
7680 + $source_contents = str_replace(
7681 + '@@XSPEED_UA_RE@@',
7682 + str_replace( "'", "\\'", $ua_rule['regex'] ),
7683 + $source_contents
7684 + );
7685 +
7686 + /*
7687 + * Ours or absent — the ownership gate at the top of this method ruled
7688 + * out everything else. The old code path that moved a foreign drop-in
7689 + * into uploads/xspeed-backups and wrote ours on top is gone: it
7690 + * disabled the other plugin's page cache the moment an xSpeed install
7691 + * ran, with nothing in its own UI to explain why.
7692 + */
7693 +
7694 + // Bake the configured cache lifetime in. The drop-in runs before
7695 + // WordPress loads, so it cannot read the option — it previously fell
7696 + // back to a hardcoded 86400 for every ordinary page, because
7697 + // write_meta() only emits a `ttl` sidecar when the value DIFFERS from
7698 + // the page default. That made the admin's "1 to 720 hours" control a
7699 + // no-op at the layer that actually answers the request: 12h served
7700 + // stale for up to 2x the configured lifetime, and 168h lost the fast
7701 + // path for 6 of every 7 days (issue #240).
7702 + //
7703 + // This is re-baked on every cache settings save (see CacheModule::boot),
7704 + // exactly like the cookie / user-agent rules above.
7705 + $expiry_hours = isset( $cache_opts['cache_expiry'] ) ? (int) $cache_opts['cache_expiry'] : 24;
7706 + if ( $expiry_hours < 1 || $expiry_hours > 720 ) {
7707 + $expiry_hours = 24;
7708 + }
7709 + $source_contents = str_replace(
7710 + '@@XSPEED_DEFAULT_TTL@@',
7711 + (string) ( $expiry_hours * HOUR_IN_SECONDS ),
7712 + $source_contents
7713 + );
7714 +
2413 7715 if ( file_exists( $target ) ) {
2414 7716 $existing = $wp_filesystem->get_contents( $target );
2415 - $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
2416 -
2417 - if ( $is_xspeed ) {
2418 - if ( $existing === $source_contents ) {
2419 - return true;
2420 - }
2421 - return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
7717 + if ( is_string( $existing ) && $existing === $source_contents ) {
7718 + return true;
2422 7719 }
2423 -
2424 - // Foreign drop-in (e.g. left over from another cache plugin) — back it up
2425 - // before overwriting so the user can recover if needed. Uploads dir
2426 - // (not wp-content root) keeps the backup out of WordPress's reserved
2427 - // drop-in location.
2428 - $upload = wp_upload_dir( null, false );
2429 - $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
2430 - if ( $basedir ) {
2431 - if ( ! file_exists( $basedir ) ) {
2432 - wp_mkdir_p( $basedir );
2433 - self::write_silence( $basedir );
2434 - }
2435 - $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
2436 - $wp_filesystem->move( $target, $backup, true );
2437 - } else {
2438 - $wp_filesystem->delete( $target );
2439 - }
2440 7720 }
2441 7721
2442 7722 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
2443 7723 }
@@ -2457,58 +7737,201 @@
2457 7737 return;
2458 7738 }
2459 7739
2460 7740 $contents = $wp_filesystem->get_contents( $target );
2461 - if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
7741 + if ( is_string( $contents ) && xspeed_has_canonical_dropin_signature( $contents ) ) {
2462 7742 wp_delete_file( $target );
2463 7743 }
2464 7744 }
2465 7745
7746 + /**
7747 + * Where wp-config.php actually is.
7748 + *
7749 + * WordPress core supports the file one directory ABOVE ABSPATH, and
7750 + * plenty of installs use that layout. This used to look only in ABSPATH
7751 + * and bail, so on those sites the constant could never be written — while
7752 + * Health, which did fall back to the parent, reported the file writable
7753 + * and told the user to toggle the cache off and on. The advice could
7754 + * never work, and its fallback hint ("another plugin left WP_CACHE false
7755 + * behind") was wrong too: there was no define at all. (#19, QA on #174)
7756 + *
7757 + * Returns '' when no wp-config.php can be found in either location.
7758 + */
7759 + public static function wp_config_path(): string {
7760 + $candidates = array( ABSPATH . 'wp-config.php', dirname( ABSPATH ) . '/wp-config.php' );
7761 + foreach ( $candidates as $path ) {
7762 + if ( file_exists( $path ) ) {
7763 + return $path;
7764 + }
7765 + }
7766 + return '';
7767 + }
7768 +
7769 + /**
7770 + * Can we actually write the constant right now?
7771 + *
7772 + * This is the single oracle for that question — Health asks THIS rather
7773 + * than running its own `wp_is_writable()` test, so the message a user
7774 + * reads can never disagree with what the plugin will do. The two differed
7775 + * in both directions: on the path (above) and on the test itself, since
7776 + * an FTP/SSH WP_Filesystem transport can refuse a file that
7777 + * `wp_is_writable()` reports as writable. (#19, QA on #174)
7778 + */
7779 + public static function can_write_wp_config(): bool {
7780 + $wp_config = self::wp_config_path();
7781 + if ( '' === $wp_config ) {
7782 + return false;
7783 + }
7784 +
7785 + global $wp_filesystem;
7786 + if ( ! function_exists( 'WP_Filesystem' ) ) {
7787 + require_once ABSPATH . 'wp-admin/includes/file.php';
7788 + }
7789 + WP_Filesystem();
7790 + return (bool) ( $wp_filesystem && $wp_filesystem->is_writable( $wp_config ) );
7791 + }
7792 +
2466 7793 public static function set_wp_cache_constant( $enable ) {
2467 - $wp_config = ABSPATH . 'wp-config.php';
2468 - if ( ! file_exists( $wp_config ) ) {
7794 + $wp_config = self::wp_config_path();
7795 + if ( '' === $wp_config ) {
2469 7796 return false;
2470 7797 }
2471 7798
7799 + /*
7800 + * WP_CACHE belongs to whoever owns the drop-in — it is the switch that
7801 + * makes core load that one file. Editing it while someone else's
7802 + * drop-in is installed either turns THEIR cache on or off; either way
7803 + * it is a write to another plugin's state. So: no ownership, no edit.
7804 + */
7805 + $owner = self::dropin_owner();
7806 + if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
7807 + return false;
7808 + }
7809 +
7810 + $state = self::wp_cache_define_state();
7811 + if ( 'duplicate' === $state || 'dynamic' === $state ) {
7812 + // Two competing defines, or a value behind an expression. A regex
7813 + // rewrite here is a guess, and a wrong guess silently kills page
7814 + // caching with no error anywhere.
7815 + return false;
7816 + }
2472 7817 global $wp_filesystem;
2473 7818 if ( ! function_exists( 'WP_Filesystem' ) ) {
2474 7819 require_once ABSPATH . 'wp-admin/includes/file.php';
2475 7820 }
2476 7821 WP_Filesystem();
2477 - if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
7822 + if ( ! $wp_filesystem ) {
2478 7823 return false;
2479 7824 }
2480 7825
2481 7826 $config = $wp_filesystem->get_contents( $wp_config );
7827 + if ( ! is_string( $config ) ) {
7828 + return false;
7829 + }
7830 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
7831 + $marker = $enable ? self::wp_cache_receipt() : '';
7832 + $updated = xspeed_rewrite_wp_cache_define( $config, (bool) $enable, $marker );
7833 + if ( ! is_string( $updated ) ) {
7834 + return false;
7835 + }
2482 7836
2483 - if ( $enable ) {
2484 - // Own the constant. A previous caching plugin (e.g. WP Rocket sets
2485 - // it false on deactivate) can leave `define( 'WP_CACHE', false );`
2486 - // behind — presence alone is not enough, the VALUE must be true or
2487 - // WordPress never loads advanced-cache.php and our drop-in is dead.
2488 - if ( preg_match( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,/", $config ) ) {
2489 - $rewritten = preg_replace(
2490 - "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*[^)]*\\)\\s*;/",
2491 - "define( 'WP_CACHE', true );",
2492 - $config,
2493 - 1
2494 - );
2495 - // If an existing define was already `true`, the rewrite is a
2496 - // no-op string-wise; either way we end on WP_CACHE === true.
2497 - if ( null !== $rewritten ) {
2498 - $config = $rewritten;
2499 - }
2500 - } else {
2501 - $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
7837 + /*
7838 + * Removing a WP_CACHE line we cannot prove we wrote is somebody else's
7839 + * configuration, so a disable needs either our drop-in or our receipt.
7840 + *
7841 + * The test is on the REWRITE, not on the request: it used to run
7842 + * before the rewrite and refuse a disable that had nothing to remove.
7843 + * An ordinary site with no drop-in and no define — every fresh
7844 + * install — therefore failed to turn page caching off, so the
7845 + * onboarding wizard reported "setup needs attention" to every user who
7846 + * declined it and Migration reported the cache import as failed.
7847 + */
7848 + if ( ! $enable && $updated !== $config
7849 + && self::DROPIN_XSPEED !== $owner
7850 + && ! self::wp_cache_receipt_matches_source( $config ) ) {
7851 + return false;
7852 + }
7853 +
7854 + /*
7855 + * Nothing to write. auto_heal() runs the whole enable transaction on
7856 + * every admin_init, so without this every wp-admin request rewrote
7857 + * wp-config.php with byte-identical content: pointless disk churn
7858 + * that trips host file-integrity monitors and widens the window for
7859 + * a concurrent write on a busy admin.
7860 + *
7861 + * It is also what makes a correct WP_CACHE on a read-only
7862 + * wp-config.php succeed. A managed host that ships the file
7863 + * unwritable, on a site where the user already pasted the define,
7864 + * is in the state we wanted — the writability test below is about
7865 + * whether we can CHANGE the file, and there is nothing to change.
7866 + */
7867 + if ( $updated === $config ) {
7868 + if ( ! $enable ) {
7869 + // Our line is not in the file, so the receipt that proved we
7870 + // wrote it is stale — drop it on the same terms as a real
7871 + // removal, or uninstall keeps a claim on nothing.
7872 + delete_option( 'xspeed_page_cache_ownership_receipt' );
2502 7873 }
2503 - } else {
2504 - $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
7874 + return true;
2505 7875 }
2506 7876
2507 - return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
7877 + if ( ! $wp_filesystem->is_writable( $wp_config ) ) {
7878 + return false;
7879 + }
7880 + $written = (bool) $wp_filesystem->put_contents( $wp_config, $updated, FS_CHMOD_FILE );
7881 + if ( $written && ! $enable ) {
7882 + delete_option( 'xspeed_page_cache_ownership_receipt' );
7883 + }
7884 + return $written;
2508 7885 }
2509 7886
7887 + private static function wp_cache_receipt(): string {
7888 + $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
7889 + if ( is_string( $receipt ) && preg_match( '/^[a-f0-9]{32}$/', $receipt ) ) {
7890 + return $receipt;
7891 + }
7892 + $receipt = substr( hash( 'sha256', XSPEED_DIR . microtime( true ) . mt_rand() ), 0, 32 );
7893 + update_option( 'xspeed_page_cache_ownership_receipt', $receipt, false );
7894 + return $receipt;
7895 + }
7896 +
2510 7897 /**
7898 + * Is the WP_CACHE line in wp-config.php ours to REMOVE?
7899 + *
7900 + * Two different questions live here and only one of them matters. "Did we
7901 + * write it" is answered by our drop-in on disk or by our receipt comment
7902 + * beside the define. "Is it ours to remove" also asks what the line does
7903 + * NOW — and once a competitor owns advanced-cache.php, a line we wrote
7904 + * ourselves is the switch that loads THEIR drop-in. They had no reason to
7905 + * touch an already-true define, so our receipt is still sitting on it.
7906 + * Removing it there would stop their live page cache.
7907 + *
7908 + * So a foreign or unreadable owner is never ours to remove, whatever the
7909 + * receipt says, and the caller treats that as a reason to leave the line
7910 + * and get on with disabling our own cache — not as a reason to refuse.
7911 + */
7912 + private static function wp_cache_define_is_ours_to_remove( string $owner ): bool {
7913 + if ( self::DROPIN_FOREIGN === $owner || self::DROPIN_UNREADABLE === $owner ) {
7914 + return false;
7915 + }
7916 + if ( self::DROPIN_XSPEED === $owner ) {
7917 + return true;
7918 + }
7919 + $path = self::wp_config_path();
7920 + if ( '' === $path ) {
7921 + return false;
7922 + }
7923 + $config = self::read_file( $path );
7924 + return is_string( $config ) && self::wp_cache_receipt_matches_source( $config );
7925 + }
7926 +
7927 + private static function wp_cache_receipt_matches_source( string $source ): bool {
7928 + $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
7929 + require_once XSPEED_DIR . 'includes/wp-cache-constant.php';
7930 + return xspeed_wp_cache_receipt_matches( $source, $receipt );
7931 + }
7932 +
7933 + /**
2511 7934 * Admin-bar purge menu — a parent node plus one child per visible cache
2512 7935 * type (LiteSpeed-style), instead of a single "Purge All" link. Each
2513 7936 * child posts to the same admin-post handler with its type slug. The
2514 7937 * per-type items only appear for active/licensed modules; "Purge All"
@@ -2531,10 +7954,59 @@
2531 7954 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
2532 7955 )
2533 7956 );
2534 7957
2535 - foreach ( self::purge_types() as $slug => $type ) {
2536 - if ( empty( $type['visible'] ) ) {
7958 + // Settings first, then the two whole-errand actions (Purge All,
7959 + // Purge this URL), then the per-type items. The order is the one WP
7960 + // Rocket uses, and it front-loads what people open this menu for:
7961 + // nobody reaches for "Purge Object Cache" as often as they reach for
7962 + // the page they are looking at.
7963 + $wp_admin_bar->add_node(
7964 + array(
7965 + 'id' => 'xspeed-purge-settings',
7966 + 'parent' => 'xspeed-purge',
7967 + 'title' => esc_html__( 'Settings', 'xspeed' ),
7968 + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
7969 + )
7970 + );
7971 +
7972 + $types = self::purge_types();
7973 +
7974 + // 'all' is rendered out of band so the single-URL item can sit
7975 + // directly under it. A filter that reorders or drops it is honoured:
7976 + // the loop below skips whatever was emitted here.
7977 + $emitted = array();
7978 + if ( ! empty( $types['all']['visible'] ) ) {
7979 + $wp_admin_bar->add_node(
7980 + array(
7981 + 'id' => 'xspeed-purge-all',
7982 + 'parent' => 'xspeed-purge',
7983 + 'title' => esc_html( $types['all']['label'] ),
7984 + 'href' => self::purge_type_url( 'all' ),
7985 + )
7986 + );
7987 + $emitted['all'] = true;
7988 + }
7989 +
7990 + // Only when the current screen is about one thing — a front-end view,
7991 + // or a published post's edit screen. On a list table or a settings
7992 + // page there is nothing for "this" to mean, so the item stays hidden
7993 + // rather than silently targeting the dashboard. Purge_Ui decides both
7994 + // the label and the scope, which differ between the two contexts.
7995 + $context = Purge_Ui::context_node();
7996 + if ( null !== $context ) {
7997 + $wp_admin_bar->add_node(
7998 + array(
7999 + 'id' => 'xspeed-purge-this-url',
8000 + 'parent' => 'xspeed-purge',
8001 + 'title' => esc_html( $context['title'] ),
8002 + 'href' => $context['href'],
8003 + )
8004 + );
8005 + }
8006 +
8007 + foreach ( $types as $slug => $type ) {
8008 + if ( empty( $type['visible'] ) || isset( $emitted[ $slug ] ) ) {
2537 8009 continue;
2538 8010 }
2539 8011 $wp_admin_bar->add_node(
2540 8012 array(