PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
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 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-cdn-rewriter.php

class-cdn-rewriter.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.6, at includes/class-cdn-rewriter.php

389 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cdn_Rewriter — rewrites local-origin asset URLs to a user-supplied
4 * CDN hostname (BunnyCDN, KeyCDN, Cloudflare R2 pull-zone, etc.).
5 *
6 * Assumes pull-zone CDN (CDN fetches from origin on demand); we never
7 * upload anything. The user sets `cdn_url` to e.g. `cdn.example.com`
8 * and we rewrite asset URLs from `https://example.com/wp-content/…`
9 * to `https://cdn.example.com/wp-content/…`.
10 *
11 * Strategy: same buffer-pass approach as Lazy_Loader — regex over
12 * specific tag attributes is ~10× faster than a full DOMDocument round
13 * trip, and CDN rewriting is purely a string substitution on URLs that
14 * point at the site origin. Out-of-origin URLs are left alone.
15 *
16 * Handled attributes: src, href, srcset, data-src, data-srcset, poster.
17 * Honors extension whitelist + glob exclude patterns (reuses
18 * Glob_Matcher so `*.pdf` / `/cart/*` work the same as elsewhere).
19 *
20 * @package XSpeed
21 */
22
23 declare(strict_types=1);
24
25 namespace XSpeed;
26
27 defined( 'ABSPATH' ) || exit;
28
29 final class Cdn_Rewriter {
30
31 /** @var array|null */
32 private static $opts = null;
33 /** @var string|null */
34 private static $home_host = null;
35 /** @var string */
36 private static $home_scheme = 'https';
37
38 public const DEFAULT_EXTENSIONS = array(
39 'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'ico',
40 'woff', 'woff2', 'ttf', 'otf', 'eot',
41 'css', 'js',
42 'mp4', 'webm', 'mp3', 'ogg',
43 );
44
45 public static function reset_state(): void {
46 self::$opts = null;
47 self::$home_host = null;
48 self::$home_scheme = 'https';
49 }
50
51 /**
52 * Top-level HTML transform. Returns input unchanged if disabled or
53 * cdn_url is empty.
54 */
55 public static function process_html( string $html ): string {
56 if ( '' === $html ) {
57 return $html;
58 }
59 $opts = self::opts();
60 if ( empty( $opts['enabled'] ) || empty( $opts['cdn_url'] ) ) {
61 return $html;
62 }
63 self::prime_origin();
64 if ( self::is_dev_host() ) {
65 return $html;
66 }
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 {
129 // Rewrite src, href, poster, data-src.
130 $html = preg_replace_callback(
131 '#\b(src|href|poster|data-src)\s*=\s*([\'"])([^\'"]+)\2#i',
132 static function ( $m ) use ( $opts ) {
133 $rewritten = self::rewrite_url( $m[3], $opts );
134 return $m[1] . '=' . $m[2] . $rewritten . $m[2];
135 },
136 $html
137 );
138
139 // Rewrite srcset / data-srcset (comma-separated `url 1x, url 2x`).
140 $html = preg_replace_callback(
141 '#\b(srcset|data-srcset)\s*=\s*([\'"])([^\'"]+)\2#i',
142 static function ( $m ) use ( $opts ) {
143 $rewritten = self::rewrite_srcset( $m[3], $opts );
144 return $m[1] . '=' . $m[2] . $rewritten . $m[2];
145 },
146 $html
147 );
148
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 );
162 }
163
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 /**
189 * Public for tests + REST validation. Returns the rewritten URL or
190 * the input unchanged.
191 */
192 public static function rewrite_url( string $url, array $opts ): string {
193 $url = trim( $url );
194 if ( '' === $url ) {
195 return $url;
196 }
197 if ( null === self::$home_host ) {
198 self::prime_origin();
199 }
200 // Skip data:, mailto:, tel:, javascript:, fragments, blob:.
201 if ( preg_match( '#^(data|mailto|tel|javascript|blob|about):#i', $url ) ) {
202 return $url;
203 }
204 if ( '#' === substr( $url, 0, 1 ) ) {
205 return $url;
206 }
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
216 $abs = self::absolutize( $url );
217 if ( null === $abs ) {
218 return $url;
219 }
220
221 // Must be same origin.
222 $parts = wp_parse_url( $abs );
223 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
224 return $url;
225 }
226 if ( strtolower( $parts['host'] ) !== self::$home_host ) {
227 return $url;
228 }
229
230 $path = (string) ( $parts['path'] ?? '' );
231 if ( '' === $path ) {
232 return $url;
233 }
234
235 // Extension whitelist.
236 $included = isset( $opts['included_extensions'] ) && is_array( $opts['included_extensions'] )
237 ? $opts['included_extensions']
238 : self::DEFAULT_EXTENSIONS;
239 $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
240 if ( '' === $ext || ! in_array( $ext, array_map( 'strtolower', $included ), true ) ) {
241 return $url;
242 }
243
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.
249 $excluded = isset( $opts['excluded_patterns'] ) && is_array( $opts['excluded_patterns'] )
250 ? $opts['excluded_patterns']
251 : array();
252 foreach ( $excluded as $pattern ) {
253 if ( '' === $pattern ) {
254 continue;
255 }
256 if ( ! class_exists( '\\XSpeed\\Glob_Matcher' ) ) {
257 continue;
258 }
259 if ( Glob_Matcher::matches( $pattern, $path ) || Glob_Matcher::matches( $pattern, $abs ) ) {
260 return $url;
261 }
262 }
263
264 $cdn_host = self::normalize_host( (string) $opts['cdn_url'] );
265 if ( '' === $cdn_host ) {
266 return $url;
267 }
268
269 $query = isset( $parts['query'] ) ? '?' . $parts['query'] : '';
270 $fragment = isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '';
271
272 return self::$home_scheme . '://' . $cdn_host . $path . $query . $fragment;
273 }
274
275 /**
276 * Rewrite each candidate URL inside an srcset descriptor list.
277 */
278 public static function rewrite_srcset( string $srcset, array $opts ): string {
279 $parts = preg_split( '#\s*,\s*#', trim( $srcset ) );
280 if ( ! is_array( $parts ) ) {
281 return $srcset;
282 }
283 $out = array();
284 foreach ( $parts as $candidate ) {
285 $candidate = trim( $candidate );
286 if ( '' === $candidate ) {
287 continue;
288 }
289 // `<url> <descriptor>` — descriptor optional (1x, 2x, 800w).
290 $split = preg_split( '#\s+#', $candidate, 2 );
291 $url = $split[0];
292 $descr = isset( $split[1] ) ? ' ' . $split[1] : '';
293 $rewritten = self::rewrite_url( $url, $opts );
294 $out[] = $rewritten . $descr;
295 }
296 return implode( ', ', $out );
297 }
298
299 /**
300 * Convert relative/scheme-relative URLs to absolute against the site
301 * origin. Returns null if we can't make sense of it.
302 */
303 private static function absolutize( string $url ): ?string {
304 if ( preg_match( '#^https?://#i', $url ) ) {
305 return $url;
306 }
307 if ( 0 === strpos( $url, '//' ) ) {
308 return self::$home_scheme . ':' . $url;
309 }
310 if ( 0 === strpos( $url, '/' ) ) {
311 return self::$home_scheme . '://' . self::$home_host . $url;
312 }
313 // Bare relative paths like `images/x.png` — these would need a
314 // base URL to resolve. The DOM rendering picked one already; we
315 // can't reliably guess. Leave alone.
316 return null;
317 }
318
319 /**
320 * Strip scheme + trailing slash from a user-entered CDN URL so the
321 * stored value is just a host (cdn.example.com). Tolerant of
322 * `https://cdn.example.com/`, `//cdn.example.com`, or bare host.
323 */
324 public static function normalize_host( string $value ): string {
325 $value = trim( $value );
326 if ( '' === $value ) {
327 return '';
328 }
329 $value = preg_replace( '#^https?://#i', '', $value );
330 $value = preg_replace( '#^//#', '', $value );
331 $value = rtrim( $value, '/' );
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 );
369 }
370
371 private static function prime_origin(): void {
372 $home = function_exists( 'home_url' ) ? home_url() : '';
373 $p = wp_parse_url( $home );
374 if ( is_array( $p ) && ! empty( $p['host'] ) ) {
375 self::$home_host = strtolower( $p['host'] );
376 self::$home_scheme = isset( $p['scheme'] ) ? strtolower( $p['scheme'] ) : 'https';
377 }
378 }
379
380 private static function opts(): array {
381 if ( null === self::$opts ) {
382 self::$opts = function_exists( 'get_option' )
383 ? (array) get_option( 'xspeed_module_cdn', array() )
384 : array();
385 }
386 return self::$opts;
387 }
388 }
389