PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.7
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.0.7, at includes/class-cdn-rewriter.php

238 lines 6.9 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
65 // Rewrite src, href, poster, data-src.
66 $html = preg_replace_callback(
67 '#\b(src|href|poster|data-src)\s*=\s*([\'"])([^\'"]+)\2#i',
68 static function ( $m ) use ( $opts ) {
69 $rewritten = self::rewrite_url( $m[3], $opts );
70 return $m[1] . '=' . $m[2] . $rewritten . $m[2];
71 },
72 $html
73 );
74
75 // Rewrite srcset / data-srcset (comma-separated `url 1x, url 2x`).
76 $html = preg_replace_callback(
77 '#\b(srcset|data-srcset)\s*=\s*([\'"])([^\'"]+)\2#i',
78 static function ( $m ) use ( $opts ) {
79 $rewritten = self::rewrite_srcset( $m[3], $opts );
80 return $m[1] . '=' . $m[2] . $rewritten . $m[2];
81 },
82 $html
83 );
84
85 return $html;
86 }
87
88 /**
89 * Public for tests + REST validation. Returns the rewritten URL or
90 * the input unchanged.
91 */
92 public static function rewrite_url( string $url, array $opts ): string {
93 $url = trim( $url );
94 if ( '' === $url ) {
95 return $url;
96 }
97 if ( null === self::$home_host ) {
98 self::prime_origin();
99 }
100 // Skip data:, mailto:, tel:, javascript:, fragments, blob:.
101 if ( preg_match( '#^(data|mailto|tel|javascript|blob|about):#i', $url ) ) {
102 return $url;
103 }
104 if ( '#' === substr( $url, 0, 1 ) ) {
105 return $url;
106 }
107
108 $abs = self::absolutize( $url );
109 if ( null === $abs ) {
110 return $url;
111 }
112
113 // Must be same origin.
114 $parts = wp_parse_url( $abs );
115 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
116 return $url;
117 }
118 if ( strtolower( $parts['host'] ) !== self::$home_host ) {
119 return $url;
120 }
121
122 $path = (string) ( $parts['path'] ?? '' );
123 if ( '' === $path ) {
124 return $url;
125 }
126
127 // Extension whitelist.
128 $included = isset( $opts['included_extensions'] ) && is_array( $opts['included_extensions'] )
129 ? $opts['included_extensions']
130 : self::DEFAULT_EXTENSIONS;
131 $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
132 if ( '' === $ext || ! in_array( $ext, array_map( 'strtolower', $included ), true ) ) {
133 return $url;
134 }
135
136 // Excluded path globs (reuse Glob_Matcher for *.pdf, /cart/*).
137 $excluded = isset( $opts['excluded_patterns'] ) && is_array( $opts['excluded_patterns'] )
138 ? $opts['excluded_patterns']
139 : array();
140 foreach ( $excluded as $pattern ) {
141 if ( '' === $pattern ) {
142 continue;
143 }
144 if ( class_exists( '\\XSpeed\\Glob_Matcher' ) && Glob_Matcher::matches( $pattern, $path ) ) {
145 return $url;
146 }
147 }
148
149 $cdn_host = self::normalize_host( (string) $opts['cdn_url'] );
150 if ( '' === $cdn_host ) {
151 return $url;
152 }
153
154 $query = isset( $parts['query'] ) ? '?' . $parts['query'] : '';
155 $fragment = isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '';
156
157 return self::$home_scheme . '://' . $cdn_host . $path . $query . $fragment;
158 }
159
160 /**
161 * Rewrite each candidate URL inside an srcset descriptor list.
162 */
163 public static function rewrite_srcset( string $srcset, array $opts ): string {
164 $parts = preg_split( '#\s*,\s*#', trim( $srcset ) );
165 if ( ! is_array( $parts ) ) {
166 return $srcset;
167 }
168 $out = array();
169 foreach ( $parts as $candidate ) {
170 $candidate = trim( $candidate );
171 if ( '' === $candidate ) {
172 continue;
173 }
174 // `<url> <descriptor>` — descriptor optional (1x, 2x, 800w).
175 $split = preg_split( '#\s+#', $candidate, 2 );
176 $url = $split[0];
177 $descr = isset( $split[1] ) ? ' ' . $split[1] : '';
178 $rewritten = self::rewrite_url( $url, $opts );
179 $out[] = $rewritten . $descr;
180 }
181 return implode( ', ', $out );
182 }
183
184 /**
185 * Convert relative/scheme-relative URLs to absolute against the site
186 * origin. Returns null if we can't make sense of it.
187 */
188 private static function absolutize( string $url ): ?string {
189 if ( preg_match( '#^https?://#i', $url ) ) {
190 return $url;
191 }
192 if ( 0 === strpos( $url, '//' ) ) {
193 return self::$home_scheme . ':' . $url;
194 }
195 if ( 0 === strpos( $url, '/' ) ) {
196 return self::$home_scheme . '://' . self::$home_host . $url;
197 }
198 // Bare relative paths like `images/x.png` — these would need a
199 // base URL to resolve. The DOM rendering picked one already; we
200 // can't reliably guess. Leave alone.
201 return null;
202 }
203
204 /**
205 * Strip scheme + trailing slash from a user-entered CDN URL so the
206 * stored value is just a host (cdn.example.com). Tolerant of
207 * `https://cdn.example.com/`, `//cdn.example.com`, or bare host.
208 */
209 public static function normalize_host( string $value ): string {
210 $value = trim( $value );
211 if ( '' === $value ) {
212 return '';
213 }
214 $value = preg_replace( '#^https?://#i', '', $value );
215 $value = preg_replace( '#^//#', '', $value );
216 $value = rtrim( $value, '/' );
217 return strtolower( $value );
218 }
219
220 private static function prime_origin(): void {
221 $home = function_exists( 'home_url' ) ? home_url() : '';
222 $p = wp_parse_url( $home );
223 if ( is_array( $p ) && ! empty( $p['host'] ) ) {
224 self::$home_host = strtolower( $p['host'] );
225 self::$home_scheme = isset( $p['scheme'] ) ? strtolower( $p['scheme'] ) : 'https';
226 }
227 }
228
229 private static function opts(): array {
230 if ( null === self::$opts ) {
231 self::$opts = function_exists( 'get_option' )
232 ? (array) get_option( 'xspeed_module_cdn', array() )
233 : array();
234 }
235 return self::$opts;
236 }
237 }
238