diff --git a/readme.txt b/readme.txt index 8013c7c..3dae29b 100644 --- a/readme.txt +++ b/readme.txt @@ -24,6 +24,8 @@ Feel free to create a PR to [plugin Github repo](https://github.com/Dexerto/on-d == Changelog == += 1.2.6 = +- feat: introduce cloudflare cache purge @MuhammedAO = 1.2.5 = - feat: prevent revalidate functions from running more than once within a single save_post request from @MuhammedAO - fix: tags array populated by paths filter from @cavemon diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php index 255b9f5..016e9e9 100644 --- a/src/Admin/Settings.php +++ b/src/Admin/Settings.php @@ -11,6 +11,7 @@ use OnDemandRevalidation\Admin\SettingsRegistry; use OnDemandRevalidation\Revalidation; +use OnDemandRevalidation\CloudflareCachePurge; /** * Class Settings @@ -40,6 +41,11 @@ public function init() { add_action( 'init', array( $this, 'register_settings' ) ); add_action( 'admin_init', array( $this, 'initialize_settings_page' ) ); + + if ( is_admin() ) { + CloudflareCachePurge::add_purge_cache_button_script(); + } + if ( is_admin() ) { Revalidation::test_revalidation_button(); } @@ -54,8 +60,8 @@ public function init() { public function add_options_page() { add_options_page( - __( 'Next.js On-Demand Revalidation', 'on-demand-revalidation' ), - __( 'Next.js On-Demand Revalidation', 'on-demand-revalidation' ), + __( 'On-Demand Revalidation', 'on-demand-revalidation' ), + __( 'On-Demand Revalidation', 'on-demand-revalidation' ), 'manage_options', 'on-demand-revalidation', array( $this, 'render_settings_page' ) @@ -138,6 +144,67 @@ public function register_settings() { ), ) ); + + $this->settings_api->register_section( + 'on_demand_revalidation_cloudflare_settings', + array( + 'title' => __( 'Cloudflare Cache Purge', 'on-demand-revalidation' ), + 'desc' => __( 'Configure settings for Cloudflare cache purging via path or tag. These settings are optional and will only take effect if Cloudflare authentication is successfully verified.', 'on-demand-revalidation' ), + ) + ); + + $this->settings_api->register_fields( + 'on_demand_revalidation_cloudflare_settings', + array( + + array( + 'name' => 'cloudflare_cache_purge_enabled', + 'desc' => __( 'Enable cache purge on post update', 'on-demand-revalidation' ), + 'type' => 'checkbox', + ), + + array( + 'name' => 'cloudflare_schedule_on_post_update', + 'desc' => __( 'Schedule purge on post update. (If unchecked, the purge will run immediately during the update, which might slow down the process.)', 'on-demand-revalidation' ), + 'type' => 'checkbox', + ), + + array( + 'name' => 'cloudflare_zone_id', + 'label' => __( 'Zone ID', 'on-demand-revalidation' ), + 'type' => 'text', + 'desc' => '' . __( 'Click here for information on how to find your Zone ID and API token.', 'on-demand-revalidation' ) . '', + ), + array( + 'name' => 'cloudflare_api_token', + 'label' => __( 'API Token', 'on-demand-revalidation' ), + 'type' => 'password', + ), + + array( + 'name' => 'cloudflare_cache_purge_paths', + 'label' => __( 'Paths to Purge', 'on-demand-revalidation' ), + 'desc' => __( 'Enter one path per line that you want to purge from Cloudflare cache.', 'on-demand-revalidation' ), + 'type' => 'textarea', + 'placeholder' => 'https://example.com/category/post', + ), + + array( + 'name' => 'cloudflare_cache_purge_tags', + 'label' => __( 'Tags to Purge', 'on-demand-revalidation' ), + 'desc' => __( 'Enter one tag per line to purge related content from Cloudflare cache.', 'on-demand-revalidation' ), + 'type' => 'textarea', + 'placeholder' => 'category-page', + ), + array( + 'name' => 'test-config', + 'label' => __( 'Test Config', 'on-demand-revalidation' ), + 'desc' => 'Test Config', + 'type' => 'html', + ), + + ) + ); } /** @@ -162,7 +229,7 @@ public function render_settings_page() { ?>
Next.js On-Demand Revalidation'; + echo '

On-Demand Revalidation

'; $this->settings_api->show_navigation(); $this->settings_api->show_forms(); ?> diff --git a/src/CloudflareCachePurge.php b/src/CloudflareCachePurge.php new file mode 100644 index 0000000..9941dd5 --- /dev/null +++ b/src/CloudflareCachePurge.php @@ -0,0 +1,359 @@ + 'Bearer ' . $api_token, + 'Content-Type' => 'application/json', + ); + + // Verify the token. + $api_token_verify_response = wp_remote_request( + $api_token_verify_url, + array( + 'method' => 'GET', + 'headers' => $headers, + 'timeout' => 10, //phpcs:ignore + ) + ); + + + if ( is_wp_error( $api_token_verify_response ) ) { + return false; + } + + $api_token_verified_body = wp_remote_retrieve_body( $api_token_verify_response ); + $api_token_verified_data = json_decode( $api_token_verified_body, true ); + + if ( ! isset( $api_token_verified_data['success'] ) || 1 !== (int) $api_token_verified_data['success'] ) { + return false; + } + + // Validate Zone ID. + $zone_url = 'https://api.cloudflare.com/client/v4/zones?status=active'; + $zone_response = wp_remote_request( + $zone_url, + array( + 'method' => 'GET', + 'headers' => $headers, + 'timeout' => 10, //phpcs:ignore + ) + ); + + if ( is_wp_error( $zone_response ) ) { + return false; + } + + $zone_body = wp_remote_retrieve_body( $zone_response ); + $zone_data = json_decode( $zone_body, true ); + + + if ( ! isset( $zone_data['success'] ) || 1 !== (int) $zone_data['success'] ) { + return false; + } + + + $zone_found = false; + foreach ( $zone_data['result'] as $zone ) { + if ( $zone['id'] === $zone_id ) { + $zone_found = true; + break; + } + } + + if ( ! $zone_found ) { + return false; + } + + return true; + } + + /** + * Runs the purge process using the current settings and a provided post. + * + * @param \WP_Post $post The post object used for placeholder replacement. + * @return array The result from purge_cache(). + */ + public static function run_purge( $post ) { + $settings = get_option( 'on_demand_revalidation_cloudflare_settings' ); + + + if ( ! isset( $settings['cloudflare_cache_purge_enabled'] ) || 'on' !== $settings['cloudflare_cache_purge_enabled'] ) { + return array( + 'success' => false, + 'message' => 'Cloudflare cache purge is disabled.', + ); + } + + + $zone_id = $settings['cloudflare_zone_id'] ?? ''; + $api_token = $settings['cloudflare_api_token'] ?? ''; + $paths = isset( $settings['cloudflare_cache_purge_paths'] ) ? explode( "\n", trim( $settings['cloudflare_cache_purge_paths'] ) ) : array(); + $tags = isset( $settings['cloudflare_cache_purge_tags'] ) ? explode( "\n", trim( $settings['cloudflare_cache_purge_tags'] ) ) : array(); + + // If no paths/tags are configured, return early. + if ( empty( $paths ) && empty( $tags ) ) { + return array( + 'success' => false, + 'message' => 'No paths or tags provided for cache purging.', + ); + } + + // Validate credentials. + if ( ! self::validate_token_zone_id( $zone_id, $api_token ) ) { + return array( + 'success' => false, + 'message' => 'Invalid Cloudflare API Token or Zone ID. Cache purge disabled.', + ); + } + + return self::purge_cache( $zone_id, $api_token, $paths, $tags, $post ); + } + + /** + * Purges the Cloudflare cache for the specified paths and tags, with dynamic placeholder replacement. + * + * @param string $zone_id The Cloudflare zone ID. + * @param string $api_token The Cloudflare API token. + * @param array $paths An array of URLs (paths) to purge (can contain placeholders). + * @param array $tags An array of tags to purge (can contain placeholders). + * @param \WP_Post $post The WordPress post object used to replace placeholders in paths and tags. + * + * @return array An array containing the success status and a message. + */ + public static function purge_cache( $zone_id, $api_token, $paths = array(), $tags = array(), $post = null ) { + if ( $post instanceof \WP_Post ) { + $paths = Helpers::rewrite_placeholders( $paths, $post ); + $tags = Helpers::rewrite_placeholders( $tags, $post ); + } + + // Ensure full URLs for paths if required by Cloudflare. + $site_url = get_site_url(); + $paths = array_map( + function ( $path ) use ( $site_url ) { + return ( str_starts_with( $path, '/' ) ) ? trailingslashit( $site_url ) . ltrim( $path, '/' ) : $path; + }, + $paths + ); + + $purge_data = array(); + if ( ! empty( $paths ) ) { + $purge_data['files'] = array_values( array_unique( $paths ) ); + } + if ( ! empty( $tags ) ) { + $purge_data['tags'] = array_values( array_unique( $tags ) ); + } + + $purge_url = "https://api.cloudflare.com/client/v4/zones/$zone_id/purge_cache"; + $headers = array( + 'Authorization' => 'Bearer ' . $api_token, + 'Content-Type' => 'application/json', + ); + + $response = wp_remote_post( + $purge_url, + array( + 'method' => 'POST', + 'headers' => $headers, + 'body' => wp_json_encode( $purge_data ), + 'timeout' => 10, //phpcs:ignore + ) + ); + + if ( is_wp_error( $response ) ) { + return array( + 'success' => false, + 'message' => 'Cloudflare cache purge request failed: ' . $response->get_error_message(), + ); + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + if ( isset( $data['success'] ) && true === $data['success'] ) { + return array( + 'success' => true, + 'message' => 'Cloudflare cache purged successfully.', + ); + } else { + $error_message = ! empty( $data['errors'] ) ? wp_json_encode( $data['errors'] ) : 'Unknown error occurred.'; + return array( + 'success' => false, + 'message' => 'Cloudflare cache purge failed: ' . $error_message, + ); + } + } + + + /** + * Automatically handle Cloudflare cache purging on post update. + * + * This function is hooked into the 'save_post' action. + * + * @param int $post_ID The post ID. + * @param \WP_Post $post The post object. + */ + public static function handle_post_update( $post_ID, $post ) { + $excluded_statuses = array( 'auto-draft', 'inherit', 'draft', 'trash' ); + + if ( isset( $post->post_status ) && in_array( $post->post_status, $excluded_statuses, true ) ) { + return; + } + + if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) { + return; + } + + if ( false !== wp_is_post_revision( $post_ID ) ) { + return; + } + + if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { + return; + } + self::purge_post( $post ); + } + + + + /** + * Checks the "schedule on post update" setting and either purges immediately or schedules a cron event. + * + * @param \WP_Post $post The post object. + */ + public static function purge_post( $post ) { + $settings = get_option( 'on_demand_revalidation_cloudflare_settings' ); + + if ( isset( $settings['cloudflare_schedule_on_post_update'] ) && 'on' === $settings['cloudflare_schedule_on_post_update'] ) { + + wp_schedule_single_event( time(), 'on_demand_revalidation_cloudflare_on_post_update', array( $post->ID ) ); + } else { + + self::run_purge( $post ); + } + } + + /** + * Cron callback: Purges the cache for a post given its ID. + * + * @param int $post_ID The post ID. + */ + public static function cron_purge( int $post_ID ) { + $post = get_post( $post_ID ); + if ( $post ) { + self::run_purge( $post ); + } + } + + + + + /** + * Handles the transition of post status. + * + * @param string $new_status The new status of the post. + * @param string $old_status The old status of the post. + * @param object $post The post object. + */ + public static function handle_transition_post_status( $new_status, $old_status, $post ) { + if ( ( ( 'draft' !== $old_status && 'trash' !== $old_status ) && 'trash' === $new_status ) || + ( 'publish' === $old_status && 'draft' === $new_status ) ) { + + self::purge_post( $post ); + } + } + + + /** + * Adds the jQuery script to handle the "Test Config" button click on the admin page. + * + * @return void + */ + public static function add_purge_cache_button_script() { + add_action( + 'admin_footer', + function () { ?> + + __( 'You do not have permission to manage options.', 'on-demand-revalidation' ) ) ); + wp_die(); + } + $posts = get_posts( //phpcs:ignore --suppress_filters already set to false + array( + 'numberposts' => 1, + 'post_status' => 'publish', + 'suppress_filters' => false, + ) + ); + $post = ! empty( $posts ) ? $posts[0] : null; + + $response = self::run_purge( $post ); + wp_send_json( $response ); + wp_die(); + } + ); + } +}