PluginProbe
ezCache / 2.5.3
ezCache v2.5.3
2.6.5 2.6.4 2.6.2 2.6.3 2.6.1 2.6.0 2.5.6 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5 2.2.1 2.2.2 trunk 1.2 1.2.1 1.2.2 1.2.3 1.2.4 1.3 1.3.1 1.3.10 1.3.11 All 49 releases
ezcache / includes / Cache.php

Cache.php in ezCache 2.5.3, at includes/Cache.php

1,467 lines 38.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Upress\EzCache;
4
5 use MatthiasMullie\Minify\CSS;
6 use MatthiasMullie\Minify\JS;
7 use RecursiveDirectoryIterator;
8 use RecursiveIteratorIterator;
9 use RegexIterator;
10 use UnexpectedValueException;
11 use Upress\EzCache\BackgroundProcesses\ConvertWebpProcess;
12 use Upress\EzCache\FileOptimizer\CombineGoogleFonts;
13 use Upress\EzCache\FileOptimizer\CssMinifier;
14 use Upress\EzCache\FileOptimizer\CssCombiner;
15 use Upress\EzCache\FileOptimizer\JsMinifier;
16 use Upress\EzCache\FileOptimizer\JsCombiner;
17 use Upress\EzCache\FileOptimizer\WebpConverter;
18 use Upress\EzCache\ThirdParty\Minify_HTML;
19 use Upress\EzCache\Utilities\Logger;
20 use Upress\EzCache\PremiumFeatures;
21
22 class Cache {
23 protected static $instance;
24 protected $settings;
25 protected $cache_start_time;
26 protected $webp_processor;
27 protected $root_cache_dir = WP_CONTENT_DIR . '/cache/ezcache/';
28
29 public static function instance() {
30 if ( ! self::$instance ) {
31 self::$instance = new self();
32 }
33
34 return self::$instance;
35 }
36
37 private function __construct() {
38 $this->settings = Settings::get_settings();
39 $this->webp_processor = new ConvertWebpProcess();
40 $this->cache_start_time = microtime( true );
41 }
42
43 /**
44 * @return string
45 */
46 public function get_default_cache_path() {
47 $hostname = preg_replace( '/:.*$/', '', $this->get_http_host() );
48
49 return $this->root_cache_dir . $hostname . '/';
50 }
51
52 /**
53 * Get the HTTP host
54 *
55 * @return string
56 */
57 public function get_http_host() {
58 if ( ! empty( $_SERVER['HTTP_HOST'] ) ) {
59 $host = function_exists( 'mb_strtolower' ) ? mb_strtolower( $_SERVER['HTTP_HOST'] ) : strtolower( $_SERVER['HTTP_HOST'] );
60
61 return htmlentities( $host );
62 } elseif ( function_exists( 'get_option' ) ) {
63 return (string) parse_url( get_option( 'home' ), PHP_URL_HOST );
64 }
65
66 return '';
67 }
68
69 /**
70 * Check if the current user has a log in cookie set (ie. the user is logged in)
71 * @return bool
72 */
73 public function has_login_cookie() {
74 $cookiehash = '';
75 if ( defined( 'COOKIEHASH' ) ) {
76 $cookiehash = preg_quote( constant( 'COOKIEHASH' ), '|' );
77 }
78
79 $regex = "|^wordpress_logged_in_{$cookiehash}|";
80 if ( defined( 'LOGGED_IN_COOKIE' ) ) {
81 $regex = "|^" . preg_quote( constant( 'LOGGED_IN_COOKIE' ), '|' ) . '|';
82 }
83
84 foreach ( $_COOKIE as $key => $value ) {
85 if ( preg_match( $regex, $key ) ) {
86 return true;
87 }
88 }
89
90 return false;
91 }
92
93 /**
94 * Check if a cookie is set to show that the current user has left comments and saved their data
95 * @return bool
96 */
97 public function has_comment_author_cookie() {
98 $cookiehash = '';
99 if ( defined( 'COOKIEHASH' ) ) {
100 $cookiehash = preg_quote( constant( 'COOKIEHASH' ) );
101 }
102
103 $regex = "/^wp-postpass_{$cookiehash}|^comment_author_{$cookiehash}/";
104
105 foreach ( $_COOKIE as $key => $value ) {
106 if ( preg_match( $regex, $key ) ) {
107 return true;
108 }
109 }
110
111 return false;
112 }
113
114 /**
115 * Check if the request supports gzip compression
116 *
117 * @return bool
118 */
119 public function gzip_accepted() {
120 if ( defined( 'EZCACHE_DISABLE_GZIP' ) && EZCACHE_DISABLE_GZIP ) {
121 return false;
122 }
123
124 return isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) && false !== strpos( $_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip' );
125 }
126
127 /**
128 * Check if the request supports webp images
129 *
130 * @return bool
131 */
132 public function webp_accepted() {
133 return isset( $_SERVER['HTTP_ACCEPT'] ) && false !== strpos( $_SERVER['HTTP_ACCEPT'], 'image/webp' );
134 }
135
136 /**
137 * Check if the request comes from the backend
138 *
139 * @return bool
140 */
141 public function is_backend() {
142 if ( is_admin() ) {
143 return true;
144 }
145
146 $script = isset( $_SERVER['PHP_SELF'] ) ? basename( $_SERVER['PHP_SELF'] ) : '';
147 if ( $script !== 'index.php' ) {
148 if ( in_array( $script, [ 'wp-login.php', 'xmlrpc.php', 'wp-cron.php' ] ) ) {
149 return true;
150 } elseif ( defined( 'DOING_CRON' ) && DOING_CRON ) {
151 return true;
152 } elseif ( PHP_SAPI == 'cli' || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
153 return true;
154 }
155 }
156
157 return false;
158 }
159
160 /**
161 * Return the relative URL based on the url provided or false if the url is not on this website
162 * @param string $url
163 *
164 * @return string|bool
165 */
166 public function get_relative_url( $url ) {
167 $site_url = site_url();
168 if ( false === strpos( $url, $site_url ) ) {
169 if ( preg_match( '`^(https?:)?//([^/]+)(/.*)?$`i', $url, $matches ) ) {
170 $url = isset( $matches[3] ) ? $matches[3] : '';
171 }
172 } else {
173 $url = str_replace( $site_url, '', $url );
174 if ( 0 !== strpos( $url, '/' ) ) {
175 $url = '/' . $url;
176 }
177 }
178
179 if ( preg_match( '/^https?:\/\//i', $url ) ) {
180 return false;
181 }
182
183 return $url;
184 }
185
186 /**
187 * Should we serve the cached file
188 *
189 * @return bool
190 */
191 public function should_serve_cached_data() {
192 // Dev Mode — bypass cache entirely
193 if ( self::is_dev_mode_active() ) {
194 return false;
195 }
196 if ( defined( 'WP_CLI' ) && WP_CLI ) {
197 return false;
198 }
199 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
200 return false;
201 }
202 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
203 return false;
204 }
205 if ( defined( 'JSON_REQUEST' ) && JSON_REQUEST ) {
206 return false;
207 }
208 if ( defined( 'WC_API_REQUEST' ) && WC_API_REQUEST ) {
209 return false;
210 }
211 if ( defined( 'WP_ADMIN' ) && WP_ADMIN ) {
212 return false;
213 }
214 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
215 return false;
216 }
217 if ( defined( 'WP_USE_THEMES' ) && false === WP_USE_THEMES ) {
218 return false;
219 }
220
221 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || ( isset( $_SERVER['REQUEST_METHOD'] ) && in_array( $_SERVER['REQUEST_METHOD'], [
222 'HEAD',
223 'POST',
224 'PUT',
225 'PATCH',
226 'DELETE',
227 ] ) ) || isset( $_GET['customize_changeset_uuid'] ) || isset( $_POST['wp_customize'] ) ) {
228 return false;
229 }
230
231 $settings = $this->settings;
232
233 if ( $settings->no_cache_known_users && $this->has_login_cookie() ) {
234 return false;
235 }
236
237 if ( $settings->no_cache_comment_authors && $this->has_comment_author_cookie() ) {
238 return false;
239 }
240
241 if ( $this->is_backend() ) {
242 return false;
243 }
244
245 // Don't cache with variables but the cache is enabled if the visitor comes from an RSS feed, a Facebook action or Google Adsense tracking
246 if (
247 (
248 $settings->no_cache_query_params
249 && ! empty( $_GET )
250 && ! (
251 isset( $_GET['utm_source'], $_GET['utm_medium'], $_GET['utm_campaign'] )
252 || isset( $_GET['utm_expid'] )
253 || isset( $_GET['fb_action_ids'], $_GET['fb_action_types'], $_GET['fb_source'] )
254 || isset( $_GET['gclid'] )
255 )
256 ) || (
257 isset( $_GET['permalink_name'] )
258 || isset( $_GET['lp-variation-id'] )
259 || isset( $_GET['lang'] )
260 || isset( $_GET['s'] )
261 || isset( $_GET['age-verified'] )
262 || isset( $_GET['ao_noptimize'] )
263 || isset( $_GET['usqp'] )
264 || isset( $_GET['woo_ajax'] )
265 )
266 ) {
267
268 return false;
269 }
270
271 // Don't cache pages where the rejected cookies are defined
272 if ( ! empty( $settings->rejected_cookies ) ) {
273 $rejected_cookies = preg_split( "/\\r\\n|\\r|\\n/u", trim( $settings->rejected_cookies ), - 1, PREG_SPLIT_NO_EMPTY );
274 $rejected_cookies = array_filter( $rejected_cookies );
275
276 if ( preg_match( '#(' . implode( '|', $rejected_cookies ) . ')#', var_export( $_COOKIE, true ) ) ) {
277 return false;
278 }
279 }
280
281 return true;
282 }
283
284 /**
285 * Should we save the cache files.
286 * Most of the checks here have to be run after the page was rendered as they require WordPress.
287 *
288 * @return bool
289 */
290 public function should_save_cache() {
291 global $wp_query;
292
293 if ( ! $this->should_serve_cached_data() ) {
294 return false;
295 }
296
297 $settings = $this->settings;
298
299 // check if we have any errors or otherwise settings preventing caching
300 $error = error_get_last();
301 if ( null !== $error && ( $error['type'] & ( E_ERROR | E_CORE_ERROR | E_PARSE | E_COMPILE_ERROR | E_USER_ERROR ) ) ) {
302 return false;
303 }
304
305 if ( function_exists( 'http_response_code' ) && http_response_code() > 300 ) {
306 return false;
307 }
308
309 if ( is_404() ) {
310 return false;
311 }
312
313 if ( $settings->bypass_cache->single && is_single() ) {
314 return false;
315 }
316 if ( $settings->bypass_cache->pages && is_page() ) {
317 return false;
318 }
319 if ( $settings->bypass_cache->frontpage && is_front_page() ) {
320 return false;
321 }
322 if ( $settings->bypass_cache->home && is_home() ) {
323 return false;
324 }
325 if ( $settings->bypass_cache->archives && is_archive() ) {
326 return false;
327 }
328 if ( $settings->bypass_cache->tag && is_tag() ) {
329 return false;
330 }
331 if ( $settings->bypass_cache->category && is_category() ) {
332 return false;
333 }
334 if ( $settings->bypass_cache->feed && is_feed() ) {
335 return false;
336 }
337 if ( $settings->bypass_cache->search && is_search() ) {
338 return false;
339 }
340 if ( $settings->bypass_cache->author && is_author() ) {
341 return false;
342 }
343 if ( function_exists( 'is_checkout' ) && is_checkout() ) {
344 return false;
345 }
346 if ( function_exists( 'is_cart' ) && is_cart() ) {
347 return false;
348 }
349
350 if ( is_null( $wp_query ) || is_robots() || get_query_var( 'sitemap' ) || get_query_var( 'xsl' ) || get_query_var( 'xml_sitemap' ) ) {
351 return false;
352 }
353
354 if ( isset( $_GET['preview'] ) || isset( $_POST['wp_customize'] ) ) {
355 return false;
356 }
357
358 if ( get_post_meta( get_the_ID(), '_ezcache_do_not_cache_post', true ) ) {
359 return false;
360 }
361
362 // check useragent
363 $rejected_useragents = preg_split( "/\\r\\n|\\r|\\n/u", trim( $settings->rejected_user_agent ), - 1, PREG_SPLIT_NO_EMPTY );
364 $rejected_useragents = array_filter( $rejected_useragents );
365 if ( ! empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
366 foreach ( $rejected_useragents as $ua ) {
367 if ( empty( $ua ) ) {
368 continue;
369 }
370
371 if ( false !== strpos( $_SERVER['HTTP_USER_AGENT'], trim( $ua ) ) ) {
372 return false;
373 }
374 }
375 }
376
377 // check URL
378 $rejected_uris = preg_split( "/\\r\\n|\\r|\\n/u", trim( $settings->rejected_uri ), - 1, PREG_SPLIT_NO_EMPTY );
379 $rejected_uris = array_filter( $rejected_uris );
380 $domain = untrailingslashit( home_url() );
381 if ( ! empty( $_SERVER['REQUEST_URI'] ) ) {
382 foreach ( $rejected_uris as $url ) {
383 $url = str_replace( $domain, '', $url );
384 $url = '/' . trim( $url, '/' );
385 $url = str_replace( [ '\/*', '*' ], [ '\/?.*?', '.*?' ], preg_quote( $url, '/' ) );
386 $url = str_replace( '\/\.*?', '\/.*?', $url );
387 if ( @preg_match( "/^{$url}\/?$/u", urldecode( $_SERVER['REQUEST_URI'] ) ) ) {
388 return false;
389 }
390 }
391 }
392
393 return true;
394 }
395
396 /**
397 * Get the mobile browser name
398 *
399 * @return string
400 */
401 public function detect_mobile() {
402 if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
403 return '';
404 }
405
406 $mobile_browsers = apply_filters( 'ezcache_mobile_browsers', [
407 '2.0 MMP',
408 '240x320',
409 '400X240',
410 'AvantGo',
411 'BlackBerry',
412 'Blazer',
413 'Cellphone',
414 'Danger',
415 'DoCoMo',
416 'Elaine/3.0',
417 'EudoraWeb',
418 'Googlebot-Mobile',
419 'hiptop',
420 'IEMobile',
421 'KYOCERA/WX310K',
422 'LG/U990',
423 'MIDP-2.',
424 'MMEF20',
425 'MOT-V',
426 'NetFront',
427 'Newt',
428 'Nintendo Wii',
429 'Nitro',
430 'Nokia',
431 'Opera Mini',
432 'Palm',
433 'PlayStation Portable',
434 'portalmmm',
435 'Proxinet',
436 'ProxiNet',
437 'SHARP-TQ-GX10',
438 'SHG-i900',
439 'Small',
440 'SonyEricsson',
441 'Symbian OS',
442 'SymbianOS',
443 'TS21i-10',
444 'UP.Browser',
445 'UP.Link',
446 'webOS',
447 'Windows CE',
448 'WinWAP',
449 'YahooSeeker/M1A1-R2D2',
450 'iPhone',
451 'iPod',
452 'iPad',
453 'Android',
454 'BlackBerry9530',
455 'LG-TU915 Obigo',
456 'LGE VX',
457 'webOS',
458 'Nokia5800',
459 ] );
460 $user_agent = strtolower( $_SERVER['HTTP_USER_AGENT'] );
461 foreach ( $mobile_browsers as $browser ) {
462 if ( strstr( $user_agent, trim( strtolower( $browser ) ) ) ) {
463 return $user_agent;
464 }
465 }
466
467 if ( isset( $_SERVER['HTTP_X_WAP_PROFILE'] ) ) {
468 return $_SERVER['HTTP_X_WAP_PROFILE'];
469 }
470
471 if ( isset( $_SERVER['HTTP_PROFILE'] ) ) {
472 return $_SERVER['HTTP_PROFILE'];
473 }
474
475 $browser_prefixes = apply_filters( 'ezcache_mobile_browser_prefixes', [
476 'w3c',
477 'w3c-',
478 'acs-',
479 'alav',
480 'alca',
481 'amoi',
482 'audi',
483 'avan',
484 'benq',
485 'bird',
486 'blac',
487 'blaz',
488 'brew',
489 'cell',
490 'cldc',
491 'cmd-',
492 'dang',
493 'doco',
494 'eric',
495 'hipt',
496 'htc_',
497 'inno',
498 'ipaq',
499 'ipod',
500 'jigs',
501 'kddi',
502 'keji',
503 'leno',
504 'lg-c',
505 'lg-d',
506 'lg-g',
507 'lge-',
508 'lg/u',
509 'maui',
510 'maxo',
511 'midp',
512 'mits',
513 'mmef',
514 'mobi',
515 'mot-',
516 'moto',
517 'mwbp',
518 'nec-',
519 'newt',
520 'noki',
521 'palm',
522 'pana',
523 'pant',
524 'phil',
525 'play',
526 'port',
527 'prox',
528 'qwap',
529 'sage',
530 'sams',
531 'sany',
532 'sch-',
533 'sec-',
534 'send',
535 'seri',
536 'sgh-',
537 'shar',
538 'sie-',
539 'siem',
540 'smal',
541 'smar',
542 'sony',
543 'sph-',
544 'symb',
545 't-mo',
546 'teli',
547 'tim-',
548 'tosh',
549 'tsm-',
550 'upg1',
551 'upsi',
552 'vk-v',
553 'voda',
554 'wap-',
555 'wapa',
556 'wapi',
557 'wapp',
558 'wapr',
559 'webc',
560 'winw',
561 'winw',
562 'xda',
563 'xda-',
564 ] );
565 foreach ( $browser_prefixes as $prefix ) {
566 if ( substr( $user_agent, 0, 4 ) == $prefix ) {
567 return $prefix;
568 }
569 }
570
571 $accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? strtolower( $_SERVER['HTTP_ACCEPT'] ) : '';
572 if ( strpos( $accept, 'wap' ) !== false ) {
573 return 'wap';
574 }
575
576 if ( isset( $_SERVER['ALL_HTTP'] ) && false !== strpos( strtolower( $_SERVER['ALL_HTTP'] ), 'operamini' ) ) {
577 return 'operamini';
578 }
579
580 return '';
581 }
582
583 /**
584 * Search & replace in a string
585 *
586 * @param string|string[] $search
587 * @param string $subject
588 *
589 * @return string
590 */
591 public function deep_replace( $search, $subject ) {
592 $subject = (string) $subject;
593
594 $count = 1;
595 while ( $count ) {
596 $subject = str_replace( $search, '', $subject, $count );
597 }
598
599 return $subject;
600 }
601
602 /**
603 * Get the cache directory URL for the current post
604 *
605 * @param int $post_id
606 *
607 * @param null|string $url
608 *
609 * @return mixed|string
610 */
611 public function get_current_url_cache_dir( $post_id = 0, $url = null ) {
612 static $url_cache_dir = [];
613
614 if ( isset( $url_cache_dir[ $post_id ] ) ) {
615 return $url_cache_dir[ $post_id ];
616 }
617
618 $uri = strtolower( $url ? ( '/' . ltrim( $url, '/' ) ) : $_SERVER['REQUEST_URI'] );
619
620 $DONOTREMEMBER = 0;
621 if ( 0 !== $post_id ) {
622 $site_url = site_url();
623 $permalink = get_permalink( $post_id );
624 if ( false === strpos( $permalink, $site_url ) ) {
625 $DONOTREMEMBER = 1;
626 if ( preg_match( '`^(https?:)?//([^/]+)(/.*)?$`i', $permalink, $matches ) ) {
627 $uri = isset( $matches[3] ) ? $matches[3] : '';
628 } elseif ( preg_match( '`^/([^/]+)(/.*)?$`i', $permalink, $matches ) ) {
629 $uri = $permalink;
630 } else {
631 $uri = '';
632 }
633 } else {
634 $uri = str_replace( $site_url, '', $permalink );
635 if ( 0 !== strpos( $uri, '/' ) ) {
636 $uri = '/' . $uri;
637 }
638 }
639 }
640
641 $uri = $this->deep_replace(
642 [
643 '..',
644 '\\',
645 'index.php',
646 ],
647 preg_replace(
648 '/[ <>\'\"\r\n\t()]/',
649 '',
650 preg_replace( "/(\?.*)?(#.*)?$/", '', $uri )
651 )
652 );
653
654 $uri = md5( $uri );
655 $dir = str_replace( '..', '', str_replace( '//', '/', $uri . '/' ) );
656
657 if ( $DONOTREMEMBER == 0 ) {
658 $url_cache_dir[ $post_id ] = $dir;
659 }
660
661 return $dir;
662 }
663
664 /**
665 * Get the cache directory path
666 *
667 * @param int $postid
668 *
669 * @param null|string $url
670 *
671 * @return string
672 */
673 public function get_real_cache_dir( $postid = 0, $url = null ) {
674 return $this->get_default_cache_path() . $this->get_current_url_cache_dir( $postid, $url );
675 }
676
677 /**
678 * Get the full cache file path
679 *
680 * @param int $postid
681 *
682 * @param null|string $url
683 *
684 * @return string
685 */
686 public function get_cache_file_path( $postid = 0, $url = null ) {
687 return $this->get_real_cache_dir( $postid, $url ) . $this->get_cache_filename();
688 }
689
690 /**
691 * Get the filename for the cached file
692 *
693 * @return string
694 */
695 public function get_cache_filename() {
696 $settings = $this->settings;
697
698 // Add support for https and http caching
699 // also supports https requests coming from an nginx reverse proxy
700 $is_https = ( ( isset( $_SERVER['HTTPS'] ) && 'on' == strtolower( $_SERVER['HTTPS'] ) ) || ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' == strtolower( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ) );
701 $extra_str = $is_https ? '-https' : '';
702
703 if ( $settings->separate_mobile_cache ) {
704 $mobile_ua = $this->detect_mobile();
705 if ( ! empty( $mobile_ua ) ) {
706 $extra_str .= '-mobile';
707 }
708 }
709
710 if ( $settings->enable_webp_support && $this->webp_accepted() ) {
711 $extra_str .= '-webp';
712 }
713
714 $filename = 'index';
715 if ( ! empty( $_SERVER['QUERY_STRING'] ) ) {
716 $filename = md5( $_SERVER['QUERY_STRING'] );
717 }
718
719 return $filename . $extra_str . '.html';
720 }
721
722 /**
723 * Check if we have a cached file and serve it
724 */
725 public function maybe_serve_cached_data() {
726 if ( ! $this->should_serve_cached_data() ) {
727 return;
728 }
729
730 // ── Redis Full-Page Cache fast path ────────────────────
731 // When enabled, try Redis first. A hit is sub-millisecond and skips
732 // the disk read entirely. On miss we fall through to the disk path
733 // below (and the response handler in maybe_write_cache_file will
734 // populate Redis for next time).
735 if (
736 ! empty( $this->settings->enable_redis_fullpage )
737 && class_exists( '\\Upress\\EzCache\\RedisObjectCache' )
738 ) {
739 $current_url = ( is_ssl() ? 'https://' : 'http://' )
740 . ( $_SERVER['HTTP_HOST'] ?? '' )
741 . ( $_SERVER['REQUEST_URI'] ?? '/' );
742 $cached_html = \Upress\EzCache\RedisObjectCache::get_page( $current_url );
743 if ( false !== $cached_html && '' !== $cached_html ) {
744 header( 'X-Cached-With: ezCache (Redis)' );
745 header( 'Vary: Accept-Encoding, Cookie' );
746 echo $cached_html;
747 exit;
748 }
749 }
750
751 $cache_file = $this->get_cache_file_path();
752 $gzip_accepted = $this->gzip_accepted();
753
754 $cache_file = $cache_file . '.gz';
755 $filesize = file_exists( $cache_file ) ? @filesize( $cache_file ) : false;
756
757 if ( ! $filesize ) {
758 // the file is empty, we have nothing to serve
759 return;
760 }
761
762 header( "X-Cached-With: ezCache" );
763 header( "Vary: Accept-Encoding, Cookie" );
764 header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', filemtime( $cache_file ) ) . ' GMT' );
765
766 // Getting If-Modified-Since headers sent by the client.
767 if ( function_exists( 'apache_request_headers' ) ) {
768 $headers = apache_request_headers();
769 $http_if_modified_since = ( isset( $headers['If-Modified-Since'] ) ) ? $headers['If-Modified-Since'] : '';
770 } else {
771 $http_if_modified_since = ( isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ? $_SERVER['HTTP_IF_MODIFIED_SINCE'] : '';
772 }
773
774 // Checking if the client is validating his cache and if it is current.
775 if ( $http_if_modified_since && ( strtotime( $http_if_modified_since ) === @filemtime( $cache_file ) ) ) {
776 // Client's cache is current, so we just respond '304 Not Modified'.
777 header( $_SERVER['SERVER_PROTOCOL'] . ' 304 Not Modified', true, 304 );
778 exit;
779 }
780
781 // Serve the cache if file isn't store in the client browser cache.
782 // if the browser does not support gzip read the file and output it without gzip encoding
783 if ( ! $gzip_accepted ) {
784 readgzfile( $cache_file );
785 exit;
786 }
787
788 // otherwise output the gzipped file as-is
789 header( "Content-Length: {$filesize}" );
790 header( "Content-Encoding: gzip" );
791 readfile( $cache_file );
792 exit;
793 }
794
795 public function do_frontend_optimizations() {
796 if ( ! $this->should_serve_cached_data() ) {
797 return;
798 }
799
800 $settings = $this->settings;
801
802 if ( isset( $settings->disable_wp_emoji ) && $settings->disable_wp_emoji ) {
803 add_action( 'init', function () {
804 remove_action( 'admin_print_styles', 'print_emoji_styles' );
805 remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
806 remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
807 remove_action( 'wp_print_styles', 'print_emoji_styles' );
808 remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' );
809 remove_filter( 'the_content_feed', 'wp_staticize_emoji' );
810 remove_filter( 'comment_text_rss', 'wp_staticize_emoji' );
811 add_filter( 'emoji_svg_url', '__return_false' );
812 }, 999 );
813 }
814
815
816 if ( ! empty( $settings->critical_css ) ) {
817 add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_critical_css' ], PHP_INT_MAX );
818 }
819 }
820
821 public function enqueue_critical_css() {
822 wp_register_style( 'ezcache-critical-css', false );
823 wp_enqueue_style( 'ezcache-critical-css' );
824 wp_add_inline_style( 'ezcache-critical-css', $this->settings->critical_css );
825 }
826
827 /**
828 * Write cache file if we need to
829 * @noinspection PhpUnused
830 */
831 public function maybe_write_cache_file() {
832 if ( ! $this->should_serve_cached_data() ) {
833 return;
834 }
835
836 ob_start( [ $this, 'optimize_and_write_cache_file' ] );
837 }
838
839 /**
840 * Optimize output and write the buffer to the cache file
841 *
842 * @param string $buffer
843 *
844 * @return string
845 */
846 public function optimize_and_write_cache_file( $buffer ) {
847 global $wpdb;
848
849 // we need these check to run after WordPress is finished preparing the page
850 if ( ! $this->should_save_cache() ) {
851 return $buffer;
852 }
853
854 $real_cache_dir = $this->get_real_cache_dir();
855 $cache_file = $this->get_cache_file_path() . '.gz';
856 $asset_cache_dir = $this->get_default_cache_path() . 'min/';
857 $asset_cache_url = trailingslashit( trailingslashit( get_site_url() ) . trim( str_replace( dirname( WP_CONTENT_DIR ), '', $asset_cache_dir ), '/' ) );
858 $settings = $this->settings;
859
860 if ( $settings->optimize_google_fonts ) {
861 $optimizer = new CombineGoogleFonts();
862 $buffer = $optimizer->optimize( $buffer );
863 }
864
865 if ( $settings->minify_css ) {
866 if ( $settings->combine_css ) {
867 $optimizer = new CssCombiner( $asset_cache_dir, $asset_cache_url, $settings->combine_css_footer );
868 } else {
869 $optimizer = new CssMinifier( $asset_cache_dir, $asset_cache_url );
870 }
871
872 $buffer = $optimizer->optimize( $buffer );
873 }
874
875 if ( $settings->minify_js ) {
876 if ( $settings->combine_head_js ) {
877 $optimizer = new JsCombiner( $asset_cache_dir, $asset_cache_url, 'head', $settings->combine_head_inline_js );
878 $buffer = $optimizer->optimize( $buffer );
879 }
880
881 if ( $settings->combine_body_js ) {
882 $optimizer = new JsCombiner( $asset_cache_dir, $asset_cache_url, 'body', $settings->combine_body_inline_js );
883 $buffer = $optimizer->optimize( $buffer );
884 }
885
886 if ( ! $settings->combine_head_js && ! $settings->combine_body_js ) {
887 $optimizer = new JsMinifier( $asset_cache_dir, $asset_cache_url );
888 $buffer = $optimizer->optimize( $buffer );
889 }
890 }
891
892 if ( $settings->minify_html ) {
893 wp_raise_memory_limit( 'image' );
894
895 $buffer = Minify_HTML::minify( $buffer, [
896 'htmlCleanComments' => $settings->minify_html_comments,
897
898 'cssMinifier' => function ( $css ) use ( $settings ) {
899 if ( ! $settings->minify_inline_css ) {
900 return $css;
901 }
902
903 $minifier = new CSS( $css );
904 $minifier->setMaxImportSize( 0 );
905 $minifier->setImportExtensions( [] );
906
907 return $minifier->minify();
908 },
909
910 'jsMinifier' => function ( $js ) use ( $settings ) {
911 if ( ! $settings->minify_inline_js ) {
912 return $js;
913 }
914
915 $minifier = new JS( $js );
916
917 return $minifier->minify();
918 },
919 ] );
920 }
921
922 if ( $settings->enable_webp_support && $this->webp_accepted() ) {
923 $optimizer = new WebpConverter( $real_cache_dir, $cache_file, $this->webp_processor, $wpdb );
924 $buffer = $optimizer->optimize( $buffer );
925 }
926
927 $buffer = trim( $buffer );
928 if ( empty( $buffer ) ) {
929 Logger::log( 'ezCache will not save cache file for a blank page' );
930
931 return $buffer;
932 }
933
934 if ( ! apply_filters( 'wp_bost_hide_cache_time_comment', false ) ) {
935 $total_time = number_format( microtime( true ) - $this->cache_start_time, 2 );
936 $cache_type = ( \Upress\EzCache\Settings::get_settings()->enable_redis_fullpage ?? false ) ? 'Redis' : 'Disk';
937 $buffer .= "\n<!-- Cached by ezCache | Full-Page Cache: {$cache_type} | Generated: " . date('Y-m-d H:i:s') . " | Time: {$total_time}s -->";
938 }
939
940 $buffer = apply_filters( 'ezcache_before_save_cache', $buffer );
941
942 // ── Redis Full-Page Cache write ───────────────────────
943 // Mirror the cached HTML to Redis when the flag is on. TTL matches
944 // the disk-cache lifetime so both backends expire in sync.
945 if (
946 ! empty( $settings->enable_redis_fullpage )
947 && class_exists( '\\Upress\\EzCache\\RedisObjectCache' )
948 ) {
949 $current_url = ( is_ssl() ? 'https://' : 'http://' )
950 . ( $_SERVER['HTTP_HOST'] ?? '' )
951 . ( $_SERVER['REQUEST_URI'] ?? '/' );
952 $ttl = ! empty( $settings->cache_lifetime ) ? (int) $settings->cache_lifetime : 604800;
953 \Upress\EzCache\RedisObjectCache::set_page( $current_url, $buffer, $ttl );
954 }
955
956 if ( ! file_exists( $real_cache_dir ) ) {
957 if ( ! @wp_mkdir_p( $real_cache_dir ) ) {
958 Logger::log( 'ezCache could not create directory ' . $real_cache_dir );
959
960 return $buffer;
961 }
962 }
963
964 // write gzipped file
965 $handle = @fopen( $cache_file, 'w' );
966
967 if ( $handle && @flock( $handle, LOCK_EX ) ) {
968 fwrite( $handle, gzencode( $buffer, 6, FORCE_GZIP ) );
969 flock( $handle, LOCK_UN );
970 } else {
971 Logger::log( 'ezCache could not write to ' . str_replace( ABSPATH, '', $cache_file ) );
972 }
973
974 if ( $handle ) {
975 fclose( $handle );
976 }
977
978 return $buffer;
979 }
980
981 /**
982 * Delete a path recursively
983 *
984 * @param string $path
985 */
986 public function rmdir_recursive( $path ) {
987 if ( ! file_exists( $path ) ) {
988 return;
989 }
990
991 $files = glob( $path . '/*' );
992 foreach ( $files as $file ) {
993 if ( file_exists( $file ) && is_dir( $file ) ) {
994 $this->rmdir_recursive( $file );
995 } elseif ( file_exists( $file ) ) {
996 unlink( $file );
997 }
998 }
999
1000 rmdir( $path );
1001 }
1002
1003 /**
1004 * Preload the homepage and immediately create cache for it
1005 */
1006 public function preload_homepage() {
1007 $desktop_ua = apply_filters(
1008 'ezcache_desktop_useragent',
1009 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36 (ezCache Preload)'
1010 );
1011 $mobile_ua = apply_filters(
1012 'ezcache_mobile_useragent',
1013 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/ 604.1.21 (KHTML, like Gecko) Version/ 12.0 Mobile/17A6278a Safari/602.1.26 (ezCache Preload)'
1014 );
1015
1016 wp_safe_remote_get( site_url(), [
1017 'user-agent' => $desktop_ua,
1018 'timeout' => 0.1,
1019 ] );
1020
1021 wp_safe_remote_get( site_url(), [
1022 'user-agent' => $mobile_ua,
1023 'timeout' => 0.1,
1024 ] );
1025 }
1026
1027 function delete_missing_webp_images( $delete_all = false ) {
1028 global $wpdb;
1029
1030 // delete the actual files
1031 $ids = [ 0 ];
1032 $where = $delete_all ? '' : "WHERE `status` = 'completed'";
1033 $images = $wpdb->get_results( "SELECT * FROM `{$wpdb->prefix}ezcache_webp_images` {$where}" );
1034 foreach ( $images as $image ) {
1035 if ( ! file_exists( $image->webp_path ) ) {
1036 $ids[] = $image->id;
1037 } elseif ( ( $delete_all || ! file_exists( $image->path ) ) && file_exists( $image->webp_path ) ) {
1038 unlink( $image->webp_path );
1039 $ids[] = $image->id;
1040 }
1041 }
1042
1043 // clean the database
1044 $wpdb->query(
1045 $wpdb->prepare(
1046 "DELETE FROM `{$wpdb->prefix}ezcache_webp_images` WHERE `status` = 'failed' OR `id` IN ( " . substr( str_repeat( "%d, ", count( $ids ) ), 0, - 2 ) . " )",
1047 $ids
1048 )
1049 );
1050
1051 $wpdb->query( "OPTIMIZE TABLE `{$wpdb->prefix}ezcache_webp_images`" );
1052 }
1053
1054 function delete_all_webp_images() {
1055 $this->delete_missing_webp_images( true );
1056 }
1057
1058 /**
1059 * Clear all caches
1060 *
1061 * @param bool $clear_webp Should deleting cache clear the WebP images
1062 */
1063 public function clear_cache( $clear_webp = false ) {
1064 $this->rmdir_recursive( $this->root_cache_dir );
1065 @wp_mkdir_p( $this->root_cache_dir );
1066
1067 if ( $clear_webp ) {
1068 $this->delete_all_webp_images();
1069 } else {
1070 $this->delete_missing_webp_images();
1071 }
1072
1073 $this->purge_varnish_cache();
1074
1075 // Also flush Redis (both object cache and full-page keys live under ezcache:*).
1076 // If Redis is disabled or unavailable this is a no-op.
1077 if ( class_exists( '\\Upress\\EzCache\\RedisObjectCache' ) ) {
1078 \Upress\EzCache\RedisObjectCache::flush();
1079 }
1080
1081 $this->preload_homepage();
1082
1083 /**
1084 * Fires after the entire cache has been cleared.
1085 * Used by the Preload module to start a fresh preload run.
1086 */
1087 do_action( 'ezcache_after_clear_cache' );
1088 }
1089
1090 /**
1091 * Clear cache for a single post
1092 *
1093 * @param int $post_id
1094 */
1095 public function clear_cache_single( $post_id ) {
1096 $real_cache_dir = $this->get_real_cache_dir( $post_id );
1097
1098 $this->rmdir_recursive( $real_cache_dir );
1099
1100 $this->purge_varnish_cache();
1101
1102 // Remove the matching Redis full-page key so the next request rebuilds.
1103 if ( class_exists( '\\Upress\\EzCache\\RedisObjectCache' ) ) {
1104 $url = get_permalink( $post_id );
1105 if ( $url ) {
1106 \Upress\EzCache\RedisObjectCache::delete_page( $url );
1107 }
1108 }
1109
1110 /**
1111 * Fires after a single post's cache has been cleared.
1112 *
1113 * @param int $post_id
1114 */
1115 do_action( 'ezcache_after_clear_cache_single', $post_id );
1116 }
1117
1118 public function clear_cache_url( $url ) {
1119 $real_cache_dir = $this->get_real_cache_dir( 0, $url );
1120
1121 $this->rmdir_recursive( $real_cache_dir );
1122
1123 $this->purge_varnish_cache();
1124
1125 if ( class_exists( '\\Upress\\EzCache\\RedisObjectCache' ) ) {
1126 \Upress\EzCache\RedisObjectCache::delete_page( $url );
1127 }
1128
1129 /**
1130 * Fires after a URL's cache has been cleared.
1131 *
1132 * @param string $url
1133 */
1134 do_action( 'ezcache_after_clear_cache_url', $url );
1135 }
1136
1137 public function purge_varnish_cache() {
1138 // Whether Varnish PURGE is enabled (on by default). Can be turned off from
1139 // the settings screen on servers where Varnish is not in the request path,
1140 // to avoid generating needless 403 noise in the logs.
1141 $enabled = ! isset( $this->settings->enable_varnish_purge ) || ! empty( $this->settings->enable_varnish_purge );
1142
1143 /**
1144 * Filters whether ezCache should send a PURGE request to Varnish.
1145 *
1146 * Return false to skip the PURGE entirely.
1147 *
1148 * @param bool $enabled Whether the PURGE request should be sent.
1149 */
1150 if ( ! apply_filters( 'ezcache_should_purge_varnish', $enabled ) ) {
1151 return;
1152 }
1153
1154 $desktop_ua = apply_filters(
1155 'ezcache_desktop_useragent',
1156 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36 (ezCache Preload)'
1157 );
1158
1159 $parseUrl = parse_url( home_url() );
1160
1161 $schema = 'http://';
1162 if ( isset( $parseUrl['scheme'] ) ) {
1163 $schema = $parseUrl['scheme'] . '://';
1164 }
1165
1166 $host = $parseUrl['host'];
1167
1168 // Send the PURGE to the local Varnish instance over loopback rather than to
1169 // the public host. The public hostname is preserved in the Host header so
1170 // Varnish still matches the right cache objects, while the request originates
1171 // from 127.0.0.1 — which is what Varnish/nginx PURGE ACLs typically allow,
1172 // avoiding the public 403 errors seen when the request leaves and re-enters
1173 // the server via its public IP.
1174 $purge_host = apply_filters( 'ezcache_varnish_purge_host', '127.0.0.1' );
1175
1176 $request_args = [
1177 'method' => 'PURGE',
1178 'headers' => [
1179 'Host' => $host,
1180 'User-Agent' => $desktop_ua,
1181 ],
1182 'sslverify' => false,
1183 ];
1184 $response = wp_remote_request( $schema . $purge_host . '/.*', $request_args );
1185 if ( is_wp_error( $response ) || $response['response']['code'] != '200' ) {
1186 if ( $schema === 'https://' ) {
1187 $schema = 'http://';
1188 } else {
1189 $schema = 'https://';
1190 }
1191 wp_remote_request( $schema . $purge_host . '/.*', $request_args );
1192 }
1193 }
1194
1195 /**
1196 * Delete expired cache
1197 */
1198 public function clear_expired_cache() {
1199 $settings = $this->settings;
1200
1201 try {
1202 $dir = new RecursiveDirectoryIterator( $this->root_cache_dir );
1203 $iterator = new RecursiveIteratorIterator( $dir );
1204 $files = new RegexIterator( $iterator, '/^.+\.(?:gz|html|js|css)$/i', RegexIterator::GET_MATCH );
1205 } catch ( UnexpectedValueException $ex ) {
1206 if ( strpos( $ex->getMessage(), 'No such file or directory' ) ) {
1207 $files = [];
1208 } else {
1209 throw $ex;
1210 }
1211 }
1212
1213 foreach ( $files as $file ) {
1214 if ( is_array( $file ) ) {
1215 $file = array_shift( $file );
1216 }
1217
1218 $stats = stat( $file );
1219 if ( $stats['mtime'] > ( time() - $settings->cache_lifetime ) ) {
1220 // skip not expired files
1221 continue;
1222 }
1223
1224 @unlink( $file );
1225 }
1226 }
1227
1228 /**
1229 * Get caching statistics and file sizes
1230 * @return array
1231 */
1232 public function get_cache_stats() {
1233 $settings = $this->settings;
1234 $cache_dir = $this->get_default_cache_path();
1235
1236 try {
1237 $dir = new RecursiveDirectoryIterator( $cache_dir );
1238 $iterator = new RecursiveIteratorIterator( $dir );
1239 $files = new RegexIterator( $iterator, '/^.+\.(?:gz|html|css|js)$/i', RegexIterator::GET_MATCH );
1240 } catch ( UnexpectedValueException $ex ) {
1241 if ( strpos( $ex->getMessage(), 'No such file or directory' ) ) {
1242 $files = [];
1243 } else {
1244 throw $ex;
1245 }
1246 }
1247
1248 $raw_data = [];
1249
1250 $mobile_count = 0;
1251 $mobile_size = 0;
1252 $mobile_expired_count = 0;
1253 $mobile_expired_size = 0;
1254 $desktop_count = 0;
1255 $desktop_size = 0;
1256 $desktop_expired_count = 0;
1257 $desktop_expired_size = 0;
1258 $js_count = 0;
1259 $js_size = 0;
1260 $js_expired_count = 0;
1261 $js_expired_size = 0;
1262 $css_count = 0;
1263 $css_size = 0;
1264 $css_expired_count = 0;
1265 $css_expired_size = 0;
1266
1267 foreach ( $files as $file ) {
1268 if ( is_array( $file ) ) {
1269 $file = array_shift( $file );
1270 }
1271
1272 $stats = stat( $file );
1273 $expired = $stats['mtime'] <= ( time() - $settings->cache_lifetime );
1274
1275 $raw_data[] = [
1276 'path' => $file,
1277 'stats' => $stats,
1278 'expired' => $expired,
1279 ];
1280
1281 if ( preg_match( '/^.+?-mobile\.html(\.gz)?$/i', $file ) ) {
1282 if ( ! $expired ) {
1283 $mobile_count ++;
1284 $mobile_size += $stats['size'];
1285 } else {
1286 $mobile_expired_count ++;
1287 $mobile_expired_size += $stats['size'];
1288 }
1289 } elseif ( preg_match( '/\.css$/i', $file ) ) {
1290 if ( $expired ) {
1291 $css_expired_count ++;
1292 $css_expired_size += $stats['size'];
1293 } else {
1294 $css_count ++;
1295 $css_size += $stats['size'];
1296 }
1297 } elseif ( preg_match( '/\.js$/i', $file ) ) {
1298 if ( $expired ) {
1299 $js_expired_count ++;
1300 $js_expired_size += $stats['size'];
1301 } else {
1302 $js_count ++;
1303 $js_size += $stats['size'];
1304 }
1305 } else {
1306 if ( ! $expired ) {
1307 $desktop_count ++;
1308 $desktop_size += $stats['size'];
1309 } else {
1310 $desktop_expired_count ++;
1311 $desktop_expired_size += $stats['size'];
1312 }
1313 }
1314 }
1315
1316 // we want to count only the number of pages which have cache, but we have 2 files for each page
1317 $mobile_count = $mobile_count / 2;
1318 $desktop_count = $desktop_count / 2;
1319
1320
1321 global $wpdb;
1322 $webp_images = 0;
1323 $webp_images_size = 0;
1324 $webp_images_original_size = 0;
1325
1326 $results = $wpdb->get_row( "SELECT COUNT(*) AS total, SUM(`original_size`) AS total_original_size, SUM(`webp_size`) AS total_webp_size FROM `{$wpdb->prefix}ezcache_webp_images` WHERE `status` = 'completed'" );
1327 if ( $results ) {
1328 $webp_images = intval( $results->total );
1329 $webp_images_size = intval( $results->total_webp_size );
1330 $webp_images_original_size = intval( $results->total_original_size );
1331 }
1332
1333 return compact( 'webp_images', 'webp_images_original_size', 'webp_images_size', 'mobile_count', 'desktop_count', 'mobile_expired_count', 'desktop_expired_count', 'mobile_size', 'desktop_size', 'mobile_expired_size', 'desktop_expired_size', 'css_size', 'css_count', 'js_count', 'js_size', 'css_expired_count', 'css_expired_size', 'js_expired_count', 'js_expired_size' );
1334 }
1335
1336 /**
1337 * Get the status of the cache
1338 *
1339 * @return array
1340 */
1341 public function get_status() {
1342 global $wpdb;
1343
1344 $wp_cache_enabled = defined( 'WP_CACHE' ) && WP_CACHE;
1345 $adv_cache_exists = file_exists( WP_CONTENT_DIR . '/advanced-cache.php' );
1346 $correct_advanced_cache = $adv_cache_exists && strpos( file_get_contents( WP_CONTENT_DIR . '/advanced-cache.php' ), 'ezCache Advanced Cache' ) !== false;
1347 $webp_table_exists = ! is_null( $wpdb->get_row( "SHOW TABLES LIKE '{$wpdb->prefix}ezcache_webp_images'" ) );
1348
1349 return [
1350 'cache_enabled' => $wp_cache_enabled,
1351 'adv_cache_exists' => $adv_cache_exists,
1352 'correct_cache_exists' => $correct_advanced_cache,
1353 'webp_table_exists' => $webp_table_exists,
1354 ];
1355 }
1356
1357 /**
1358 * Get a path by the URL
1359 *
1360 * @param string $url
1361 *
1362 * @return bool|string
1363 */
1364 public static function url_to_path( $url ) {
1365 $root_dir = trailingslashit( dirname( WP_CONTENT_DIR ) );
1366 $root_url = str_replace( wp_basename( WP_CONTENT_DIR ), '', content_url() );
1367 $url_host = wp_parse_url( $url, PHP_URL_HOST );
1368
1369 // relative path.
1370 if ( null === $url_host ) {
1371 $subdir_levels = substr_count( preg_replace( '/https?:\/\//', '', site_url() ), '/' );
1372 $url = trailingslashit( site_url() . str_repeat( '/..', $subdir_levels ) ) . ltrim( $url, '/' );
1373 }
1374
1375 $root_url = preg_replace( '/^https?:/', '', $root_url );
1376 $url_rep = preg_replace( '/^https?:/', '', $url );
1377 $file = str_replace( $root_url, $root_dir, $url_rep );
1378 $real_path = self::realpath( $file );
1379
1380 if ( ! file_exists( $real_path ) ) {
1381 return false;
1382 }
1383
1384 return $real_path;
1385 }
1386
1387 /**
1388 * Returns canonicalized absolute pathname.
1389 * The resulting path will have no symbolic link, '/./' or '/../' components.
1390 * Same as the defautl PHP realpath() function but works even when the files does not exist.
1391 *
1392 * @param string $file The path being checked.
1393 *
1394 * @return string
1395 * @see \realpath()
1396 *
1397 */
1398 public static function realpath( $file ) {
1399 $path = [];
1400
1401 foreach ( explode( '/', $file ) as $part ) {
1402 if ( '' === $part || '.' === $part ) {
1403 continue;
1404 }
1405
1406 if ( '..' !== $part ) {
1407 array_push( $path, $part );
1408 } elseif ( count( $path ) > 0 ) {
1409 array_pop( $path );
1410 }
1411 }
1412
1413 $prefix = 'WIN' === strtoupper( substr( PHP_OS, 0, 3 ) ) ? '' : '/';
1414
1415 return $prefix . join( '/', $path );
1416 }
1417
1418 /**
1419 * Check if Development Mode is active (file-based, works before WP loads)
1420 */
1421 public static function is_dev_mode_active() {
1422 $flag_file = (defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR : dirname(__DIR__)) . '/cache/ezcache/.dev-mode';
1423 if ( ! file_exists( $flag_file ) ) {
1424 return false;
1425 }
1426 $expires = (int) trim( @file_get_contents( $flag_file ) );
1427 if ( $expires === 0 ) {
1428 return true;
1429 }
1430 if ( time() >= $expires ) {
1431 @unlink( $flag_file );
1432 return false;
1433 }
1434 return true;
1435 }
1436
1437 public static function enable_dev_mode( $seconds = 3600 ) {
1438 $dir = WP_CONTENT_DIR . '/cache/ezcache';
1439 if ( ! is_dir( $dir ) ) {
1440 @mkdir( $dir, 0755, true );
1441 }
1442 $expires = ( $seconds === 0 ) ? 0 : time() + $seconds;
1443 file_put_contents( $dir . '/.dev-mode', (string) $expires );
1444 }
1445
1446 public static function disable_dev_mode() {
1447 @unlink( WP_CONTENT_DIR . '/cache/ezcache/.dev-mode' );
1448 }
1449
1450 public static function get_dev_mode_status() {
1451 $flag = WP_CONTENT_DIR . '/cache/ezcache/.dev-mode';
1452 if ( ! file_exists( $flag ) ) {
1453 return [ 'active' => false ];
1454 }
1455 $expires = (int) trim( @file_get_contents( $flag ) );
1456 if ( $expires > 0 && time() >= $expires ) {
1457 @unlink( $flag );
1458 return [ 'active' => false ];
1459 }
1460 return [
1461 'active' => true,
1462 'expires' => $expires === 0 ? 'permanent' : $expires,
1463 'remaining' => $expires === 0 ? null : $expires - time(),
1464 ];
1465 }
1466 }
1467