init(); } /** * When the plugin is activated. * * This runs on the 'activate_fastless-cache/fastless-cache.php' action. It adds or * updates the 'fastless_cache' option in the database for each site Fastless Cache * is activated on, creates the advanced-cache.php file in the wp-content * directory, and then maybe sets the WP_CACHE constant in the wp-config.php file. * * @param bool $network_wide True if the plugin was network activated, false otherwise. */ public static function on_activation($network_wide) { self::each_site($network_wide, self::class . '::update_backend'); Fastless_Cache_Disk::setup(); } /** * When the upgrader process is complete. * * This runs on the 'upgrader_process_complete' action. It clears the cache when * the core, themes, or plugins are updated. * * @param WP_Upgrader $upgrader Upgrader instance. * @param array $data Array of bulk item update data. */ public static function on_upgrade($upgrader, $data) { if ($data['action'] !== 'update') { return; } if ($data['type'] === 'core') { self::clear_complete_cache(); } if ($data['type'] === 'theme' && isset($data['themes'])) { $updated_themes = (array) $data['themes']; $sites_themes = self::each_site(is_multisite(), 'wp_get_theme'); foreach ($sites_themes as $blog_id => $site_theme) { // Clear the site cache if the active or parent theme has been updated. if (in_array($site_theme->stylesheet, $updated_themes, true) || in_array($site_theme->template, $updated_themes, true)) { self::clear_page_cache_by_site($blog_id); } } } if ($data['type'] === 'plugin' && isset($data['plugins'])) { $updated_plugins = (array) $data['plugins']; $network_plugins = is_multisite() ? array_flip((array) get_site_option('active_sitewide_plugins', array())) : array(); // Clear the complete cache if a network activated plugin has been updated. if (! empty(array_intersect($updated_plugins, $network_plugins))) { self::clear_complete_cache(); } else { $sites_plugins = self::each_site(is_multisite(), 'get_option', array('active_plugins', array())); foreach ($sites_plugins as $blog_id => $site_plugins) { // Clear the site cache if an activated plugin has been updated. if (! empty(array_intersect($updated_plugins, (array) $site_plugins))) { self::clear_page_cache_by_site($blog_id); } } } } } /** * When Fastless Cache is updated (deprecated). */ public static function on_fastless_cache_update() { self::each_site(is_multisite(), 'Fastless_Cache_Disk::clean'); Fastless_Cache_Disk::setup(); self::clear_complete_cache(); } /** * When the plugin is deactivated. * * This runs on the 'deactivate_fastless-cache/fastless-cache.php' action. It * deletes the settings and advanced-cache.php files and then maybe unsets the * WP_CACHE constant in the wp-config.php file. The site cache is then cleared and * the WP-Cron events unscheduled. * * @param bool $network_wide True if the plugin was network deactivated, false otherwise. */ public static function on_deactivation($network_wide) { self::each_site($network_wide, 'Fastless_Cache_Disk::clean'); self::each_site($network_wide, self::class . '::clear_site_cache', array(), true); self::each_site($network_wide, self::class . '::unschedule_events'); } /** * When the plugin is uninstalled. * * This runs on the 'uninstall_fastless-cache/fastless-cache.php' action. It deletes * the 'fastless_cache' option and plugin transients from the database for each * site in the installation and then removes everything the plugin created on * disk: the cache and settings directories, the advanced-cache.php drop-in, the * WP_CACHE constant and the plugin's .htaccess rules. */ public static function on_uninstall() { self::each_site(is_multisite(), self::class . '::uninstall_backend'); Fastless_Cache_Disk::uninstall(); } /** * When the cache is created or cleared. * * This runs on the 'fastless_cache_page_cache_created', * 'fastless_cache_site_cache_cleared', and 'fastless_cache_page_cache_cleared' * actions. It keeps the 'fastless_cache_cache_size' transient up to date. The * cache index count can only be greater than 1 when the cache has been cleared, * which can be the case for both cache cleared hooks. * * @param string $url Site or post URL. * @param int $id Blog or post ID * @param array $index Index of the cache created or cleared. */ public static function on_cache_created_cleared($url, $id, $index) { if (is_multisite() && ! wp_is_site_initialized(get_current_blog_id())) { return; } $current_cache_size = get_transient('fastless_cache_cache_size'); $current_cache_count = get_transient('fastless_cache_cache_count'); if (count($index) > 1) { if ($current_cache_size !== false) { // Prevent an incorrect cache size being built when the cache cleared index is not the entire site. delete_transient('fastless_cache_cache_size'); delete_transient('fastless_cache_cache_count'); } return; } // The changed cache size is negative when the cache is cleared. $changed_cache_size = array_sum(current($index)['versions']); $changed_cache_count = ($changed_cache_size > 0) ? 1 : -1; // Only keep stats that are already being tracked up to date. Recomputing // them here would walk the whole cache directory during a visitor // request; the dashboard recomputes them on demand when the transients // are missing (see self::get_cache_size() and self::get_cache_count()). if ($current_cache_size !== false) { $new_cache_size = $current_cache_size + $changed_cache_size; $new_cache_size = ($new_cache_size >= 0) ? $new_cache_size : 0; set_transient('fastless_cache_cache_size', $new_cache_size, DAY_IN_SECONDS); } if ($current_cache_count !== false) { $new_cache_count = (int) $current_cache_count + $changed_cache_count; $new_cache_count = ($new_cache_count >= 0) ? $new_cache_count : 0; set_transient('fastless_cache_cache_count', $new_cache_count, DAY_IN_SECONDS); } } /** * When a site's initialization routine should be executed. * * This runs on the 'wp_initialize_site' action. If the plugin is network * activated the 'fastless_cache' option will be added to the new site's database, * triggering the new site's settings file to be created. * * @param WP_Site $new_site New site instance. */ public static function install_later($new_site) { if (! is_plugin_active_for_network(FASTLESS_CACHE_BASE)) { return; } self::switch_to_blog((int) $new_site->blog_id); self::update_backend(); self::restore_current_blog(); } /** * Update the disk and backend requirements for the current site. * * This update process begins by first deleting the settings and * advanced-cache.php files and then maybe unsets the WP_CACHE constant in the * wp-config.php file. A new advanced-cache.php file is then created and the * WP_CACHE constant is maybe set. If a multisite network, the preceding actions * will only be done when the first site in the network is updated. Next, the * 'fastless_cache' option is updated in the database for the current site, which * triggers a new settings file to be created. Lastly, the site cache is cleared. */ public static function update() { self::update_disk(); self::update_backend(); self::clear_site_cache(); } /** * Add or update the backend requirements for the current site. * * This adds or updates the 'fastless_cache' option in the database, which triggers * the creation of the settings file. It will call self::on_update_backend() when * the plugin actions have not been registered as hooks yet, like when the plugin * is activated, but in this case even if the backend was not truly updated. * * @return array The new or current option value. */ public static function update_backend() { delete_metadata('user', 0, '_clear_post_cache_on_update', '', true); // < 1.5.0 $old_value = get_option('fastless-cache'); // < 1.5.0 if ($old_value !== false) { delete_option('fastless-cache'); add_option('fastless_cache', $old_value); } $old_value = get_option('fastless_cache', array()); $value = self::normalise_settings($old_value); // On a fresh install the `fastless_cache` option does not exist yet, so // the WP_CACHE consent check in Fastless_Cache_Disk::setup() would read // nothing and never enable the cache. Seed the option with sectioned // defaults (the shape Fastless_Cache_Options and the disk class read) // so the plugin works straight after activation. The flat engine shape // in $value is still used for the settings file. if (get_option('fastless_cache') === false && class_exists('Fastless_Cache_Settings_Schema')) { $sectioned_defaults = array(); foreach (Fastless_Cache_Settings_Schema::all() as $section => $fields) { $sectioned_defaults[$section] = Fastless_Cache_Settings_Schema::defaults($section); } add_option('fastless_cache', $sectioned_defaults); } // The settings version lives in its own option, so the sectioned // `fastless_cache` option is never rewritten in the flat engine shape. update_option('fastless_cache_settings_version', FASTLESS_CACHE_VERSION); if (has_action('update_option', array(__CLASS__, 'on_update_option')) === false) { self::on_update_backend('fastless_cache', $value); } return $value; } /** * Update the disk requirements for the current site. * * This deletes the settings and advanced-cache.php files and then maybe unsets * the WP_CACHE constant in the wp-config.php file. A new advanced-cache.php file * is then created and the WP_CACHE constant is maybe set. If a multisite network, * the 'fastless_cache_disk_updated' site transient is set afterward to only allow * this to be ran once. */ public static function update_disk() { if (is_multisite()) { if (get_site_transient('fastless_cache_disk_updated') !== FASTLESS_CACHE_VERSION) { self::each_site(true, 'Fastless_Cache_Disk::clean'); Fastless_Cache_Disk::setup(); set_site_transient('fastless_cache_disk_updated', FASTLESS_CACHE_VERSION, HOUR_IN_SECONDS); } } else { Fastless_Cache_Disk::clean(); Fastless_Cache_Disk::setup(); } } /** * When the backend is about to be updated. * * This runs when the 'fastless_cache' option is about to be added or updated in * the database. * * @param string $option Name of the option (for legacy reasons). * @param array $value The new option value. */ public static function on_update_backend($option, $value) { $settings = self::normalise_settings($value); // Recorded for the advanced-cache.php drop-in, which runs before // WooCommerce is loaded and so cannot call is_cart() and friends. $settings['uncacheable_paths'] = self::get_uncacheable_paths(); // Precompile the exclusion lists into ready-to-use forms so the cache // engine does not re-parse and re-quote them on every request. These are // derived values regenerated whenever the settings file is written; the // engine falls back to runtime compilation when they are absent. $page_paths = trim( (string) $settings['excluded_page_paths'] . "\n" . (string) $settings['uncacheable_paths'], "\n" ); $settings['excluded_page_paths_regex'] = Fastless_Cache_Engine::build_exclusion_regex( Fastless_Cache_Engine::parse_exclusion_list($page_paths) ); $settings['excluded_cookies_regex'] = Fastless_Cache_Engine::build_exclusion_regex(array_merge( array('wp-postpass*', 'wordpress_logged_in*', 'comment_author*'), Fastless_Cache_Engine::parse_exclusion_list((string) $settings['excluded_cookies']) )); $settings['excluded_query_strings_list'] = Fastless_Cache_Engine::parse_exclusion_list((string) $settings['excluded_query_strings']); $settings['included_query_strings_list'] = Fastless_Cache_Engine::parse_exclusion_list((string) $settings['included_query_strings']); $settings['included_cookie_strings_list'] = Fastless_Cache_Engine::parse_exclusion_list((string) $settings['included_cookie_strings']); // The delivered page's Content-Type charset, captured here because the // drop-in cannot call get_option() when it serves a cached page. $settings['blog_charset'] = function_exists('get_option') ? (string) get_option('blog_charset', 'UTF-8') : 'UTF-8'; // Elementor is not loaded when the drop-in serves a cached page, so the // decision to bypass preview requests is resolved here and stored. $settings['elementor_preview_bypass'] = ( class_exists('Fastless_Cache_Elementor') && Fastless_Cache_Elementor::is_active() && ! empty($settings['elementor_cache']) ); Fastless_Cache_Disk::create_settings_file($settings); self::unschedule_events(); } /** * Before an option is added. * * This runs on the 'add_option' action. * * @param string $option Name of the option to add. * @param mixed $value Value of the option. */ public static function on_add_option($option, $value) { if ($option === 'fastless_cache') { self::on_update_backend($option, $value); } } /** * Before an option value is updated. * * This runs on the 'update_option' action. * * @param string $option Name of the option to update. * @param mixed $old_value The old option value. * @param mixed $value The new option value. */ public static function on_update_option($option, $old_value, $value) { $options = array( // wp-admin/admin.php?page=fastless-cache 'fastless_cache', // wp-admin/options-general.php 'home', // wp-admin/options-reading.php 'page_on_front', 'page_for_posts', ); if (in_array($option, $options, true)) { if ($option === 'fastless_cache') { self::on_update_backend($option, $value); } else { self::clear_cache_on_option_save($option, $old_value, $value); } if ($option === 'home') { Fastless_Cache_Disk::delete_settings_file(); } } } /** * After the value of an option has been successfully updated. * * This runs on the 'updated_option' action. * * @param string $option Name of the updated option. * @param mixed $old_value The old option value. * @param mixed $value The new option value. */ public static function on_updated_option($option, $old_value, $value) { $options = array( // wp-admin/options-general.php 'blogname', 'blogdescription', 'WPLANG', 'timezone_string', 'gmt_offset', 'date_format', 'time_format', 'start_of_week', // wp-admin/options-reading.php 'page_on_front', 'page_for_posts', 'posts_per_page', 'blog_public', // wp-admin/options-discussion.php 'require_name_email', 'comment_registration', 'close_comments_for_old_posts', 'show_comments_cookies_opt_in', 'thread_comments', 'thread_comments_depth', 'page_comments', 'comments_per_page', 'default_comments_page', 'comment_order', 'show_avatars', 'avatar_rating', 'avatar_default', // wp-admin/options-permalink.php 'permalink_structure', 'category_base', 'tag_base', // wp-admin/themes.php 'template', 'stylesheet', // wp-admin/widgets.php 'sidebars_widgets', 'widget_*', // wp-admin/customize.php 'site_icon', // WooCommerce → Settings → Advanced (page setup) 'woocommerce_cart_page_id', 'woocommerce_checkout_page_id', 'woocommerce_myaccount_page_id', ); if (strpos($option, 'widget_') === 0) { $option = 'widget_*'; } if (in_array($option, $options, true)) { self::clear_cache_on_option_save($option, $old_value, $value); if ($option === 'permalink_structure') { self::update_backend(); } // Moving the cart, checkout or my-account page changes the paths the // pre-WordPress drop-in bypasses, so the settings file is refreshed. if (in_array($option, self::get_woocommerce_page_options(), true)) { self::on_update_backend('fastless_cache', self::get_settings(false)); } } } /** * The WooCommerce options that hold the transactional page IDs. * * @return string[] Option names. */ private static function get_woocommerce_page_options() { return array( 'woocommerce_cart_page_id', 'woocommerce_checkout_page_id', 'woocommerce_myaccount_page_id', ); } /** * The URL paths of the WooCommerce cart, checkout and my-account pages. * * These are written into the settings file so the advanced-cache.php drop-in * can bypass them before WordPress — and therefore WooCommerce — is loaded. * * @return string One path per line, empty when WooCommerce is not active. */ private static function get_uncacheable_paths() { if (! function_exists('wc_get_page_id') || ! function_exists('get_permalink')) { return ''; } $paths = array(); foreach (array('cart', 'checkout', 'myaccount') as $page) { $page_id = (int) wc_get_page_id($page); if ($page_id <= 0) { continue; } $permalink = get_permalink($page_id); if (! is_string($permalink) || '' === $permalink) { continue; } $path = wp_parse_url($permalink, PHP_URL_PATH); if (is_string($path) && '' !== $path) { $paths[] = $path; } } return implode("\n", array_unique($paths)); } /** * When a site's uninitialization routine should be executed. * * This runs on the 'wp_uninitialize_site' action. This deletes the settings file * and then clears the site cache for the deleted site. The advanced-cache.php * file will also be deleted and the WP_CACHE constant maybe unset if it was the * only site that had Fastless Cache activated. * * @param WP_Site $old_site Deleted site instance. */ public static function uninstall_later($old_site) { Fastless_Cache_Disk::clean(); self::clear_page_cache_by_site((int) $old_site->blog_id); } /** * Uninstall backend requirements. * * This deletes the 'fastless_cache' option, the settings version stamp, * every 'fastless_cache_*' transient (the per-user cache cleared notices * never expire, so they cannot be left behind) and the * 'fastless_cache_disk_updated' site transient. */ public static function uninstall_backend() { global $wpdb; delete_option('fastless_cache'); delete_option('fastless_cache_settings_version'); delete_transient('fastless_cache_cache_size'); delete_transient('fastless_cache_cache_count'); delete_transient(self::get_cache_cleared_transient_name()); delete_site_transient('fastless_cache_disk_updated'); if (! isset($wpdb) || ! is_object($wpdb)) { return; } // Sweep up any remaining 'fastless_cache_*' transients, such as the // cache cleared notices of users other than the one uninstalling. $transients = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- deliberate direct query: cache maintenance must read live data; a persistent cache layer here would serve stale results. $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_fastless_cache_%', '_transient_timeout_fastless_cache_%' ) ); if (! is_array($transients)) { return; } foreach ($transients as $transient) { delete_transient(preg_replace('/^_transient_(timeout_)?/', '', $transient)); } } /** * Enter each site and call a callback with an array of parameters. * * This assumes that the callback function exists on the site being entered. It * will not perform the callback or restart the cache engine on sites that do not * have Fastless Cache active, unless it is a must-use plugin or it is being * activated or uninstalled. * * @param bool $sites Whether to enter all sites or the current site. * @param string $callback Callback function. * @param array $callback_params (Optional) Callback function parameters. Default empty array. * @param bool $restart_engine (Optional) Whether to restart the cache engine. Default false. * @return array An array of callback returns with blog IDs as the keys. */ private static function each_site($sites, $callback, $callback_params = array(), $restart_engine = false) { $blog_ids = $sites ? self::get_blog_ids() : array(get_current_blog_id()); $last_blog_id = end($blog_ids); $skip_active_check = ! self::is_fastless_cache_active(); $callback_return = array(); foreach ($blog_ids as $blog_id) { self::switch_to_blog($blog_id, $restart_engine, $skip_active_check); if ($skip_active_check || self::is_fastless_cache_active()) { $callback_return[$blog_id] = call_user_func_array($callback, $callback_params); } $_restart_engine = ($restart_engine && $blog_id === $last_blog_id) ? true : false; self::restore_current_blog($_restart_engine, $skip_active_check); } return $callback_return; } /** * Switch the current blog. * * This is a wrapper for switch_to_blog() that can restart the cache engine, * allowing the correct site data to be picked up after the switch. * * @param int $blog_id The ID of the blog to switch to. * @param bool $restart_engine (Optional) Whether to restart the cache engine after the switch. Default false. * @param bool $force_restart (Optional) Whether to force restart the cache engine. Default false. * @return bool True if the current blog was switched, false otherwise. */ public static function switch_to_blog($blog_id, $restart_engine = false, $force_restart = false) { if (! is_multisite() || $blog_id === get_current_blog_id()) { return false; } switch_to_blog($blog_id); if (($force_restart || self::is_fastless_cache_active()) && $restart_engine) { Fastless_Cache_Engine::start(); } return true; } /** * Restore the current blog after switching. * * This is a wrapper for restore_current_blog() that can restart the cache engine, * allowing the correct site data to be picked up after the switch. * * @param bool $restart_engine (Optional) Whether to restart the cache engine after the switch. Default false. * @param bool $force_restart (Optional) Whether to force restart the cache engine. Default false. * @return bool True if the current blog was restored, false otherwise. */ public static function restore_current_blog($restart_engine = false, $force_restart = false) { if (! is_multisite() || ! ms_is_switched()) { return false; } restore_current_blog(); if (($force_restart || self::is_fastless_cache_active()) && $restart_engine) { Fastless_Cache_Engine::start(true); } return true; } /** * Whether Fastless Cache is active. * * This checks if Fastless Cache is in the active plugins list. It will not be in * that list when installed as a must-use plugin. This copies is_plugin_active(). * That function is not being used directly because of its availability. * * @return bool True if Fastless Cache is in the active plugins list, false if not. */ private static function is_fastless_cache_active() { if (in_array(FASTLESS_CACHE_BASE, (array) get_option('active_plugins', array()), true)) { return true; } if (! is_multisite()) { return false; } $plugins = get_site_option('active_sitewide_plugins'); if (isset($plugins[FASTLESS_CACHE_BASE])) { return true; } return false; } /** * Whether the page cache is actually running. * * True only when WordPress is set to load the cache (WP_CACHE) and the * advanced-cache.php drop-in that serves cached pages is present. This is * the signal behind the dashboard's "cache is running" indicator. * * @return bool True when the page cache is armed, false otherwise. */ public static function is_cache_active() { if (! defined('WP_CACHE') || ! WP_CACHE) { return false; } if (! defined('WP_CONTENT_DIR')) { return false; } return file_exists(WP_CONTENT_DIR . '/advanced-cache.php'); } /** * After a plugin has been activated or deactivated. * * This runs on the 'activated_plugin' and 'deactivated_plugin' actions. * */ public static function on_plugin_activation_deactivation() { // A plugin (de)activation can change what the pre-WordPress drop-in must // bypass and whether Elementor is present, so refresh the derived values // in the settings file (uncacheable_paths, elementor_preview_bypass). self::update_backend(); if (Fastless_Cache_Engine::$settings['clear_site_cache_on_changed_plugin']) { self::clear_site_cache(); } } /** * Get the plugin settings from the database for the current site. * * This can update the disk and backend requirements and then clear the site * cache if the settings do not exist or are outdated. If that occurs, the * settings after the update will be returned. * * @param bool $update Whether to update the disk and backend requirements if the settings are * outdated. Default true. * @return array|bool Plugin settings from the database, false if settings do not exist and update * was skipped or failed. */ public static function get_settings($update = true) { if ($update && get_option('fastless_cache_settings_version') !== FASTLESS_CACHE_VERSION) { self::update(); } return self::normalise_settings(get_option('fastless_cache', array())); } /** * Flatten sectioned settings into the flat shape the cache engine uses. * * The `fastless_cache` option is stored as an array keyed by section (see * Fastless_Cache_Options), while the engine and the settings file use flat * top-level keys. Anything already flat passes through; when both shapes * are present the sectioned values win, so a value written by the admin * panel supersedes a leftover flat key from an earlier build. * * @param mixed $settings Settings, sectioned or flat. * @return array Flat settings. */ private static function flatten_settings($settings) { if (! is_array($settings)) { return array(); } // Derived from the schema so a section added to (or split out of) the // admin panel can never be silently dropped here again. The static // fallback covers the case where the admin subsystem is unavailable. $sections = class_exists('Fastless_Cache_Settings_Schema') ? array_keys(Fastless_Cache_Settings_Schema::all()) : array('general', 'clearing', 'exclusions', 'query_strings', 'cookies', 'tools'); $flat = array(); foreach ($settings as $key => $value) { if (! in_array($key, $sections, true)) { $flat[$key] = $value; } } foreach ($sections as $section) { if (isset($settings[$section]) && is_array($settings[$section])) { $flat = array_merge($flat, $settings[$section]); } } return $flat; } /** * Normalise settings into the flat shape stored in the settings file and * read by the cache engine, filling in every default. * * @param mixed $settings Settings, sectioned or flat. * @return array Flat settings merged over the defaults. */ private static function normalise_settings($settings) { $normalised = wp_parse_args(self::flatten_settings($settings), self::get_default_settings()); // System settings are computed at runtime and must never be taken from a // stale stored value: a leftover flat 'version' key (from an older build) // would otherwise override the plugin version and mark the settings file // as permanently outdated, so the cache engine would never start. return wp_parse_args(self::get_default_system_settings(), $normalised); } /** * Get the blog ID for the current site or of a given site. * * @param WP_Site|int|string $site (Optional) Site instance or site blog ID. Default is the current site. * @return int The blog ID or 0 if not found. */ private static function get_blog_id($site = null) { if (empty($site)) { return get_current_blog_id(); } if ($site instanceof WP_Site) { return (int) $site->blog_id; } if (is_numeric($site)) { $blog_id = (int) $site; if (in_array($blog_id, self::get_blog_ids(), true)) { return $blog_id; } } return 0; } /** * Get the blog IDs. * * @global wpdb $wpdb WordPress database abstraction object. * * @return int[] Blog IDs. */ private static function get_blog_ids() { if (is_multisite()) { global $wpdb; $blog_ids = array_map('absint', $wpdb->get_col("SELECT blog_id FROM $wpdb->blogs")); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- deliberate direct query: cache maintenance must read live data; a persistent cache layer here would serve stale results. } else { $blog_ids = array(1); } return $blog_ids; } /** * Get the blog path for the current site. * * This gets the end part of the URL in case the installation is in a nested * subdirectory. An empty string is being returned instead of '/' as WordPress * does because it simplifies checking the blog path in * self::get_root_blog_exclusions(). * * @return string Blog path from site address URL (with leading and trailing slashes), empty * string if not found. */ public static function get_blog_path() { $site_url_path = (string) wp_parse_url(home_url(), PHP_URL_PATH); $site_url_path_pieces = explode('/', trim($site_url_path, '/')); $blog_path = end($site_url_path_pieces); $blog_path = (! empty($blog_path)) ? '/' . $blog_path . '/' : ''; return $blog_path; } /** * Get the blog path from a given URL. * * @return string Blog path from URL (with leading and trailing slashes), '/' if not found. */ public static function get_blog_path_from_url($url) { $url_path = (string) wp_parse_url($url, PHP_URL_PATH); $url_path_pieces = explode('/', trim($url_path, '/')); $blog_path = '/'; $blog_paths = self::get_blog_paths(); foreach ($url_path_pieces as $url_path_piece) { $url_path_piece = '/' . $url_path_piece . '/'; if (in_array($url_path_piece, $blog_paths, true)) { $blog_path = $url_path_piece; break; } } return $blog_path; } /** * Get the blog paths. * * @global wpdb $wpdb WordPress database abstraction object. * * @return string[] Blog paths. */ public static function get_blog_paths() { if (is_multisite()) { global $wpdb; $blog_paths = $wpdb->get_col("SELECT path FROM $wpdb->blogs"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- deliberate direct query: cache maintenance must read live data; a persistent cache layer here would serve stale results. } else { $blog_paths = array('/'); } return $blog_paths; } /** * Get the WP-Cron events. * * @return string[] An array of events with action hooks as the keys and recurrences as the values. */ private static function get_events() { $events = array('fastless_cache_clear_expired_cache' => 'hourly'); return $events; } /** * Get the permalink structure (deprecated). * */ private static function get_permalink_structure() { $permalink_structure = get_option('permalink_structure'); if ($permalink_structure && preg_match('/\/$/', $permalink_structure)) { return 'has_trailing_slash'; } if ($permalink_structure && ! preg_match('/\/$/', $permalink_structure)) { return 'no_trailing_slash'; } if (empty($permalink_structure)) { return 'plain'; } } /** * Get the cache index for the current site. * * @return array[] Cache index from the disk. */ public static function get_cache_index() { $args['subpages']['exclude'] = self::get_root_blog_exclusions(); // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams -- filesystem cache-index exclusion array, not a WP_Query argument. $cache = Fastless_Cache_Disk::cache_iterator(home_url(), $args); $cache_index = $cache['index']; return $cache_index; } public static function count_cache_pages($path) { $count = 0; $iterator = new FilesystemIterator($path, FilesystemIterator::SKIP_DOTS); foreach ($iterator as $fileInfo) { // If it's a directory, recurse into it if ($fileInfo->isDir()) { $count += self::count_cache_pages($fileInfo->getPathname()); } // If it's a file, increment the count elseif ($fileInfo->isFile()) { $count++; } } return $count; } public static function get_cache_count() { $cache_size = get_transient('fastless_cache_cache_count'); if ($cache_size === false) { $url = home_url(); $url = esc_url_raw($url, array('http', 'https')); $cache_dir = Fastless_Cache_Disk::get_cache_dir($url) . '/'; if (! is_dir($cache_dir)) { return 0; } $cache_size = self::count_cache_pages($cache_dir); set_transient('fastless_cache_cache_count', $cache_size, DAY_IN_SECONDS); } return $cache_size; } /** * Get the cache size for the current site. * * This sets the 'fastless_cache_cache_size' transient in the database when the * cache size is retrieved from the disk. * * @return int Cache size in bytes, either from the database or disk. */ public static function get_cache_size() { $cache_size = get_transient('fastless_cache_cache_size'); if ($cache_size === false) { $args['subpages']['exclude'] = self::get_root_blog_exclusions(); // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams -- filesystem cache-index exclusion array, not a WP_Query argument. $cache = Fastless_Cache_Disk::cache_iterator(home_url(), $args); $cache_size = $cache['size']; set_transient('fastless_cache_cache_size', $cache_size, DAY_IN_SECONDS); } return $cache_size; } /** * Get the name of the transient that is used in the cache clear notice. * * @return string Name of the transient. */ private static function get_cache_cleared_transient_name() { $transient_name = 'fastless_cache_cache_cleared_' . get_current_user_id(); return $transient_name; } /** * Get the default plugin settings. * * @param string $settings_type (Optional) The default plugin 'system' or 'user' settings, all default plugin * settings otherwise. * @return array Default plugin settings. */ private static function get_default_settings($settings_type = '') { switch ($settings_type) { case 'system': return self::get_default_system_settings(); case 'user': return self::get_default_user_settings(); default: return wp_parse_args(self::get_default_user_settings(), self::get_default_system_settings()); } } /** * Get the default plugin system settings. * * @return array Default plugin system settings. */ private static function get_default_system_settings() { $default_system_settings = array( 'version' => (string) FASTLESS_CACHE_VERSION, 'use_trailing_slashes' => (int) (substr(get_option('permalink_structure'), -1, 1) === '/'), 'permalink_structure' => (string) self::get_permalink_structure(), ); return $default_system_settings; } /** * Get the default plugin user settings. * * @return array Default plugin user settings. */ private static function get_default_user_settings() { $default_user_settings = array( 'cache_expires' => 0, 'cache_expiry_time' => 0, 'clear_site_cache_on_saved_post' => 0, 'clear_site_cache_on_saved_comment' => 0, 'clear_site_cache_on_saved_term' => 0, 'clear_site_cache_on_saved_user' => 0, 'clear_site_cache_on_changed_plugin' => 0, 'mobile_cache' => 0, 'compress_cache' => (int) Fastless_Cache_Disk::supports_gzip(), 'minify_html' => 0, 'excluded_post_ids' => '', 'excluded_page_paths' => '', 'excluded_query_strings' => '', 'query_cache' => 0, 'cookie_cache' => 0, 'excluded_cookies' => '', 'included_query_strings' => '', 'included_cookie_strings' => '', 'woocommerce_cart_cache' => 0, 'browser_cache_max_age' => 0, 'elementor_cache' => 1, 'excluded_page_paths_detailed' => '', 'clear_site_cache_on_saved_settings' => 0, 'disable_footprint' => 0 ); return $default_user_settings; } /** * Get the subpages that do not belong to the root blog in a subdirectory network. * * @return string[] Blog paths to the other sites in a network if the current site is the root blog * in a subdirectory network, empty otherwise. */ private static function get_root_blog_exclusions() { if (! is_multisite() || is_subdomain_install()) { return array(); } $current_blog_path = self::get_blog_path(); $network_blog_paths = self::get_blog_paths(); if (! in_array($current_blog_path, $network_blog_paths, true)) { return $network_blog_paths; } return array(); } /** * Add the plugin action links in the plugins list table. * * This runs on the 'plugin_action_links_fastless-cache/fastless-cache.php' action. * * @param string[] $action_links Action links. * @return string[] The action links after maybe being updated. */ public static function add_plugin_action_links($action_links) { if (! current_user_can('manage_options')) { return $action_links; } array_unshift($action_links, sprintf( '%s', admin_url('admin.php?page=' . Fastless_Cache_Admin::SLUG), esc_html__('Settings', 'fastless-cache') )); return $action_links; } /** * Add the plugin metadata in the plugins list table. * * This runs on the 'plugin_row_meta' action. * * @param string[] $plugin_meta An array of the plugin's metadata, including the version, author, author URI, * and plugin URI. * @param string $plugin_file Path to the plugin file relative to the plugins directory. * @return string[] An array of the plugin's metadata after maybe being updated. */ public static function add_plugin_row_meta($plugin_meta, $plugin_file) { if ($plugin_file !== FASTLESS_CACHE_BASE) { return $plugin_meta; } $plugin_meta = wp_parse_args( array( '' . esc_html__('Documentation', 'fastless-cache') . '', ), $plugin_meta ); $plugin_meta = wp_parse_args( array( 'Sponsor', ), $plugin_meta ); return $plugin_meta; } /** * Add the admin bar items. * * This runs on the 'admin_bar_menu' action. It adds the clear cache buttons to * the admin bar. * * @param WP_Admin_Bar $wp_admin_bar Admin bar instance, passed by reference. */ public static function add_admin_bar_items($wp_admin_bar) { if (! self::user_can_clear_cache()) { return; } // Add top-level Fastless Cache menu $wp_admin_bar->add_menu( array( 'id' => 'fastless_cache', 'href' => admin_url('admin.php?page=' . Fastless_Cache_Admin::SLUG), 'title' => '' . esc_html__('Fastless Cache', 'fastless-cache') . '', 'meta' => array('title' => esc_html__('Fastless Cache Settings', 'fastless-cache')), ) ); // Add Clear Site/Network Cache as first sub-item $title = (is_multisite() && is_network_admin()) ? esc_html__('Clear Network Cache', 'fastless-cache') : esc_html__('Clear Site Cache', 'fastless-cache'); $wp_admin_bar->add_menu( array( 'id' => 'fastless_cache_clear_cache', 'href' => wp_nonce_url(add_query_arg(array( '_cache' => 'fastless-cache', '_action' => 'clear', )), 'fastless_cache_clear_cache_nonce'), 'parent' => 'fastless_cache', 'title' => '' . $title . '', 'meta' => array('title' => $title), ) ); // Add Clear Page Cache as second sub-item (if not in admin) if (! is_admin()) { $wp_admin_bar->add_menu( array( 'id' => 'fastless_cache_clear_page_cache', 'href' => wp_nonce_url(add_query_arg(array( '_cache' => 'fastless-cache', '_action' => 'clearurl', )), 'fastless_cache_clear_cache_nonce'), 'parent' => 'fastless_cache', 'title' => '' . esc_html__('Clear Page Cache', 'fastless-cache') . '', 'meta' => array('title' => esc_html__('Clear Page Cache', 'fastless-cache')), ) ); } } /** * Whether the current user can clear the cache. * * @return bool True if the current user can clear the cache, false otherwise. */ private static function user_can_clear_cache() { /** * Filters whether the current user can clear the cache. * * @since 1.6.0 * * @param bool $can_clear_cache Whether the current user can clear the cache. Default is whether the current * user has the 'manage_options' capability. */ $can_clear_cache = apply_filters('fastless_cache_user_can_clear_cache', current_user_can('manage_options')); $can_clear_cache = apply_filters_deprecated('user_can_clear_cache', array($can_clear_cache), '1.6.0', 'fastless_cache_user_can_clear_cache'); return $can_clear_cache; } public static function process_clear_cache_asset_manager() { self::each_site((is_multisite() && is_network_admin()), self::class . '::clear_site_cache', array(), true); if (is_admin()) { set_transient(self::get_cache_cleared_transient_name(), 1); } } /** * Process a clear cache request. * * This runs on the 'init' action. It clears the cache when a clear cache button * is clicked in the admin bar. * */ public static function process_clear_cache_request() { if (empty($_GET['_cache']) || empty($_GET['_action']) || $_GET['_cache'] !== 'fastless-cache' || ($_GET['_action'] !== 'clear' && $_GET['_action'] !== 'clearurl')) { return; } if (empty($_GET['_wpnonce']) || ! wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'fastless_cache_clear_cache_nonce')) { return; } if (! self::user_can_clear_cache()) { return; } if ($_GET['_action'] === 'clearurl') { $request_uri = isset($_SERVER['REQUEST_URI']) ? Fastless_Cache_Engine::wp_unslash_safe($_SERVER['REQUEST_URI']) : ''; // 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. $request_uri = Fastless_Cache_Engine::validate_request_uri($request_uri); if ('' !== $request_uri) { self::clear_page_cache_by_url(Fastless_Cache_Engine::$request_headers['Host'] . $request_uri); } } elseif ($_GET['_action'] === 'clear') { self::each_site((is_multisite() && is_network_admin()), self::class . '::clear_site_cache', array(), true); } // Redirect to the same page. wp_safe_redirect(remove_query_arg(array('_cache', '_action', '_wpnonce'))); if (is_admin()) { set_transient(self::get_cache_cleared_transient_name(), 1); } exit; } /** * Display an admin notice after the cache has been cleared. * * This runs on the 'admin_notices' action. Only shown on our own settings screen * to comply with Guideline 11. * */ public static function cache_cleared_notice() { if (! self::user_can_clear_cache()) { return; } // Only show on our own settings screen. $screen = get_current_screen(); if (! $screen || strpos($screen->id, 'fastless-cache') === false) { return; } if (get_transient(self::get_cache_cleared_transient_name())) { printf( '
%s
%s
%s
%s
%s
advanced-cache.php'
)
);
}
// Warnings: only show on our own settings screen.
if (! $on_own_screen) {
return;
}
// Check the advanced-cache.php drop-in file (only if no conflict).
if (! file_exists(WP_CONTENT_DIR . '/advanced-cache.php') && ! get_option('fastless_cache_conflicting_plugin', false) && ! get_option('fastless_cache_dropin_conflict', false)) {
$dropin_created = Fastless_Cache_Disk::create_advanced_cache_file();
if ($dropin_created === false) {
printf(
'%s
advanced-cache.php',
'' . esc_html(FASTLESS_CACHE_DIR) . '',
'' . esc_html(WP_CONTENT_DIR) . ''
)
);
}
}
// Check the WordPress installation directory index file.
if (! file_exists(FASTLESS_CACHE_INDEX_FILE)) {
printf(
'%s
' . esc_html(FASTLESS_CACHE_INDEX_FILE) . '',
'FASTLESS_CACHE_INDEX_FILE',
'wp-config.php'
)
);
}
// Check the permalink structure.
if (empty(get_option('permalink_structure'))) {
printf(
'%s
%s
' . esc_html($parent_dir) . '',
'755',
sprintf(
'%s',
'https://wordpress.org/support/article/changing-file-permissions/',
esc_html__('file permissions', 'fastless-cache')
)
)
);
}
}
// Check the Autoptimize HTML optimization.
if (defined('AUTOPTIMIZE_PLUGIN_DIR') && Fastless_Cache_Engine::$settings['minify_html'] && get_option('autoptimize_html', '') !== '') {
printf(
'%s