| @@ -12,8 +12,13 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | if (!defined('WPO_CACHE_FILES_DIR')) define('WPO_CACHE_FILES_DIR', untrailingslashit(WP_CONTENT_DIR).'/cache/wpo-cache'); |
| 14 | 14 | |
| 15 | 15 | /** |
| 16 | + * Minimum Firefox version for WebP support | |
| 17 | + */ | |
| 18 | +if (!defined('WPO_MIN_FIREFOX_VERSION_FOR_WEBP')) define('WPO_MIN_FIREFOX_VERSION_FOR_WEBP', '65.0.0'); | |
| 19 | + | |
| 20 | +/** | |
| 16 | 21 | * Holds utility functions used by file based cache |
| 17 | 22 | */ |
| 18 | 23 | |
| 19 | 24 | /** |
| @@ -89,10 +94,14 @@ | ||
| 89 | 94 | $no_cache_because[] = __('Comments are opened and the visitor saved his information.', 'wp-optimize'); |
| 90 | 95 | } |
| 91 | 96 | } |
| 92 | 97 | |
| 98 | + if (wpo_restricted_cache_page_with_cart_items()) { | |
| 99 | + $no_cache_because[] = __('User has items in WooCommerce cart.', 'wp-optimize'); | |
| 100 | + } | |
| 101 | + | |
| 93 | 102 | $can_cache_page = true; |
| 94 | - | |
| 103 | + | |
| 95 | 104 | if (defined('DONOTCACHEPAGE') && DONOTCACHEPAGE) { |
| 96 | 105 | $can_cache_page = false; |
| 97 | 106 | } |
| 98 | 107 | |
| @@ -127,12 +136,14 @@ | ||
| 127 | 136 | } |
| 128 | 137 | |
| 129 | 138 | // Get cache file name |
| 130 | 139 | $file_ext = '.html'; |
| 140 | + $is_feed_cache = false; | |
| 131 | 141 | |
| 132 | 142 | if (wpo_feeds_caching_enabled()) { |
| 133 | 143 | if (is_feed()) { |
| 134 | 144 | $file_ext = '.rss-xml'; |
| 145 | + $is_feed_cache = true; | |
| 135 | 146 | } |
| 136 | 147 | } |
| 137 | 148 | |
| 138 | 149 | $cache_filename = wpo_cache_filename($file_ext); |
| @@ -164,13 +175,13 @@ | ||
| 164 | 175 | // Add http headers |
| 165 | 176 | wpo_cache_add_nocache_http_header($message); |
| 166 | 177 | |
| 167 | 178 | if ((!defined('DOING_CRON') || !DOING_CRON) && (!defined('REST_REQUEST') || !REST_REQUEST)) { |
| 168 | - $not_cached_details = ""; | |
| 179 | + $not_cached_details = ''; | |
| 169 | 180 | |
| 170 | 181 | // Output the reason only when the user has turned on debugging |
| 171 | 182 | if (((defined('WP_DEBUG') && WP_DEBUG) || isset($_GET['wpo_cache_debug']))) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Not using the value, only checks for existence |
| 172 | - $not_cached_details = "because: ".htmlspecialchars($message) . " "; | |
| 183 | + $not_cached_details = "because: ".htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . " "; | |
| 173 | 184 | } |
| 174 | 185 | |
| 175 | 186 | $buffer .= sprintf("\n<!-- WP Optimize page cache - https://teamupdraft.com/wp-optimize/ - page NOT cached %s-->\n", $not_cached_details); |
| 176 | 187 | } |
| @@ -237,9 +248,11 @@ | ||
| 237 | 248 | |
| 238 | 249 | if (defined('WPO_CACHE_FILENAME_DEBUG') && WPO_CACHE_FILENAME_DEBUG) { |
| 239 | 250 | $add_to_footer .= "\n<!-- WP Optimize page cache debug information -->\n"; |
| 240 | 251 | if (!empty($GLOBALS['wpo_cache_filename_debug']) && is_array($GLOBALS['wpo_cache_filename_debug'])) { |
| 241 | - $add_to_footer .= "<!-- \n" . join("\n", array_map('htmlspecialchars', $GLOBALS['wpo_cache_filename_debug'])) . "\n -->"; | |
| 252 | + $add_to_footer .= "<!-- \n" . join("\n", array_map(function($s) { | |
| 253 | + return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8'); | |
| 254 | + }, $GLOBALS['wpo_cache_filename_debug'])) . "\n -->"; | |
| 242 | 255 | } |
| 243 | 256 | } |
| 244 | 257 | |
| 245 | 258 | if (function_exists('gzencode') && apply_filters('wpo_allow_cache_gzip_files', true)) { |
| @@ -246,8 +259,23 @@ | ||
| 246 | 259 | // Only replace inside the addition, not inside the main buffer (e.g. post content) |
| 247 | 260 | $add_to_footer = str_replace('by WP-Optimize', 'by WP-Optimize (gzip)', $add_to_footer); |
| 248 | 261 | } |
| 249 | 262 | |
| 263 | + // Allow extensions to inject content before </body> in cached HTML (HTML pages only, not sitemaps or RSS feeds). | |
| 264 | + // Content is inserted before the last </body> tag to produce valid HTML. | |
| 265 | + // Falls back to appending after </html> if no </body> is found (e.g., partial HTML responses). | |
| 266 | + if (!wpo_is_cacheable_sitemap_request() && !$is_feed_cache) { | |
| 267 | + $footer_injection = apply_filters('wpo_cache_add_to_footer', '', $cache_filename); | |
| 268 | + if ('' !== $footer_injection) { | |
| 269 | + $body_close_pos = strripos($buffer, '</body>'); | |
| 270 | + if (false !== $body_close_pos) { | |
| 271 | + $buffer = substr($buffer, 0, $body_close_pos) . $footer_injection . substr($buffer, $body_close_pos); | |
| 272 | + } else { | |
| 273 | + $buffer .= $footer_injection; | |
| 274 | + } | |
| 275 | + } | |
| 276 | + } | |
| 277 | + | |
| 250 | 278 | // XML documents must not contain HTML comments in the footer, as this would invalidate the XML |
| 251 | 279 | if (wpo_is_cacheable_sitemap_request() && '' !== $add_to_footer) { |
| 252 | 280 | $pattern = '#</([a-zA-Z0-9:_-]+)>\s*$#'; |
| 253 | 281 | $replacement = $add_to_footer . "\n</$1>"; |
| @@ -424,10 +452,10 @@ | ||
| 424 | 452 | |
| 425 | 453 | if (wpo_cache_mobile_caching_enabled() && wpo_is_mobile()) { |
| 426 | 454 | $filename = 'mobile.' . $filename; |
| 427 | 455 | } |
| 428 | - | |
| 429 | - if (wpo_webp_images_enabled() && !wpo_is_using_webp_images_redirection() && wpo_is_using_alter_html()) { | |
| 456 | + | |
| 457 | + if (wpo_webp_images_enabled() && !wpo_is_using_webp_images_redirection() && wpo_is_browser_supports_webp()) { | |
| 430 | 458 | $filename = $filename . '.webp'; |
| 431 | 459 | } |
| 432 | 460 | |
| 433 | 461 | $cookies = wpo_cache_cookies(); |
| @@ -528,8 +556,9 @@ | ||
| 528 | 556 | $filename = 'index'; |
| 529 | 557 | $cache_key = ''; |
| 530 | 558 | |
| 531 | 559 | if (!empty($params)) { |
| 560 | + ksort($params); | |
| 532 | 561 | foreach ($params as $key => $value) { |
| 533 | 562 | if (is_array($value)) $value = serialize($value); |
| 534 | 563 | $_cache_key = $key.'_'.$value; |
| 535 | 564 | $_cache_key = preg_replace('/[^a-z0-9_\-]/i', '-', $_cache_key); |
| @@ -706,15 +735,27 @@ | ||
| 706 | 735 | } |
| 707 | 736 | endif; |
| 708 | 737 | |
| 709 | 738 | /** |
| 710 | - * Check whether webp images using alter html method or not | |
| 739 | + * Check whether the requesting browser supports WebP images | |
| 711 | 740 | * |
| 712 | 741 | * @return bool |
| 713 | 742 | */ |
| 714 | -if (!function_exists('wpo_is_using_alter_html')) : | |
| 715 | - function wpo_is_using_alter_html() { | |
| 716 | - return (isset($_SERVER['HTTP_ACCEPT']) && false !== strpos($_SERVER['HTTP_ACCEPT'], 'image/webp')); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Executes before WP fully loads, only doing string comparison | |
| 743 | +if (!function_exists('wpo_is_browser_supports_webp')) : | |
| 744 | + function wpo_is_browser_supports_webp() { | |
| 745 | + // Direct Accept header check (works for image sub-resource requests) | |
| 746 | + if (isset($_SERVER['HTTP_ACCEPT']) && false !== strpos($_SERVER['HTTP_ACCEPT'], 'image/webp')) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Executes before WP fully loads, only doing string comparison | |
| 747 | + return true; | |
| 748 | + } | |
| 749 | + | |
| 750 | + // Fallback for older Firefox versions, which support WebP but don't send 'image/webp' in the Accept header. | |
| 751 | + $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Executes before WP fully loads, only extracting a version number via regex for a version_compare, not used for output or storage | |
| 752 | + | |
| 753 | + if (!empty($user_agent) && preg_match('/Firefox\/([\d\.]+[a-z\d]*)/', $user_agent, $matches)) { | |
| 754 | + return version_compare(WPO_MIN_FIREFOX_VERSION_FOR_WEBP, $matches[1], '<='); | |
| 755 | + } | |
| 756 | + | |
| 757 | + return false; | |
| 717 | 758 | } |
| 718 | 759 | endif; |
| 719 | 760 | |
| 720 | 761 | /** |
| @@ -808,9 +849,11 @@ | ||
| 808 | 849 | |
| 809 | 850 | if ($use_gzip) $path .= '.gz'; |
| 810 | 851 | |
| 811 | 852 | $modified_time = file_exists($path) ? (int) filemtime($path) : time(); |
| 812 | - | |
| 853 | + | |
| 854 | + $modified_time = apply_filters('wpo_cache_modified_time', $modified_time, $path); | |
| 855 | + | |
| 813 | 856 | // Cache has expired, purge and exit. |
| 814 | 857 | if (!empty($GLOBALS['wpo_cache_config']['page_cache_length'])) { |
| 815 | 858 | if (time() > ($GLOBALS['wpo_cache_config']['page_cache_length'] + $modified_time)) { |
| 816 | 859 | wpo_delete_files($path); |
| @@ -960,9 +1003,9 @@ | ||
| 960 | 1003 | return false; |
| 961 | 1004 | } |
| 962 | 1005 | |
| 963 | 1006 | // check in not disabled current user agent |
| 964 | - $user_agent = isset($_SERVER['HTTP_USER_AGENT']) && is_string($_SERVER['HTTP_USER_AGENT']) ? htmlspecialchars(stripslashes($_SERVER['HTTP_USER_AGENT'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- false positive | |
| 1007 | + $user_agent = isset($_SERVER['HTTP_USER_AGENT']) && is_string($_SERVER['HTTP_USER_AGENT']) ? stripslashes($_SERVER['HTTP_USER_AGENT']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- value used for comparison only, not output | |
| 965 | 1008 | if (!empty($user_agent) && false === wpo_is_accepted_user_agent($user_agent)) { |
| 966 | 1009 | $no_cache_because[] = "In the settings, caching is disabled for matches for this request's user agent"; |
| 967 | 1010 | } |
| 968 | 1011 | |
| @@ -1000,8 +1043,12 @@ | ||
| 1000 | 1043 | } |
| 1001 | 1044 | } |
| 1002 | 1045 | } |
| 1003 | 1046 | |
| 1047 | + if (wpo_restricted_cache_page_with_cart_items()) { | |
| 1048 | + $no_cache_because[] = 'User has items in WooCommerce cart.'; | |
| 1049 | + } | |
| 1050 | + | |
| 1004 | 1051 | $restricted_page_type_cache = wpo_restricted_cache_page_type(''); |
| 1005 | 1052 | if (!empty($restricted_page_type_cache)) { |
| 1006 | 1053 | $no_cache_because[] = $restricted_page_type_cache; |
| 1007 | 1054 | } |
| @@ -1045,8 +1092,19 @@ | ||
| 1045 | 1092 | } |
| 1046 | 1093 | endif; |
| 1047 | 1094 | |
| 1048 | 1095 | /** |
| 1096 | + * Checks if the current request has WooCommerce cart items. | |
| 1097 | + * | |
| 1098 | + * @return bool Returns true if the user has items in the WooCommerce cart, false otherwise. | |
| 1099 | + */ | |
| 1100 | +if (!function_exists('wpo_restricted_cache_page_with_cart_items')) : | |
| 1101 | + function wpo_restricted_cache_page_with_cart_items(): bool { | |
| 1102 | + return !empty($_COOKIE['woocommerce_items_in_cart']) || !empty($_COOKIE['woocommerce_cart_hash']); | |
| 1103 | + } | |
| 1104 | +endif; | |
| 1105 | + | |
| 1106 | +/** | |
| 1049 | 1107 | * Checks if the current request is a cacheable sitemap request |
| 1050 | 1108 | * |
| 1051 | 1109 | * @return bool |
| 1052 | 1110 | */ |
| @@ -1148,23 +1206,18 @@ | ||
| 1148 | 1206 | function wpo_get_url_path($url = '') { |
| 1149 | 1207 | $url = '' === $url ? wpo_current_url() : $url; |
| 1150 | 1208 | $url_parts = parse_url($url); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url not available this early |
| 1151 | 1209 | |
| 1210 | + // Normalize path to avoid issues with encoded characters, and to ensure that the path is consistent. | |
| 1211 | + if (isset($url_parts['path'])) { | |
| 1212 | + $url_parts['path'] = wpo_normalize_url_path($url_parts['path']); | |
| 1213 | + } | |
| 1214 | + | |
| 1152 | 1215 | if (isset($url_parts['path']) && false !== stripos($url_parts['path'], '/index.')) { |
| 1153 | 1216 | $url_parts['path'] = preg_replace('/(.*?)index\.(php|html)(\/.+)/i', '$1index-$2$3', $url_parts['path']); |
| 1154 | 1217 | $url_parts['path'] = preg_replace('/index\.(php|html)/i', 'index-$1', $url_parts['path']); |
| 1155 | 1218 | } |
| 1156 | 1219 | |
| 1157 | - /* | |
| 1158 | - * Convert the hexadecimal digits within the percent-encoded triplet to uppercase, to ensure that the path remains | |
| 1159 | - * consistent. For instance, "example.com/%e0%a6" will be converted to "example.com/%E0%A6". | |
| 1160 | - */ | |
| 1161 | - if (isset($url_parts['path'])) { | |
| 1162 | - $url_parts['path'] = preg_replace_callback('/%[0-9A-F]{2}/i', function($matches) { | |
| 1163 | - return strtoupper($matches[0]); | |
| 1164 | - }, $url_parts['path']); | |
| 1165 | - } | |
| 1166 | - | |
| 1167 | 1220 | if (!isset($url_parts['host'])) $url_parts['host'] = ''; |
| 1168 | 1221 | if (!isset($url_parts['path'])) $url_parts['path'] = ''; |
| 1169 | 1222 | |
| 1170 | 1223 | return $url_parts['host'].$url_parts['path']; |
| @@ -2040,8 +2093,32 @@ | ||
| 2040 | 2093 | |
| 2041 | 2094 | return $path; |
| 2042 | 2095 | } |
| 2043 | 2096 | } |
| 2097 | + | |
| 2098 | +/** | |
| 2099 | + * Normalize url path | |
| 2100 | + * | |
| 2101 | + * @param string $url_path | |
| 2102 | + * @return string | |
| 2103 | + */ | |
| 2104 | +if (!function_exists('wpo_normalize_url_path')) : | |
| 2105 | + function wpo_normalize_url_path($url_path) { | |
| 2106 | + $prev = null; | |
| 2107 | + $iterations = 0; | |
| 2108 | + | |
| 2109 | + while ($url_path !== $prev && 5 > $iterations) { | |
| 2110 | + $prev = $url_path; | |
| 2111 | + $url_path = rawurldecode($url_path); | |
| 2112 | + $iterations++; | |
| 2113 | + } | |
| 2114 | + | |
| 2115 | + $url_path = preg_replace('/\.\.?\//', '-', $url_path); // replace './' and '../' with '-' to prevent directory traversal | |
| 2116 | + $url_path = strtolower($url_path); | |
| 2117 | + | |
| 2118 | + return $url_path; | |
| 2119 | + } | |
| 2120 | +endif; | |
| 2044 | 2121 | |
| 2045 | 2122 | /** |
| 2046 | 2123 | * Get path to wp-config.php when called from WP-CLI. |
| 2047 | 2124 | * |