settings = maple_performance()->settings; $this->init_hooks(); } /** * Initialize hooks */ private function init_hooks() { // Start output buffering early add_action( 'template_redirect', array( $this, 'start_buffering' ), -999 ); // Cache clearing hooks add_action( 'save_post', array( $this, 'clear_post_cache' ), 10, 1 ); add_action( 'delete_post', array( $this, 'clear_post_cache' ), 10, 1 ); add_action( 'wp_trash_post', array( $this, 'clear_post_cache' ), 10, 1 ); add_action( 'comment_post', array( $this, 'clear_post_cache_by_comment' ), 10, 2 ); add_action( 'edit_comment', array( $this, 'clear_post_cache_by_comment' ), 10, 1 ); add_action( 'switch_theme', array( __CLASS__, 'clear_all' ) ); add_action( 'activated_plugin', array( __CLASS__, 'clear_all' ) ); add_action( 'deactivated_plugin', array( __CLASS__, 'clear_all' ) ); add_action( 'update_option_permalink_structure', array( __CLASS__, 'clear_all' ) ); // WooCommerce hooks if ( class_exists( 'WooCommerce' ) ) { add_action( 'woocommerce_product_set_stock', array( $this, 'clear_product_cache' ) ); add_action( 'woocommerce_variation_set_stock', array( $this, 'clear_product_cache' ) ); } } /** * Start output buffering */ public function start_buffering() { // Check if we should cache this request if ( maple_performance()->is_excluded() ) { return; } // Check if cached version exists $cache_file = $this->get_cache_file_path(); if ( $this->serve_cache( $cache_file ) ) { exit; } // Start buffering for cache creation ob_start( array( $this, 'process_buffer' ) ); } /** * Process output buffer and create cache */ public function process_buffer( $buffer ) { // Don't cache empty or error pages if ( empty( $buffer ) || http_response_code() !== 200 ) { return $buffer; } // Don't cache if contains certain markers if ( $this->should_skip_caching( $buffer ) ) { return $buffer; } // Write cache file $this->write_cache( $buffer ); return $buffer; } /** * Check if page should skip caching based on content */ private function should_skip_caching( $buffer ) { // Skip if contains no-cache markers $skip_markers = array( '', '', 'woocommerce-cart', 'woocommerce-checkout', ); foreach ( $skip_markers as $marker ) { if ( strpos( $buffer, $marker ) !== false ) { return true; } } return false; } /** * Get cache file path for current request */ private function get_cache_file_path( $url = null ) { if ( null === $url ) { $url = $this->get_current_url(); } $parsed = parse_url( $url ); $host = $parsed['host'] ?? ''; $path = $parsed['path'] ?? '/'; // Sanitize host - only allow alphanumeric, dots, and hyphens $host = preg_replace( '/[^a-zA-Z0-9.-]/', '', $host ); // Sanitize path - remove any path traversal attempts $path = str_replace( array( '..', "\0" ), '', $path ); $path = preg_replace( '/[^a-zA-Z0-9\/_-]/', '', $path ); $path = preg_replace( '#/+#', '/', $path ); // Collapse multiple slashes $path = rtrim( $path, '/' ); if ( empty( $path ) ) { $path = '/index'; } // Limit path depth to prevent excessive directory creation $path_parts = explode( '/', trim( $path, '/' ) ); if ( count( $path_parts ) > 10 ) { $path_parts = array_slice( $path_parts, 0, 10 ); $path = '/' . implode( '/', $path_parts ); } // Build cache directory structure $cache_dir = MAPLE_PERF_CACHE_DIR . $host . $path . '/'; // Verify the resolved path is within cache directory $real_cache_base = realpath( MAPLE_PERF_CACHE_DIR ); if ( $real_cache_base ) { // Use dirname to check parent since $cache_dir may not exist yet $parent_dir = dirname( $cache_dir ); while ( ! is_dir( $parent_dir ) && $parent_dir !== dirname( $parent_dir ) ) { $parent_dir = dirname( $parent_dir ); } if ( is_dir( $parent_dir ) ) { $real_parent = realpath( $parent_dir ); if ( $real_parent && strpos( $real_parent, $real_cache_base ) !== 0 ) { // Path escapes cache directory - return safe fallback return MAPLE_PERF_CACHE_DIR . 'fallback/https-index.html'; } } } // Determine cache file name $is_https = is_ssl() ? 'https' : 'http'; $cache_file = $cache_dir . $is_https . '-index.html'; return $cache_file; } /** * Get current URL */ private function get_current_url() { $scheme = is_ssl() ? 'https' : 'http'; // Sanitize HTTP_HOST $host = isset( $_SERVER['HTTP_HOST'] ) ? $_SERVER['HTTP_HOST'] : ''; $host = preg_replace( '/[^a-zA-Z0-9.-]/', '', $host ); // Sanitize REQUEST_URI $uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '/'; $uri = filter_var( $uri, FILTER_SANITIZE_URL ); // Remove query strings for cache key $uri = strtok( $uri, '?' ); // Remove any null bytes or path traversal $uri = str_replace( array( "\0", '..' ), '', $uri ); return $scheme . '://' . $host . $uri; } /** * Serve cached file if exists */ private function serve_cache( $cache_file ) { // Check for gzipped version first if ( $this->settings['cache_gzip'] && $this->client_accepts_gzip() ) { $gzip_file = $cache_file . '.gz'; if ( file_exists( $gzip_file ) && $this->is_cache_valid( $gzip_file ) ) { header( 'Content-Encoding: gzip' ); header( 'Content-Type: text/html; charset=UTF-8' ); header( 'X-Maple-Cache: HIT (gzip)' ); readfile( $gzip_file ); return true; } } // Check for brotli version if ( $this->settings['cache_brotli'] && $this->client_accepts_brotli() ) { $br_file = $cache_file . '.br'; if ( file_exists( $br_file ) && $this->is_cache_valid( $br_file ) ) { header( 'Content-Encoding: br' ); header( 'Content-Type: text/html; charset=UTF-8' ); header( 'X-Maple-Cache: HIT (brotli)' ); readfile( $br_file ); return true; } } // Serve uncompressed if ( file_exists( $cache_file ) && $this->is_cache_valid( $cache_file ) ) { header( 'Content-Type: text/html; charset=UTF-8' ); header( 'X-Maple-Cache: HIT' ); readfile( $cache_file ); return true; } return false; } /** * Check if cache file is still valid */ private function is_cache_valid( $cache_file ) { // No expiry set if ( empty( $this->settings['cache_expiry'] ) || $this->settings['cache_expiry'] === 0 ) { return true; } $file_age = time() - filemtime( $cache_file ); $max_age = $this->settings['cache_expiry'] * HOUR_IN_SECONDS; return $file_age < $max_age; } /** * Check if client accepts gzip */ private function client_accepts_gzip() { $encoding = $_SERVER['HTTP_ACCEPT_ENCODING'] ?? ''; return strpos( $encoding, 'gzip' ) !== false; } /** * Check if client accepts brotli */ private function client_accepts_brotli() { $encoding = $_SERVER['HTTP_ACCEPT_ENCODING'] ?? ''; return strpos( $encoding, 'br' ) !== false; } /** * Write cache file */ private function write_cache( $content ) { $cache_file = $this->get_cache_file_path(); $cache_dir = dirname( $cache_file ); // Create directory if needed if ( ! file_exists( $cache_dir ) ) { wp_mkdir_p( $cache_dir ); } // Verify we're still within cache directory (paranoid check) $real_cache_base = realpath( MAPLE_PERF_CACHE_DIR ); $real_cache_dir = realpath( $cache_dir ); if ( false === $real_cache_base || false === $real_cache_dir ) { return; } if ( strpos( $real_cache_dir, $real_cache_base ) !== 0 ) { return; } // Add cache signature $timestamp = gmdate( 'D, d M Y H:i:s' ) . ' GMT'; $signature = "\n"; $content .= $signature; // Write HTML file with proper permissions // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $cache_file, $content ); if ( file_exists( $cache_file ) ) { chmod( $cache_file, 0644 ); } // Create gzipped version if ( $this->settings['cache_gzip'] && function_exists( 'gzencode' ) ) { $gzip_content = gzencode( $content, 9 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $cache_file . '.gz', $gzip_content ); if ( file_exists( $cache_file . '.gz' ) ) { chmod( $cache_file . '.gz', 0644 ); } } // Create brotli version if ( $this->settings['cache_brotli'] && function_exists( 'brotli_compress' ) ) { $br_content = brotli_compress( $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $cache_file . '.br', $br_content ); if ( file_exists( $cache_file . '.br' ) ) { chmod( $cache_file . '.br', 0644 ); } } } /** * Clear cache for a specific post */ public function clear_post_cache( $post_id ) { // Don't clear for autosaves or revisions if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) { return; } $post = get_post( $post_id ); if ( ! $post || $post->post_status !== 'publish' ) { return; } // Clear post URL $url = get_permalink( $post_id ); $this->clear_url_cache( $url ); // Clear home page $this->clear_url_cache( home_url( '/' ) ); // Clear archive pages $post_type = get_post_type( $post_id ); $archive_url = get_post_type_archive_link( $post_type ); if ( $archive_url ) { $this->clear_url_cache( $archive_url ); } // Clear category/tag archives - limit to prevent performance issues $taxonomies = get_object_taxonomies( $post_type ); $terms_cleared = 0; $max_terms = 50; // Limit term cache clearing to prevent slowdown foreach ( $taxonomies as $taxonomy ) { if ( $terms_cleared >= $max_terms ) { break; } $terms = get_the_terms( $post_id, $taxonomy ); if ( $terms && ! is_wp_error( $terms ) ) { foreach ( $terms as $term ) { if ( $terms_cleared >= $max_terms ) { break; } $term_url = get_term_link( $term ); if ( ! is_wp_error( $term_url ) ) { $this->clear_url_cache( $term_url ); $terms_cleared++; } } } } // Clear stats transient since cache changed delete_transient( 'maple_perf_cache_stats' ); } /** * Clear cache by comment */ public function clear_post_cache_by_comment( $comment_id, $comment_approved = null ) { $comment = get_comment( $comment_id ); if ( $comment ) { $this->clear_post_cache( $comment->comment_post_ID ); } } /** * Clear product cache (WooCommerce) */ public function clear_product_cache( $product ) { if ( is_numeric( $product ) ) { $product_id = $product; } else { $product_id = $product->get_id(); } $this->clear_post_cache( $product_id ); } /** * Clear cache for a specific URL */ public function clear_url_cache( $url ) { $cache_file = $this->get_cache_file_path( $url ); if ( file_exists( $cache_file ) ) { @unlink( $cache_file ); } if ( file_exists( $cache_file . '.gz' ) ) { @unlink( $cache_file . '.gz' ); } if ( file_exists( $cache_file . '.br' ) ) { @unlink( $cache_file . '.br' ); } // Also clear https version if http was cleared and vice versa $alt_file = str_replace( array( '/http-index.html', '/https-index.html' ), array( '/https-index.html', '/http-index.html' ), $cache_file ); if ( file_exists( $alt_file ) ) { @unlink( $alt_file ); } if ( file_exists( $alt_file . '.gz' ) ) { @unlink( $alt_file . '.gz' ); } if ( file_exists( $alt_file . '.br' ) ) { @unlink( $alt_file . '.br' ); } } /** * Clear all cache */ public static function clear_all() { self::recursive_delete( MAPLE_PERF_CACHE_DIR ); // Recreate directories wp_mkdir_p( MAPLE_PERF_CACHE_DIR ); wp_mkdir_p( MAPLE_PERF_CACHE_DIR . 'assets/' ); // Add index.php files with ABSPATH check $index = " $max_iterations ) { break; // Safety limit reached } $file_path = $file->getRealPath(); // Verify each file is within allowed directory if ( strpos( $file_path, $real_dir ) !== 0 ) { continue; } if ( $file->isDir() ) { @rmdir( $file_path ); } else { @unlink( $file_path ); } } } catch ( Exception $e ) { // Handle iterator exceptions gracefully return; } } /** * Get cache size */ public static function get_cache_size() { $size = 0; if ( ! is_dir( MAPLE_PERF_CACHE_DIR ) ) { return $size; } // Limit iterations to prevent runaway on huge cache directories $max_iterations = 10000; $iterations = 0; try { $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( MAPLE_PERF_CACHE_DIR, RecursiveDirectoryIterator::SKIP_DOTS ) ); foreach ( $files as $file ) { if ( ++$iterations > $max_iterations ) { break; // Safety limit reached } if ( $file->isFile() ) { $size += $file->getSize(); } } } catch ( Exception $e ) { // Handle iterator exceptions gracefully return $size; } return $size; } /** * Get number of cached files */ public static function get_cache_count() { $count = 0; if ( ! is_dir( MAPLE_PERF_CACHE_DIR ) ) { return $count; } // Limit iterations to prevent runaway on huge cache directories $max_iterations = 10000; $iterations = 0; try { $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( MAPLE_PERF_CACHE_DIR, RecursiveDirectoryIterator::SKIP_DOTS ) ); foreach ( $files as $file ) { if ( ++$iterations > $max_iterations ) { break; // Safety limit reached } if ( $file->isFile() && pathinfo( $file, PATHINFO_EXTENSION ) === 'html' ) { $count++; } } } catch ( Exception $e ) { // Handle iterator exceptions gracefully return $count; } return $count; } }