PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.7 All 30 releases
← All changes | includes/class-cdn-rewriter.php +154 -3 1.1.01.3.4 View file →
@@ -60,9 +60,73 @@
60 60 if ( empty( $opts['enabled'] ) || empty( $opts['cdn_url'] ) ) {
61 61 return $html;
62 62 }
63 63 self::prime_origin();
64 + if ( self::is_dev_host() ) {
65 + return $html;
66 + }
64 67
68 + // Rewrite everything except the regions where a URL-shaped string is
69 + // content rather than a reference. <script> is the one with teeth:
70 + // inline JS that compares, signs or posts an asset path would see a
71 + // different origin, and anything it fetches at runtime silently
72 + // becomes cross-origin. <pre>/<code> are documentation — rewriting
73 + // them edits a tutorial's text — and <textarea> is user input.
74 + //
75 + // <style> is deliberately NOT protected: the url() pass exists to
76 + // rewrite inline stylesheets.
77 + //
78 + // Only the INNER TEXT is held back, not the opening tag — a
79 + // `<script src="…">` attribute is a genuine asset reference and must
80 + // still reach the CDN, while the JS between the tags must not. The
81 + // pattern therefore captures the body separately from the tags around
82 + // it.
83 + $parts = preg_split(
84 + '#(<(?:script|pre|code|textarea)\b[^>]*>)(.*?)(</(?:script|pre|code|textarea)\s*>)#is',
85 + $html,
86 + -1,
87 + PREG_SPLIT_DELIM_CAPTURE
88 + );
89 +
90 + if ( ! is_array( $parts ) ) {
91 + // preg_split can fail on a pathological buffer (PCRE backtrack
92 + // limit). Rewriting everything is what we did before this guard
93 + // existed, so fall back to it rather than silently disabling the
94 + // CDN on one page.
95 + return self::rewrite_segment( $html, $opts );
96 + }
97 +
98 + // PREG_SPLIT_DELIM_CAPTURE yields, per match:
99 + // [ text, open-tag, body, close-tag, text, … ]
100 + // so index % 4 === 2 is the protected body and everything else is
101 + // rewritable — including the open tag carrying src/href.
102 + foreach ( $parts as $i => $part ) {
103 + if ( '' === $part ) {
104 + continue;
105 + }
106 + if ( 2 === $i % 4 ) {
107 + // Protected body. One exception: JSON-escaped slashes
108 + // (`https:\/\/…`) are how wp_localize_script and the block
109 + // editor emit asset URLs, and they only ever appear inside an
110 + // inline script. That form is unambiguously a data payload
111 + // rather than code, so it is still rewritten — while plain JS
112 + // strings, which inline code may compare or sign, are not.
113 + $parts[ $i ] = self::rewrite_escaped_slash_urls( $part, $opts );
114 + continue;
115 + }
116 + $parts[ $i ] = self::rewrite_segment( $part, $opts );
117 + }
118 +
119 + return implode( '', $parts );
120 + }
121 +
122 + /**
123 + * Apply every URL rewrite to one rewritable slice of the document.
124 + *
125 + * Split out of process_html() so the protected-region skipping above has
126 + * something to call per segment; the passes themselves are unchanged.
127 + */
128 + private static function rewrite_segment( string $html, array $opts ): string {
65 129 // Rewrite src, href, poster, data-src.
66 130 $html = preg_replace_callback(
67 131 '#\b(src|href|poster|data-src)\s*=\s*([\'"])([^\'"]+)\2#i',
68 132 static function ( $m ) use ( $opts ) {
@@ -81,12 +145,48 @@
81 145 },
82 146 $html
83 147 );
84 148
85 - return $html;
149 + // CSS url() references — inline <style> blocks and style="" attributes.
150 + // Without this an inline background-image stays on the origin even
151 + // though its extension is in the include list.
152 + $html = preg_replace_callback(
153 + '#url\(\s*([\'"]?)([^\'")]+)\1\s*\)#i',
154 + static function ( $m ) use ( $opts ) {
155 + $rewritten = self::rewrite_url( $m[2], $opts );
156 + return 'url(' . $m[1] . $rewritten . $m[1] . ')';
157 + },
158 + $html
159 + );
160 +
161 + return self::rewrite_escaped_slash_urls( $html, $opts );
86 162 }
87 163
88 164 /**
165 + * Rewrite JSON-escaped asset URLs (`http:\/\/site\/wp-content\/…`).
166 + *
167 + * These come from wp_localize_script and block-editor payloads — ordinary
168 + * asset URLs that happen to live inside a JSON string, so without this
169 + * they are the one category a whole-page pass would miss. Kept separate
170 + * because it is also the only rewrite applied inside an inline <script>,
171 + * where this escaped form marks a data payload rather than code.
172 + */
173 + private static function rewrite_escaped_slash_urls( string $html, array $opts ): string {
174 + return (string) preg_replace_callback(
175 + '#https?:\\\\/\\\\/[^"\'\s\\\\]+(?:\\\\/[^"\'\s\\\\]+)*#i',
176 + static function ( $m ) use ( $opts ) {
177 + $plain = str_replace( '\\/', '/', $m[0] );
178 + $rewritten = self::rewrite_url( $plain, $opts );
179 + if ( $rewritten === $plain ) {
180 + return $m[0];
181 + }
182 + return str_replace( '/', '\\/', $rewritten );
183 + },
184 + $html
185 + );
186 + }
187 +
188 + /**
89 189 * Public for tests + REST validation. Returns the rewritten URL or
90 190 * the input unchanged.
91 191 */
92 192 public static function rewrite_url( string $url, array $opts ): string {
@@ -104,8 +204,16 @@
104 204 if ( '#' === substr( $url, 0, 1 ) ) {
105 205 return $url;
106 206 }
107 207
208 + // Never rewrite on a local/dev host — the CDN has no origin to pull
209 + // from, so every rewritten asset would 404. Checked here as well as
210 + // in process_html() because rewrite_url() is also reached directly
211 + // via the wp_get_attachment_url filter.
212 + if ( self::is_dev_host() ) {
213 + return $url;
214 + }
215 +
108 216 $abs = self::absolutize( $url );
109 217 if ( null === $abs ) {
110 218 return $url;
111 219 }
@@ -132,9 +240,13 @@
132 240 if ( '' === $ext || ! in_array( $ext, array_map( 'strtolower', $included ), true ) ) {
133 241 return $url;
134 242 }
135 243
136 - // Excluded path globs (reuse Glob_Matcher for *.pdf, /cart/*).
244 + // Excluded globs (reuse Glob_Matcher for *.pdf, /cart/*). Matched
245 + // against the FULL absolute URL as well as the bare path: matching
246 + // the path alone made it impossible to exclude by query string
247 + // (`*nocdn=1*`) or by host, which is exactly what someone reaches
248 + // for when one asset must stay on the origin.
137 249 $excluded = isset( $opts['excluded_patterns'] ) && is_array( $opts['excluded_patterns'] )
138 250 ? $opts['excluded_patterns']
139 251 : array();
140 252 foreach ( $excluded as $pattern ) {
@@ -140,9 +252,12 @@
140 252 foreach ( $excluded as $pattern ) {
141 253 if ( '' === $pattern ) {
142 254 continue;
143 255 }
144 - if ( class_exists( '\\XSpeed\\Glob_Matcher' ) && Glob_Matcher::matches( $pattern, $path ) ) {
256 + if ( ! class_exists( '\\XSpeed\\Glob_Matcher' ) ) {
257 + continue;
258 + }
259 + if ( Glob_Matcher::matches( $pattern, $path ) || Glob_Matcher::matches( $pattern, $abs ) ) {
145 260 return $url;
146 261 }
147 262 }
148 263
@@ -214,8 +329,44 @@
214 329 $value = preg_replace( '#^https?://#i', '', $value );
215 330 $value = preg_replace( '#^//#', '', $value );
216 331 $value = rtrim( $value, '/' );
217 332 return strtolower( $value );
333 + }
334 +
335 + /**
336 + * Is this site running on a local/dev hostname?
337 + *
338 + * Rewriting to a CDN on `localhost` or `mysite.test` can only produce
339 + * broken URLs — the CDN has nothing to pull from, so every asset 404s.
340 + * A developer who leaves CDN settings switched on in a local copy of a
341 + * production database would otherwise get a silently broken site with
342 + * no clue why.
343 + *
344 + * Filterable for the rare setup where a dev-suffixed host really is
345 + * publicly reachable behind a real CDN.
346 + */
347 + public static function is_dev_host(): bool {
348 + if ( null === self::$home_host ) {
349 + self::prime_origin();
350 + }
351 + $host = (string) self::$home_host;
352 +
353 + // The suffixes the issue names, plus the loopback hosts. `.example` is
354 + // deliberately NOT here: it is the RFC 2606 documentation TLD, not a
355 + // local-development convention, and excluding it would be guesswork
356 + // about someone's real domain.
357 + $is_dev = ( 'localhost' === $host )
358 + || ( '127.0.0.1' === $host )
359 + || ( '::1' === $host )
360 + || (bool) preg_match( '/\.(test|local|dev|localhost)$/i', $host );
361 +
362 + /**
363 + * Whether to refuse CDN rewriting for this hostname.
364 + *
365 + * @param bool $is_dev Whether the host looks local/dev.
366 + * @param string $host The site's hostname.
367 + */
368 + return (bool) apply_filters( 'xspeed_cdn_is_dev_host', $is_dev, $host );
218 369 }
219 370
220 371 private static function prime_origin(): void {
221 372 $home = function_exists( 'home_url' ) ? home_url() : '';