settings = $settings; $this->logger = $logger; $this->filesystem = $filesystem; } /** * Register hooks. * * @return void */ public function register() { add_action( 'init', array( $this, 'serve_cached_page' ), 0 ); add_action( 'save_post', array( $this, 'purge_on_content_change' ), 10, 2 ); add_action( 'deleted_post', array( $this, 'purge_all' ) ); add_action( 'transition_post_status', array( $this, 'purge_all' ) ); add_action( 'comment_post', array( $this, 'purge_all' ) ); add_action( 'edit_terms', array( $this, 'purge_all' ) ); add_action( 'switch_theme', array( $this, 'purge_all' ) ); } /** * Serve a cached page early. * * @return void */ public function serve_cached_page() { if ( ! $this->is_cacheable_request( true ) ) { return; } $file = $this->cache_file_for_current_request(); if ( ! file_exists( $file ) || ! $this->is_fresh( $file ) ) { return; } if ( ! headers_sent() ) { header( 'X-Acelora-Cache: HIT' ); header( 'Content-Type: text/html; charset=' . get_bloginfo( 'charset' ) ); } $this->logger->log( 'info', 'Cache hit.', array( 'url' => $this->current_url() ) ); echo $this->filesystem->get_contents( $file ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Cached HTML is generated from WordPress output and sent as the response body. exit; } /** * Purge all cached pages. * * @return int */ public function purge_all() { $deleted = $this->filesystem->clear_directory( $this->filesystem->page_cache_dir() ); $this->logger->log( 'info', 'Cache purged.', array( 'deleted' => $deleted ) ); return $deleted; } /** * Purge cache when content changes. * * @param int $post_id Post ID. * @param \WP_Post $post Post object. * @return void */ public function purge_on_content_change( $post_id, $post ) { if ( wp_is_post_revision( $post_id ) || 'auto-draft' === $post->post_status ) { return; } $this->purge_all(); } /** * Purge a single URL. * * @param string $url URL. * @return bool */ public function purge_url( $url ) { $file = $this->cache_file_for_url( $url, $this->device_bucket() ); if ( file_exists( $file ) ) { return $this->filesystem->delete_file( $file ); } return false; } /** * Remove expired pages. * * @return int */ public function cleanup_expired() { $directory = $this->filesystem->page_cache_dir(); $removed = 0; if ( ! is_dir( $directory ) ) { return 0; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ) ); foreach ( $iterator as $file ) { if ( $file->isFile() && 'html' === strtolower( $file->getExtension() ) && ! $this->is_fresh( $file->getPathname() ) ) { if ( $this->filesystem->delete_file( $file->getPathname() ) ) { ++$removed; } } } $this->logger->log( 'info', 'Expired cache cleanup completed.', array( 'removed' => $removed ) ); return $removed; } /** * Get current cache file. * * @return string */ private function cache_file_for_current_request() { return $this->cache_file_for_url( $this->current_url(), $this->device_bucket() ); } /** * Cache file for a URL. * * @param string $url URL. * @param string $device Device bucket. * @return string */ private function cache_file_for_url( $url, $device ) { $hash = hash( 'sha256', strtolower( $url ) . '|' . $device ); return $this->filesystem->page_cache_dir() . $device . '/' . substr( $hash, 0, 2 ) . '/' . $hash . '.html'; } /** * Determine whether cache file is fresh. * * @param string $file File path. * @return bool */ private function is_fresh( $file ) { $lifetime = max( 60, absint( $this->settings->get( 'cache.lifetime', 86400 ) ) ); return filemtime( $file ) >= ( time() - $lifetime ); } /** * Is the current request eligible for caching? * * @param bool $early Whether this check runs before main query conditionals. * @return bool */ private function is_cacheable_request( $early ) { if ( ! $this->settings->get( 'cache.enabled', 1 ) || is_admin() || wp_doing_ajax() || wp_doing_cron() ) { return false; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return false; } $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET'; if ( 'GET' !== strtoupper( $method ) ) { return false; } if ( is_user_logged_in() && ! $this->settings->get( 'cache.logged_in_cache', 0 ) ) { return false; } if ( is_user_logged_in() && $this->has_excluded_role() ) { return false; } if ( $this->has_excluded_cookie() || $this->has_excluded_query_string() || $this->has_excluded_url() ) { return false; } return $early || ! is_preview(); } /** * Is the resolved WordPress view cacheable? * * @return bool */ private function is_cacheable_wp_view() { if ( is_404() || is_search() || is_feed() || is_trackback() || is_robots() || is_preview() || is_customize_preview() ) { return false; } if ( is_singular() && post_password_required() ) { return false; } return true; } /** * Check excluded URL patterns. * * @return bool */ private function has_excluded_url() { $url = $this->current_url(); $path = (string) wp_parse_url( $url, PHP_URL_PATH ); foreach ( $this->settings->list_value( $this->settings->get( 'cache.excluded_urls', '' ) ) as $pattern ) { if ( '' !== $pattern && ( false !== stripos( $path, $pattern ) || false !== stripos( $url, $pattern ) ) ) { return true; } } return false; } /** * Check excluded cookie patterns. * * @return bool */ private function has_excluded_cookie() { $excluded = $this->settings->list_value( $this->settings->get( 'cache.excluded_cookies', '' ) ); foreach ( array_keys( $_COOKIE ) as $cookie ) { foreach ( $excluded as $pattern ) { if ( '' !== $pattern && false !== stripos( $cookie, $pattern ) ) { return true; } } } return false; } /** * Check query strings. * * @return bool */ private function has_excluded_query_string() { $query_string = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : ''; if ( '' === $query_string ) { return false; } parse_str( $query_string, $query ); $excluded = $this->settings->list_value( $this->settings->get( 'cache.excluded_queries', '' ) ); foreach ( array_keys( $query ) as $key ) { if ( in_array( (string) $key, $excluded, true ) ) { return true; } } return true; } /** * Check excluded roles. * * @return bool */ private function has_excluded_role() { $user = wp_get_current_user(); $excluded = $this->settings->list_value( $this->settings->get( 'cache.excluded_roles', '' ) ); return (bool) array_intersect( (array) $user->roles, $excluded ); } /** * Device bucket for mobile cache handling. * * @return string */ private function device_bucket() { if ( ! $this->settings->get( 'cache.mobile_cache', 1 ) ) { return 'all'; } return wp_is_mobile() ? 'mobile' : 'desktop'; } /** * Current URL. * * @return string */ private function current_url() { $scheme = is_ssl() ? 'https://' : 'http://'; $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : wp_parse_url( home_url(), PHP_URL_HOST ); $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; return $scheme . $host . $uri; } }