PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 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 All 35 releases
xspeed / includes / class-server-caches.php

class-server-caches.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.1, at includes/class-server-caches.php

785 lines 32.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Server_Caches — forward xSpeed's purges to a cache in front of PHP.
4 *
5 * xSpeed owns one cache. A LiteSpeed stack has two: ours, and LSCache holding
6 * its own copy of the same URL at the server. Purging ours and stopping there
7 * left the server still serving the page we had just invalidated — measured on
8 * OpenLiteSpeed before this existed. Two adapters ship: LiteSpeed, and the
9 * nginx FastCGI cache reached through the Nginx Helper plugin.
10 *
11 * Each adapter decides for itself which purges are worth forwarding, from the
12 * `intent` and `scope` on the context. They do not answer alike, and the
13 * reasoning for each lives on the adapter — see `forward_nginx_helper()`,
14 * which stands down on a content purge where `forward_litespeed()` does not.
15 *
16 * This is the counterpart to Render_Caches. That one clears caches of RENDERED
17 * OUTPUT owned by page builders; this one clears caches of whole RESPONSES
18 * owned by the web server. Both are integrations with software we do not ship,
19 * and both hang off a public seam so a site can add its own.
20 *
21 * Nothing here touches another plugin's files or runs a shell command. Each
22 * integration calls the documented public API of the plugin it integrates
23 * with, and detects that plugin by class or constant rather than by path — a
24 * renamed plugin folder must not silently disable the integration.
25 *
26 * Tier: Free. xSpeed's tiering rule (FEATURES.md) is that anything LiteSpeed
27 * Cache ships free, xSpeed ships free — and their purge API is free. Gating
28 * this would mean an unlicensed site keeps serving stale HTML from LSCache,
29 * which is a correctness bug, not a paid feature.
30 *
31 * @package XSpeed
32 */
33
34 declare(strict_types=1);
35
36 namespace XSpeed;
37
38 defined( 'ABSPATH' ) || exit;
39
40 final class Server_Caches {
41
42 /*
43 * Forwarding itself is deliberately not a listener. `Cache` calls
44 * forward() directly, before it fires the public purge actions.
45 *
46 * As a listener this would be one callback among many, and WordPress stops
47 * dispatching an action's remaining callbacks when an earlier one throws —
48 * so an unrelated third-party listener's bug could silently skip our
49 * LiteSpeed forwarding, leaving the server serving stale HTML while xSpeed
50 * reported a successful purge. Shipped behaviour should not be hostage to
51 * that. Third parties still extend through `xspeed_purge_server_caches`
52 * below, which runs after we have done our own work.
53 *
54 * Both built-in adapters are reached only from forward(). Neither
55 * registers a hook of its own, so this is the single place that decides
56 * whether a given purge reaches a server cache.
57 *
58 * boot() below is the one exception, and it registers nothing that
59 * forwards — only the end-of-import purge that forward_nginx_helper()'s
60 * import gate depends on.
61 */
62
63 /**
64 * Register the end-of-import purge.
65 *
66 * `forward_nginx_helper()` stands down for the length of an import: a
67 * WXR run fires hundreds of individually-justified purges, and clearing
68 * the whole nginx zone once per imported post is the waste that gate
69 * exists to stop. That trade is only correct if a single purge follows
70 * the import — otherwise the install finishes with nginx still serving
71 * every pre-import page for the rest of its TTL, which is worse than the
72 * waste. This is that purge, and nothing else issues it.
73 *
74 * `import_end` is WordPress's own signal, fired by the WXR importer and
75 * by every importer that follows its lead. An importer that fires
76 * `import_start` and then dies without `import_end` leaves the zone
77 * stale — the same outcome as not having the gate, so no worse than
78 * before, and not worth a `shutdown` fallback that would fire a full
79 * purge on every request that ever touched an importer.
80 */
81 public static function boot(): void {
82 if ( ! function_exists( 'add_action' ) ) {
83 return;
84 }
85 add_action( 'import_end', array( __CLASS__, 'purge_after_import' ) );
86 }
87
88 /**
89 * Clear everything once, now that the import is done.
90 *
91 * `complete` intent, which is what `Cache::purge_all()` announces by
92 * default — and the one intent the import gate lets through, so this
93 * reaches the server layer even though `did_action( 'import_start' )` is
94 * still true for the rest of the request.
95 */
96 public static function purge_after_import(): void {
97 if ( ! class_exists( __NAMESPACE__ . '\\Cache' ) ) {
98 return;
99 }
100 Cache::purge_all( 'import finished' );
101 }
102
103 /**
104 * Forward one purge to every server cache we recognise.
105 *
106 * The public context carries the action an adapter should take:
107 * `urls` purges only the listed response URLs, `site` purges this site's
108 * response cache, and `network` represents a deliberate whole-tree sweep.
109 * Older callers that omit `scope` retain the original url/null behaviour.
110 *
111 * @param array<string,mixed> $context See `xspeed_after_purge_url`.
112 */
113 public static function forward( $context ): void {
114 if ( ! is_array( $context ) ) {
115 return;
116 }
117
118 // A broken built-in adapter must not suppress the public seam. The local
119 // purge already succeeded, and another adapter may still clear the edge.
120 try {
121 self::forward_litespeed( $context );
122 } catch ( \Throwable $e ) {
123 if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) {
124 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- built-in integration failure after local invalidation.
125 error_log( '[xspeed] LiteSpeed response purge failed: ' . $e->getMessage() );
126 }
127 }
128
129 // Its own try, for the same reason the LiteSpeed one has its own: two
130 // server caches can be in front of one site, and a bad day for one
131 // adapter must not leave the other serving stale HTML.
132 try {
133 self::forward_nginx_helper( $context );
134 } catch ( \Throwable $e ) {
135 if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) {
136 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- built-in integration failure after local invalidation.
137 error_log( '[xspeed] nginx FastCGI response purge failed: ' . $e->getMessage() );
138 }
139 }
140
141 /**
142 * Fires so a site can invalidate a server cache xSpeed does not know.
143 *
144 * Same context as the event that triggered it. Use this rather than
145 * subscribing to `xspeed_after_purge_url` directly when you want to
146 * run only after the built-in integrations have had their turn.
147 *
148 * @since 1.2.3
149 *
150 * @param array $context Bounded purge context.
151 */
152 // Use WordPress' dispatcher so current_action(), did_action(), the `all`
153 // hook and observability tools retain native semantics. Cache wraps each
154 // callback one level down so a throwing adapter cannot cancel the ones
155 // queued behind it.
156 Cache::do_action_isolated( 'xspeed_purge_server_caches', $context );
157 }
158
159 /**
160 * LiteSpeed Cache: URL purges plus its response-cache-only full seam.
161 *
162 * URL purges use LiteSpeed's documented `litespeed_purge_url` action. A
163 * site-wide response invalidation calls the public
164 * `LiteSpeed\Purge::purge_all_lscache()` seam added in 7.7. Older releases
165 * expose only the broad purge-all API, so full forwarding deliberately
166 * stands down there. Do not use `litespeed_purge_all`: in 7.9 that also
167 * deletes LiteSpeed
168 * CSS/JS, local-resource, object and opcode caches and may purge its
169 * Cloudflare integration. xSpeed only owns the response invalidation.
170 *
171 * Detected by constant, not plugin path. `LSCWP_V` is defined by the
172 * plugin bootstrap and survives a renamed folder.
173 *
174 * Forwards on every intent, including `content` — unlike the nginx
175 * adapter, which stands down there. See `forward_nginx_helper()` for why
176 * the two differ.
177 *
178 * @param array<string,mixed> $context Public purge context.
179 */
180 private static function forward_litespeed( array $context ): void {
181 if ( ! defined( 'LSCWP_V' ) ) {
182 return;
183 }
184
185 $url = isset( $context['url'] ) && is_string( $context['url'] ) ? $context['url'] : '';
186 $host = isset( $context['host'] ) && is_string( $context['host'] ) ? $context['host'] : '';
187 $scope = isset( $context['scope'] ) && is_string( $context['scope'] )
188 ? $context['scope']
189 : ( '' !== $url ? 'urls' : 'site' );
190
191 if ( 'none' === $scope ) {
192 return;
193 }
194
195 if ( 'site' === $scope || 'network' === $scope ) {
196 // A full purge scoped to ANOTHER site — Multisite::purge_site()
197 // runs inside switch_to_blog(), so the request's LSCache is not
198 // that site's — must not flush ours. `'*'` is the deliberate
199 // whole-tree sweep and does mean everything. An empty host is the
200 // single-site case, where the purge is ours by definition.
201 if ( '' !== $host && '*' !== $host && ! self::host_is_this_site( $host ) ) {
202 return;
203 }
204 if ( is_callable( array( '\\LiteSpeed\\Purge', 'purge_all_lscache' ) ) ) {
205 // LiteSpeed normally prefixes `*` with the current blog ID. A
206 // network response contract needs the raw `*` tag. This is the same
207 // official switch used by its Empty Entire Cache path and does not
208 // invoke its CSS/JS, object or opcode purgers.
209 if ( 'network' === $scope && ! defined( 'LSWCP_EMPTYCACHE' ) ) {
210 define( 'LSWCP_EMPTYCACHE', true );
211 }
212 \LiteSpeed\Purge::purge_all_lscache( 'xSpeed response invalidation' );
213 self::note_forwarded( 'LiteSpeed Cache' );
214 }
215 return;
216 }
217
218 $urls = array();
219 if ( isset( $context['urls'] ) && is_array( $context['urls'] ) ) {
220 $urls = $context['urls'];
221 } elseif ( '' !== $url ) {
222 $urls = array( $url );
223 }
224 $targets = array();
225 foreach ( array_unique( array_filter( $urls, 'is_string' ) ) as $target_url ) {
226 $targets = array_merge( $targets, self::litespeed_targets_for_url( $target_url ) );
227 }
228 foreach ( array_values( array_unique( $targets ) ) as $target ) {
229 do_action( 'litespeed_purge_url', $target );
230 }
231 if ( array() !== $targets ) {
232 self::note_forwarded( 'LiteSpeed Cache' );
233 }
234 }
235
236 /**
237 * nginx FastCGI full-page cache, through the Nginx Helper plugin.
238 *
239 * Forwards on every intent, and on `content` only when Nginx Helper is not
240 * purging for itself or `xspeed_nginx_helper_defer_content_purge` says
241 * to. That asymmetry with `forward_litespeed()`, which forwards on all of
242 * them, is deliberate.
243 *
244 * The two server caches are not alike in what a purge costs. LSCache is
245 * per-site and tag-based: a site purge bumps one tag for one blog. The
246 * nginx FastCGI zone is ONE directory per WordPress install, and clearing
247 * it is a recursive unlink of every cached page — on multisite, of every
248 * site on the network. So the blast radius of forwarding is an order of
249 * magnitude apart for the same event.
250 *
251 * The other half is that we are not the only one purging. Nginx Helper
252 * hooks `transition_post_status`, `before_delete_post` and the comment
253 * hooks itself and purges only the URLs the edit touched (the post, the
254 * homepage, the post's archives), behind its own `enable_purge` option and
255 * an import guard. Its term hooks purge the homepage alone, which is why
256 * a renamed or deleted term is `presentation` and still forwards. On a content
257 * purge it has already done the narrow, correct thing. Forwarding on top
258 * of that replaced targeted purging with a whole-install wipe at the same
259 * frequency: publishing one post cleared every cached page on the site,
260 * and an import cost one full wipe per post. (QA #444.)
261 *
262 * That argument only holds while Nginx Helper's `enable_purge` is on. It
263 * defaults to off, and with it off Nginx Helper purges nothing on a
264 * content edit. Standing down there left the edited post stale at the
265 * server for the whole TTL, where before this adapter existed it was
266 * cleared. So a content purge forwards when Nginx Helper is not purging
267 * for itself. (QA #448)
268 *
269 * The trade that stays: Nginx Helper purges the post, the homepage and
270 * the post's archives. An ordinary page that lists recent posts is none of
271 * those, and keeps its old list until the server TTL expires. When xSpeed
272 * knows that happened, because a narrow purge fell back to the whole site
273 * for a list the theme draws everywhere, the context says so in
274 * `fallback` and the zone is cleared (fallback_needs_whole_zone()). The
275 * `xspeed_nginx_helper_defer_content_purge` filter returns to clearing
276 * the whole zone on every content purge outside an import, for a site that
277 * needs those pages current.
278 *
279 * `presentation` and `complete` still forward, because neither of those is
280 * something Nginx Helper covers. It has no hook for `switch_theme`,
281 * `activated_plugin` or `wp_update_nav_menu`, and no notion of a settings
282 * write or a core update — and each of those changes the markup of every
283 * page, not a listed few. An unrecognised intent forwards too: a purge
284 * whose reason we do not know is likelier to need the server layer than
285 * not, and a redundant purge costs a cold cache while a skipped one costs
286 * wrong HTML for the whole TTL.
287 *
288 * Whether LiteSpeed should also stand down on `content` is a fair question
289 * and was deliberately not revisited here — it has no targeted self-purge
290 * to fall back on, so standing it down would leave LSCache stale where
291 * nginx is merely over-cleared.
292 *
293 * @param array<string,mixed> $context Public purge context.
294 */
295 private static function forward_nginx_helper( array $context ): void {
296 // Guarded rather than assumed: Free is upgraded as a unit, but a
297 // half-copied update can leave this file newer than that one.
298 if ( ! class_exists( __NAMESPACE__ . '\\Host_Page_Caches' ) ) {
299 return;
300 }
301
302 $url = isset( $context['url'] ) && is_string( $context['url'] ) ? $context['url'] : '';
303 $scope = isset( $context['scope'] ) && is_string( $context['scope'] )
304 ? $context['scope']
305 : ( '' !== $url ? 'urls' : 'site' );
306
307 if ( 'none' === $scope ) {
308 return;
309 }
310
311 // Named pages go to Nginx Helper's own per-URL purge, whether or not
312 // its automatic purging is on. Its own rules clear the post, the home
313 // page and the post's archives, but no archive page past the first and
314 // no neighbouring post, so leaving a narrow purge to it would leave
315 // those stale. Only this site's URLs: with the `get_request` method
316 // Nginx Helper keeps the path and swaps in its own host. Collected
317 // and sent once, at shutdown (flush_nginx_helper()).
318 if ( 'urls' === $scope ) {
319 if ( self::is_importing() || ! Host_Page_Caches::nginx_helper_is_fastcgi() ) {
320 return;
321 }
322 $urls = isset( $context['urls'] ) && is_array( $context['urls'] ) ? $context['urls'] : array( $url );
323 foreach ( $urls as $target ) {
324 if ( is_string( $target ) && '' !== $target && self::is_this_site( $target ) ) {
325 self::queue_nginx_url( $target );
326 }
327 }
328 return;
329 }
330
331 $intent = isset( $context['intent'] ) && is_string( $context['intent'] ) && '' !== $context['intent']
332 ? $context['intent']
333 : 'complete';
334
335 // Nothing to decide on a site with no nginx zone, so the filter below
336 // is only asked when there is one.
337 if ( ! Host_Page_Caches::nginx_helper_is_fastcgi() ) {
338 return;
339 }
340
341 if ( 'content' === $intent ) {
342 /**
343 * Whether a content purge (a post saved, a comment approved, a
344 * term added) is left to Nginx Helper instead of clearing the
345 * whole nginx cache.
346 *
347 * Defaults to true when Nginx Helper's automatic purging is on,
348 * since it has already purged the post, the homepage and the
349 * post's archives. Defaults to false when `fallback` in the
350 * context says pages outside those changed (`theme_list`,
351 * `pending`, `filter`, `listing`). Return false to clear the
352 * whole zone instead, for a site whose pages list posts
353 * somewhere Nginx Helper does not purge.
354 *
355 * @param bool $defer Whether to leave it to Nginx Helper.
356 * @param array<string,mixed> $context Public purge context.
357 */
358 $defer = (bool) apply_filters(
359 'xspeed_nginx_helper_defer_content_purge',
360 Host_Page_Caches::nginx_helper_purges_changes() && ! self::fallback_needs_whole_zone( $context ),
361 $context
362 );
363 if ( $defer ) {
364 return;
365 }
366 }
367
368 // An import is a long run of legitimate purges that each individually
369 // justify a forward — new terms, new menu items — and together clear
370 // the install's cache hundreds of times for one operation. Nginx
371 // Helper stands its own purging down for exactly this (its
372 // `is_import_request()`), and a single purge after the import is both
373 // cheaper and more correct. An explicit `complete` still goes through:
374 // an operator who presses Purge All mid-import means it.
375 if ( 'complete' !== $intent && self::is_importing() ) {
376 return;
377 }
378
379 // No host check, deliberately — the mirror of the one in
380 // forward_litespeed(). There, a purge aimed at another blog must not
381 // flush THIS request's LSCache, because LSCache is per-site. nginx
382 // keys one zone per install, so the other blog's cached pages live in
383 // the same directory as ours: skipping on a foreign host would leave
384 // the pages the purge was actually for still being served. Pro's
385 // Multisite::purge_site() runs inside switch_to_blog() and reaches
386 // here with that blog's host.
387 self::purge_nginx_zone();
388 }
389
390 /**
391 * Most URLs sent to Nginx Helper one by one in a request. Past this the
392 * zone is cleared once instead. A typical save names about 35 pages.
393 */
394 private const NGINX_URL_LIMIT = 100;
395
396 /**
397 * Seconds of per-URL purging after which the rest of the batch becomes
398 * one zone purge. Nginx Helper's `get_request` method sends a blocking
399 * GET per URL with WordPress's 5-second default timeout.
400 */
401 private const NGINX_URL_SECONDS = 3.0;
402
403 /**
404 * This request's nginx work, sent once at shutdown.
405 *
406 * `urls` is keyed by URL, so a page that two saves in one request both
407 * name (a bulk edit, where every post shares the home page and the
408 * archives) is sent once. `overflow` means more than the limit arrived.
409 * `zone_done` means the zone was cleared earlier in this request, and
410 * `zone_again` that another clear was asked for after it.
411 *
412 * @var array{urls:array<string,bool>,overflow:bool,zone_done:bool,zone_again:bool,armed:bool}
413 */
414 private static $nginx = array(
415 'urls' => array(),
416 'overflow' => false,
417 'zone_done' => false,
418 'zone_again' => false,
419 'armed' => false,
420 );
421
422 /** Test seam: forget this request's nginx work. */
423 public static function reset(): void {
424 self::$nginx = array(
425 'urls' => array(),
426 'overflow' => false,
427 'zone_done' => false,
428 'zone_again' => false,
429 'armed' => false,
430 );
431 }
432
433 /**
434 * Clear the whole nginx zone, at most once now and once more at the end
435 * of the request.
436 *
437 * The first clear runs at once, as it always has, so an operator's Purge
438 * All lands before the response. A later one in the same request (a bulk
439 * edit of ten posts that each fall back to the whole site) waits for
440 * shutdown and runs once, after every change the request makes. Clearing
441 * the zone covers every URL queued before it, so those are dropped.
442 */
443 private static function purge_nginx_zone(): void {
444 self::note_forwarded( 'Nginx Helper' );
445 self::$nginx['urls'] = array();
446 self::$nginx['overflow'] = false;
447 if ( self::$nginx['zone_done'] ) {
448 self::$nginx['zone_again'] = true;
449 self::arm_nginx_flush();
450 return;
451 }
452 if ( Host_Page_Caches::purge_nginx_helper() ) {
453 self::$nginx['zone_done'] = true;
454 }
455 }
456
457 /**
458 * Tell an operator's purge report which cache took the purge. The
459 * nginx batch is sent at shutdown, after the caller has printed its
460 * answer, so this records the hand-off rather than the send.
461 *
462 * @param string $layer Cache name.
463 */
464 private static function note_forwarded( string $layer ): void {
465 if ( class_exists( __NAMESPACE__ . '\\Cache' ) ) {
466 Cache::note_purge_forwarded( $layer );
467 }
468 }
469
470 /** Add one URL to this request's nginx batch. */
471 private static function queue_nginx_url( string $url ): void {
472 self::note_forwarded( 'Nginx Helper' );
473 if ( self::$nginx['zone_again'] ) {
474 // The zone is cleared at shutdown anyway.
475 return;
476 }
477 if ( ! self::$nginx['overflow'] ) {
478 self::$nginx['urls'][ $url ] = true;
479 if ( count( self::$nginx['urls'] ) > self::nginx_url_limit() ) {
480 self::$nginx['overflow'] = true;
481 self::$nginx['urls'] = array();
482 }
483 }
484 self::arm_nginx_flush();
485 }
486
487 /**
488 * Send the batch at shutdown, or now when shutdown is already running.
489 *
490 * Inside `shutdown` a callback added at a priority that has already run
491 * would never fire, so a purge raised there (Cache::flush_pending_saves()
492 * runs at priority 1) is sent straight away.
493 */
494 private static function arm_nginx_flush(): void {
495 if ( function_exists( 'did_action' ) && did_action( 'shutdown' ) ) {
496 self::flush_nginx_helper();
497 return;
498 }
499 if ( self::$nginx['armed'] || ! function_exists( 'add_action' ) ) {
500 return;
501 }
502 self::$nginx['armed'] = true;
503 add_action( 'shutdown', array( __CLASS__, 'flush_nginx_helper' ), 20, 0 );
504 }
505
506 /**
507 * Send this request's nginx work: one zone clear, or each queued URL.
508 *
509 * Sent in the request that raised it, not handed to WP-Cron the way the
510 * Cloudflare module defers its edge calls. On a site behind an nginx page
511 * cache, anonymous visits are answered by nginx and never run PHP, so
512 * WP-Cron can go a long time without a request to run on, and the pages
513 * would stay stale until it did. The visitor requests that reach here (a
514 * comment, a stock change at checkout) name about four URLs, and Nginx
515 * Helper's own comment and post hooks already purge inline in those same
516 * requests. NGINX_URL_SECONDS bounds the wait when the purge endpoint is
517 * slow.
518 *
519 * Public because it is a `shutdown` callback; not part of the contract.
520 */
521 public static function flush_nginx_helper(): void {
522 $work = self::$nginx;
523 self::$nginx['urls'] = array();
524 self::$nginx['overflow'] = false;
525 self::$nginx['zone_again'] = false;
526 self::$nginx['armed'] = false;
527
528 if ( ! Host_Page_Caches::nginx_helper_is_fastcgi() ) {
529 return;
530 }
531 if ( $work['zone_again'] ) {
532 Host_Page_Caches::purge_nginx_helper();
533 return;
534 }
535 if ( $work['overflow'] ) {
536 self::record_nginx_zone_fallback();
537 if ( Host_Page_Caches::purge_nginx_helper() ) {
538 self::$nginx['zone_done'] = true;
539 }
540 return;
541 }
542 if ( array() === $work['urls'] ) {
543 return;
544 }
545 Host_Page_Caches::purge_nginx_helper_urls( array_keys( $work['urls'] ), self::NGINX_URL_SECONDS );
546 }
547
548 /**
549 * How many URLs a request may send to Nginx Helper one by one.
550 */
551 private static function nginx_url_limit(): int {
552 if ( ! function_exists( 'apply_filters' ) ) {
553 return self::NGINX_URL_LIMIT;
554 }
555 /**
556 * Filter how many URLs one request sends to Nginx Helper one by one
557 * before clearing the whole nginx zone instead.
558 *
559 * @param int $limit URLs per request.
560 */
561 $limit = (int) apply_filters( 'xspeed_nginx_helper_url_purge_limit', self::NGINX_URL_LIMIT );
562 return $limit > 0 ? $limit : self::NGINX_URL_LIMIT;
563 }
564
565 /** Say in the activity log that a batch became a zone clear. */
566 private static function record_nginx_zone_fallback(): void {
567 if ( ! class_exists( __NAMESPACE__ . '\\Activity_Log' ) || ! function_exists( '__' ) ) {
568 return;
569 }
570 Activity_Log::record(
571 'cache_purged',
572 sprintf(
573 /* translators: %d: most URLs purged one by one. */
574 __( 'Cleared the whole nginx cache: more than %d pages changed in one request, too many to purge one by one', 'xspeed' ),
575 self::nginx_url_limit()
576 )
577 );
578 }
579
580 /**
581 * Whether a site-wide content purge fell back from a narrow one for a
582 * reason Nginx Helper's own rules do not cover.
583 *
584 * Nginx Helper purges the post, the homepage and the first page of the
585 * post's archives. That is enough for `limit`: the pages were all named,
586 * only too many of them, and the ones past the first archive page wait
587 * for the server TTL, as every content purge did before narrow purges.
588 * It is not enough for the others:
589 *
590 * - `theme_list`: a list the theme draws on every page changed.
591 * - `pending`: the save could not be worked out at all.
592 * - `filter`: a site's own code said the named pages are not enough.
593 * - `listing`: pages that run a post list of their own (a page builder
594 * grid) may have changed, and they are not ones Nginx Helper purges.
595 *
596 * An excluded post type (a WooCommerce product) carries no reason and
597 * stays with Nginx Helper.
598 *
599 * @param array<string,mixed> $context Public purge context.
600 */
601 private static function fallback_needs_whole_zone( array $context ): bool {
602 $reason = isset( $context['fallback'] ) && is_string( $context['fallback'] ) ? $context['fallback'] : '';
603 return in_array( $reason, array( Cache::FALLBACK_THEME_LIST, Cache::FALLBACK_PENDING, Cache::FALLBACK_FILTER, Cache::FALLBACK_LISTING ), true );
604 }
605
606 /**
607 * Whether WordPress is importing content right now.
608 */
609 private static function is_importing(): bool {
610 if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
611 return true;
612 }
613
614 // The WXR importer defines WP_IMPORTING, but not every importer does;
615 // `import_start` is the signal the others share.
616 return function_exists( 'did_action' ) && did_action( 'import_start' ) > 0;
617 }
618
619 /**
620 * Build LiteSpeed targets for one same-site URL.
621 *
622 * @return string[]
623 */
624 private static function litespeed_targets_for_url( string $url ): array {
625 // Only this site's own URLs. `purge_url()` supports cross-site purges
626 // (multisite, WP-CLI, cron), and LSCache is per-site: reducing another
627 // site's URL to a path would have this site's LiteSpeed purge its OWN
628 // /page/ — the wrong entry gone, the intended one still stale, and a
629 // success reported for both. The other site's server cache is not
630 // addressable from here, so we stand down and leave it to a
631 // network-aware listener on `xspeed_purge_server_caches`. (QA review)
632 if ( ! self::is_this_site( $url ) ) {
633 return array();
634 }
635 // Both trailing-slash forms. Our own sweep purges `/about` and
636 // `/about/` because the cache key preserves whichever the request
637 // used, and LiteSpeed tags them separately for the same reason — so
638 // forwarding only the canonical form can leave the other a HIT. Root
639 // stays a single '/'. (QA review; plausible rather than reproduced —
640 // LSCache dedupes identical tags, so the cost of being wrong is one
641 // redundant purge.)
642 return self::slash_forms( self::site_relative( $url ) );
643 }
644
645 /**
646 * A relative target in both trailing-slash forms, deduplicated.
647 *
648 * @return string[]
649 */
650 private static function slash_forms( string $relative ): array {
651 $query = '';
652 $path = $relative;
653 $split = strpos( $relative, '?' );
654 if ( false !== $split ) {
655 $path = substr( $relative, 0, $split );
656 $query = substr( $relative, $split );
657 }
658 if ( '/' === $path || '' === $path ) {
659 return array( $relative );
660 }
661 $bare = rtrim( $path, '/' );
662 // Keep the exact spelling too. `/path///` can be a distinct server key.
663 return array_values( array_unique( array( $path . $query, $bare . $query, $bare . '/' . $query ) ) );
664 }
665
666 /**
667 * Is this URL served by the site we are running as?
668 *
669 * Host and port, because a site on a non-standard port is a different
670 * origin. Unknown either way means no — a purge sent to the wrong cache is
671 * worse than one not sent at all.
672 */
673 private static function is_this_site( string $url ): bool {
674 if ( ! self::host_is_this_site( self::host_of( $url ) ) ) {
675 return false;
676 }
677
678 // On a subdirectory network, equal hosts do not mean equal blogs.
679 if ( function_exists( 'is_multisite' ) && is_multisite()
680 && function_exists( 'get_blog_details' ) && function_exists( 'get_current_blog_id' )
681 ) {
682 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- bounded URL ownership lookup.
683 if ( ! is_array( $parts ) ) {
684 return false;
685 }
686 $host = isset( $parts['host'] ) ? (string) $parts['host'] : '';
687 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
688 $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) );
689 for ( $take = min( count( $segments ), 2 ); $take >= 0; --$take ) {
690 $candidate = 0 === $take ? '/' : '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/';
691 $details = get_blog_details( array( 'domain' => $host, 'path' => $candidate ), false );
692 if ( $details && isset( $details->blog_id ) ) {
693 return (int) $details->blog_id === (int) get_current_blog_id();
694 }
695 }
696 }
697
698 return true;
699 }
700
701 /** Compare a host[:port] against the running site's. */
702 private static function host_is_this_site( string $host ): bool {
703 if ( ! function_exists( 'home_url' ) ) {
704 return false;
705 }
706 $ours = self::host_of( (string) home_url( '/' ) );
707 return '' !== $ours && '' !== $host && $ours === strtolower( $host );
708 }
709
710 /**
711 * host[:port] of a URL, lowercased; '' when it has none.
712 *
713 * A port that is the default for the scheme is dropped, because it is not
714 * part of the origin: `https://site.com:443/p/` and `https://site.com/p/`
715 * are the same page, and RFC 3986 6.2.3 says so. Comparing them as raw
716 * strings made `:443` look like a different site, so the purge stood down
717 * and LiteSpeed was told nothing at all — while the caller was told the
718 * page "was already cold". The page kept serving the old copy until its
719 * TTL ran out.
720 *
721 * Reachable from `wp xspeed cache purge-url`, the MCP `purge_url` tool,
722 * and any plugin passing a canonical URL that spells out the port. The
723 * reverse direction was worse: a site whose own `home_url()` carries
724 * `:443` — normal behind a proxy — matched none of its own URLs, so no
725 * per-page purge ever reached the server cache, silently, site-wide.
726 *
727 * A NON-default port is still kept: `site.com:8443` genuinely is a
728 * different origin from `site.com`, and collapsing those would send one
729 * site's purge to another's cache. (QA #348)
730 */
731 private static function host_of( string $url ): string {
732 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- host only.
733 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
734 return '';
735 }
736 $host = strtolower( (string) $parts['host'] );
737 if ( empty( $parts['port'] ) ) {
738 return $host;
739 }
740 $port = (int) $parts['port'];
741 $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
742 if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
743 return $host;
744 }
745 return $host . ':' . $port;
746 }
747
748 /**
749 * Reduce an absolute URL to the site-relative path LiteSpeed keys on.
750 *
751 * LiteSpeed does this itself in `Utility::make_relative()`, by stripping a
752 * `LSCWP_DOMAIN` built with `HTTP_URL_STRIP_ALL` — which strips the PORT.
753 * On a site served from a non-standard port, `http://host:8244/page/` has
754 * `http://host` removed and becomes `:8244/page/`, which is not a valid
755 * URI tag, so the purge silently matches nothing and the server keeps
756 * serving the page. Measured on OpenLiteSpeed 1.8.2 with LiteSpeed Cache
757 * 7.9: an absolute URL left the entry a HIT, the same purge sent as a path
758 * turned it into a MISS.
759 *
760 * Sending the path sidesteps their parsing entirely and is what they
761 * ultimately hash, so it is correct on standard ports too — this is not a
762 * workaround we would want to remove once they fix it.
763 *
764 * Query strings are preserved: LiteSpeed tags them separately, and a purge
765 * for `/shop/` should not silently claim to have cleared `/shop/?page=2`.
766 */
767 private static function site_relative( string $url ): string {
768 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- path extraction only.
769 if ( ! is_array( $parts ) ) {
770 return $url;
771 }
772 // An absolute origin with no path is the homepage. LiteSpeed expects
773 // '/', never the original absolute URL. Preserve a root query below.
774 $path = isset( $parts['path'] ) && '' !== (string) $parts['path'] ? (string) $parts['path'] : '/';
775 $relative = '/' . ltrim( $path, '/' );
776 // isset(), not empty(): a query of "0" is a real, distinct cache entry
777 // and empty() calls it falsy, so `/shop/?0` would be sent as `/shop/`
778 // and leave the entry the caller named stale.
779 if ( isset( $parts['query'] ) && '' !== (string) $parts['query'] ) {
780 $relative .= '?' . $parts['query'];
781 }
782 return $relative;
783 }
784 }
785