diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..99fafa4 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,65 @@ +# Testing WP Recache (manual) + +Environment: Docker (`docker-compose up -d`), or any WP ≥ 5.8 + PHP ≥ 7.4 install with the plugin symlinked into `wp-content/plugins/`. + +## Activation + +1. Activate the plugin. +2. Check `wp-config.php` contains `define('WP_CACHE', true); // Added by WP Recache.` +3. Check these files exist: + - `wp-content/advanced-cache.php` + - `wp-content/wp-recache-cache.php` + - `wp-content/cache/wp-recache/.htaccess` + `index.php` + +## Core cache (issue #1 stories 1, 11, 12) + +4. Anonymous visit (incognito) to a page twice: + - 1st: response header `X-WP-Recache: MISS`, file `md5(host + path) . '.html'` appears in `wp-content/cache/wp-recache/`. + - 2nd: `X-WP-Recache: HIT` (via WP) or `X-WP-Recache-Dropin: 1` (served before WP loaded). +5. Same URL with `?utm_source=test`: always MISS, no new cache file (query strings bypass). +6. Define `DONOTCACHEPAGE` on a page (e.g. in a must-use plugin): page never cached. +7. Logged-in visit: no `X-WP-Recache` headers, nothing cached (logged-in cookie also bypasses the drop-in). + +## Exclusions (stories 4–6) + +8. Settings → Cache → Exclude URLs: add `/about`, save, visit `/about` twice → never cached. +9. Default exclusions: `/cart`, `/checkout`, `/my-account` never cached. +10. Feeds (`/feed/`) and 404 pages: no `X-WP-Recache` MISS header, no cache file. + +## Purge (stories 2, 3) + +11. Edit a published post → its cache file and the homepage file are deleted. +12. Add a comment to a post → that post's cache file is deleted (not the comment ID's). +13. Admin bar → WP Recache → Purge All Cache (works on every admin page AND on the front-end admin bar, not just the settings page) → cache dir emptied. +14. Settings → Advanced → Purge All Cache button → same result. +15. Heartbeat purge: `wp.heartbeat.send('wp_recache_purge', 1)` in browser console on an admin page → next heartbeat tick purges all (check response `wp_recache_purged`). + +## Mobile (story 7) + +16. Enable "Separate Mobile Cache", visit once with a mobile UA (`curl -A "iPhone"`), once desktop → two files: `*-mobile.html`, `*-desktop.html`. +17. With mobile cache ON, the drop-in does not serve (falls through to WP path) — expected. + +## Stats (story 9) + +18. Settings → Cache → Cache Statistics shows file count and size matching `wp-content/cache/wp-recache/`. + +## Multisite (story 10) + +19. On a network: same path on two sites produces two different files (host+path in key). Purge on one site must not touch the other. + +## Deactivation / uninstall + +20. Deactivate: `advanced-cache.php`, `wp-recache-cache.php` removed, `WP_CACHE` marker line removed from `wp-config.php`. +21. If another plugin's `advanced-cache.php` exists (no "WP Recache" marker), deactivation must NOT delete it. +22. Uninstall: options deleted, `wp-content/cache/wp-recache/` removed. + +## Automated + +- `composer test` (PHPUnit, needs WP test suite via docker-compose). +- `composer phpstan`, `composer lint`. + +## Known ceilings + +- Drop-in ignores `cache_duration` TTL (no WP available to read the option); freshness relies on purge-on-update. Files are never served stale for edited content, only for TTL-based expiry. +- Preload toggle (story 8) has no crawler implementation yet. +- Purge on publish clears only the post + homepage; archives/taxonomy pages expire via TTL. diff --git a/assets/admin.js b/assets/admin.js new file mode 100644 index 0000000..3c52817 --- /dev/null +++ b/assets/admin.js @@ -0,0 +1,23 @@ +/** + * WP Recache admin-bar / settings actions. + * + * @package WP_Recache + */ + +function wpRecachePurgeAll() { + if (!confirm('Are you sure you want to purge all cache?')) { + return; + } + + jQuery.post(wpRecache.ajaxUrl, { + action: 'wp_recache_purge_all', + nonce: wpRecache.nonce + }, function (response) { + if (response.success) { + alert('Cache purged successfully'); + location.reload(); + } else { + alert('Error: ' + response.data); + } + }); +} diff --git a/includes/Activator.php b/includes/Activator.php index 9d1a7c8..72b0bad 100644 --- a/includes/Activator.php +++ b/includes/Activator.php @@ -15,7 +15,9 @@ class WP_Recache_Activator { public static function activate() { self::create_cache_directory(); self::set_default_options(); + self::create_cache_server(); self::create_advanced_cache(); + self::set_wp_cache_constant(true); self::flush_rewrite_rules(); } @@ -29,7 +31,7 @@ class WP_Recache_Activator { $htaccess = WP_RECACHE_CACHE_PATH . '.htaccess'; if (!file_exists($htaccess)) { - file_put_contents($htaccess, "Deny from all\n"); + file_put_contents($htaccess, "\nRequire all denied\n\n\nDeny from all\n\n"); } $index = WP_RECACHE_CACHE_PATH . 'index.php'; @@ -59,6 +61,50 @@ class WP_Recache_Activator { } } + /** + * Write the standalone cache server required by the advanced-cache.php + * drop-in. Must run without WordPress loaded. + */ + private static function create_cache_server() { + $content = ' $value) { + if (strpos($name, \'wordpress_logged_in\') === 0) { + return; + } +} + +$uri = isset($_SERVER[\'REQUEST_URI\']) ? (string) parse_url($_SERVER[\'REQUEST_URI\'], PHP_URL_PATH) : \'/\'; +$host = $_SERVER[\'HTTP_HOST\'] ?? \'\'; +$file = WP_CONTENT_DIR . \'/cache/wp-recache/\' . md5($host . $uri) . \'.html\'; + +// With separate mobile cache enabled, files carry a -desktop/-mobile suffix, +// so the plain file never exists and serving falls through to WordPress. +if (file_exists($file)) { + header(\'X-WP-Recache: HIT\'); + header(\'X-WP-Recache-Dropin: 1\'); + readfile($file); + exit; +} +'; + + file_put_contents(WP_CONTENT_DIR . '/wp-recache-cache.php', $content); + } + /** * Create advanced-cache.php drop-in. */ @@ -87,6 +133,40 @@ if (file_exists(WP_CONTENT_DIR . \'/wp-recache-cache.php\')) { file_put_contents($advanced_cache, $content); } + /** + * Add or remove the WP_CACHE constant in wp-config.php. + * + * Without it, WordPress never loads advanced-cache.php. + * + * @param bool $enable True to add the constant, false to remove it. + */ + public static function set_wp_cache_constant($enable) { + $marker = "define('WP_CACHE', true); // Added by WP Recache.\n"; + $config_path = ABSPATH . 'wp-config.php'; + + if (!file_exists($config_path)) { + $config_path = dirname(ABSPATH) . '/wp-config.php'; + } + + if (!file_exists($config_path) || !is_writable($config_path)) { + return; + } + + $content = file_get_contents($config_path); + + if ($enable) { + if (strpos($content, $marker) !== false || preg_match("/define\s*\(\s*['\"]WP_CACHE['\"]\s*,\s*true\s*\)/", $content)) { + return; + } + + $content = preg_replace('/^<\?php/', " admin_url('admin-ajax.php'), - 'nonce' => wp_create_nonce('wp_recache_nonce'), - ]); + add_action('admin_enqueue_scripts', 'wp_recache_enqueue_admin_bar_script'); + // New exclusions must not leave stale files that the drop-in would still serve. + add_action('update_option_wp_recache_exclude_urls', 'wp_recache_delete_all_cache'); } /** @@ -93,11 +91,13 @@ class WP_Recache_Admin { * @return array Sanitized URLs. */ public function sanitize_exclude_urls($input) { - if (!is_array($input)) { - return []; + if (is_array($input)) { + $input = implode("\n", $input); } - return array_map('sanitize_text_field', $input); + $lines = array_filter(array_map('trim', explode("\n", (string) $input))); + + return array_map('sanitize_text_field', $lines); } /** @@ -110,6 +110,10 @@ class WP_Recache_Admin { $active_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'cache'; + if (!in_array($active_tab, ['cache', 'optimize', 'advanced'], true)) { + $active_tab = 'cache'; + } + include WP_RECACHE_INC_PATH . 'Admin/views/settings.php'; } diff --git a/includes/Admin/views/settings.php b/includes/Admin/views/settings.php index 8813389..21e2b35 100644 --- a/includes/Admin/views/settings.php +++ b/includes/Admin/views/settings.php @@ -48,7 +48,7 @@ $stats = WP_Recache_Admin::get_cache_stats(); Exclude URLs - +

One URL per line. These pages will not be cached.

@@ -131,23 +131,3 @@ $stats = WP_Recache_Admin::get_cache_stats(); - - diff --git a/includes/Cache/WPCache.php b/includes/Cache/WPCache.php index 1acbadf..b14b4ca 100644 --- a/includes/Cache/WPCache.php +++ b/includes/Cache/WPCache.php @@ -9,17 +9,26 @@ defined('ABSPATH') || exit; class WP_Recache_WPCache { + /** + * Whether this request started our output buffer. + * + * @var bool + */ + private $buffer_started = false; + /** * Initialize cache hooks. */ public function init() { - add_action('init', [$this, 'start_output_buffering']); + // template_redirect: query conditionals (is_feed/is_404/is_preview) are + // available here, and REST/AJAX requests never reach it. + add_action('template_redirect', [$this, 'start_output_buffering'], 0); add_action('shutdown', [$this, 'end_output_buffering']); add_action('save_post', [$this, 'purge_post_cache']); - add_action('wp_insert_comment', [$this, 'purge_post_cache']); + add_action('wp_insert_comment', [$this, 'purge_comment_cache']); - add_filter('heartbeat_received', [$this, 'purge_cache_onheartbeat']); + add_filter('heartbeat_received', [$this, 'purge_cache_onheartbeat'], 10, 2); } /** @@ -54,8 +63,14 @@ class WP_Recache_WPCache { } } + // Bots get served warm cache but never warm it themselves. + if (wp_recache_is_robots()) { + return; + } + header('X-WP-Recache: MISS'); ob_start([$this, 'cache_output']); + $this->buffer_started = true; } /** @@ -69,6 +84,20 @@ class WP_Recache_WPCache { return $output; } + // Respect opt-outs from WordPress and third-party plugins. + if (defined('DONOTCACHEPAGE') && DONOTCACHEPAGE) { + return $output; + } + + $status = http_response_code(); + if ($status !== false && $status !== 200) { + return $output; + } + + if (wp_recache_is_robots()) { + return $output; + } + $url = $this->get_current_url(); $cache_path = wp_recache_get_cache_path_with_device($url); $cache_dir = dirname($cache_path); @@ -86,7 +115,7 @@ class WP_Recache_WPCache { * End output buffering. */ public function end_output_buffering() { - if (ob_get_level()) { + if ($this->buffer_started && ob_get_level()) { ob_end_flush(); } } @@ -106,15 +135,30 @@ class WP_Recache_WPCache { wp_recache_delete_cache(home_url('/')); } + /** + * Purge cache for the post a comment belongs to. + * + * @param int $comment_id Comment ID. + */ + public function purge_comment_cache($comment_id) { + $comment = get_comment($comment_id); + + if ($comment) { + $this->purge_post_cache($comment->comment_post_ID); + } + } + /** * Purge cache on heartbeat. * * @param array $response Heartbeat response. + * @param array $data Data sent with the heartbeat request. * @return array Modified response. */ - public function purge_cache_onheartbeat($response) { - if (isset($response['wp_recache_purge'])) { + public function purge_cache_onheartbeat($response, $data) { + if (isset($data['wp_recache_purge'])) { wp_recache_delete_all_cache(); + $response['wp_recache_purged'] = true; } return $response; @@ -127,8 +171,8 @@ class WP_Recache_WPCache { */ private function get_current_url() { $protocol = is_ssl() ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST']; - $uri = $_SERVER['REQUEST_URI']; + $host = $_SERVER['HTTP_HOST'] ?? ''; + $uri = $_SERVER['REQUEST_URI'] ?? '/'; return $protocol . '://' . $host . $uri; } diff --git a/includes/Common/Helpers.php b/includes/Common/Helpers.php index 1eadf00..0696fa4 100644 --- a/includes/Common/Helpers.php +++ b/includes/Common/Helpers.php @@ -58,11 +58,12 @@ function wp_recache_should_exclude() { return true; } - if (wp_recache_is_robots()) { + // Dynamic query-string requests are never cached (story 11). + if (!empty($_SERVER['QUERY_STRING'])) { return true; } - if (is_preview()) { + if (is_preview() || is_feed() || is_404()) { return true; } @@ -93,40 +94,22 @@ function wp_recache_is_robots() { * Check if request is a POST action. */ function wp_recache_is_post_action() { - return $_SERVER['REQUEST_METHOD'] === 'POST'; + return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST'; } /** * Get cache file path for URL. * - * @param string $url URL to cache. - */ -function wp_recache_get_cache_path($url) { - $path = wp_parse_url($url, PHP_URL_PATH); - $path = trim($path, '/'); - - if (empty($path)) { - $path = 'index'; - } - - $path = sanitize_file_name($path); - - return WP_RECACHE_CACHE_PATH . $path . '.html'; -} - -/** - * Get cache key for URL. + * Keyed by md5(host + path) so nested paths, hosts and multisite + * sub-directories never collide. Query strings are bypassed upstream. * * @param string $url URL to cache. */ -function wp_recache_get_cache_key($url) { - $key = wp_parse_url($url, PHP_URL_PATH); +function wp_recache_get_cache_path($url) { + $host = (string) wp_parse_url($url, PHP_URL_HOST); + $path = (string) wp_parse_url($url, PHP_URL_PATH); - if (empty($key)) { - $key = '/'; - } - - return md5($key); + return WP_RECACHE_CACHE_PATH . md5($host . $path) . '.html'; } /** @@ -214,3 +197,18 @@ function wp_recache_get_cache_path_with_device($url) { return $path; } + +/** + * Enqueue the admin-bar purge script (admin and front-end admin bar). + */ +function wp_recache_enqueue_admin_bar_script() { + if (!is_admin_bar_showing() || !current_user_can('manage_options')) { + return; + } + + wp_enqueue_script('wp-recache-admin', WP_RECACHE_URL . 'assets/admin.js', ['jquery'], WP_RECACHE_VERSION, true); + wp_localize_script('wp-recache-admin', 'wpRecache', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('wp_recache_nonce'), + ]); +} diff --git a/includes/Deactivator.php b/includes/Deactivator.php index a9f91da..af42de1 100644 --- a/includes/Deactivator.php +++ b/includes/Deactivator.php @@ -14,20 +14,38 @@ class WP_Recache_Deactivator { */ public static function deactivate() { self::remove_advanced_cache(); + self::remove_cache_server(); + WP_Recache_Activator::set_wp_cache_constant(false); self::flush_rewrite_rules(); } /** - * Remove advanced-cache.php drop-in. + * Remove advanced-cache.php drop-in, but only if it is ours. */ private static function remove_advanced_cache() { $advanced_cache = WP_CONTENT_DIR . '/advanced-cache.php'; - if (file_exists($advanced_cache)) { + if (!file_exists($advanced_cache)) { + return; + } + + $content = file_get_contents($advanced_cache); + if (strpos($content, 'WP Recache') !== false) { unlink($advanced_cache); } } + /** + * Remove the standalone cache server. + */ + private static function remove_cache_server() { + $server = WP_CONTENT_DIR . '/wp-recache-cache.php'; + + if (file_exists($server)) { + unlink($server); + } + } + /** * Flush rewrite rules. */ diff --git a/tests/test-cache.php b/tests/test-cache.php index d5ff5ac..8dee9d3 100644 --- a/tests/test-cache.php +++ b/tests/test-cache.php @@ -10,6 +10,11 @@ class WP_Recache_WPCache_Test extends WP_UnitTestCase { public function test_cache_creates_file_on_first_request() { $cache = new WP_Recache_WPCache(); + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['REQUEST_URI'] = '/test-page/'; + $_SERVER['HTTPS'] = 'off'; + unset($_SERVER['HTTP_USER_AGENT']); + $url = 'https://example.com/test-page/'; $path = wp_recache_get_cache_path($url); @@ -59,6 +64,11 @@ class WP_Recache_WPCache_Test extends WP_UnitTestCase { public function test_cache_file_is_created_with_correct_permissions() { $cache = new WP_Recache_WPCache(); + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['REQUEST_URI'] = '/test-page/'; + $_SERVER['HTTPS'] = 'off'; + unset($_SERVER['HTTP_USER_AGENT']); + $url = 'https://example.com/test-page/'; $path = wp_recache_get_cache_path($url); diff --git a/tests/test-helpers.php b/tests/test-helpers.php index 8805699..d3cea89 100644 --- a/tests/test-helpers.php +++ b/tests/test-helpers.php @@ -34,26 +34,26 @@ class WP_Recache_Helpers_Test extends WP_UnitTestCase { public function test_wp_recache_get_cache_path_returns_correct_path() { $url = 'https://example.com/test-page/'; - $expected = WP_RECACHE_CACHE_PATH . 'test-page.html'; + $expected = WP_RECACHE_CACHE_PATH . md5('example.com/test-page/') . '.html'; $this->assertEquals($expected, wp_recache_get_cache_path($url)); } public function test_wp_recache_get_cache_path_handles_root_url() { $url = 'https://example.com/'; - $expected = WP_RECACHE_CACHE_PATH . 'index.html'; + $expected = WP_RECACHE_CACHE_PATH . md5('example.com/') . '.html'; $this->assertEquals($expected, wp_recache_get_cache_path($url)); } - public function test_wp_recache_get_cache_key_returns_md5_hash() { - $url = 'https://example.com/test-page/'; - $expected = md5('/test-page/'); - $this->assertEquals($expected, wp_recache_get_cache_key($url)); + public function test_wp_recache_get_cache_path_no_collision_between_nested_urls() { + $a = wp_recache_get_cache_path('https://example.com/a/b/'); + $b = wp_recache_get_cache_path('https://example.com/ab/'); + $this->assertNotEquals($a, $b); } - public function test_wp_recache_get_cache_key_handles_root_url() { - $url = 'https://example.com/'; - $expected = md5('/'); - $this->assertEquals($expected, wp_recache_get_cache_key($url)); + public function test_wp_recache_get_cache_path_no_collision_between_hosts() { + $a = wp_recache_get_cache_path('https://site1.example.com/'); + $b = wp_recache_get_cache_path('https://site2.example.com/'); + $this->assertNotEquals($a, $b); } public function test_wp_recache_is_mobile_returns_false_for_desktop_user_agent() { diff --git a/uninstall.php b/uninstall.php index f0546ed..dbd2148 100644 --- a/uninstall.php +++ b/uninstall.php @@ -49,3 +49,9 @@ if (file_exists($advanced_cache)) { unlink($advanced_cache); } } + +// Remove the standalone cache server. +$cache_server = WP_CONTENT_DIR . '/wp-recache-cache.php'; +if (file_exists($cache_server)) { + unlink($cache_server); +} diff --git a/wp-recache.php b/wp-recache.php index a841460..61d8300 100644 --- a/wp-recache.php +++ b/wp-recache.php @@ -34,11 +34,13 @@ function wp_recache_init() { $admin->init(); } - if (WP_RECACHE_CACHE_ENABLED) { + if (wp_recache_is_cache_enabled()) { require_once WP_RECACHE_INC_PATH . 'Cache/WPCache.php'; $cache = new WP_Recache_WPCache(); $cache->init(); } + + add_action('wp_enqueue_scripts', 'wp_recache_enqueue_admin_bar_script'); } /** @@ -54,6 +56,7 @@ register_activation_hook(__FILE__, 'wp_recache_activate'); * Plugin deactivation. */ function wp_recache_deactivate() { + require_once WP_RECACHE_INC_PATH . 'Activator.php'; require_once WP_RECACHE_INC_PATH . 'Deactivator.php'; WP_Recache_Deactivator::deactivate(); }