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 +1591 -849 3.2.22 → 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,248 +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();
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);
44 + }
30 45
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();
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 + }
59 53 }
60 - }
54 +
55 + $restricted_page_type_cache = apply_filters('wpo_restricted_cache_page_type', false);
61 56
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');
57 + if ($restricted_page_type_cache) {
58 + $no_cache_because[] = $restricted_page_type_cache;
68 59 }
69 - }
60 +
61 + $conditional_tag_exceptions = apply_filters('wpo_url_in_conditional_tags_exceptions', false);
70 62
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');
63 + if ($conditional_tag_exceptions) {
64 + $no_cache_because[] = $conditional_tag_exceptions;
90 65 }
91 - }
92 -
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 - }
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');
129 74 }
130 75 }
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 - // Only output if the user has turned on debugging output
141 - if (((defined('WP_DEBUG') && WP_DEBUG) || isset($_GET['wpo_cache_debug'])) && (!defined('DOING_CRON') || !DOING_CRON) && (!defined('REST_REQUEST') || !REST_REQUEST)) {
142 - $buffer .= "\n<!-- WP Optimize page cache - https://getwpo.com - page NOT cached because: ".htmlspecialchars($message)." -->\n";
143 - }
144 76
145 - return $buffer;
146 -
147 - } else {
148 -
149 - // Prevent mixed content when there's an http request but the site URL uses https.
150 - $home_url = get_home_url();
151 -
152 - if (!is_ssl() && 'https' === strtolower(parse_url($home_url, PHP_URL_SCHEME))) {
153 - $https_home_url = $home_url;
154 - $http_home_url = str_ireplace('https://', 'http://', $https_home_url);
155 - $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 + }
156 87 }
157 -
158 - $modified_time = time(); // Take this as soon before writing as possible
159 - $timezone_string = '';
160 - $utc = (float) $GLOBALS['wpo_cache_config']['gmt_offset'];
161 - $modified_time += $utc * 3600;
162 88
163 - if (!empty($GLOBALS['wpo_cache_config']['timezone_string'])) {
164 - $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 + }
165 96 }
166 97
167 - if (!empty($timezone_string)) {
168 - $timezone_postfix = "(".$timezone_string." UTC:". $utc .")";
169 - } else {
170 - $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');
171 100 }
172 101
173 - $add_to_footer = '';
102 + $can_cache_page = true;
103 +
104 + if (defined('DONOTCACHEPAGE') && DONOTCACHEPAGE) {
105 + $can_cache_page = false;
106 + }
174 107
175 108 /**
176 - * Filter whether to display the html comment <!-- Cached by WP-Optimize ... -->
109 + * Defines if the page can be cached or not
177 110 *
178 - * @param boolean $show - Whether to display the html comment
179 - * @return boolean
111 + * @param boolean $can_cache_page
180 112 */
181 - if (preg_match('#</html>#i', $buffer) && (apply_filters('wpo_cache_show_cached_by_comment', true) || (defined('WP_DEBUG') && WP_DEBUG))) {
182 - $date_time_format = 'F j, Y g:i a';
183 - if (!empty($GLOBALS['wpo_cache_config']['date_format']) && !empty($GLOBALS['wpo_cache_config']['time_format'])) {
184 - $date_time_format = $GLOBALS['wpo_cache_config']['date_format'] . ' ' . $GLOBALS['wpo_cache_config']['time_format'];
185 - }
186 -
187 - if (!empty($GLOBALS['wpo_cache_config']['enable_mobile_caching']) && wpo_is_mobile()) {
188 - $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');
189 119 } else {
190 - $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');
191 121 }
192 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 + }
193 137
194 - // Create an empty index.php file in the cache directory for disable directory viewing.
195 - if (!is_file($path . '/index.php')) file_put_contents($path . '/index.php', '');
196 -
197 - /**
198 - * Save $buffer into cache file.
199 - */
138 + // Get cache file name
200 139 $file_ext = '.html';
201 -
140 + $is_feed_cache = false;
141 +
202 142 if (wpo_feeds_caching_enabled()) {
203 143 if (is_feed()) {
204 144 $file_ext = '.rss-xml';
145 + $is_feed_cache = true;
205 146 }
206 147 }
148 +
149 + $cache_filename = wpo_cache_filename($file_ext);
207 150
208 - $cache_filename = wpo_cache_filename($file_ext);
209 - $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();
210 160
211 - if (defined('WPO_CACHE_FILENAME_DEBUG') && WPO_CACHE_FILENAME_DEBUG) {
212 - $add_to_footer .= "\n<!-- WP Optimize page cache debug information -->\n";
213 - if (!empty($GLOBALS['wpo_cache_filename_debug']) && is_array($GLOBALS['wpo_cache_filename_debug'])) {
214 - $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.')';
215 165 }
216 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 + */
217 246
218 - // if we can then cache gzipped content in .gz file.
219 - if (function_exists('gzencode') && apply_filters('wpo_allow_cache_gzip_files', true)) {
220 - // Only replace inside the addition, not inside the main buffer (e.g. post content)
221 - 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)));
222 - }
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 + }
223 257
224 - 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 + }
225 262
226 - if (is_callable('WP_Optimize')) {
227 - // delete cached information about cache size.
228 - WP_Optimize()->get_page_cache()->delete_cache_size_information();
229 - } else {
230 - // If the shutdown occurs before plugins are loaded,
231 - // then this will trigger a fatal error, so, we check first
232 - if (!doing_action('shutdown')) {
233 - error_log('[WPO_CACHE] WP_Optimize() is not callable.');
234 - $message = 'Please report this to WP-O support: ';
235 - if (function_exists('wp_debug_backtrace_summary')) {
236 - $message .= wp_debug_backtrace_summary();
237 - } else {
238 - $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 + }
239 275 }
240 - error_log($message);
241 276 }
242 - }
243 277
244 - header('Cache-Control: no-cache'); // Check back every time to see if re-download is necessary.
245 - header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $modified_time) . ' GMT');
246 - 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 + }
247 286
248 - if (wpo_cache_can_output_gzip_content()) {
249 -
250 - if (!wpo_cache_is_in_response_headers_list('Content-Encoding', 'gzip')) {
251 - 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
252 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');
253 319
254 - // Disable php gzip to avoid double compression.
255 - 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;
256 323
257 - return ob_gzhandler($buffer, $flags);
258 - } else {
259 - 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 + }
260 339 }
261 340 }
262 -}
263 341 endif;
264 342
265 343 /**
266 344 * Load files for support plugins.
@@ -265,52 +343,89 @@
265 343 /**
266 344 * Load files for support plugins.
267 345 */
268 346 if (!function_exists('wpo_cache_load_extensions')) :
269 -function wpo_cache_load_extensions() {
270 - $extensions = glob(WPO_CACHE_EXT_DIR . '/*.php');
271 -
272 - // Add external extensions
273 - if (defined('WPO_CACHE_CUSTOM_EXT_DIR') && is_dir(WPO_CACHE_CUSTOM_EXT_DIR)) {
274 - $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 + }
275 360 }
361 +endif;
276 362
277 - 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 + }
278 376
279 - foreach ($extensions as $extension) {
280 - 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
281 378 }
282 379 }
283 -endif;
284 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 + */
285 399 if (!function_exists('wpo_restricted_cache_page_type')) {
286 -function wpo_restricted_cache_page_type($restricted) {
287 - global $post;
288 -
289 - // Don't cache search or password protected.
290 - if ((function_exists('is_search') && is_search()) || (function_exists('is_404') && is_404()) || !empty($post->post_password)) {
291 - $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;
292 426 }
293 -
294 - // Don't cache the front page if option is set.
295 - if (in_array('/', wpo_get_url_exceptions()) && function_exists('is_front_page') && is_front_page()) {
296 -
297 - $restricted = __('In the settings, caching is disabled for the front page', 'wp-optimize');
298 - }
299 -
300 - // Don't cache htacesss. Remember to properly escape any output to prevent injection.
301 - if (strpos($_SERVER['REQUEST_URI'], '.htaccess') !== false) {
302 - $restricted = 'The file path is unsuitable for caching ('.$_SERVER['REQUEST_URI'].')';
303 - }
304 -
305 - // Don't cache feeds.
306 - if (function_exists('is_feed') && is_feed() && !wpo_feeds_caching_enabled()) {
307 - $restricted = __('We don\'t cache RSS feeds', 'wp-optimize');
308 - }
309 -
310 - return $restricted;
311 427 }
312 -}
313 428
314 429 /**
315 430 * Returns true if we need cache content for loggedin users.
316 431 *
@@ -316,11 +431,11 @@
316 431 *
317 432 * @return bool
318 433 */
319 434 if (!function_exists('wpo_cache_loggedin_users')) :
320 -function wpo_cache_loggedin_users() {
321 - 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());
322 -}
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 + }
323 438 endif;
324 439
325 440 /**
326 441 * Get filename for store cache, depending on gzip, mobile and cookie settings.
@@ -328,84 +443,131 @@
328 443 * @param string $ext
329 444 * @return string
330 445 */
331 446 if (!function_exists('wpo_cache_filename')) :
332 -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 + }
333 456
334 - $wpo_cache_filename_debug = array();
335 -
336 - $filename = 'index';
337 -
338 - if (wpo_cache_mobile_caching_enabled() && wpo_is_mobile()) {
339 - $filename = 'mobile.' . $filename;
340 - }
341 -
342 - if (wpo_webp_images_enabled() && !wpo_is_using_webp_images_redirection() && wpo_is_using_alter_html()) {
343 - $filename = $filename . '.webp';
344 - }
345 -
346 - $cookies = wpo_cache_cookies();
347 -
348 - $cache_key = '';
349 -
350 - /**
351 - * Add cookie values to filename if need.
352 - * This section was inspired by things learned from WP-Rocket.
353 - */
354 - if (!empty($cookies)) {
355 - foreach ($cookies as $key => $cookie_name) {
356 - if (is_array($cookie_name) && isset($_COOKIE[$key])) {
357 - foreach ($cookie_name as $cookie_key) {
358 - if (isset($_COOKIE[$key][$cookie_key]) && '' !== $_COOKIE[$key][$cookie_key]) {
359 - $_cache_key = $cookie_key.'='.$_COOKIE[$key][$cookie_key];
360 - $_cache_key = preg_replace('/[^a-z0-9_\-\=]/i', '-', $_cache_key);
361 - $cache_key .= '-' . $_cache_key;
362 - $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 + }
363 480 }
481 + continue;
364 482 }
365 - 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 + }
366 490 }
367 -
368 - if (isset($_COOKIE[$cookie_name]) && '' !== $_COOKIE[$cookie_name]) {
369 - $_cache_key = $cookie_name.'='.$_COOKIE[$cookie_name];
370 - $_cache_key = preg_replace('/[^a-z0-9_\-\=]/i', '-', $_cache_key);
371 - $cache_key .= '-' . $_cache_key;
372 - $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 + }
373 507 }
374 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;
375 519 }
520 +endif;
376 521
377 - $query_variables = wpo_cache_query_variables();
378 522
379 - /**
380 - * Add GET variables to cache file name if need.
381 - */
382 - if (!empty($query_variables)) {
383 - foreach ($query_variables as $variable) {
384 - if (isset($_GET[$variable]) && !empty($_GET[$variable])) {
385 - $_cache_key = $variable.'='.$_GET[$variable];
386 - $_cache_key = preg_replace('/[^a-z0-9_\-\=]/i', '-', $_cache_key);
387 - $cache_key .= '-' . $_cache_key;
388 - $wpo_cache_filename_debug[] = 'GET parameter: name: ' . $variable . ', value:' . htmlentities($_GET[$variable]) . ', cache_key:' . $_cache_key;
389 - }
390 - }
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);
391 536 }
392 537
393 - // add hash of queried cookies and variables to cache file name.
394 - if ('' !== $cache_key) {
395 - $hash = md5($cache_key);
396 - $filename .= '-'.$hash;
397 - $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);
398 541 }
399 542
400 - $filename = apply_filters('wpo_cache_filename', $filename);
543 + return $filename;
544 +}
545 +endif;
401 546
402 - $wpo_cache_filename_debug[] = 'Extension: ' . $ext;
403 - $wpo_cache_filename_debug[] = 'Filename: ' . $filename.$ext;
404 547
405 - $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 = '';
406 558
407 - 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';
408 570 }
409 571 endif;
410 572
411 573 /**
@@ -411,14 +573,14 @@
411 573 /**
412 574 * Returns site url from site_url() function or if it is not available from cache configuration.
413 575 */
414 576 if (!function_exists('wpo_site_url')) :
415 -function wpo_site_url() {
416 - if (is_callable('site_url')) return site_url('/');
417 -
418 - $site_url = empty($GLOBALS['wpo_cache_config']['site_url']) ? '' : $GLOBALS['wpo_cache_config']['site_url'];
419 - return $site_url;
420 -}
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 + }
421 583 endif;
422 584
423 585 /**
424 586 * Get cookie names which impact on cache file name.
@@ -425,12 +587,12 @@
425 587 *
426 588 * @return array
427 589 */
428 590 if (!function_exists('wpo_cache_cookies')) :
429 -function wpo_cache_cookies() {
430 - $cookies = empty($GLOBALS['wpo_cache_config']['wpo_cache_cookies']) ? array() : $GLOBALS['wpo_cache_config']['wpo_cache_cookies'];
431 - return $cookies;
432 -}
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 + }
433 595 endif;
434 596
435 597 /**
436 598 * Get GET variable names which impact on cache file name.
@@ -437,21 +599,21 @@
437 599 *
438 600 * @return array
439 601 */
440 602 if (!function_exists('wpo_cache_query_variables')) :
441 -function wpo_cache_query_variables() {
442 - if (defined('WPO_CACHE_URL_PARAMS') && WPO_CACHE_URL_PARAMS) {
443 - $variables = array_keys($_GET);
444 - } else {
445 - $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);
446 615 }
447 -
448 - if (!empty($variables)) {
449 - sort($variables);
450 - }
451 -
452 - return wpo_cache_maybe_ignore_query_variables($variables);
453 -}
454 616 endif;
455 617
456 618 /**
457 619 * Get list of all received HTTP headers.
@@ -458,37 +620,37 @@
458 620 *
459 621 * @return array
460 622 */
461 623 if (!function_exists('wpo_get_http_headers')) :
462 -function wpo_get_http_headers() {
463 -
464 - static $headers;
465 -
466 - if (!empty($headers)) return $headers;
467 -
468 - $headers = array();
469 -
470 - // if is apache server then use get allheaders() function.
471 - if (function_exists('getallheaders')) {
472 - $headers = getallheaders();
473 - } else {
474 - // https://www.php.net/manual/en/function.getallheaders.php
475 - foreach ($_SERVER as $key => $value) {
476 -
477 - $key = strtolower($key);
478 -
479 - if ('HTTP_' == substr($key, 0, 5)) {
480 - $headers[str_replace(' ', '-', ucwords(str_replace('_', ' ', substr($key, 5))))] = $value;
481 - } elseif ('content_type' == $key) {
482 - $headers["Content-Type"] = $value;
483 - } elseif ('content_length' == $key) {
484 - $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 + }
485 648 }
486 649 }
650 +
651 + return $headers;
487 652 }
488 -
489 - return $headers;
490 -}
491 653 endif;
492 654
493 655 /**
494 656 * Check if requested Accept-Encoding headers has gzip value.
@@ -495,15 +657,15 @@
495 657 *
496 658 * @return bool
497 659 */
498 660 if (!function_exists('wpo_cache_gzip_accepted')) :
499 -function wpo_cache_gzip_accepted() {
500 - $headers = wpo_get_http_headers();
501 -
502 - if (isset($headers['Accept-Encoding']) && preg_match('/gzip/i', $headers['Accept-Encoding'])) return true;
503 -
504 - return false;
505 -}
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 + }
506 668 endif;
507 669
508 670 /**
509 671 * Check if we can output gzip content in current answer, i.e. check Accept-Encoding headers has gzip value
@@ -511,11 +673,11 @@
511 673 *
512 674 * @return bool
513 675 */
514 676 if (!function_exists('wpo_cache_can_output_gzip_content')) :
515 -function wpo_cache_can_output_gzip_content() {
516 - return wpo_cache_gzip_accepted() && function_exists('ob_gzhandler');
517 -}
677 + function wpo_cache_can_output_gzip_content() {
678 + return wpo_cache_gzip_accepted() && function_exists('ob_gzhandler');
679 + }
518 680 endif;
519 681
520 682 /**
521 683 * Check if header with certain name exists in already prepared headers and has value comparable with $header_value.
@@ -525,29 +687,29 @@
525 687 *
526 688 * @return bool
527 689 */
528 690 if (!function_exists('wpo_cache_is_in_response_headers_list')) :
529 -function wpo_cache_is_in_response_headers_list($header_name, $header_value) {
530 - $headers_list = headers_list();
531 -
532 - if (!empty($headers_list)) {
533 - $header_name = strtolower($header_name);
534 -
535 - foreach ($headers_list as $value) {
536 - $value = explode(':', $value);
537 -
538 - if (strtolower($value[0]) == $header_name) {
539 - if (preg_match('/'.$header_value.'/', $value[1])) {
540 - return true;
541 - } else {
542 - 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 + }
543 706 }
544 707 }
545 708 }
709 +
710 + return false;
546 711 }
547 -
548 - return false;
549 -}
550 712 endif;
551 713
552 714 /**
553 715 * Check if mobile cache is enabled and current request is from moblile device.
@@ -554,12 +716,12 @@
554 716 *
555 717 * @return bool
556 718 */
557 719 if (!function_exists('wpo_cache_mobile_caching_enabled')) :
558 -function wpo_cache_mobile_caching_enabled() {
559 - if (!empty($GLOBALS['wpo_cache_config']['enable_mobile_caching'])) return true;
560 - return false;
561 -}
720 + function wpo_cache_mobile_caching_enabled() {
721 + if (!empty($GLOBALS['wpo_cache_config']['enable_mobile_caching'])) return true;
722 + return false;
723 + }
562 724 endif;
563 725
564 726 /**
565 727 * Check if webp images enabled
@@ -573,15 +735,27 @@
573 735 }
574 736 endif;
575 737
576 738 /**
577 - * Check whether webp images using alter html method or not
739 + * Check whether the requesting browser supports WebP images
578 740 *
579 741 * @return bool
580 742 */
581 -if (!function_exists('wpo_is_using_alter_html')) :
582 - function wpo_is_using_alter_html() {
583 - 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;
584 758 }
585 759 endif;
586 760
587 761 /**
@@ -591,15 +765,15 @@
591 765 */
592 766 if (!function_exists('wpo_is_using_webp_images_redirection')) :
593 767 function wpo_is_using_webp_images_redirection() {
594 768 if (empty($GLOBALS['wpo_cache_config']['uploads'])) return false;
595 -
769 +
596 770 $uploads_dir = $GLOBALS['wpo_cache_config']['uploads'];
597 771 $htaccess_file = $uploads_dir . '/.htaccess';
598 772 if (!file_exists($htaccess_file)) return false;
599 - $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
600 774 $comment_sections = array('Register webp mime type', 'WP-Optimize WebP Rules');
601 -
775 +
602 776 if (function_exists('str_contains')) {
603 777 return str_contains($htaccess_content, $comment_sections[0]) && str_contains($htaccess_content, $comment_sections[1]);
604 778 } else {
605 779 return strpos($htaccess_content, $comment_sections[0]) && strpos($htaccess_content, $comment_sections[1]);
@@ -607,107 +781,373 @@
607 781 }
608 782 endif;
609 783
610 784 /**
785 + * Verify if the current request is related to the Activity Stream
786 + *
787 + * @return bool
788 + */
789 +if (!function_exists('wpo_is_activity_stream_requested')) :
790 + function wpo_is_activity_stream_requested() {
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
792 + }
793 +endif;
794 +
795 +/**
796 + * Verify if the current request is robots.txt
797 + */
798 +if (!function_exists('wpo_is_robots_txt_requested')) :
799 + function wpo_is_robots_txt_requested() {
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
801 + }
802 +endif;
803 +
804 +/**
611 805 * Serves the cache and exits
806 + *
807 + * @return void
612 808 */
613 809 if (!function_exists('wpo_serve_cache')) :
614 -function wpo_serve_cache() {
615 - $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();
616 815
617 - $file_name_rss_xml = wpo_cache_filename('.rss-xml');
618 - $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 = '';
619 822
620 - $path_dir = WPO_CACHE_FILES_DIR . '/' . wpo_get_url_path() . '/';
621 - $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
622 836
623 - if (wpo_feeds_caching_enabled()) {
624 - // check for .xml cache file if .html cache file doesn't exist
625 - if (!file_exists($path_dir . $file_name) && file_exists($path_dir . $file_name_rss_xml)) {
626 - $path = $path_dir . $file_name_rss_xml;
627 - $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 + }
628 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 + }
629 928 }
929 +endif;
630 930
631 - $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;
632 944
633 - // if we can use gzip and gzipped file exist in cache we use it.
634 - // if headers already sent we don't use gzipped file content.
635 - if (!headers_sent() && wpo_cache_gzip_accepted() && file_exists($path . '.gz')) {
636 - $path .= '.gz';
637 - $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');
638 954 }
955 +endif;
639 956
640 - $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;
641 982
642 - // Cache has expired, purge and exit.
643 - if (!empty($GLOBALS['wpo_cache_config']['page_cache_length'])) {
644 - if (time() > ($GLOBALS['wpo_cache_config']['page_cache_length'] + $modified_time)) {
645 - wpo_delete_files($path);
646 - return;
647 - }
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;
648 995 }
649 996
650 - if ($use_gzip) {
651 - // Disable zlib output compression to avoid double content compression
652 - ini_set('zlib.output_compression', 'Off');
997 + if (wpo_is_activity_stream_requested()) {
998 + return false;
653 999 }
654 1000
655 - $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 + }
656 1005
657 - 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 + }
658 1011
659 - if (!empty($modified_time) && !empty($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) === $modified_time) {
660 - if ($use_gzip && !$gzip_header_already_sent) {
661 - 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';
662 1025 }
663 -
664 - if ($send_as_feed) {
665 - 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)';
666 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 + }
667 1046
668 - header('WPO-Cache-Status: cached');
669 - header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $modified_time) . ' GMT');
670 - header($_SERVER['SERVER_PROTOCOL'] . ' 304 Not Modified', true, 304);
671 - exit;
1047 + if (wpo_restricted_cache_page_with_cart_items()) {
1048 + $no_cache_because[] = 'User has items in WooCommerce cart.';
672 1049 }
673 1050
674 - 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 + }
675 1055
676 - if (wpo_is_canonical_redirection_needed()) return;
1056 + $current_url = wpo_current_url();
677 1057
678 - if ($use_gzip && !$gzip_header_already_sent) {
679 - header('Content-Encoding: gzip');
680 - }
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 + }
681 1062
682 - // send correct headers for xml and txt files
683 - $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 + }
684 1067
685 - if (preg_match('/\.xml$/i', $filename)) {
686 - header('Content-type: text/xml');
687 - }
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
688 1072
689 - if (preg_match('/\.txt$/i', $filename)) {
690 - header('Content-type: text/plain');
691 - }
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);
692 1075
693 - if ($send_as_feed) {
694 - 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";
695 1078 }
1079 + }
696 1080
697 - header('WPO-Cache-Status: cached');
698 - if (!empty($modified_time)) {
699 - header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $modified_time) . ' GMT');
700 - }
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));
701 1083
702 - 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;
703 1094
704 - 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']);
705 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;
706 1123 }
707 1124 endif;
708 1125
709 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 +/**
710 1150 * Checks and does redirection, if needed
711 1151 *
712 1152 * @return bool
713 1153 */
@@ -715,17 +1155,21 @@
715 1155 function wpo_is_canonical_redirection_needed() {
716 1156 $permalink_structure = isset($GLOBALS['wpo_cache_config']['permalink_structure']) ? $GLOBALS['wpo_cache_config']['permalink_structure'] : '';
717 1157 $site_url = wpo_site_url();
718 1158
1159 + // Exit if server variables are not available.
1160 + if (!isset($_SERVER['HTTP_HOST'])) return false;
1161 +
719 1162 $schema = isset($_SERVER['HTTPS']) && 'on' === $_SERVER['HTTPS'] ? "https" : "http";
720 - $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
721 1165 $requested_url = $schema . $url_part;
722 - $url_parts = parse_url($requested_url);
723 - $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) : '';
724 1168
725 - if (!empty($permalink_structure) && $requested_url != $site_url) {
726 - $request_uri = rtrim($_SERVER['REQUEST_URI'], '?');
727 - 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'])) {
728 1172 $url = preg_replace('/(.+?)([\/]*)(\[\?\#][^\/]+|$)/', '$1/$3', $request_uri);
729 1173 if (0 !== strcmp($request_uri, $url)) return true;
730 1174 } else {
731 1175 $url = rtrim($request_uri, '/');
@@ -739,18 +1183,18 @@
739 1183 /**
740 1184 * Clears the cache
741 1185 */
742 1186 if (!function_exists('wpo_cache_flush')) :
743 -function wpo_cache_flush() {
744 -
745 - if (defined('WPO_CACHE_FILES_DIR') && '' != WPO_CACHE_FILES_DIR) wpo_delete_files(WPO_CACHE_FILES_DIR);
746 -
747 - if (function_exists('wp_cache_flush')) {
748 - 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');
749 1196 }
750 -
751 - do_action('wpo_cache_flush');
752 -}
753 1197 endif;
754 1198
755 1199 /**
756 1200 * Get URL path for caching
@@ -758,31 +1202,27 @@
758 1202 * @since 1.0
759 1203 * @return string
760 1204 */
761 1205 if (!function_exists('wpo_get_url_path')) :
762 -function wpo_get_url_path($url = '') {
763 - $url = '' == $url ? wpo_current_url() : $url;
764 - $url_parts = parse_url($url);
765 -
766 - if (isset($url_parts['path']) && false !== stripos($url_parts['path'], '/index.php')) {
767 - $url_parts['path'] = preg_replace('/(.*?)index\.php(\/.+)/i', '$1index-php$2', $url_parts['path']);
768 - }
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
769 1209
770 - /*
771 - * Convert the hexadecimal digits within the percent-encoded triplet to uppercase, to ensure that the path remains
772 - * consistent. For instance, "example.com/%e0%a6" will be converted to "example.com/%E0%A6".
773 - */
774 - if (isset($url_parts['path'])) {
775 - $url_parts['path'] = preg_replace_callback('/%[0-9A-F]{2}/i', function($matches) {
776 - return strtoupper($matches[0]);
777 - }, $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'];
778 1224 }
779 -
780 - if (!isset($url_parts['host'])) $url_parts['host'] = '';
781 - if (!isset($url_parts['path'])) $url_parts['path'] = '';
782 -
783 - return $url_parts['host'].$url_parts['path'];
784 -}
785 1225 endif;
786 1226
787 1227 /**
788 1228 * Get requested url.
@@ -789,19 +1229,21 @@
789 1229 *
790 1230 * @return string
791 1231 */
792 1232 if (!function_exists('wpo_current_url')) :
793 -function wpo_current_url() {
794 - // 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).
795 - // Otherwise this function would return a different URL at the beginning and end of the cache process.
796 - static $url = '';
797 - if ('' != $url) return $url;
798 - $http_host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
799 - $url = rtrim('http' . ((isset($_SERVER['HTTPS']) && ('on' == $_SERVER['HTTPS'] || 1 == $_SERVER['HTTPS']) ||
800 - isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && 'https' == $_SERVER['HTTP_X_FORWARDED_PROTO']) ? 's' : '' )
801 - . '://' . $http_host.$_SERVER['REQUEST_URI'], '/');
802 - return $url;
803 -}
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 + }
804 1246 endif;
805 1247
806 1248 /**
807 1249 * Return list of conditional tag exceptions.
@@ -808,42 +1250,42 @@
808 1250 *
809 1251 * @return array
810 1252 */
811 1253 if (!function_exists('wpo_get_conditional_tags_exceptions')) :
812 -function wpo_get_conditional_tags_exceptions() {
813 - static $exceptions = null;
814 -
815 - if (null !== $exceptions) return $exceptions;
816 -
817 - if (!empty($GLOBALS['wpo_cache_config'])) {
818 - if (empty($GLOBALS['wpo_cache_config']['cache_exception_conditional_tags'])) {
819 - $exceptions = array();
820 -
821 - } 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 + }
822 1268
823 - $exceptions = $GLOBALS['wpo_cache_config']['cache_exception_conditional_tags'];
824 -
825 - }
826 -
827 - } elseif (class_exists('WPO_Page_Cache')) {
828 -
829 - $config = WPO_Page_Cache::instance()->config->get();
830 -
831 - if (is_array($config) && array_key_exists('cache_exception_conditional_tags', $config)) {
832 - $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 +
833 1282 } else {
834 1283 $exceptions = array();
835 1284 }
836 -
837 - $exceptions = is_array($exceptions) ? $exceptions : preg_split('#(\n|\r|\r\n)#', $exceptions);
838 - $exceptions = array_filter($exceptions, 'trim');
839 1285
840 - } else {
841 - $exceptions = array();
1286 + return $exceptions;
842 1287 }
843 -
844 - return $exceptions;
845 -}
846 1288 endif;
847 1289
848 1290 /**
849 1291 * Return list of url exceptions.
@@ -850,38 +1292,66 @@
850 1292 *
851 1293 * @return array
852 1294 */
853 1295 if (!function_exists('wpo_get_url_exceptions')) :
854 -function wpo_get_url_exceptions() {
855 - static $exceptions = null;
856 -
857 - if (null !== $exceptions) return $exceptions;
858 -
859 - // if called from file-based-page-cache.php when WP loading
860 - // and cache settings exists then use it otherwise get settings from database.
861 - if (!empty($GLOBALS['wpo_cache_config'])) {
862 - 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 {
863 1321 $exceptions = array();
864 - } else {
865 - $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']);
866 1322 }
867 - } elseif (class_exists('WPO_Page_Cache')) {
868 - $config = WPO_Page_Cache::instance()->config->get();
1323 +
1324 + return apply_filters('wpo_get_url_exceptions', $exceptions);
1325 + }
1326 +endif;
869 1327
870 - if (is_array($config) && array_key_exists('cache_exception_urls', $config)) {
871 - $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 + }
872 1348 } else {
873 - $exceptions = array();
1349 + $cache_include_urls = array();
874 1350 }
875 -
876 - $exceptions = is_array($exceptions) ? $exceptions : preg_split('#(\n|\r)#', $exceptions);
877 - $exceptions = array_filter($exceptions, 'trim');
878 - } else {
879 - $exceptions = array();
1351 +
1352 + return apply_filters('wpo_get_cache_include_urls', $cache_include_urls);
880 1353 }
881 -
882 - return apply_filters('wpo_get_url_exceptions', $exceptions);
883 -}
884 1354 endif;
885 1355
886 1356 /**
887 1357 * Return true of exception url matches current url
@@ -890,12 +1360,12 @@
890 1360 * @param bool $regex Whether to check with regex or not.
891 1361 * @return bool true if matched, false otherwise
892 1362 */
893 1363 if (!function_exists('wpo_current_url_exception_match')) :
894 -function wpo_current_url_exception_match($exception) {
895 -
896 - return wpo_url_exception_match(wpo_current_url(), $exception);
897 -}
1364 + function wpo_current_url_exception_match($exception) {
1365 +
1366 + return wpo_url_exception_match(wpo_current_url(), $exception);
1367 + }
898 1368 endif;
899 1369
900 1370 /**
901 1371 * Check if url in conditional tags exceptions list.
@@ -902,31 +1372,32 @@
902 1372 *
903 1373 * @return string
904 1374 */
905 1375 if (!function_exists('wpo_url_in_conditional_tags_exceptions')) :
906 -function wpo_url_in_conditional_tags_exceptions() {
907 -
908 - $exceptions = wpo_get_conditional_tags_exceptions();
909 - $restricted = '';
910 - $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');
911 - //Filter for add more conditional tags to whitelist in the exceptions list.
912 - $allowed_functions = apply_filters('wpo_allowed_conditional_tags_exceptions', $allowed_functions);
913 - if (!empty($exceptions)) {
914 - foreach ($exceptions as $exception) {
915 - if (false !== strpos($exception, 'is_')) {
916 - $exception_function = $exception;
917 - if ('()' == substr($exception, -2)) {
918 - $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 + }
919 1395 }
920 -
921 - if (in_array($exception_function, $allowed_functions) && function_exists($exception_function) && call_user_func($exception_function)) {
922 - $restricted = sprintf(__('In the settings, caching is disabled for %s', 'wp-optimize'), $exception_function);
923 - }
924 1396 }
925 1397 }
1398 + return $restricted;
926 1399 }
927 - return $restricted;
928 -}
929 1400 endif;
930 1401
931 1402
932 1403 /**
@@ -936,72 +1407,114 @@
936 1407 *
937 1408 * @return bool
938 1409 */
939 1410 if (!function_exists('wpo_url_in_exceptions')) :
940 -function wpo_url_in_exceptions($url) {
941 - $exceptions = wpo_get_url_exceptions();
942 -
943 - if (!empty($exceptions)) {
944 - foreach ($exceptions as $exception) {
945 -
946 - // don't check / - front page using regexp, we handle it in wpo_restricted_cache_page_type()
947 - if ('/' == $exception) continue;
948 -
949 - if (wpo_url_exception_match($url, $exception)) {
950 - // Exception match.
951 - return true;
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 + }
952 1424 }
953 1425 }
1426 +
1427 + return false;
954 1428 }
955 -
956 - return false;
957 -}
958 1429 endif;
959 1430
960 1431 /**
961 - * Check if url string match with exception.
1432 + * Checks if URL matches against listed include.
962 1433 *
963 - * @param string $url - complete url string i.e. http(s):://domain/path
964 - * @param string $exception - complete url or absolute path, can consist (.*) wildcards
1434 + * Supports:
1435 + * - Root-based paths (e.g., /page)
1436 + * - Wildcards (*) in the last segment
1437 + * - One optional parent directory (sub-dir multisite)
965 1438 *
1439 + * @param string $url
966 1440 * @return bool
967 1441 */
968 -if (!function_exists('wpo_url_exception_match')) :
969 -function wpo_url_exception_match($url, $exception) {
970 - if (preg_match('#^[\s]*$#', $exception)) {
971 - return false;
972 - }
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)
973 1446
974 - $exception = str_replace('*', '.*', $exception);
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, '/');
975 1451
976 - $exception = trim($exception);
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);
977 1461
978 - // used to test websites placed in subdirectories.
979 - $sub_dir = '';
1462 + if (preg_match('#^(/[^/]+)?' . $pattern . '$#i', $path)) {
1463 + return true;
1464 + }
1465 + }
980 1466
981 - // if exception defined from root i.e. /page1 then remove domain part in url.
982 - if (preg_match('/^\//', $exception)) {
983 - // get site sub directory.
984 - $sub_dir = preg_replace('#^(http|https):\/\/.*\/#Ui', '', wpo_site_url());
985 - // add prefix slash and remove slash.
986 - $sub_dir = ('' == $sub_dir) ? '' : '/' . rtrim($sub_dir, '/');
987 - // get relative path
988 - $url = preg_replace('#^(http|https):\/\/.*\/#Ui', '/', $url);
1467 + if (wpo_url_exception_match($url, $include_url)) {
1468 + return true;
1469 + }
1470 + }
1471 + }
1472 +
1473 + return false;
989 1474 }
1475 +endif;
990 1476
991 - $url = rtrim($url, '/') . '/';
992 - $exception = rtrim($exception, '/');
1477 +/**
1478 + * Checks if an URL matches against listed exceptions.
1479 + *
1480 + * @param string $url - complete url string i.e. http(s)://domain/path
1481 + * @param string $exception - complete url or absolute path, can contain (.*) wildcards; Sometimes can be urlencoded
1482 + *
1483 + * @return bool
1484 + */
1485 +if (!function_exists('wpo_url_exception_match')) :
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);
993 1510
994 - // if we have no wildcat in the end of exception then add slash.
995 - if (!preg_match('#\(\.\*\)$#', $exception)) $exception .= '/';
1511 + if (!$exception) return false;
996 1512
997 - $exception = preg_quote($exception);
998 -
999 - // fix - unescape possible escaped mask .*
1000 - $exception = str_replace('\\.\\*', '.*', $exception);
1001 -
1002 - return preg_match('#^'.$exception.'$#i', $url) || preg_match('#^'.$sub_dir.$exception.'$#i', $url);
1003 -}
1513 + $exception = urldecode($exception);
1514 +
1515 + return (preg_match('#^'.$exception.'$#i', $url) || preg_match('#^'.$sub_dir.$exception.'$#i', $url));
1516 + }
1004 1517 endif;
1005 1518
1006 1519 /**
1007 1520 * Checks if its a mobile device
@@ -1008,114 +1521,194 @@
1008 1521 *
1009 1522 * @see https://developer.wordpress.org/reference/functions/wp_is_mobile/
1010 1523 */
1011 1524 if (!function_exists('wpo_is_mobile')) :
1012 -function wpo_is_mobile() {
1013 - if (empty($_SERVER['HTTP_USER_AGENT'])) {
1014 - $is_mobile = false;
1015 - // many mobile devices (all iPhone, iPad, etc.)
1016 - } elseif (strpos($_SERVER['HTTP_USER_AGENT'], 'Mobile') !== false
1017 - || strpos($_SERVER['HTTP_USER_AGENT'], 'Android') !== false
1018 - || strpos($_SERVER['HTTP_USER_AGENT'], 'Silk/') !== false
1019 - || strpos($_SERVER['HTTP_USER_AGENT'], 'Kindle') !== false
1020 - || strpos($_SERVER['HTTP_USER_AGENT'], 'BlackBerry') !== false
1021 - || strpos($_SERVER['HTTP_USER_AGENT'], 'Opera Mini') !== false
1022 - || strpos($_SERVER['HTTP_USER_AGENT'], 'Opera Mobi') !== false
1023 - ) {
1024 - $is_mobile = true;
1025 - } else {
1026 - $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;
1027 1544 }
1028 -
1029 - return $is_mobile;
1030 -}
1031 1545 endif;
1032 1546
1033 1547 /**
1034 - * Check if current browser agent is not disabled in options.
1548 + * Converts a wildcard mask to a regular expression pattern.
1035 1549 *
1036 - * @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.
1037 1553 */
1038 -if (!function_exists('wpo_is_accepted_user_agent')) :
1039 -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);
1040 1557
1041 - $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 + }
1042 1561
1043 - if (!empty($exceptions)) {
1044 - foreach ($exceptions as $exception) {
1045 - if ('' == trim($exception)) continue;
1562 + // Convert wildcard to regex
1563 + $mask = str_replace('*', '.*', $mask);
1046 1564
1047 - 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, '/') . '/';
1048 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;
1049 1581 }
1582 +endif;
1050 1583
1051 - return true;
1052 -}
1053 -endif;
1054 1584
1055 -if (!function_exists('wpo_delete_files')) :
1056 1585 /**
1057 - * Deletes a specified source file or directory.
1586 + * Check if current browser agent is not disabled in options.
1058 1587 *
1059 - * If $src is a file, only that file will be deleted. If $src is a directory, the behavior depends on the
1060 - * $recursive parameter. When $recursive is true, the directory and its contents (including files and subdirectories)
1061 - * will be deleted. When $recursive is false, only the files in the top-level directory(eg. $src directory) will be deleted,
1062 - * while the $src directory itself and its subdirectories will remain untouched.
1588 + * @param string $user_agent
1063 1589 *
1064 - * @param string $src The path to the source file or directory to delete.
1065 - * @param bool $recursive (Optional) When set to true, the directory and its contents (including files and subdirectories)
1066 - * will be deleted. If false, only the files in the top-level directory will be deleted while
1067 - * its subdirectories will be preserved. Defaults to true.
1068 - *
1069 - * @return bool Returns true if the specified file or all files within the specified directory (and its subdirectories,
1070 - * when $recursive is true) are successfully deleted. Returns false if any file(s) could not be deleted
1071 - * due to file permissions or other reasons.
1590 + * @return bool
1072 1591 */
1073 -function wpo_delete_files($src, $recursive = true) {
1074 - // If the source doesn't exist, consider it deleted and return true
1075 - 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 +
1076 1608 return true;
1077 1609 }
1610 +endif;
1078 1611
1079 - /*
1080 - * If the source is a file, delete it and return the result.
1081 - * If `unlink()` fails, we also verify if the file still exists before returning the result, as another
1082 - * 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.
1083 1629 */
1084 - if (is_file($src)) {
1085 - if (!@unlink($src) && file_exists($src)) { // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress PHP warning in case of failure
1086 - 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;
1087 1634 }
1088 - return true;
1089 - }
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 + }
1090 1669
1091 - $success = true;
1092 -
1093 - // If recursive is false, delete only the top-level files and return the result
1094 - 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
1095 1691 $dir_handle = @opendir($src); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress PHP warning in case of failure
1096 -
1692 +
1097 1693 /*
1098 1694 * If opendir() is successful, process directory contents. If not, check if the directory exists.
1099 1695 * If it exists, return false (failure). Otherwise, assume it's already deleted and return true (success).
1100 1696 */
1101 1697 if (false !== $dir_handle) {
1102 -
1698 +
1103 1699 while (false !== ($file = readdir($dir_handle))) {
1104 - if ('.' == $file || '..' == $file) {
1700 + if ('.' === $file || '..' === $file) {
1105 1701 continue;
1106 1702 }
1107 1703
1108 - $full_path = $src . '/' . $file;
1109 -
1110 - // If it's a file, delete it
1111 - if (is_file($full_path)) {
1112 - if (!wpo_delete_files($full_path)) {
1113 - $success = false;
1114 - }
1704 + $full_path = rtrim($src, '/\\') . DIRECTORY_SEPARATOR . $file;
1705 +
1706 + if (!wpo_delete_files($full_path)) {
1707 + $success = false;
1115 1708 }
1116 1709 }
1117 -
1710 +
1118 1711 closedir($dir_handle);
1119 1712 } else {
1120 1713 if (file_exists($src)) {
1121 1714 $success = false;
@@ -1121,111 +1714,83 @@
1121 1714 $success = false;
1122 1715 }
1123 1716 }
1124 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 +
1125 1734 return $success;
1126 1735 }
1736 +endif;
1127 1737
1128 - // If recursive is true, delete all files and directories recursively
1129 - $dir_handle = @opendir($src); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress PHP warning in case of failure
1130 -
1131 - /*
1132 - * If opendir() is successful, process directory contents. If not, check if the directory exists.
1133 - * 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
1134 1745 */
1135 - if (false !== $dir_handle) {
1136 -
1137 - while (false !== ($file = readdir($dir_handle))) {
1138 - if ('.' == $file || '..' == $file) {
1139 - 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;
1140 1761 }
1141 1762
1142 - $full_path = $src . '/' . $file;
1143 -
1144 - if (!wpo_delete_files($full_path)) {
1145 - $success = false;
1146 - }
1763 + $file = readdir($handle);
1147 1764 }
1148 -
1149 - closedir($dir_handle);
1150 - } else {
1151 - if (file_exists($src)) {
1152 - $success = false;
1153 - }
1765 +
1766 + closedir($handle);
1767 + return $is_empty;
1154 1768 }
1155 -
1156 - /*
1157 - * Delete the source directory itself.
1158 - * Success of `rmdir` operation is not recorded; we only ultimately care about emptying, not removing
1159 - * entirely (empty folders in our context are harmless)
1160 - */
1161 - if ($success) {
1162 - @rmdir($src); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- suppress PHP warning in case of failure
1163 - }
1164 -
1165 - // Delete cached information about cache size
1166 - WP_Optimize()->get_page_cache()->delete_cache_size_information();
1167 -
1168 - return $success;
1169 -}
1170 1769 endif;
1171 1770
1172 -if (!function_exists('wpo_is_empty_dir')) :
1173 1771 /**
1174 - * Check if selected directory is empty or has only index.php which we added for security reasons.
1175 - *
1176 - * @param string $dir
1177 - *
1178 - * @return bool
1179 - */
1180 -function wpo_is_empty_dir($dir) {
1181 - if (!file_exists($dir) || !is_dir($dir)) return false;
1182 -
1183 - $handle = opendir($dir);
1184 -
1185 - if (false === $handle) return false;
1186 -
1187 - $is_empty = true;
1188 - $file = readdir($handle);
1189 -
1190 - while (false !== $file) {
1191 -
1192 - if ('.' != $file && '..' != $file && 'index.php' != $file) {
1193 - $is_empty = false;
1194 - break;
1195 - }
1196 -
1197 - $file = readdir($handle);
1198 - }
1199 -
1200 - closedir($handle);
1201 - return $is_empty;
1202 -}
1203 -endif;
1204 -
1205 -/**
1206 1772 * Either store for later output, or output now. Only the most-recent call will be effective.
1207 1773 *
1208 1774 * @param String|Null $output - if not null, then the string to use when called by the shutdown action.
1209 1775 */
1210 1776 if (!function_exists('wpo_cache_add_footer_output')) :
1211 -function wpo_cache_add_footer_output($output = null) {
1212 -
1213 - static $buffered = null;
1214 -
1215 - if (function_exists('current_filter') && 'shutdown' == current_filter()) {
1216 - // Only add the line if it was a page, not something else (e.g. REST response)
1217 - if (function_exists('did_action') && did_action('wp_footer')) {
1218 - echo "\n<!-- WP Optimize page cache - https://getwpo.com - ".$buffered." -->\n";
1219 - } elseif (defined('WPO_CACHE_DEBUG') && WPO_CACHE_DEBUG && (!defined('REST_REQUEST') || !REST_REQUEST)) {
1220 - 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 + }
1221 1791 }
1222 - } else {
1223 - if (null == $buffered && function_exists('add_action')) add_action('shutdown', 'wpo_cache_add_footer_output', 11);
1224 - $buffered = $output;
1225 1792 }
1226 -
1227 -}
1228 1793 endif;
1229 1794
1230 1795 /**
1231 1796 * Remove variable names that shouldn't influence cache.
@@ -1234,46 +1799,53 @@
1234 1799 *
1235 1800 * @return array
1236 1801 */
1237 1802 if (!function_exists('wpo_cache_maybe_ignore_query_variables')) :
1238 -function wpo_cache_maybe_ignore_query_variables($variables) {
1239 -
1240 - /**
1241 - * Filters the current $_GET variables that will be used when caching or excluding from cache.
1242 - * Currently:
1243 - * - 'wpo_cache_debug' (Shows the reason for not being cached even when WP_DEBUG isn't set)
1244 - * - 'doing_wp_cron' (alternative cron)
1245 - * - 'aiosp_sitemap_path', 'aiosp_sitemap_page' (All in one SEO sitemap)
1246 - * - 'xml_sitemap', 'seopress_sitemap', 'seopress_news', 'seopress_video', 'seopress_cpt', 'seopress_paged' (SEOPress sitemap)
1247 - * - 'sitemap', 'sitemap_n' (YOAST SEO sitemap)
1248 - */
1249 - $exclude_variables = array(
1250 - 'wpo_cache_debug', // Shows the reason for not being cached even when WP_DEBUG isn't set
1251 - 'doing_wp_cron', // alternative cron
1252 - 'aiosp_sitemap_path', // All in one SEO sitemap
1253 - 'aiosp_sitemap_page',
1254 - 'xml_sitemap', // SEOPress sitemap
1255 - 'seopress_sitemap',
1256 - 'seopress_news',
1257 - 'seopress_video',
1258 - 'seopress_cpt',
1259 - 'seopress_paged',
1260 - 'sitemap', // YOAST SEO sitemap
1261 - 'sitemap_n',
1262 - );
1263 - $exclude_variables = function_exists('apply_filters') ? apply_filters('wpo_cache_ignore_query_variables', $exclude_variables) : $exclude_variables;
1264 -
1265 - if (empty($exclude_variables)) return $variables;
1266 -
1267 - foreach ($exclude_variables as $variable) {
1268 - $exclude = array_search($variable, $variables);
1269 - if (false !== $exclude) {
1270 - 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);
1271 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;
1272 1847 }
1273 -
1274 - return $variables;
1275 -}
1276 1848 endif;
1277 1849
1278 1850 /**
1279 1851 * Get cache config
@@ -1283,35 +1855,99 @@
1283 1855 *
1284 1856 * @return mixed
1285 1857 */
1286 1858 if (!function_exists('wpo_cache_config_get')) :
1287 -function wpo_cache_config_get($key, $default = false) {
1288 - $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;
1289 1871
1290 - 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;
1291 1882
1292 - if (isset($config[$key])) {
1293 - return $config[$key];
1294 - } else {
1295 - 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
1296 1892 }
1297 -}
1298 1893 endif;
1299 1894
1300 -if (!function_exists('wpo_disable_cache_directories_viewing')) :
1301 -function wpo_disable_cache_directories_viewing() {
1302 - 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;
1303 1912
1304 - 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;
1305 1929
1306 - // Create .htaccess file for apache server.
1307 - if ($is_apache) {
1308 - $htaccess_filename = WPO_CACHE_FILES_DIR . '/.htaccess';
1309 1930
1310 - // CS does not like heredoc
1311 - // phpcs:disable
1312 - $htaccess_content = <<<EOF
1313 -# 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
1314 1950 Options -Indexes
1315 1951
1316 1952 # Disable access to any files
1317 1953 <FilesMatch ".*">
@@ -1316,26 +1952,34 @@
1316 1952 # Disable access to any files
1317 1953 <FilesMatch ".*">
1318 1954 Order allow,deny
1319 1955 Deny from all
1320 -</FilesMatch>
1956 +</FilesMatch>
1321 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', '');
1322 1980 // phpcs:enable
1323 -
1324 - 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
1325 1981 }
1326 -
1327 - // Create web.config file for IIS servers.
1328 - if ($is_IIS || $is_iis7) {
1329 - $webconfig_filename = WPO_CACHE_FILES_DIR . '/web.config';
1330 - $webconfig_content = "<configuration>\n<system.webServer>\n<authorization>\n<deny users=\"*\" />\n</authorization>\n</system.webServer>\n</configuration>\n";
1331 -
1332 - 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
1333 - }
1334 -
1335 - // Create empty index.php file for all servers.
1336 - 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
1337 -}
1338 1982 endif;
1339 1983
1340 1984 /**
1341 1985 * Add the headers indicating why the page is not cached or served from cache
@@ -1345,16 +1989,26 @@
1345 1989 * @return void
1346 1990 */
1347 1991 if (!function_exists('wpo_cache_add_nocache_http_header')) :
1348 1992 function wpo_cache_add_nocache_http_header($message = '') {
1349 - 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;
1350 1999
1351 - if (function_exists('current_filter') && 'send_headers' === current_filter() && $buffered_message && !headers_sent()) {
1352 - header('WPO-Cache-Status: not cached');
1353 - header('WPO-Cache-Message: '. trim(str_replace(array("\r", "\n", ':'), ' ', strip_tags($buffered_message))));
1354 - } else {
1355 - if (!$buffered_message && function_exists('add_action')) add_action('send_headers', 'wpo_cache_add_nocache_http_header', 11);
1356 - $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);
1357 2011 }
1358 2012 }
1359 2013 endif;
1360 2014
@@ -1368,17 +2022,28 @@
1368 2022 return apply_filters('wpo_feeds_caching_enabled', true);
1369 2023 }
1370 2024 endif;
1371 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 +
1372 2037 if (!function_exists('wpo_debug_backtrace_summary')) {
1373 2038 function wpo_debug_backtrace_summary($ignore_class = null, $skip_frames = 0, $pretty = true) {
1374 2039 static $truncate_paths;
1375 -
1376 - $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
1377 2042 $caller = array();
1378 2043 $check_class = !is_null($ignore_class);
1379 2044 $skip_frames++; // Skip this function.
1380 -
2045 +
1381 2046 if (!isset($truncate_paths)) {
1382 2047 $truncate_paths = array(
1383 2048 wpo_normalize_path(WP_CONTENT_DIR),
1384 2049 wpo_normalize_path(ABSPATH),
@@ -1383,17 +2048,17 @@
1383 2048 wpo_normalize_path(WP_CONTENT_DIR),
1384 2049 wpo_normalize_path(ABSPATH),
1385 2050 );
1386 2051 }
1387 -
2052 +
1388 2053 foreach ($trace as $call) {
1389 2054 if ($skip_frames > 0) {
1390 2055 $skip_frames--;
1391 2056 } elseif (isset($call['class'])) {
1392 - if ($check_class && $ignore_class == $call['class']) {
2057 + if ($check_class && $ignore_class === $call['class']) {
1393 2058 continue; // Filter out calls.
1394 2059 }
1395 -
2060 +
1396 2061 $caller[] = "{$call['class']}{$call['type']}{$call['function']}";
1397 2062 } else {
1398 2063 if (in_array($call['function'], array('do_action', 'apply_filters', 'do_action_ref_array', 'apply_filters_ref_array'), true)) {
1399 2064 $caller[] = "{$call['function']}('{$call['args'][0]}')";
@@ -1416,16 +2081,93 @@
1416 2081 if (!function_exists('wpo_normalize_path')) {
1417 2082 function wpo_normalize_path($path) {
1418 2083 // Standardise all paths to use '/'.
1419 2084 $path = str_replace('\\', '/', $path);
1420 -
2085 +
1421 2086 // Replace multiple slashes down to a singular, allowing for network shares having two slashes.
1422 2087 $path = preg_replace('|(?<=.)/+|', '/', $path);
1423 -
2088 +
1424 2089 // Windows paths should uppercase the drive letter.
1425 2090 if (':' === substr($path, 1, 1)) {
1426 2091 $path = ucfirst($path);
1427 2092 }
2093 +
2094 + return $path;
2095 + }
2096 +}
1428 2097
1429 - 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;
1430 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');
1431 2172 }
2173 +endif;