PluginProbe ʕ •ᴥ•ʔ
LiteSpeed Cache / 7.6.1
LiteSpeed Cache v7.6.1
trunk 1.0.15 1.9.1.1 2.9.9.2 3.6.4 4.6 5.7.0.1 6.5.4 7.0.0.1 7.0.1 7.1 7.2 7.3 7.3.0.1 7.4 7.5 7.5.0.1 7.6 7.6.1 7.6.2 7.7 7.8 7.8.0.1 7.8.1
litespeed-cache / src / cdn.cls.php
litespeed-cache / src Last commit date
cdn 7 months ago data_structure 7 months ago activation.cls.php 7 months ago admin-display.cls.php 7 months ago admin-settings.cls.php 7 months ago admin.cls.php 7 months ago api.cls.php 7 months ago avatar.cls.php 7 months ago base.cls.php 7 months ago cdn.cls.php 7 months ago cloud.cls.php 7 months ago conf.cls.php 7 months ago control.cls.php 7 months ago core.cls.php 7 months ago crawler-map.cls.php 7 months ago crawler.cls.php 7 months ago css.cls.php 7 months ago data.cls.php 7 months ago data.upgrade.func.php 7 months ago db-optm.cls.php 7 months ago debug2.cls.php 7 months ago doc.cls.php 7 months ago error.cls.php 7 months ago esi.cls.php 7 months ago file.cls.php 7 months ago gui.cls.php 7 months ago health.cls.php 7 months ago htaccess.cls.php 7 months ago img-optm.cls.php 7 months ago import.cls.php 7 months ago import.preset.cls.php 7 months ago lang.cls.php 7 months ago localization.cls.php 7 months ago media.cls.php 7 months ago metabox.cls.php 7 months ago object-cache-wp.cls.php 7 months ago object-cache.cls.php 7 months ago object.lib.php 7 months ago optimize.cls.php 7 months ago optimizer.cls.php 7 months ago placeholder.cls.php 7 months ago purge.cls.php 7 months ago report.cls.php 7 months ago rest.cls.php 7 months ago root.cls.php 7 months ago router.cls.php 7 months ago str.cls.php 7 months ago tag.cls.php 7 months ago task.cls.php 7 months ago tool.cls.php 7 months ago ucss.cls.php 7 months ago utility.cls.php 7 months ago vary.cls.php 7 months ago vpi.cls.php 7 months ago
cdn.cls.php
622 lines
1 <?php
2 /**
3 * CDN handling for LiteSpeed Cache.
4 *
5 * Rewrites eligible asset URLs to configured CDN endpoints and integrates with WordPress filters.
6 *
7 * @since 1.2.3
8 * @package LiteSpeed
9 */
10
11 namespace LiteSpeed;
12
13 defined( 'WPINC' ) || exit();
14
15 /**
16 * Class CDN
17 *
18 * Processes page content and WordPress asset URLs to map to CDN domains according to settings.
19 */
20 class CDN extends Root {
21 const LOG_TAG = '[CDN]';
22
23 const BYPASS = 'LITESPEED_BYPASS_CDN';
24
25 /**
26 * The working HTML/content buffer being processed.
27 *
28 * @var string
29 */
30 private $content;
31
32 /**
33 * Whether CDN feature is enabled.
34 *
35 * @var bool
36 */
37 private $_cfg_cdn;
38
39 /**
40 * List of original site URLs (may include wildcards) to be replaced.
41 *
42 * @var string[]
43 */
44 private $_cfg_url_ori;
45
46 /**
47 * List of directories considered internal/original for CDN rewriting.
48 *
49 * @var string[]
50 */
51 private $_cfg_ori_dir;
52
53 /**
54 * CDN mapping rules; keys include mapping kinds or file extensions, values are URL(s).
55 *
56 * @var array<string,string|string[]>
57 */
58 private $_cfg_cdn_mapping = [];
59
60 /**
61 * List of URL substrings/regex used to exclude items from CDN.
62 *
63 * @var string[]
64 */
65 private $_cfg_cdn_exclude;
66
67 /**
68 * Hosts used by CDN mappings for quick membership checks.
69 *
70 * @var string[]
71 */
72 private $cdn_mapping_hosts = [];
73
74 /**
75 * Initialize CDN integration and register filters if enabled.
76 *
77 * @since 1.2.3
78 * @return void
79 */
80 public function init() {
81 self::debug2( 'init' );
82
83 if ( defined( self::BYPASS ) ) {
84 self::debug2( 'CDN bypass' );
85 return;
86 }
87
88 if ( ! Router::can_cdn() ) {
89 if ( ! defined( self::BYPASS ) ) {
90 define( self::BYPASS, true );
91 }
92 return;
93 }
94
95 $this->_cfg_cdn = $this->conf( Base::O_CDN );
96 if ( ! $this->_cfg_cdn ) {
97 if ( ! defined( self::BYPASS ) ) {
98 define( self::BYPASS, true );
99 }
100 return;
101 }
102
103 $this->_cfg_url_ori = $this->conf( Base::O_CDN_ORI );
104 // Parse cdn mapping data to array( 'filetype' => 'url' )
105 $mapping_to_check = [ Base::CDN_MAPPING_INC_IMG, Base::CDN_MAPPING_INC_CSS, Base::CDN_MAPPING_INC_JS ];
106 foreach ( $this->conf( Base::O_CDN_MAPPING ) as $v ) {
107 if ( ! $v[ Base::CDN_MAPPING_URL ] ) {
108 continue;
109 }
110 $this_url = $v[ Base::CDN_MAPPING_URL ];
111 $this_host = wp_parse_url( $this_url, PHP_URL_HOST );
112 // Check img/css/js
113 foreach ( $mapping_to_check as $to_check ) {
114 if ( $v[ $to_check ] ) {
115 self::debug2( 'mapping ' . $to_check . ' -> ' . $this_url );
116
117 // If filetype to url is one to many, make url be an array
118 $this->_append_cdn_mapping( $to_check, $this_url );
119
120 if ( ! in_array( $this_host, $this->cdn_mapping_hosts, true ) ) {
121 $this->cdn_mapping_hosts[] = $this_host;
122 }
123 }
124 }
125 // Check file types
126 if ( $v[ Base::CDN_MAPPING_FILETYPE ] ) {
127 foreach ( $v[ Base::CDN_MAPPING_FILETYPE ] as $v2 ) {
128 $this->_cfg_cdn_mapping[ Base::CDN_MAPPING_FILETYPE ] = true;
129
130 // If filetype to url is one to many, make url be an array
131 $this->_append_cdn_mapping( $v2, $this_url );
132
133 if ( ! in_array( $this_host, $this->cdn_mapping_hosts, true ) ) {
134 $this->cdn_mapping_hosts[] = $this_host;
135 }
136 }
137 self::debug2( 'mapping ' . implode( ',', $v[ Base::CDN_MAPPING_FILETYPE ] ) . ' -> ' . $this_url );
138 }
139 }
140
141 if ( ! $this->_cfg_url_ori || ! $this->_cfg_cdn_mapping ) {
142 if ( ! defined( self::BYPASS ) ) {
143 define( self::BYPASS, true );
144 }
145 return;
146 }
147
148 $this->_cfg_ori_dir = $this->conf( Base::O_CDN_ORI_DIR );
149 // In case user customized upload path
150 if ( defined( 'UPLOADS' ) ) {
151 $this->_cfg_ori_dir[] = UPLOADS;
152 }
153
154 // Check if need preg_replace
155 $this->_cfg_url_ori = Utility::wildcard2regex( $this->_cfg_url_ori );
156
157 $this->_cfg_cdn_exclude = $this->conf( Base::O_CDN_EXC );
158
159 if ( ! empty( $this->_cfg_cdn_mapping[ Base::CDN_MAPPING_INC_IMG ] ) ) {
160 // Hook to srcset
161 if ( function_exists( 'wp_calculate_image_srcset' ) ) {
162 add_filter( 'wp_calculate_image_srcset', [ $this, 'srcset' ], 999 );
163 }
164 // Hook to mime icon
165 add_filter( 'wp_get_attachment_image_src', [ $this, 'attach_img_src' ], 999 );
166 add_filter( 'wp_get_attachment_url', [ $this, 'url_img' ], 999 );
167 }
168
169 if ( ! empty( $this->_cfg_cdn_mapping[ Base::CDN_MAPPING_INC_CSS ] ) ) {
170 add_filter( 'style_loader_src', [ $this, 'url_css' ], 999 );
171 }
172
173 if ( ! empty( $this->_cfg_cdn_mapping[ Base::CDN_MAPPING_INC_JS ] ) ) {
174 add_filter( 'script_loader_src', [ $this, 'url_js' ], 999 );
175 }
176
177 add_filter( 'litespeed_buffer_finalize', [ $this, 'finalize' ], 30 );
178 }
179
180 /**
181 * Associate all filetypes with CDN URL.
182 *
183 * @since 2.0
184 * @access private
185 *
186 * @param string $filetype Mapping key (e.g., extension or mapping constant).
187 * @param string $url CDN base URL to use for this mapping.
188 * @return void
189 */
190 private function _append_cdn_mapping( $filetype, $url ) {
191 // If filetype to url is one to many, make url be an array
192 if ( empty( $this->_cfg_cdn_mapping[ $filetype ] ) ) {
193 $this->_cfg_cdn_mapping[ $filetype ] = $url;
194 } elseif ( is_array( $this->_cfg_cdn_mapping[ $filetype ] ) ) {
195 // Append url to filetype
196 $this->_cfg_cdn_mapping[ $filetype ][] = $url;
197 } else {
198 // Convert _cfg_cdn_mapping from string to array
199 $this->_cfg_cdn_mapping[ $filetype ] = [ $this->_cfg_cdn_mapping[ $filetype ], $url ];
200 }
201 }
202
203 /**
204 * Whether the given type is included in CDN mappings.
205 *
206 * @since 1.6.2.1
207 *
208 * @param string $type 'css' or 'js'.
209 * @return bool True if included in CDN.
210 */
211 public function inc_type( $type ) {
212 if ( 'css' === $type && ! empty( $this->_cfg_cdn_mapping[ Base::CDN_MAPPING_INC_CSS ] ) ) {
213 return true;
214 }
215
216 if ( 'js' === $type && ! empty( $this->_cfg_cdn_mapping[ Base::CDN_MAPPING_INC_JS ] ) ) {
217 return true;
218 }
219
220 return false;
221 }
222
223 /**
224 * Run CDN processing on finalized buffer.
225 * NOTE: After cache finalized, cannot change cache control.
226 *
227 * @since 1.2.3
228 * @access public
229 *
230 * @param string $content The HTML/content buffer.
231 * @return string The processed content.
232 */
233 public function finalize( $content ) {
234 $this->content = $content;
235
236 $this->_finalize();
237 return $this->content;
238 }
239
240 /**
241 * Replace eligible URLs with CDN URLs in the working buffer.
242 *
243 * @since 1.2.3
244 * @access private
245 * @return void
246 */
247 private function _finalize() {
248 if ( defined( self::BYPASS ) ) {
249 return;
250 }
251
252 self::debug( 'CDN _finalize' );
253
254 // Start replacing img src
255 if ( ! empty( $this->_cfg_cdn_mapping[ Base::CDN_MAPPING_INC_IMG ] ) ) {
256 $this->_replace_img();
257 $this->_replace_inline_css();
258 }
259
260 if ( ! empty( $this->_cfg_cdn_mapping[ Base::CDN_MAPPING_FILETYPE ] ) ) {
261 $this->_replace_file_types();
262 }
263 }
264
265 /**
266 * Parse all file types and replace according to configured attributes.
267 *
268 * @since 1.2.3
269 * @access private
270 * @return void
271 */
272 private function _replace_file_types() {
273 $ele_to_check = $this->conf( Base::O_CDN_ATTR );
274
275 foreach ( $ele_to_check as $v ) {
276 if ( ! $v || false === strpos( $v, '.' ) ) {
277 self::debug2( 'replace setting bypassed: no . attribute ' . $v );
278 continue;
279 }
280
281 self::debug2( 'replace attribute ' . $v );
282
283 $v = explode( '.', $v );
284 $attr = preg_quote( $v[1], '#' );
285 if ( $v[0] ) {
286 $pattern = '#<' . preg_quote( $v[0], '#' ) . '([^>]+)' . $attr . '=([\'"])(.+)\g{2}#iU';
287 } else {
288 $pattern = '# ' . $attr . '=([\'"])(.+)\g{1}#iU';
289 }
290
291 preg_match_all( $pattern, $this->content, $matches );
292
293 if (empty($matches[$v[0] ? 3 : 2])) {
294 continue;
295 }
296
297 foreach ($matches[$v[0] ? 3 : 2] as $k2 => $url) {
298 // self::debug2( 'check ' . $url );
299 $postfix = '.' . pathinfo((string) wp_parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
300 if (!array_key_exists($postfix, $this->_cfg_cdn_mapping)) {
301 // self::debug2( 'non-existed postfix ' . $postfix );
302 continue;
303 }
304
305 self::debug2( 'matched file_type ' . $postfix . ' : ' . $url );
306
307 $url2 = $this->rewrite( $url, Base::CDN_MAPPING_FILETYPE, $postfix );
308 if ( ! $url2 ) {
309 continue;
310 }
311
312 $attr_str = str_replace( $url, $url2, $matches[0][ $k2 ] );
313 $this->content = str_replace( $matches[0][ $k2 ], $attr_str, $this->content );
314 }
315 }
316 }
317
318 /**
319 * Parse all images and replace their src attributes.
320 *
321 * @since 1.2.3
322 * @access private
323 * @return void
324 */
325 private function _replace_img() {
326 preg_match_all( '#<img([^>]+?)src=([\'"\\\]*)([^\'"\s\\\>]+)([\'"\\\]*)([^>]*)>#i', $this->content, $matches );
327 foreach ( $matches[3] as $k => $url ) {
328 // Check if is a DATA-URI
329 if ( false !== strpos( $url, 'data:image' ) ) {
330 continue;
331 }
332
333 $url2 = $this->rewrite( $url, Base::CDN_MAPPING_INC_IMG );
334 if ( ! $url2 ) {
335 continue;
336 }
337
338 $html_snippet = sprintf( '<img %1$s src=%2$s %3$s>', $matches[1][ $k ], $matches[2][ $k ] . $url2 . $matches[4][ $k ], $matches[5][ $k ] );
339 $this->content = str_replace( $matches[0][ $k ], $html_snippet, $this->content );
340 }
341 }
342
343 /**
344 * Parse and replace all inline styles containing url().
345 *
346 * @since 1.2.3
347 * @access private
348 * @return void
349 */
350 private function _replace_inline_css() {
351 self::debug2( '_replace_inline_css', $this->_cfg_cdn_mapping );
352
353 /**
354 * Excludes `\` from URL matching
355 *
356 * @see #959152 - WordPress LSCache CDN Mapping causing malformed URLS
357 * @see #685485
358 * @since 3.0
359 */
360 preg_match_all( '/url\((?![\'"]?data)[\'"]?(.+?)[\'"]?\)/i', $this->content, $matches );
361 foreach ( $matches[1] as $k => $url ) {
362 $url = str_replace( [ ' ', '\t', '\n', '\r', '\0', '\x0B', '"', "'", '&quot;', '&#039;' ], '', $url );
363
364 // Parse file postfix
365 $parsed_url = wp_parse_url( $url, PHP_URL_PATH );
366 if ( ! $parsed_url ) {
367 continue;
368 }
369
370 $postfix = '.' . pathinfo( $parsed_url, PATHINFO_EXTENSION );
371 if ( array_key_exists( $postfix, $this->_cfg_cdn_mapping ) ) {
372 self::debug2( 'matched file_type ' . $postfix . ' : ' . $url );
373 $url2 = $this->rewrite( $url, Base::CDN_MAPPING_FILETYPE, $postfix );
374 if ( ! $url2 ) {
375 continue;
376 }
377 } elseif ( in_array( $postfix, [ 'jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'avif' ], true ) ) {
378 $url2 = $this->rewrite( $url, Base::CDN_MAPPING_INC_IMG );
379 if ( ! $url2 ) {
380 continue;
381 }
382 } else {
383 continue;
384 }
385
386 $attr = str_replace( $matches[1][ $k ], $url2, $matches[0][ $k ] );
387 $this->content = str_replace( $matches[0][ $k ], $attr, $this->content );
388 }
389
390 self::debug2( '_replace_inline_css done' );
391 }
392
393 /**
394 * Filter: wp_get_attachment_image_src.
395 *
396 * @since 1.2.3
397 * @since 1.7 Removed static from function.
398 * @access public
399 *
400 * @param array{0:string,1:int,2:int} $img The URL of the attachment image src, the width, the height.
401 * @return array{0:string,1:int,2:int}
402 */
403 public function attach_img_src( $img ) {
404 if ( $img ) {
405 $url = $this->rewrite( $img[0], Base::CDN_MAPPING_INC_IMG );
406 if ( $url ) {
407 $img[0] = $url;
408 }
409 }
410 return $img;
411 }
412
413 /**
414 * Try to rewrite one image URL with CDN.
415 *
416 * @since 1.7
417 * @access public
418 *
419 * @param string $url Original URL.
420 * @return string URL after rewriting, or original if not applicable.
421 */
422 public function url_img( $url ) {
423 if ( $url ) {
424 $url2 = $this->rewrite( $url, Base::CDN_MAPPING_INC_IMG );
425 if ( $url2 ) {
426 $url = $url2;
427 }
428 }
429 return $url;
430 }
431
432 /**
433 * Try to rewrite one CSS URL with CDN.
434 *
435 * @since 1.7
436 * @access public
437 *
438 * @param string $url Original URL.
439 * @return string URL after rewriting, or original if not applicable.
440 */
441 public function url_css( $url ) {
442 if ( $url ) {
443 $url2 = $this->rewrite( $url, Base::CDN_MAPPING_INC_CSS );
444 if ( $url2 ) {
445 $url = $url2;
446 }
447 }
448 return $url;
449 }
450
451 /**
452 * Try to rewrite one JS URL with CDN.
453 *
454 * @since 1.7
455 * @access public
456 *
457 * @param string $url Original URL.
458 * @return string URL after rewriting, or original if not applicable.
459 */
460 public function url_js( $url ) {
461 if ( $url ) {
462 $url2 = $this->rewrite( $url, Base::CDN_MAPPING_INC_JS );
463 if ( $url2 ) {
464 $url = $url2;
465 }
466 }
467 return $url;
468 }
469
470 /**
471 * Filter responsive image sources for CDN.
472 *
473 * @since 1.2.3
474 * @since 1.7 Removed static from function.
475 * @access public
476 *
477 * @param array<int,array{url:string}> $srcs Srcset array.
478 * @return array<int,array{url:string}>
479 */
480 public function srcset( $srcs ) {
481 if ( $srcs ) {
482 foreach ( $srcs as $w => $data ) {
483 $url = $this->rewrite( $data['url'], Base::CDN_MAPPING_INC_IMG );
484 if ( ! $url ) {
485 continue;
486 }
487 $srcs[ $w ]['url'] = $url;
488 }
489 }
490 return $srcs;
491 }
492
493 /**
494 * Replace an URL with mapped CDN URL, if applicable.
495 *
496 * @since 1.2.3
497 * @access public
498 *
499 * @param string $url Target URL.
500 * @param string $mapping_kind Mapping kind (e.g., Base::CDN_MAPPING_INC_IMG or Base::CDN_MAPPING_FILETYPE).
501 * @param string|false $postfix File extension (with dot) when mapping by file type.
502 * @return string|false Replaced URL on success, false when not applicable.
503 */
504 public function rewrite( $url, $mapping_kind, $postfix = false ) {
505 self::debug2( 'rewrite ' . $url );
506 $url_parsed = wp_parse_url( $url );
507
508 if ( empty( $url_parsed['path'] ) ) {
509 self::debug2( '-rewrite bypassed: no path' );
510 return false;
511 }
512
513 // Only images under wp-content/wp-includes can be replaced
514 $is_internal_folder = Utility::str_hit_array( $url_parsed['path'], $this->_cfg_ori_dir );
515 if ( ! $is_internal_folder ) {
516 self::debug2( '-rewrite failed: path not match: ' . LSCWP_CONTENT_FOLDER );
517 return false;
518 }
519
520 // Check if is external url
521 if ( ! empty( $url_parsed['host'] ) ) {
522 if ( ! Utility::internal( $url_parsed['host'] ) && ! $this->_is_ori_url( $url ) ) {
523 self::debug2( '-rewrite failed: host not internal' );
524 return false;
525 }
526 }
527
528 $exclude = Utility::str_hit_array( $url, $this->_cfg_cdn_exclude );
529 if ( $exclude ) {
530 self::debug2( '-abort excludes ' . $exclude );
531 return false;
532 }
533
534 // Fill full url before replacement
535 if ( empty( $url_parsed['host'] ) ) {
536 $url = Utility::uri2url( $url );
537 self::debug2( '-fill before rewritten: ' . $url );
538
539 $url_parsed = wp_parse_url( $url );
540 }
541
542 $scheme = ! empty( $url_parsed['scheme'] ) ? $url_parsed['scheme'] . ':' : '';
543
544 // Find the mapping url to be replaced to
545 if ( empty( $this->_cfg_cdn_mapping[ $mapping_kind ] ) ) {
546 return false;
547 }
548 if ( Base::CDN_MAPPING_FILETYPE !== $mapping_kind ) {
549 $final_url = $this->_cfg_cdn_mapping[ $mapping_kind ];
550 } else {
551 // select from file type
552 $final_url = $this->_cfg_cdn_mapping[ $postfix ];
553 if ( ! $final_url ) {
554 return false;
555 }
556 }
557
558 // If filetype to url is one to many, need to random one
559 if ( is_array( $final_url ) ) {
560 $final_url = $final_url[ array_rand( $final_url ) ];
561 }
562
563 // Now lets replace CDN url
564 foreach ( $this->_cfg_url_ori as $v ) {
565 if ( false !== strpos( $v, '*' ) ) {
566 $url = preg_replace( '#' . $scheme . $v . '#iU', $final_url, $url );
567 } else {
568 $url = str_replace( $scheme . $v, $final_url, $url );
569 }
570 }
571 self::debug2( '-rewritten: ' . $url );
572
573 return $url;
574 }
575
576 /**
577 * Check if the given URL matches any configured "original" URLs for CDN.
578 *
579 * @since 2.1
580 * @access private
581 *
582 * @param string $url URL to test.
583 * @return bool True if URL is one of the originals.
584 */
585 private function _is_ori_url( $url ) {
586 $url_parsed = wp_parse_url( $url );
587
588 $scheme = ! empty( $url_parsed['scheme'] ) ? $url_parsed['scheme'] . ':' : '';
589
590 foreach ( $this->_cfg_url_ori as $v ) {
591 $needle = $scheme . $v;
592 if ( false !== strpos( $v, '*' ) ) {
593 if ( preg_match( '#' . $needle . '#iU', $url ) ) {
594 return true;
595 }
596 } elseif ( 0 === strpos( $url, $needle ) ) {
597 return true;
598 }
599 }
600
601 return false;
602 }
603
604 /**
605 * Check if the host is one of the CDN mapping hosts.
606 *
607 * @since 1.2.3
608 *
609 * @param string $host Hostname to check.
610 * @return bool False when bypassed, otherwise true if internal CDN host.
611 */
612 public static function internal( $host ) {
613 if ( defined( self::BYPASS ) ) {
614 return false;
615 }
616
617 $instance = self::cls();
618
619 return in_array( $host, $instance->cdn_mapping_hosts, true ); // todo: can add $this->_is_ori_url() check in future
620 }
621 }
622