PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.3 1.1.4 1.1.5 All 32 releases
← All changes | includes/class-preloader.php +149 -10 1.2.4 → 1.3.6 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 /**
@@ -376,9 +495,12 @@
376 495 if ( empty( $opts['add_missing_dimensions'] ) ) {
377 496 return;
378 497 }
379 498
380 - if ( ! preg_match_all( '#<img\b[^>]*\bsrc\s*=\s*["\']([^"\']+)["\'][^>]*>#i', $html, $m, PREG_SET_ORDER ) ) {
499 + // Match any <img>, not only one carrying `src`. The URL worth warming
500 + // may live in a lazy attribute instead — which is the whole point of
501 + // #328 — and resolvable_image_url() below is what knows where to look.
502 + if ( ! preg_match_all( '#<img\b[^>]*>#i', $html, $m, PREG_SET_ORDER ) ) {
381 503 return;
382 504 }
383 505
384 506 $home = wp_parse_url( home_url(), PHP_URL_HOST );
@@ -385,13 +507,30 @@
385 507 $targets = array();
386 508 foreach ( $m as $tag ) {
387 509 // Only tags MISSING a dimension are worth resolving — one that
388 510 // already declares both needs nothing.
389 - if ( preg_match( '#\bwidth\s*=#i', $tag[0] ) && preg_match( '#\bheight\s*=#i', $tag[0] ) ) {
511 + // Same lookbehind as Lazy_Loader::ensure_dimensions(): a bare
512 + // `\bwidth=` also matches `data-width=`, so a slider carrying its
513 + // own metadata looked already-sized and was skipped from warming.
514 + // The two must agree, or the collector skips exactly the tags the
515 + // renderer still needs measured. (#333 review round 3, issue 2)
516 + if ( preg_match( '#(?<![-\w])width\s*=#i', $tag[0] ) && preg_match( '#(?<![-\w])height\s*=#i', $tag[0] ) ) {
390 517 continue;
391 518 }
392 - $src = $tag[1];
393 - if ( ! preg_match( '#^https?://#i', $src ) ) {
519 + // Ask the same resolver the render path uses, rather than reading
520 + // `src` directly. A slider parks a spacer in `src` and the real
521 + // URL in `data-lazy`/`data-src`/`data-original`, so a collector
522 + // looking only at `src` warmed the SPACER and never the image —
523 + // leaving remotely-hosted slider images unresolvable at render
524 + // time, the exact markup #328 is about. (#333 review round 2,
525 + // issue 3)
526 + // `false`: do not let the resolver settle a name-refused URL by
527 + // MEASURING it. That is circular here — remote measurement is
528 + // gated until warm_dimensions() sets $warming, and this collector
529 + // is what feeds warm_dimensions(). Take the URL the tag offers and
530 + // let the warm pass decide. (#333 review round 3, issue 3)
531 + $src = Lazy_Loader::resolvable_image_url( $tag[0], false );
532 + if ( '' === $src || ! preg_match( '#^https?://#i', $src ) ) {
394 533 continue;
395 534 }
396 535 $host = wp_parse_url( $src, PHP_URL_HOST );
397 536 if ( ! $host || $host === $home ) {
@@ -586,9 +725,9 @@
586 725 $sitemap_url,
587 726 array(
588 727 'timeout' => self::REQUEST_TIMEOUT,
589 728 'sslverify' => false,
590 - 'user-agent' => self::USER_AGENT,
729 + 'user-agent' => self::user_agent(),
591 730 )
592 731 );
593 732 if ( is_wp_error( $res ) ) {
594 733 // Record WHY, don't just vanish. "Unreachable" and "valid but