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 +7162 -177 1.0.21.3.2 View file →
@@ -19,19 +19,323 @@
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
26 - $invalidate_hooks = array( 'save_post', 'deleted_post', 'trashed_post', 'comment_post', 'wp_set_comment_status', 'switch_theme', 'activated_plugin', 'deactivated_plugin' );
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 +
155 + // Events that should invalidate cached output. Beyond posts/comments,
156 + // this covers user and term changes — the REST cache can serve
157 + // /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect
158 + // rendered author bylines / term-archive pages. Without them, an edit
159 + // left the matching endpoint (and archives) stale for the full TTL.
160 + // (FBS-82408)
161 + $invalidate_hooks = array(
162 + 'save_post', 'before_delete_post', 'trashed_post',
163 + 'comment_post', 'wp_set_comment_status',
164 + 'switch_theme', 'activated_plugin', 'deactivated_plugin',
165 + // Users → /wp/v2/users + author archives.
166 + 'profile_update', 'user_register', 'deleted_user',
167 + // Terms → /wp/v2/{taxonomy} + term archives.
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',
173 + );
27 174 foreach ( $invalidate_hooks as $hook ) {
28 - 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 + );
29 201 add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
30 202 }
31 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 +
32 317 add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );
33 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 +
34 338 add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
35 339 add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
36 340 }
37 341
@@ -47,35 +351,265 @@
47 351 self::purge_all( 'settings change' );
48 352 Minifier::purge_minified();
49 353 }
50 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 +
51 503 public function maybe_start_cache() {
52 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 + }
53 528 return;
54 529 }
55 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 +
56 535 $key = self::cache_key();
57 536 $file = self::cache_file_for( $key );
58 537
59 538 if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
60 539 Hit_Counter::record_hit();
540 + // Emit the HIT marker on THIS path too. The drop-in
541 + // (advanced-cache.php) sends "HIT (php)" and the nginx static
542 + // rewrite sends "HIT (nginx)", but this template_redirect
543 + // serve path — the one that runs when the drop-in isn't loaded
544 + // (e.g. WP_CACHE not true) — previously streamed the cached
545 + // file with NO marker, so a genuine HIT looked like a MISS in
546 + // the response headers. Same header + value as the drop-in.
547 + self::mark( 'HIT (php)' );
548 + // Replay stored response bits so the HIT matches the original:
549 + // a non-HTML Content-Type (cached feeds, sitemaps) and a non-200
550 + // status (a cached 404 must serve 404, not 200). No-op for
551 + // ordinary pages, which write no .meta.
552 + $meta = self::read_meta( $key );
553 + if ( ! headers_sent() ) {
554 + if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) {
555 + http_response_code( (int) $meta['status'] );
556 + }
557 + if ( ! empty( $meta['content_type'] ) && is_string( $meta['content_type'] ) ) {
558 + header( 'Content-Type: ' . $meta['content_type'] );
559 + }
560 + // Conditional GET: emit Last-Modified + ETag and answer a
561 + // matching If-Modified-Since / If-None-Match with 304 so
562 + // aggregators (and browsers) skip re-downloading an unchanged
563 + // cached response — the bandwidth win feeds are about.
564 + // (FBS-82407 #5)
565 + if ( self::serve_not_modified( $file ) ) {
566 + exit; // 304 sent, no body.
567 + }
568 + }
569 + // Serve the precompressed Brotli sibling when the client accepts
570 + // it (an add-on, the Pro Brotli module, wrote <file>.br). On this
571 + // PHP serve path the web server never sees the .br, so without
572 + // this a br-capable client got the plain .html — precompression
573 + // did nothing here. Falls through to plain readfile otherwise.
574 + $br = self::maybe_serve_brotli( $file );
575 + if ( null !== $br ) {
576 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a static cache file directly; WP_Filesystem would buffer through PHP memory and is not appropriate for response streaming.
577 + readfile( $br );
578 + exit;
579 + }
61 580 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- readfile is optimal for streaming a static cache file directly to the visitor; WP_Filesystem would buffer through PHP memory and is not appropriate for response streaming.
62 581 readfile( $file );
63 582 exit;
64 583 }
65 584
66 - // Cache miss → render fresh + write cache. On LiteSpeed servers
67 - // we also signal the server-level LSCache module to cache this
68 - // response, so subsequent requests skip PHP entirely (~5-15ms
69 - // TTFB vs. our PHP drop-in's ~30ms floor). Defers if the
70 - // LiteSpeed Cache plugin is active — that plugin owns its own
71 - // header emission and conflicts with ours.
585 + // Cache miss → render fresh + write cache. On LiteSpeed we send an
586 + // explicit "stand down" header so the server's LSCache module does
587 + // NOT cache + shadow our response — xSpeed's own .htaccess static
588 + // rewrite owns hit serving (and hit accounting) here, exactly as on
589 + // Apache. See maybe_emit_lscache_headers() for the full rationale.
72 590 self::maybe_emit_lscache_headers();
73 591
74 592 // We're about to render fresh + cache → miss for this request.
75 - 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 + }
76 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' );
77 610
611 +
78 612 // WP < 6.9 fallback: ob_start() with a callback, paired with an
79 613 // explicit shutdown close so the buffer lifecycle is visible to
80 614 // reviewers and Plugin Check, instead of relying on PHP's implicit
81 615 // request-end flush. We record our nesting level so close_buffer()
@@ -104,20 +638,178 @@
104 638 }
105 639 self::$buffer_level = null;
106 640 }
107 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 +
108 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 +
109 797 $opts = Settings::get();
110 798 if ( empty( $opts['cache_enabled'] ) ) {
111 - return false;
799 + return self::bypass( 'cache-disabled' );
112 800 }
113 801
114 - if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
115 - return false;
802 + if ( is_user_logged_in() ) {
803 + return self::bypass( 'logged-in' );
116 804 }
117 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 +
118 810 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
119 - return false;
811 + return self::bypass( 'donotcachepage' );
120 812 }
121 813
122 814 // All exclusion knobs now owned by CacheModule.
123 815 $cache_opts = Settings_Manager::get( 'cache' );
@@ -123,22 +815,69 @@
123 815 $cache_opts = Settings_Manager::get( 'cache' );
124 816
125 817 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
126 818 if ( 'GET' !== $method ) {
127 - return false;
819 + return self::bypass( 'non-get' );
128 820 }
129 821
822 + // Search-results requests carry a `s` query param, which the
823 + // query-string gate below would normally reject as "dynamic". An
824 + // add-on (xspeed-pro search cache) can opt them in: when this is a
825 + // genuine is_search() and the filter returns true, the `s` param is
826 + // treated as cacheable (the search term goes into the cache key so
827 + // different searches stay distinct — see cache_key()).
828 + $cache_search = self::should_cache_search();
829 +
830 + // Feed opt-in is resolved BEFORE the query-string gate so query-form
831 + // feeds (/?feed=rss2, used on plain-permalink sites) aren't rejected
832 + // as "dynamic" by that gate — the `feed` param is then allowed through
833 + // just like the search `s` param. Feeds are excluded by default (the
834 + // `/feed/` pattern in excluded_urls); an add-on (xspeed-pro feed cache)
835 + // opts them back in via the filter. (FBS-82407 #4)
836 + $is_feed_request = function_exists( 'is_feed' ) && is_feed();
837 + /**
838 + * Whether to cache the current feed request.
839 + *
840 + * Default false → feeds fall through to the normal URL-exclusion
841 + * rules (so `/feed/` keeps them out). A listener returning true
842 + * opts this feed request into caching.
843 + *
844 + * @param bool $cache_feed Whether to cache this feed request.
845 + */
846 + $cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );
847 +
130 848 // Query string handling: anything OUTSIDE the ignored-params
131 849 // allow-list (utm_*, fbclid, gclid by default) means a unique
132 850 // request that we don't want to share with the canonical cache
133 851 // entry. Skip cache rather than poison the key.
134 - $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.
135 863 if ( '' !== $query_raw ) {
136 864 $ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
137 865 parse_str( $query_raw, $params );
138 866 foreach ( $params as $key => $_ ) {
867 + // Allow the search param through when search caching is on.
868 + if ( $cache_search && 's' === $key ) {
869 + continue;
870 + }
871 + // Allow query-form feed params through when feed caching opted
872 + // this request in (?feed=rss2 / &withcomments=1 on feeds).
873 + if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) {
874 + continue;
875 + }
139 876 if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
140 - 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' );
141 880 }
142 881 }
143 882 }
144 883
@@ -145,10 +884,10 @@
145 884 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
146 885 $path = (string) strtok( $request_uri, '?' );
147 886
148 887 $excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
149 - if ( Glob_Matcher::any_match( $excluded_urls, $path ) ) {
150 - return false;
888 + if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) {
889 + return self::bypass( 'excluded-url' );
151 890 }
152 891
153 892 // Cookie-based exclusion. We only check cookie NAMES (matching
154 893 // values would leak content-sensitive logic into the cache key
@@ -155,10 +894,21 @@
155 894 // rules); presence of any matching cookie name skips cache.
156 895 $excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
157 896 if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
158 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 + }
159 909 if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
160 - return false;
910 + return self::bypass( 'excluded-cookie' );
161 911 }
162 912 }
163 913 }
164 914
@@ -169,9 +919,9 @@
169 919 if ( ! empty( $bypass_uas ) ) {
170 920 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
171 921 foreach ( $bypass_uas as $needle ) {
172 922 if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
173 - return false;
923 + return self::bypass( 'user-agent' );
174 924 }
175 925 }
176 926 }
177 927
@@ -178,25 +928,218 @@
178 928 // Per-post override (Phase 3.4). Honored only on singular
179 929 // post-context requests — archives / 404s / taxonomies use the
180 930 // global policy above.
181 931 if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
932 + return self::bypass( 'post-excluded' );
933 + }
934 +
935 + /**
936 + * Final say on whether the current request is cacheable.
937 + *
938 + * Runs at template_redirect (full WP context), so listeners may use
939 + * conditional tags (is_search(), is_feed(), is_404(),
940 + * wp_is_maintenance_mode(), …). The core engine has already applied
941 + * its own exclusion rules and reached `true`; a listener returning
942 + * false vetoes caching for this request. This is the documented
943 + * extension point add-ons (xspeed-pro) hook to add their own
944 + * request-level cache policy without forking the engine.
945 + *
946 + * Note: this gates the WRITE side. The pre-WP drop-in
947 + * (advanced-cache.php) cannot run PHP filters, so request types that
948 + * must never be *served* from a stale file are handled by not
949 + * writing them here and/or by purging — see the conflict notes in
950 + * advanced-cache.php.
951 + *
952 + * @param bool $should_cache Whether to cache the current request.
953 + */
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;
962 + }
963 +
964 + /**
965 + * Whether the current request is a 404 we may cache.
966 + *
967 + * True only when: it's a genuine main-query is_404(), an add-on opted
968 + * in via `xspeed_should_cache_404` (default false), and the request
969 + * isn't a transient 404 we must never freeze — maintenance mode or a
970 + * 404 emitted while the DB/site is in an error state. The xspeed-pro
971 + * 404 cache flips the filter; Free never caches 404s on its own.
972 + */
973 + public static function should_cache_404(): bool {
974 + if ( ! function_exists( 'is_404' ) || ! is_404() ) {
182 975 return false;
183 976 }
977 + // Never cache a 404 served because the site is down for
978 + // maintenance — that screen disappears the moment maintenance
979 + // ends, and a cached copy would outlive it.
980 + if ( function_exists( 'wp_is_maintenance_mode' ) && wp_is_maintenance_mode() ) {
981 + return false;
982 + }
184 983
984 + /**
985 + * Whether to cache the current 404 response.
986 + *
987 + * Default false. A listener returning true opts the (genuine)
988 + * 404 into the page cache, served back for any unknown URL under
989 + * one generic key. The 404 status is preserved on the HIT.
990 + *
991 + * @param bool $cache_404 Whether to cache this 404.
992 + */
993 + return (bool) apply_filters( 'xspeed_should_cache_404', false );
994 + }
995 +
996 + /**
997 + * Whether the current request is an internal search-results page we
998 + * may cache.
999 + *
1000 + * True only when: it's a genuine main-query is_search() with a
1001 + * non-empty term, and an add-on opted in via `xspeed_should_cache_search`
1002 + * (default false). The search term is folded into the cache key (see
1003 + * search_term() / cache_key()) so different searches stay distinct.
1004 + * The xspeed-pro search cache flips the filter; Free never caches
1005 + * search results on its own.
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 +
185 1071 return true;
186 1072 }
187 1073
1074 + public static function should_cache_search(): bool {
1075 + if ( ! function_exists( 'is_search' ) || ! is_search() ) {
1076 + return false;
1077 + }
1078 + // Empty search (`?s=`) renders the same as a normal archive and
1079 + // carries no term to key on — let it fall through to the usual
1080 + // rules rather than caching an ambiguous entry.
1081 + if ( '' === self::search_term() ) {
1082 + return false;
1083 + }
1084 +
1085 + /**
1086 + * Whether to cache the current search-results request.
1087 + *
1088 + * Default false. A listener returning true opts the search page
1089 + * into the cache, keyed by the normalized search term.
1090 + *
1091 + * @param bool $cache_search Whether to cache this search request.
1092 + */
1093 + return (bool) apply_filters( 'xspeed_should_cache_search', false );
1094 + }
1095 +
188 1096 /**
1097 + * The current request's normalized search term, or '' if none. Reads
1098 + * the raw `s` query param (works on the pre-WP drop-in path too, where
1099 + * get_search_query() isn't available), trims + lowercases so
1100 + * "WordPress" and "wordpress" share one entry, and collapses internal
1101 + * whitespace.
1102 + */
1103 + public static function search_term(): string {
1104 + $raw = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only cache-key derivation from a public search param; no state change.
1105 + $raw = trim( $raw );
1106 + if ( '' === $raw ) {
1107 + return '';
1108 + }
1109 + $raw = preg_replace( '/\s+/', ' ', $raw );
1110 + return function_exists( 'mb_strtolower' ) ? mb_strtolower( $raw ) : strtolower( $raw );
1111 + }
1112 +
1113 + /**
189 1114 * Is this query-string key on the ignored-params allow-list? Supports
190 - * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`,
191 - * 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.
192 1126 */
193 1127 private static function query_key_is_ignored( string $key, array $ignored ): bool {
194 - return Glob_Matcher::any_match( $ignored, $key );
1128 + return Glob_Matcher::any_match_name( $ignored, $key );
195 1129 }
196 1130
197 1131 public static function cache_key() {
198 1132 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';
1133 +
1134 + // Cacheable 404s share ONE generic per-host entry — keying them by
1135 + // URL would let a scanner flood (millions of random paths) bloat
1136 + // the cache with identical 404 bodies. Both the write and the HIT
1137 + // lookup run through here, so they agree on the key automatically.
1138 + if ( self::should_cache_404() ) {
1139 + return md5( $host . '|404' );
1140 + }
1141 +
199 1142 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
200 1143 // Strip the query string from the key so /post and /post?utm_*=…
201 1144 // share the same cache entry. should_cache() above already
202 1145 // rejected requests with non-ignored params, so by the time we
@@ -212,9 +1155,27 @@
212 1155 if ( ! empty( $opts['mobile_separate'] ) ) {
213 1156 $device = self::is_mobile_request() ? '|m' : '|d';
214 1157 }
215 1158
216 - return md5( $host . $uri . $device );
1159 + // Search-results requests fold the normalized term into the key so
1160 + // /?s=foo and /?s=bar get distinct entries (the query string is
1161 + // otherwise stripped above). Only added when search caching opted
1162 + // in, so non-search URLs are unaffected.
1163 + $search = self::should_cache_search() ? '|s=' . self::search_term() : '';
1164 +
1165 + // Query-form feeds (/?feed=rss2 vs /?feed=atom) share the same path
1166 + // once the query is stripped, so fold the feed type into the key to
1167 + // keep the flavors distinct. Pretty-permalink feeds (/feed/rss/) carry
1168 + // the type in $uri already and are unaffected. (FBS-82407 #4)
1169 + $feed = '';
1170 + if ( function_exists( 'is_feed' ) && is_feed() && function_exists( 'get_query_var' ) ) {
1171 + $feed_type = (string) get_query_var( 'feed' );
1172 + if ( '' !== $feed_type ) {
1173 + $feed = '|feed=' . preg_replace( '/[^a-z0-9]/i', '', $feed_type );
1174 + }
1175 + }
1176 +
1177 + return md5( $host . $uri . $device . $search . $feed );
217 1178 }
218 1179
219 1180 /**
220 1181 * Server-side mobile detection. Prefers WordPress's `wp_is_mobile()`
@@ -225,20 +1186,635 @@
225 1186 private static function is_mobile_request(): bool {
226 1187 if ( function_exists( 'wp_is_mobile' ) ) {
227 1188 return (bool) wp_is_mobile();
228 1189 }
1190 + // Fallback for the rare context where wp_is_mobile() isn't loaded.
1191 + // Mirrors core's wp_is_mobile() EXACTLY — including the
1192 + // Sec-CH-UA-Mobile client hint it checks *before* UA tokens — so the
1193 + // bucket this picks matches whatever the engine's primary path (and
1194 + // the drop-in's own copy of this logic) would pick for the same
1195 + // request. Drift here re-introduces the cross-path key mismatch.
1196 + if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) {
1197 + return '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE'];
1198 + }
229 1199 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
230 1200 if ( '' === $ua ) {
231 1201 return false;
232 1202 }
233 - // Mirrors the token list wp_is_mobile() uses internally.
234 1203 return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
235 1204 }
236 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 +
237 1497 public static function cache_file_for( $key ) {
238 - return XSPEED_CACHE_DIR . '/' . $key . '.html';
1498 + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.html';
239 1499 }
240 1500
1501 + /**
1502 + * If a precompressed Brotli sibling (`<file>.br`) exists and the client
1503 + * advertises `Accept-Encoding: br`, emit the Brotli response headers and
1504 + * return the `.br` path to stream. Returns null to fall through to the
1505 + * plain file. Keeps the PHP serve path in parity with the web server's
1506 + * static .br serving (mod_brotli / ngx_brotli rewrite).
1507 + *
1508 + * Free has no Brotli logic of its own — this only fires when an add-on
1509 + * (the Pro Brotli module) actually wrote the .br, so it's a safe no-op
1510 + * on Free-only installs.
1511 + *
1512 + * @param string $file Absolute path to the cached .html file.
1513 + * @return string|null The .br path to stream, or null to serve $file.
1514 + */
1515 + public static function maybe_serve_brotli( string $file ): ?string {
1516 + if ( headers_sent() ) {
1517 + return null;
1518 + }
1519 + $accept = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] )
1520 + ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) )
1521 + : '';
1522 + // Match `br` as a token (comma/space delimited), not a substring, so
1523 + // a hypothetical "xbr" encoding can't false-positive.
1524 + if ( ! preg_match( '/(^|[\s,])br([\s,;]|$)/', $accept ) ) {
1525 + return null;
1526 + }
1527 + $br = $file . '.br';
1528 + if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) {
1529 + return null;
1530 + }
1531 + if ( ! self::brotli_sibling_is_usable( $file, $br ) ) {
1532 + return null; // fall through to the plain .html
1533 + }
1534 + header( 'Content-Encoding: br' );
1535 + header( 'Vary: Accept-Encoding', false );
1536 + // The byte length changes for the compressed body — drop any
1537 + // Content-Length the caller may have set so the stream isn't
1538 + // truncated/padded. readfile() lets the SAPI set the right length.
1539 + header_remove( 'Content-Length' );
1540 + return $br;
1541 + }
1542 +
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 + /**
1753 + * Sidecar metadata file for a cache entry. Holds response bits the HIT
1754 + * path must replay — Content-Type (cached feeds → application/rss+xml,
1755 + * sitemaps → text/xml) and status (a cached 404 must serve 404, not
1756 + * 200). JSON, one tiny file per entry, written only when there's
1757 + * something non-default to replay.
1758 + */
1759 + public static function cache_meta_for( $key ) {
1760 + return XSPEED_CACHE_DIR . '/' . self::current_host_dir() . '/' . $key . '.meta';
1761 + }
1762 +
1763 + /**
1764 + * Read the .meta sidecar for a cache entry as an array, or [] if none.
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.
1769 + */
1770 + public static function read_meta( $key ): array {
1771 + $meta_file = self::cache_meta_for( $key );
1772 + if ( ! file_exists( $meta_file ) ) {
1773 + return array();
1774 + }
1775 + // 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.
1776 + $raw = file_get_contents( $meta_file );
1777 + $data = json_decode( (string) $raw, true );
1778 + return is_array( $data ) ? $data : array();
1779 + }
1780 +
1781 + /**
1782 + * Conditional-GET support for a cache HIT. Emits Last-Modified + ETag
1783 + * derived from the cache file's mtime, and — when the request's
1784 + * If-Modified-Since / If-None-Match still match — sends 304 Not Modified
1785 + * and returns true (caller should exit without a body). Returns false to
1786 + * proceed with a normal 200 body. Lets aggregators/browsers skip
1787 + * re-downloading an unchanged cached response. (FBS-82407 #5)
1788 + *
1789 + * @param string $file Absolute path to the cache .html file.
1790 + * @return bool True when a 304 was sent.
1791 + */
1792 + public static function serve_not_modified( string $file ): bool {
1793 + $mtime = (int) filemtime( $file );
1794 + if ( $mtime <= 0 ) {
1795 + return false;
1796 + }
1797 + $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT';
1798 + $etag = '"' . md5( $file . '|' . $mtime ) . '"';
1799 + header( 'Last-Modified: ' . $last_modified );
1800 + header( 'ETag: ' . $etag );
1801 +
1802 + $ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
1803 + $inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : '';
1804 +
1805 + $etag_match = '' !== $inm && false !== strpos( $inm, $etag );
1806 + $time_match = '' !== $ims && ( strtotime( $ims ) >= $mtime );
1807 +
1808 + if ( $etag_match || $time_match ) {
1809 + if ( function_exists( 'http_response_code' ) ) {
1810 + http_response_code( 304 );
1811 + }
1812 + return true;
1813 + }
1814 + return false;
1815 + }
1816 +
241 1817 public static function is_expired( $file ) {
242 1818 // cache_expiry now owned by CacheModule; per-post override
243 1819 // (Phase 3.4) shrinks the TTL further when the editor set one.
244 1820 $opts = Settings_Manager::get( 'cache' );
@@ -246,9 +1822,57 @@
246 1822 $post_override = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
247 1823 if ( null !== $post_override ) {
248 1824 $max_age = $post_override;
249 1825 }
250 - return ( time() - filemtime( $file ) ) > $max_age;
1826 +
1827 + /**
1828 + * Filter the max-age (seconds) for the current cache entry.
1829 + *
1830 + * Lets an add-on apply a request-type-specific TTL — e.g. the
1831 + * xspeed-pro feed cache gives feeds a longer expiry than pages,
1832 + * since aggregators tolerate more staleness. Return seconds.
1833 + *
1834 + * @param int $max_age Computed max-age in seconds.
1835 + */
1836 + $max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age );
1837 +
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;
251 1875 }
252 1876
253 1877 /**
254 1878 * Accumulator for the full response body across all output-handler phases.
@@ -283,10 +1907,18 @@
283 1907 if ( strlen( $full ) < 255 ) {
284 1908 return $buffer;
285 1909 }
286 1910
287 - if ( function_exists( 'http_response_code' ) && 200 !== http_response_code() ) {
288 - return $buffer;
1911 + // Status gate. We cache 200 by default. A 404 may be cached too,
1912 + // but only when an add-on (xspeed-pro 404 cache) opts in for a
1913 + // genuine is_404() — never a transient 404 (maintenance screen,
1914 + // DB error, or a 404 emitted outside the main query), which would
1915 + // otherwise be frozen until purge. Any other status is skipped.
1916 + $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
1917 + if ( 200 !== $status ) {
1918 + if ( 404 !== $status || ! self::should_cache_404() ) {
1919 + return $buffer;
1920 + }
289 1921 }
290 1922
291 1923 // If no mid-request flush happened, $buffer === $full and we can
292 1924 // safely minify the on-wire bytes too. Otherwise earlier chunks have
@@ -294,8 +1926,28 @@
294 1926 // the first visitor sees unminified HTML, every cache hit after that
295 1927 // is minified.
296 1928 $single_chunk = ( $buffer === $full );
297 1929
1930 + /**
1931 + * Filter: xspeed_cache_final_html
1932 + *
1933 + * Last chance to transform the fully-rendered page HTML before it is
1934 + * minified and written to the cache file. Runs on cache MISS only, so
1935 + * whatever a listener injects here is baked into the cached HTML and
1936 + * replayed on every subsequent HIT (the drop-in short-circuits before
1937 + * PHP on a HIT — a wp_head hook would never fire there).
1938 + *
1939 + * The Preload module uses this to inject the LCP-image <link rel=preload>
1940 + * + preconnect hints and add fetchpriority="high" to the hero <img>.
1941 + * Keep listeners fast and idempotent; this is the on-wire body.
1942 + *
1943 + * @param string $full Complete page HTML.
1944 + */
1945 + $full = (string) apply_filters( 'xspeed_cache_final_html', $full );
1946 + if ( $single_chunk ) {
1947 + $buffer = $full;
1948 + }
1949 +
298 1950 // minify_html now owned by the Minify module; read through the
299 1951 // module's storage so this stays consistent with the engine that
300 1952 // applies CSS/JS minification.
301 1953 $minify_opts = Settings_Manager::get( 'minify' );
@@ -305,27 +1957,117 @@
305 1957 $buffer = $full;
306 1958 }
307 1959 }
308 1960
309 - if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
310 - wp_mkdir_p( XSPEED_CACHE_DIR );
311 - 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;
312 1968 }
313 1969
314 - // Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
315 - // where $key comes from md5() — guaranteed to be exactly 32 lowercase
316 - // hex chars, so no traversal sequence ('..', '/', null byte, etc.)
317 - // can appear. The write is therefore always inside XSPEED_CACHE_DIR.
318 - $file = self::cache_file_for( 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 + }
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 +
319 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.
320 2028 file_put_contents( $file, $full, LOCK_EX );
321 2029
2030 + /**
2031 + * Fires after the flat hash cache file ({md5}.html) is written.
2032 + *
2033 + * Mirror of `xspeed_static_file_written` for the flat cache. The PHP
2034 + * serve path (Cache::maybe_serve_brotli / the drop-in) serves THIS
2035 + * file and looks for a `{md5}.html.br` sibling — which only the Pro
2036 + * Brotli listener on this hook writes. Without it the .br sibling was
2037 + * never created and the PHP path could never serve Brotli (FBS-83039,
2038 + * Blocker 2): the static-tree .br (written on xspeed_static_file_written)
2039 + * lives in a different cache layout the PHP path never reads.
2040 + *
2041 + * @param string $file Absolute path to the flat cache file just written.
2042 + * @param string $full The HTML written to it.
2043 + */
2044 + do_action( 'xspeed_flat_file_written', $file, $full );
2045 +
2046 + // Persist a non-default Content-Type so the HIT path can replay it
2047 + // (cached feeds must serve application/rss+xml, not text/html).
2048 + // Only written when the response set a content-type other than
2049 + // the HTML default — pages don't pay for an extra file.
2050 + self::write_meta( $key, $full );
2051 +
322 2052 // Static-cache tree (xspeed-static/{host}{path}/index.html). The
323 2053 // .htaccess rewrite block serves this file directly via the web
324 2054 // server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
325 2055 // store_static() returns silently on any path/permission issue —
326 2056 // the drop-in remains the safety net.
327 - self::store_static( $full );
2057 + //
2058 + // Skip it entirely when mobile_separate is on: the rewrite is
2059 + // disabled in that mode (static_rewrite_allowed()), so a static file
2060 + // would only be dead weight — and a device-blind one at that.
2061 + // Skip the static-tree write for responses the web server can't replay
2062 + // correctly: a non-200 status (a cached 404 would be served as a soft
2063 + // 200, FBS-82406) or a non-HTML content-type (a cached feed would go
2064 + // out as text/html, FBS-82407). The web server serves these .html files
2065 + // directly with no PHP, so there's no .meta replay — keep them on the
2066 + // drop-in / PHP path instead, which DOES replay status + content-type.
2067 + if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
2068 + self::store_static( $full );
2069 + }
328 2070
329 2071 return $buffer;
330 2072 }
331 2073
@@ -339,13 +2081,149 @@
339 2081 * $uri has its query string stripped, null bytes removed, '..'
340 2082 * sequences collapsed, and after concatenation we verify the
341 2083 * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
342 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.
343 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 +
344 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 +
345 2223 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
346 2224 $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
347 - $host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
2225 + $host = self::static_host_dir( $host );
348 2226 $uri = str_replace( "\0", '', $uri );
349 2227 $uri = (string) strtok( $uri, '?' );
350 2228 if ( '' === $host || '' === $uri ) {
351 2229 return;
@@ -374,36 +2252,1615 @@
374 2252 if ( ! is_dir( $dir ) ) {
375 2253 return;
376 2254 }
377 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.
378 - file_put_contents( $file, $html, LOCK_EX );
2256 + $written = file_put_contents( $file, $html, LOCK_EX );
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 +
2268 + if ( false !== $written ) {
2269 + /**
2270 + * Fires after a static cache file (index.html) is written.
2271 + *
2272 + * The extension point for serving pre-compressed siblings:
2273 + * the xspeed-pro Brotli module writes `index.html.br` next to
2274 + * the file here so the web server's static rewrite can serve a
2275 + * Brotli copy to clients that advertise `Accept-Encoding: br`,
2276 + * falling back to GZIP / the plain file otherwise. No core
2277 + * behavior depends on a listener being present.
2278 + *
2279 + * @param string $file Absolute path to the static cache file just written.
2280 + * @param string $html The HTML written to it.
2281 + */
2282 + do_action( 'xspeed_static_file_written', $file, $html );
2283 + }
379 2284 }
380 2285
381 2286 /**
2287 + * Write the .meta sidecar for a cache entry when the response carries
2288 + * anything the HIT path must replay beyond a plain 200 text/html:
2289 + * - a non-HTML Content-Type (cached feeds → application/rss+xml,
2290 + * sitemaps → text/xml, …), and/or
2291 + * - a non-200 status (a cached 404 must serve 404, not 200).
2292 + *
2293 + * Ordinary 200 text/html pages get NO .meta file, so the common path
2294 + * stays a single write.
2295 + *
2296 + * @param string $key Cache key for the current request.
2297 + */
2298 + /**
2299 + * True only for a plain 200 text/html response — the only kind the
2300 + * web-server static tree can serve correctly (it streams the .html with
2301 + * no PHP, so it can't replay a 404 status or a feed Content-Type). Used
2302 + * to gate store_static() so cached 404s / feeds stay on the replay-capable
2303 + * drop-in / PHP path. (FBS-82406, FBS-82407)
2304 + */
2305 + private static function response_is_plain_html(): bool {
2306 + $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
2307 + if ( 200 !== $status && $status > 0 ) {
2308 + return false;
2309 + }
2310 + foreach ( headers_list() as $header ) {
2311 + if ( 0 === stripos( $header, 'content-type:' ) ) {
2312 + $ct = trim( substr( $header, strlen( 'content-type:' ) ) );
2313 + if ( '' !== $ct && false === stripos( $ct, 'text/html' ) ) {
2314 + return false;
2315 + }
2316 + }
2317 + }
2318 + return true;
2319 + }
2320 +
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 {
2549 + $content_type = '';
2550 + foreach ( headers_list() as $header ) {
2551 + if ( 0 === stripos( $header, 'content-type:' ) ) {
2552 + $content_type = trim( substr( $header, strlen( 'content-type:' ) ) );
2553 + }
2554 + }
2555 + $status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
2556 +
2557 + $meta = array();
2558 + $is_default_type = ( '' === $content_type || false !== stripos( $content_type, 'text/html' ) );
2559 + if ( ! $is_default_type ) {
2560 + $meta['content_type'] = $content_type;
2561 + }
2562 + if ( 200 !== $status && $status > 0 ) {
2563 + $meta['status'] = $status;
2564 + }
2565 +
2566 + // Per-content TTL (seconds). The drop-in and static fast paths can't
2567 + // call is_expired() / the xspeed_cache_max_age filter (they run before
2568 + // WP), so persist the resolved max-age here whenever it differs from
2569 + // the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page
2570 + // default. The fast paths read this to expire correctly. (FBS-82407)
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 +
2596 + if ( $ttl > 0 && $ttl !== $default_ttl ) {
2597 + $meta['ttl'] = $ttl;
2598 + }
2599 +
2600 + // Nothing to replay → no sidecar.
2601 + if ( empty( $meta ) ) {
2602 + return;
2603 + }
2604 +
2605 + $payload = wp_json_encode( $meta );
2606 + if ( false === $payload ) {
2607 + return;
2608 + }
2609 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend shutdown write.
2610 + file_put_contents( self::cache_meta_for( $key ), $payload, LOCK_EX );
2611 + }
2612 +
2613 + /**
382 2614 * @param string $cause Free-form human reason. Recorded in the
383 2615 * Activity log to give users context (e.g.
384 2616 * 'post saved', 'settings change', 'manual',
385 2617 * 'theme switch').
386 2618 */
387 - public static function purge_all( string $cause = 'manual' ) {
2619 + /**
2620 + * Purge the cache entries for ONE URL — every variant of it: the
2621 + * flat-hash entry (+ .meta / .html.br siblings), both device buckets
2622 + * (mobile_separate keys them separately), both trailing-slash forms,
2623 + * and the static-tree index.html (+ .br) the server rewrite serves.
2624 + * The rest of the cache is untouched — this is the surgical
2625 + * alternative to purge_all for "I just edited this one page".
2626 + *
2627 + * @param string $url Absolute URL, or site-relative path ("/about/").
2628 + * @param string $cause Who asked, for the purge log. See purge_all().
2629 + * @return int Number of cache files removed.
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 +
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 + }
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.
3312 + if ( ! is_array( $parts ) ) {
3313 + return 0;
3314 + }
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 );
3365 + if ( '' === $host && function_exists( 'home_url' ) ) {
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.
3367 + if ( is_array( $home ) ) {
3368 + $host = self::host_port_of( $home );
3369 + }
3370 + }
3371 + if ( '' === $host ) {
3372 + return 0;
3373 + }
3374 + $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
3375 + $path = '/' . ltrim( $path, '/' );
3376 + if ( false !== strpos( $path, '..' ) ) {
3377 + return 0;
3378 + }
3379 +
3380 + // The cache key preserves REQUEST_URI's trailing-slash form, so
3381 + // purge both. Root stays a single '/'.
3382 + $forms = array( $path );
3383 + if ( '/' !== $path ) {
3384 + $forms[] = rtrim( $path, '/' );
3385 + $forms[] = rtrim( $path, '/' ) . '/';
3386 + }
3387 + $forms = array_unique( $forms );
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 +
388 3404 $count = 0;
3405 + foreach ( $forms as $uri ) {
3406 + // '' = mobile_separate off; '|m' / '|d' = the device buckets.
3407 + foreach ( array( '', '|m', '|d' ) as $device ) {
3408 + $key = md5( $host . $uri . $device );
3409 + $file = $base . '/' . $key . '.html';
3410 + if ( is_file( $file ) ) {
3411 + wp_delete_file( $file );
3412 + ++$count;
3413 + }
3414 + foreach ( array( $base . '/' . $key . '.meta', $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
3415 + if ( is_file( $sidecar ) ) {
3416 + wp_delete_file( $sidecar );
3417 + }
3418 + }
3419 + }
3420 + }
3421 +
3422 + // Static tree (served directly by the nginx/.htaccess rewrite).
3423 + if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
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, '/' ) );
3427 + $file = $dir . '/index.html';
3428 + if ( is_file( $file ) ) {
3429 + wp_delete_file( $file );
3430 + ++$count;
3431 + }
3432 + foreach ( array( $file . '.br', self::brotli_size_sidecar( $file . '.br' ) ) as $sidecar ) {
3433 + if ( is_file( $sidecar ) ) {
3434 + wp_delete_file( $sidecar );
3435 + }
3436 + }
3437 + }
3438 +
3439 + if ( $count > 0 ) {
3440 + Cache_Inventory::invalidate();
3441 + Activity_Log::record(
3442 + 'cache_purge_url',
3443 + sprintf(
3444 + /* translators: 1: cause of the purge, 2: URL or path, 3: number of files removed. */
3445 + __( 'Purged one URL (%1$s) — %2$s, %3$d file(s) removed', 'xspeed' ),
3446 + $cause,
3447 + $host . $path,
3448 + $count
3449 + ),
3450 + Activity_Log::INFO
3451 + );
3452 + }
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 +
3511 + return $count;
3512 + }
3513 +
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 +
3605 + $count = 0;
389 3606 if ( is_dir( XSPEED_CACHE_DIR ) ) {
390 - $files = glob( XSPEED_CACHE_DIR . '/*.html' );
391 - if ( $files ) {
392 - $count = count( $files );
393 - foreach ( $files as $f ) {
394 - 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 + }
395 3646 }
3647 + if ( ! is_dir( $root ) ) {
3648 + continue;
3649 + }
3650 + $files = glob( $root . '/*.html' );
3651 + if ( $files ) {
3652 + $count += count( $files );
3653 + foreach ( $files as $f ) {
3654 + self::sweep_delete( $f );
3655 + }
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 + }
396 3685 }
397 3686 }
398 3687 // Static-cache tree purge — recursive because the layout is
399 3688 // xspeed-static/{host}/{path}/index.html, so a flat glob can't
400 - // reach everything.
3689 + // reach everything. Already host-segmented, so scoping is just a
3690 + // matter of starting one level down.
401 3691 if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
402 - $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 + }
403 3698 }
3699 + // REST response cache (cache/xspeed/rest/*.json) — same purge
3700 + // triggers (publish, settings change) invalidate it too.
3701 + $rest = Rest_Cache::purge();
3702 + $count += $rest;
3703 +
3704 + // Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
3705 + // purge_all is a full filesystem sweep and must clear these too, even
3706 + // when the Minify module is currently disabled — orphaned min/ files
3707 + // from a feature the user later turned off must still be removed, and
3708 + // a stale combined-<hash>.css that the regenerated page no longer
3709 + // references otherwise 404s and breaks the frontend. (FBS-83114/83116)
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;
3742 + }
3743 + if ( function_exists( 'wp_cache_flush' ) ) {
3744 + return (bool) wp_cache_flush();
3745 + }
3746 + return false;
3747 + }
3748 +
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';
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 + }
3789 +
3790 + $removed = self::purge_local( $host );
3791 + $count = $removed['pages'] + $removed['rest'];
3792 +
3793 + self::flush_object_cache( $network_wide );
3794 +
404 3795 self::update_stats( array( 'last_purge' => time() ) );
405 3796
3797 + // Fire AFTER the local sweep so module listeners (Critical CSS,
3798 + // Unused CSS, Cloudflare edge purge) run — this action had three
3799 + // registered listeners but was never emitted. Treat it as additive
3800 + // (CDN / edge invalidation), not the mechanism for clearing local
3801 + // files. (FBS-83114)
3802 + // Wrapped: this action predates the purge-event contract and has its
3803 + // own third-party listeners. One of them throwing used to abort
3804 + // purge_all() here, which now also means the contract event below
3805 + // never fires and a server cache keeps serving stale HTML. The local
3806 + // sweep is already done by this point, so swallowing is strictly safer
3807 + // than letting a listener decide the rest of the method runs.
3808 + try {
3809 + // Isolated per listener: one throwing used to cancel every
3810 + // listener queued behind it — Critical CSS, Unused CSS and the
3811 + // Cloudflare edge purge all hang off this hook. (QA #348)
3812 + self::do_action_isolated( 'xspeed_after_purge_all', $cause );
3813 + } catch ( \Throwable $e ) {
3814 + self::log_purge_listener_error( 'xspeed_after_purge_all', $e );
3815 + }
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 +
3858 + // The list behind the "Cached pages" card is memoized for a minute;
3859 + // a purge has to drop it or the drill-down shows pages that no
3860 + // longer exist.
3861 + Cache_Inventory::invalidate();
3862 +
406 3863 // Trigger of WP_CLI / hook / admin-bar purges all hit the same
407 3864 // path. Record once with the supplied cause so the dashboard
408 3865 // activity feed reads naturally.
409 3866 Activity_Log::record(
@@ -410,16 +3867,853 @@
410 3867 'cache_purged',
411 3868 sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ),
412 3869 Activity_Log::INFO
413 3870 );
3871 +
3872 + return $count;
414 3873 }
415 3874
416 3875 /**
417 - * Recursively delete every `index.html` and empty directory inside
418 - * the static-cache tree. Used by purge_all(). Returns the number of
419 - * .html files removed so purge stats stay accurate across the flat
420 - * + static caches.
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
421 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 + /**
4376 + * The per-type purge menu, LiteSpeed-style. Each entry is a cache type
4377 + * the user can purge individually from the admin-bar dropdown. `visible`
4378 + * controls whether the item shows (active + licensed module only) — it
4379 + * NEVER limits Purge All, which always sweeps everything on disk.
4380 + *
4381 + * Pro registers its own types (Critical CSS, Unused CSS, …) by filtering
4382 + * `xspeed_purge_types`, so Free degrades gracefully when Pro is absent.
4383 + *
4384 + * @return array<string,array{label:string,visible:bool}>
4385 + */
4386 + public static function purge_types(): array {
4387 + $minify_on = false;
4388 + if ( class_exists( '\\XSpeed\\Settings_Manager' ) ) {
4389 + $min = Settings_Manager::get( 'minify' );
4390 + $minify_on = ! empty( $min['minify_css'] ) || ! empty( $min['minify_js'] ) || ! empty( $min['combine_css'] ) || ! empty( $min['combine_js'] );
4391 + }
4392 + // Object cache is "active" when an external object-cache drop-in is in
4393 + // use — the canonical WP signal, independent of our settings option.
4394 + $oc_on = function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache();
4395 +
4396 + $types = array(
4397 + 'all' => array(
4398 + 'label' => __( 'Purge All', 'xspeed' ),
4399 + 'visible' => true,
4400 + ),
4401 + 'page' => array(
4402 + 'label' => __( 'Purge Page / Static Cache', 'xspeed' ),
4403 + 'visible' => true,
4404 + ),
4405 + 'assets' => array(
4406 + 'label' => __( 'Purge CSS / JS Cache', 'xspeed' ),
4407 + 'visible' => $minify_on,
4408 + ),
4409 + 'object' => array(
4410 + 'label' => __( 'Purge Object Cache', 'xspeed' ),
4411 + 'visible' => $oc_on,
4412 + ),
4413 + 'rest' => array(
4414 + 'label' => __( 'Purge REST Cache', 'xspeed' ),
4415 + 'visible' => true,
4416 + ),
4417 + );
4418 +
4419 + /**
4420 + * Filter the admin-bar purge-type menu. Pro modules add their own
4421 + * (Critical CSS, Unused CSS, CDN). Adding a type here only adds a
4422 + * MENU item — purge_type() must know how to handle the same slug.
4423 + *
4424 + * @param array $types Map of slug => [label, visible].
4425 + */
4426 + return (array) apply_filters( 'xspeed_purge_types', $types );
4427 + }
4428 +
4429 + /**
4430 + * Purge a single cache type by slug. 'all' delegates to purge_all();
4431 + * every other slug clears just its own artifacts. Unknown slugs (e.g. a
4432 + * Pro type) fan out via the `xspeed_purge_type_{slug}` action so the
4433 + * owning module can handle it. Returns the number of items removed where
4434 + * countable.
4435 + *
4436 + * @param string $type Cache type slug.
4437 + * @param string $cause Who asked. Threaded through so the purge log can
4438 + * tell an AI assistant's purge apart from a click —
4439 + * "the cache cleared four times today" is only
4440 + * actionable once you know what kept clearing it.
4441 + */
4442 + public static function purge_type( string $type, string $cause = 'manual' ): int {
4443 + switch ( $type ) {
4444 + case 'all':
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;
4453 +
4454 + case 'page':
4455 + $count = self::purge_pages();
4456 + self::update_stats( array( 'last_purge' => time() ) );
4457 + Cache_Inventory::invalidate();
4458 + self::record_partial_purge( 'page', $cause, $count );
4459 + self::announce_purge( $cause, $count );
4460 + return $count;
4461 +
4462 + case 'assets':
4463 + if ( class_exists( '\\XSpeed\\Minifier' ) ) {
4464 + Minifier::purge_minified();
4465 + }
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;
4490 +
4491 + case 'object':
4492 + if ( function_exists( 'wp_cache_flush' ) ) {
4493 + wp_cache_flush();
4494 + }
4495 + self::record_partial_purge( 'object cache', $cause, null );
4496 + return 0;
4497 +
4498 + case 'rest':
4499 + $count = Rest_Cache::purge();
4500 + self::record_partial_purge( 'REST responses', $cause, $count );
4501 + self::announce_purge( $cause, $count );
4502 + return $count;
4503 +
4504 + default:
4505 + return self::purge_type_unhandled( $type, $cause );
4506 + }
4507 + }
4508 +
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 + /**
4649 + * Log a partial purge so the drill-down behind "Last purge" shows every
4650 + * clear, not only the full ones. Without this a site whose object cache
4651 + * is flushed on a schedule looks, from the log, like nothing happens.
4652 + *
4653 + * @param string $what Human label for the slice purged.
4654 + * @param string $cause Who asked.
4655 + * @param int|null $count Items removed, when countable.
4656 + */
4657 + private static function record_partial_purge( string $what, string $cause, ?int $count ): void {
4658 + $message = null === $count
4659 + ? sprintf(
4660 + /* translators: 1: what was purged, 2: cause of the purge. */
4661 + __( 'Purged %1$s (%2$s)', 'xspeed' ),
4662 + $what,
4663 + $cause
4664 + )
4665 + : sprintf(
4666 + /* translators: 1: what was purged, 2: cause of the purge, 3: number of files removed. */
4667 + __( 'Purged %1$s (%2$s) — %3$d file(s) removed', 'xspeed' ),
4668 + $what,
4669 + $cause,
4670 + $count
4671 + );
4672 +
4673 + Activity_Log::record( 'cache_purged', $message, Activity_Log::INFO );
4674 + }
4675 +
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 + /**
4693 + * Recursively delete every `index.html` (and its precompressed
4694 + * `index.html.br` sibling, if the Pro Brotli module wrote one) plus
4695 + * empty directories inside the static-cache tree. Used by purge_all().
4696 + * Returns the number of .html files removed so purge stats stay accurate
4697 + * across the flat + static caches — .br siblings are not counted
4698 + * (they're encodings of a page, not pages).
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 +
422 4716 private static function rmtree_html( string $dir ): int {
423 4717 if ( ! is_dir( $dir ) ) {
424 4718 return 0;
425 4719 }
@@ -443,10 +4737,16 @@
443 4737 @rmdir( $path );
444 4738 continue;
445 4739 }
446 4740 if ( substr( $entry, -5 ) === '.html' ) {
447 - wp_delete_file( $path );
4741 + self::sweep_delete( $path );
448 4742 ++$removed;
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 );
449 4749 }
450 4750 }
451 4751 return $removed;
452 4752 }
@@ -463,25 +4763,42 @@
463 4763 }
464 4764 }
465 4765
466 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 + /**
467 4776 * Persist stats with autoload disabled — stats are only read in admin
468 4777 * contexts, so there is no reason to inflate every frontend request's
469 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.
470 4783 */
471 - private static function update_stats( array $stats ) {
472 - if ( false === get_option( 'xspeed_stats' ) ) {
4784 + public static function update_stats( array $stats ) {
4785 + if ( false === get_option( 'xspeed_stats', false ) ) {
473 4786 add_option( 'xspeed_stats', $stats, '', 'no' );
474 4787 return;
475 4788 }
476 - update_option( 'xspeed_stats', $stats );
4789 + update_option( 'xspeed_stats', array_merge( self::get_stats_option(), $stats ) );
477 4790 }
478 4791
479 4792 public static function get_stats() {
480 4793 $count = 0;
481 4794 $size = 0;
482 - if ( is_dir( XSPEED_CACHE_DIR ) ) {
483 - $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' );
484 4801 if ( $files ) {
485 4802 $count = count( $files );
486 4803 foreach ( $files as $f ) {
487 4804 $size += filesize( $f );
@@ -487,10 +4804,29 @@
487 4804 $size += filesize( $f );
488 4805 }
489 4806 }
490 4807 }
4808 + // Drain the HIT-log file BEFORE reading totals. Two serve paths that
4809 + // bypass the normal in-PHP record_hit() append one line per HIT here:
4810 + // the nginx server-level rewrite (see nginx_snippet(), never reaches
4811 + // PHP) and the advanced-cache.php drop-in (runs pre-WordPress, can't
4812 + // reach Hit_Counter). Without this drain both look like a 0% hit-ratio
4813 + // on a perfectly working cache.
4814 + Hit_Counter::collect_nginx_log_hits();
4815 +
4816 + // Apache/LiteSpeed static-rewrite HITs are served straight from disk
4817 + // by .htaccess and never reach PHP either — but there's no .htaccess
4818 + // equivalent of nginx's access_log directive, so we count them by
4819 + // scanning the web server's own access log incrementally. No-op when
4820 + // the log isn't readable (managed hosts) — see the method docblock.
4821 + Hit_Counter::collect_server_log_hits();
4822 +
491 4823 $stats = get_option( 'xspeed_stats', array() );
492 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();
493 4829 return array(
494 4830 'cached_pages' => $count,
495 4831 'cache_size' => $size,
496 4832 'last_purge' => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
@@ -499,21 +4835,120 @@
499 4835 // CacheHero stat grid + the Health module's panel.
500 4836 'hits_24h' => $totals['hits'],
501 4837 'misses_24h' => $totals['misses'],
502 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,
503 4866 );
504 4867 }
505 4868
506 4869 /**
507 - * Apply the user's enable/disable choice. Called only from the REST
508 - * toggle endpoint, which is gated by current_user_can( 'manage_options' )
509 - * and a verified REST nonce. This is the only place the drop-in and
510 - * the WP_CACHE constant are written — they MUST NOT happen on
511 - * register_activation_hook (WordPress.org review requirement).
4870 + * Why the cache is not serving, when nothing REFUSES to enable it.
512 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 + *
513 4946 * @param bool $enable User's choice.
514 4947 * @return array{
515 4948 * enabled: bool,
4949 + * blocked: bool,
4950 + * blocked_reason: ?string,
516 4951 * dropin_installed: bool,
517 4952 * wp_cache_constant: bool,
518 4953 * wp_config_writable: bool,
519 4954 * manual_snippet: ?string
@@ -518,27 +4953,228 @@
518 4953 * wp_config_writable: bool,
519 4954 * manual_snippet: ?string
520 4955 * }
521 4956 */
522 - 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 ) {
523 4987 $enable = (bool) $enable;
524 4988
525 4989 if ( $enable ) {
526 - $dropin_ok = self::install_dropin();
527 - $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;
528 5143 $rewrite_ok = self::install_rewrite();
5144 + self::ensure_hits_log_file();
5145 + self::sync_mobile_flag();
529 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 + }
530 5154
531 - Activity_Log::record(
532 - 'cache_enabled_event',
533 - $wp_config_ok
534 - ? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
535 - : 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
536 - $wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
537 - );
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 + }
538 5172
539 5173 return array(
540 5174 'enabled' => true,
5175 + 'blocked' => false,
5176 + 'blocked_reason' => null,
541 5177 'dropin_installed' => (bool) $dropin_ok,
542 5178 'wp_cache_constant' => (bool) $wp_config_ok,
543 5179 'rewrite_installed' => (bool) $rewrite_ok,
544 5180 'wp_config_writable' => self::wp_config_writable(),
@@ -543,33 +5179,310 @@
543 5179 'rewrite_installed' => (bool) $rewrite_ok,
544 5180 'wp_config_writable' => self::wp_config_writable(),
545 5181 'manual_snippet' => $snippet,
546 5182 'nginx_snippet' => self::nginx_snippet(),
5183 + // Unified server-block snippet aggregating every enabled
5184 + // module's directives — the same value the dashboard and
5185 + // Health insight render. The wizard shows this so all three
5186 + // surfaces stay in lockstep. Null on non-nginx hosts.
5187 + 'nginx_server_block' => self::full_nginx_server_block(),
547 5188 );
548 5189 }
549 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 + }
550 5247 self::remove_dropin();
551 - 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 + }
552 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 + }
5299 + // Drop the device-bucket marker too — with the drop-in gone there's
5300 + // nothing left to read it, and leaving it behind would dirty a fresh
5301 + // re-enable (and leaks across test runs).
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 + }
553 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 + }
554 5339 Activity_Log::record(
555 5340 'cache_disabled_event',
556 - 'Cache disabled. Drop-in removed.',
557 - Activity_Log::INFO
5341 + $log_message,
5342 + $constant_left ? Activity_Log::WARN : Activity_Log::INFO
558 5343 );
559 5344
560 5345 return array(
561 5346 'enabled' => false,
5347 + 'blocked' => false,
5348 + 'blocked_reason' => null,
562 5349 'dropin_installed' => false,
563 - 'wp_cache_constant' => false,
5350 + 'wp_cache_constant' => $constant_left,
564 5351 'rewrite_installed' => false,
565 5352 'wp_config_writable' => self::wp_config_writable(),
566 5353 'manual_snippet' => null,
567 5354 'nginx_snippet' => self::nginx_snippet(),
5355 + 'nginx_server_block' => self::full_nginx_server_block(),
568 5356 );
569 5357 }
570 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 +
571 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 + /**
572 5485 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
573 5486 * direct is_writable() under WordPress.WP.AlternativeFunctions.
574 5487 */
575 5488 private static function wp_config_writable() {
@@ -591,8 +5504,883 @@
591 5504 * Apache / LiteSpeed via .htaccess.
592 5505 *
593 5506 * Returns null when the server isn't nginx (no point showing it).
594 5507 */
5508 + /**
5509 + * Create wp-content/cache/xspeed/hits.log as an empty file so the
5510 + * server-level rewrite's `access_log` directive has somewhere to
5511 + * write on first request. Idempotent — touches an existing file
5512 + * without disturbing accumulated lines. Called from Cache::toggle()
5513 + * on enable and from auto_heal() when the file is missing.
5514 + *
5515 + * Permissions matter here. The file is created by PHP-FPM (often uid
5516 + * www-data), but the nginx process that appends HIT lines may run as a
5517 + * DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in
5518 + * its own container as uid `nginx`, PHP-FPM in another as `www-data`)
5519 + * they don't share a user at all. A default-umask 0644 file is then
5520 + * unwritable by nginx, the access_log write silently fails, and the
5521 + * dashboard shows a 0% hit ratio even though static HITs are serving.
5522 + * So we widen the dir to 0777 and the file to 0666 — group/other write —
5523 + * so whatever uid nginx runs as can append. The file holds HIT request
5524 + * lines and must be protected like an access log: paths and queries can
5525 + * contain sensitive values.
5526 + */
5527 + /**
5528 + * Directory holding the nginx hit log. Lives under uploads/, NOT the
5529 + * cache dir — uninstall.php and a cache purge both delete the cache
5530 + * dir, which would orphan the pasted nginx `access_log` directive's
5531 + * parent directory and make `nginx -t` fail [emerg], taking down every
5532 + * vhost on the host (FBS-82478). uploads/ always exists, isn't a
5533 + * plugin-managed cache dir, and is never deleted on uninstall — so the
5534 + * directive's target dir survives both, and nginx (which creates a
5535 + * missing log FILE but not a missing DIR) can always open it.
5536 + *
5537 + * Falls back to the cache dir only if uploads is somehow unavailable.
5538 + */
5539 + public static function hits_log_dir(): string {
5540 + if ( function_exists( 'wp_upload_dir' ) ) {
5541 + $uploads = wp_upload_dir( null, false );
5542 + if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
5543 + return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed';
5544 + }
5545 + }
5546 + return XSPEED_CACHE_DIR;
5547 + }
5548 +
5549 + /** Absolute path to the nginx hit log file. */
5550 + public static function hits_log_path(): string {
5551 + return self::hits_log_dir() . '/hits.log';
5552 + }
5553 +
5554 + /**
5555 + * Sync the drop-in's mobile-bucket flag file with the `mobile_separate`
5556 + * setting. The drop-in (advanced-cache.php) runs before WordPress loads,
5557 + * so it can't read the option — instead it checks for a zero-byte
5558 + * `.mobile-separate` marker next to the cache files. When the setting is
5559 + * on we touch the marker; when off we remove it. The drop-in's cache_key
5560 + * computation keys off the marker's presence so its '|m'/'|d' device
5561 + * bucket stays in lockstep with Cache::cache_key().
5562 + *
5563 + * Without this, turning on mobile_separate made Cache::store() write keys
5564 + * with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's
5565 + * file_exists() always missed, every HIT fell through to a full WP boot,
5566 + * and the fast pre-WP path was silently dead.
5567 + *
5568 + * @param bool|null $enabled Force a state; null reads the current setting.
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 +
5753 + public static function sync_mobile_flag( $enabled = null ): void {
5754 + if ( null === $enabled ) {
5755 + $stored = self::stored_cache_opts();
5756 + $enabled = ! empty( $stored['mobile_separate'] );
5757 + }
5758 + $dir = XSPEED_CACHE_DIR;
5759 + $flag = $dir . '/.mobile-separate';
5760 + if ( $enabled ) {
5761 + if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
5762 + return;
5763 + }
5764 + if ( ! file_exists( $flag ) ) {
5765 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged -- read by the pre-WP drop-in via file_exists(); must be a plain marker, not WP_Filesystem.
5766 + @touch( $flag );
5767 + }
5768 + return;
5769 + }
5770 + if ( file_exists( $flag ) ) {
5771 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
5772 + @unlink( $flag );
5773 + }
5774 + }
5775 +
5776 + /**
5777 + * Write / remove the `.maintenance-active` sentinel next to the cache
5778 + * files. The pre-WP drop-in checks for this marker and bails when present,
5779 + * so a page cached while the site was live is NOT served during
5780 + * maintenance / coming-soon mode — WordPress loads and renders the
5781 + * maintenance screen instead. The Pro Maintenance-Cache module drives this
5782 + * on the maintenance on/off transition. (FBS-82409 B1)
5783 + *
5784 + * @param bool $active True to arm the sentinel (entering maintenance),
5785 + * false to clear it (site recovered).
5786 + */
5787 + public static function sync_maintenance_flag( bool $active ): void {
5788 + $dir = XSPEED_CACHE_DIR;
5789 + $flag = $dir . '/.maintenance-active';
5790 + if ( $active ) {
5791 + if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
5792 + return;
5793 + }
5794 + if ( ! file_exists( $flag ) ) {
5795 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged -- read by the pre-WP drop-in via file_exists(); must be a plain marker, not WP_Filesystem.
5796 + @touch( $flag );
5797 + }
5798 + return;
5799 + }
5800 + if ( file_exists( $flag ) ) {
5801 + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
5802 + @unlink( $flag );
5803 + }
5804 + }
5805 +
5806 + /**
5807 + * Reconcile every mobile_separate-dependent artifact to the current
5808 + * setting. Called on boot and whenever the cache settings are saved, so
5809 + * flipping mobile_separate at runtime can't leave the install in a
5810 + * half-converted state.
5811 + *
5812 + * Three things must agree with the setting:
5813 + * 1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()),
5814 + * 2. the device-blind server rewrite — present only when OFF
5815 + * (static_rewrite_allowed()),
5816 + * 3. the now-stale static-cache tree + page cache, which were keyed
5817 + * under the old scheme and would serve wrong-device HTML.
5818 + *
5819 + * No-ops when the cache is disabled — there's nothing installed to
5820 + * reconcile, and toggle() handles install/teardown itself.
5821 + */
5822 + public static function reconcile_mobile_separate(): void {
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 + }
5832 +
5833 + // The rewrite/static reconciliation below needs the plugin's path
5834 + // constants. They're absent in early-boot / unit-test contexts where
5835 + // only the drop-in flag matters — bail to the flag-only behavior then.
5836 + if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
5837 + return;
5838 + }
5839 +
5840 + // Only touch the rewrite + caches when caching is actually on.
5841 + $opts = get_option( 'xspeed_options', array() );
5842 + if ( empty( $opts['cache_enabled'] ) ) {
5843 + return;
5844 + }
5845 +
5846 + $rewrite_present = self::rewrite_installed();
5847 + $rewrite_wanted = self::static_rewrite_allowed();
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 +
5872 + if ( $rewrite_present === $rewrite_wanted ) {
5873 + // Already consistent — nothing flipped, leave caches intact so a
5874 + // plain settings save (e.g. expiry change) doesn't blow the cache.
5875 + return;
5876 + }
5877 +
5878 + // Bring the rewrite into line with what this server actually supports.
5879 + if ( $rewrite_wanted ) {
5880 + self::install_rewrite();
5881 + } else {
5882 + self::remove_rewrite();
5883 + }
5884 +
5885 + // Only discard cache contents when the device bucketing changed.
5886 + if ( $mobile_flipped ) {
5887 + self::purge_all( 'mobile_separate changed' );
5888 + }
5889 + }
5890 +
5891 + /**
5892 + * Whether the server-level static-rewrite fast path may be used.
5893 + *
5894 + * The rewrite serves `{host}{path}/index.html` straight from the web
5895 + * server, keyed only by host + path — it has no way to run our PHP
5896 + * device detection, so it can't tell mobile from desktop. When
5897 + * `mobile_separate` is on, a single static file would be shared across
5898 + * devices and whoever primed it wins (mobile visitors could get desktop
5899 + * HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent
5900 + * UA matcher into .htaccess AND the nginx snippet (three copies that
5901 + * would inevitably drift), we simply DON'T engage the static rewrite when
5902 + * mobile_separate is on. Requests then fall through to the PHP drop-in,
5903 + * which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only
5904 + * on mobile-separate sites, in exchange for guaranteed correctness.
5905 + *
5906 + * LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in
5907 + * particular — `.htaccess` CAN run our RewriteRule to serve the static
5908 + * file, but its `.htaccess` engine ignores `mod_headers`, so we cannot
5909 + * stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no
5910 + * `.htaccess` equivalent of nginx's per-location `access_log` to record
5911 + * the hit. The result was a cache that worked but was invisible: no HIT
5912 + * header and a hit-ratio frozen near 0%. Every OTHER server gives the
5913 + * user a visible HIT header + a counted hit (nginx via add_header +
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
5918 + * through the PHP drop-in instead — the drop-in emits
5919 + * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the
5920 + * observable behavior the other servers get. The cost is the drop-in's
5921 + * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in
5922 + * exchange the dashboard hit-ratio and the response header finally tell
5923 + * the truth there. (Apache keeps the static fast path — it honors the
5924 + * header.) See maybe_emit_lscache_headers() for the paired LSCache
5925 + * stand-down that stops LiteSpeed's own module from shadowing the
5926 + * drop-in.
5927 + */
5928 + public static function static_rewrite_allowed(): bool {
5929 + // LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
5930 + if ( Server::LITESPEED === Server::type() ) {
5931 + return false;
5932 + }
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();
5948 + return empty( $opts['mobile_separate'] );
5949 + }
5950 +
5951 + /**
5952 + * Why the device-blind static rewrite is NOT installed, when it isn't.
5953 + * Returns 'mobile_separate' when Separate Mobile Cache is the blocker
5954 + * (the static file is one-per-URL, so it can't coexist with per-device
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.)
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 +
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 + }
6086 + if ( Server::LITESPEED === Server::type() ) {
6087 + return ''; // Intended on LiteSpeed — not a "block".
6088 + }
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' : '';
6094 + }
6095 +
6096 + /**
6097 + * Whether migration flagged Separate Mobile Cache for user review. Set by
6098 + * Migration::map_mobile_separate() when a source plugin (WP Rocket / WP
6099 + * Super Cache / LiteSpeed) had its "separate mobile cache" option on: we
6100 + * import it as OFF (to keep the device-blind static fast path) but record
6101 + * this flag so the dashboard can invite the user to turn it back on only
6102 + * if their site genuinely serves different HTML per device. (FBS-83145)
6103 + */
6104 + public static function mobile_separate_needs_review(): bool {
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'] );
6118 + }
6119 +
6120 + /**
6121 + * Clear the review flag — called when the user has acted on the prompt
6122 + * (dismissed it, or turned Separate Mobile Cache on/off deliberately) so
6123 + * the dashboard callout doesn't nag forever. Writes the option directly
6124 + * (bypassing Settings_Manager) so it never touches schema fields.
6125 + */
6126 + public static function clear_mobile_separate_review(): void {
6127 + $stored = get_option( 'xspeed_module_cache', array() );
6128 + if ( ! is_array( $stored ) || empty( $stored['mobile_separate_review'] ) ) {
6129 + return;
6130 + }
6131 + unset( $stored['mobile_separate_review'] );
6132 + update_option( 'xspeed_module_cache', $stored );
6133 + }
6134 +
6135 + /**
6136 + * On-demand probe: does the homepage serve materially the same HTML to a
6137 + * desktop and a mobile browser? Fetches home_url() twice over loopback —
6138 + * once with a desktop User-Agent, once with a mobile one — strips
6139 + * per-request noise (nonces, CSRF tokens, session ids, inline timestamps),
6140 + * and compares. When identical, Separate Mobile Cache is almost certainly
6141 + * unnecessary and the user can turn it off to regain the static fast path.
6142 + *
6143 + * NEVER run automatically (no page-load cost) — only from the dashboard
6144 + * "Check now" button. Result is cached for 10 minutes so a double-click or
6145 + * a re-render doesn't fire two more self-requests. (FBS-83145)
6146 + *
6147 + * @return array{ identical:bool, checked:bool, reason?:string, desktop_bytes?:int, mobile_bytes?:int }
6148 + */
6149 + public static function probe_mobile_equality(): array {
6150 + $cached = get_transient( 'xspeed_mobile_equality_probe' );
6151 + if ( is_array( $cached ) ) {
6152 + return $cached;
6153 + }
6154 +
6155 + $home = home_url( '/' );
6156 + $host = (string) wp_parse_url( $home, PHP_URL_HOST );
6157 + if ( '' === $host ) {
6158 + $result = array( 'identical' => false, 'checked' => false, 'reason' => 'home_url has no host' );
6159 + set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
6160 + return $result;
6161 + }
6162 +
6163 + // Match WP core's own mobile detection (wp_is_mobile) so the probe
6164 + // reflects what the site would actually branch on. iPhone Safari for
6165 + // mobile; a current desktop Chrome UA for desktop.
6166 + $desktop_ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
6167 + $mobile_ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
6168 +
6169 + $is_local = function_exists( 'wp_get_environment_type' )
6170 + && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
6171 +
6172 + $fetch = static function ( string $ua ) use ( $home, $is_local ) {
6173 + $resp = wp_remote_get(
6174 + $home,
6175 + array(
6176 + 'timeout' => 5,
6177 + 'sslverify' => ! $is_local,
6178 + 'redirection' => 2,
6179 + // Bust any per-device cache so we compare freshly-rendered
6180 + // HTML, and pass the device UA the site would branch on.
6181 + 'user-agent' => $ua,
6182 + 'headers' => array( 'Cache-Control' => 'no-cache' ),
6183 + )
6184 + );
6185 + if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
6186 + return null;
6187 + }
6188 + return (string) wp_remote_retrieve_body( $resp );
6189 + };
6190 +
6191 + $desktop = $fetch( $desktop_ua );
6192 + $mobile = $fetch( $mobile_ua );
6193 +
6194 + if ( null === $desktop || null === $mobile ) {
6195 + $result = array( 'identical' => false, 'checked' => false, 'reason' => 'could not fetch homepage twice' );
6196 + set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
6197 + return $result;
6198 + }
6199 +
6200 + $identical = self::normalize_html_for_diff( $desktop ) === self::normalize_html_for_diff( $mobile );
6201 +
6202 + $result = array(
6203 + 'identical' => $identical,
6204 + 'checked' => true,
6205 + 'desktop_bytes' => strlen( $desktop ),
6206 + 'mobile_bytes' => strlen( $mobile ),
6207 + );
6208 + set_transient( 'xspeed_mobile_equality_probe', $result, 10 * MINUTE_IN_SECONDS );
6209 + return $result;
6210 + }
6211 +
6212 + /**
6213 + * Strip per-request noise from HTML so a desktop-vs-mobile diff reflects
6214 + * real structural differences, not nonces / session ids / timestamps that
6215 + * change on every render. Deliberately conservative: it normalizes the
6216 + * handful of well-known noise sources and collapses whitespace, so a site
6217 + * that truly serves different markup per device still compares as different.
6218 + */
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)
6228 + $patterns = array(
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',
6290 + // wp-generated unique ids (e.g. wp-block ids, aria ids).
6291 + '/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i',
6292 + // ISO-ish timestamps + epoch-looking numbers in query strings.
6293 + '/\?ver=[0-9.]+/',
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/',
6323 + );
6324 + $html = (string) preg_replace( $patterns, 'X', $html );
6325 + // Collapse all whitespace so trivial formatting differences don't count.
6326 + return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
6327 + }
6328 +
6329 + public static function ensure_hits_log_file(): bool {
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.
6352 + $dir = self::hits_log_dir();
6353 + if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
6354 + return false;
6355 + }
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 +
6365 + $path = self::hits_log_path();
6366 + if ( ! file_exists( $path ) ) {
6367 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
6368 + @touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
6369 + }
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 +
6380 + return file_exists( $path );
6381 + }
6382 +
595 6383 public static function nginx_snippet(): ?string {
596 6384 if ( Server::NGINX !== Server::type() ) {
597 6385 return null;
598 6386 }
@@ -624,39 +6412,178 @@
624 6412 // chain so when the file isn't cached, $xspeed_no_cache
625 6413 // gets a "-nofile" tag and the rewrite is skipped — the
626 6414 // request falls through to whatever `location /` the user
627 6415 // already had (typically `try_files $uri $uri/ /index.php?$args;`).
6416 + // Absolute path to the hit-log file from the nginx process's
6417 + // filesystem view. Nginx's `access_log buffer=N flush=Ns` form
6418 + // requires a literal path — `$document_root` variables are
6419 + // rejected — so PHP computes it. Lives under uploads/ (NOT the
6420 + // cache dir): a cache purge or uninstall deletes the cache dir,
6421 + // which would orphan this directive's parent directory and make
6422 + // `nginx -t` fail [emerg] for EVERY vhost on the host
6423 + // (FBS-82478). uploads/ survives both, so the directive can
6424 + // never take nginx down. Works on every topology where the nginx
6425 + // process shares a filesystem with PHP (container or host).
6426 + $hits_abs = self::hits_log_path();
6427 +
628 6428 $lines = array();
629 - $lines[] = '# xSpeed static cache — paste at SERVER level (inside `server { }`,';
630 - $lines[] = '# above your existing `location / { … }`; do NOT put it inside any';
631 - $lines[] = '# location block).';
6429 + $lines[] = '# xSpeed static cache — paste at server level, above location / { }.';
6430 + // Cache host must match the on-disk dir PHP writes: store_static() /
6431 + // static_host() take HTTP_HOST and strip every char outside
6432 + // [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits
6433 + // (localhost:8192 → localhost8192). nginx's own $host can't reproduce
6434 + // that: $host has the port already stripped ENTIRELY (→ localhost), so
6435 + // the -f check looks for localhost/... while PHP wrote localhost8192/...
6436 + // and the rewrite never fires on a non-standard port. Derive
6437 + // $xspeed_host from $http_host (which keeps the port) and drop just the
6438 + // colon, so it equals the PHP dir on every port. On standard ports
6439 + // $http_host has no colon, so $xspeed_host == $host == the bare domain.
6440 + $lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com)
6441 + $lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host())
632 6442 $lines[] = 'set $xspeed_no_cache "no-cache";';
633 - $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
634 - $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
635 - $lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
636 - $lines[] = 'if (!-f "$document_root' . $rel . '/$host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
6443 + $lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
6444 + $lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
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 + }
6492 + $lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
6493 + // Neither `add_header` nor `access_log` is allowed inside an `if{}`
6494 + // at server level (nginx rejects with "directive is not allowed
6495 + // here"). The logging therefore lives in a `location` block that
6496 + // matches the rewritten URI after `rewrite … last;` restarts
6497 + // location matching. Every HIT lands there exactly once, every
6498 + // MISS / PHP-served request never matches it.
637 6499 $lines[] = 'if ($xspeed_no_cache = "no-cache") {';
638 - $lines[] = ' rewrite ^ ' . $rel . '/$host$uri/index.html last;';
6500 + $lines[] = ' rewrite ^ ' . $rel . '/$xspeed_host$uri/index.html last;';
639 6501 $lines[] = '}';
6502 + $lines[] = '';
6503 + $lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.';
6504 + $lines[] = 'location ^~ ' . $rel . '/ {';
6505 + $lines[] = ' internal;';
6506 + // LITERAL log path (not `set $var; access_log $var`). The variable form
6507 + // makes nginx open the log lazily per-request and SILENTLY drop the
6508 + // line if the open fails — so on a working host hits were served
6509 + // (X-XSpeed-Cache fires regardless) but nothing was ever written and
6510 + // the hit ratio sat at 0%. A literal path makes nginx open the file at
6511 + // config load and actually log every hit.
6512 + //
6513 + // Deleting the log FILE is still safe with a literal path: nginx
6514 + // recreates it on the next write/reload and `nginx -t` stays green
6515 + // (verified). The only thing that [emerg]s `nginx -t` is a missing
6516 + // parent DIRECTORY — and the log lives under uploads/xspeed/, which
6517 + // survives cache purge + uninstall, and which ensure_hits_log_file()
6518 + // (run on every admin_init via auto_heal) recreates if it ever goes
6519 + // missing. So: hits are logged, and a user deleting the log can't take
6520 + // nginx down.
6521 + $lines[] = ' access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
6522 + $lines[] = ' add_header X-XSpeed-Cache "HIT (nginx)" always;';
6523 + $lines[] = '}';
640 6524 return implode( "\n", $lines );
641 6525 }
642 6526
643 6527 /**
644 - * Emit LiteSpeed Cache module headers on the cache-miss render
645 - * path so the server caches the response and serves subsequent
646 - * requests at edge speed without booting PHP again.
6528 + * Aggregate every enabled module's nginx_directives() into one
6529 + * pasteable server-block snippet. Replaces the per-module "paste
6530 + * this snippet" notices with a single consolidated paste — every
6531 + * future feature toggle just regenerates this output.
647 6532 *
648 - * LSCache reads two response headers:
649 - * - X-LiteSpeed-Cache-Control: public,max-age=N → "cache for N s"
650 - * - X-LiteSpeed-Tag: tag1,tag2 → tag the entry for selective
651 - * purge later via X-LiteSpeed-Purge in any later response.
6533 + * Returns null on non-nginx hosts (nothing to paste).
652 6534 *
653 - * Server detection runs through Server::type() so a non-LiteSpeed
654 - * host (Apache / nginx / IIS) sees a no-op — the headers are
655 - * harmless if emitted there, but we skip them to keep response
656 - * headers tidy. The conflict check defers to the LiteSpeed Cache
657 - * plugin when present so we don't double-cache.
6535 + * Sections render in module-registration order so the layout stays
6536 + * predictable; each module gets a comment header `# <slug>`.
658 6537 */
6538 + public static function full_nginx_server_block(): ?string {
6539 + if ( Server::NGINX !== Server::type() ) {
6540 + return null;
6541 + }
6542 +
6543 + $blocks = array();
6544 + foreach ( Module_Registry::all() as $module ) {
6545 + $directives = $module->nginx_directives();
6546 + if ( ! is_string( $directives ) || '' === trim( $directives ) ) {
6547 + continue;
6548 + }
6549 + $blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives );
6550 + }
6551 +
6552 + if ( empty( $blocks ) ) {
6553 + return null;
6554 + }
6555 +
6556 + $header = "# xSpeed unified nginx config — paste into `server { }`, above `location / { }`; re-paste after toggling features.\n";
6557 +
6558 + return $header . "\n" . implode( "\n\n", $blocks ) . "\n";
6559 + }
6560 +
6561 + /**
6562 + * Tell LiteSpeed's LSCache module to stand down on the cache-miss
6563 + * render path.
6564 + *
6565 + * History: this method used to emit X-LiteSpeed-Cache-Control:
6566 + * public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's
6567 + * LSCache store. That delegation backfired — once LSCache cached a
6568 + * page it served every subsequent request from its OWN store and
6569 + * intercepted the request before our site-root .htaccess static
6570 + * rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache
6571 + * header, our static-cache tree never served, the HIT log never
6572 + * written (hit ratio frozen at 0%), and the Health probe reporting a
6573 + * false "cache running on PHP fallback" because it never saw an
6574 + * xSpeed-served response.
6575 + *
6576 + * xSpeed now owns the cache on LiteSpeed exactly as it does on Apache:
6577 + * our `.htaccess` mod_rewrite block serves hits straight from the
6578 + * static-cache tree (with the X-XSpeed-Cache header + access-log HIT
6579 + * accounting), and PHP/the drop-in is the fallback. To guarantee
6580 + * LSCache doesn't shadow that with its own copy — some LiteSpeed
6581 + * configs cache by default — we send an explicit `no-cache` control so
6582 + * the server defers to our rewrite. Skipped when the LiteSpeed Cache
6583 + * plugin is active (it owns its own header policy; our Conflict
6584 + * registry handles that coexistence separately).
6585 + */
659 6586 public static function maybe_emit_lscache_headers(): void {
660 6587 if ( headers_sent() ) {
661 6588 return;
662 6589 }
@@ -670,20 +6597,65 @@
670 6597 if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) {
671 6598 return;
672 6599 }
673 6600
674 - $opts = Settings_Manager::get( 'cache' );
675 - $expiry = isset( $opts['cache_expiry'] ) ? (int) $opts['cache_expiry'] : DAY_IN_SECONDS;
676 - $expiry = max( 60, min( $expiry, 30 * DAY_IN_SECONDS ) );
6601 + // Explicitly opt this response OUT of LSCache so the server can't
6602 + // shadow our static-rewrite cache with its own internal copy.
6603 + header( 'X-LiteSpeed-Cache-Control: no-cache' );
6604 + }
677 6605
678 - // Tags scope the entry so a single post change can purge just
679 - // that page (or its archive) instead of the whole cache. We
680 - // always send the global `xspeed` tag plus a path-derived one.
681 - $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
682 - $path_tag = 'xspeed_' . md5( (string) strtok( $request_uri, '?' ) );
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 + }
683 6634
684 - header( 'X-LiteSpeed-Cache-Control: public,max-age=' . $expiry );
685 - header( 'X-LiteSpeed-Tag: xspeed,' . $path_tag );
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;
686 6658 }
687 6659
688 6660 /**
689 6661 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
@@ -708,30 +6680,85 @@
708 6680 if ( empty( $opts['cache_enabled'] ) ) {
709 6681 return;
710 6682 }
711 6683
712 - $dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
713 - $dropin_ours = false;
714 - if ( file_exists( $dropin_target ) ) {
715 - $contents = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
716 - $dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
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;
717 6690 }
718 6691
719 - if ( ! $dropin_ours ) {
720 - self::install_dropin();
6692 + // Rewrite block goes last. It's what turns the static-cache
6693 + // tree into a PHP-bypass — every cache hit served by the web
6694 + // server directly. Without it we still cache, just at drop-in
6695 + // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
6696 + //
6697 + // Reconcile against mobile_separate: the rewrite is device-blind, so
6698 + // it must be ABSENT when mobile_separate is on and PRESENT otherwise.
6699 + // auto_heal() runs periodically, so it also repairs a rewrite that
6700 + // was left installed before mobile_separate was switched on.
6701 + if ( self::static_rewrite_allowed() ) {
6702 + if ( ! self::rewrite_installed() ) {
6703 + self::install_rewrite();
6704 + }
6705 + } elseif ( self::rewrite_installed() ) {
6706 + self::remove_rewrite();
721 6707 }
722 6708
723 - if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
724 - self::set_wp_cache_constant( true );
6709 + // HITs log file — nginx writes one line per HIT served directly
6710 + // (see nginx_snippet()), Cache::get_stats() drains the file via
6711 + // Hit_Counter::collect_nginx_log_hits(). If the file vanishes
6712 + // (plugin upgrade wiped wp-content/cache/), nginx errors silently
6713 + // on the access_log directive and the counter stays at 0.
6714 + self::ensure_hits_log_file();
6715 + }
6716 +
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;
725 6739 }
726 6740
727 - // Rewrite block goes last. It's what turns the static-cache
728 - // tree into a PHP-bypass — every cache hit served by the web
729 - // server directly. Without it we still cache, just at drop-in
730 - // speed (~85ms TTFB) instead of static-file speed (~25-40ms).
731 - if ( ! self::rewrite_installed() ) {
732 - self::install_rewrite();
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;
733 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 + }
734 6761 }
735 6762
736 6763 /**
737 6764 * Build the .htaccess rules that map cacheable requests to the
@@ -751,14 +6778,46 @@
751 6778 $rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
752 6779 $rel = '/' . ltrim( $rel, '/' );
753 6780 $rel = rtrim( $rel, '/' );
754 6781
755 - 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(
756 6796 '<IfModule mod_rewrite.c>',
757 6797 ' RewriteEngine On',
758 6798 ' RewriteCond %{REQUEST_METHOD} ^GET$',
759 6799 ' RewriteCond %{QUERY_STRING} ^$',
760 - ' 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(
761 6820 // Capture REQUEST_URI without its trailing slash into %1.
762 6821 // store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
763 6822 // so this normalization lets `/blog/` and `/blog` both hit
764 6823 // the same cache file without producing the double-slash
@@ -764,10 +6823,44 @@
764 6823 // the same cache file without producing the double-slash
765 6824 // path that would skip the -f check below.
766 6825 ' RewriteCond %{REQUEST_URI} ^(.*?)/?$',
767 6826 ' RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f',
768 - ' RewriteRule . ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
6827 + // Pattern is `^`, NOT `.`. The per-directory rewrite engine
6828 + // strips the leading slash before matching, so the HOMEPAGE
6829 + // request `/` arrives here as an EMPTY path. `.` requires at
6830 + // least one character and therefore never matches the homepage
6831 + // — on LiteSpeed (which honors this strictly) the front page
6832 + // fell through to PHP while every inner page rewrote fine.
6833 + // `^` matches the empty string AND any non-empty path, so it
6834 + // covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
6835 + // 1.8: `.` → homepage served by PHP drop-in; `^` → served
6836 + // directly from the static file.)
6837 + ' RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
769 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 + )
770 6863 );
771 6864 }
772 6865
773 6866 /**
@@ -786,13 +6879,41 @@
786 6879 * on every Health card paint.
787 6880 *
788 6881 * @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int}
789 6882 */
790 - public static function probe_static_rewrite(): array {
6883 + /**
6884 + * @param bool $allow_probe When false (the default), return ONLY a cached
6885 + * result and never make an HTTP request — so admin page loads are never
6886 + * blocked by the loopback probe. The actual HTTP probe only runs when a
6887 + * caller explicitly opts in (the Health tab / cron). Previously this ran
6888 + * synchronously on every dashboard bootstrap, so a slow/timing-out
6889 + * loopback request added up to `timeout` seconds to admin page loads on
6890 + * hosts that block self-requests. (FBS-82142)
6891 + */
6892 + /**
6893 + * Discard the cached probe result and run a fresh one.
6894 + *
6895 + * Without this there was no way to re-check: the result sat in a transient
6896 + * for five minutes and nothing ever deleted it, so a user who fixed their
6897 + * nginx config kept seeing "nginx detected — configure for max cache speed"
6898 + * with no means of confirming the fix worked. (FBS-84012)
6899 + */
6900 + public static function recheck_static_rewrite(): array {
6901 + delete_transient( 'xspeed_rewrite_probe' );
6902 + return self::probe_static_rewrite( true );
6903 + }
6904 +
6905 + public static function probe_static_rewrite( bool $allow_probe = false ): array {
791 6906 $cached = get_transient( 'xspeed_rewrite_probe' );
792 6907 if ( is_array( $cached ) ) {
793 6908 return $cached;
794 6909 }
6910 + // No cached result yet and the caller doesn't want to pay for a live
6911 + // HTTP probe (e.g. the admin bootstrap): report "pending" without
6912 + // blocking. The Health tab will run the real probe on demand.
6913 + if ( ! $allow_probe ) {
6914 + return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true );
6915 + }
795 6916
796 6917 $home = home_url( '/' );
797 6918 $host = (string) wp_parse_url( $home, PHP_URL_HOST );
798 6919 if ( '' === $host ) {
@@ -821,13 +6942,22 @@
821 6942 }
822 6943 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir.
823 6944 file_put_contents( $probe_file, $nonce, LOCK_EX );
824 6945
6946 + // Verify TLS by default — disabling it site-wide is a needless MITM
6947 + // exposure (FBS-82142). Only relax verification in local/dev
6948 + // environments, where self-signed certs are common and there's no
6949 + // real attacker in the loop.
6950 + $is_local = function_exists( 'wp_get_environment_type' )
6951 + && in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
825 6952 $resp = wp_remote_get(
826 6953 $probe_url,
827 6954 array(
828 - 'timeout' => 4,
829 - 'sslverify' => false,
6955 + // 3s cap so a host that hangs on loopback self-requests can't
6956 + // stall the caller for long; the result/error is cached so we
6957 + // don't repeat the wait every minute.
6958 + 'timeout' => 3,
6959 + 'sslverify' => ! $is_local,
830 6960 'redirection' => 0,
831 6961 'headers' => array( 'Cache-Control' => 'no-cache' ),
832 6962 )
833 6963 );
@@ -844,11 +6974,20 @@
844 6974
845 6975 if ( is_wp_error( $resp ) ) {
846 6976 $result = array(
847 6977 'active' => false,
6978 + // The request never completed, so we learned NOTHING about the
6979 + // rewrite. Flagged inconclusive so the UI doesn't tell the user
6980 + // to configure a server that may already be configured — a
6981 + // blocked loopback, a self-signed cert, or a timeout is a probe
6982 + // failure, not a missing rewrite. (FBS-84012)
6983 + 'inconclusive' => true,
848 6984 'reason' => 'http error: ' . $resp->get_error_message(),
849 6985 );
850 - set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
6986 + // Cache the failure for the full 5 minutes (not 1) so a host that
6987 + // times out on the loopback probe isn't re-probed — and re-stalled
6988 + // — on every page load within the window. (FBS-82142)
6989 + set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
851 6990 return $result;
852 6991 }
853 6992
854 6993 $code = (int) wp_remote_retrieve_response_code( $resp );
@@ -863,25 +7002,41 @@
863 7002 // AND didn't add an X-Powered-By: PHP header. All three are
864 7003 // individually noisy; together they're conclusive.
865 7004 $active = $match && $has_etag && ! $ua_php && 200 === $code;
866 7005
7006 + /*
7007 + * `inconclusive` separates "we proved the rewrite isn't serving" from
7008 + * "the probe couldn't tell". Only the former should drive a
7009 + * configure-your-server banner; the latter previously rendered the
7010 + * same alarming copy at a user who had already configured nginx
7011 + * correctly, and there was no way to clear it. (FBS-84012)
7012 + */
7013 + $inconclusive = false;
867 7014 if ( $active ) {
868 7015 $reason = 'static-served';
869 7016 } elseif ( 200 === $code && $match && $ua_php ) {
870 7017 $reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
871 7018 } elseif ( 200 === $code && ! $match ) {
872 - $reason = 'unexpected body (CDN cached an older response?)';
7019 + // Something answered 200 with content that isn't our nonce — a CDN,
7020 + // a proxy, a security plugin. That tells us nothing about the
7021 + // origin's rewrite.
7022 + $reason = 'unexpected body (CDN cached an older response?)';
7023 + $inconclusive = true;
873 7024 } elseif ( 404 === $code ) {
874 7025 $reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
875 7026 } else {
876 - $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
7027 + // Redirects, 403s from a WAF, 5xx — the probe never reached a
7028 + // verdict about the rewrite itself.
7029 + $reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
7030 + $inconclusive = true;
877 7031 }
878 7032
879 7033 $result = array(
880 - 'active' => $active,
881 - 'reason' => $reason,
882 - 'code' => $code,
883 - 'php' => $ua_php,
7034 + 'active' => $active,
7035 + 'inconclusive' => $inconclusive,
7036 + 'reason' => $reason,
7037 + 'code' => $code,
7038 + 'php' => $ua_php,
884 7039 );
885 7040 set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
886 7041 return $result;
887 7042 }
@@ -913,8 +7068,13 @@
913 7068 * block manually: strip any prior xSpeed Static Cache markers,
914 7069 * then write our block followed by the rest of the file.
915 7070 */
916 7071 public static function install_rewrite(): bool {
7072 + // The static rewrite is device-blind; never install it when
7073 + // mobile_separate is on (see static_rewrite_allowed()).
7074 + if ( ! self::static_rewrite_allowed() ) {
7075 + return false;
7076 + }
917 7077 $htaccess = ABSPATH . '.htaccess';
918 7078 $existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
919 7079 if ( false === $existing ) {
920 7080 $existing = '';
@@ -933,12 +7093,49 @@
933 7093 $cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
934 7094 $block = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
935 7095 $next = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );
936 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 +
937 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.
938 7110 return false !== file_put_contents( $htaccess, $next, LOCK_EX );
939 7111 }
940 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 +
941 7138 public static function remove_rewrite(): bool {
942 7139 $htaccess = ABSPATH . '.htaccess';
943 7140 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
944 7141 if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
@@ -959,9 +7156,21 @@
959 7156 * follows it. Idempotent — returns the input unchanged if the
960 7157 * marker isn't present.
961 7158 */
962 7159 private static function strip_marker_block( string $contents, string $marker ): string {
963 - $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";
964 7173 $out = preg_replace( $pattern, '', $contents );
965 7174 return is_string( $out ) ? $out : $contents;
966 7175 }
967 7176
@@ -973,8 +7182,429 @@
973 7182 $footer = "# END $marker\n";
974 7183 return $header . implode( "\n", $lines ) . "\n" . $footer;
975 7184 }
976 7185
7186 + /**
7187 + * Parse the `XSPEED_DROPIN_VERSION: N` stamp out of a drop-in's source.
7188 + * Returns 0 when absent (an un-stamped older copy reinstalls). Used to
7189 + * detect a stale installed drop-in vs the bundled source.
7190 + */
7191 + private static function dropin_version( string $contents ): int {
7192 + if ( preg_match( '/XSPEED_DROPIN_VERSION:\s*(\d+)/', $contents, $m ) ) {
7193 + return (int) $m[1];
7194 + }
7195 + return 0;
7196 + }
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 +
977 7607 public static function install_dropin() {
978 7608 $source = XSPEED_DIR . 'includes/advanced-cache.php';
979 7609 $target = WP_CONTENT_DIR . '/advanced-cache.php';
980 7610 if ( ! file_exists( $source ) ) {
@@ -980,8 +7610,26 @@
980 7610 if ( ! file_exists( $source ) ) {
981 7611 return false;
982 7612 }
983 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 +
984 7632 global $wp_filesystem;
985 7633 if ( ! function_exists( 'WP_Filesystem' ) ) {
986 7634 require_once ABSPATH . 'wp-admin/includes/file.php';
987 7635 }
@@ -994,35 +7642,82 @@
994 7642 if ( ! is_string( $source_contents ) ) {
995 7643 return false;
996 7644 }
997 7645
7646 + // Bake the absolute hit-log path into the drop-in. It runs before
7647 + // WordPress loads, so it can't resolve wp_upload_dir() itself — we
7648 + // substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path
7649 + // (never the cache dir; see hits_log_dir() / FBS-82478). Use a single
7650 + // quoted PHP string literal so the installed file stays valid PHP.
7651 + $source_contents = str_replace(
7652 + '@@XSPEED_HITS_LOG@@',
7653 + str_replace( "'", "\\'", self::hits_log_path() ),
7654 + $source_contents
7655 + );
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 +
998 7715 if ( file_exists( $target ) ) {
999 7716 $existing = $wp_filesystem->get_contents( $target );
1000 - $is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );
1001 -
1002 - if ( $is_xspeed ) {
1003 - if ( $existing === $source_contents ) {
1004 - return true;
1005 - }
1006 - return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
7717 + if ( is_string( $existing ) && $existing === $source_contents ) {
7718 + return true;
1007 7719 }
1008 -
1009 - // Foreign drop-in (e.g. left over from another cache plugin) — back it up
1010 - // before overwriting so the user can recover if needed. Uploads dir
1011 - // (not wp-content root) keeps the backup out of WordPress's reserved
1012 - // drop-in location.
1013 - $upload = wp_upload_dir( null, false );
1014 - $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
1015 - if ( $basedir ) {
1016 - if ( ! file_exists( $basedir ) ) {
1017 - wp_mkdir_p( $basedir );
1018 - self::write_silence( $basedir );
1019 - }
1020 - $backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
1021 - $wp_filesystem->move( $target, $backup, true );
1022 - } else {
1023 - $wp_filesystem->delete( $target );
1024 - }
1025 7720 }
1026 7721
1027 7722 return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
1028 7723 }
@@ -1042,61 +7737,351 @@
1042 7737 return;
1043 7738 }
1044 7739
1045 7740 $contents = $wp_filesystem->get_contents( $target );
1046 - if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
7741 + if ( is_string( $contents ) && xspeed_has_canonical_dropin_signature( $contents ) ) {
1047 7742 wp_delete_file( $target );
1048 7743 }
1049 7744 }
1050 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 +
1051 7793 public static function set_wp_cache_constant( $enable ) {
1052 - $wp_config = ABSPATH . 'wp-config.php';
1053 - if ( ! file_exists( $wp_config ) ) {
7794 + $wp_config = self::wp_config_path();
7795 + if ( '' === $wp_config ) {
1054 7796 return false;
1055 7797 }
1056 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 + }
1057 7817 global $wp_filesystem;
1058 7818 if ( ! function_exists( 'WP_Filesystem' ) ) {
1059 7819 require_once ABSPATH . 'wp-admin/includes/file.php';
1060 7820 }
1061 7821 WP_Filesystem();
1062 - if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
7822 + if ( ! $wp_filesystem ) {
1063 7823 return false;
1064 7824 }
1065 7825
1066 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 + }
1067 7836
1068 - if ( $enable ) {
1069 - if ( strpos( $config, "define( 'WP_CACHE'" ) !== false || strpos( $config, "define('WP_CACHE'" ) !== false ) {
1070 - return true;
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' );
1071 7873 }
1072 - $config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
1073 - } else {
1074 - $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
7874 + return true;
1075 7875 }
1076 7876
1077 - 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;
1078 7885 }
1079 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 +
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 + /**
7934 + * Admin-bar purge menu — a parent node plus one child per visible cache
7935 + * type (LiteSpeed-style), instead of a single "Purge All" link. Each
7936 + * child posts to the same admin-post handler with its type slug. The
7937 + * per-type items only appear for active/licensed modules; "Purge All"
7938 + * always shows and always sweeps everything. (FBS-83114)
7939 + *
7940 + * The parent node links to the settings page rather than a purge URL —
7941 + * clicking the top-level item used to wipe the whole cache instantly with
7942 + * no confirmation, which is far too destructive for a stray click. Purging
7943 + * stays available (and explicit) through the child items. (FBS-84068)
7944 + */
1080 7945 public function admin_bar_purge( $wp_admin_bar ) {
1081 7946 if ( ! current_user_can( 'manage_options' ) ) {
1082 7947 return;
1083 7948 }
7949 +
1084 7950 $wp_admin_bar->add_node(
1085 7951 array(
1086 7952 'id' => 'xspeed-purge',
1087 - 'title' => __( 'Purge xSpeed Cache', 'xspeed' ),
1088 - 'href' => wp_nonce_url( admin_url( 'admin-post.php?action=xspeed_purge' ), 'xspeed_purge' ),
7953 + 'title' => __( 'xSpeed Cache', 'xspeed' ),
7954 + 'href' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ),
1089 7955 )
1090 7956 );
7957 +
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 ] ) ) {
8009 + continue;
8010 + }
8011 + $wp_admin_bar->add_node(
8012 + array(
8013 + 'id' => 'xspeed-purge-' . $slug,
8014 + 'parent' => 'xspeed-purge',
8015 + 'title' => esc_html( $type['label'] ),
8016 + 'href' => self::purge_type_url( $slug ),
8017 + )
8018 + );
8019 + }
1091 8020 }
1092 8021
8022 + /**
8023 + * Nonce-protected admin-post URL for purging a single type. The nonce
8024 + * action is per-type so a leaked URL can't be replayed for a different
8025 + * scope.
8026 + */
8027 + private static function purge_type_url( string $type ): string {
8028 + return wp_nonce_url(
8029 + admin_url( 'admin-post.php?action=xspeed_purge&type=' . rawurlencode( $type ) ),
8030 + 'xspeed_purge_' . $type
8031 + );
8032 + }
8033 +
1093 8034 public function handle_admin_bar_purge() {
1094 8035 if ( ! current_user_can( 'manage_options' ) ) {
1095 8036 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
1096 8037 }
1097 - check_admin_referer( 'xspeed_purge' );
1098 - self::purge_all();
1099 - wp_safe_redirect( wp_get_referer() ?: admin_url() );
8038 + $type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all';
8039 + check_admin_referer( 'xspeed_purge_' . $type );
8040 +
8041 + // Only honour known types; anything else falls back to a full purge.
8042 + if ( ! array_key_exists( $type, self::purge_types() ) ) {
8043 + $type = 'all';
8044 + }
8045 + self::purge_type( $type );
8046 +
8047 + wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) );
1100 8048 exit;
8049 + }
8050 +
8051 + /**
8052 + * Resolve a safe redirect target for an admin-bar purge.
8053 + *
8054 + * The purge sends the admin back where they came from — but the referer
8055 + * can be a ONE-SHOT action URL (e.g. update.php?action=upload-plugin from
8056 + * installing a plugin zip, or any *.php?action=… that consumed a POST /
8057 + * temp upload). Redirecting there re-runs the action with nothing to act
8058 + * on, so WordPress dies — the classic "Please select a file" from
8059 + * File_Upload_Upgrader. Strip the transient action args so we return to a
8060 + * safe, re-GET-able view of the same page; fall back to the dashboard when
8061 + * there is no usable referer.
8062 + *
8063 + * @param string|false $referer Raw wp_get_referer() value.
8064 + * @return string Safe URL to redirect to.
8065 + */
8066 + public static function safe_purge_redirect( $referer ): string {
8067 + $referer = is_string( $referer ) ? $referer : '';
8068 + if ( '' === $referer ) {
8069 + return admin_url();
8070 + }
8071 +
8072 + // A referer that lands on an action-processing endpoint (update.php,
8073 + // update-core.php, plugin/theme install/upload flows) can't be safely
8074 + // re-requested — send them to the dashboard instead of replaying it.
8075 + $path = (string) wp_parse_url( $referer, PHP_URL_PATH );
8076 + if ( preg_match( '#/wp-admin/(update|update-core)\.php$#', $path ) ) {
8077 + return admin_url();
8078 + }
8079 +
8080 + // Otherwise keep them on the same page but drop the query args that
8081 + // would re-trigger a form action or upload on load.
8082 + return remove_query_arg(
8083 + array( 'action', 'action2', 'package', 'overwrite', 'plugin', 'theme', 'file', '_wpnonce', '_ajax_nonce' ),
8084 + $referer
8085 + );
1101 8086 }
1102 8087 }