| @@ -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() : ''; |