added maple performance caching plugin
This commit is contained in:
parent
c44c49a836
commit
e468202f95
12 changed files with 4501 additions and 0 deletions
608
native/wordpress/maple-performance-wp/inc/class-maple-cache.php
Normal file
608
native/wordpress/maple-performance-wp/inc/class-maple-cache.php
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue