true, 'page_cache_type' => 'php', ]; /** * @param Logger|null $logger * @param Validator|null $validator * @param Setting|null $setting */ public function __construct( ?Logger $logger = null, ?Validator $validator = null, ?Setting $setting = null ) { $this->logger = $logger ?? new Logger(); $this->validator = $validator ?? new Validator(); $this->setting = $setting ?? new Setting(); } /** * Initialize cache functionality * * @return void */ public function init(): void { $this->ensure_cache_directory(); add_action('wp_ajax_runcache_get_cache_settings', [$this, 'ajax_get_settings']); add_action('wp_ajax_runcache_save_cache_settings', [$this, 'ajax_save_settings']); add_action('wp_ajax_runcache_verify_cache', [$this, 'ajax_verify_cache']); add_action('wp_ajax_runcache_request_cache_type', [$this, 'ajax_request_cache_type']); } /** * Get current cache provider type * * @return string */ public function get_cache_type(): string { return $this->get_settings()['page_cache_type']; } /** * Get cache provider by key * * @param string $key * @return CacheProvider|false */ public function get_cache_provider(string $key): CacheProvider|false { if (empty($key)) { return false; } $cache_providers = apply_filters('runcache_cache_providers', []); if (isset($cache_providers[$key]) && $cache_providers[$key] instanceof CacheProvider) { return $cache_providers[$key]; } $this->logger->warning('Provider not found', [ 'component' => 'PageCache', 'key' => $key, 'available' => array_keys($cache_providers) ]); return false; } /** * Get cache settings * * @return array */ public function get_settings(): array { $settings = wp_parse_args($this->setting->get('page_cache'), $this->default_settings); // Add server information // Read-only: serves the cached value or ''. Never detects or runs HTTP. $settings['server_type'] = (new Server())->get_server_type(); $settings['document_root'] = untrailingslashit(ABSPATH); $config_dir = (new File())->get_config_dir(); $settings['config_dir'] = $config_dir !== false ? untrailingslashit($config_dir) : untrailingslashit(ABSPATH); $settings['site_url'] = home_url(); $settings['site_name'] = get_bloginfo('name'); $settings['admin_url'] = admin_url(); return apply_filters('runcache_cache_settings', $settings); } /** * Save cache settings * * @param array $settings * @return bool */ public function save_settings(array $settings): bool { // Read the previous settings from the raw stored option. // The enriched getter computes server_type and must stay off this path. $raw_stored = $this->setting->get('page_cache'); $stored_settings = is_array($raw_stored) ? $raw_stored : []; $previous_settings = wp_parse_args($stored_settings, $this->default_settings); $previous_provider = $this->get_cache_provider($previous_settings['page_cache_type']); // Merge incoming settings on top of the stored settings plus defaults. // Partial updates (e.g. ['enable' => false]) keep the stored page_cache_type. $settings = wp_parse_args($settings, $previous_settings); // Allow plugins to validate/modify settings before save $settings = apply_filters('runcache_cache_settings_before_save', $settings, $previous_settings); $result = $this->setting->set('page_cache', $settings); $new_provider = $this->get_cache_provider($settings['page_cache_type']); $dropin = new Dropin(); if ($settings['enable']) { if ($previous_settings['page_cache_type'] !== $settings['page_cache_type']) { // Disable previous provider if ($previous_provider) { $previous_provider->disable(); } // Remove toggle for previous cache type $dropin->remove_cache_toggle($previous_settings['page_cache_type']); } // Enable new provider if ($new_provider) { $enabled = $new_provider->enable(); if (!$enabled) { $this->logger->error('Failed to enable provider', [ 'component' => 'PageCache', 'provider' => $settings['page_cache_type'], ]); } // Create toggle for new cache type $dropin->write_cache_toggle($settings['page_cache_type']); } } else { // Disable current provider if ($previous_provider) { $previous_provider->disable(); } // Remove toggle for current cache type $dropin->remove_cache_toggle($previous_settings['page_cache_type']); } do_action('runcache_cache_settings_saved', $settings); return $result; } /** * Verify cache setup * * @return array */ public function verify_cache(): array { $settings = $this->get_settings(); if (!$settings['enable']) { return [ 'type' => $this->default_settings['page_cache_type'], 'message' => 'Page cache is disabled', 'verified' => false ]; } $provider = $this->get_cache_provider($settings['page_cache_type']); if (!$provider) { return [ 'type' => $this->default_settings['page_cache_type'], 'message' => 'Cache provider not found', 'verified' => false ]; } $verified = $provider->verify(); if (is_array($verified)) { return [ 'type' => $settings['page_cache_type'], 'message' => $verified['message'], 'verified' => $verified['verified'] ]; } return [ 'type' => $settings['page_cache_type'], 'message' => '', 'verified' => $verified ]; } /** * Get cache directory path * * @return string */ public function get_cache_directory(): string { return RUNCACHE_CACHE_DIRECTORY; } /** * Get cache file path from URL path * * @param string $path * @return string|false */ public function get_cache_file_path($path): string|false { $path = $this->validator->sanitize_url_pattern($path); if ($path === false) { $this->logger->error('Invalid cache path', ['path' => $path]); return false; } $file_path = $this->get_cache_directory() . $path . '.html'; if (!$this->validator->validate_file_path($file_path)) { $this->logger->error('Invalid cache file path', ['path' => $file_path]); return false; } return $file_path; } /** * Get cache meta file path from URL path * * @param string $path * @return string|false */ public function get_cache_meta_path($path): string|false { $path = $this->validator->sanitize_url_pattern($path); if ($path === false) { $this->logger->error('Invalid cache path', ['path' => $path]); return false; } $file_path = $this->get_cache_directory() . $path . '.meta'; if (!$this->validator->validate_file_path($file_path)) { $this->logger->error('Invalid cache file path', ['path' => $file_path]); return false; } return $file_path; } /** * Ensure cache directory exists with current protection files * * Protection files are rewritten when their content is missing or * outdated, so existing installs pick up new deny rules automatically. * * @return bool */ public function ensure_cache_directory(): bool { $cache_dir = $this->get_cache_directory(); $file = new File(); if (!$file->exists($cache_dir) && !$file->mkdir($cache_dir)) { $this->logger->error('Failed to create cache directory', [ 'path' => $cache_dir ]); return false; } $index_content = ''; $htaccess_content = "Options -Indexes\n\n Require all denied\n\n"; $web_config_content = "\n\n \n \n \n \n \n \n \n \n \n \n\n"; $this->ensure_protection_file($file, $cache_dir . 'index.html', $index_content); $this->ensure_protection_file($file, $cache_dir . '.htaccess', $htaccess_content); $this->ensure_protection_file($file, $cache_dir . 'web.config', $web_config_content); return true; } /** * Write a protection file only when its content is missing or outdated. * * @param File $file File helper. * @param string $path Absolute file path. * @param string $content Expected content. * @return void */ private function ensure_protection_file(File $file, string $path, string $content): void { if ($file->exists($path)) { $current = $file->read($path); if ($current === $content) { return; } } $file->write($path, $content); } /** * Enable WP_CACHE constant in wp-config.php * * @return bool */ public function enable_wp_cache(): bool { $file = new File(); // Find wp-config.php $wp_config_path = $file->locate_wp_config(); if ($wp_config_path === false) { $this->logger->error('wp-config.php not found'); return false; } // Read current content $config_content = $file->read($wp_config_path); if ($config_content === false) { $this->logger->error('Failed to read wp-config.php', ['path' => $wp_config_path]); return false; } // Add or update WP_CACHE constant $wp_cache_line = "define('WP_CACHE', true); // Added by RunCache"; if (preg_match('/define\s*\(\s*[\'"]WP_CACHE[\'"]\s*,\s*(true|false)\s*\)\s*;/', $config_content)) { // Replace existing WP_CACHE line $config_content = preg_replace( '/define\s*\(\s*[\'"]WP_CACHE[\'"]\s*,\s*(true|false)\s*\)\s*;.*/', $wp_cache_line, $config_content ); } else { // Add new constant after write_wp_config($wp_config_path, $config_content); } /** * Disable WP_CACHE constant in wp-config.php * * @return bool */ public function disable_wp_cache(): bool { $file = new File(); // Find wp-config.php $wp_config_path = $file->locate_wp_config(); if ($wp_config_path === false) { $this->logger->error('wp-config.php not found'); return false; } // Read current content $config_content = $file->read($wp_config_path); if ($config_content === false) { $this->logger->error('Failed to read wp-config.php', ['path' => $wp_config_path]); return false; } // Remove WP_CACHE constant if (preg_match('/define\s*\(\s*[\'"]WP_CACHE[\'"]\s*,\s*(true|false)\s*\)\s*;/', $config_content)) { $config_content = preg_replace( '/define\s*\(\s*[\'"]WP_CACHE[\'"]\s*,\s*(true|false)\s*\)\s*;.*\n?/', '', $config_content ); } return $this->write_wp_config($wp_config_path, $config_content); } /** * Write config content to wp-config.php with backup and restore * * @param string $wp_config_path * @param string $config_content * @return bool */ private function write_wp_config(string $wp_config_path, string $config_content): bool { $file = new File(); // Check permissions before attempting any modifications $permission_check = $file->check_permissions($wp_config_path); if (!$permission_check['success']) { $this->logger->error('wp-config.php permission check failed', [ 'path' => $wp_config_path, 'readable' => $permission_check['readable'], 'writable' => $permission_check['writable'] ]); return false; } $this->logger->info('wp-config.php permissions check passed', ['path' => $wp_config_path]); $backup_path = $wp_config_path . '.runcache-backup.php'; // Delete existing backup if present if ($file->exists($backup_path)) { $this->logger->info('Removing existing wp-config.php backup'); if (!$file->delete($backup_path)) { $this->logger->error('Failed to remove existing wp-config.php backup', [ 'path' => $wp_config_path, 'backup' => $backup_path ]); return false; } } // Create backup if (!$file->copy($wp_config_path, $backup_path, true)) { $this->logger->error('Failed to create wp-config.php backup', [ 'path' => $wp_config_path, 'backup' => $backup_path ]); return false; } $this->logger->info('wp-config.php backup created successfully'); // Verify file is writable if (!$file->is_writable($wp_config_path)) { $this->logger->error('wp-config.php is not writable', ['path' => $wp_config_path]); $file->delete($backup_path); return false; } $this->logger->info('Attempting to write wp-config.php', ['content_length' => strlen($config_content)]); try { // Write new content if (!$file->write($wp_config_path, $config_content)) { $this->logger->error('Failed to update wp-config.php', [ 'path' => $wp_config_path, 'backup_exists' => $file->exists($backup_path) ]); // Restore from backup $this->logger->info('Attempting to restore wp-config.php from backup'); if (!$file->copy($backup_path, $wp_config_path, true)) { $this->logger->error('Failed to restore wp-config.php from backup', ['backup_path' => $backup_path]); } return false; } $this->logger->info('wp-config.php updated successfully'); return true; } finally { $file->delete($backup_path); } } /** * Request cache type from homepage headers * * Uses the public domain first so that CDN / edge-cached responses are * detected correctly. If the public request does not expose the origin * cache type header (for example because a CDN returns a challenge page, * strips headers, or serves an edge-only response), fall back to an * internal loopback request that reaches the origin web server directly. * * @return string|null */ public function request_cache_type(): string|null { $server = new Server(); $homepage_url = home_url('/'); // First try the public URL to detect CDN / edge cache responses. $headers = $server->get_headers($homepage_url, ['timeout' => 10], true); if ($headers === null) { return null; } // If the public request did not expose the origin cache type header, // fall back to an internal loopback request. This bypasses CDN edges, // bot challenges, and proxies that hide or rewrite origin headers while // preserving the public Host header so the correct vhost is served. if (empty($headers['x-runcache-type'])) { $internal_headers = $server->get_headers($homepage_url, ['timeout' => 10], false); if (is_array($internal_headers) && !empty($internal_headers['x-runcache-type'])) { $headers = $internal_headers; } } $cache_type = $headers['x-runcache-type'] ?? ''; if (is_array($cache_type)) { $cache_type = $cache_type[0]; } return strtolower($cache_type); } /** * Enable page caching * * @return void */ public function enable(): void { $settings = $this->get_settings(); $settings['enable'] = true; $this->setting->set('page_cache', $settings); } /** * Disable page caching * * @return void */ public function disable(): void { $settings = $this->get_settings(); $settings['enable'] = false; $this->setting->set('page_cache', $settings); } /** * AJAX handler for getting cache settings * * @return void */ public function ajax_get_settings(): void { $this->validator->validate_ajax_request(); wp_send_json_success($this->get_settings()); } /** * AJAX handler for saving cache settings * * @return void */ public function ajax_save_settings(): void { $this->validator->validate_ajax_request(); $settings = $this->validator->validate_ajax_settings(); $result = $this->save_settings($settings); $result ? wp_send_json_success($this->get_settings()) : wp_send_json_error(['message' => 'Failed to save settings']); } /** * AJAX handler for verifying cache * * @return void */ public function ajax_verify_cache(): void { $this->validator->validate_ajax_request(); wp_send_json_success($this->verify_cache()); } /** * AJAX handler for requesting cache type * * @return void */ public function ajax_request_cache_type(): void { $this->validator->validate_ajax_request(); wp_send_json_success([ 'cache_type' => $this->request_cache_type() ]); } }