array(), 'size' => 0, ); if (! is_string($url) || empty($url)) { return $cache; } $url = esc_url_raw($url, array('http', 'https')); $cache_dir = self::get_cache_dir($url); if (! is_dir($cache_dir)) { return $cache; } $switched = false; if (is_multisite() && ! ms_is_switched()) { $blog_domain = (string) wp_parse_url($url, PHP_URL_HOST); $blog_path = is_subdomain_install() ? '/' : Fastless_Cache::get_blog_path_from_url($url); $blog_id = get_blog_id_from_url($blog_domain, $blog_path); if ($blog_id !== 0) { $switched = Fastless_Cache::switch_to_blog($blog_id, true); } } $args = self::get_cache_iterator_args($url, $args); $recursive = ($args['subpages'] === 1 || ! empty($args['subpages']['include']) || isset($args['subpages']['exclude'])); $filter = ($recursive && $args['subpages'] !== 1) ? $args['subpages'] : null; $cache_objects = self::get_dir_objects($cache_dir, $recursive, $filter); $cache_keys_regex = self::get_cache_keys_regex($args['keys']); foreach ($cache_objects as $cache_object) { if (is_file($cache_object)) { if ($args['root'] && strpos($cache_object, $args['root']) !== 0) { // Skip to the next object because the file does not start with the provided root path. continue; } $cache_object_name = basename($cache_object); if ($cache_keys_regex && ! preg_match($cache_keys_regex, $cache_object_name)) { // Skip to the next object because the file name does not match the provided cache keys. continue; } if ($args['expired'] && ! self::cache_expired($cache_object)) { // Skip to the next object because the file is not expired. continue; } $cache_object_dir = dirname($cache_object); $cache_object_size = (int) @filesize($cache_object); if ($args['clear']) { if (! @unlink($cache_object)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- native unlink inside purge loops; wp_delete_file() adds a filter call per file and is not loaded this early. // Skip to the next object because the file deletion failed. continue; } // The cache size is negative when cleared. $cache_object_size = -$cache_object_size; // Remove the containing directory if empty along with any of its empty parents. self::rmdir($cache_object_dir, true); } if (strpos($cache_object_name, 'index') === false) { // Skip to the next object because the file is not a cache version and no longer // needs to be handled, such as a hidden file. continue; } if (! isset($cache['index'][$cache_object_dir]['url'])) { $cache['index'][$cache_object_dir]['url'] = self::get_cache_url($cache_object_dir); // Resolving the post ID costs a database query per cached // URL. Only the clear path (the page_cache_cleared hook // payload) needs it; size and count runs just sum files. $cache['index'][$cache_object_dir]['id'] = $args['clear'] ? url_to_postid($cache['index'][$cache_object_dir]['url']) : 0; } $cache['index'][$cache_object_dir]['versions'][$cache_object_name] = $cache_object_size; $cache['size'] += $cache_object_size; } } // Sort the cache index by forward slashes from the lowest to highest. uksort($cache['index'], self::class . '::sort_dir_objects'); if ($args['clear']) { self::fire_cache_cleared_hooks($cache['index'], $args['hooks']); } if ($switched) { Fastless_Cache::restore_current_blog(true); } return $cache; } /** * Get the cache size (deprecated). */ public static function cache_size($dir = null) { return self::get_cache_size($dir); } /** * Clear the cache (deprecated). * */ public static function clear_cache($clear_url = null, $clear_type = 'page') { Fastless_Cache::clear_page_cache_by_url($clear_url, $clear_type); } /** * Create the advanced-cache.php drop-in file. * * @return string|bool Path to the created file, false on failure. */ public static function create_advanced_cache_file() { if (! wp_is_writable(WP_CONTENT_DIR)) { return false; } $advanced_cache_sample_file = FASTLESS_CACHE_DIR . '/advanced-cache.php'; if (! is_readable($advanced_cache_sample_file)) { return false; } $advanced_cache_file = WP_CONTENT_DIR . '/advanced-cache.php'; // If advanced-cache.php exists and does not carry our signature, do not overwrite. if (file_exists($advanced_cache_file)) { $existing_contents = file_get_contents($advanced_cache_file); if (is_string($existing_contents) && strpos($existing_contents, 'FASTLESS_CACHE_DROPIN') === false) { // Foreign drop-in detected. Do not overwrite. // Set a flag so admin can show a notice. update_option('fastless_cache_dropin_conflict', true); return false; } } $advanced_cache_file_contents = file_get_contents($advanced_cache_sample_file); $search = "realpath(__DIR__) . '/constants.php'"; $replace = "'" . FASTLESS_CACHE_CONSTANTS_FILE . "'"; // Add our signature to the drop-in so we can identify it later. $advanced_cache_file_contents = str_replace($search, $replace, $advanced_cache_file_contents); // Insert signature comment after the opening \n\tRequire all denied\n\n" . "\n\tOrder deny,allow\n\tDeny from all\n\n" ); } } /** * Fire the cache cleared hooks. * * @param array[] $cache_cleared_index Index of the cache cleared. * @param array[] $hooks Cache cleared hooks to 'include' and/or 'exclude' from being fired. */ private static function fire_cache_cleared_hooks($cache_cleared_index, $hooks) { if (empty($cache_cleared_index) || empty($hooks)) { return; } if (isset($hooks['include'])) { $hooks_to_fire = $hooks['include']; } else { $hooks_to_fire = array('fastless_cache_complete_cache_cleared', 'fastless_cache_site_cache_cleared', 'fastless_cache_page_cache_cleared'); } if (! empty($hooks['exclude'])) { $hooks_to_fire = array_diff($hooks_to_fire, $hooks['exclude']); } if (empty($hooks_to_fire)) { return; } if (in_array('fastless_cache_page_cache_cleared', $hooks_to_fire, true)) { foreach ($cache_cleared_index as $cache_cleared_dir => $cache_cleared_data) { $page_cleared_url = $cache_cleared_data['url']; $page_cleared_id = $cache_cleared_data['id']; /** * Fires after the page cache has been cleared. * * @param string $page_cleared_url Full URL of the page cleared. * @param int $page_cleared_id Post ID of the page cleared. * @param array[] $cache_cleared_index Index of the cache cleared. */ do_action('fastless_cache_page_cache_cleared', $page_cleared_url, $page_cleared_id, $cache_cleared_index); do_action('ce_action_cache_by_url_cleared', $page_cleared_url); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- deprecated 1.6.0 back-compat hook kept intentionally; renaming would break existing integrations. } } if (in_array('fastless_cache_site_cache_cleared', $hooks_to_fire, true) && empty(Fastless_Cache::get_cache_index())) { $site_cleared_url = user_trailingslashit(home_url()); $site_cleared_id = get_current_blog_id(); /** * Fires after the site cache has been cleared. * * @param string $site_cleared_url Full URL of the site cleared. * @param int $site_cleared_id Post ID of the site cleared. * @param array[] $cache_cleared_index Index of the cache cleared. */ do_action('fastless_cache_site_cache_cleared', $site_cleared_url, $site_cleared_id, $cache_cleared_index); } if (in_array('fastless_cache_complete_cache_cleared', $hooks_to_fire, true) && ! is_dir(FASTLESS_CACHE_CACHE_DIR)) { /** * Fires after the complete cache has been cleared. */ do_action('fastless_cache_complete_cache_cleared'); do_action('ce_action_cache_cleared'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- deprecated 1.6.0 back-compat hook kept intentionally; renaming would break existing integrations. } } /** * Filters whether a file or directory should be included or excluded. * * @param string $dir_object File or directory path to filter (without trailing slash). * @param array[] $filter File or directory path(s) to 'include' and/or 'exclude' (without trailing slash). * @return bool True if directory object should be included, false if excluded. */ private static function filter_dir_object($dir_object, $filter) { if (isset($filter['exclude'])) { $match = in_array($dir_object, $filter['exclude'], true); if ($match) { return false; } } if (isset($filter['include'])) { $match = in_array($dir_object, $filter['include'], true); if ($match) { return true; } } if (! isset($match)) { return true; } ksort($filter); // Sort the keys in alphabetical order to check for an exclusion first. if (is_dir($dir_object)) { $dir_object = $dir_object . '/'; // Append a trailing slash to prevent a false match. } foreach ($filter as $filter_type => $filter_value) { if ($filter_type !== 'include' && $filter_type !== 'exclude') { continue; } foreach ($filter_value as $filter_object) { // If a trailing asterisk exists remove it to allow a wildcard match. if (substr($filter_object, -1, 1) === '*') { $filter_object = substr($filter_object, 0, -1); // Otherwise, maybe append a trailing slash to force a strict match. } elseif (is_dir($dir_object)) { $filter_object = $filter_object . '/'; } if (str_replace($filter_object, '', $dir_object) !== $dir_object) { switch ($filter_type) { case 'include': return true; // Past inclusion or present wildcard inclusion. case 'exclude': return false; // Present wildcard exclusion. } } if (strpos($filter_object, $dir_object) === 0 && $filter_type === 'include') { return true; // Future strict or wildcard inclusion. } } } if (isset($filter['include'])) { return false; // Match not found. } return true; } /** * Get the cache directory path for the current URL or from a given URL. * * This does not check whether the returned cache directory path exists. The * untrailingslashit() function is not being used to remove the trailing slash * because it is not available when the cache engine is started early. * * @param string $url (Optional) Full URL to a cached page (with or without wildcard path). Default * is the current URL. * @return string Cache directory path (without trailing slash), empty string if the URL is invalid. */ public static function get_cache_dir($url = null) { if (empty($url)) { $request_uri = isset($_SERVER['REQUEST_URI']) ? Fastless_Cache_Engine::validate_request_uri($_SERVER['REQUEST_URI']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- value passed to validate_request_uri() which unslashes and validates. if ('' === $request_uri) { return FASTLESS_CACHE_CACHE_DIR; } $url = 'http://' . Fastless_Cache_Engine::$request_headers['Host'] . $request_uri; } $url_parts = Fastless_Cache_Engine::wp_parse_url_safe($url); if (! is_array($url_parts) || ! isset($url_parts['host']) || ! is_string($url_parts['host'])) { return FASTLESS_CACHE_CACHE_DIR; } $url_host = $url_parts['host']; $url_path = (isset($url_parts['path']) && is_string($url_parts['path'])) ? $url_parts['path'] : ''; if ('' !== $url_path && substr($url_path, -1, 1) === '*') { $url_path = dirname($url_path); } $cache_dir = sprintf( '%s/%s%s', FASTLESS_CACHE_CACHE_DIR, strtolower($url_host), $url_path ); $cache_dir = rtrim($cache_dir, '/\\'); return $cache_dir; } /** * Get the cache iterator arguments. * * @global WP_Rewrite $wp_rewrite WordPress rewrite component. * * @param string $url (Optional) Full URL to a cached page (with or without wildcard path and query * string). Default null. * @param array|string $args (Optional) Cache iterator arguments or an arguments template. Default empty array. * @return array Cache iterator arguments. */ private static function get_cache_iterator_args($url = null, $args = array()) { $default_args = array( 'clear' => 0, 'expired' => 0, 'hooks' => 0, 'keys' => 0, 'root' => '', 'subpages' => 0, ); if (! is_array($args)) { $args_template = $args; $args = array( 'clear' => 1, 'hooks' => array('include' => 'fastless_cache_page_cache_cleared'), ); switch ($args_template) { case 'pagination': global $wp_rewrite; $included_subpages[] = isset($wp_rewrite->pagination_base) ? $wp_rewrite->pagination_base : ''; $included_subpages[] = isset($wp_rewrite->comments_pagination_base) ? $wp_rewrite->comments_pagination_base . '-*' : ''; $args['subpages']['include'] = $included_subpages; break; case 'subpages': $args['subpages'] = 1; break; default: $args = array(); } } $url_path = (string) wp_parse_url($url, PHP_URL_PATH); if (substr($url_path, -1, 1) === '*') { $args['root'] = FASTLESS_CACHE_CACHE_DIR . '/' . substr((string) wp_parse_url($url, PHP_URL_HOST) . $url_path, 0, -1); $args['subpages']['include'] = basename($url_path); } // Merge query string arguments into the parameter arguments and then the default arguments. wp_parse_str((string) wp_parse_url($url, PHP_URL_QUERY), $query_string_args); $args = wp_parse_args($query_string_args, $args); $args = wp_parse_args($args, $default_args); $args = self::validate_cache_iterator_args($args); return $args; } /** * Get the path to the cache file for the current request. * * This does not check whether the returned cache file exists. It sets the * $cache_file property to prevent different paths being returned on the same * request. This can occur because the $_SERVER['REQUEST_URI'] superglobal can be * updated, like by another plugin, between trying to deliver a cached page and * then actually creating it. * * @return string Path to the cache file. */ public static function get_cache_file() { if (! empty(self::$cache_file)) { return self::$cache_file; } self::$cache_file = sprintf( '%s/%s', self::get_cache_dir(), self::get_cache_file_name() ); return self::$cache_file; } /** * Get the name of the cache file for the current request. * * @return string Name of the cache file. */ private static function get_cache_file_name() { $cache_keys = self::get_cache_keys(); $cache_file_name = $cache_keys['scheme'] . 'index' . $cache_keys['query'] . $cache_keys['cookie'] . $cache_keys['device'] . '.html' . $cache_keys['compression']; return $cache_file_name; } /** * Get the cache keys from the request headers for the cache file name. * * This has some functionality copied from is_ssl() and wp_is_mobile(). * * @return string[] An array of cache keys with names as the keys and keys as the values. */ private static function get_cache_keys() { $cache_keys = array( 'scheme' => 'http-', 'device' => '', 'query' => '', 'cookie' => '', 'compression' => '', ); // Determine scheme using validated server variables. $https = isset($_SERVER['HTTPS']) ? strtolower((string) Fastless_Cache_Engine::wp_unslash_safe($_SERVER['HTTPS'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. $server_port = isset($_SERVER['SERVER_PORT']) ? (string) Fastless_Cache_Engine::wp_unslash_safe($_SERVER['SERVER_PORT']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. if ($https === 'on' || $https === '1' || $server_port === '443') { $cache_keys['scheme'] = 'https-'; } elseif ( Fastless_Cache_Engine::$request_headers['X-Forwarded-Proto'] === 'https' || Fastless_Cache_Engine::$request_headers['X-Forwarded-Scheme'] === 'https' ) { $cache_keys['scheme'] = 'https-'; } if (Fastless_Cache_Engine::$settings['query_cache']) { $query_cache_params = Fastless_Cache_Engine::$default_include_queries; $user_query_params = ! empty(Fastless_Cache_Engine::$settings['included_query_strings_list']) ? (array) Fastless_Cache_Engine::$settings['included_query_strings_list'] : Fastless_Cache_Engine::parse_exclusion_list(Fastless_Cache_Engine::$settings['included_query_strings']); if (!empty($user_query_params)) { $query_cache_params = array_unique(array_merge($query_cache_params, $user_query_params)); } if (!empty($_GET)) { // phpcs:ignore WordPress.Security.NonceVerification -- read-only inspection of request data for cache key/bypass decisions; no state change occurs here, so no nonce applies. $query_cache_values = array(); foreach ($_GET as $key => $value) { // phpcs:ignore WordPress.Security.NonceVerification -- read-only inspection of request data for cache key/bypass decisions; no state change occurs here, so no nonce applies. if (!in_array($key, Fastless_Cache_Engine::$default_ignore_queries) && in_array($key, $query_cache_params)) { $query_cache_values[$key] = $value; } } if (!empty($query_cache_values)) { $cache_keys['query'] = '-' . md5(serialize($query_cache_values)); } } } if (Fastless_Cache_Engine::$settings['cookie_cache']) { $cookie_strings = ! empty(Fastless_Cache_Engine::$settings['included_cookie_strings_list']) ? (array) Fastless_Cache_Engine::$settings['included_cookie_strings_list'] : Fastless_Cache_Engine::parse_exclusion_list(Fastless_Cache_Engine::$settings['included_cookie_strings']); if (! empty($_COOKIE)) { $cookie_cached_values = array(); foreach ($_COOKIE as $key => $value) { if (in_array($key, $cookie_strings)) { $cookie_cached_values[$key] = $value; } } if (!empty($cookie_cached_values)) { $cache_keys['cookie'] = '-' . md5(serialize($cookie_cached_values)); } } } if (Fastless_Cache_Engine::$settings['mobile_cache']) { if ( strpos(Fastless_Cache_Engine::$request_headers['User-Agent'], 'Mobile') !== false || strpos(Fastless_Cache_Engine::$request_headers['User-Agent'], 'Android') !== false || strpos(Fastless_Cache_Engine::$request_headers['User-Agent'], 'Silk/') !== false || strpos(Fastless_Cache_Engine::$request_headers['User-Agent'], 'Kindle') !== false || strpos(Fastless_Cache_Engine::$request_headers['User-Agent'], 'BlackBerry') !== false || strpos(Fastless_Cache_Engine::$request_headers['User-Agent'], 'Opera Mini') !== false || strpos(Fastless_Cache_Engine::$request_headers['User-Agent'], 'Opera Mobi') !== false ) { $cache_keys['device'] = '-mobile'; } } if (Fastless_Cache_Engine::$settings['compress_cache']) { $cache_keys['compression'] = self::preferred_compression( Fastless_Cache_Engine::$request_headers['Accept-Encoding'], function_exists('brotli_compress'), self::supports_gzip() ); } return $cache_keys; } /** * Get the cache keys regex for the cache iterator. * * This uses positive and negative lookaheads to create a regex that will be used * to check the name of the cache file in the cache iterator, for example: * * #^(?=.*https)(?=.*mobile).+$# * * #^(?=.*https)(?!.*mobile).+$# * * #^.+$# * * @param array[] $cache_keys Cache keys to 'include' and/or 'exclude'. * @return string Cache keys regex, false on failure. */ private static function get_cache_keys_regex($cache_keys) { if (! is_array($cache_keys)) { return false; } $cache_keys_regex = '#^'; foreach ($cache_keys as $filter_type => $filter_value) { switch ($filter_type) { case 'include': $lookahead = '?='; break; case 'exclude': $lookahead = '?!'; break; default: continue 2; // Skip to the next filter value. } foreach ($filter_value as $cache_key) { $cache_keys_regex .= '(' . $lookahead . '.*' . preg_quote($cache_key) . ')'; } } $cache_keys_regex .= '.+$#'; return $cache_keys_regex; } /** * Get the cache signature. * * This gets the HTML comment that is inserted at the bottom of a new cache file. * * @param string $cache_file_name Name of the new cache file. * @return string HTML comment with the current time in HTTP-date format and the new cache file name. */ private static function get_cache_signature($cache_file_name) { $cache_signature = sprintf( '', 'Fastless Cache by AppsCreo', self::get_current_time(), 'https://wordpress.org/plugins/fastless-cache/' ); if (! empty(Fastless_Cache_Engine::$settings['disable_footprint'])) { $cache_signature = ''; } return $cache_signature; } /** * Get the cache size from the disk (deprecated). * */ public static function get_cache_size($dir = null) { if (empty($dir)) { $cache_size = Fastless_Cache::get_cache_size(); } else { $url = self::get_cache_url($dir); $cache = self::cache_iterator($url, array('subpages' => 1)); $cache_size = $cache['size']; } return $cache_size; } /** * Get the cache URL for a given directory path. * * This only checks if the given directory path is in the plugin cache directory. It * does not check whether the URL returned is from a cache directory that exists. * * @param string $dir Directory path to a cached page. * @return string Full cache URL (with trailing slash if set), empty string if the directory path * is invalid. */ private static function get_cache_url($dir) { if (strpos($dir, FASTLESS_CACHE_CACHE_DIR) !== 0) { return ''; } $cache_url = wp_parse_url(home_url(), PHP_URL_SCHEME) . '://' . str_replace(FASTLESS_CACHE_CACHE_DIR . '/', '', $dir); $cache_url = user_trailingslashit($cache_url); return $cache_url; } /** * Get the path to the settings file for the current site. * * @param bool $fallback (Optional) Whether the fallback settings file should be returned. Default false. * @return string Path to the settings file. */ private static function get_settings_file($fallback = false) { $settings_file = sprintf( '%s/%s', FASTLESS_CACHE_SETTINGS_DIR, self::get_settings_file_name($fallback) ); return $settings_file; } /** * Get the name of the settings file for the current site. * * This uses home_url() in the late cache engine start to get the settings file * name when creating and deleting the settings file or when getting the plugin * settings from the settings file. Otherwise, it finds the name of the settings * file in the settings directory when the cache engine is started early. * * @param bool $fallback (Optional) Whether the fallback settings file name should be returned. Default false. * @param bool $skip_blog_path (Optional) Whether the blog path should be included in the settings file name. * Default false. * @return string Name of the settings file. */ private static function get_settings_file_name($fallback = false, $skip_blog_path = false) { $settings_file_name = ''; if (function_exists('home_url')) { $settings_file_name = wp_parse_url(home_url(), PHP_URL_HOST); if (is_multisite() && defined('SUBDOMAIN_INSTALL') && ! SUBDOMAIN_INSTALL) { $blog_path = Fastless_Cache::get_blog_path(); $settings_file_name .= (! empty($blog_path)) ? '.' . trim($blog_path, '/') : ''; } $settings_file_name .= '.json'; } elseif (is_dir(FASTLESS_CACHE_SETTINGS_DIR)) { if ($fallback) { $settings_files = array_map('basename', self::get_dir_objects(FASTLESS_CACHE_SETTINGS_DIR)); $settings_file_regex = '/\.json$/'; if (is_multisite()) { $settings_file_regex = '/^' . strtolower(Fastless_Cache_Engine::$request_headers['Host']); $settings_file_regex = str_replace('.', '\.', $settings_file_regex); if (defined('SUBDOMAIN_INSTALL') && ! SUBDOMAIN_INSTALL && ! $skip_blog_path) { $request_uri = isset($_SERVER['REQUEST_URI']) ? Fastless_Cache_Engine::validate_request_uri($_SERVER['REQUEST_URI']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- value passed to validate_request_uri() which unslashes and validates. $url_path = trim(Fastless_Cache_Engine::wp_parse_url_safe($request_uri, PHP_URL_PATH), '/'); if (! empty($url_path)) { $url_path_regex = str_replace('/', '|', $url_path); $url_path_regex = '\.(' . $url_path_regex . ')'; $settings_file_regex .= $url_path_regex; } } $settings_file_regex .= '\.json$/'; } $filtered_settings_files = preg_grep($settings_file_regex, $settings_files); if (! empty($filtered_settings_files)) { $settings_file_name = current($filtered_settings_files); } elseif (is_multisite() && defined('SUBDOMAIN_INSTALL') && ! SUBDOMAIN_INSTALL && ! $skip_blog_path) { $fallback = true; $skip_blog_path = true; $settings_file_name = self::get_settings_file_name($fallback, $skip_blog_path); } } else { $settings_file_name = strtolower(Fastless_Cache_Engine::$request_headers['Host']); if (is_multisite() && defined('SUBDOMAIN_INSTALL') && ! SUBDOMAIN_INSTALL && ! $skip_blog_path) { $request_uri = isset($_SERVER['REQUEST_URI']) ? Fastless_Cache_Engine::validate_request_uri($_SERVER['REQUEST_URI']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- value passed to validate_request_uri() which unslashes and validates. $url_path_pieces = explode('/', $request_uri, 3); $blog_path = $url_path_pieces[1] ?? ''; if (! empty($blog_path)) { $settings_file_name .= '.' . $blog_path; } $settings_file_name .= '.json'; // Check if the main site in a subdirectory network. if (! is_file(FASTLESS_CACHE_SETTINGS_DIR . '/' . $settings_file_name)) { $fallback = false; $skip_blog_path = true; $settings_file_name = self::get_settings_file_name($fallback, $skip_blog_path); } } $settings_file_name .= (strpos($settings_file_name, '.json') === false) ? '.json' : ''; } } return $settings_file_name; } /** * Get the plugin settings from the settings file for the current site. * * Settings are stored as a JSON file rather than an executable PHP file, so * they are read back with json_decode() instead of being included. * * This will create the settings file if it does not exist and the cache engine * was started late. If that occurs, the settings from the new settings file will * be returned. Before it is created, checking if the settings file exists after * retrieving the database settings is done in case an update occurred, which * would have resulted in a new settings file being created. * * This can update the disk and backend requirements and then clear the site cache * if the settings are outdated. If that occurs, a new settings file will be * created and an empty array returned. * * @param bool $update Whether to update the disk and backend requirements if the settings are * outdated. Default true. * @return array Plugin settings from the settings file, empty array when outdated or on failure. */ public static function get_settings($update = true) { $settings = array(); $settings_file = self::get_settings_file(); if (is_file($settings_file)) { $settings = self::read_settings_file($settings_file); } else { $fallback = true; $settings_file = self::get_settings_file($fallback); if (is_file($settings_file)) { $settings = self::read_settings_file($settings_file); } } $outdated_settings = (! empty($settings) && (! defined('FASTLESS_CACHE_VERSION') || ! isset($settings['version']) || $settings['version'] !== FASTLESS_CACHE_VERSION)); if ($outdated_settings) { $settings = array(); } if (empty($settings) && class_exists('Fastless_Cache')) { if ($outdated_settings) { if ($update) { Fastless_Cache::update(); } } else { $_settings = Fastless_Cache::get_settings(); $settings_file = self::get_settings_file(); if (is_file($settings_file)) { $settings = self::read_settings_file($settings_file); } else { $settings_file = self::create_settings_file($_settings); if ($settings_file !== false) { $settings = self::read_settings_file($settings_file); } } } } return $settings; } /** * Read plugin settings from a JSON settings file. * * @param string $settings_file Path to the JSON settings file. * @return array Plugin settings from the file, empty array on failure. */ private static function read_settings_file($settings_file) { $contents = (string) file_get_contents($settings_file); $settings = json_decode($contents, true); return (is_array($settings)) ? $settings : array(); } /** * Get the files and directories inside of a given directory. * * @param string $dir Directory path to scan (without trailing slash). * @param bool $recursive (Optional) Whether to recursively include directory objects in nested * directories. Default false. * @param array[] $filter (Optional) Directory paths relative to $dir (without leading and/or trailing * slashes) to 'include' and/or 'exclude'. Default null. * @return string[] File and directory paths to objects found, empty array if the directory path is invalid. */ private static function get_dir_objects($dir, $recursive = false, $filter = null) { $dir_objects = array(); self::collect_dir_objects($dir, $recursive, $filter, $dir_objects); return $dir_objects; } /** * Collect the files and directories inside a directory into an accumulator. * * Appends into $dir_objects by reference so a deep or wide tree does not * copy the whole list at every level; the previous recursive array_merge * was quadratic in the number of objects. Order is unchanged: a directory's * children are appended before the directory itself. * * @param string $dir Directory path to scan (without trailing slash). * @param bool $recursive Whether to recurse into nested directories. * @param array[] $filter Directory filter, or null. * @param string[] $dir_objects Accumulator, appended to by reference. */ private static function collect_dir_objects($dir, $recursive, $filter, &$dir_objects) { if (! is_dir($dir)) { return; } $dir_object_names = scandir($dir); // The sorted order is alphabetical in ascending order. if (is_array($filter) && empty($filter['full_path'])) { $filter['full_path'] = 1; foreach ($filter as $filter_type => &$filter_value) { if ($filter_type === 'include' || $filter_type === 'exclude') { foreach ($filter_value as &$filter_object) { $filter_object = $dir . '/' . $filter_object; } } } } foreach ($dir_object_names as $dir_object_name) { if ($dir_object_name === '.' || $dir_object_name === '..') { continue; // Skip object because it is the current or parent folder link. } $dir_object = $dir . '/' . $dir_object_name; if (is_dir($dir_object)) { if (! empty($filter['full_path']) && ! self::filter_dir_object($dir_object, $filter)) { continue; // Skip object because it is excluded. } if ($recursive) { self::collect_dir_objects($dir_object, $recursive, $filter, $dir_objects); } } $dir_objects[] = $dir_object; } } /** * Get the site objects (deprecated). * */ public static function get_site_objects($site_url) { $site_objects = array(); $dir = self::get_cache_dir($site_url); if (! is_dir($dir)) { return $site_objects; } $site_objects = array_map('basename', self::get_dir_objects($dir)); // Maybe filter the site objects. if (is_multisite() && ! is_subdomain_install()) { $blog_path = Fastless_Cache::get_blog_path(); $blog_paths = Fastless_Cache::get_blog_paths(); // Check if the main site in a subdirectory network. if (! in_array($blog_path, $blog_paths, true)) { foreach ($site_objects as $key => $site_object) { // Delete the site object if it does not belong to the main site. if (in_array('/' . $site_object . '/', $blog_paths, true)) { unset($site_objects[$key]); } } } } return $site_objects; } /** * Get the current time. * * @return string Current time in HTTP-date format. */ private static function get_current_time() { $current_time = current_time('D, d M Y H:i:s', true) . ' GMT'; return $current_time; } /** * Get the current WordPress filesystem instance. * * This will initialize the WordPress filesystem if it has not yet been and will * cache the result afterward. * * @throws \RuntimeException If the WordPress filesystem could not be initialized. * * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. * * @return WP_Filesystem_Base WordPress filesystem. */ public static function get_filesystem() { global $wp_filesystem; if ($wp_filesystem instanceof WP_Filesystem_Base) { return $wp_filesystem; } try { require_once ABSPATH . 'wp-admin/includes/file.php'; $filesystem = WP_Filesystem(); if ($filesystem === null) { throw new \RuntimeException('The provided filesystem method is unavailable.'); } if ($filesystem === false) { if (is_wp_error($wp_filesystem->errors) && $wp_filesystem->errors->has_errors()) { throw new \RuntimeException( $wp_filesystem->errors->get_error_message(), is_numeric($wp_filesystem->errors->get_error_code()) ? (int) $wp_filesystem->errors->get_error_code() : 0 ); } throw new \RuntimeException('Unspecified failure.'); } if (! is_object($wp_filesystem) || ! $wp_filesystem instanceof WP_Filesystem_Base) { throw new \RuntimeException('$wp_filesystem is not an instance of WP_Filesystem_Base.'); } } catch (\Exception $e) { throw new \RuntimeException( sprintf('There was an error initializing the WP_Filesystem class: %1$s', $e->getMessage()), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message is re-thrown for programmatic handling, not printed as page output. $e->getCode(), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message is re-thrown for programmatic handling, not printed as page output. $e // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message is re-thrown for programmatic handling, not printed as page output. ); } return $wp_filesystem; } /** * Make a directory recursively based on the directory path. * * This assumes that the directory (and its parent) should have 755 permissions, * and will attempt to update any existing directories accordingly. * * @param string $dir Directory path to create. * @return bool True if the directory either already exists or was created *and* has the * correct permissions, false otherwise. */ private static function mkdir_p($dir) { /** * Filters the mode assigned to directories on creation. * * @param int $mode Mode that defines the access permissions for the created directory. The mode * must be an octal number, which means it should have a leading zero. Default is 0755. */ $mode_octal = (int) apply_filters('fastless_cache_mkdir_mode', 0755); $parent_dir = dirname($dir); // Native stat instead of WP_Filesystem: this runs on the request // shutdown path, and booting the filesystem API would pull // wp-admin/includes/file.php into a frontend request for a chmod check. $has_expected_mode = function_exists('fileperms') && ((int) @fileperms($dir) & 0777) === $mode_octal && ((int) @fileperms($parent_dir) & 0777) === $mode_octal; if (is_dir($dir) && $has_expected_mode) { return true; } // Directory validation $valid = false; if (! empty(FASTLESS_CACHE_CACHE_DIR) && strpos($dir, FASTLESS_CACHE_CACHE_DIR) === 0) { $valid = true; } if (! empty(FASTLESS_CACHE_SETTINGS_DIR) && strpos($dir, FASTLESS_CACHE_SETTINGS_DIR) === 0) { $valid = true; } if (! $valid || strpos($dir, '../') !== false) { return false; } if (! wp_mkdir_p($dir)) { return false; } return true; } /** * Remove an empty directory based on the directory path. * * This is a wrapper for rmdir() that can delete empty parent directories and will * call clearstatcache() when necessary. It suppresses errors on failure. * * @param string $dir Directory path to remove. * @param bool $parents (Optional) Whether empty parent directories should also be removed. Default false. * @return array[]|bool An array of removed directories with paths as the keys and objects as the * values. There are no directory objects because a directory has to be empty to * be removed, which is why it will always be an empty array. False if no * directories were removed. */ private static function rmdir($dir, $parents = false) { $removed_dir = @rmdir($dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- native FS required: purge iterates thousands of entries and WP_Filesystem is not loaded in every context where this runs. clearstatcache(); if ($removed_dir) { $removed_dir = array($dir => array()); if ($parents) { $parent_dir = dirname($dir); while (@rmdir($parent_dir)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- native FS required: purge iterates thousands of entries and WP_Filesystem is not loaded in every context where this runs. clearstatcache(); $removed_dir[$parent_dir] = array(); $parent_dir = dirname($parent_dir); } } } return $removed_dir; } private static function set_htaccess_rules() { $htaccess_file = ABSPATH . '.htaccess'; if (!file_exists($htaccess_file) || !is_writeable($htaccess_file)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writeable -- writability probe only; WP_Filesystem is not loaded at this point. return; } // Get the contents of the .htaccess file $htaccess_contents = file_get_contents($htaccess_file); // Get the rules we want to add $htaccess_rules = file_get_contents(FASTLESS_CACHE_DIR . '/htaccess.txt'); // Get the site's hostname $hostname = wp_parse_url(site_url(), PHP_URL_HOST); // Replace HOSTNAME with the current site's hostname $htaccess_rules = str_replace('HOSTNAME', $hostname, $htaccess_rules); // Allow others to modify the rules $htaccess_rules = apply_filters('fastless_cache_htaccess_rules', $htaccess_rules); $marker_regex = '/# BEGIN FastlessCache.*?# END FastlessCache/s'; // If the rules is already in the file, replace it if (preg_match($marker_regex, $htaccess_contents)) { $htaccess_contents = preg_replace($marker_regex, $htaccess_rules, $htaccess_contents); } // If WordPress rules are present, add it before that elseif (strpos($htaccess_contents, '# BEGIN WordPress') !== false) { $htaccess_contents = str_replace( '# BEGIN WordPress', "$htaccess_rules\n\n# BEGIN WordPress", $htaccess_contents ); } // Otherwise, add it to the top of the file else { $htaccess_contents = "$htaccess_rules\n$htaccess_contents"; } file_put_contents($htaccess_file, $htaccess_contents); // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- managed # BEGIN/END FastlessCache block in .htaccess protects the cache directory; standard cache plugin behavior. } private static function remove_htaccess_rules() { $htaccess_file = ABSPATH . '.htaccess'; if (!file_exists($htaccess_file) || !is_writeable($htaccess_file)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writeable -- writability probe only; WP_Filesystem is not loaded at this point. return; } $htaccess = file_get_contents($htaccess_file); if (! is_string($htaccess)) { return; } // Remove our rules. The pattern is line-ending agnostic so LF and // CRLF files are both handled without leaving stray \r characters. // Non-greedy so duplicate blocks never swallow user lines between them. $new_htaccess = preg_replace('/# BEGIN FastlessCache.*?# END FastlessCache(?:\r?\n)*/s', '', $htaccess); if (! is_string($new_htaccess) || $new_htaccess === $htaccess) { return; // Nothing owned found: leave the user's file untouched. } // Write back to htaccess file_put_contents($htaccess_file, $new_htaccess); // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- managed # BEGIN/END FastlessCache block in .htaccess protects the cache directory; standard cache plugin behavior. } /** * Remove the global files exactly once per request. * * The drop-in, the wp-config.php constant and the .htaccess block are * shared by every site, so on multisite the per-blog deactivation loop * must not process them more than once. */ public static function clean_global_files() { static $done = false; if ($done) { return; } $done = true; // Only delete our own drop-in. self::delete_advanced_cache_file(); // Ownership is detected by marker inside the callee. self::set_wp_cache_constant(false); self::remove_htaccess_rules(); } /** * Set or unset the WP_CACHE constant in the wp-config.php file. * * This has some functionality copied from wp-load.php when trying to find the * wp-config.php file. It will only set the WP_CACHE constant if the wp-config.php * file is considered to be default and it is not already set. It will only unset * the WP_CACHE constant if previously set by the plugin. * * @param bool $set (Optional) True to set the WP_CACHE constant, false to unset. Default true. * @return string|bool Path to the updated wp-config.php file, false otherwise. */ private static function set_wp_cache_constant($set = true) { if (file_exists(ABSPATH . 'wp-config.php')) { // The config file resides in ABSPATH. $wp_config_file = ABSPATH . 'wp-config.php'; } elseif (@file_exists(dirname(ABSPATH) . '/wp-config.php') && ! @file_exists(dirname(ABSPATH) . '/wp-settings.php')) { // The config file resides one level above ABSPATH but is not part of another installation. $wp_config_file = dirname(ABSPATH) . '/wp-config.php'; } else { // The config file could not be found. return false; } if (! wp_is_writable($wp_config_file)) { return false; } $wp_config_file_contents = file_get_contents($wp_config_file); if (! is_string($wp_config_file_contents)) { return false; } if ($set) { $default_wp_config_file = (strpos($wp_config_file_contents, '/** Sets up WordPress vars and included files. */') !== false); if (! $default_wp_config_file) { return false; } $found_wp_cache_constant = preg_match('#define\s*\(\s*[\'\"]WP_CACHE[\'\"]\s*,.+\);#', $wp_config_file_contents); if ($found_wp_cache_constant) { return false; } // Match the file's own line-ending style so the inserted block can // always be found again by the marker-based removal below. $eol = (strpos($wp_config_file_contents, "\r\n") !== false) ? "\r\n" : "\n"; $new_wp_config_lines = '/** Enables page caching for Fastless Cache. */' . $eol; $new_wp_config_lines .= "if ( ! defined( 'WP_CACHE' ) ) {" . $eol; $new_wp_config_lines .= "\tdefine( 'WP_CACHE', true );" . $eol; $new_wp_config_lines .= '}' . $eol; $new_wp_config_lines .= $eol; $new_wp_config_file_contents = preg_replace('#(/\*\* Sets up WordPress vars and included files\. \*/)#', $new_wp_config_lines . '$1', $wp_config_file_contents); // Track that we set it. update_option('fastless_cache_wp_cache_set_by_us', true); } else { // Unset. // Ownership is detected by marker, never by the flag option: the // flag may be missing while the block is present. Patterns are // line-ending agnostic so LF and CRLF files are both handled. $eol = '\r?\n'; if (strpos($wp_config_file_contents, '/** Enables page caching for Fastless Cache. */') !== false) { $new_wp_config_file_contents = preg_replace('#/\*\* Enables page caching for Fastless Cache\. \*/' . $eol . '.+' . $eol . '.+' . $eol . '\}' . $eol . '(' . $eol . ')?#', '', $wp_config_file_contents); } elseif (strpos($wp_config_file_contents, '// Added by Fastless Cache') !== false) { // < 1.5.0 $new_wp_config_file_contents = preg_replace('#.+Added by Fastless Cache\r?\n#', '', $wp_config_file_contents); } else { return false; // Not previously set by the plugin. } } if (! is_string($new_wp_config_file_contents) || empty($new_wp_config_file_contents)) { return false; } $wp_config_file_updated = file_put_contents($wp_config_file, $new_wp_config_file_contents, LOCK_EX); // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- inserting the WP_CACHE define into wp-config.php is required to enable the advanced-cache.php drop-in; standard cache plugin behavior. return ($wp_config_file_updated === false) ? false : $wp_config_file; } /** * Sort file and directory paths by the number of forward slashes. * * This sorts paths by the lowest amount of forward slashes to the highest. * * @param string $a File or directory path to compare in sort. * @param string $b File or directory path to compare in sort. * @return int 1 if $a has more slashes than $b, 0 if equal, and -1 if less. */ private static function sort_dir_objects($a, $b) { $a = substr_count($a, '/'); $b = substr_count($b, '/'); if ($a === $b) { return 0; } return ($a > $b) ? 1 : -1; } /** * Minify HTML. * * This removes HTML comments and collapses whitespace. Inline