PluginProbe
WP-Optimize – Cache, Compress images, Minify & Clean database to boost page speed & performance / 4.7.0
WP-Optimize – Cache, Compress images, Minify & Clean database to boost page speed & performance v4.7.0
4.7.0 4.6.1 4.6.0 4.5.5 4.5.4 4.5.3 4.5.2 3.2.20 3.2.21 3.2.22 3.2.3 3.2.5 3.2.6 3.2.7 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.7.0 3.7.1 All 111 releases
← All changes | cache/file-based-page-cache-functions.php +1568 -863 3.3.1 → 4.7.0 View file →
@@ -7,8 +7,18 @@
7 7 */
8 8 if (!defined('WPO_CACHE_EXT_DIR')) define('WPO_CACHE_EXT_DIR', dirname(__FILE__).'/extensions');
9 9
10 10 /**
11 + * Directory that stores the cache, including gzipped files and mobile specific cache
12 + */
13 +if (!defined('WPO_CACHE_FILES_DIR')) define('WPO_CACHE_FILES_DIR', untrailingslashit(WP_CONTENT_DIR).'/cache/wpo-cache');
14 +
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 +/**
11 21 * Holds utility functions used by file based cache
12 22 */
13 23
14 24 /**
@@ -19,254 +29,316 @@
19 29 *
20 30 * @return String
21 31 */
22 32 if (!function_exists('wpo_cache')) :
23 -function wpo_cache($buffer, $flags) {
24 -
25 - // This case appears to happen for unclear reasons without WP being fully loaded, e.g. https://wordpress.org/support/topic/fatal-error-since-wp-5-8-update/ . It is simplest just to short-circuit it.
26 - if ('' === $buffer) return '';
27 -
28 - // This array records reasons why no caching took place. Be careful not to allow actions to proceed that should not - i.e. take note of its state appropriately.
29 - $no_cache_because = array();
30 -
31 - if (strlen($buffer) < 255) {
32 - $no_cache_because[] = sprintf(__('Output is too small (less than %d bytes) to be worth caching', 'wp-optimize'), 255);
33 - }
34 -
35 - // Don't cache pages for logged in users.
36 - if (!wpo_cache_loggedin_users() && (!function_exists('is_user_logged_in') || (function_exists('wp_get_current_user') && is_user_logged_in()))) {
37 - $no_cache_because[] = __('User is logged in', 'wp-optimize');
38 - }
39 -
40 - $restricted_page_type_cache = apply_filters('wpo_restricted_cache_page_type', false);
41 - if ($restricted_page_type_cache) {
42 - $no_cache_because[] = $restricted_page_type_cache;
43 - }
44 -
45 - $conditional_tag_exceptions = apply_filters('wpo_url_in_conditional_tags_exceptions', false);
46 - if ($conditional_tag_exceptions) {
47 - $no_cache_because[] = $conditional_tag_exceptions;
48 - }
49 -
50 - // No root cache folder, so short-circuit here
51 - if (!file_exists(WPO_CACHE_DIR)) {
52 - $no_cache_because[] = __('WP-O cache parent directory was not found', 'wp-optimize').' ('.WPO_CACHE_DIR.')';
53 - } elseif (!file_exists(WPO_CACHE_FILES_DIR)) {
54 - // Try creating a folder for cached files, if it was flushed recently
55 - if (!mkdir(WPO_CACHE_FILES_DIR)) {
56 - $no_cache_because[] = __('WP-O cache directory was not found', 'wp-optimize').' ('.WPO_CACHE_FILES_DIR.')';
57 - } else {
58 - wpo_disable_cache_directories_viewing();
33 + function wpo_cache($buffer, $flags) {
34 +
35 + // This case appears to happen for unclear reasons without WP being fully loaded, e.g. https://wordpress.org/support/topic/fatal-error-since-wp-5-8-update/ . It is simplest just to short-circuit it.
36 + if ('' === $buffer) return '';
37 +
38 + // This array records reasons why no caching took place. Be careful not to allow actions to proceed that should not - i.e. take note of its state appropriately.
39 + $no_cache_because = array();
40 +
41 + if (strlen($buffer) < 255) {
42 + // translators: %s is the number of bytes
43 + $no_cache_because[] = sprintf(__('Output is too small (less than %d bytes) to be worth caching', 'wp-optimize'), 255);
59 44 }
60 - }
61 45
62 - // If comments are opened and the user has saved his information.
63 - if (function_exists('comments_open') && function_exists('get_post') && get_post() && comments_open()) {
64 - $commenter = wp_get_current_commenter();
65 - // if any of the fields contain something, do not save to cache
66 - if ('' != $commenter['comment_author'] || '' != $commenter['comment_author_email'] || '' != $commenter['comment_author_url']) {
67 - $no_cache_because[] = __('Comments are opened and the visitor saved his information.', 'wp-optimize');
46 + if (defined('REST_REQUEST') && REST_REQUEST) {
47 + if (!wpo_rest_caching_enabled()) {
48 + $no_cache_because[] = __('This is a REST API request (identified by REST_REQUEST constant) and you have not enabled REST API caching', 'wp-optimize');
49 + } else {
50 + // Don't process REST requests here
51 + return $buffer;
52 + }
68 53 }
69 - }
54 +
55 + $restricted_page_type_cache = apply_filters('wpo_restricted_cache_page_type', false);
70 56
71 - $can_cache_page = true;
72 -
73 - if (defined('DONOTCACHEPAGE') && DONOTCACHEPAGE) {
74 - $can_cache_page = false;
75 - }
76 -
77 - /**
78 - * Defines if the page can be cached or not
79 - *
80 - * @param boolean $can_cache_page
81 - */
82 - $can_cache_page_filter = apply_filters('wpo_can_cache_page', $can_cache_page);
83 -
84 - if (!$can_cache_page_filter) {
85 - if ($can_cache_page) {
86 - $can_cache_page = false;
87 - $no_cache_because[] = __('wpo_can_cache_page filter forbade it', 'wp-optimize');
88 - } else {
89 - $no_cache_because[] = __('DONOTCACHEPAGE constant forbade it and wpo_can_cache_page filter did not over-ride it', 'wp-optimize');
57 + if ($restricted_page_type_cache) {
58 + $no_cache_because[] = $restricted_page_type_cache;
90 59 }
91 - }
60 +
61 + $conditional_tag_exceptions = apply_filters('wpo_url_in_conditional_tags_exceptions', false);
92 62
93 - if (defined('REST_REQUEST') && REST_REQUEST) {
94 - $no_cache_because[] = __('This is a REST API request (identified by REST_REQUEST constant)', 'wp-optimize');
95 - }
96 -
97 - // Don't cache with fatal error pages.
98 - $last_error = error_get_last();
99 - if (is_array($last_error) && E_ERROR == $last_error['type']) {
100 - $no_cache_because[] = __('This page has a fatal error', 'wp-optimize');
101 - }
102 -
103 - if (http_response_code() >= 500) {
104 - $no_cache_because[] = sprintf(__('This page has a critical error (HTTP code %s)', 'wp-optimize'), http_response_code());
105 - } elseif (http_response_code() >= 400) {
106 - $no_cache_because[] = sprintf(__('This page returned an HTTP unauthorised response code (%s)', 'wp-optimize'), http_response_code());
107 - }
108 -
109 - if (empty($no_cache_because)) {
110 -
111 - $buffer = apply_filters('wpo_pre_cache_buffer', $buffer, $flags);
112 -
113 - $url_path = wpo_get_url_path();
114 -
115 - $dirs = explode('/', $url_path);
116 -
117 - $path = WPO_CACHE_FILES_DIR;
118 -
119 - foreach ($dirs as $dir) {
120 - if (!empty($dir)) {
121 - $path .= '/' . $dir;
122 -
123 - if (!file_exists($path)) {
124 - if (!mkdir($path)) {
125 - $no_cache_because[] = __('Attempt to create subfolder within cache directory failed', 'wp-optimize')." ($path)";
126 - break;
127 - }
128 - }
129 - }
63 + if ($conditional_tag_exceptions) {
64 + $no_cache_because[] = $conditional_tag_exceptions;
130 65 }
131 - }
132 -
133 - if (!empty($no_cache_because)) {
134 -
135 - $message = implode(', ', $no_cache_because);
136 -
137 - // Add http headers
138 - wpo_cache_add_nocache_http_header($message);
139 -
140 - if ((!defined('DOING_CRON') || !DOING_CRON) && (!defined('REST_REQUEST') || !REST_REQUEST)) {
141 - $not_cached_details = "";
142 -
143 - // Output the reason only when the user has turned on debugging
144 - if (((defined('WP_DEBUG') && WP_DEBUG) || isset($_GET['wpo_cache_debug']))) {
145 - $not_cached_details = "because: ".htmlspecialchars($message) . " ";
66 +
67 + // Don't cache pages for logged in users.
68 + if (!function_exists('is_user_logged_in') || (function_exists('wp_get_current_user') && is_user_logged_in())) {
69 + if (!wpo_cache_loggedin_users()) {
70 + $no_cache_because[] = __('User is logged in', 'wp-optimize');
71 + } elseif (!empty($GLOBALS['wpo_cache_config']['enable_user_caching'])) {
72 + // Will only run when "Serve cached pages to logged in users" is checked
73 + $no_cache_because[] = __('User is logged in, this works only when the cache is preloaded', 'wp-optimize');
146 74 }
147 -
148 - $buffer .= sprintf("\n<!-- WP Optimize page cache - https://getwpo.com - page NOT cached %s-->\n", $not_cached_details);
149 75 }
150 76
151 - return $buffer;
152 -
153 - } else {
154 -
155 - // Prevent mixed content when there's an http request but the site URL uses https.
156 - $home_url = get_home_url();
157 -
158 - if (!is_ssl() && 'https' === strtolower(parse_url($home_url, PHP_URL_SCHEME))) {
159 - $https_home_url = $home_url;
160 - $http_home_url = str_ireplace('https://', 'http://', $https_home_url);
161 - $buffer = str_replace(esc_url($http_home_url), esc_url($https_home_url), $buffer);
77 + // No root cache folder, so short-circuit here
78 + if (!file_exists(WPO_CACHE_DIR)) {
79 + $no_cache_because[] = __('WP-O cache parent directory was not found', 'wp-optimize').' ('.WPO_CACHE_DIR.')';
80 + } elseif (!file_exists(WPO_CACHE_FILES_DIR)) {
81 + // Try creating a folder for cached files, if it was flushed recently
82 + if (!mkdir(WPO_CACHE_FILES_DIR)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- wp_mkdir_p not available this early
83 + $no_cache_because[] = __('WP-O cache directory was not found', 'wp-optimize').' ('.WPO_CACHE_FILES_DIR.')';
84 + } else {
85 + wpo_disable_cache_directories_viewing();
86 + }
162 87 }
163 -
164 - $modified_time = time(); // Take this as soon before writing as possible
165 - $timezone_string = '';
166 - $utc = isset($GLOBALS['wpo_cache_config']['gmt_offset']) ? (float) $GLOBALS['wpo_cache_config']['gmt_offset'] : 0;
167 - $modified_time += $utc * 3600;
168 88
169 - if (!empty($GLOBALS['wpo_cache_config']['timezone_string'])) {
170 - $timezone_string = 'UTC' !== $GLOBALS['wpo_cache_config']['timezone_string'] ? $GLOBALS['wpo_cache_config']['timezone_string'] : '';
89 + // If comments are opened and the user has saved his information.
90 + if (function_exists('comments_open') && function_exists('get_post') && get_post() && comments_open()) {
91 + $commenter = wp_get_current_commenter();
92 + // if any of the fields contain something, do not save to cache
93 + if ('' !== $commenter['comment_author'] || '' !== $commenter['comment_author_email'] || '' !== $commenter['comment_author_url']) {
94 + $no_cache_because[] = __('Comments are opened and the visitor saved his information.', 'wp-optimize');
95 + }
171 96 }
172 97
173 - if (!empty($timezone_string)) {
174 - $timezone_postfix = "(".$timezone_string." UTC:". $utc .")";
175 - } else {
176 - $timezone_postfix = "(UTC:" . $utc . ")";
98 + if (wpo_restricted_cache_page_with_cart_items()) {
99 + $no_cache_because[] = __('User has items in WooCommerce cart.', 'wp-optimize');
177 100 }
178 101
179 - $add_to_footer = '';
102 + $can_cache_page = true;
103 +
104 + if (defined('DONOTCACHEPAGE') && DONOTCACHEPAGE) {
105 + $can_cache_page = false;
106 + }
180 107
181 108 /**
182 - * Filter whether to display the html comment <!-- Cached by WP-Optimize ... -->
109 + * Defines if the page can be cached or not
183 110 *
184 - * @param boolean $show - Whether to display the html comment
185 - * @return boolean
111 + * @param boolean $can_cache_page
186 112 */
187 - if (preg_match('#</html>#i', $buffer) && (apply_filters('wpo_cache_show_cached_by_comment', true) || (defined('WP_DEBUG') && WP_DEBUG))) {
188 - $date_time_format = 'F j, Y g:i a';
189 - if (!empty($GLOBALS['wpo_cache_config']['date_format']) && !empty($GLOBALS['wpo_cache_config']['time_format'])) {
190 - $date_time_format = $GLOBALS['wpo_cache_config']['date_format'] . ' ' . $GLOBALS['wpo_cache_config']['time_format'];
191 - }
192 -
193 - if (!empty($GLOBALS['wpo_cache_config']['enable_mobile_caching']) && wpo_is_mobile()) {
194 - $add_to_footer .= "\n<!-- Cached by WP-Optimize - for mobile devices - https://getwpo.com - Last modified: " . gmdate($date_time_format, $modified_time) . " " . $timezone_postfix . " -->\n";
113 + $can_cache_page_filter = apply_filters('wpo_can_cache_page', $can_cache_page);
114 +
115 + if (!$can_cache_page_filter) {
116 + if ($can_cache_page) {
117 + $can_cache_page = false;
118 + $no_cache_because[] = __('wpo_can_cache_page filter forbade it', 'wp-optimize');
195 119 } else {
196 - $add_to_footer .= "\n<!-- Cached by WP-Optimize - https://getwpo.com - Last modified: " . gmdate($date_time_format, $modified_time) . " " . $timezone_postfix . " -->\n";
120 + $no_cache_because[] = __('DONOTCACHEPAGE constant forbade it and wpo_can_cache_page filter did not over-ride it', 'wp-optimize');
197 121 }
198 122 }
123 +
124 + // Don't cache with fatal error pages.
125 + $last_error = error_get_last();
126 + if (is_array($last_error) && E_ERROR === $last_error['type']) {
127 + $no_cache_because[] = __('This page has a fatal error', 'wp-optimize');
128 + }
129 +
130 + if (http_response_code() >= 500) {
131 + // translators: %s is the HTTP response code for critical errors
132 + $no_cache_because[] = sprintf(__('This page has a critical error (HTTP code %s)', 'wp-optimize'), http_response_code());
133 + } elseif (http_response_code() >= 400) {
134 + // translators: %s is the HTTP response code for unauthorised access
135 + $no_cache_because[] = sprintf(__('This page returned an HTTP unauthorised response code (%s)', 'wp-optimize'), http_response_code());
136 + }
199 137
200 - // Create an empty index.php file in the cache directory for disable directory viewing.
201 - if (!is_file($path . '/index.php')) file_put_contents($path . '/index.php', '');
202 -
203 - /**
204 - * Save $buffer into cache file.
205 - */
138 + // Get cache file name
206 139 $file_ext = '.html';
207 -
140 + $is_feed_cache = false;
141 +
208 142 if (wpo_feeds_caching_enabled()) {
209 143 if (is_feed()) {
210 144 $file_ext = '.rss-xml';
145 + $is_feed_cache = true;
211 146 }
212 147 }
148 +
149 + $cache_filename = wpo_cache_filename($file_ext);
213 150
214 - $cache_filename = wpo_cache_filename($file_ext);
215 - $cache_file = $path . '/' .$cache_filename;
151 + if (defined('WPO_CACHE_DONT_PROCESS_THIS_PAGE') && WPO_CACHE_DONT_PROCESS_THIS_PAGE) {
152 + $no_cache_because[] = __('The WPO_CACHE_DONT_PROCESS_THIS_PAGE constant is set.', 'wp-optimize');
153 + }
154 +
155 + if (empty($no_cache_because)) {
156 +
157 + $buffer = apply_filters('wpo_pre_cache_buffer', $buffer, $flags);
158 +
159 + $url_path = wpo_get_url_path();
216 160
217 - if (defined('WPO_CACHE_FILENAME_DEBUG') && WPO_CACHE_FILENAME_DEBUG) {
218 - $add_to_footer .= "\n<!-- WP Optimize page cache debug information -->\n";
219 - if (!empty($GLOBALS['wpo_cache_filename_debug']) && is_array($GLOBALS['wpo_cache_filename_debug'])) {
220 - $add_to_footer .= "<!-- \n" . join("\n", array_map('htmlspecialchars', $GLOBALS['wpo_cache_filename_debug'])) . "\n --->";
161 + $path = WPO_CACHE_FILES_DIR . '/' .$url_path;
162 +
163 + if (!wp_mkdir_p($path)) {
164 + $no_cache_because[] = __('Attempt to create subfolder within cache directory failed', 'wp-optimize').' ('.$url_path.')';
221 165 }
222 166 }
167 +
168 + if (!empty($no_cache_because)) {
169 +
170 + if (function_exists('do_action')) {
171 + do_action('wpo_page_not_cached', $no_cache_because);
172 + }
173 + $message = implode(', ', $no_cache_because);
174 +
175 + // Add http headers
176 + wpo_cache_add_nocache_http_header($message);
177 +
178 + if ((!defined('DOING_CRON') || !DOING_CRON) && (!defined('REST_REQUEST') || !REST_REQUEST)) {
179 + $not_cached_details = '';
180 +
181 + // Output the reason only when the user has turned on debugging
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
183 + $not_cached_details = "because: ".htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . " ";
184 + }
185 +
186 + $buffer .= sprintf("\n<!-- WP Optimize page cache - https://teamupdraft.com/wp-optimize/ - page NOT cached %s-->\n", $not_cached_details);
187 + }
188 +
189 + return $buffer;
190 +
191 + } else {
192 +
193 + // Prevent mixed content when there's an http request but the site URL uses https.
194 + $home_url = get_home_url();
195 +
196 + if (!is_ssl() && 'https' === strtolower(parse_url($home_url, PHP_URL_SCHEME))) { // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url not available this early
197 + $https_home_url = $home_url;
198 + $http_home_url = str_ireplace('https://', 'http://', $https_home_url);
199 + $buffer = str_replace(esc_url($http_home_url), esc_url($https_home_url), $buffer);
200 + }
201 +
202 + $modified_time = time(); // Take this as soon before writing as possible
203 + $timezone_string = '';
204 + $utc = isset($GLOBALS['wpo_cache_config']['gmt_offset']) ? (float) $GLOBALS['wpo_cache_config']['gmt_offset'] : 0;
205 + $modified_time += $utc * 3600;
206 +
207 + if (!empty($GLOBALS['wpo_cache_config']['timezone_string'])) {
208 + $timezone_string = 'UTC' !== $GLOBALS['wpo_cache_config']['timezone_string'] ? $GLOBALS['wpo_cache_config']['timezone_string'] : '';
209 + }
210 +
211 + if (!empty($timezone_string)) {
212 + $timezone_postfix = "(".$timezone_string." UTC:". $utc .")";
213 + } else {
214 + $timezone_postfix = "(UTC:" . $utc . ")";
215 + }
216 +
217 + $add_to_footer = '';
218 +
219 + /**
220 + * Filter whether to display the html comment <!-- Cached by WP-Optimize ... -->
221 + *
222 + * @param boolean $show - Whether to display the html comment
223 + * @return boolean
224 + */
225 + if ((preg_match('#</html>#i', $buffer) || wpo_is_cacheable_sitemap_request())
226 + && (apply_filters('wpo_cache_show_cached_by_comment', true) || (defined('WP_DEBUG') && WP_DEBUG))
227 + ) {
228 + $date_time_format = 'F j, Y g:i a';
229 + if (!empty($GLOBALS['wpo_cache_config']['date_format']) && !empty($GLOBALS['wpo_cache_config']['time_format'])) {
230 + $date_time_format = $GLOBALS['wpo_cache_config']['date_format'] . ' ' . $GLOBALS['wpo_cache_config']['time_format'];
231 + }
232 +
233 + if (!empty($GLOBALS['wpo_cache_config']['enable_mobile_caching']) && wpo_is_mobile()) {
234 + $add_to_footer .= "\n<!-- Cached by WP-Optimize - for mobile devices - https://teamupdraft.com/wp-optimize/ - Last modified: " . gmdate($date_time_format, $modified_time) . " " . $timezone_postfix . " -->\n";
235 + } else {
236 + $add_to_footer .= "\n<!-- Cached by WP-Optimize - https://teamupdraft.com/wp-optimize/ - Last modified: " . gmdate($date_time_format, $modified_time) . " " . $timezone_postfix . " -->\n";
237 + }
238 + }
239 +
240 + // Create an empty index.php file in the cache directory for disable directory viewing.
241 + if (!is_file($path . '/index.php')) file_put_contents($path . '/index.php', ''); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- WP_Filesystem not available this early
242 +
243 + /**
244 + * Save $buffer into cache file.
245 + */
223 246
224 - // if we can then cache gzipped content in .gz file.
225 - if (function_exists('gzencode') && apply_filters('wpo_allow_cache_gzip_files', true)) {
226 - // Only replace inside the addition, not inside the main buffer (e.g. post content)
227 - file_put_contents($cache_file . '.gz', gzencode($buffer.str_replace('by WP-Optimize', 'by WP-Optimize (gzip)', $add_to_footer), apply_filters('wpo_cache_gzip_level', 6)));
228 - }
247 + $cache_file = $path . '/' .$cache_filename;
248 +
249 + if (defined('WPO_CACHE_FILENAME_DEBUG') && WPO_CACHE_FILENAME_DEBUG) {
250 + $add_to_footer .= "\n<!-- WP Optimize page cache debug information -->\n";
251 + if (!empty($GLOBALS['wpo_cache_filename_debug']) && is_array($GLOBALS['wpo_cache_filename_debug'])) {
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 -->";
255 + }
256 + }
229 257
230 - file_put_contents($cache_file, $buffer.$add_to_footer);
258 + if (function_exists('gzencode') && apply_filters('wpo_allow_cache_gzip_files', true)) {
259 + // Only replace inside the addition, not inside the main buffer (e.g. post content)
260 + $add_to_footer = str_replace('by WP-Optimize', 'by WP-Optimize (gzip)', $add_to_footer);
261 + }
231 262
232 - if (is_callable('WP_Optimize')) {
233 - // delete cached information about cache size.
234 - WP_Optimize()->get_page_cache()->delete_cache_size_information();
235 - } else {
236 - // If the shutdown occurs before plugins are loaded,
237 - // then this will trigger a fatal error, so, we check first
238 - if (!doing_action('shutdown')) {
239 - error_log('[WPO_CACHE] WP_Optimize() is not callable.');
240 - $message = 'Please report this to WP-O support: ';
241 - if (function_exists('wp_debug_backtrace_summary')) {
242 - $message .= wp_debug_backtrace_summary();
243 - } else {
244 - $message .= wpo_debug_backtrace_summary();
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 + }
245 275 }
246 - error_log($message);
247 276 }
248 - }
249 277
250 - header('Cache-Control: no-cache'); // Check back every time to see if re-download is necessary.
251 - header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $modified_time) . ' GMT');
252 - header('WPO-Cache-Status: saving to cache');
278 + // XML documents must not contain HTML comments in the footer, as this would invalidate the XML
279 + if (wpo_is_cacheable_sitemap_request() && '' !== $add_to_footer) {
280 + $pattern = '#</([a-zA-Z0-9:_-]+)>\s*$#';
281 + $replacement = $add_to_footer . "\n</$1>";
282 + $buffer = preg_replace($pattern, $replacement, $buffer, 1); // Insert the comment before the final closing tag
283 + } else {
284 + $buffer .= $add_to_footer;
285 + }
253 286
254 - if (wpo_cache_can_output_gzip_content()) {
255 -
256 - if (!wpo_cache_is_in_response_headers_list('Content-Encoding', 'gzip')) {
257 - header('Content-Encoding: gzip');
287 + // if we can then cache gzipped content in .gz file.
288 + if (function_exists('gzencode') && apply_filters('wpo_allow_cache_gzip_files', true)) {
289 + $gzipped_buffer = gzencode($buffer, apply_filters('wpo_cache_gzip_level', 6));
290 + file_put_contents($cache_file . '.gz', $gzipped_buffer); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- WP_Filesystem not available this early
258 291 }
292 +
293 + file_put_contents($cache_file, $buffer); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- WP_Filesystem not available this early
294 +
295 + if (is_callable('WP_Optimize')) {
296 + // delete cached information about cache size.
297 + WP_Optimize()->get_page_cache()->delete_cache_size_information();
298 + } else {
299 + // If the shutdown occurs before plugins are loaded,
300 + // then this will trigger a fatal error, so, we check first
301 + if (!doing_action('shutdown')) {
302 + // phpcs:disable
303 + // Edge case handling for debugging purpose
304 + error_log('[WPO_CACHE] WP_Optimize() is not callable.');
305 + $message = 'Please report this to WP-O support: ';
306 + if (function_exists('wp_debug_backtrace_summary')) {
307 + $message .= wp_debug_backtrace_summary();
308 + } else {
309 + $message .= wpo_debug_backtrace_summary();
310 + }
311 + error_log($message);
312 + // phpcs:enable
313 + }
314 + }
315 +
316 + header('Cache-Control: no-cache'); // Check back every time to see if re-download is necessary.
317 + header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $modified_time) . ' GMT');
318 + header('WPO-Cache-Status: saving to cache');
259 319
260 - // Disable php gzip to avoid double compression.
261 - ini_set('zlib.output_compression', 'Off');
320 + // Enable gzipped output only if it is supported and the output buffer level is ≤2
321 + // (i.e., no extra handlers beyond default and WPO_Page_Optimizer::optimize() are active)
322 + $wpo_cache_can_output_gzip_content = wpo_cache_can_output_gzip_content() && ob_get_level() <= 2;
262 323
263 - return ob_gzhandler($buffer, $flags);
264 - } else {
265 - return $buffer;
324 + // Allow to override gzip output via the 'wpo_cache_can_output_gzip_content' filter
325 + $wpo_cache_can_output_gzip_content = apply_filters('wpo_cache_can_output_gzip_content', $wpo_cache_can_output_gzip_content);
326 +
327 + if ($wpo_cache_can_output_gzip_content) {
328 +
329 + if (!wpo_cache_is_in_response_headers_list('Content-Encoding', 'gzip')) {
330 + header('Content-Encoding: gzip');
331 + }
332 +
333 + ini_set('zlib.output_compression', 'Off'); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- disabling php gzip to avoid double compression.
334 +
335 + return ob_gzhandler($buffer, $flags);
336 + } else {
337 + return $buffer;
338 + }
266 339 }
267 340 }
268 -}
269 341 endif;
270 342
271 343 /**
272 344 * Load files for support plugins.
@@ -271,52 +343,89 @@
271 343 /**
272 344 * Load files for support plugins.
273 345 */
274 346 if (!function_exists('wpo_cache_load_extensions')) :
275 -function wpo_cache_load_extensions() {
276 - $extensions = glob(WPO_CACHE_EXT_DIR . '/*.php');
277 -
278 - // Add external extensions
279 - if (defined('WPO_CACHE_CUSTOM_EXT_DIR') && is_dir(WPO_CACHE_CUSTOM_EXT_DIR)) {
280 - $extensions = array_merge($extensions, glob(WPO_CACHE_CUSTOM_EXT_DIR . '/*.php'));
347 + function wpo_cache_load_extensions() {
348 + $extensions = glob(WPO_CACHE_EXT_DIR . '/*.php');
349 +
350 + // Add external extensions
351 + if (defined('WPO_CACHE_CUSTOM_EXT_DIR') && is_dir(WPO_CACHE_CUSTOM_EXT_DIR)) {
352 + $extensions = array_merge($extensions, glob(WPO_CACHE_CUSTOM_EXT_DIR . '/*.php'));
353 + }
354 +
355 + if (empty($extensions)) return;
356 +
357 + foreach ($extensions as $extension) {
358 + if (is_file($extension)) require_once $extension;
359 + }
281 360 }
361 +endif;
282 362
283 - if (empty($extensions)) return;
363 +/**
364 + * Check whether the current request is a search query.
365 + *
366 + * Uses `is_search()` when available and falls back to checking for a
367 + * * non-empty string `s` query parameter for early execution points.
368 + *
369 + * @return bool True if a search query parameter is present, false otherwise.
370 + */
371 +if (!function_exists('wpo_is_search')) {
372 + function wpo_is_search(): bool {
373 + if (function_exists('is_search') && is_search()) {
374 + return true;
375 + }
284 376
285 - foreach ($extensions as $extension) {
286 - if (is_file($extension)) require_once $extension;
377 + return isset($_GET['s']) && is_string($_GET['s']) && '' !== trim($_GET['s']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Executes before WP fully loads, Nonce not available, only comparing
287 378 }
288 379 }
289 -endif;
290 380
381 +/**
382 + * Determine whether the current request represents a page type
383 + * that should not be cached.
384 + *
385 + * This function checks for known non-cacheable scenarios such as
386 + * - Search results
387 + * - 404 pages
388 + * - Password-protected content
389 + * - Front page when excluded via settings
390 + * - RSS feeds (when feed caching is disabled)
391 + * - Unsafe file paths (e.g., .htaccess)
392 + *
393 + * The first matched restriction reason will overwrite the passed
394 + * value and be returned as a human-readable string.
395 + *
396 + * @param string $restricted Existing restriction reason, if any.
397 + * @return string Restriction reason if caching is disallowed, otherwise the original value passed in `$restricted`.
398 + */
291 399 if (!function_exists('wpo_restricted_cache_page_type')) {
292 -function wpo_restricted_cache_page_type($restricted) {
293 - global $post;
294 -
295 - // Don't cache search or password protected.
296 - if ((function_exists('is_search') && is_search()) || (function_exists('is_404') && is_404()) || !empty($post->post_password)) {
297 - $restricted = __('Page type is not cacheable (search, 404 or password-protected)', 'wp-optimize');
400 + function wpo_restricted_cache_page_type($restricted) {
401 + global $post;
402 +
403 + // Don't cache search or password protected.
404 + if (wpo_is_search() || (function_exists('bbp_is_search') && bbp_is_search()) || (function_exists('is_404') && is_404()) || !empty($post->post_password)) {
405 + $restricted = 'Page type is not cacheable (search, 404 or password-protected)';
406 + }
407 +
408 + // Don't cache the front page if option is set.
409 + if (in_array('/', wpo_get_url_exceptions()) && function_exists('is_front_page') && is_front_page()) {
410 +
411 + $restricted = __('In the settings, caching is disabled for the front page', 'wp-optimize');
412 + }
413 +
414 + // Don't cache htacesss. Remember to properly escape any output to prevent injection.
415 + $request_uri = isset($_SERVER['REQUEST_URI']) ? htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- WP functions may not be available, so using php native functions. only outputting to browser
416 + if (strpos($request_uri, '.htaccess') !== false) {
417 + $restricted = 'The file path is unsuitable for caching ('.$request_uri.')';
418 + }
419 +
420 + // Don't cache feeds.
421 + if (function_exists('is_feed') && is_feed() && !wpo_feeds_caching_enabled()) {
422 + $restricted = __('We don\'t cache RSS feeds', 'wp-optimize');
423 + }
424 +
425 + return $restricted;
298 426 }
299 -
300 - // Don't cache the front page if option is set.
301 - if (in_array('/', wpo_get_url_exceptions()) && function_exists('is_front_page') && is_front_page()) {
302 -
303 - $restricted = __('In the settings, caching is disabled for the front page', 'wp-optimize');
304 - }
305 -
306 - // Don't cache htacesss. Remember to properly escape any output to prevent injection.
307 - if (strpos($_SERVER['REQUEST_URI'], '.htaccess') !== false) {
308 - $restricted = 'The file path is unsuitable for caching ('.$_SERVER['REQUEST_URI'].')';
309 - }
310 -
311 - // Don't cache feeds.
312 - if (function_exists('is_feed') && is_feed() && !wpo_feeds_caching_enabled()) {
313 - $restricted = __('We don\'t cache RSS feeds', 'wp-optimize');
314 - }
315 -
316 - return $restricted;
317 427 }
318 -}
319 428
320 429 /**
321 430 * Returns true if we need cache content for loggedin users.
322 431 *
@@ -322,11 +431,11 @@
322 431 *
323 432 * @return bool
324 433 */
325 434 if (!function_exists('wpo_cache_loggedin_users')) :
326 -function wpo_cache_loggedin_users() {
327 - return !empty($GLOBALS['wpo_cache_config']['enable_user_caching']) || !empty($GLOBALS['wpo_cache_config']['enable_user_specific_cache']) || (function_exists('wpo_we_cache_per_role') && wpo_we_cache_per_role());
328 -}
435 + function wpo_cache_loggedin_users() {
436 + return !empty($GLOBALS['wpo_cache_config']['enable_user_caching']) || !empty($GLOBALS['wpo_cache_config']['enable_user_specific_cache']) || (function_exists('wpo_we_cache_per_role') && wpo_we_cache_per_role());
437 + }
329 438 endif;
330 439
331 440 /**
332 441 * Get filename for store cache, depending on gzip, mobile and cookie settings.
@@ -334,84 +443,131 @@
334 443 * @param string $ext
335 444 * @return string
336 445 */
337 446 if (!function_exists('wpo_cache_filename')) :
338 -function wpo_cache_filename($ext = '.html') {
447 + function wpo_cache_filename($ext = '.html') {
448 +
449 + $wpo_cache_filename_debug = array();
450 +
451 + $filename = 'index';
452 +
453 + if (wpo_cache_mobile_caching_enabled() && wpo_is_mobile()) {
454 + $filename = 'mobile.' . $filename;
455 + }
339 456
340 - $wpo_cache_filename_debug = array();
341 -
342 - $filename = 'index';
343 -
344 - if (wpo_cache_mobile_caching_enabled() && wpo_is_mobile()) {
345 - $filename = 'mobile.' . $filename;
346 - }
347 -
348 - if (wpo_webp_images_enabled() && !wpo_is_using_webp_images_redirection() && wpo_is_using_alter_html()) {
349 - $filename = $filename . '.webp';
350 - }
351 -
352 - $cookies = wpo_cache_cookies();
353 -
354 - $cache_key = '';
355 -
356 - /**
357 - * Add cookie values to filename if need.
358 - * This section was inspired by things learned from WP-Rocket.
359 - */
360 - if (!empty($cookies)) {
361 - foreach ($cookies as $key => $cookie_name) {
362 - if (is_array($cookie_name) && isset($_COOKIE[$key])) {
363 - foreach ($cookie_name as $cookie_key) {
364 - if (isset($_COOKIE[$key][$cookie_key]) && '' !== $_COOKIE[$key][$cookie_key]) {
365 - $_cache_key = $cookie_key.'='.$_COOKIE[$key][$cookie_key];
366 - $_cache_key = preg_replace('/[^a-z0-9_\-\=]/i', '-', $_cache_key);
367 - $cache_key .= '-' . $_cache_key;
368 - $wpo_cache_filename_debug[] = 'Cookie: name: ' . $key . '[' . $cookie_key . '], value: *** , cache_key:' . $_cache_key;
457 + if (wpo_webp_images_enabled() && !wpo_is_using_webp_images_redirection() && wpo_is_browser_supports_webp()) {
458 + $filename = $filename . '.webp';
459 + }
460 +
461 + $cookies = wpo_cache_cookies();
462 +
463 + $cache_key = '';
464 +
465 + /**
466 + * Add cookie values to filename if need.
467 + * This section was inspired by things learned from WP-Rocket.
468 + */
469 + if (!empty($cookies)) {
470 + foreach ($cookies as $key => $cookie_name) {
471 + if (is_array($cookie_name) && isset($_COOKIE[$key])) {
472 + foreach ($cookie_name as $cookie_key) {
473 + if (isset($_COOKIE[$key][$cookie_key]) && '' !== $_COOKIE[$key][$cookie_key]) {
474 + $cookie_value = $_COOKIE[$key][$cookie_key]; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- WP functions may not available, also removing everything except letters, numbers, hyphens and equal signs below
475 + $_cache_key = $cookie_key.'='.$cookie_value;
476 + $_cache_key = preg_replace('/[^a-z0-9_\-\=]/i', '-', $_cache_key);
477 + $cache_key .= '-' . $_cache_key;
478 + $wpo_cache_filename_debug[] = 'Cookie: name: ' . $key . '[' . $cookie_key . '], value: *** , cache_key:' . $_cache_key;
479 + }
369 480 }
481 + continue;
370 482 }
371 - continue;
483 +
484 + if (isset($_COOKIE[$cookie_name]) && '' !== $_COOKIE[$cookie_name]) {
485 + $_cache_key = $cookie_name.'='. $_COOKIE[$cookie_name]; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- WP functions may not available, also removing everything except letters, numbers, hyphens and equal signs below
486 + $_cache_key = preg_replace('/[^a-z0-9_\-\=]/i', '-', $_cache_key);
487 + $cache_key .= '-' . $_cache_key;
488 + $wpo_cache_filename_debug[] = 'Cookie: name: ' . $cookie_name . ', value: *** , cache_key:' . $_cache_key;
489 + }
372 490 }
373 -
374 - if (isset($_COOKIE[$cookie_name]) && '' !== $_COOKIE[$cookie_name]) {
375 - $_cache_key = $cookie_name.'='.$_COOKIE[$cookie_name];
376 - $_cache_key = preg_replace('/[^a-z0-9_\-\=]/i', '-', $_cache_key);
377 - $cache_key .= '-' . $_cache_key;
378 - $wpo_cache_filename_debug[] = 'Cookie: name: ' . $cookie_name . ', value: *** , cache_key:' . $_cache_key;
491 + }
492 +
493 + $query_variables = wpo_cache_query_variables();
494 +
495 + /**
496 + * Add GET variables to cache file name if need.
497 + */
498 + if (!empty($query_variables)) {
499 + foreach ($query_variables as $variable) {
500 + if (isset($_GET[$variable]) && '' !== $_GET[$variable]) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Executes before WP fully loads, Nonce not available
501 + $query_variable_value = $_GET[$variable]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- WP functions may not available, also removing everything except letters, numbers, hyphens and equal signs below
502 + $_cache_key = $variable.'='.$query_variable_value;
503 + $_cache_key = preg_replace('/[^a-z0-9_\-\=]/i', '-', $_cache_key);
504 + $cache_key .= '-' . $_cache_key;
505 + $wpo_cache_filename_debug[] = 'GET parameter: name: ' . $variable . ', value:' . htmlentities($query_variable_value) . ', cache_key:' . $_cache_key;
506 + }
379 507 }
380 508 }
509 +
510 + $filename = wpo_build_cache_filename($filename, $cache_key);
511 + $filename = apply_filters('wpo_cache_filename', $filename);
512 +
513 + $wpo_cache_filename_debug[] = 'Extension: ' . $ext;
514 + $wpo_cache_filename_debug[] = 'Filename: ' . $filename;
515 +
516 + $GLOBALS['wpo_cache_filename_debug'] = $wpo_cache_filename_debug;
517 +
518 + return $filename . $ext;
381 519 }
520 +endif;
382 521
383 - $query_variables = wpo_cache_query_variables();
384 522
385 - /**
386 - * Add GET variables to cache file name if need.
387 - */
388 - if (!empty($query_variables)) {
389 - foreach ($query_variables as $variable) {
390 - if (isset($_GET[$variable]) && !empty($_GET[$variable])) {
391 - $_cache_key = $variable.'='.$_GET[$variable];
392 - $_cache_key = preg_replace('/[^a-z0-9_\-\=]/i', '-', $_cache_key);
393 - $cache_key .= '-' . $_cache_key;
394 - $wpo_cache_filename_debug[] = 'GET parameter: name: ' . $variable . ', value:' . htmlentities($_GET[$variable]) . ', cache_key:' . $_cache_key;
395 - }
396 - }
523 +if (!function_exists('wpo_build_cache_filename')) :
524 +/**
525 + * Builds a cache filename using the original filename, cache key,
526 + * and trims it if it exceeds the file system limit.
527 + *
528 + * @param string $filename
529 + * @param string $cache_key
530 + * @return string
531 + */
532 +function wpo_build_cache_filename($filename, $cache_key) {
533 + if ('' !== $cache_key) {
534 + // Add human-readable cache key to the filename
535 + $filename .= preg_replace('/\-+/', '-', '-'.$cache_key);
397 536 }
398 537
399 - // add hash of queried cookies and variables to cache file name.
400 - if ('' !== $cache_key) {
401 - $hash = md5($cache_key);
402 - $filename .= '-'.$hash;
403 - $wpo_cache_filename_debug[] = 'Hash: ' . $hash;
538 + // Trimming filename if it exceeds 240 characters due to filesystem limitations
539 + if (strlen($filename) > 240) {
540 + $filename = substr($filename, 0, 199) . '-' . sha1($filename);
404 541 }
405 542
406 - $filename = apply_filters('wpo_cache_filename', $filename);
543 + return $filename;
544 +}
545 +endif;
407 546
408 - $wpo_cache_filename_debug[] = 'Extension: ' . $ext;
409 - $wpo_cache_filename_debug[] = 'Filename: ' . $filename.$ext;
410 547
411 - $GLOBALS['wpo_cache_filename_debug'] = $wpo_cache_filename_debug;
548 +if (!function_exists('wpo_rest_cache_filename')) :
549 +/**
550 + * Builds the rest cache filename, uses passed params.
551 + *
552 + * @param array $params
553 + * @return string
554 + */
555 +function wpo_rest_cache_filename($params) {
556 + $filename = 'index';
557 + $cache_key = '';
412 558
413 - return $filename . $ext;
559 + if (!empty($params)) {
560 + ksort($params);
561 + foreach ($params as $key => $value) {
562 + if (is_array($value)) $value = serialize($value);
563 + $_cache_key = $key.'_'.$value;
564 + $_cache_key = preg_replace('/[^a-z0-9_\-]/i', '-', $_cache_key);
565 + $cache_key .= '-' . $_cache_key;
566 + }
567 + }
568 +
569 + return wpo_build_cache_filename($filename, $cache_key) . '.json';
414 570 }
415 571 endif;
416 572
417 573 /**
@@ -417,14 +573,14 @@
417 573 /**
418 574 * Returns site url from site_url() function or if it is not available from cache configuration.
419 575 */
420 576 if (!function_exists('wpo_site_url')) :
421 -function wpo_site_url() {
422 - if (is_callable('site_url')) return site_url('/');
423 -
424 - $site_url = empty($GLOBALS['wpo_cache_config']['site_url']) ? '' : $GLOBALS['wpo_cache_config']['site_url'];
425 - return $site_url;
426 -}
577 + function wpo_site_url() {
578 + if (is_callable('site_url')) return site_url('/');
579 +
580 + $site_url = empty($GLOBALS['wpo_cache_config']['site_url']) ? '' : $GLOBALS['wpo_cache_config']['site_url'];
581 + return $site_url;
582 + }
427 583 endif;
428 584
429 585 /**
430 586 * Get cookie names which impact on cache file name.
@@ -431,12 +587,12 @@
431 587 *
432 588 * @return array
433 589 */
434 590 if (!function_exists('wpo_cache_cookies')) :
435 -function wpo_cache_cookies() {
436 - $cookies = empty($GLOBALS['wpo_cache_config']['wpo_cache_cookies']) ? array() : $GLOBALS['wpo_cache_config']['wpo_cache_cookies'];
437 - return $cookies;
438 -}
591 + function wpo_cache_cookies() {
592 + $cookies = empty($GLOBALS['wpo_cache_config']['wpo_cache_cookies']) ? array() : $GLOBALS['wpo_cache_config']['wpo_cache_cookies'];
593 + return $cookies;
594 + }
439 595 endif;
440 596
441 597 /**
442 598 * Get GET variable names which impact on cache file name.
@@ -443,21 +599,21 @@
443 599 *
444 600 * @return array
445 601 */
446 602 if (!function_exists('wpo_cache_query_variables')) :
447 -function wpo_cache_query_variables() {
448 - if (defined('WPO_CACHE_URL_PARAMS') && WPO_CACHE_URL_PARAMS) {
449 - $variables = array_keys($_GET);
450 - } else {
451 - $variables = empty($GLOBALS['wpo_cache_config']['wpo_cache_query_variables']) ? array() : $GLOBALS['wpo_cache_config']['wpo_cache_query_variables'];
603 + function wpo_cache_query_variables() {
604 + if (defined('WPO_CACHE_URL_PARAMS') && WPO_CACHE_URL_PARAMS) {
605 + $variables = array_keys($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Executes early, nonce not available
606 + } else {
607 + $variables = empty($GLOBALS['wpo_cache_config']['wpo_cache_query_variables']) ? array() : $GLOBALS['wpo_cache_config']['wpo_cache_query_variables'];
608 + }
609 +
610 + if (!empty($variables)) {
611 + sort($variables);
612 + }
613 +
614 + return wpo_cache_maybe_ignore_query_variables($variables);
452 615 }
453 -
454 - if (!empty($variables)) {
455 - sort($variables);
456 - }
457 -
458 - return wpo_cache_maybe_ignore_query_variables($variables);
459 -}
460 616 endif;
461 617
462 618 /**
463 619 * Get list of all received HTTP headers.
@@ -464,37 +620,37 @@
464 620 *
465 621 * @return array
466 622 */
467 623 if (!function_exists('wpo_get_http_headers')) :
468 -function wpo_get_http_headers() {
469 -
470 - static $headers;
471 -
472 - if (!empty($headers)) return $headers;
473 -
474 - $headers = array();
475 -
476 - // if is apache server then use get allheaders() function.
477 - if (function_exists('getallheaders')) {
478 - $headers = getallheaders();
479 - } else {
480 - // https://www.php.net/manual/en/function.getallheaders.php
481 - foreach ($_SERVER as $key => $value) {
482 -
483 - $key = strtolower($key);
484 -
485 - if ('HTTP_' == substr($key, 0, 5)) {
486 - $headers[str_replace(' ', '-', ucwords(str_replace('_', ' ', substr($key, 5))))] = $value;
487 - } elseif ('content_type' == $key) {
488 - $headers["Content-Type"] = $value;
489 - } elseif ('content_length' == $key) {
490 - $headers["Content-Length"] = $value;
624 + function wpo_get_http_headers() {
625 +
626 + static $headers;
627 +
628 + if (!empty($headers)) return $headers;
629 +
630 + $headers = array();
631 +
632 + // if is apache server then use get allheaders() function.
633 + if (function_exists('getallheaders')) {
634 + $headers = getallheaders();
635 + } else {
636 + // https://www.php.net/manual/en/function.getallheaders.php
637 + foreach ($_SERVER as $key => $value) {
638 +
639 + $key = strtolower($key);
640 +
641 + if ('HTTP_' === substr($key, 0, 5)) {
642 + $headers[str_replace(' ', '-', ucwords(str_replace('_', ' ', substr($key, 5))))] = $value;
643 + } elseif ('content_type' === $key) {
644 + $headers["Content-Type"] = $value;
645 + } elseif ('content_length' === $key) {
646 + $headers["Content-Length"] = $value;
647 + }
491 648 }
492 649 }
650 +
651 + return $headers;
493 652 }
494 -
495 - return $headers;
496 -}
497 653 endif;
498 654
499 655 /**
500 656 * Check if requested Accept-Encoding headers has gzip value.
@@ -501,15 +657,15 @@
501 657 *
502 658 * @return bool
503 659 */
504 660 if (!function_exists('wpo_cache_gzip_accepted')) :
505 -function wpo_cache_gzip_accepted() {
506 - $headers = wpo_get_http_headers();
507 -
508 - if (isset($headers['Accept-Encoding']) && preg_match('/gzip/i', $headers['Accept-Encoding'])) return true;
509 -
510 - return false;
511 -}
661 + function wpo_cache_gzip_accepted() {
662 + $headers = wpo_get_http_headers();
663 +
664 + if (isset($headers['Accept-Encoding']) && preg_match('/gzip/i', $headers['Accept-Encoding'])) return true;
665 +
666 + return false;
667 + }
512 668 endif;
513 669
514 670 /**
515 671 * Check if we can output gzip content in current answer, i.e. check Accept-Encoding headers has gzip value
@@ -517,11 +673,11 @@
517 673 *
518 674 * @return bool
519 675 */
520 676 if (!function_exists('wpo_cache_can_output_gzip_content')) :
521 -function wpo_cache_can_output_gzip_content() {
522 - return wpo_cache_gzip_accepted() && function_exists('ob_gzhandler');
523 -}
677 + function wpo_cache_can_output_gzip_content() {
678 + return wpo_cache_gzip_accepted() && function_exists('ob_gzhandler');
679 + }
524 680 endif;
525 681
526 682 /**
527 683 * Check if header with certain name exists in already prepared headers and has value comparable with $header_value.
@@ -531,29 +687,29 @@
531 687 *
532 688 * @return bool
533 689 */
534 690 if (!function_exists('wpo_cache_is_in_response_headers_list')) :
535 -function wpo_cache_is_in_response_headers_list($header_name, $header_value) {
536 - $headers_list = headers_list();
537 -
538 - if (!empty($headers_list)) {
539 - $header_name = strtolower($header_name);
540 -
541 - foreach ($headers_list as $value) {
542 - $value = explode(':', $value);
543 -
544 - if (strtolower($value[0]) == $header_name) {
545 - if (preg_match('/'.$header_value.'/', $value[1])) {
546 - return true;
547 - } else {
548 - return false;
691 + function wpo_cache_is_in_response_headers_list($header_name, $header_value) {
692 + $headers_list = headers_list();
693 +
694 + if (!empty($headers_list)) {
695 + $header_name = strtolower($header_name);
696 +
697 + foreach ($headers_list as $value) {
698 + $value = explode(':', $value);
699 +
700 + if (strtolower($value[0]) === $header_name) {
701 + if (preg_match('/'.$header_value.'/', $value[1])) {
702 + return true;
703 + } else {
704 + return false;
705 + }
549 706 }
550 707 }
551 708 }
709 +
710 + return false;
552 711 }
553 -
554 - return false;
555 -}
556 712 endif;
557 713
558 714 /**
559 715 * Check if mobile cache is enabled and current request is from moblile device.
@@ -560,12 +716,12 @@
560 716 *
561 717 * @return bool
562 718 */
563 719 if (!function_exists('wpo_cache_mobile_caching_enabled')) :
564 -function wpo_cache_mobile_caching_enabled() {
565 - if (!empty($GLOBALS['wpo_cache_config']['enable_mobile_caching'])) return true;
566 - return false;
567 -}
720 + function wpo_cache_mobile_caching_enabled() {
721 + if (!empty($GLOBALS['wpo_cache_config']['enable_mobile_caching'])) return true;
722 + return false;
723 + }
568 724 endif;
569 725
570 726 /**
571 727 * Check if webp images enabled
@@ -579,15 +735,27 @@
579 735 }
580 736 endif;
581 737
582 738 /**
583 - * Check whether webp images using alter html method or not
739 + * Check whether the requesting browser supports WebP images
584 740 *
585 741 * @return bool
586 742 */
587 -if (!function_exists('wpo_is_using_alter_html')) :
588 - function wpo_is_using_alter_html() {
589 - return (isset($_SERVER['HTTP_ACCEPT']) && false !== strpos($_SERVER['HTTP_ACCEPT'], 'image/webp'));
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;
590 758 }
591 759 endif;
592 760
593 761 /**
@@ -597,15 +765,15 @@
597 765 */
598 766 if (!function_exists('wpo_is_using_webp_images_redirection')) :
599 767 function wpo_is_using_webp_images_redirection() {
600 768 if (empty($GLOBALS['wpo_cache_config']['uploads'])) return false;
601 -
769 +
602 770 $uploads_dir = $GLOBALS['wpo_cache_config']['uploads'];
603 771 $htaccess_file = $uploads_dir . '/.htaccess';
604 772 if (!file_exists($htaccess_file)) return false;
605 - $htaccess_content = file_get_contents($htaccess_file);
773 + $htaccess_content = file_get_contents($htaccess_file); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem not available this early
606 774 $comment_sections = array('Register webp mime type', 'WP-Optimize WebP Rules');
607 -
775 +
608 776 if (function_exists('str_contains')) {
609 777 return str_contains($htaccess_content, $comment_sections[0]) && str_contains($htaccess_content, $comment_sections[1]);
610 778 } else {
611 779 return strpos($htaccess_content, $comment_sections[0]) && strpos($htaccess_content, $comment_sections[1]);
@@ -619,9 +787,9 @@
619 787 * @return bool
620 788 */
621 789 if (!function_exists('wpo_is_activity_stream_requested')) :
622 790 function wpo_is_activity_stream_requested() {
623 - return (isset($_SERVER['HTTP_ACCEPT']) && false !== strpos($_SERVER['HTTP_ACCEPT'], 'application/activity+json'));
791 + return (isset($_SERVER['HTTP_ACCEPT']) && preg_match('/(application\/(ld\+json|activity\+json|json))/i', $_SERVER['HTTP_ACCEPT'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Executes before WP fully loads, only doing string comparison
624 792 }
625 793 endif;
626 794
627 795 /**
@@ -628,112 +796,358 @@
628 796 * Verify if the current request is robots.txt
629 797 */
630 798 if (!function_exists('wpo_is_robots_txt_requested')) :
631 799 function wpo_is_robots_txt_requested() {
632 - return (isset($_SERVER['REQUEST_URI']) && 'robots.txt' === basename($_SERVER['REQUEST_URI']));
800 + return (isset($_SERVER['REQUEST_URI']) && 'robots.txt' === basename($_SERVER['REQUEST_URI'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended -- Executes before WP fully loads, only doing string comparison
633 801 }
634 802 endif;
635 803
636 804 /**
637 805 * Serves the cache and exits
806 + *
807 + * @return void
638 808 */
639 809 if (!function_exists('wpo_serve_cache')) :
640 -function wpo_serve_cache() {
641 - $file_name = wpo_cache_filename();
810 + function wpo_serve_cache() {
811 + // Do not serve cache for cron requests.
812 + if (defined('DOING_CRON') && DOING_CRON) return;
813 +
814 + $file_name = wpo_cache_filename();
642 815
643 - $file_name_rss_xml = wpo_cache_filename('.rss-xml');
644 - $send_as_feed = false;
816 + if (defined('WPO_CACHE_DONT_PROCESS_THIS_PAGE') && WPO_CACHE_DONT_PROCESS_THIS_PAGE) return;
817 +
818 + $file_name_rss_xml = wpo_cache_filename('.rss-xml');
819 + $send_as_feed = false;
820 + $send_as_rest_response = false;
821 + $headers_file = '';
645 822
646 - $path_dir = WPO_CACHE_FILES_DIR . '/' . wpo_get_url_path() . '/';
647 - $path = $path_dir . $file_name;
823 + $path_dir = WPO_CACHE_FILES_DIR . '/' . wpo_get_url_path() . '/';
824 + $path = $path_dir . $file_name;
825 +
826 + if (wpo_feeds_caching_enabled()) {
827 + // check for .xml cache file if .html cache file doesn't exist
828 + if (!file_exists($path_dir . $file_name) && file_exists($path_dir . $file_name_rss_xml)) {
829 + $path = $path_dir . $file_name_rss_xml;
830 + $send_as_feed = true;
831 + }
832 + }
833 +
834 + if (wpo_rest_caching_enabled()) {
835 + $file_name_rest_json = wpo_rest_cache_filename($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Executes early, nonce not available
648 836
649 - if (wpo_feeds_caching_enabled()) {
650 - // check for .xml cache file if .html cache file doesn't exist
651 - if (!file_exists($path_dir . $file_name) && file_exists($path_dir . $file_name_rss_xml)) {
652 - $path = $path_dir . $file_name_rss_xml;
653 - $send_as_feed = true;
837 + if (is_file($path_dir . $file_name_rest_json)) {
838 + $path = $path_dir . $file_name_rest_json;
839 +
840 + if (is_file($path . '.headers')) {
841 + $headers_file = $path . '.headers';
842 + }
843 +
844 + $send_as_rest_response = true;
845 + }
654 846 }
847 +
848 + $use_gzip = wpo_serve_cache_should_use_gzip($path);
849 +
850 + if ($use_gzip) $path .= '.gz';
851 +
852 + $modified_time = file_exists($path) ? (int) filemtime($path) : time();
853 +
854 + $modified_time = apply_filters('wpo_cache_modified_time', $modified_time, $path);
855 +
856 + // Cache has expired, purge and exit.
857 + if (!empty($GLOBALS['wpo_cache_config']['page_cache_length'])) {
858 + if (time() > ($GLOBALS['wpo_cache_config']['page_cache_length'] + $modified_time)) {
859 + wpo_delete_files($path);
860 + return;
861 + }
862 + }
863 +
864 + if ($use_gzip) {
865 + // Disable zlib output compression to avoid double content compression
866 + ini_set('zlib.output_compression', 'Off'); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- disabling php gzip to avoid double compression.
867 + }
868 +
869 + header('Cache-Control: no-cache'); // Check back later
870 +
871 + if (!empty($modified_time) && !empty($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) === $modified_time) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- `strtotime` ensures that the value is an integer
872 +
873 + if ($use_gzip) wpo_send_gzip_header();
874 +
875 + if ($send_as_feed) {
876 + header('Content-type: application/rss+xml');
877 + }
878 +
879 + $allowed_protocols = array('HTTP/1.0', 'HTTP/1.1', 'HTTP/2', 'HTTP/3');
880 + $protocol = $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Header value
881 + if (in_array($protocol, $allowed_protocols, true)) {
882 + $safe_protocol = $protocol;
883 + } else {
884 + $safe_protocol = 'HTTP/1.1';
885 + }
886 +
887 + header('WPO-Cache-Status: cached');
888 + header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $modified_time) . ' GMT');
889 + header( $safe_protocol. ' 304 Not Modified', true, 304);
890 + exit;
891 + }
892 +
893 + if (file_exists($path) && is_readable($path)) {
894 +
895 + if (!$send_as_rest_response && wpo_is_canonical_redirection_needed()) return;
896 +
897 + if ($use_gzip) wpo_send_gzip_header();
898 +
899 + header('WPO-Cache-Status: cached');
900 +
901 + if ($send_as_rest_response) {
902 + wpo_send_rest_cache_headers($headers_file);
903 + }
904 +
905 + // send correct headers for xml and txt files
906 + $filename = basename(dirname($path));
907 +
908 + if (preg_match('/\.xml$/i', $filename)) {
909 + header('Content-type: text/xml');
910 + }
911 +
912 + if (preg_match('/\.txt$/i', $filename)) {
913 + header('Content-type: text/plain');
914 + }
915 +
916 + if ($send_as_feed) {
917 + header('Content-type: application/rss+xml');
918 + }
919 +
920 + if (!empty($modified_time)) {
921 + header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $modified_time) . ' GMT');
922 + }
923 +
924 + readfile($path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- If we use `get_contents` we need to echo it, it will result in not escaped error
925 + exit;
926 +
927 + }
655 928 }
929 +endif;
656 930
657 - $use_gzip = false;
931 +/**
932 + * Checks if we should use gzip in response when serve cache
933 + *
934 + * @param string $path - path to the cached file
935 + * @return bool
936 + */
937 +if (!function_exists('wpo_serve_cache_should_use_gzip')) :
938 + function wpo_serve_cache_should_use_gzip($path) {
939 + // if we can use gzip and gzipped file exist in cache we use it.
940 + // if headers already sent we don't use gzipped file content.
941 + return !headers_sent() && wpo_cache_gzip_accepted() && file_exists($path . '.gz');
942 + }
943 +endif;
658 944
659 - // if we can use gzip and gzipped file exist in cache we use it.
660 - // if headers already sent we don't use gzipped file content.
661 - if (!headers_sent() && wpo_cache_gzip_accepted() && file_exists($path . '.gz')) {
662 - $path .= '.gz';
663 - $use_gzip = true;
945 +/**
946 + * Sends the Content-Encoding: gzip header if it has not already been sent
947 + *
948 + * @return void
949 + */
950 +if (!function_exists('wpo_send_gzip_header')) :
951 + function wpo_send_gzip_header() {
952 + $gzip_header_already_sent = wpo_cache_is_in_response_headers_list('Content-Encoding', 'gzip');
953 + if (!$gzip_header_already_sent) header('Content-Encoding: gzip');
664 954 }
955 +endif;
665 956
666 - $modified_time = file_exists($path) ? (int) filemtime($path) : time();
957 +/**
958 + * Sends the necessary and cached headers for the REST response.
959 + *
960 + * @param string $headers_file file with cached headers
961 + * @return void
962 + */
963 +if (!function_exists('wpo_send_rest_cache_headers')) :
964 + function wpo_send_rest_cache_headers($headers_file) {
965 + header('Content-type: application/json');
966 + header('Cache-Control: no-store');
967 + header('X-Content-Type-Options: nosniff');
968 + header('X-Robots-Tag: noindex');
969 +
970 + if ('' !== $headers_file) {
971 + $headers_json = file_get_contents($headers_file);
972 + $headers_to_send = json_decode($headers_json, true);
973 +
974 + if (!empty($headers_to_send) && is_array($headers_to_send)) {
975 + foreach ($headers_to_send as $header => $value) {
976 + header($header.': '.$value);
977 + }
978 + }
979 + }
980 + }
981 +endif;
667 982
668 - // Cache has expired, purge and exit.
669 - if (!empty($GLOBALS['wpo_cache_config']['page_cache_length'])) {
670 - if (time() > ($GLOBALS['wpo_cache_config']['page_cache_length'] + $modified_time)) {
671 - wpo_delete_files($path);
672 - return;
673 - }
983 +/**
984 + * Check if all requirements needed to serve the cache are met.
985 + *
986 + * @return bool|array returns false or an array with messages if one of the requirements is not met
987 + */
988 +if (!function_exists('wpo_can_serve_from_cache')) :
989 +function wpo_can_serve_from_cache() {
990 +
991 + $no_cache_because = array();
992 +
993 + if (wpo_is_robots_txt_requested()) {
994 + return false;
674 995 }
675 996
676 - if ($use_gzip) {
677 - // Disable zlib output compression to avoid double content compression
678 - ini_set('zlib.output_compression', 'Off');
997 + if (wpo_is_activity_stream_requested()) {
998 + return false;
679 999 }
680 1000
681 - $gzip_header_already_sent = wpo_cache_is_in_response_headers_list('Content-Encoding', 'gzip');
1001 + // Fix for compatibility issue with Jetpack's infinity scroll feature
1002 + if (isset($_GET['infinity']) && 'scrolling' === $_GET['infinity']) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Executes early, nonce not available
1003 + return false;
1004 + }
682 1005
683 - header('Cache-Control: no-cache'); // Check back later
1006 + // check in not disabled current user agent
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
1008 + if (!empty($user_agent) && false === wpo_is_accepted_user_agent($user_agent)) {
1009 + $no_cache_because[] = "In the settings, caching is disabled for matches for this request's user agent";
1010 + }
684 1011
685 - if (!empty($modified_time) && !empty($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) === $modified_time) {
686 - if ($use_gzip && !$gzip_header_already_sent) {
687 - header('Content-Encoding: gzip');
1012 + $is_cache_page_forced = function_exists('apply_filters') ? apply_filters('wpo_cache_page_force', false) : false;
1013 + $is_get_request = isset($_SERVER['REQUEST_METHOD']) && 'GET' === $_SERVER['REQUEST_METHOD'];
1014 +
1015 + // Don't cache non-GET requests.
1016 + if (!$is_cache_page_forced && !$is_get_request) {
1017 + $no_cache_because[] = 'The request method was not GET ('.(isset($_SERVER['REQUEST_METHOD']) ? htmlspecialchars($_SERVER['REQUEST_METHOD'], ENT_QUOTES, 'UTF-8') : '-').')'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Not needed only using it to display
1018 + }
1019 +
1020 + // Don't cache if logged in.
1021 + if (!empty($_COOKIE)) {
1022 +
1023 + if (!wpo_cache_loggedin_users() && wpo_is_wp_user_cookies_exist()) {
1024 + $no_cache_because[] = 'WordPress login cookies were detected';
688 1025 }
689 -
690 - if ($send_as_feed) {
691 - header('Content-type: application/rss+xml');
1026 +
1027 + if (!empty($_COOKIE['wpo_commented_post'])) {
1028 + $no_cache_because[] = 'The user has commented on a post (comment cookie set)';
692 1029 }
1030 +
1031 + // get cookie exceptions from options.
1032 + $cache_exception_cookies = empty($GLOBALS['wpo_cache_config']['cache_exception_cookies']) ? array() : $GLOBALS['wpo_cache_config']['cache_exception_cookies'];
1033 +
1034 + // check if any cookie exists from an exception list.
1035 + if (!empty($cache_exception_cookies)) {
1036 + foreach ($_COOKIE as $key => $value) {
1037 + foreach ($cache_exception_cookies as $cookie) {
1038 + if ('' !== trim($cookie) && false !== strpos($key, $cookie)) {
1039 + $no_cache_because[] = 'An excepted cookie was set ('.$key.')';
1040 + break 2;
1041 + }
1042 + }
1043 + }
1044 + }
1045 + }
693 1046
694 - header('WPO-Cache-Status: cached');
695 - header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $modified_time) . ' GMT');
696 - header($_SERVER['SERVER_PROTOCOL'] . ' 304 Not Modified', true, 304);
697 - exit;
1047 + if (wpo_restricted_cache_page_with_cart_items()) {
1048 + $no_cache_because[] = 'User has items in WooCommerce cart.';
698 1049 }
699 1050
700 - if (file_exists($path) && is_readable($path)) {
1051 + $restricted_page_type_cache = wpo_restricted_cache_page_type('');
1052 + if (!empty($restricted_page_type_cache)) {
1053 + $no_cache_because[] = $restricted_page_type_cache;
1054 + }
701 1055
702 - if (wpo_is_canonical_redirection_needed()) return;
1056 + $current_url = wpo_current_url();
703 1057
704 - if ($use_gzip && !$gzip_header_already_sent) {
705 - header('Content-Encoding: gzip');
706 - }
1058 + // Deal with allowed urls
1059 + if (wpo_cache_specific_urls_only() && !wpo_url_in_cache_include($current_url)) {
1060 + $no_cache_because[] = 'Cache only specific URLs enabled, but URL not in list';
1061 + }
707 1062
708 - // send correct headers for xml and txt files
709 - $filename = basename(dirname($path));
1063 + // Deal with optional cache exceptions only when specific-URL caching is disabled
1064 + if (!wpo_cache_specific_urls_only() && wpo_url_in_exceptions($current_url)) {
1065 + $no_cache_because[] = 'In the settings, caching is disabled for matches for the current URL';
1066 + }
710 1067
711 - if (preg_match('/\.xml$/i', $filename)) {
712 - header('Content-type: text/xml');
713 - }
1068 + if (!empty($_GET)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Executes early, nonce not available
1069 + $get_variable_names = wpo_cache_query_variables();
1070 +
1071 + $get_variables = wpo_cache_maybe_ignore_query_variables(array_keys($_GET)); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Executes early, nonce not available. Value only used for comparison
714 1072
715 - if (preg_match('/\.txt$/i', $filename)) {
716 - header('Content-type: text/plain');
717 - }
1073 + // if GET variables include one or more undefined variable names, then we don't cache.
1074 + $get_variables_diff = array_diff($get_variables, $get_variable_names);
718 1075
719 - if ($send_as_feed) {
720 - header('Content-type: application/rss+xml');
1076 + if (!empty($get_variables_diff) && !wpo_is_cacheable_sitemap_request()) {
1077 + $no_cache_because[] = "In the settings, caching is disabled for matches for one of the current request's GET parameters";
721 1078 }
1079 + }
722 1080
723 - header('WPO-Cache-Status: cached');
724 - if (!empty($modified_time)) {
725 - header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $modified_time) . ' GMT');
726 - }
1081 + $request_uri = isset($_SERVER['REQUEST_URI']) ? strval(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Executes before WP fully loads
1082 + $file_extension = strtolower(pathinfo($request_uri, PATHINFO_EXTENSION));
727 1083
728 - readfile($path);
1084 + // Don't cache disallowed extensions. Prevents wp-cron.php, xmlrpc.php, etc.
1085 + if (!preg_match('#index\.php$#i', $request_uri) && !wpo_is_cacheable_sitemap_request() && in_array($file_extension, array('php', 'xml', 'xsl'))) {
1086 + $no_cache_because[] = 'The request extension is not suitable for caching';
1087 + }
1088 +
1089 + if (!empty($no_cache_because)) return $no_cache_because;
1090 +
1091 + return true;
1092 +}
1093 +endif;
729 1094
730 - exit;
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']);
731 1103 }
1104 +endif;
1105 +
1106 +/**
1107 + * Checks if the current request is a cacheable sitemap request
1108 + *
1109 + * @return bool
1110 + */
1111 +if (!function_exists('wpo_is_cacheable_sitemap_request')) :
1112 +function wpo_is_cacheable_sitemap_request() {
1113 + $is_sitemap_defined = defined('WPO_CACHE_SITEMAP') && WPO_CACHE_SITEMAP;
1114 +
1115 + if (!$is_sitemap_defined) return false;
1116 +
1117 + $request_uri = isset($_SERVER['REQUEST_URI']) ? strval(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Executes before WP fully loads
1118 + $is_sitemap_request = 1 === preg_match('#[a-zA-Z0-9_-]*?sitemap([a-zA-Z0-9_-]+)?\.xml$#i', $request_uri);
1119 +
1120 + if ($is_sitemap_request) return true;
1121 +
1122 + return false;
732 1123 }
733 1124 endif;
734 1125
735 1126 /**
1127 + * Checks if WordPress user cookies are set.
1128 + *
1129 + * @return bool
1130 + */
1131 +if (!function_exists('wpo_is_wp_user_cookies_exist')) :
1132 + function wpo_is_wp_user_cookies_exist(): bool {
1133 + if (empty($_COOKIE)) return false;
1134 +
1135 + $wp_user_cookies = array('wordpress_sec_', 'wordpress_logged_in_');
1136 +
1137 + foreach (array_keys($_COOKIE) as $cookie_name) {
1138 + foreach ($wp_user_cookies as $user_cookie_name) {
1139 + if (0 === strpos($cookie_name, $user_cookie_name)) {
1140 + return true;
1141 + }
1142 + }
1143 + }
1144 +
1145 + return false;
1146 + }
1147 +endif;
1148 +
1149 +/**
736 1150 * Checks and does redirection, if needed
737 1151 *
738 1152 * @return bool
739 1153 */
@@ -740,21 +1154,22 @@
740 1154 if (!function_exists('wpo_is_canonical_redirection_needed')) :
741 1155 function wpo_is_canonical_redirection_needed() {
742 1156 $permalink_structure = isset($GLOBALS['wpo_cache_config']['permalink_structure']) ? $GLOBALS['wpo_cache_config']['permalink_structure'] : '';
743 1157 $site_url = wpo_site_url();
744 -
1158 +
745 1159 // Exit if server variables are not available.
746 1160 if (!isset($_SERVER['HTTP_HOST'])) return false;
747 1161
748 1162 $schema = isset($_SERVER['HTTPS']) && 'on' === $_SERVER['HTTPS'] ? "https" : "http";
749 - $url_part = "://" . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
1163 + $request_uri = isset($_SERVER['REQUEST_URI']) ? strval(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Executes before WP fully loads, only using return value of parse_url
1164 + $url_part = "://" . $_SERVER['HTTP_HOST'] . $request_uri; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Executes before WP fully loads, only using return value of parse_url
750 1165 $requested_url = $schema . $url_part;
751 - $url_parts = parse_url($requested_url);
752 - $extension = pathinfo($url_parts['path'], PATHINFO_EXTENSION);
1166 + $url_parts = parse_url($requested_url); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url not available this early
1167 + $extension = isset($url_parts['path']) ? pathinfo($url_parts['path'], PATHINFO_EXTENSION) : '';
753 1168
754 - if (!empty($permalink_structure) && $requested_url != $site_url && ((isset($url_parts['path']) && '/' !== $url_parts['path']) || isset($url_parts['query']))) {
755 - $request_uri = rtrim($_SERVER['REQUEST_URI'], '?');
756 - if ('/' == substr($permalink_structure, -1) && empty($extension) && empty($url_parts['query']) && empty($url_parts['fragment'])) {
1169 + if (!empty($permalink_structure) && $requested_url !== $site_url && ((isset($url_parts['path']) && '/' !== $url_parts['path']) || isset($url_parts['query']))) {
1170 + $request_uri = rtrim($request_uri, '?');
1171 + if ('/' === substr($permalink_structure, -1) && empty($extension) && empty($url_parts['query']) && empty($url_parts['fragment'])) {
757 1172 $url = preg_replace('/(.+?)([\/]*)(\[\?\#][^\/]+|$)/', '$1/$3', $request_uri);
758 1173 if (0 !== strcmp($request_uri, $url)) return true;
759 1174 } else {
760 1175 $url = rtrim($request_uri, '/');
@@ -768,18 +1183,18 @@
768 1183 /**
769 1184 * Clears the cache
770 1185 */
771 1186 if (!function_exists('wpo_cache_flush')) :
772 -function wpo_cache_flush() {
773 -
774 - if (defined('WPO_CACHE_FILES_DIR') && '' != WPO_CACHE_FILES_DIR) wpo_delete_files(WPO_CACHE_FILES_DIR);
775 -
776 - if (function_exists('wp_cache_flush')) {
777 - wp_cache_flush();
1187 + function wpo_cache_flush() {
1188 +
1189 + if (defined('WPO_CACHE_FILES_DIR') && '' !== WPO_CACHE_FILES_DIR) wpo_delete_files(WPO_CACHE_FILES_DIR);
1190 +
1191 + if (function_exists('wp_cache_flush')) {
1192 + wp_cache_flush();
1193 + }
1194 +
1195 + do_action('wpo_cache_flush');
778 1196 }
779 -
780 - do_action('wpo_cache_flush');
781 -}
782 1197 endif;
783 1198
784 1199 /**
785 1200 * Get URL path for caching
@@ -787,31 +1202,27 @@
787 1202 * @since 1.0
788 1203 * @return string
789 1204 */
790 1205 if (!function_exists('wpo_get_url_path')) :
791 -function wpo_get_url_path($url = '') {
792 - $url = '' == $url ? wpo_current_url() : $url;
793 - $url_parts = parse_url($url);
794 -
795 - if (isset($url_parts['path']) && false !== stripos($url_parts['path'], '/index.php')) {
796 - $url_parts['path'] = preg_replace('/(.*?)index\.php(\/.+)/i', '$1index-php$2', $url_parts['path']);
797 - }
1206 + function wpo_get_url_path($url = '') {
1207 + $url = '' === $url ? wpo_current_url() : $url;
1208 + $url_parts = parse_url($url); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url not available this early
798 1209
799 - /*
800 - * Convert the hexadecimal digits within the percent-encoded triplet to uppercase, to ensure that the path remains
801 - * consistent. For instance, "example.com/%e0%a6" will be converted to "example.com/%E0%A6".
802 - */
803 - if (isset($url_parts['path'])) {
804 - $url_parts['path'] = preg_replace_callback('/%[0-9A-F]{2}/i', function($matches) {
805 - return strtoupper($matches[0]);
806 - }, $url_parts['path']);
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 +
1215 + if (isset($url_parts['path']) && false !== stripos($url_parts['path'], '/index.')) {
1216 + $url_parts['path'] = preg_replace('/(.*?)index\.(php|html)(\/.+)/i', '$1index-$2$3', $url_parts['path']);
1217 + $url_parts['path'] = preg_replace('/index\.(php|html)/i', 'index-$1', $url_parts['path']);
1218 + }
1219 +
1220 + if (!isset($url_parts['host'])) $url_parts['host'] = '';
1221 + if (!isset($url_parts['path'])) $url_parts['path'] = '';
1222 +
1223 + return $url_parts['host'].$url_parts['path'];
807 1224 }
808 -
809 - if (!isset($url_parts['host'])) $url_parts['host'] = '';
810 - if (!isset($url_parts['path'])) $url_parts['path'] = '';
811 -
812 - return $url_parts['host'].$url_parts['path'];
813 -}
814 1225 endif;
815 1226
816 1227 /**
817 1228 * Get requested url.
@@ -818,19 +1229,21 @@
818 1229 *
819 1230 * @return string
820 1231 */
821 1232 if (!function_exists('wpo_current_url')) :
822 -function wpo_current_url() {
823 - // Note: We use `static $url` to save the first value we retrieve, as some plugins change $_SERVER later on in the process (e.g. Weglot).
824 - // Otherwise this function would return a different URL at the beginning and end of the cache process.
825 - static $url = '';
826 - if ('' != $url) return $url;
827 - $http_host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
828 - $url = rtrim('http' . ((isset($_SERVER['HTTPS']) && ('on' == $_SERVER['HTTPS'] || 1 == $_SERVER['HTTPS']) ||
829 - isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && 'https' == $_SERVER['HTTP_X_FORWARDED_PROTO']) ? 's' : '' )
830 - . '://' . $http_host.$_SERVER['REQUEST_URI'], '/');
831 - return $url;
832 -}
1233 + function wpo_current_url() {
1234 + // Note: We use `static $url` to save the first value we retrieve, as some plugins change $_SERVER later on in the process (e.g. Weglot).
1235 + // Otherwise this function would return a different URL at the beginning and end of the cache process.
1236 + static $url = '';
1237 + if ('' !== $url) return $url;
1238 + $http_host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Executes before WP fully loads
1239 + $request_uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Executes before WP fully loads
1240 + $url = rtrim('http' . ((isset($_SERVER['HTTPS']) && ('on' === $_SERVER['HTTPS'] || 1 === (int) $_SERVER['HTTPS']) ||
1241 + isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO']) ? 's' : '' )
1242 + . '://' . $http_host.$request_uri, '/');
1243 + $filtered_url = filter_var($url, FILTER_VALIDATE_URL);
1244 + return false !== $filtered_url ? $filtered_url : preg_replace('/[^a-z0-9\-._~:\/?#\[\]@!$&\'()*+,;=%]/i', '', $url);
1245 + }
833 1246 endif;
834 1247
835 1248 /**
836 1249 * Return list of conditional tag exceptions.
@@ -837,42 +1250,42 @@
837 1250 *
838 1251 * @return array
839 1252 */
840 1253 if (!function_exists('wpo_get_conditional_tags_exceptions')) :
841 -function wpo_get_conditional_tags_exceptions() {
842 - static $exceptions = null;
843 -
844 - if (null !== $exceptions) return $exceptions;
845 -
846 - if (!empty($GLOBALS['wpo_cache_config'])) {
847 - if (empty($GLOBALS['wpo_cache_config']['cache_exception_conditional_tags'])) {
848 - $exceptions = array();
849 -
850 - } else {
1254 + function wpo_get_conditional_tags_exceptions() {
1255 + static $exceptions = null;
1256 +
1257 + if (null !== $exceptions) return $exceptions;
1258 +
1259 + if (!empty($GLOBALS['wpo_cache_config'])) {
1260 + if (empty($GLOBALS['wpo_cache_config']['cache_exception_conditional_tags'])) {
1261 + $exceptions = array();
1262 +
1263 + } else {
1264 +
1265 + $exceptions = $GLOBALS['wpo_cache_config']['cache_exception_conditional_tags'];
1266 +
1267 + }
851 1268
852 - $exceptions = $GLOBALS['wpo_cache_config']['cache_exception_conditional_tags'];
853 -
854 - }
855 -
856 - } elseif (class_exists('WPO_Page_Cache')) {
857 -
858 - $config = WPO_Page_Cache::instance()->config->get();
859 -
860 - if (is_array($config) && array_key_exists('cache_exception_conditional_tags', $config)) {
861 - $exceptions = $config['cache_exception_conditional_tags'];
1269 + } elseif (class_exists('WPO_Page_Cache')) {
1270 +
1271 + $config = WPO_Page_Cache::instance()->config->get();
1272 +
1273 + if (is_array($config) && array_key_exists('cache_exception_conditional_tags', $config)) {
1274 + $exceptions = $config['cache_exception_conditional_tags'];
1275 + } else {
1276 + $exceptions = array();
1277 + }
1278 +
1279 + $exceptions = is_array($exceptions) ? $exceptions : preg_split('#(\n|\r|\r\n)#', $exceptions);
1280 + $exceptions = array_filter($exceptions, 'trim');
1281 +
862 1282 } else {
863 1283 $exceptions = array();
864 1284 }
865 -
866 - $exceptions = is_array($exceptions) ? $exceptions : preg_split('#(\n|\r|\r\n)#', $exceptions);
867 - $exceptions = array_filter($exceptions, 'trim');
868 1285
869 - } else {
870 - $exceptions = array();
1286 + return $exceptions;
871 1287 }
872 -
873 - return $exceptions;
874 -}
875 1288 endif;
876 1289
877 1290 /**
878 1291 * Return list of url exceptions.
@@ -879,38 +1292,66 @@
879 1292 *
880 1293 * @return array
881 1294 */
882 1295 if (!function_exists('wpo_get_url_exceptions')) :
883 -function wpo_get_url_exceptions() {
884 - static $exceptions = null;
885 -
886 - if (null !== $exceptions) return $exceptions;
887 -
888 - // if called from file-based-page-cache.php when WP loading
889 - // and cache settings exists then use it otherwise get settings from database.
890 - if (!empty($GLOBALS['wpo_cache_config'])) {
891 - if (empty($GLOBALS['wpo_cache_config']['cache_exception_urls'])) {
1296 + function wpo_get_url_exceptions() {
1297 + static $exceptions = null;
1298 +
1299 + if (null !== $exceptions) return $exceptions;
1300 +
1301 + // if called from file-based-page-cache.php when WP loading
1302 + // and cache settings exists then use it otherwise get settings from database.
1303 + if (!empty($GLOBALS['wpo_cache_config'])) {
1304 + if (empty($GLOBALS['wpo_cache_config']['cache_exception_urls'])) {
1305 + $exceptions = array();
1306 + } else {
1307 + $exceptions = is_array($GLOBALS['wpo_cache_config']['cache_exception_urls']) ? $GLOBALS['wpo_cache_config']['cache_exception_urls'] : preg_split('#(\n|\r)#', $GLOBALS['wpo_cache_config']['cache_exception_urls']);
1308 + }
1309 + } elseif (class_exists('WPO_Page_Cache')) {
1310 + $config = WPO_Page_Cache::instance()->config->get();
1311 +
1312 + if (is_array($config) && array_key_exists('cache_exception_urls', $config)) {
1313 + $exceptions = $config['cache_exception_urls'];
1314 + } else {
1315 + $exceptions = array();
1316 + }
1317 +
1318 + $exceptions = is_array($exceptions) ? $exceptions : preg_split('#(\n|\r)#', $exceptions);
1319 + $exceptions = array_filter($exceptions, 'trim');
1320 + } else {
892 1321 $exceptions = array();
893 - } else {
894 - $exceptions = is_array($GLOBALS['wpo_cache_config']['cache_exception_urls']) ? $GLOBALS['wpo_cache_config']['cache_exception_urls'] : preg_split('#(\n|\r)#', $GLOBALS['wpo_cache_config']['cache_exception_urls']);
895 1322 }
896 - } elseif (class_exists('WPO_Page_Cache')) {
897 - $config = WPO_Page_Cache::instance()->config->get();
1323 +
1324 + return apply_filters('wpo_get_url_exceptions', $exceptions);
1325 + }
1326 +endif;
898 1327
899 - if (is_array($config) && array_key_exists('cache_exception_urls', $config)) {
900 - $exceptions = $config['cache_exception_urls'];
1328 +/**
1329 + * Returns a list of URLs that are included in the cache.
1330 + *
1331 + * @return array
1332 + */
1333 +if (!function_exists('wpo_get_cache_include_urls')) :
1334 + function wpo_get_cache_include_urls() {
1335 + static $cache_include_urls = null;
1336 +
1337 + if (null !== $cache_include_urls) return $cache_include_urls;
1338 +
1339 + // if called from file-based-page-cache.php when WP loading
1340 + // and cache settings exists then use it otherwise get settings from database.
1341 + if (!empty($GLOBALS['wpo_cache_config'])) {
1342 + if (empty($GLOBALS['wpo_cache_config']['cache_include_urls'])) {
1343 + $cache_include_urls = array();
1344 + } else {
1345 + $cache_include_urls = is_array($GLOBALS['wpo_cache_config']['cache_include_urls']) ? $GLOBALS['wpo_cache_config']['cache_include_urls'] : preg_split('#(\n|\r)#', $GLOBALS['wpo_cache_config']['cache_include_urls']);
1346 + $cache_include_urls = array_filter($cache_include_urls, 'trim');
1347 + }
901 1348 } else {
902 - $exceptions = array();
1349 + $cache_include_urls = array();
903 1350 }
904 -
905 - $exceptions = is_array($exceptions) ? $exceptions : preg_split('#(\n|\r)#', $exceptions);
906 - $exceptions = array_filter($exceptions, 'trim');
907 - } else {
908 - $exceptions = array();
1351 +
1352 + return apply_filters('wpo_get_cache_include_urls', $cache_include_urls);
909 1353 }
910 -
911 - return apply_filters('wpo_get_url_exceptions', $exceptions);
912 -}
913 1354 endif;
914 1355
915 1356 /**
916 1357 * Return true of exception url matches current url
@@ -919,12 +1360,12 @@
919 1360 * @param bool $regex Whether to check with regex or not.
920 1361 * @return bool true if matched, false otherwise
921 1362 */
922 1363 if (!function_exists('wpo_current_url_exception_match')) :
923 -function wpo_current_url_exception_match($exception) {
924 -
925 - return wpo_url_exception_match(wpo_current_url(), $exception);
926 -}
1364 + function wpo_current_url_exception_match($exception) {
1365 +
1366 + return wpo_url_exception_match(wpo_current_url(), $exception);
1367 + }
927 1368 endif;
928 1369
929 1370 /**
930 1371 * Check if url in conditional tags exceptions list.
@@ -931,31 +1372,32 @@
931 1372 *
932 1373 * @return string
933 1374 */
934 1375 if (!function_exists('wpo_url_in_conditional_tags_exceptions')) :
935 -function wpo_url_in_conditional_tags_exceptions() {
936 -
937 - $exceptions = wpo_get_conditional_tags_exceptions();
938 - $restricted = '';
939 - $allowed_functions = array('is_single', 'is_page', 'is_front_page', 'is_home', 'is_archive', 'is_tag', 'is_category', 'is_feed', 'is_search', 'is_author', 'is_woocommerce', 'is_shop', 'is_product', 'is_account_page', 'is_product_category', 'is_product_tag', 'is_wc_endpoint_url', 'is_bbpress', 'bbp_is_forum_archive', 'bbp_is_topic_archive', 'bbp_is_topic_tag', 'bbp_is_single_forum', 'bbp_is_single_topic', 'bbp_is_single_view', 'bbp_is_single_user', 'bbp_is_user_home', 'bbp_is_search');
940 - //Filter for add more conditional tags to whitelist in the exceptions list.
941 - $allowed_functions = apply_filters('wpo_allowed_conditional_tags_exceptions', $allowed_functions);
942 - if (!empty($exceptions)) {
943 - foreach ($exceptions as $exception) {
944 - if (false !== strpos($exception, 'is_')) {
945 - $exception_function = $exception;
946 - if ('()' == substr($exception, -2)) {
947 - $exception_function = substr($exception, 0, -2);
1376 + function wpo_url_in_conditional_tags_exceptions() {
1377 +
1378 + $exceptions = wpo_get_conditional_tags_exceptions();
1379 + $restricted = '';
1380 + $allowed_functions = array('is_single', 'is_page', 'is_front_page', 'is_home', 'is_archive', 'is_tag', 'is_category', 'is_feed', 'is_search', 'is_author', 'is_woocommerce', 'is_shop', 'is_product', 'is_account_page', 'is_product_category', 'is_product_tag', 'is_wc_endpoint_url', 'is_bbpress', 'bbp_is_forum_archive', 'bbp_is_topic_archive', 'bbp_is_topic_tag', 'bbp_is_single_forum', 'bbp_is_single_topic', 'bbp_is_single_view', 'bbp_is_single_user', 'bbp_is_user_home', 'bbp_is_search');
1381 + //Filter for add more conditional tags to whitelist in the exceptions list.
1382 + $allowed_functions = apply_filters('wpo_allowed_conditional_tags_exceptions', $allowed_functions);
1383 + if (!empty($exceptions)) {
1384 + foreach ($exceptions as $exception) {
1385 + if (false !== strpos($exception, 'is_')) {
1386 + $exception_function = $exception;
1387 + if ('()' === substr($exception, -2)) {
1388 + $exception_function = substr($exception, 0, -2);
1389 + }
1390 +
1391 + if (in_array($exception_function, $allowed_functions) && function_exists($exception_function) && call_user_func($exception_function)) {
1392 + // translators: %s is the function name for conditional tag
1393 + $restricted = sprintf(__('In the settings, caching is disabled for %s', 'wp-optimize'), $exception_function);
1394 + }
948 1395 }
949 -
950 - if (in_array($exception_function, $allowed_functions) && function_exists($exception_function) && call_user_func($exception_function)) {
951 - $restricted = sprintf(__('In the settings, caching is disabled for %s', 'wp-optimize'), $exception_function);
952 - }
953 1396 }
954 1397 }
1398 + return $restricted;
955 1399 }
956 - return $restricted;
957 -}
958 1400 endif;
959 1401
960 1402
961 1403 /**
@@ -965,26 +1407,72 @@
965 1407 *
966 1408 * @return bool
967 1409 */
968 1410 if (!function_exists('wpo_url_in_exceptions')) :
969 -function wpo_url_in_exceptions($url) {
970 - $exceptions = wpo_get_url_exceptions();
1411 + function wpo_url_in_exceptions($url) {
1412 + $exceptions = wpo_get_url_exceptions();
1413 +
1414 + if (!empty($exceptions)) {
1415 + foreach ($exceptions as $exception) {
1416 +
1417 + // don't check / - front page using regexp, we handle it in wpo_restricted_cache_page_type()
1418 + if ('/' === $exception) continue;
1419 +
1420 + if (wpo_url_exception_match($url, $exception)) {
1421 + // Exception match.
1422 + return true;
1423 + }
1424 + }
1425 + }
1426 +
1427 + return false;
1428 + }
1429 +endif;
971 1430
972 - if (!empty($exceptions)) {
973 - foreach ($exceptions as $exception) {
1431 +/**
1432 + * Checks if URL matches against listed include.
1433 + *
1434 + * Supports:
1435 + * - Root-based paths (e.g., /page)
1436 + * - Wildcards (*) in the last segment
1437 + * - One optional parent directory (sub-dir multisite)
1438 + *
1439 + * @param string $url
1440 + * @return bool
1441 + */
1442 +if (!function_exists('wpo_url_in_cache_include')) :
1443 + function wpo_url_in_cache_include($url): bool {
1444 + $url = preg_replace('/\?.*/', '', $url); // Remove query string
1445 + $url = rtrim($url, '/'); // normalize URL (remove trailing slash)
974 1446
975 - // don't check / - front page using regexp, we handle it in wpo_restricted_cache_page_type()
976 - if ('/' == $exception) continue;
1447 + // Get path only
1448 + $path = parse_url($url, PHP_URL_PATH); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url not available this early
1449 + $path = $path ?? '/';
1450 + $path = rtrim($path, '/');
977 1451
978 - if (wpo_url_exception_match($url, $exception)) {
979 - // Exception match.
980 - return true;
1452 + $cache_include_urls = wpo_get_cache_include_urls();
1453 +
1454 + if (!empty($cache_include_urls) && is_array($cache_include_urls)) {
1455 + foreach ($cache_include_urls as $include_url) {
1456 + $include_url = rtrim($include_url, '/'); // normalize include URL (remove trailing slash)
1457 + if (strpos($include_url, '/') === 0) {
1458 + // Support wildcards and allow one subdirectory max
1459 + $pattern = preg_quote($include_url, '#');
1460 + $pattern = str_replace('\*', '.*', $pattern);
1461 +
1462 + if (preg_match('#^(/[^/]+)?' . $pattern . '$#i', $path)) {
1463 + return true;
1464 + }
1465 + }
1466 +
1467 + if (wpo_url_exception_match($url, $include_url)) {
1468 + return true;
1469 + }
981 1470 }
982 1471 }
1472 +
1473 + return false;
983 1474 }
984 -
985 - return false;
986 -}
987 1475 endif;
988 1476
989 1477 /**
990 1478 * Checks if an URL matches against listed exceptions.
@@ -994,51 +1482,39 @@
994 1482 *
995 1483 * @return bool
996 1484 */
997 1485 if (!function_exists('wpo_url_exception_match')) :
998 -function wpo_url_exception_match($url, $exception) {
999 - if (preg_match('#^[\s]*$#', $exception)) {
1000 - return false;
1001 - }
1486 + function wpo_url_exception_match($url, $exception) {
1487 + if (preg_match('#^[\s]*$#', $exception)) {
1488 + return false;
1489 + }
1490 +
1491 + $exception = trim($exception);
1492 +
1493 + // Used to test websites placed in subdirectories.
1494 + $sub_dir = '';
1495 +
1496 + // If exception defined from root i.e. /page1 then remove domain part in url.
1497 + if (preg_match('/^\//', $exception)) {
1498 + // get site sub directory.
1499 + $sub_dir = preg_replace('#^(http|https):\/\/.*\/#Ui', '', wpo_site_url());
1500 + // add prefix slash and remove slash.
1501 + $sub_dir = ('' === $sub_dir || null === $sub_dir) ? '' : '/' . rtrim($sub_dir, '/');
1502 + // get relative path
1503 + $url = preg_replace('#^(http|https):\/\/.*\/#Ui', '/', $url);
1504 + }
1505 +
1506 + $url = urldecode(rtrim($url, '/')) . '/';
1507 + $exception = rtrim($exception, '/');
1508 +
1509 + $exception = wpo_mask_to_regex($exception, true);
1002 1510
1003 - $exception = str_replace('*', '.*', $exception);
1511 + if (!$exception) return false;
1004 1512
1005 - $exception = trim($exception);
1006 -
1007 - // Used to test websites placed in subdirectories.
1008 - $sub_dir = '';
1009 -
1010 - // If exception defined from root i.e. /page1 then remove domain part in url.
1011 - if (preg_match('/^\//', $exception)) {
1012 - // get site sub directory.
1013 - $sub_dir = preg_replace('#^(http|https):\/\/.*\/#Ui', '', wpo_site_url());
1014 - // add prefix slash and remove slash.
1015 - $sub_dir = ('' == $sub_dir) ? '' : '/' . rtrim($sub_dir, '/');
1016 - // get relative path
1017 - $url = preg_replace('#^(http|https):\/\/.*\/#Ui', '/', $url);
1513 + $exception = urldecode($exception);
1514 +
1515 + return (preg_match('#^'.$exception.'$#i', $url) || preg_match('#^'.$sub_dir.$exception.'$#i', $url));
1018 1516 }
1019 -
1020 - $url = urldecode(rtrim($url, '/')) . '/';
1021 - $exception = rtrim($exception, '/');
1022 -
1023 - // if we have no wildcard in the end of exception then add slash.
1024 - if (!preg_match('#\(\.\*\)$#', $exception)) $exception .= '/';
1025 -
1026 - $exception = preg_quote($exception);
1027 -
1028 - // fix - unescape some possibly escaped mask characters
1029 - $search = array(
1030 - '\\.\\*',
1031 - '\\-',
1032 - );
1033 - $replace = array(
1034 - '.*',
1035 - '-',
1036 - );
1037 - $exception = urldecode(str_replace($search, $replace, $exception));
1038 -
1039 - return (preg_match('#^'.$exception.'$#i', $url) || preg_match('#^'.$sub_dir.$exception.'$#i', $url));
1040 -}
1041 1517 endif;
1042 1518
1043 1519 /**
1044 1520 * Checks if its a mobile device
@@ -1045,114 +1521,194 @@
1045 1521 *
1046 1522 * @see https://developer.wordpress.org/reference/functions/wp_is_mobile/
1047 1523 */
1048 1524 if (!function_exists('wpo_is_mobile')) :
1049 -function wpo_is_mobile() {
1050 - if (empty($_SERVER['HTTP_USER_AGENT'])) {
1051 - $is_mobile = false;
1052 - // many mobile devices (all iPhone, iPad, etc.)
1053 - } elseif (strpos($_SERVER['HTTP_USER_AGENT'], 'Mobile') !== false
1054 - || strpos($_SERVER['HTTP_USER_AGENT'], 'Android') !== false
1055 - || strpos($_SERVER['HTTP_USER_AGENT'], 'Silk/') !== false
1056 - || strpos($_SERVER['HTTP_USER_AGENT'], 'Kindle') !== false
1057 - || strpos($_SERVER['HTTP_USER_AGENT'], 'BlackBerry') !== false
1058 - || strpos($_SERVER['HTTP_USER_AGENT'], 'Opera Mini') !== false
1059 - || strpos($_SERVER['HTTP_USER_AGENT'], 'Opera Mobi') !== false
1060 - ) {
1061 - $is_mobile = true;
1062 - } else {
1063 - $is_mobile = false;
1525 + function wpo_is_mobile() {
1526 + $user_agent = empty($_SERVER['HTTP_USER_AGENT']) ? null : $_SERVER['HTTP_USER_AGENT']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for string comparison
1527 + if (empty($user_agent)) {
1528 + $is_mobile = false;
1529 + // many mobile devices (all iPhone, iPad, etc.)
1530 + } elseif (strpos($user_agent, 'Mobile') !== false
1531 + || strpos($user_agent, 'Android') !== false
1532 + || strpos($user_agent, 'Silk/') !== false
1533 + || strpos($user_agent, 'Kindle') !== false
1534 + || strpos($user_agent, 'BlackBerry') !== false
1535 + || strpos($user_agent, 'Opera Mini') !== false
1536 + || strpos($user_agent, 'Opera Mobi') !== false
1537 + ) {
1538 + $is_mobile = true;
1539 + } else {
1540 + $is_mobile = false;
1541 + }
1542 +
1543 + return $is_mobile;
1064 1544 }
1065 -
1066 - return $is_mobile;
1067 -}
1068 1545 endif;
1069 1546
1070 1547 /**
1071 - * Check if current browser agent is not disabled in options.
1548 + * Converts a wildcard mask to a regular expression pattern.
1072 1549 *
1073 - * @return bool
1550 + * @param string $mask
1551 + * @param boolean $add_trailing_slash Whether to add a trailing slash to the regex pattern if the mask doesn't already end with a wildcard. This allows matching URLs with or without a trailing slash.
1552 + * @return string|false Regular expression pattern if conversion is successful, false if the input mask is empty after trimming.
1074 1553 */
1075 -if (!function_exists('wpo_is_accepted_user_agent')) :
1076 -function wpo_is_accepted_user_agent($user_agent) {
1554 +if (!function_exists('wpo_mask_to_regex')) :
1555 + function wpo_mask_to_regex($mask, $add_trailing_slash = false) {
1556 + $mask = trim($mask);
1077 1557
1078 - $exceptions = is_array($GLOBALS['wpo_cache_config']['cache_exception_browser_agents']) ? $GLOBALS['wpo_cache_config']['cache_exception_browser_agents'] : preg_split('#(\n|\r)#', $GLOBALS['wpo_cache_config']['cache_exception_browser_agents']);
1558 + if ('' === $mask) {
1559 + return false;
1560 + }
1079 1561
1080 - if (!empty($exceptions)) {
1081 - foreach ($exceptions as $exception) {
1082 - if ('' == trim($exception)) continue;
1562 + // Convert wildcard to regex
1563 + $mask = str_replace('*', '.*', $mask);
1083 1564
1084 - if (preg_match('#'.$exception.'#i', $user_agent)) return false;
1565 + // If the mask doesn't already end with a wildcard, add a trailing slash to match URLs with or without a trailing slash.
1566 + if ($add_trailing_slash && !preg_match('#\(\.\*\)$#', $mask)) {
1567 + $mask = rtrim($mask, '/') . '/';
1085 1568 }
1569 +
1570 + // Escape regex characters
1571 + $mask = preg_quote($mask);
1572 +
1573 + // Restore wildcard and dash
1574 + $mask = str_replace(
1575 + array('\.\*', '\-'),
1576 + array('.*', '-'),
1577 + $mask
1578 + );
1579 +
1580 + return $mask;
1086 1581 }
1582 +endif;
1087 1583
1088 - return true;
1089 -}
1090 -endif;
1091 1584
1092 -if (!function_exists('wpo_delete_files')) :
1093 1585 /**
1094 - * Deletes a specified source file or directory.
1586 + * Check if current browser agent is not disabled in options.
1095 1587 *
1096 - * If $src is a file, only that file will be deleted. If $src is a directory, the behavior depends on the
1097 - * $recursive parameter. When $recursive is true, the directory and its contents (including files and subdirectories)
1098 - * will be deleted. When $recursive is false, only the files in the top-level directory(eg. $src directory) will be deleted,
1099 - * while the $src directory itself and its subdirectories will remain untouched.
1588 + * @param string $user_agent
1100 1589 *
1101 - * @param string $src The path to the source file or directory to delete.
1102 - * @param bool $recursive (Optional) When set to true, the directory and its contents (including files and subdirectories)
1103 - * will be deleted. If false, only the files in the top-level directory will be deleted while
1104 - * its subdirectories will be preserved. Defaults to true.
1105 - *
1106 - * @return bool Returns true if the specified file or all files within the specified directory (and its subdirectories,
1107 - * when $recursive is true) are successfully deleted. Returns false if any file(s) could not be deleted
1108 - * due to file permissions or other reasons.
1590 + * @return bool
1109 1591 */
1110 -function wpo_delete_files($src, $recursive = true) {
1111 - // If the source doesn't exist, consider it deleted and return true
1112 - if (!file_exists($src)) {
1592 +if (!function_exists('wpo_is_accepted_user_agent')) :
1593 + function wpo_is_accepted_user_agent($user_agent) {
1594 +
1595 + if (empty($GLOBALS['wpo_cache_config'])) return true;
1596 +
1597 + $exceptions = is_array($GLOBALS['wpo_cache_config']['cache_exception_browser_agents']) ? $GLOBALS['wpo_cache_config']['cache_exception_browser_agents'] : preg_split('#(\n|\r)#', $GLOBALS['wpo_cache_config']['cache_exception_browser_agents']);
1598 +
1599 + if (!empty($exceptions)) {
1600 + foreach ($exceptions as $exception) {
1601 + if ('' === trim($exception)) continue;
1602 +
1603 + $exception = wpo_mask_to_regex($exception);
1604 + if ($exception && preg_match('#'.$exception.'#i', $user_agent)) return false;
1605 + }
1606 + }
1607 +
1113 1608 return true;
1114 1609 }
1610 +endif;
1115 1611
1116 - /*
1117 - * If the source is a file, delete it and return the result.
1118 - * If `unlink()` fails, we also verify if the file still exists before returning the result, as another
1119 - * PHP process may have already deleted the file between the execution of `is_file()` and `unlink()` operations.
1612 +if (!function_exists('wpo_delete_files')) :
1613 + /**
1614 + * Deletes a specified source file or directory.
1615 + *
1616 + * If $src is a file, only that file will be deleted. If $src is a directory, the behavior depends on the
1617 + * $recursive parameter. When $recursive is true, the directory and its contents (including files and subdirectories)
1618 + * will be deleted. When $recursive is false, only the files in the top-level directory(eg. $src directory) will be deleted,
1619 + * while the $src directory itself and its subdirectories will remain untouched.
1620 + *
1621 + * @param string $src The path to the source file or directory to delete.
1622 + * @param bool $recursive (Optional) When set to true, the directory and its contents (including files and subdirectories)
1623 + * will be deleted. If false, only the files in the top-level directory will be deleted while
1624 + * its subdirectories will be preserved. Defaults to true.
1625 + *
1626 + * @return bool Returns true if the specified file or all files within the specified directory (and its subdirectories,
1627 + * when $recursive is true) are successfully deleted. Returns false if any file(s) could not be deleted
1628 + * due to file permissions or other reasons.
1120 1629 */
1121 - if (is_file($src)) {
1122 - if (!@unlink($src) && file_exists($src)) { // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress PHP warning in case of failure
1123 - return false;
1630 + function wpo_delete_files($src, $recursive = true) {
1631 + // If the source doesn't exist, consider it deleted and return true
1632 + if (!file_exists($src)) {
1633 + return true;
1124 1634 }
1125 - return true;
1126 - }
1635 +
1636 + /*
1637 + * If the source is a file, delete it and return the result.
1638 + * If `unlink()` fails, we also verify if the file still exists before returning the result, as another
1639 + * PHP process may have already deleted the file between the execution of `is_file()` and `unlink()` operations.
1640 + */
1641 + if (is_file($src)) {
1642 + // phpcs:disable
1643 + // Generic.PHP.NoSilencedErrors.Discouraged -- suppress PHP warning in case of failure
1644 + // WordPress.WP.AlternativeFunctions.unlink_unlink -- wp_delete_file may not be available this early
1645 + if (!@unlink($src) && file_exists($src)) {
1646 + return false;
1647 + }
1648 + // phpcs:enable
1649 +
1650 + return true;
1651 + }
1652 +
1653 + $success = true;
1654 +
1655 + // If recursive is false, delete only the top-level files and return the result
1656 + if (!$recursive) {
1657 + $dir_handle = @opendir($src); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress PHP warning in case of failure
1658 +
1659 + /*
1660 + * If opendir() is successful, process directory contents. If not, check if the directory exists.
1661 + * If it exists, return false (failure). Otherwise, assume it's already deleted and return true (success).
1662 + */
1663 + if (false !== $dir_handle) {
1664 +
1665 + while (false !== ($file = readdir($dir_handle))) {
1666 + if ('.' === $file || '..' === $file) {
1667 + continue;
1668 + }
1127 1669
1128 - $success = true;
1129 -
1130 - // If recursive is false, delete only the top-level files and return the result
1131 - if (!$recursive) {
1670 + $full_path = rtrim($src, '/\\') . DIRECTORY_SEPARATOR . $file;
1671 +
1672 + // If it's a file, delete it
1673 + if (is_file($full_path)) {
1674 + if (!wpo_delete_files($full_path)) {
1675 + $success = false;
1676 + }
1677 + }
1678 + }
1679 +
1680 + closedir($dir_handle);
1681 + } else {
1682 + if (file_exists($src)) {
1683 + $success = false;
1684 + }
1685 + }
1686 +
1687 + return $success;
1688 + }
1689 +
1690 + // If recursive is true, delete all files and directories recursively
1132 1691 $dir_handle = @opendir($src); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress PHP warning in case of failure
1133 -
1692 +
1134 1693 /*
1135 1694 * If opendir() is successful, process directory contents. If not, check if the directory exists.
1136 1695 * If it exists, return false (failure). Otherwise, assume it's already deleted and return true (success).
1137 1696 */
1138 1697 if (false !== $dir_handle) {
1139 -
1698 +
1140 1699 while (false !== ($file = readdir($dir_handle))) {
1141 - if ('.' == $file || '..' == $file) {
1700 + if ('.' === $file || '..' === $file) {
1142 1701 continue;
1143 1702 }
1144 1703
1145 - $full_path = $src . '/' . $file;
1146 -
1147 - // If it's a file, delete it
1148 - if (is_file($full_path)) {
1149 - if (!wpo_delete_files($full_path)) {
1150 - $success = false;
1151 - }
1704 + $full_path = rtrim($src, '/\\') . DIRECTORY_SEPARATOR . $file;
1705 +
1706 + if (!wpo_delete_files($full_path)) {
1707 + $success = false;
1152 1708 }
1153 1709 }
1154 -
1710 +
1155 1711 closedir($dir_handle);
1156 1712 } else {
1157 1713 if (file_exists($src)) {
1158 1714 $success = false;
@@ -1158,111 +1714,83 @@
1158 1714 $success = false;
1159 1715 }
1160 1716 }
1161 1717
1718 + /*
1719 + * Delete the source directory itself.
1720 + * Success of `rmdir` operation is not recorded; we only ultimately care about emptying, not removing
1721 + * entirely (empty folders in our context are harmless)
1722 + */
1723 + if ($success) {
1724 + // phpcs:disable
1725 + // WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem not available this early
1726 + // Generic.PHP.NoSilencedErrors.Discouraged -- suppress errors from displaying
1727 + @rmdir($src);
1728 + // phpcs:enable
1729 + }
1730 +
1731 + // Delete cached information about cache size
1732 + WP_Optimize()->get_page_cache()->delete_cache_size_information();
1733 +
1162 1734 return $success;
1163 1735 }
1736 +endif;
1164 1737
1165 - // If recursive is true, delete all files and directories recursively
1166 - $dir_handle = @opendir($src); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress PHP warning in case of failure
1167 -
1168 - /*
1169 - * If opendir() is successful, process directory contents. If not, check if the directory exists.
1170 - * If it exists, return false (failure). Otherwise, assume it's already deleted and return true (success).
1738 +if (!function_exists('wpo_is_empty_dir')) :
1739 + /**
1740 + * Check if selected directory is empty or has only index.php which we added for security reasons.
1741 + *
1742 + * @param string $dir
1743 + *
1744 + * @return bool
1171 1745 */
1172 - if (false !== $dir_handle) {
1173 -
1174 - while (false !== ($file = readdir($dir_handle))) {
1175 - if ('.' == $file || '..' == $file) {
1176 - continue;
1746 + function wpo_is_empty_dir($dir) {
1747 + if (!file_exists($dir) || !is_dir($dir)) return false;
1748 +
1749 + $handle = opendir($dir);
1750 +
1751 + if (false === $handle) return false;
1752 +
1753 + $is_empty = true;
1754 + $file = readdir($handle);
1755 +
1756 + while (false !== $file) {
1757 +
1758 + if ('.' !== $file && '..' !== $file && 'index.php' !== $file) {
1759 + $is_empty = false;
1760 + break;
1177 1761 }
1178 1762
1179 - $full_path = $src . '/' . $file;
1180 -
1181 - if (!wpo_delete_files($full_path)) {
1182 - $success = false;
1183 - }
1763 + $file = readdir($handle);
1184 1764 }
1185 -
1186 - closedir($dir_handle);
1187 - } else {
1188 - if (file_exists($src)) {
1189 - $success = false;
1190 - }
1765 +
1766 + closedir($handle);
1767 + return $is_empty;
1191 1768 }
1192 -
1193 - /*
1194 - * Delete the source directory itself.
1195 - * Success of `rmdir` operation is not recorded; we only ultimately care about emptying, not removing
1196 - * entirely (empty folders in our context are harmless)
1197 - */
1198 - if ($success) {
1199 - @rmdir($src); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress PHP warning in case of failure
1200 - }
1201 -
1202 - // Delete cached information about cache size
1203 - WP_Optimize()->get_page_cache()->delete_cache_size_information();
1204 -
1205 - return $success;
1206 -}
1207 1769 endif;
1208 1770
1209 -if (!function_exists('wpo_is_empty_dir')) :
1210 1771 /**
1211 - * Check if selected directory is empty or has only index.php which we added for security reasons.
1212 - *
1213 - * @param string $dir
1214 - *
1215 - * @return bool
1216 - */
1217 -function wpo_is_empty_dir($dir) {
1218 - if (!file_exists($dir) || !is_dir($dir)) return false;
1219 -
1220 - $handle = opendir($dir);
1221 -
1222 - if (false === $handle) return false;
1223 -
1224 - $is_empty = true;
1225 - $file = readdir($handle);
1226 -
1227 - while (false !== $file) {
1228 -
1229 - if ('.' != $file && '..' != $file && 'index.php' != $file) {
1230 - $is_empty = false;
1231 - break;
1232 - }
1233 -
1234 - $file = readdir($handle);
1235 - }
1236 -
1237 - closedir($handle);
1238 - return $is_empty;
1239 -}
1240 -endif;
1241 -
1242 -/**
1243 1772 * Either store for later output, or output now. Only the most-recent call will be effective.
1244 1773 *
1245 1774 * @param String|Null $output - if not null, then the string to use when called by the shutdown action.
1246 1775 */
1247 1776 if (!function_exists('wpo_cache_add_footer_output')) :
1248 -function wpo_cache_add_footer_output($output = null) {
1249 -
1250 - static $buffered = null;
1251 -
1252 - if (function_exists('current_filter') && 'shutdown' == current_filter()) {
1253 - // Only add the line if it was a page, not something else (e.g. REST response)
1254 - if (function_exists('did_action') && did_action('wp_footer')) {
1255 - echo "\n<!-- WP Optimize page cache - https://getwpo.com - ".$buffered." -->\n";
1256 - } elseif (defined('WPO_CACHE_DEBUG') && WPO_CACHE_DEBUG && (!defined('REST_REQUEST') || !REST_REQUEST)) {
1257 - error_log('[CACHE DEBUG] '.wpo_current_url() . ' - ' . $buffered);
1777 + function wpo_cache_add_footer_output($output = null) {
1778 +
1779 + static $buffered = null;
1780 +
1781 + if (null === $buffered) {
1782 + add_action('shutdown', 'wpo_cache_add_footer_output', 11);
1783 + $buffered = $output;
1784 + } elseif ('shutdown' === current_filter()) {
1785 + // Only add the line if it was a page, not something else (e.g. REST response)
1786 + if (did_action('wp_footer') && !preg_match('/\/wp\-json\//', $_SERVER['REQUEST_URI']) && apply_filters('wpo_cache_show_cached_by_comment', true)) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Executes before WP fully loads, global value only used for string comparison
1787 + echo "\n<!-- WP Optimize page cache - https://teamupdraft.com/wp-optimize/ - ".esc_html($buffered)." -->\n";
1788 + } elseif (defined('WPO_CACHE_DEBUG') && WPO_CACHE_DEBUG && (!defined('REST_REQUEST') || !REST_REQUEST)) {
1789 + error_log('[CACHE DEBUG] '.wpo_current_url() . ' - ' . $buffered); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Edge case, used for debugging
1790 + }
1258 1791 }
1259 - } else {
1260 - if (null == $buffered && function_exists('add_action')) add_action('shutdown', 'wpo_cache_add_footer_output', 11);
1261 - $buffered = $output;
1262 1792 }
1263 -
1264 -}
1265 1793 endif;
1266 1794
1267 1795 /**
1268 1796 * Remove variable names that shouldn't influence cache.
@@ -1271,46 +1799,53 @@
1271 1799 *
1272 1800 * @return array
1273 1801 */
1274 1802 if (!function_exists('wpo_cache_maybe_ignore_query_variables')) :
1275 -function wpo_cache_maybe_ignore_query_variables($variables) {
1276 -
1277 - /**
1278 - * Filters the current $_GET variables that will be used when caching or excluding from cache.
1279 - * Currently:
1280 - * - 'wpo_cache_debug' (Shows the reason for not being cached even when WP_DEBUG isn't set)
1281 - * - 'doing_wp_cron' (alternative cron)
1282 - * - 'aiosp_sitemap_path', 'aiosp_sitemap_page' (All in one SEO sitemap)
1283 - * - 'xml_sitemap', 'seopress_sitemap', 'seopress_news', 'seopress_video', 'seopress_cpt', 'seopress_paged' (SEOPress sitemap)
1284 - * - 'sitemap', 'sitemap_n' (YOAST SEO sitemap)
1285 - */
1286 - $exclude_variables = array(
1287 - 'wpo_cache_debug', // Shows the reason for not being cached even when WP_DEBUG isn't set
1288 - 'doing_wp_cron', // alternative cron
1289 - 'aiosp_sitemap_path', // All in one SEO sitemap
1290 - 'aiosp_sitemap_page',
1291 - 'xml_sitemap', // SEOPress sitemap
1292 - 'seopress_sitemap',
1293 - 'seopress_news',
1294 - 'seopress_video',
1295 - 'seopress_cpt',
1296 - 'seopress_paged',
1297 - 'sitemap', // YOAST SEO sitemap
1298 - 'sitemap_n',
1299 - );
1300 - $exclude_variables = function_exists('apply_filters') ? apply_filters('wpo_cache_ignore_query_variables', $exclude_variables) : $exclude_variables;
1301 -
1302 - if (empty($exclude_variables)) return $variables;
1303 -
1304 - foreach ($exclude_variables as $variable) {
1305 - $exclude = array_search($variable, $variables);
1306 - if (false !== $exclude) {
1307 - array_splice($variables, $exclude, 1);
1803 + function wpo_cache_maybe_ignore_query_variables($variables) {
1804 +
1805 + /**
1806 + * Filters the current $_GET variables that will be used when caching or excluding from cache.
1807 + * Currently:
1808 + * - 'wpo_cache_debug' (Shows the reason for not being cached even when WP_DEBUG isn't set)
1809 + * - 'doing_wp_cron' (alternative cron)
1810 + * - 'aiosp_sitemap_path', 'aiosp_sitemap_page' (All in one SEO sitemap)
1811 + * - 'xml_sitemap', 'seopress_sitemap', 'seopress_news', 'seopress_video', 'seopress_cpt', 'seopress_paged' (SEOPress sitemap)
1812 + * - 'sitemap', 'sitemap_n' (YOAST SEO sitemap)
1813 + */
1814 + $exclude_variables = array(
1815 + 'wpo_cache_debug', // Shows the reason for not being cached even when WP_DEBUG isn't set
1816 + 'doing_wp_cron', // alternative cron
1817 + 'aiosp_sitemap_path', // All in one SEO sitemap
1818 + 'aiosp_sitemap_page',
1819 + 'xml_sitemap', // SEOPress sitemap
1820 + 'seopress_sitemap',
1821 + 'seopress_news',
1822 + 'seopress_video',
1823 + 'seopress_cpt',
1824 + 'seopress_paged',
1825 + 'sitemap', // YOAST SEO sitemap
1826 + 'sitemap_n',
1827 + );
1828 +
1829 + // Analytics extension - only works in premium version
1830 + if (file_exists(WPO_CACHE_EXT_DIR . '/analytics.php')) {
1831 + $analytics_variables = include(WPO_CACHE_EXT_DIR . '/analytics.php');
1832 + $user_defined_variables = wpo_cache_config_get('cache_ignore_query_variables');
1833 + $user_defined_variables = is_array($user_defined_variables) ? $user_defined_variables : array();
1834 + $exclude_variables = array_merge($exclude_variables, $analytics_variables, $user_defined_variables);
1308 1835 }
1836 +
1837 + if (empty($exclude_variables)) return $variables;
1838 +
1839 + foreach ($exclude_variables as $variable) {
1840 + $exclude = array_search($variable, $variables);
1841 + if (false !== $exclude) {
1842 + array_splice($variables, $exclude, 1);
1843 + }
1844 + }
1845 +
1846 + return $variables;
1309 1847 }
1310 -
1311 - return $variables;
1312 -}
1313 1848 endif;
1314 1849
1315 1850 /**
1316 1851 * Get cache config
@@ -1320,35 +1855,99 @@
1320 1855 *
1321 1856 * @return mixed
1322 1857 */
1323 1858 if (!function_exists('wpo_cache_config_get')) :
1324 -function wpo_cache_config_get($key, $default = false) {
1325 - $config = $GLOBALS['wpo_cache_config'];
1859 + function wpo_cache_config_get($key, $default = false) {
1860 + $config = $GLOBALS['wpo_cache_config'];
1861 +
1862 + if (!$config) return false;
1863 +
1864 + if (isset($config[$key])) {
1865 + return $config[$key];
1866 + } else {
1867 + return $default;
1868 + }
1869 + }
1870 +endif;
1326 1871
1327 - if (!$config) return false;
1872 +/**
1873 + * Checks if cache only specific urls option enabled
1874 + *
1875 + * @return boolean
1876 + */
1877 +if (!function_exists('wpo_cache_specific_urls_only')) :
1878 + function wpo_cache_specific_urls_only(): bool {
1879 + return wpo_cache_config_get('cache_specific_urls_only', false);
1880 + }
1881 +endif;
1328 1882
1329 - if (isset($config[$key])) {
1330 - return $config[$key];
1331 - } else {
1332 - return $default;
1883 +if (!function_exists('wpo_read_cache_directory_htaccess')) :
1884 + /**
1885 + * Read .htaccess file for the cache directory.
1886 + *
1887 + * @return string
1888 + */
1889 + function wpo_read_cache_directory_htaccess() {
1890 + $htaccess_filename = WPO_CACHE_FILES_DIR . '/.htaccess';
1891 + return is_file($htaccess_filename) ? file_get_contents($htaccess_filename) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem not available this early
1333 1892 }
1334 -}
1335 1893 endif;
1336 1894
1337 -if (!function_exists('wpo_disable_cache_directories_viewing')) :
1338 -function wpo_disable_cache_directories_viewing() {
1339 - global $is_apache, $is_IIS, $is_iis7;
1895 +if (!function_exists('wpo_write_cache_directory_htaccess')) :
1896 + /**
1897 + * Write .htaccess file for the cache directory.
1898 + *
1899 + * @param string $htaccess_content
1900 + *
1901 + * @return void
1902 + */
1903 + function wpo_write_cache_directory_htaccess($htaccess_content) {
1904 + $htaccess_filename = WPO_CACHE_FILES_DIR . '/.htaccess';
1905 + // phpcs:disable
1906 + // WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- WP_Filesystem not available this early
1907 + // Generic.PHP.NoSilencedErrors.Discouraged -- suppress errors from displaying
1908 + @file_put_contents($htaccess_filename, $htaccess_content);
1909 + // phpcs:enable
1910 + }
1911 +endif;
1340 1912
1341 - if (!is_dir(WPO_CACHE_FILES_DIR)) return;
1913 +if (!function_exists('wpo_allow_access_to_index_cache_files')) :
1914 + /**
1915 + * Update the .htaccess file to allow access to index.html files in the cache directory.
1916 + *
1917 + * @return void
1918 + */
1919 + function wpo_allow_access_to_index_cache_files() {
1920 + $htaccess_content = wpo_read_cache_directory_htaccess();
1921 +
1922 + if (false === strpos($htaccess_content, 'Allow access to index.html files')) {
1923 + $allow_access_to_index_html = "\n\n# Allow access to index.html files\n<FilesMatch \"index\\.html$\">\n\tOrder allow,deny\n\tAllow from all\n</FilesMatch>";
1924 + $htaccess_content .= $allow_access_to_index_html;
1925 + wpo_write_cache_directory_htaccess($htaccess_content);
1926 + }
1927 + }
1928 +endif;
1342 1929
1343 - // Create .htaccess file for apache server.
1344 - if ($is_apache) {
1345 - $htaccess_filename = WPO_CACHE_FILES_DIR . '/.htaccess';
1346 1930
1347 - // CS does not like heredoc
1348 - // phpcs:disable
1349 - $htaccess_content = <<<EOF
1350 -# Disable directory browsing
1931 +if (!function_exists('wpo_disable_cache_directories_viewing')) :
1932 + /**
1933 + * Create config files to disable cache directory viewing
1934 + *
1935 + * @return void
1936 + */
1937 + function wpo_disable_cache_directories_viewing() {
1938 + global $is_apache, $is_IIS, $is_iis7;
1939 +
1940 + if (!is_dir(WPO_CACHE_FILES_DIR)) return;
1941 +
1942 + // Create a .htaccess file for apache server.
1943 + if ($is_apache) {
1944 + $htaccess_filename = WPO_CACHE_FILES_DIR . '/.htaccess';
1945 +
1946 + // CS does not like heredoc
1947 + // phpcs:disable
1948 + $htaccess_content = <<<EOF
1949 +# Disable directory browsing
1351 1950 Options -Indexes
1352 1951
1353 1952 # Disable access to any files
1354 1953 <FilesMatch ".*">
@@ -1353,26 +1952,34 @@
1353 1952 # Disable access to any files
1354 1953 <FilesMatch ".*">
1355 1954 Order allow,deny
1356 1955 Deny from all
1357 -</FilesMatch>
1956 +</FilesMatch>
1358 1957 EOF;
1958 + // phpcs:enable
1959 +
1960 + if (!is_file($htaccess_filename)) wpo_write_cache_directory_htaccess($htaccess_content);
1961 + }
1962 +
1963 + // Create web.config file for IIS servers.
1964 + if ($is_IIS || $is_iis7) {
1965 + $webconfig_filename = WPO_CACHE_FILES_DIR . '/web.config';
1966 + $webconfig_content = "<configuration>\n<system.webServer>\n<authorization>\n<deny users=\"*\" />\n</authorization>\n</system.webServer>\n</configuration>\n";
1967 +
1968 + // phpcs:disable
1969 + // Generic.PHP.NoSilencedErrors.Discouraged -- suppress the error when there is file permission issues
1970 + // WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- WP_Filesystem not available this early
1971 + if (!is_file($webconfig_filename)) @file_put_contents($webconfig_filename, $webconfig_content);
1972 + // phpcs:enable
1973 + }
1974 +
1975 + // Create empty index.php file for all servers.
1976 + // phpcs:disable
1977 + // Generic.PHP.NoSilencedErrors.Discouraged -- suppress the error when there is file permission issues
1978 + // WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- WP_Filesystem not available this early
1979 + if (!is_file(WPO_CACHE_FILES_DIR . '/index.php')) @file_put_contents(WPO_CACHE_FILES_DIR . '/index.php', '');
1359 1980 // phpcs:enable
1360 -
1361 - if (!is_file($htaccess_filename)) @file_put_contents($htaccess_filename, $htaccess_content); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress the error when there is file permission issues
1362 1981 }
1363 -
1364 - // Create web.config file for IIS servers.
1365 - if ($is_IIS || $is_iis7) {
1366 - $webconfig_filename = WPO_CACHE_FILES_DIR . '/web.config';
1367 - $webconfig_content = "<configuration>\n<system.webServer>\n<authorization>\n<deny users=\"*\" />\n</authorization>\n</system.webServer>\n</configuration>\n";
1368 -
1369 - if (!is_file($webconfig_filename)) @file_put_contents($webconfig_filename, $webconfig_content); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress the error when there is file permission issues
1370 - }
1371 -
1372 - // Create empty index.php file for all servers.
1373 - if (!is_file(WPO_CACHE_FILES_DIR . '/index.php')) @file_put_contents(WPO_CACHE_FILES_DIR . '/index.php', '');// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress the error when there is file permission issues
1374 -}
1375 1982 endif;
1376 1983
1377 1984 /**
1378 1985 * Add the headers indicating why the page is not cached or served from cache
@@ -1382,16 +1989,26 @@
1382 1989 * @return void
1383 1990 */
1384 1991 if (!function_exists('wpo_cache_add_nocache_http_header')) :
1385 1992 function wpo_cache_add_nocache_http_header($message = '') {
1386 - static $buffered_message = null;
1993 + if (!headers_sent()) {
1994 + header('WPO-Cache-Status: not cached');
1995 + header('WPO-Cache-Message: '. trim(str_replace(array("\r", "\n", ':'), ' ', strip_tags($message)))); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- wp_strip_all_tags not available this early
1996 + }
1997 + }
1998 +endif;
1387 1999
1388 - if (function_exists('current_filter') && 'send_headers' === current_filter() && $buffered_message && !headers_sent()) {
1389 - header('WPO-Cache-Status: not cached');
1390 - header('WPO-Cache-Message: '. trim(str_replace(array("\r", "\n", ':'), ' ', strip_tags($buffered_message))));
1391 - } else {
1392 - if (!$buffered_message && function_exists('add_action')) add_action('send_headers', 'wpo_cache_add_nocache_http_header', 11);
1393 - $buffered_message = $message;
2000 +/**
2001 + * Add the headers indicating why the page is not cached or served from cache by integrating with the send_headers filter
2002 + *
2003 + * @param string $message - The headers
2004 + *
2005 + * @return void
2006 + */
2007 +if (!function_exists('wpo_cache_add_nocache_http_header_with_send_headers_action')) :
2008 + function wpo_cache_add_nocache_http_header_with_send_headers_action($message) {
2009 + if ('' !== $message && !headers_sent()) {
2010 + wpo_cache_add_nocache_http_header($message);
1394 2011 }
1395 2012 }
1396 2013 endif;
1397 2014
@@ -1405,17 +2022,28 @@
1405 2022 return apply_filters('wpo_feeds_caching_enabled', true);
1406 2023 }
1407 2024 endif;
1408 2025
2026 +/**
2027 + * Check if REST caching enabled
2028 + *
2029 + * @return bool
2030 + */
2031 +if (!function_exists('wpo_rest_caching_enabled')) :
2032 + function wpo_rest_caching_enabled() {
2033 + return wpo_cache_config_get('enable_rest_caching', false);
2034 + }
2035 +endif;
2036 +
1409 2037 if (!function_exists('wpo_debug_backtrace_summary')) {
1410 2038 function wpo_debug_backtrace_summary($ignore_class = null, $skip_frames = 0, $pretty = true) {
1411 2039 static $truncate_paths;
1412 -
1413 - $trace = debug_backtrace(false);
2040 +
2041 + $trace = debug_backtrace(false); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Edge case, using for debugging purpose
1414 2042 $caller = array();
1415 2043 $check_class = !is_null($ignore_class);
1416 2044 $skip_frames++; // Skip this function.
1417 -
2045 +
1418 2046 if (!isset($truncate_paths)) {
1419 2047 $truncate_paths = array(
1420 2048 wpo_normalize_path(WP_CONTENT_DIR),
1421 2049 wpo_normalize_path(ABSPATH),
@@ -1420,17 +2048,17 @@
1420 2048 wpo_normalize_path(WP_CONTENT_DIR),
1421 2049 wpo_normalize_path(ABSPATH),
1422 2050 );
1423 2051 }
1424 -
2052 +
1425 2053 foreach ($trace as $call) {
1426 2054 if ($skip_frames > 0) {
1427 2055 $skip_frames--;
1428 2056 } elseif (isset($call['class'])) {
1429 - if ($check_class && $ignore_class == $call['class']) {
2057 + if ($check_class && $ignore_class === $call['class']) {
1430 2058 continue; // Filter out calls.
1431 2059 }
1432 -
2060 +
1433 2061 $caller[] = "{$call['class']}{$call['type']}{$call['function']}";
1434 2062 } else {
1435 2063 if (in_array($call['function'], array('do_action', 'apply_filters', 'do_action_ref_array', 'apply_filters_ref_array'), true)) {
1436 2064 $caller[] = "{$call['function']}('{$call['args'][0]}')";
@@ -1453,16 +2081,93 @@
1453 2081 if (!function_exists('wpo_normalize_path')) {
1454 2082 function wpo_normalize_path($path) {
1455 2083 // Standardise all paths to use '/'.
1456 2084 $path = str_replace('\\', '/', $path);
1457 -
2085 +
1458 2086 // Replace multiple slashes down to a singular, allowing for network shares having two slashes.
1459 2087 $path = preg_replace('|(?<=.)/+|', '/', $path);
1460 -
2088 +
1461 2089 // Windows paths should uppercase the drive letter.
1462 2090 if (':' === substr($path, 1, 1)) {
1463 2091 $path = ucfirst($path);
1464 2092 }
2093 +
2094 + return $path;
2095 + }
2096 +}
1465 2097
1466 - return $path;
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;
1467 2119 }
2120 +endif;
2121 +
2122 +/**
2123 + * Get path to wp-config.php when called from WP-CLI.
2124 + *
2125 + * @return string
2126 + */
2127 +if (!function_exists('wpo_wp_cli_locate_wp_config')) :
2128 + function wpo_wp_cli_locate_wp_config() {
2129 + $config_path = '';
2130 +
2131 + if (is_callable('\WP_CLI\Utils\locate_wp_config')) {
2132 + $config_path = \WP_CLI\Utils\locate_wp_config();
2133 + }
2134 +
2135 + return $config_path;
2136 + }
2137 +endif;
2138 +
2139 +
2140 +/**
2141 + * Retrieves and sanitizes a value from a superglobal array in a way similar to WordPress's sanitize_text_field(),
2142 + * for use before WordPress is fully loaded
2143 + *
2144 + * @param string $key
2145 + * @param string $global_type
2146 + * @return string sanitized string.
2147 + */
2148 +if (!function_exists('wpo_early_sanitize_superglobal_text')) :
2149 +function wpo_early_sanitize_superglobal_text($key, $global_type = 'server') {
2150 +
2151 + $str = '';
2152 +
2153 + // phpcs:disable
2154 + // Sanitized later in the code, without using WordPress functions as they are not available at this stage
2155 + if ('server' === $global_type) {
2156 + $str = $_SERVER[$key] ?? '';
2157 + }
2158 + // phpcs:enable
2159 +
2160 + if ('' === $str || !is_scalar($str)) {
2161 + return '';
2162 + }
2163 +
2164 + // Remove backslashes (simulate wp_unslash()).
2165 + $str = stripslashes((string) $str);
2166 +
2167 + // Remove ASCII control characters (0x00–0x1F and 0x7F).
2168 + $str = preg_replace('/[\x00-\x1F\x7F]/u', '', $str);
2169 +
2170 + // Trim whitespace and encode special HTML characters.
2171 + return htmlspecialchars(trim($str), ENT_QUOTES, 'UTF-8');
1468 2172 }
2173 +endif;