PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.5
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.5
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 1.1.3 1.1.4 1.1.5 1.1.6 All 31 releases
← All changes | includes/class-preloader.php +125 -6 1.3.41.3.5 View file →
@@ -35,9 +35,24 @@
35 35
36 36 public const STATE_KEY = 'xspeed_preloader_state';
37 37 public const STATE_TTL = 86400; // 24h — long enough for slow crawls.
38 38 public const CRON_HOOK = 'xspeed_preloader_tick';
39 - public const USER_AGENT = 'xSpeed-Preloader/1.0 (+cache warmer; admin-initiated)';
39 + /**
40 + * User-agent for every request the preloader makes.
41 + *
42 + * Deliberately contains no substring from the 7G/8G bad-bot lists. The
43 + * previous value, "xSpeed-Preloader/1.0", matched the `loader` token in
44 + * the alphabetical slice `(linkscan|linkwalker|loader|lwp-download|...)`
45 + * — a match on "Pre*loader*" — so nginx ports of 8G answered every warm
46 + * with 403 and newly published posts were never warmed. Upstream 8G
47 + * v1.5 has since dropped `loader`, but forks and vendored copies (xCloud
48 + * among them) still ship the older slice, so the name has to stay clear
49 + * of it. "Warmer" matches nothing in either list. (#481)
50 + *
51 + * Read through user_agent() rather than using this constant directly, so
52 + * the `xspeed_preloader_user_agent` filter applies.
53 + */
54 + public const USER_AGENT = 'xSpeed-Warmer/1.0 (+cache warmer; admin-initiated)';
40 55 public const REQUEST_TIMEOUT = 8;
41 56
42 57 /**
43 58 * Default cap on NEW remote images resolved per warmed page.
@@ -57,8 +72,102 @@
57 72 */
58 73 private const REMOTE_DIMENSION_LIMIT = 20;
59 74
60 75 /**
76 + * The user-agent every preloader request sends.
77 + *
78 + * Filterable because the blocking rule lives on the server, not here: a
79 + * host with its own bad-bot list can clear a warm without patching the
80 + * plugin or waiting for a release. An empty filter return is ignored —
81 + * sending no UA gets a request blocked at least as often. (#481)
82 + */
83 + public static function user_agent(): string {
84 + /**
85 + * Filter the preloader's user-agent string.
86 + *
87 + * @param string $user_agent Default self::USER_AGENT.
88 + */
89 + $ua = apply_filters( 'xspeed_preloader_user_agent', self::USER_AGENT );
90 +
91 + return ( is_string( $ua ) && '' !== trim( $ua ) ) ? trim( $ua ) : self::USER_AGENT;
92 + }
93 +
94 + /**
95 + * Is this status code the signature of a firewall refusing our warmer?
96 + *
97 + * 403 and 406 are what bad-bot rules (7G/8G, mod_security, Wordfence)
98 + * answer with. We only ever warm our OWN origin, and a page a visitor can
99 + * load must be loadable by us too — so these codes mean the request was
100 + * judged by its user-agent, not that the page is missing or broken. (#481)
101 + */
102 + private static function is_firewall_block( int $code ): bool {
103 + return in_array( $code, array( 403, 406 ), true );
104 + }
105 +
106 + /**
107 + * Explain a warm failure in terms the admin can act on.
108 + *
109 + * A bare "HTTP 403" sent people hunting a broken page; the page is fine,
110 + * and the fix is a server rule, so the message has to name the cause and
111 + * the exact UA to allow. (#481)
112 + */
113 + private static function failure_detail( int $code ): string {
114 + if ( ! self::is_firewall_block( $code ) ) {
115 + return sprintf( 'HTTP %d', $code );
116 + }
117 +
118 + return sprintf(
119 + 'HTTP %d — your server\'s firewall is blocking the xSpeed cache warmer by user-agent, so this page was not warmed. Allow the user-agent "%s" (on xCloud this is the 8G firewall\'s bad-bot rule), or change it with the xspeed_preloader_user_agent filter.',
120 + $code,
121 + self::user_agent()
122 + );
123 + }
124 +
125 + /** Option holding the last firewall-shaped warm refusal. */
126 + public const FIREWALL_BLOCK_OPTION = 'xspeed_preloader_firewall_block';
127 +
128 + /**
129 + * Record that the origin refused a warm by user-agent, for ui_notices().
130 + *
131 + * An option rather than a transient: the condition is a server rule that
132 + * persists until someone changes it, and a notice that expired on its own
133 + * would let a site go back to never warming, silently. Cleared by
134 + * clear_firewall_block() on the first warm that succeeds. (#481)
135 + */
136 + private static function remember_firewall_block( string $url, int $code ): void {
137 + if ( ! function_exists( 'update_option' ) ) {
138 + return;
139 + }
140 + update_option(
141 + self::FIREWALL_BLOCK_OPTION,
142 + array(
143 + 'url' => $url,
144 + 'code' => $code,
145 + 'user_agent' => self::user_agent(),
146 + 'ts' => time(),
147 + ),
148 + false
149 + );
150 + }
151 +
152 + /** Forget the firewall block once a warm gets through. */
153 + public static function clear_firewall_block(): void {
154 + if ( function_exists( 'delete_option' ) && self::firewall_block() ) {
155 + delete_option( self::FIREWALL_BLOCK_OPTION );
156 + }
157 + }
158 +
159 + /** The last firewall-shaped refusal, or null when there isn't one. */
160 + public static function firewall_block(): ?array {
161 + if ( ! function_exists( 'get_option' ) ) {
162 + return null;
163 + }
164 + $block = get_option( self::FIREWALL_BLOCK_OPTION, null );
165 +
166 + return ( is_array( $block ) && ! empty( $block['code'] ) ) ? $block : null;
167 + }
168 +
169 + /**
61 170 * How many new remote images one warmed page may resolve.
62 171 */
63 172 private static function remote_dimension_limit(): int {
64 173 /**
@@ -280,9 +389,9 @@
280 389 $url,
281 390 array(
282 391 'timeout' => self::REQUEST_TIMEOUT,
283 392 'sslverify' => false,
284 - 'user-agent' => self::USER_AGENT,
393 + 'user-agent' => self::user_agent(),
285 394 'blocking' => true,
286 395 )
287 396 );
288 397 if ( is_wp_error( $response ) ) {
@@ -296,13 +405,19 @@
296 405 $code = (int) wp_remote_retrieve_response_code( $response );
297 406 if ( $code >= 400 ) {
298 407 Activity_Log::record(
299 408 'preloader_warm_failed',
300 - sprintf( 'Warm %s failed (%s): HTTP %d', $cause, $url, $code ),
409 + sprintf( 'Warm %s failed (%s): %s', $cause, $url, self::failure_detail( $code ) ),
301 410 Activity_Log::WARN
302 411 );
412 + if ( self::is_firewall_block( $code ) ) {
413 + self::remember_firewall_block( $url, $code );
414 + }
303 415 return false;
304 416 }
417 + // A warm that got through proves the firewall is no longer refusing us,
418 + // so the notice must go — otherwise it outlives the problem. (#481)
419 + self::clear_firewall_block();
305 420 Activity_Log::record(
306 421 'preloader_warmed_one',
307 422 sprintf( 'Warmed %s (%s)', $url, $cause ),
308 423 Activity_Log::INFO
@@ -315,9 +430,9 @@
315 430 $url,
316 431 array(
317 432 'timeout' => self::REQUEST_TIMEOUT,
318 433 'sslverify' => false,
319 - 'user-agent' => self::USER_AGENT,
434 + 'user-agent' => self::user_agent(),
320 435 'headers' => array(
321 436 'Accept' => 'text/html,application/xhtml+xml',
322 437 ),
323 438 'blocking' => true,
@@ -337,15 +452,19 @@
337 452 $code = (int) wp_remote_retrieve_response_code( $response );
338 453 if ( $code >= 400 ) {
339 454 $state['errors'][] = array(
340 455 'url' => $url,
341 - 'error' => sprintf( 'HTTP %d', $code ),
456 + 'error' => self::failure_detail( $code ),
342 457 'ts' => time(),
343 458 );
344 459 $state['errors'] = array_slice( $state['errors'], -20 );
460 + if ( self::is_firewall_block( $code ) ) {
461 + self::remember_firewall_block( $url, $code );
462 + }
345 463 return;
346 464 }
347 465
466 + self::clear_firewall_block();
348 467 self::warm_remote_dimensions( (string) wp_remote_retrieve_body( $response ) );
349 468 }
350 469
351 470 /**
@@ -606,9 +725,9 @@
606 725 $sitemap_url,
607 726 array(
608 727 'timeout' => self::REQUEST_TIMEOUT,
609 728 'sslverify' => false,
610 - 'user-agent' => self::USER_AGENT,
729 + 'user-agent' => self::user_agent(),
611 730 )
612 731 );
613 732 if ( is_wp_error( $res ) ) {
614 733 // Record WHY, don't just vanish. "Unreachable" and "valid but