<?php /** * Class and methods to generate Critical CSS. * * @link https://ewww.io/swis/ * @package SWIS_Performance */ namespace SWIS; if ( ! defined( 'ABSPATH' ) ) { exit; } // TODO: does it need to extend page parser? /** * Generates and inserts Critical CSS via an external API. */ final class Critical_CSS extends Page_Parser { /** * A list of user-defined exclusions, populated by validate_user_exclusions(). * * @access protected * @var array $user_exclusions */ protected $user_exclusions = array(); /** * Register actions and filters for Critical CSS. */ function __construct() { if ( ! $this->get_option( 'critical_css_key' ) ) { return; } parent::__construct(); $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); if ( $this->background_mode_enabled() ) { // Add handler to manually start the (async) preloader. add_action( 'admin_action_swis_generate_css_manual', array( $this, 'generate_css_manual' ) ); } else { // TODO: do we need to stop the process when they clear the cache? // Any time the cache is cleared, clear the preload queue. add_action( 'swis_complete_cache_cleared', array( $this, 'stop_generate_css' ) ); add_action( 'swis_site_cache_cleared', array( $this, 'stop_generate_css' ) ); add_action( 'swis_cache_by_url_cleared', array( $this, 'stop_generate_css' ) ); } // Actions to process preload via AJAX. add_action( 'wp_ajax_swis_generate_css_init', array( $this, 'start_generate_css_ajax' ) ); add_action( 'wp_ajax_swis_url_generate_css', array( $this, 'url_generate_css_ajax' ) ); // Allow the user to override the preload delay with a constant. add_filter( 'swis_generate_css_delay', array( $this, 'generate_css_delay_override' ) ); // Overrides for user exclusions. add_filter( 'swis_skip_generate_css', array( $this, 'skip_generate_css' ), 10, 2 ); $this->validate_user_exclusions(); } /** * Checks to see if the user defined an override for the generate css delay. * * @param int $delay The current delay (defaults to 5 seconds). * @return int The default, or a user-configured override. */ function generate_css_delay_override( $delay ) { if ( defined( 'SWIS_GENERATE_CSS_DELAY' ) ) { $delay_override = SWIS_GENERATE_CSS_DELAY; return absint( $delay_override ); } return $delay; } /** * Validate the user-defined exclusions. */ function validate_user_exclusions() { $user_exclusions = $this->get_option( 'cache_preload_exclude' ); if ( ! empty( $user_exclusions ) ) { if ( is_string( $user_exclusions ) ) { $user_exclusions = array( $user_exclusions ); } if ( is_array( $user_exclusions ) ) { foreach ( $user_exclusions as $exclusion ) { if ( ! is_string( $exclusion ) ) { continue; } $this->user_exclusions[] = $exclusion; } } } } /** * Exclude page from being preloaded based on user specified list. * * @param boolean $skip Whether SWIS should skip preloading. * @param string $url The page URL. * @return boolean True to skip the page, unchanged otherwise. */ function skip_cache_preload( $skip, $url ) { if ( $this->user_exclusions ) { foreach ( $this->user_exclusions as $exclusion ) { if ( false !== strpos( $url, $exclusion ) ) { $this->debug_message( __METHOD__ . "(); user excluded $url via $exclusion" ); return true; } } } return $skip; } /** * Handle the manual preload admin action. */ function manual_preload_action() { if ( false === current_user_can( 'manage_options' ) || ! check_admin_referer( 'swis_cache_preload_nonce', 'swis_cache_preload_nonce' ) ) { wp_die( esc_html__( 'Access denied', 'swis-performance' ) ); } if ( ! empty( $_GET['swis_stop_preload'] ) ) { $this->stop_preload(); } else { $this->start_preload(); } $base_url = admin_url( 'options-general.php?page=swis-performance-options' ); wp_safe_redirect( $base_url ); exit; } /** * Begin preload process. */ function start_preload() { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); $this->stop_preload(); swis()->cache_preload_async->dispatch(); } /** * Stop preload process. */ function stop_preload() { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); swis()->cache_preload_background->cancel_process(); \delete_transient( 'swis_cache_preload_total' ); } /** * Check if the home/front page is uncached and therefore the preloader should be launched. */ function check_front_page_cache() { if ( $this->get_option( 'cache_preload_front_page_auto' ) && ! \is_user_logged_in() && \is_front_page() && ! \get_transient( 'swis_cache_preload_frontpage_triggered' ) ) { $this->debug_message( 'front page not cached, starting preload' ); \set_transient( 'swis_cache_preload_frontpage_triggered', true, 10 * MINUTE_IN_SECONDS ); $this->start_preload(); } } /** * Begin preload process. * * @param string $url The page to preload. */ function start_preload_url( $url ) { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); $this->debug_message( "preloading $url" ); swis()->cache_preload_async->data( array( 'swis_preload_url' => esc_url( $url ), ) )->dispatch(); } /** * Begin preload process via AJAX request. */ function start_preload_ajax() { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); if ( false === current_user_can( 'manage_options' ) || ! check_ajax_referer( 'swis_cache_preload_nonce', 'swis_cache_preload_nonce', false ) ) { die( wp_json_encode( array( 'error' => esc_html__( 'Access token has expired, please reload the page.', 'swis-performance' ) ) ) ); } $remaining_urls = (int) swis()->cache_preload_background->count_queue(); $completed = 0; if ( empty( $remaining_urls ) ) { $this->debug_message( 'looking for URLs to preload' ); $this->get_urls(); $total_urls = (int) swis()->cache_preload_background->count_queue(); } else { $total_urls = (int) get_transient( 'swis_cache_preload_total' ); if ( ! $total_urls ) { $total_urls = $remaining_urls; set_transient( 'swis_cache_preload_total', (int) $total_urls, DAY_IN_SECONDS ); } $completed = $total_urls - $remaining_urls; } /* translators: %d: number of images */ $message = sprintf( esc_html__( '%1$d / %2$d pages have been completed.', 'swis-performance' ), $completed, (int) $total_urls ); die( wp_json_encode( array( 'success' => $total_urls, 'message' => $message, ) ) ); } /** * Preload the next URL in the queue via AJAX request. */ function preload_url_ajax() { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); if ( false === current_user_can( 'manage_options' ) || ! check_ajax_referer( 'swis_cache_preload_nonce', 'swis_cache_preload_nonce', false ) ) { die( wp_json_encode( array( 'error' => esc_html__( 'Access token has expired, please reload the page.', 'swis-performance' ) ) ) ); } global $wpdb; $url = $wpdb->get_row( "SELECT id,page_url FROM $wpdb->swis_queue WHERE queue_name = 'swis_cache_preload' LIMIT 1", ARRAY_A ); if ( ! $this->is_iterable( $url ) || empty( $url['page_url'] ) ) { die( wp_json_encode( array( 'success' => 0 ) ) ); } $this->preload( $url['page_url'] ); swis()->cache_preload_background->delete( $url['id'] ); $remaining_urls = (int) swis()->cache_preload_background->count_queue(); $total_urls = (int) get_transient( 'swis_cache_preload_total' ); $completed = $total_urls - $remaining_urls; /* translators: %d: number of images */ $message = sprintf( esc_html__( '%1$d / %2$d pages have been completed.', 'swis-performance' ), (int) $completed, (int) $total_urls ); die( wp_json_encode( array( 'success' => $remaining_urls, 'message' => $message, ) ) ); } /** * Gets all the URLs to preload, called via AJAX or async operation. */ function get_urls() { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); $urls = array_merge( $this->get_homepage_urls(), $this->get_sitemap_urls() ); if ( $this->is_iterable( $urls ) ) { foreach ( $urls as $url ) { if ( empty( $url ) || ! is_string( $url ) ) { continue; } $this->debug_message( "queueing $url for preload" ); swis()->cache_preload_background->push_to_queue( $url ); } set_transient( 'swis_cache_preload_total', (int) swis()->cache_preload_background->count_queue(), DAY_IN_SECONDS ); } } /** * Fetch the home page and get all links for preloading. * * @return array A list of URLs that should be preloaded. */ function get_homepage_urls() { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); $urls = array(); $home_url = get_home_url(); $home_domain = $this->parse_url( $home_url, PHP_URL_HOST ); $args = array( 'user-agent' => 'SWIS Performance/Preload', 'sslverify' => apply_filters( 'https_local_ssl_verify', false, $home_url ), ); $args = apply_filters( 'swis_cache_preload_homepage_request_args', $args ); $result = wp_remote_get( $home_url, $args ); if ( is_wp_error( $result ) ) { $this->debug_message( 'cache preload error: ' . $result->get_error_message() ); return $urls; } $http_code = wp_remote_retrieve_response_code( $result ); if ( 200 !== (int) $http_code ) { $this->debug_message( "cache preload error, http code $http_code" ); return $urls; } $content = wp_remote_retrieve_body( $result ); $links = $this->get_elements_from_html( $content, 'a' ); foreach ( $links as $link ) { $url = $this->get_attribute( $link, 'href' ); $url = $this->should_preload( $url, $home_url, $home_domain ); if ( ! empty( $url ) ) { $urls[] = $url; } } return $urls; } /** * Fetch the sitemap to get URLs for preloading. * * @param string $sitemap_url The sitemap URL to search through. * @return array A list of URLs that should be preloaded. */ function get_sitemap_urls( $sitemap_url = '' ) { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); $urls = array(); $sitemap_urls = false; if ( ! $sitemap_url ) { if ( defined( 'SWIS_CACHE_PRELOAD_SITEMAP' ) && SWIS_CACHE_PRELOAD_SITEMAP ) { $sitemap_override = SWIS_CACHE_PRELOAD_SITEMAP; if ( is_string( $sitemap_override ) ) { $sitemap_urls = array( $sitemap_override ); } } if ( ! $this->is_iterable( $sitemap_urls ) ) { $sitemap_urls = array( home_url( 'sitemap_index.xml' ), home_url( 'sitemap.xml' ), home_url( 'wp-sitemap.xml' ), ); } $sitemap_urls = apply_filters( 'swis_cache_preload_default_sitemaps', $sitemap_urls ); foreach ( $sitemap_urls as $sitemap_url ) { $sitemap_xml = $this->get_sitemap_xml( $sitemap_url ); if ( $sitemap_xml ) { break; } } } else { $sitemap_xml = $this->get_sitemap_xml( $sitemap_url ); } if ( $sitemap_xml && function_exists( 'simplexml_load_string' ) ) { libxml_use_internal_errors( true ); $xml = simplexml_load_string( $sitemap_xml ); if ( false !== $xml ) { $url_count = count( $xml->url ); $map_count = count( $xml->sitemap ); if ( $url_count ) { foreach ( $xml->url as $xml_url ) { if ( ! empty( $xml_url->loc ) ) { $this->debug_message( 'found a url in sitemap: ' . $xml_url->loc ); $urls[] = (string) $xml_url->loc; } } } if ( $map_count ) { foreach ( $xml->sitemap as $sitemap ) { $this->debug_message( 'found a child map at ' . $sitemap->loc ); $urls = array_merge( $urls, $this->get_sitemap_urls( (string) $sitemap->loc ) ); } } } } if ( empty( $urls ) ) { $urls = $this->get_post_urls(); } return $urls; } /** * Retrieve a sitemap for parsing. * * @param string $sitemap_url The sitemap URL. * @return string The contents of the sitemap. */ function get_sitemap_xml( $sitemap_url ) { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); $this->debug_message( "fetching $sitemap_url" ); $args = array( 'user-agent' => 'SWIS Performance/Preload', 'sslverify' => apply_filters( 'https_local_ssl_verify', false, $sitemap_url ), ); $args = apply_filters( 'swis_cache_preload_sitemap_request_args', $args ); $result = wp_remote_get( esc_url_raw( $sitemap_url ), $args ); if ( is_wp_error( $result ) ) { $this->debug_message( 'cache preload error: ' . $result->get_error_message() ); return ''; } $http_code = wp_remote_retrieve_response_code( $result ); if ( 200 !== $http_code ) { $this->debug_message( "cache preload error, code $http_code" ); return ''; } $xml_content = wp_remote_retrieve_body( $result ); // Check to be sure this is a valid sitemap. if ( false === strpos( $xml_content, '<loc>' ) ) { $this->debug_message( 'cache preload error, no <loc> sections found!' ); return ''; } return $xml_content; } /** * Fetch posts for preloading, fallback if no sitemaps were found. * * @return array A list of URLs that should be preloaded. */ function get_post_urls() { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); $urls = array(); $post_types = get_post_types( array( 'public' => true ) ); $post_types = array_filter( $post_types, 'is_post_type_viewable' ); $args = apply_filters( 'swis_preload_posts_args', array( 'fields' => 'ids', 'numberposts' => 1000, 'orderby' => 'post_date', 'order' => 'DESC', 'posts_per_page' => -1, 'post_status' => 'publish', 'post_type' => $post_types, ) ); $blog_posts = get_posts( $args ); if ( ! $this->is_iterable( $blog_posts ) ) { return $urls; } foreach ( $blog_posts as $blog_post ) { $permalink = get_permalink( $blog_post ); $this->debug_message( "found $permalink for post $blog_post" ); if ( $permalink ) { $urls[] = $permalink; } } return $urls; } /** * Check if the given URL should be preloaded. * * @param string $url URL to check. * @param string $home_url Homepage URL. * @param string $home_domain Homepage domain name. * @return bool True to preload, false otherwise. */ function should_preload( $url, $home_url, $home_domain ) { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); $this->debug_message( "checking $url against $home_url with $home_domain" ); $url_parts = $this->parse_url( $url ); if ( empty( $url_parts ) ) { $this->debug_message( 'parsing failed' ); return false; } if ( ! empty( $url_parts['fragment'] ) ) { $this->debug_message( 'bookmark not necessary' ); return false; } if ( empty( $url_parts['host'] ) ) { $url = home_url( $url ); $this->debug_message( "fixed $url" ); $url_parts = $this->parse_url( $url ); } if ( 0 === strpos( $url, '//' ) && ! empty( $url_parts['scheme'] ) ) { $url = $url_parts['scheme'] . ':' . $url; $this->debug_message( "added scheme to $url" ); } if ( untrailingslashit( $url ) === untrailingslashit( $home_url ) ) { $this->debug_message( 'URL is home URL' ); return false; } if ( $url_parts['host'] !== $home_domain ) { $this->debug_message( 'URL is not at home' ); return false; } if ( apply_filters( 'swis_skip_cache_preload', false, $url ) ) { $this->debug_message( 'URL skipped by user/filter' ); return false; } if ( $this->is_file_url( $url ) ) { $this->debug_message( 'URL is a file' ); return false; } $cache_settings = swis()->cache->get_settings(); if ( ! empty( $cache_settings['excluded_query_strings'] ) ) { $query_string_regex = self::$settings['excluded_query_strings']; } else { $query_string_regex = '/^(?!(fbclid|ref|mc_(cid|eid)|utm_(source|medium|campaign|term|content|expid)|gclid|fb_(action_ids|action_types|source)|age-verified|usqp|cn-reloaded|_ga|_ke)).+$/'; } if ( ! empty( $url_parts['query'] ) && preg_match( $query_string_regex, $url_parts['query'] ) ) { $this->debug_message( 'URL has disallowed query params' ); return false; } return $url; } /** * Check if the URL is already cached. * * @param string $url The URL path to check. * @return bool True for cached, false if it ain't. */ function is_cached( $url ) { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); if ( ! class_exists( '\SWIS\Disk_Cache' ) ) { return false; } $cache_file_dir = Disk_Cache::get_cache_file_dir( $url ); $this->debug_message( "checking if $cache_file_dir/ exists" ); if ( \is_dir( $cache_file_dir ) ) { $dir_objects = Disk_Cache::get_dir_objects( $cache_file_dir ); if ( $this->is_iterable( $dir_objects ) ) { foreach ( $dir_objects as $dir_object ) { if ( is_file( $dir_object ) ) { $this->debug_message( 'it sure does!' ); return true; } } } } return false; } /** * Check if the URL path is to a known file type. * * @param string $path The URL path to check. * @return bool True for known files, false for everything else. */ function is_file_url( $path ) { $known_types = array( 'jpe', 'jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'tiff', 'pdf', 'doc', 'docx', 'odt', 'txt', 'mp3', 'ogg', 'avi', 'm4v', 'mov', 'wvm', 'qt', 'webm', 'ogv', 'mp4', 'm4p', 'mpg', 'mpeg', 'mpv', 'zip', 'tar', 'bz2', 'tgz', 'rar', 'gz' ); $known_types = implode( '|', $known_types ); if ( preg_match( '#\.(?:' . $known_types . ')$#i', $path ) ) { return true; } return false; } /** * Preloads the given URL. * * @param string $url The page to preload. */ function preload( $url ) { $this->debug_message( '<b>' . __METHOD__ . '()</b>' ); if ( $this->is_cached( $url ) ) { return; } // Sleep first, instead of later, which gives the cache clearing time to finish. // It also means that when we're done, that's it, and we exit right away. if ( $this->function_exists( 'sleep' ) ) { sleep( absint( apply_filters( 'swis_cache_preload_delay', 5 ) ) ); } $args = array( 'timeout' => 10, 'user-agent' => 'SWIS Performance/Preload', 'sslverify' => apply_filters( 'https_local_ssl_verify', false, $url ), ); if ( $this->get_option( 'cache_webp' ) ) { $args['headers'] = 'Accept: image/webp'; } $args = apply_filters( 'swis_cache_preload_url_request_args', $args ); $result = wp_remote_get( esc_url_raw( $url ), $args ); if ( is_wp_error( $result ) ) { $this->debug_message( 'cache preload error: ' . $result->get_error_message() ); } else { $this->debug_message( wp_remote_retrieve_response_code( $result ) ); } } }