monorepo/native/wordpress/maple-performance-wp/inc/class-maple-cache.php
2026-02-02 12:35:28 -05:00

608 lines
19 KiB
PHP

<?php
/**
* Page Caching Class
*
* Handles static HTML file generation and serving.
*
* @package MaplePerformance
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Cache class
*/
class Maple_Performance_Cache {
/**
* Single instance
*/
private static $instance = null;
/**
* Plugin settings
*/
private $settings;
/**
* Get instance
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->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(
'<!-- no-cache -->',
'<!-- maple-no-cache -->',
'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<!-- Maple Performance WP @ {$timestamp} -->";
$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 = "<?php\n// Silence is golden.\nif ( ! defined( 'ABSPATH' ) ) { exit; }";
file_put_contents( MAPLE_PERF_CACHE_DIR . 'index.php', $index );
file_put_contents( MAPLE_PERF_CACHE_DIR . 'assets/index.php', $index );
// Clear stats transient
delete_transient( 'maple_perf_cache_stats' );
}
/**
* Recursively delete directory
*/
private static function recursive_delete( $dir ) {
if ( ! is_dir( $dir ) ) {
return;
}
// Safety check - only delete within wp-content/cache/maple-performance
$real_dir = realpath( $dir );
$allowed_base = realpath( WP_CONTENT_DIR );
if ( false === $real_dir || false === $allowed_base ) {
return;
}
if ( strpos( $real_dir, $allowed_base ) !== 0 ) {
return;
}
// Additional safety - must contain 'maple-performance' in path
if ( strpos( $real_dir, 'maple-performance' ) === false ) {
return;
}
// Limit iterations to prevent runaway deletion
$max_iterations = 50000;
$iterations = 0;
try {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ( $files as $file ) {
if ( ++$iterations > $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;
}
}