From f0285f0df67802405ea0e146a8b235d19821198f Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 15 Sep 2025 15:47:46 +0530 Subject: [PATCH 1/4] feat: Add Cloudflare edge cache purging via cache tags - Integrate Cloudflare edge cache purging using cache tags. - Replace Fastly logic with Cloudflare SDK and custom tag emission. - Add admin UI, WP-CLI commands, and hooks for Cloudflare cache management. - Credits: pantheon-systems/pantheon-advanced-page-cache for architectural inspiration. --- .editorconfig | 23 + README.md | 4 + admin/class-nginx-helper-admin.php | 255 +++++- .../partials/easycache-cloudflare-options.php | 133 +++ admin/partials/nginx-helper-admin-display.php | 3 + composer.json | 39 +- composer.lock | 798 ++++++++++++++++-- includes/class-cli.php | 101 +++ includes/class-cloudflare-client.php | 206 +++++ includes/class-cloudflare-purger.php | 436 ++++++++++ includes/class-cloudflare-tag-emitter.php | 462 ++++++++++ includes/class-nginx-helper.php | 86 ++ nginx-helper.php | 10 +- utils/autoloader.php | 29 + utils/functions.php | 29 + 15 files changed, 2500 insertions(+), 114 deletions(-) create mode 100644 .editorconfig create mode 100644 admin/partials/easycache-cloudflare-options.php create mode 100644 includes/class-cli.php create mode 100644 includes/class-cloudflare-client.php create mode 100644 includes/class-cloudflare-purger.php create mode 100644 includes/class-cloudflare-tag-emitter.php create mode 100644 utils/autoloader.php create mode 100644 utils/functions.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e34b3b07 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +# WordPress Coding Standards +# https://make.wordpress.org/core/handbook/coding-standards/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +indent_size = 4 + +[{.jshintrc,*.json,*.yml}] +indent_style = space +indent_size = 2 + +[{*.txt,wp-config-sample.php}] +end_of_line = crlf + diff --git a/README.md b/README.md index 1eac0b64..0780b38a 100644 --- a/README.md +++ b/README.md @@ -587,6 +587,10 @@ Fix url escaping [#82](https://github.com/rtCamp/nginx-helper/pull/82) - by * First release +## Credits ## + +This plugin’s Cloudflare edge cache purging and cache tag architecture is inspired by the excellent work in [pantheon-systems/pantheon-advanced-page-cache](https://github.com/pantheon-systems/pantheon-advanced-page-cache). + ## Upgrade Notice ## ### 2.2.3 ### diff --git a/admin/class-nginx-helper-admin.php b/admin/class-nginx-helper-admin.php index f9e13a91..db279e7d 100644 --- a/admin/class-nginx-helper-admin.php +++ b/admin/class-nginx-helper-admin.php @@ -9,6 +9,8 @@ * @subpackage nginx-helper/admin */ +use EasyCache\Cloudflare_Client; + /** * The admin-specific functionality of the plugin. * @@ -56,7 +58,16 @@ class Nginx_Helper_Admin { * @var string[] $options Purge options. */ public $options; - + + /** + * Purge options. + * + * @since 2.0.0 + * @access public + * @var string[] $options Cloudflare options. + */ + public $cf_options; + /** * WP-CLI Command. * @@ -74,11 +85,12 @@ class Nginx_Helper_Admin { * @param string $version The version of this plugin. */ public function __construct( $plugin_name, $version ) { - + $this->plugin_name = $plugin_name; $this->version = $version; - - $this->options = $this->nginx_helper_settings(); + + $this->options = $this->nginx_helper_settings(); + $this->cf_options = $this->get_cloudflare_settings(); } /** @@ -86,22 +98,26 @@ public function __construct( $plugin_name, $version ) { * Required since i18n is used in the settings tab which can be invoked only after init hook since WordPress 6.7 */ public function initialize_setting_tab() { - + /** * Define settings tabs */ $this->settings_tabs = apply_filters( - 'rt_nginx_helper_settings_tabs', - array( - 'general' => array( - 'menu_title' => __( 'General', 'nginx-helper' ), - 'menu_slug' => 'general', - ), - 'support' => array( - 'menu_title' => __( 'Support', 'nginx-helper' ), - 'menu_slug' => 'support', - ), - ) + 'rt_nginx_helper_settings_tabs', + array( + 'general' => array( + 'menu_title' => __( 'General', 'nginx-helper' ), + 'menu_slug' => 'general', + ), + 'support' => array( + 'menu_title' => __( 'Support', 'nginx-helper' ), + 'menu_slug' => 'support', + ), + 'cloudflare' => array( + 'menu_title' => __( 'Cloudflare', 'nginx-helper' ), + 'menu_slug' => 'cloudflare', + ), + ) ); } @@ -299,28 +315,81 @@ public function nginx_helper_default_settings() { ); } - - public function store_default_options() { - $options = get_site_option( 'rt_wp_nginx_helper_options', array() ); - $default_settings = $this->nginx_helper_default_settings(); - - $removable_default_settings = array( - 'redis_port', - 'redis_prefix', - 'redis_hostname', - 'redis_database', - 'redis_unix_socket' - ); - - // Remove all the keys that are not to be stored by default. - foreach ( $removable_default_settings as $removable_key ) { - unset( $default_settings[ $removable_key ] ); - } - - $diffed_options = wp_parse_args( $options, $default_settings ); - - add_site_option( 'rt_wp_nginx_helper_options', $diffed_options ); - } + + public function store_default_options() { + $options = get_site_option( 'rt_wp_nginx_helper_options', array() ); + $default_settings = $this->nginx_helper_default_settings(); + + $removable_default_settings = array( + 'redis_port', + 'redis_prefix', + 'redis_hostname', + 'redis_database', + 'redis_unix_socket' + ); + + // Remove all the keys that are not to be stored by default. + foreach ( $removable_default_settings as $removable_key ) { + unset( $default_settings[ $removable_key ] ); + } + + $diffed_options = wp_parse_args( $options, $default_settings ); + + add_site_option( 'rt_wp_nginx_helper_options', $diffed_options ); + + $this->store_cloudflare_settings(); + } + + /** + * Gets the default settings for cloudflare. + * + * @return array An array of settings. + */ + public function get_cloudflare_default_settings() { + return array( + 'api_token' => '', + 'zone_id' => '', + 'default_cache_ttl' => 604800, + 'api_token_enabled_by_constant' => false, + ); + } + + /** + * Gets the current cloudflare settings. + * + * @return array The current settings. + */ + public function get_cloudflare_settings() { + $default_settings = $this->get_cloudflare_default_settings(); + + $stored_options = get_site_option( 'easycache_cf_settings', array() ); + + if ( defined( 'EASYCACHE_CLOUDFLARE_API_TOKEN' ) && !empty( EASYCACHE_CLOUDFLARE_API_TOKEN ) ) { + $stored_options['api_token'] = EASYCACHE_CLOUDFLARE_API_TOKEN; + $stored_options['api_token_enabled_by_constant'] = true; + } + + $diff_options = wp_parse_args( $stored_options, $default_settings ); + + $diff_options['is_enabled'] = ! empty( $diff_options['api_token'] ) && ! empty( $diff_options['zone_id'] ); + + return $diff_options; + } + + /** + * Stores the cloudflare settings. + * + * @return array The current settings. + */ + public function store_cloudflare_settings() { + $default_settings = $this->get_cloudflare_default_settings(); + + $stored_options = get_site_option( 'easycache_cf_settings', array() ); + + $diff_options = wp_parse_args( $stored_options, $default_settings ); + + add_site_option( 'easycache_cf_settings', $diff_options ); + } /** * Get settings. @@ -365,7 +434,7 @@ public function nginx_helper_settings() { $data['cache_method'] = 'enable_redis'; $data['redis_hostname'] = RT_WP_NGINX_HELPER_REDIS_HOSTNAME; $data['redis_port'] = RT_WP_NGINX_HELPER_REDIS_PORT; - $data['redis_prefix'] = RT_WP_NGINX_HELPER_REDIS_PREFIX; + $data['redis_prefix'] = RT_WP_NGINX_HELPER_REDIS_PREFIX; $data['redis_database'] = defined('RT_WP_NGINX_HELPER_REDIS_DATABASE') ? RT_WP_NGINX_HELPER_REDIS_DATABASE : 0; return $data; @@ -821,6 +890,10 @@ public function purge_all() { do_action( 'rt_nginx_helper_after_purge_all' ); } + + if( $this->cf_options['is_enabled'] ) { + Cloudflare_Client::purgeEverything(); + } wp_redirect( esc_url_raw( $redirect_url ) ); exit(); @@ -1128,5 +1201,107 @@ public function purge_product_cache_on_update( $product_id ) { $nginx_purger->purge_url( $product_url ); } } - + + /** + * Handles the page rule update on Cloudflare tab. + * + * @return void + */ + public function handle_cf_page_rule_update() { + $nonce = isset( $_POST['easycache_add_page_rule_nonce'] ) ? wp_unslash( $_POST['easycache_add_page_rule_nonce'] ) : ''; + if ( wp_verify_nonce( $nonce, 'easycache_add_page_rule_nonce' ) ) { + + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $result = EasyCache\Cloudflare_Client::setupCacheEverythingPageRule(); + if( $result ) { + set_transient( 'ec_page_rule_save_state_admin_notice', $result, 60 ); + } + } + } + + /** + * Display admin notices for cloudflare page save rules. + */ + public function cf_page_rule_save_display_admin_notices() { + if ( $result = get_transient( 'ec_page_rule_save_state_admin_notice' ) ) { + $class = 'notice'; + $message = ''; + + switch ( $result ) { + case 'created': + $class .= ' notice-success'; + $message = __( 'The Cloudflare "Cache Everything" Page Rule was created successfully.', 'nginx-helper' ); + break; + case 'exists': + $class .= ' notice-info'; + $message = __( 'The "Cache Everything" Page Rule already exists. No action was taken.', 'nginx-helper' ); + break; + default: + $class .= ' notice-error'; + $message = __( 'Failed to create the Page Rule. Please check that your API Token has Page Rules Read/Write permissions.', 'nginx-helper' ); + break; + } + + printf( '

%2$s

', esc_attr( $class ), esc_html( $message ) ); + delete_transient( 'ec_page_rule_save_state_admin_notice' ); + } + } + + /** + * Register a toolbar button to purge the cache for the current page. + * + * @param object $wp_admin_bar Instance of WP_Admin_Bar. + */ + public static function add_cloudflare_admin_bar_purge( $wp_admin_bar ) { + if ( is_admin() || ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) { + return; + } + + if ( ! empty( $_GET['message'] ) && 'ec-cleared-url-cache' === $_GET['message'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $title = esc_html__( 'URL Cache Cleared', 'easycache' ); + } else { + $title = esc_html__( 'Clear URL Cache', 'easycache' ); + } + + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( $_SERVER['REQUEST_URI'] ) : ''; + $wp_admin_bar->add_menu( [ + 'parent' => '', + 'id' => 'clear-page-cache', + 'title' => $title, + 'meta' => [ + 'title' => __( 'Purge the current URL from Cloudflare cache.', 'easycache' ), + ], + 'href' => wp_nonce_url( admin_url( 'admin-ajax.php?action=ec_clear_url_cache&path=' . rawurlencode( home_url( $request_uri ) ) ), 'ec-clear-url-cache' ), + ] ); + } + + /** + * Handle an admin-ajax request to clear the URL cache for Cloudflare. + * + * @return void + */ + public static function handle_cloudflare_clear_cache_ajax() { + $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( $_GET['_wpnonce'] ) : ''; + if ( empty( $nonce ) + || ! wp_verify_nonce( $nonce, 'ec-clear-url-cache' ) + || ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( "You shouldn't be doing this.", 'easycache' ) ); + } + + $path = isset( $_GET['path'] ) ? esc_url_raw( $_GET['path'] ) : ''; + if ( empty( $path ) ) { + wp_die( esc_html__( 'No path provided.', 'easycache' ) ); + } + + $ret = Cloudflare_Client::purgeByUrls( [ $path ] ); + if ( ! $ret ) { + wp_die( esc_html__( 'Failed to clear URL cache.', 'easycache' ) ); + } + + wp_safe_redirect( add_query_arg( 'message', 'ec-cleared-url-cache', $path ) ); + exit; + } } diff --git a/admin/partials/easycache-cloudflare-options.php b/admin/partials/easycache-cloudflare-options.php new file mode 100644 index 00000000..c81fb21a --- /dev/null +++ b/admin/partials/easycache-cloudflare-options.php @@ -0,0 +1,133 @@ +get_cloudflare_default_settings(); + + $args = wp_parse_args( $all_inputs, $default_args ); + + update_site_option( 'easycache_cf_settings', $args ); + + echo '

' . esc_html__( 'Settings saved.', 'nginx-helper' ) . '

'; +} + + +$ec_site_settings = $nginx_helper_admin->get_cloudflare_settings(); +?> + +
+
+ + +
+

+
+ + + + + + + + + + + + + + + +
+

+
+
+ + + +
+ + name="api_token" id="cf_api_token" type="password" class="password-input" + value=""/> + + + + +

+

+
+

+
+
+ + + +
+ +
+

+
+
+ + + +
+ +

+
+
+ +
+ +
+
+ + + diff --git a/admin/partials/nginx-helper-admin-display.php b/admin/partials/nginx-helper-admin-display.php index bd153714..44251dbd 100644 --- a/admin/partials/nginx-helper-admin-display.php +++ b/admin/partials/nginx-helper-admin-display.php @@ -49,6 +49,9 @@ case 'support': include plugin_dir_path( __FILE__ ) . 'nginx-helper-support-options.php'; break; + case 'cloudflare': + include plugin_dir_path( __FILE__ ) . 'easycache-cloudflare-options.php'; + break; } ?> diff --git a/composer.json b/composer.json index f215446d..fcb7e634 100644 --- a/composer.json +++ b/composer.json @@ -1,17 +1,32 @@ { "name": "rtcamp/nginx-helper", - "description": "Cleans nginx's fastcgi/proxy cache or redis-cache whenever a post is edited/published. Also does a few more things.", - "keywords": ["wordpress", "plugin", "nginx", "nginx-helper", "fastcgi", "redis-cache", "redis", "cache"], + "type": "wordpress-plugin", + "description": "Cleans nginx's fastcgi/proxy cache or redis-cache whenever a post is edited/published. Also provides cloudflare edge cache purging with Cache-Tags.", + "keywords": [ + "wordpress", + "plugin", + "nginx", + "nginx-helper", + "fastcgi", + "redis-cache", + "redis", + "cache", + "cloudflare", + "cloudflare-edge-cache", + "cache-tags", + "easycache" + ], "homepage": "https://rtcamp.com/nginx-helper/", "license": "GPL-2.0+", - "authors": [{ - "name": "rtCamp", - "email": "support@rtcamp.com", - "homepage": "https://rtcamp.com" - }], + "authors": [ + { + "name": "rtCamp", + "email": "support@rtcamp.com", + "homepage": "https://rtcamp.com" + } + ], "minimum-stability": "dev", "prefer-stable": true, - "type": "wordpress-plugin", "support": { "issues": "https://github.com/rtCamp/nginx-helper/issues", "forum": "https://wordpress.org/support/plugin/nginx-helper", @@ -20,9 +35,15 @@ }, "require": { "php": ">=5.3.2", - "composer/installers": "^1.0" + "composer/installers": "^1.0", + "cloudflare/sdk": "^1.1" }, "require-dev": { "wpreadme2markdown/wpreadme2markdown": "*" + }, + "config": { + "allow-plugins": { + "composer/installers": true + } } } diff --git a/composer.lock b/composer.lock index 589b0711..1e4cbd29 100644 --- a/composer.lock +++ b/composer.lock @@ -1,42 +1,98 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "hash": "f8ee8d46fadaee8c9cc194ef126e7404", + "content-hash": "9a39933154bb8e65621b25ca590a1e84", "packages": [ + { + "name": "cloudflare/sdk", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/cloudflare/cloudflare-php.git", + "reference": "2d3f198773e865b5de2357d7bdbc52bdf42e8f97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cloudflare/cloudflare-php/zipball/2d3f198773e865b5de2357d7bdbc52bdf42e8f97", + "reference": "2d3f198773e865b5de2357d7bdbc52bdf42e8f97", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^7.0.1", + "php": ">=7.2.5", + "psr/http-message": "~1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.6", + "phpmd/phpmd": "@stable", + "phpunit/phpunit": "^5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cloudflare\\API\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Junade Ali", + "email": "junade@cloudflare.com" + } + ], + "description": "PHP binding for v4 of the Cloudflare Client API.", + "support": { + "issues": "https://github.com/cloudflare/cloudflare-php/issues", + "source": "https://github.com/cloudflare/cloudflare-php/tree/1.4.0" + }, + "time": "2024-12-17T23:18:20+00:00" + }, { "name": "composer/installers", - "version": "v1.0.6", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/composer/installers.git", - "reference": "b3bd071ea114a57212c75aa6a2eef5cfe0cc798f" + "reference": "d20a64ed3c94748397ff5973488761b22f6d3f19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/installers/zipball/b3bd071ea114a57212c75aa6a2eef5cfe0cc798f", - "reference": "b3bd071ea114a57212c75aa6a2eef5cfe0cc798f", + "url": "https://api.github.com/repos/composer/installers/zipball/d20a64ed3c94748397ff5973488761b22f6d3f19", + "reference": "d20a64ed3c94748397ff5973488761b22f6d3f19", "shasum": "" }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, "replace": { + "roundcube/plugin-installer": "*", "shama/baton": "*" }, "require-dev": { - "composer/composer": "1.0.*@dev", - "phpunit/phpunit": "3.7.*" + "composer/composer": "1.6.* || ^2.0", + "composer/semver": "^1 || ^3", + "phpstan/phpstan": "^0.12.55", + "phpstan/phpstan-phpunit": "^0.12.16", + "symfony/phpunit-bridge": "^4.2 || ^5", + "symfony/process": "^2.3" }, - "type": "composer-installer", + "type": "composer-plugin", "extra": { - "class": "Composer\\Installers\\Installer", + "class": "Composer\\Installers\\Plugin", "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "1.x-dev" } }, "autoload": { - "psr-0": { - "Composer\\Installers\\": "src/" + "psr-4": { + "Composer\\Installers\\": "src/Composer/Installers" } }, "notification-url": "https://packagist.org/downloads/", @@ -47,77 +103,512 @@ { "name": "Kyle Robinson Young", "email": "kyle@dontkry.com", - "homepage": "https://github.com/shama", - "role": "Developer" + "homepage": "https://github.com/shama" } ], "description": "A multi-framework Composer library installer", - "homepage": "http://composer.github.com/installers/", + "homepage": "https://composer.github.io/installers/", "keywords": [ - "TYPO3 CMS", - "TYPO3 Flow", - "TYPO3 Neos", + "Craft", + "Dolibarr", + "Eliasis", + "Hurad", + "ImageCMS", + "Kanboard", + "Lan Management System", + "MODX Evo", + "MantisBT", + "Mautic", + "Maya", + "OXID", + "Plentymarkets", + "Porto", + "RadPHP", + "SMF", + "Starbug", + "Thelia", + "Whmcs", + "WolfCMS", "agl", + "aimeos", + "annotatecms", + "attogram", + "bitrix", "cakephp", + "chef", + "cockpit", "codeigniter", + "concrete5", + "croogo", + "dokuwiki", "drupal", + "eZ Platform", + "elgg", + "expressionengine", "fuelphp", + "grav", "installer", + "itop", "joomla", + "known", "kohana", "laravel", - "li3", + "lavalite", "lithium", + "magento", + "majima", "mako", + "mediawiki", + "miaoxing", "modulework", + "modx", + "moodle", + "osclass", + "pantheon", "phpbb", + "piwik", "ppi", + "processwire", + "puppet", + "pxcms", + "reindex", + "roundcube", + "shopware", "silverstripe", + "sydes", + "sylius", "symfony", + "tastyigniter", + "typo3", "wordpress", - "zend" + "yawik", + "zend", + "zikula" ], - "time": "2013-08-20 04:37:09" - } - ], - "packages-dev": [ + "support": { + "issues": "https://github.com/composer/installers/issues", + "source": "https://github.com/composer/installers/tree/v1.12.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2021-09-13T08:19:44+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, { - "name": "symfony/console", - "version": "v2.7.1", + "name": "guzzlehttp/psr7", + "version": "2.8.0", "source": { "type": "git", - "url": "https://github.com/symfony/Console.git", - "reference": "564398bc1f33faf92fc2ec86859983d30eb81806" + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/Console/zipball/564398bc1f33faf92fc2ec86859983d30eb81806", - "reference": "564398bc1f33faf92fc2ec86859983d30eb81806", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { - "php": ">=5.3.9" + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" }, "require-dev": { - "psr/log": "~1.0", - "symfony/event-dispatcher": "~2.1", - "symfony/phpunit-bridge": "~2.7", - "symfony/process": "~2.1" + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { - "psr/log": "For using the console logger", - "symfony/event-dispatcher": "", - "symfony/process": "" + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.7-dev" + "dev-master": "1.0.x-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\Console\\": "" + "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -126,39 +617,213 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "1.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/1.1" + }, + "time": "2023-04-04T09:50:52+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Console Component", + "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", - "time": "2015-06-10 15:30:22" - }, + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + } + ], + "packages-dev": [ { "name": "wpreadme2markdown/wpreadme2markdown", - "version": "2.0.0", + "version": "4.1.1", "source": { "type": "git", - "url": "https://github.com/benbalter/WP-Readme-to-Github-Markdown.git", - "reference": "dceae108111232949affc9107c98276c6fa6c98f" + "url": "https://github.com/wpreadme2markdown/wp-readme-to-markdown.git", + "reference": "abe788b2a15d13073e47100f8a5312b8175e78a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/benbalter/WP-Readme-to-Github-Markdown/zipball/dceae108111232949affc9107c98276c6fa6c98f", - "reference": "dceae108111232949affc9107c98276c6fa6c98f", + "url": "https://api.github.com/repos/wpreadme2markdown/wp-readme-to-markdown/zipball/abe788b2a15d13073e47100f8a5312b8175e78a7", + "reference": "abe788b2a15d13073e47100f8a5312b8175e78a7", "shasum": "" }, "require": { - "php": ">= 5.3.3", - "symfony/console": "~2.4" + "guzzlehttp/guzzle": "^7.3", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "squizlabs/php_codesniffer": "*" }, - "bin": [ - "bin/wp2md" - ], "type": "library", "autoload": { "psr-4": { @@ -171,11 +836,11 @@ ], "authors": [ { - "name": "Christian Archer", - "email": "chrstnarchr@aol.com" + "name": "Benjamin J. Balter" }, { - "name": "Benjamin J. Balter" + "name": "Christian Archer", + "email": "sunchaser@sunchaser.info" } ], "description": "Convert WordPress Plugin readme.txt to Markdown", @@ -185,16 +850,21 @@ "readme", "wordpress" ], - "time": "2014-05-28 21:28:31" + "support": { + "issues": "https://github.com/wpreadme2markdown/wp-readme-to-markdown/issues", + "source": "https://github.com/wpreadme2markdown/wp-readme-to-markdown/tree/4.1.1" + }, + "time": "2024-12-16T19:44:24+00:00" } ], "aliases": [], - "minimum-stability": "stable", - "stability-flags": [], - "prefer-stable": false, + "minimum-stability": "dev", + "stability-flags": {}, + "prefer-stable": true, "prefer-lowest": false, "platform": { "php": ">=5.3.2" }, - "platform-dev": [] + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/includes/class-cli.php b/includes/class-cli.php new file mode 100644 index 00000000..21cc8c4a --- /dev/null +++ b/includes/class-cli.php @@ -0,0 +1,101 @@ +... + * : One or more cache tags. + * + * ## EXAMPLES + * + * # Purge the 'post-1' cache tag from Cloudflare. + * $ wp cloudflare cache purge-tag post-1 + * Success: Purged tag. + * + * @subcommand purge-tag + */ + public function purge_tag( $args ) { + $ret = Cloudflare_Client::purgeByTags( $args ); + if ( ! $ret ) { + WP_CLI::error( 'Failed to purge tags.' ); + } else { + $message = count( $args ) > 1 ? 'Purged tags.' : 'Purged tag.'; + WP_CLI::success( $message ); + } + } + + /** + * Purge one or more paths from Cloudflare. + * + * ## OPTIONS + * + * ... + * : One or more paths. + * + * ## EXAMPLES + * + * # Purge the homepage from Cloudflare. + * $ wp cloudflare cache purge-path '/' + * Success: Purged path. + * + * @subcommand purge-path + */ + public function purge_path( $args ) { + $ret = Cloudflare_Client::purgeByUrls( $args ); + if ( ! $ret ) { + WP_CLI::error( 'Failed to purge paths.' ); + } else { + $message = count( $args ) > 1 ? 'Purged paths.' : 'Purged path.'; + WP_CLI::success( $message ); + } + } + + /** + * Purge the entire Cloudflare cache for the zone. + * + * WARNING! Purging the entire page cache can have a severe performance + * impact on a high-traffic site. We encourage you to explore other options + * first. + * + * ## OPTIONS + * + * [--yes] + * : Answer yes to the confirmation message. + * + * ## EXAMPLES + * + * # Purging the entire page cache will display a confirmation prompt. + * $ wp cloudflare cache purge-all + * Are you sure you want to purge the entire page cache? [y/n] y + * Success: Purged page cache. + * + * @subcommand purge-all + */ + public function purge_all( $_, $assoc_args ) { + WP_CLI::confirm( 'Are you sure you want to purge the entire page cache?', $assoc_args ); + $ret = Cloudflare_Client::purgeEverything(); + if ( ! $ret ) { + WP_CLI::error( 'Failed to purge all.' ); + } else { + WP_CLI::success( 'Purged page cache.' ); + } + } +} diff --git a/includes/class-cloudflare-client.php b/includes/class-cloudflare-client.php new file mode 100644 index 00000000..d4b8e3e4 --- /dev/null +++ b/includes/class-cloudflare-client.php @@ -0,0 +1,206 @@ +cachePurge( $zone_id, null, $tags, null ); + + if ( $result ) { + error_log( 'Advanced Cloudflare Cache: Successfully purged by tags: ' . implode( ', ', $tags ) ); + + return true; + } else { + error_log( 'Advanced Cloudflare Cache: Failed to purge by tags: ' . implode( ', ', $tags ) ); + + return false; + } + } catch ( Exception $e ) { + error_log( 'Advanced Cloudflare Cache: Exception when purging by tags: ' . $e->getMessage() ); + + return false; + } + } + + /** + * Purge the entire cache for the zone. + * + * @return bool True on success, false on failure. + */ + public static function purgeEverything() { + $options = get_option( 'easycache_cf_settings' ); + $token = isset( $options['api_token'] ) ? $options['api_token'] : ''; + $zone_id = isset( $options['zone_id'] ) ? $options['zone_id'] : ''; + + if ( empty( $token ) || empty( $zone_id ) ) { + error_log( 'Advanced Cloudflare Cache: API Token or Zone ID not configured.' ); + + return false; + } + + try { + $key = new APIToken( $token ); + $adapter = new Guzzle( $key ); + $zones = new Zones( $adapter ); + + $result = $zones->cachePurgeEverything( $zone_id ); + + if ( $result ) { + error_log( 'Advanced Cloudflare Cache: Successfully purged everything.' ); + + return true; + } else { + error_log( 'Advanced Cloudflare Cache: Failed to purge everything.' ); + + return false; + } + } catch ( Exception $e ) { + error_log( 'Advanced Cloudflare Cache: Exception when purging everything: ' . $e->getMessage() ); + + return false; + } + } + + /** + * Purge the cache for a given set of URLs. + * + * @param array $urls The URLs to purge. + * + * @return bool True on success, false on failure. + */ + public static function purgeByUrls( array $urls ) { + if ( empty( $urls ) ) { + return false; + } + + $options = get_option( 'easycache_cf_settings' ); + $token = isset( $options['api_token'] ) ? $options['api_token'] : ''; + $zone_id = isset( $options['zone_id'] ) ? $options['zone_id'] : ''; + + if ( empty( $token ) || empty( $zone_id ) ) { + error_log( 'Advanced Cloudflare Cache: API Token or Zone ID not configured.' ); + + return false; + } + + try { + $key = new APIToken( $token ); + $adapter = new Guzzle( $key ); + $zones = new Zones( $adapter ); + + $result = $zones->cachePurge( $zone_id, $urls, null, null ); + + if ( $result ) { + error_log( 'Advanced Cloudflare Cache: Successfully purged by URLs: ' . implode( ', ', $urls ) ); + + return true; + } else { + error_log( 'Advanced Cloudflare Cache: Failed to purge by URLs: ' . implode( ', ', $urls ) ); + + return false; + } + } catch ( Exception $e ) { + error_log( 'Advanced Cloudflare Cache: Exception when purging by URLs: ' . $e->getMessage() ); + + return false; + } + } + + /** + * Sets up the "Cache Everything" Page Rule in Cloudflare. + * + * @return string|false 'created', 'exists', or false on failure. + */ + public static function setupCacheEverythingPageRule() { + $options = get_option( 'easycache_cf_settings' ); + $token = isset( $options['api_token'] ) ? $options['api_token'] : ''; + $zone_id = isset( $options['zone_id'] ) ? $options['zone_id'] : ''; + + if ( empty( $token ) || empty( $zone_id ) ) { + error_log( 'Advanced Cloudflare Cache: API Token or Zone ID not configured.' ); + + return false; + } + + try { + $key = new APIToken( $token ); + $adapter = new Guzzle( $key ); + $pageRules = new PageRules( $adapter ); + + $rules = $pageRules->listPageRules( $zone_id ); + $rule_url = home_url() . '/*'; + + foreach ( $rules->result as $rule ) { + if ( ! empty( $rule->targets ) ) { + foreach ( $rule->targets as $target ) { + if ( $target->target === 'url' && $target->constraint->operator === 'matches' && $target->constraint->value === $rule_url ) { + if ( ! empty( $rule->actions ) ) { + foreach ( $rule->actions as $action ) { + if ( $action->id === 'cache_level' && $action->value === 'cache_everything' ) { + return 'exists'; + } + } + } + } + } + } + } + + $config = new PageRulesConfig( $rule_url, 'cache_everything' ); + $config->setPriority( 1 ); + + if ( $pageRules->createPageRule( $zone_id, $config ) ) { + return 'created'; + } + + return false; + + } catch ( Exception $e ) { + error_log( 'Advanced Cloudflare Cache: Exception when setting up Page Rule: ' . $e->getMessage() ); + + return false; + } + } +} diff --git a/includes/class-cloudflare-purger.php b/includes/class-cloudflare-purger.php new file mode 100644 index 00000000..be7cccf3 --- /dev/null +++ b/includes/class-cloudflare-purger.php @@ -0,0 +1,436 @@ +post_status ) { + return; + } + self::purge_post_with_related( $post ); + } + + /** + * Purge cache tags associated with a post being published or unpublished. + * + * @param string $new_status New status for the post. + * @param string $old_status Old status for the post. + * @param WP_Post $post Post object. + */ + public function action_transition_post_status( $new_status, $old_status, $post ) { + if ( 'publish' !== $new_status && 'publish' !== $old_status ) { + return; + } + self::purge_post_with_related( $post ); + if ( 'publish' === $old_status ) { + return; + } + // Targets 404 pages that could be cached with no cache tags (i.e. + // a drafted post going live after the 404 has been cached). + self::clear_post_path( $post ); + } + + + /** + * Purge the cache for a given post's path + * + * @param WP_Post $post Post object. + * + * @since 1.0.0 + */ + public function clear_post_path( $post ) { + $post_path = get_permalink( $post->ID ); + $parsed_url = parse_url( $post_path ); + $path = $parsed_url['path']; + $paths = [ trailingslashit( $path ), untrailingslashit( $path ) ]; + + /** + * Paths possibly without cache tags purges + * + * @param array $paths paths to clear. + */ + $paths = apply_filters( 'ec_clear_post_path', $paths ); + Cloudflare_Client::purgeByUrls( $paths ); + } + + /** + * Purge cache tags associated with a post being deleted. + * + * @param integer $post_id ID for the post to be deleted. + */ + public function action_before_delete_post( $post_id ) { + $post = get_post( $post_id ); + self::purge_post_with_related( $post ); + } + + /** + * Purge cache tags associated with an attachment being deleted. + * + * @param integer $post_id ID for the modified attachment. + */ + public function action_delete_attachment( $post_id ) { + $post = get_post( $post_id ); + self::purge_post_with_related( $post ); + } + + /** + * Purge the post's cache tag when the post cache is cleared. + * + * @param integer $post_id ID for the modified post. + */ + public function action_clean_post_cache( $post_id ) { + $type = get_post_type( $post_id ); + + /** + * Allow specific post types to ignore the purge process. + * + * @param array $ignored_post_types Post types to ignore. + * + * @return array + * @since 1.0.0 + */ + $ignored_post_types = apply_filters( 'ec_purge_post_type_ignored', [ 'revision' ] ); + + if ( $type && in_array( $type, $ignored_post_types, true ) ) { + return; + } + + $keys = [ + 'post-' . $post_id, + 'rest-post-' . $post_id, + 'post-huge', + 'rest-post-huge', + ]; + + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + /** + * cache tags purged when clearing post cache. + * + * @param array $keys cache tags. + * @param array $post_id ID for purged post. + */ + $keys = apply_filters( 'ec_purge_clean_post_cache', $keys, $post_id ); + Cloudflare_Client::purgeByTags( $keys ); + } + + /** + * Purge cache tags associated with a term being created. + * + * @param integer $term_id ID for the created term. + * @param int $tt_id Term taxonomy ID. + * @param string $taxonomy Taxonomy slug. + */ + public function action_created_term( $term_id, $tt_id, $taxonomy ) { + self::purge_term( $term_id ); + $keys = [ 'rest-' . $taxonomy . '-collection' ]; + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + /** + * cache tags purged when creating a new term. + * + * @param array $keys cache tags. + * @param array $term_id ID for new term. + * @param array $tt_id Term taxonomy ID for new term. + * @param string $taxonomy Taxonomy for the new term. + */ + $keys = apply_filters( 'ec_purge_create_term', $keys, $term_id, $tt_id, $taxonomy ); + Cloudflare_Client::purgeByTags( $keys ); + } + + /** + * Purge cache tags associated with a term being edited. + * + * @param integer $term_id ID for the edited term. + */ + public function action_edited_term( $term_id ) { + self::purge_term( $term_id ); + } + + /** + * Purge cache tags associated with a term being deleted. + * + * @param integer $term_id ID for the deleted term. + */ + public function action_delete_term( $term_id ) { + self::purge_term( $term_id ); + } + + /** + * Purge the term's archive cache tag when the term is modified. + * + * @param integer $term_ids One or more IDs of modified terms. + */ + public function action_clean_term_cache( $term_ids ) { + $keys = []; + $term_ids = is_array( $term_ids ) ? $term_ids : [ $term_ids ]; + foreach ( $term_ids as $term_id ) { + $keys[] = 'term-' . $term_id; + $keys[] = 'rest-term-' . $term_id; + } + $keys[] = 'term-huge'; + $keys[] = 'rest-term-huge'; + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + /** + * cache tags purged when clearing term cache. + * + * @param array $keys cache tags. + * @param array $term_ids IDs for purged terms. + */ + $keys = apply_filters( 'ec_purge_clean_term_cache', $keys, $term_ids ); + Cloudflare_Client::purgeByTags( $keys ); + } + + /** + * Purge cache tags when an approved comment is updated. + * + * @param integer $id The comment ID. + * @param WP_Comment $comment Comment object. + */ + public function action_wp_insert_comment( $id, $comment ) { + if ( 1 !== (int) $comment->comment_approved ) { + return; + } + $keys = [ + 'rest-comment-' . $comment->comment_ID, + 'rest-comment-collection', + 'rest-comment-huge', + ]; + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + /** + * cache tags purged when inserting a new comment. + * + * @param array $keys cache tags. + * @param integer $id Comment ID. + * @param WP_Comment $comment Comment to be inserted. + */ + $keys = apply_filters( 'ec_purge_insert_comment', $keys, $id, $comment ); + Cloudflare_Client::purgeByTags( $keys ); + } + + /** + * Purge cache tags when a comment is approved or unapproved. + * + * @param int|string $new_status The new comment status. + * @param int|string $old_status The old comment status. + * @param object $comment The comment data. + */ + public function action_transition_comment_status( $new_status, $old_status, $comment ) { + $keys = [ + 'rest-comment-' . $comment->comment_ID, + 'rest-comment-collection', + 'rest-comment-huge', + ]; + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + /** + * cache tags purged when transitioning a comment status. + * + * @param array $keys cache tags. + * @param string $new_status New comment status. + * @param string $old_status Old comment status. + * @param WP_Comment $comment Comment being transitioned. + */ + $keys = apply_filters( 'ec_purge_transition_comment_status', $keys, $new_status, $old_status, $comment ); + Cloudflare_Client::purgeByTags( $keys ); + } + + /** + * Purge the comment's cache tag when the comment is modified. + * + * @param integer $comment_id Modified comment id. + */ + public function action_clean_comment_cache( $comment_id ) { + $keys = [ + 'rest-comment-' . $comment_id, + 'rest-comment-huge', + ]; + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + /** + * cache tags purged when cleaning comment cache. + * + * @param array $keys cache tags. + * @param integer $id Comment ID. + */ + $keys = apply_filters( 'ec_purge_clean_comment_cache', $keys, $comment_id ); + Cloudflare_Client::purgeByTags( $keys ); + } + + /** + * Purge the cache tags associated with a post being modified. + * + * @param object $post Object representing the modified post. + */ + private function purge_post_with_related( $post ) { + /** + * Allow specific post types to ignore the purge process. + * + * @param array $ignored_post_types Post types to ignore. + * + * @return array + * @since 1.0.0 + */ + $ignored_post_types = apply_filters( 'ec_purge_post_type_ignored', [ 'revision' ] ); + + if ( in_array( $post->post_type, $ignored_post_types, true ) ) { + return; + } + + $keys = [ + 'post-' . $post->ID, + $post->post_type . '-archive', + 'rest-' . $post->post_type . '-collection', + 'home', + 'front', + '404', + 'feed', + 'post-huge', + ]; + + if ( post_type_supports( $post->post_type, 'author' ) ) { + $keys[] = 'user-' . $post->post_author; + $keys[] = 'user-huge'; + } + + if ( post_type_supports( $post->post_type, 'comments' ) ) { + $keys[] = 'rest-comment-post-' . $post->ID; + $keys[] = 'rest-comment-post-huge'; + } + + $taxonomies = wp_list_filter( + get_object_taxonomies( $post->post_type, 'objects' ), + [ 'public' => true ] + ); + + foreach ( $taxonomies as $taxonomy ) { + $terms = get_the_terms( $post, $taxonomy->name ); + if ( $terms ) { + foreach ( $terms as $term ) { + $keys[] = 'term-' . $term->term_id; + } + $keys[] = 'term-huge'; + } + } + + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + /** + * Related cache tags purged when purging a post. + * + * @param array $keys cache tags. + * @param WP_Post $post Post object. + */ + $keys = apply_filters( 'ec_purge_post_with_related', $keys, $post ); + Cloudflare_Client::purgeByTags( $keys ); + } + + /** + * Purge the cache tags associated with a term being modified. + * + * @param integer $term_id ID for the modified term. + */ + private function purge_term( $term_id ) { + $keys = [ + 'term-' . $term_id, + 'rest-term-' . $term_id, + 'post-term-' . $term_id, + 'term-huge', + 'rest-term-huge', + 'post-term-huge', + ]; + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + /** + * cache tags purged when purging a term. + * + * @param array $keys cache tags. + * @param integer $term_id Term ID. + */ + $keys = apply_filters( 'ec_purge_term', $keys, $term_id ); + Cloudflare_Client::purgeByTags( $keys ); + } + + + /** + * Purge a variety of cache tags when a user is modified. + * + * @param integer $user_id ID for the modified user. + */ + public function action_clean_user_cache( $user_id ) { + $keys = [ + 'user-' . $user_id, + 'rest-user-' . $user_id, + 'user-huge', + 'rest-user-huge', + ]; + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + /** + * cache tags purged when clearing user cache. + * + * @param array $keys cache tags. + * @param array $user_id ID for purged user. + */ + $keys = apply_filters( 'ec_purge_clean_user_cache', $keys, $user_id ); + Cloudflare_Client::purgeByTags( $keys ); + } + + /** + * Purge a variety of cache tags when an option is modified. + * + * @param string $option Name of the updated option. + */ + public function action_updated_option( $option ) { + if ( ! function_exists( 'get_registered_settings' ) ) { + return; + } + $settings = get_registered_settings(); + if ( empty( $settings[ $option ] ) || empty( $settings[ $option ]['show_in_rest'] ) ) { + return; + } + $rest_name = ! empty( $settings[ $option ]['show_in_rest']['name'] ) ? $settings[ $option ]['show_in_rest']['name'] : $option; + $keys = [ + 'rest-setting-' . $rest_name, + 'rest-setting-huge', + ]; + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + /** + * cache tags purged when updating an option cache. + * + * @param array $keys cache tags. + * @param string $option Option name. + */ + $keys = apply_filters( 'ec_purge_updated_option', $keys, $option ); + Cloudflare_Client::purgeByTags( $keys ); + } +} diff --git a/includes/class-cloudflare-tag-emitter.php b/includes/class-cloudflare-tag-emitter.php new file mode 100644 index 00000000..4022f205 --- /dev/null +++ b/includes/class-cloudflare-tag-emitter.php @@ -0,0 +1,462 @@ + true ], 'objects' ) as $post_type ) { + add_filter( "rest_prepare_{$post_type->name}", [ $this, 'filter_rest_prepare_post' ], 10, 3 ); + $base = ! empty( $post_type->rest_base ) ? $post_type->rest_base : $post_type->name; + self::get_instance()->rest_api_collection_endpoints[ '/wp/v2/' . $base ] = $post_type->name; + } + foreach ( get_taxonomies( [ 'show_in_rest' => true ], 'objects' ) as $taxonomy ) { + add_filter( "rest_prepare_{$taxonomy->name}", [ $this, 'filter_rest_prepare_term' ], 10, 3 ); + $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; + self::get_instance()->rest_api_collection_endpoints[ '/wp/v2/' . $base ] = $taxonomy->name; + } + add_filter( 'rest_prepare_comment', [ $this, 'filter_rest_prepare_comment' ], 10, 3 ); + self::get_instance()->rest_api_collection_endpoints['/wp/v2/comments'] = 'comment'; + add_filter( 'rest_prepare_user', [ $this, 'filter_rest_prepare_user' ], 10, 3 ); + add_filter( 'rest_pre_get_setting', [ $this, 'filter_rest_pre_get_setting' ], 10, 2 ); + self::get_instance()->rest_api_collection_endpoints['/wp/v2/users'] = 'user'; + } + + /** + * Reset cache tags before a REST API response is generated. + * + * @param mixed $result Response to replace the requested version with. + * @param WP_REST_Server $server Server instance. + * @param WP_REST_Request $request Request used to generate the response. + */ + public function filter_rest_pre_dispatch( $result, $server, $request ) { + if ( isset( self::get_instance()->rest_api_collection_endpoints[ $request->get_route() ] ) ) { + self::get_instance()->rest_api_cache_tags[] = 'rest-' . self::get_instance()->rest_api_collection_endpoints[ $request->get_route() ] . '-collection'; + } + + return $result; + } + + /** + * Render cache tags after a REST API response is prepared + * + * @param WP_HTTP_Response $result Result to send to the client. Usually a WP_REST_Response. + * @param WP_REST_Server $server Server instance. + */ + public function filter_rest_post_dispatch( $result, $server ) { + $keys = self::get_rest_api_cache_tags(); + if ( ! empty( $keys ) && $result instanceof \WP_REST_Response ) { + $result->header( self::HEADER_KEY, implode( ' ', $keys ) ); + } + + return $result; + } + + /** + * Determine which posts are present in a REST API response. + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post Post object. + * @param WP_REST_Request $request Request object. + */ + public function filter_rest_prepare_post( $response, $post, $request ) { + self::get_instance()->rest_api_cache_tags[] = 'rest-post-' . $post->ID; + + return $response; + } + + /** + * Determine which terms are present in a REST API response. + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $term Term object. + * @param WP_REST_Request $request Request object. + */ + public function filter_rest_prepare_term( $response, $term, $request ) { + self::get_instance()->rest_api_cache_tags[] = 'rest-term-' . $term->term_id; + + return $response; + } + + /** + * Determine which comments are present in a REST API response. + * + * @param WP_REST_Response $response The response object. + * @param WP_Comment $comment The original comment object. + * @param WP_REST_Request $request Request used to generate the response. + */ + public function filter_rest_prepare_comment( $response, $comment, $request ) { + self::get_instance()->rest_api_cache_tags[] = 'rest-comment-' . $comment->comment_ID; + self::get_instance()->rest_api_cache_tags[] = 'rest-comment-post-' . $comment->comment_post_ID; + + return $response; + } + + /** + * Determine which users are present in a REST API response. + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $user User object. + * @param WP_REST_Request $request Request object. + */ + public function filter_rest_prepare_user( $response, $user, $request ) { + self::get_instance()->rest_api_cache_tags[] = 'rest-user-' . $user->ID; + + return $response; + } + + /** + * Determine which settings are present in a REST API request + * + * @param mixed $result Value to use for the requested setting. Can be a scalar + * matching the registered schema for the setting, or null to + * follow the default get_option() behavior. + * @param string $name Setting name (as shown in REST API responses). + */ + public function filter_rest_pre_get_setting( $result, $name ) { + self::get_instance()->rest_api_cache_tags[] = 'rest-setting-' . $name; + + return $result; + } + + /** + * Get the cache tags to be included in this view. + * + * cache tags are generated based on the main WP_Query. + * + * @return array + */ + public function get_main_query_cache_tags() { + global $wp_query; + + $keys = []; + if ( is_front_page() ) { + $keys[] = 'front'; + } + if ( is_home() ) { + $keys[] = 'home'; + } + if ( is_404() ) { + $keys[] = '404'; + } + if ( is_feed() ) { + $keys[] = 'feed'; + } + if ( is_date() ) { + $keys[] = 'date'; + } + if ( is_paged() ) { + $keys[] = 'paged'; + } + if ( is_search() ) { + $keys[] = 'search'; + if ( $wp_query->found_posts ) { + $keys[] = 'search-results'; + } else { + $keys[] = 'search-no-results'; + } + } + + if ( ! empty( $wp_query->posts ) ) { + foreach ( $wp_query->posts as $p ) { + $keys[] = 'post-' . $p->ID; + if ( $wp_query->is_singular() ) { + if ( post_type_supports( $p->post_type, 'author' ) ) { + $keys[] = 'post-user-' . $p->post_author; + } + + /** + * Filter ec_should_add_terms + * Gives the option to skip taxonomy terms for a given post + * + * @param $add_terms whether or not to create cache tags for a given post's taxonomy terms. + * @param $wp_query the full WP_Query object. + * + * @return bool + * usage: add_filter( 'ec_should_add_terms',"__return_false", 10, 2); + */ + $add_terms = apply_filters( 'ec_should_add_terms', true, $wp_query ); + if ( ! $add_terms ) { + continue; + } + + foreach ( get_object_taxonomies( $p ) as $tax ) { + $terms = get_the_terms( $p->ID, $tax ); + if ( $terms && ! is_wp_error( $terms ) ) { + foreach ( $terms as $t ) { + $keys[] = 'post-term-' . $t->term_id; + } + } + } + } + } + } + + if ( is_singular() ) { + $keys[] = 'single'; + if ( is_attachment() ) { + $keys[] = 'attachment'; + } + } elseif ( is_archive() ) { + $keys[] = 'archive'; + if ( is_post_type_archive() ) { + $keys[] = 'post-type-archive'; + $post_types = get_query_var( 'post_type' ); + // If multiple post types are queried, create a surrogate key for each. + if ( is_array( $post_types ) ) { + foreach ( $post_types as $post_type ) { + $keys[] = "$post_type-archive"; + } + } else { + $keys[] = "$post_types-archive"; + } + } elseif ( is_author() ) { + $user_id = get_queried_object_id(); + if ( $user_id ) { + $keys[] = 'user-' . $user_id; + } + } elseif ( is_category() || is_tag() || is_tax() ) { + $term_id = get_queried_object_id(); + if ( $term_id ) { + $keys[] = 'term-' . $term_id; + } + } + } + + // Don't emit cache tags in the admin, unless defined by the filter. + if ( is_admin() ) { + $keys = []; + } + + /** + * Customize cache tags sent in the header. + * + * @param array $keys Existing cache tags generated by the plugin. + */ + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + $keys = apply_filters( 'ec_main_query_cache_tags', $keys ); + $keys = array_unique( $keys ); + $keys = self::filter_huge_cache_tags_list( $keys ); + + return $keys; + } + + /** + * Get the cache tags to be included in this view. + * + * cache tags are generated based on filters added to REST API controllers. + * + * @return array + */ + public function get_rest_api_cache_tags() { + + /** + * Customize cache tags sent in the REST API header. + * + * @param array $keys Existing cache tags generated by the plugin. + */ + $keys = self::get_instance()->rest_api_cache_tags; + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + $keys = apply_filters( 'ec_rest_api_cache_tags', $keys ); + $keys = array_unique( $keys ); + $keys = self::filter_huge_cache_tags_list( $keys ); + + return $keys; + } + + /** + * Reset cache tags stored on the instance. + */ + public function reset_rest_api_cache_tags() { + self::get_instance()->rest_api_cache_tags = []; + } + + /** + * Filter the cache tags to ensure that the length doesn't exceed what nginx can handle. + * + * @param array $keys Existing cache tags generated by the plugin. + * + * @return array + */ + public function filter_huge_cache_tags_list( $keys ) { + $output = implode( ' ', $keys ); + if ( strlen( $output ) <= self::HEADER_MAX_LENGTH ) { + return $keys; + } + + $keycats = []; + foreach ( $keys as $k ) { + $p = strrpos( $k, '-' ); + if ( false === $p ) { + $keycats[ $k ][] = $k; + continue; + } + $cat = substr( $k, 0, $p + 1 ); + $keycats[ $cat ][] = $k; + } + + // Sort by the output length of the key category. + uasort( + $keycats, + function ( $a, $b ) { + $ca = strlen( implode( ' ', $a ) ); + $cb = strlen( implode( ' ', $b ) ); + if ( $ca === $cb ) { + return 0; + } + + return $ca > $cb ? - 1 : 1; + } + ); + + $cats = array_keys( $keycats ); + foreach ( $cats as $c ) { + $keycats[ $c ] = [ $c . 'huge' ]; + $keyout = []; + foreach ( $keycats as $v ) { + $keyout = array_merge( $keyout, $v ); + } + $output = implode( ' ', $keyout ); + if ( strlen( $output ) <= self::HEADER_MAX_LENGTH ) { + return $keyout; + } + } + + return $keyout; + } + + /** + * Inspect the model and get the right cache tags. + * + * @param WPGraphQL\Model\Model|mixed $model Model object, array, etc. + */ + public function filter_graphql_dataloader_get_model( $model ) { + if ( ! $model instanceof \WPGraphQL\Model\Model ) { + return $model; + } + + $reflect = new \ReflectionClass( $model ); + $class_short_name = $reflect->getShortName(); + $cache_tag_prefix = strtolower( $class_short_name ); + if ( isset( $model->id ) ) { + if ( ! empty( $model->databaseId ) ) { + self::get_instance()->graphql_cache_tags[] = $cache_tag_prefix . '-' . $model->databaseId; + } + } + + return $model; + } + + /** + * Get the cache tags to be included in this view. + * + * cache tags are generated based on filters added to GraphQL controllers. + * + * @return array + */ + public function get_graphql_cache_tags() { + + /** + * Customize cache tags sent in the GraphQL header. + * + * @param array $keys Existing cache tags generated by the plugin. + */ + $keys = self::get_instance()->graphql_cache_tags; + $keys[] = 'graphql-collection'; + $keys = ec_cf_prefix_cache_tags_with_blog_id( $keys ); + $keys = apply_filters( 'ec_graphql_cache_tags', $keys ); + $keys = array_unique( $keys ); + $keys = self::filter_huge_cache_tags_list( $keys ); + + return $keys; + } + + /** + * Send additional headers to graphql response. + * + * @param array $headers Existing headers as set by graphql plugin. + */ + public function filter_graphql_response_headers_to_send( $headers ) { + $keys = self::get_graphql_cache_tags(); + if ( ! empty( $keys ) ) { + $headers[ self::HEADER_KEY ] = implode( ' ', $keys ); + } + + return $headers; + } +} diff --git a/includes/class-nginx-helper.php b/includes/class-nginx-helper.php index 7cf8cb00..ed53678c 100644 --- a/includes/class-nginx-helper.php +++ b/includes/class-nginx-helper.php @@ -12,6 +12,9 @@ * @subpackage nginx-helper/includes */ +use EasyCache\Cloudflare_Purger; +use EasyCache\CloudFlare_Tag_Emitter; + /** * The core plugin class. * @@ -253,6 +256,39 @@ private function define_admin_hooks() { // WooCommerce integration. $this->loader->add_action( 'plugins_loaded', $nginx_helper_admin, 'init_woocommerce_hooks' ); + if ( $nginx_helper_admin->cf_options['is_enabled'] ) { + $this->loader->add_action( 'admin_notices', $nginx_helper_admin, 'cf_page_rule_save_display_admin_notices' ); + $this->loader->add_filter( 'wp_headers', $this, 'handle_cloudflare_headers', 999 ); + $this->loader->add_action( 'admin_bar_menu', $nginx_helper_admin, 'add_cloudflare_admin_bar_purge', 100 ); + $this->loader->add_action( 'wp_ajax_ec_clear_url_cache', $nginx_helper_admin, 'handle_cloudflare_clear_cache_ajax' ); + + // Add the cache tags. + $this->loader->add_filter( 'wp', CloudFlare_Tag_Emitter::get_instance(), 'action_wp' ); + $this->loader->add_action( 'rest_api_init', CloudFlare_Tag_Emitter::get_instance(), 'action_rest_api_init' ); + $this->loader->add_filter( 'rest_pre_dispatch', CloudFlare_Tag_Emitter::get_instance(), 'filter_rest_pre_dispatch', 10, 3 ); + $this->loader->add_filter( 'rest_post_dispatch', CloudFlare_Tag_Emitter::get_instance(), 'filter_rest_post_dispatch', 10, 2 ); + $this->loader->add_filter( 'graphql_dataloader_get_model', CloudFlare_Tag_Emitter::get_instance(), 'filter_graphql_dataloader_get_model' ); + $this->loader->add_filter( 'graphql_response_headers_to_send', CloudFlare_Tag_Emitter::get_instance(), 'filter_graphql_response_headers_to_send' ); + + /** + * Clears cache tags when various modification behaviors are performed. + */ + $this->loader->add_action( 'wp_insert_post', Cloudflare_Purger::get_instance(), 'action_wp_insert_post', 10, 2 ); + $this->loader->add_action( 'transition_post_status', Cloudflare_Purger::get_instance(), 'action_transition_post_status', 10, 3 ); + $this->loader->add_action( 'before_delete_post', Cloudflare_Purger::get_instance(), 'action_before_delete_post' ); + $this->loader->add_action( 'delete_attachment', Cloudflare_Purger::get_instance(), 'action_delete_attachment' ); + $this->loader->add_action( 'clean_post_cache', Cloudflare_Purger::get_instance(), 'action_clean_post_cache' ); + $this->loader->add_action( 'created_term', Cloudflare_Purger::get_instance(), 'action_created_term', 10, 3 ); + $this->loader->add_action( 'edited_term', Cloudflare_Purger::get_instance(), 'action_edited_term' ); + $this->loader->add_action( 'delete_term', Cloudflare_Purger::get_instance(), 'action_delete_term' ); + $this->loader->add_action( 'clean_term_cache', Cloudflare_Purger::get_instance(), 'action_clean_term_cache' ); + $this->loader->add_action( 'wp_insert_comment', Cloudflare_Purger::get_instance(), 'action_wp_insert_comment', 10, 2 ); + $this->loader->add_action( 'transition_comment_status', Cloudflare_Purger::get_instance(), 'action_transition_comment_status', 10, 3 ); + $this->loader->add_action( 'clean_comment_cache', Cloudflare_Purger::get_instance(), 'action_clean_comment_cache' ); + $this->loader->add_action( 'clean_user_cache', Cloudflare_Purger::get_instance(), 'action_clean_user_cache' ); + $this->loader->add_action( 'updated_option', Cloudflare_Purger::get_instance(), 'action_updated_option' ); + } + } /** @@ -357,6 +393,56 @@ public function handle_nginx_helper_upgrade() { update_option( 'nginx_helper_version', $this->get_version() ); } + } + + /** + * Manage the cache headers for Cloudflare. + * + * @param array $headers The headers of the site. + * + * @return array The modified headers for cache. + */ + public function handle_cloudflare_headers( $headers ) { + + // Defensively remove any Cache-Control or Expires headers set by the server or other plugins. + // This ensures our plugin has the final say. + if ( isset( $headers['Cache-Control'] ) ) { + unset( $headers['Cache-Control'] ); + } + if ( isset( $headers['Expires'] ) ) { + unset( $headers['Expires'] ); + } + + // Conditions for NOT caching (logged-in, admin, search, etc.) + $do_not_cache = is_user_logged_in() || is_admin() || is_search() || is_404() || is_customize_preview(); + + // Also check for common dynamic cookies + if ( ! $do_not_cache && ! empty( $_COOKIE ) ) { + foreach ( array_keys( $_COOKIE ) as $cookie_key ) { + if ( strpos( $cookie_key, 'wordpress_logged_in' ) !== false || strpos( $cookie_key, 'woocommerce_items_in_cart' ) !== false ) { + $do_not_cache = true; + break; + } + } + } + + if ( $do_not_cache ) { + // User is logged in or page is dynamic. Send explicit NO CACHE headers. + $headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'; + $headers['Pragma'] = 'no-cache'; // For legacy HTTP/1.0 compatibility + $headers['Expires'] = 'Wed, 11 Jan 1984 05:00:00 GMT'; // Date in the past + } else { + // Page is for an anonymous user and is cacheable. + $options = get_option( 'easycache_cf_settings' ); + $ttl = isset( $options['default_cache_ttl'] ) ? (int) $options['default_cache_ttl'] : 0; + + if ( $ttl > 0 ) { + // Send CDN-friendly caching headers. + $headers['Cache-Control'] = 'public, max-age=0, s-maxage=' . $ttl; + } + } + + return $headers; } } diff --git a/nginx-helper.php b/nginx-helper.php index bb3ff52e..d2e610b5 100644 --- a/nginx-helper.php +++ b/nginx-helper.php @@ -21,6 +21,11 @@ die; } +// Load Composer dependencies. +if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) { + require_once __DIR__ . '/vendor/autoload.php'; +} + /** * Base URL of plugin */ @@ -42,6 +47,9 @@ define( 'NGINX_HELPER_BASEPATH', plugin_dir_path( __FILE__ ) ); } +require_once NGINX_HELPER_BASEPATH . '/utils/functions.php'; +require_once NGINX_HELPER_BASEPATH . '/utils/autoloader.php'; + /** * The code that runs during plugin activation. * This action is documented in includes/class-nginx-helper-activator.php @@ -90,7 +98,7 @@ function run_nginx_helper() { require_once NGINX_HELPER_BASEPATH . 'class-nginx-helper-wp-cli-command.php'; \WP_CLI::add_command( 'nginx-helper', 'Nginx_Helper_WP_CLI_Command' ); - + \WP_CLI::add_command( 'cloudflare cache', 'EasyCache\\CLI' ); } } diff --git a/utils/autoloader.php b/utils/autoloader.php new file mode 100644 index 00000000..94125dc1 --- /dev/null +++ b/utils/autoloader.php @@ -0,0 +1,29 @@ + Date: Fri, 19 Sep 2025 18:06:29 +0530 Subject: [PATCH 2/4] feat: add cache rules option --- admin/class-nginx-helper-admin.php | 9 +- .../partials/easycache-cloudflare-options.php | 13 +++ includes/class-cloudflare-client.php | 110 ++++++++++++------ 3 files changed, 94 insertions(+), 38 deletions(-) diff --git a/admin/class-nginx-helper-admin.php b/admin/class-nginx-helper-admin.php index db279e7d..ea8a2b79 100644 --- a/admin/class-nginx-helper-admin.php +++ b/admin/class-nginx-helper-admin.php @@ -1207,15 +1207,16 @@ public function purge_product_cache_on_update( $product_id ) { * * @return void */ - public function handle_cf_page_rule_update() { - $nonce = isset( $_POST['easycache_add_page_rule_nonce'] ) ? wp_unslash( $_POST['easycache_add_page_rule_nonce'] ) : ''; - if ( wp_verify_nonce( $nonce, 'easycache_add_page_rule_nonce' ) ) { + public function handle_cf_cache_rule_update() { + $nonce = isset( $_POST['easycache_add_cache_rule_nonce'] ) ? wp_unslash( $_POST['easycache_add_cache_rule_nonce'] ) : ''; + + if ( wp_verify_nonce( $nonce, 'easycache_add_cache_rule_nonce' ) ) { if ( ! current_user_can( 'manage_options' ) ) { return; } - $result = EasyCache\Cloudflare_Client::setupCacheEverythingPageRule(); + $result = EasyCache\Cloudflare_Client::setupCacheRule(); if( $result ) { set_transient( 'ec_page_rule_save_state_admin_notice', $result, 60 ); } diff --git a/admin/partials/easycache-cloudflare-options.php b/admin/partials/easycache-cloudflare-options.php index c81fb21a..763a0f96 100644 --- a/admin/partials/easycache-cloudflare-options.php +++ b/admin/partials/easycache-cloudflare-options.php @@ -43,6 +43,11 @@ echo '

' . esc_html__( 'Settings saved.', 'nginx-helper' ) . '

'; } +if( isset( $nginx_helper_admin ) && method_exists( $nginx_helper_admin, 'handle_cf_cache_rule_update' ) ) { + $nginx_helper_admin->handle_cf_cache_rule_update(); + +} + $ec_site_settings = $nginx_helper_admin->get_cloudflare_settings(); ?> @@ -128,6 +133,14 @@ class="dashicons dashicons-hidden password-input-icon"> +
+ + +
+ diff --git a/includes/class-cloudflare-client.php b/includes/class-cloudflare-client.php index d4b8e3e4..cff23b58 100644 --- a/includes/class-cloudflare-client.php +++ b/includes/class-cloudflare-client.php @@ -10,8 +10,6 @@ use Cloudflare\API\Auth\APIToken; use Cloudflare\API\Adapter\Guzzle; use Cloudflare\API\Endpoints\Zones; -use Cloudflare\API\Endpoints\PageRules; -use Cloudflare\API\Configurations\PageRules as PageRulesConfig; use Exception; /** @@ -31,7 +29,9 @@ public static function purgeByTags( array $tags ) { return false; } - $options = get_option( 'easycache_cf_settings' ); + global $nginx_helper_admin; + + $options = $nginx_helper_admin->get_cloudflare_settings(); $token = isset( $options['api_token'] ) ? $options['api_token'] : ''; $zone_id = isset( $options['zone_id'] ) ? $options['zone_id'] : ''; @@ -70,7 +70,9 @@ public static function purgeByTags( array $tags ) { * @return bool True on success, false on failure. */ public static function purgeEverything() { - $options = get_option( 'easycache_cf_settings' ); + global $nginx_helper_admin; + + $options = $nginx_helper_admin->get_cloudflare_settings(); $token = isset( $options['api_token'] ) ? $options['api_token'] : ''; $zone_id = isset( $options['zone_id'] ) ? $options['zone_id'] : ''; @@ -115,7 +117,9 @@ public static function purgeByUrls( array $urls ) { return false; } - $options = get_option( 'easycache_cf_settings' ); + global $nginx_helper_admin; + + $options = $nginx_helper_admin->get_cloudflare_settings(); $token = isset( $options['api_token'] ) ? $options['api_token'] : ''; $zone_id = isset( $options['zone_id'] ) ? $options['zone_id'] : ''; @@ -149,57 +153,95 @@ public static function purgeByUrls( array $urls ) { } /** - * Sets up the "Cache Everything" Page Rule in Cloudflare. + * Sets up the "Cache Rule" required to purge the edge cache. * * @return string|false 'created', 'exists', or false on failure. */ - public static function setupCacheEverythingPageRule() { - $options = get_option( 'easycache_cf_settings' ); + public static function setupCacheRule() { + global $nginx_helper_admin; + + if ( ! $nginx_helper_admin ) { + return; + } + + $options = $nginx_helper_admin->get_cloudflare_settings(); + $token = isset( $options['api_token'] ) ? $options['api_token'] : ''; $zone_id = isset( $options['zone_id'] ) ? $options['zone_id'] : ''; if ( empty( $token ) || empty( $zone_id ) ) { error_log( 'Advanced Cloudflare Cache: API Token or Zone ID not configured.' ); - return false; } try { $key = new APIToken( $token ); $adapter = new Guzzle( $key ); - $pageRules = new PageRules( $adapter ); - - $rules = $pageRules->listPageRules( $zone_id ); - $rule_url = home_url() . '/*'; - - foreach ( $rules->result as $rule ) { - if ( ! empty( $rule->targets ) ) { - foreach ( $rule->targets as $target ) { - if ( $target->target === 'url' && $target->constraint->operator === 'matches' && $target->constraint->value === $rule_url ) { - if ( ! empty( $rule->actions ) ) { - foreach ( $rule->actions as $action ) { - if ( $action->id === 'cache_level' && $action->value === 'cache_everything' ) { - return 'exists'; - } - } - } - } - } + + $rulesets_response = $adapter->get( sprintf( "zones/%s/rulesets", $zone_id ) ); + $raw_existing_response = $rulesets_response->getBody() ?? []; + $cache_ruleset_id = null; + + $existing_rules = json_decode( $raw_existing_response, true ); + + if( ! array_key_exists( 'result', $existing_rules ) ) { + return false; + } + + $existing_rules = $existing_rules['result']; + + foreach ( $existing_rules as $ruleset ) { + if ( 'http_request_cache_settings' === $ruleset['phase'] && + isset( $ruleset['name'] ) && 'nginx_helper_cache_ruleset' === $ruleset['name'] ) { + return 'exists'; + } + + if ( 'http_request_cache_settings' === $ruleset['phase'] && $cache_ruleset_id === null ) { + $cache_ruleset_id = $ruleset->id; } } - $config = new PageRulesConfig( $rule_url, 'cache_everything' ); - $config->setPriority( 1 ); + $site_url = get_site_url(); + + $ruleset = array( + 'kind' => 'zone', + 'name' => 'nginx_helper_cache_ruleset', + 'phase' => 'http_request_cache_settings', + 'description' => 'Determines cache serve.', + 'rules' => [ + [ + 'expression' => "(http.request.full_uri wildcard \"". $site_url ."/*\" and not http.cookie contains \"wordpress_logged\" and not http.cookie contains \"NO_CACHE\" and not http.cookie contains \"S+ESS\" and not http.cookie contains \"fbs\" and not http.cookie contains \"SimpleSAML\" and not http.cookie contains \"PHPSESSID\" and not http.cookie contains \"wordpress\" and not http.cookie contains \"wp-\" and not http.cookie contains \"comment_author_\" and not http.cookie contains \"duo_wordpress_auth_cookie\" and not http.cookie contains \"duo_secure_wordpress_auth_cookie\" and not http.cookie contains \"bp_completed_create_steps\" and not http.cookie contains \"bp_new_group_id\" and not http.cookie contains \"wp-resetpass-\" and not http.cookie contains \"woocommerce\" and not http.cookie contains \"amazon_Login_\")", + 'action' => 'set_cache_settings', + 'action_parameters' => [ + 'cache' => true, + 'edge_ttl' => [ + 'mode' => 'override_origin', + 'default' => 3600 + ], + 'browser_ttl' => [ + 'mode' => 'override_origin', + 'default' => 1800 + ] + ] + ] + ] + ); + + if ( $cache_ruleset_id === null ) { + $ruleset_resp = $adapter->post( sprintf( 'zones/%s/rulesets', $zone_id ), $ruleset ); + } else { + $ruleset_resp = $adapter->put( sprintf( 'zones/%s/rulesets/%s', $zone_id, $cache_ruleset_id ), array( 'rules' => $ruleset ) ); + } - if ( $pageRules->createPageRule( $zone_id, $config ) ) { + if ( isset( $ruleset_resp->success ) && $ruleset_resp->success ) { return 'created'; + } else { + error_log( 'Advanced Cloudflare Cache: Failed to create/update cache rule. Response: ' . json_encode( $ruleset_resp ) ); + return false; } - return false; - } catch ( Exception $e ) { - error_log( 'Advanced Cloudflare Cache: Exception when setting up Page Rule: ' . $e->getMessage() ); - + error_log( 'Advanced Cloudflare Cache: Exception when setting up Cache Rule: ' . $e->getMessage() ); return false; } } From bcab3ba2fb67957c5e1252fe4f3d23b8b78179d8 Mon Sep 17 00:00:00 2001 From: Vedant Gandhi Date: Fri, 19 Sep 2025 18:07:26 +0530 Subject: [PATCH 3/4] feat: change description of cloudflare cache rules --- includes/class-cloudflare-client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-cloudflare-client.php b/includes/class-cloudflare-client.php index cff23b58..49ce45e2 100644 --- a/includes/class-cloudflare-client.php +++ b/includes/class-cloudflare-client.php @@ -207,7 +207,7 @@ public static function setupCacheRule() { 'kind' => 'zone', 'name' => 'nginx_helper_cache_ruleset', 'phase' => 'http_request_cache_settings', - 'description' => 'Determines cache serve.', + 'description' => 'Set\'s the edge cache rules by Nginx Helper Plugin. ', 'rules' => [ [ 'expression' => "(http.request.full_uri wildcard \"". $site_url ."/*\" and not http.cookie contains \"wordpress_logged\" and not http.cookie contains \"NO_CACHE\" and not http.cookie contains \"S+ESS\" and not http.cookie contains \"fbs\" and not http.cookie contains \"SimpleSAML\" and not http.cookie contains \"PHPSESSID\" and not http.cookie contains \"wordpress\" and not http.cookie contains \"wp-\" and not http.cookie contains \"comment_author_\" and not http.cookie contains \"duo_wordpress_auth_cookie\" and not http.cookie contains \"duo_secure_wordpress_auth_cookie\" and not http.cookie contains \"bp_completed_create_steps\" and not http.cookie contains \"bp_new_group_id\" and not http.cookie contains \"wp-resetpass-\" and not http.cookie contains \"woocommerce\" and not http.cookie contains \"amazon_Login_\")", From c81d08dc2c18cef91e00020a43db3c0e93aa99bc Mon Sep 17 00:00:00 2001 From: Vedant Gandhi Date: Fri, 19 Sep 2025 18:09:31 +0530 Subject: [PATCH 4/4] feat: update token permissions --- admin/partials/easycache-cloudflare-options.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/admin/partials/easycache-cloudflare-options.php b/admin/partials/easycache-cloudflare-options.php index 763a0f96..71f02552 100644 --- a/admin/partials/easycache-cloudflare-options.php +++ b/admin/partials/easycache-cloudflare-options.php @@ -57,7 +57,7 @@
-

+

@@ -84,7 +84,7 @@ class="dashicons dashicons-hidden password-input-icon"> if ( $ec_site_settings['api_token_enabled_by_constant'] ) { esc_html_e( 'Field enabled by constant.', 'nginx-helper' ); } else { - esc_html_e( 'Required permissions: Zone.Cache Purge.', 'nginx-helper' ); + esc_html_e( 'Required permissions: Zone.Cache Purge, Zone.Cache Rules, Account.Account Rulesets.Edit, Account.Account Filter Lists.Edit', 'nginx-helper' ); } ?>