PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Cloudflare / CloudflareModule.php

CloudflareModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.3, at includes/modules/Cloudflare/CloudflareModule.php

877 lines 32.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cloudflare module — connect a CF zone for purge + dev-mode toggles.
4 *
5 * Free tier (this module): API token / global key auth, zone
6 * verification, manual purge, auto purge on xSpeed's own purge, dev
7 * mode toggle.
8 *
9 * Pro tier (xspeed-pro): APO toggle, edge cache rules, edge cache TTL.
10 * Per FEATURES.md "Cloudflare Integration" §8-10.
11 *
12 * @package XSpeed
13 */
14
15 declare(strict_types=1);
16
17 namespace XSpeed\Modules\Cloudflare;
18
19 defined( 'ABSPATH' ) || exit;
20
21 use XSpeed\Cloudflare;
22 use XSpeed\Module;
23
24 final class CloudflareModule extends Module {
25
26 public const SLUG = 'cloudflare';
27 public const TIER = self::TIER_FREE;
28 public const VERSION = '1.1.0';
29
30 /**
31 * Where the last connection-health result is cached: the outcome of the
32 * most recent verify (token + zone reachable) or purge (Cache-Purge
33 * permission actually works). Read by ui_notices() to show a persistent
34 * warning when Cloudflare is silently failing. (#119)
35 */
36 private const HEALTH_OPTION = 'xspeed_cloudflare_health';
37
38 /** Cron event that does the edge call for a batch of purged URLs. */
39 private const PURGE_URLS_EVENT = 'xspeed_cloudflare_purge_urls';
40
41 /** Cron event for the zone-wide fallback when a batch is too large. */
42 private const PURGE_ALL_EVENT = 'xspeed_cloudflare_purge_edge_all';
43
44 /**
45 * Above this many URLs, purge the zone instead of naming every page.
46 *
47 * The batch travels as the cron event's ARGUMENT, and the cron table is
48 * an autoloaded option, so an unbounded batch is an unbounded payload in
49 * `alloptions` for as long as the event is pending. A bulk product
50 * import, or an `xspeed_purge_product_urls` filter that expands to a few
51 * hundred URLs, is enough. Past the threshold the zone purge is one call
52 * with no payload, and it is what the site would have got from
53 * `purge_all()` anyway.
54 */
55 private const MAX_DEFERRED_URLS = 100;
56
57 /**
58 * URLs purged this request, awaiting a batched call at shutdown.
59 *
60 * Keyed blog id => URL => true. By URL so the same page arriving twice —
61 * a post and the archive that lists it can resolve to the same address —
62 * is sent once. By BLOG because one module instance serves the whole
63 * process: a `Cache::purge_url()` raised inside `switch_to_blog()` would
64 * otherwise land in a batch sent against whatever blog happened to be
65 * current at shutdown, merging several sites' URLs into one zone with one
66 * site's token, and writing the health record and activity log to the
67 * wrong site too. Nothing does that today — Pro's network purge goes
68 * through `purge_all()` — but the re-entry guard in Cache anticipates a
69 * network purge that loops blogs in one request.
70 *
71 * @var array<int,array<string,true>>
72 */
73 private array $pending_edge_urls = array();
74
75 public function ui_metadata(): array {
76 return array(
77 'label' => __( 'Cloudflare', 'xspeed' ),
78 'icon' => 'Cloud',
79 'description' => __( 'Connect a Cloudflare zone for automatic edge purging when xSpeed clears its cache, plus a dev-mode toggle.', 'xspeed' ),
80 'custom_panel' => 'CloudflarePanel',
81 );
82 }
83
84 /**
85 * @inheritDoc
86 *
87 * Nothing exempt. It is inert without Cloudflare credentials, and where
88 * credentials exist the user set them up for a CDN rather than for the page
89 * cache we stood down from — but "inert today" is a weak reason to leave a
90 * switch on that nobody asked for, and on a site where the host DID take
91 * the page cache it is not inert at all.
92 */
93 public function conflict_safe_exempt(): array {
94 return array();
95 }
96
97 public function settings_schema(): array {
98 return array(
99 'enabled' => array(
100 'type' => 'bool',
101 'default' => false,
102 'label' => __( 'Enable Cloudflare integration', 'xspeed' ),
103 'description' => __( 'Use the credentials below to verify your zone and run purges.', 'xspeed' ),
104 ),
105 'auth_method' => array(
106 'type' => 'enum',
107 'default' => 'token',
108 'options' => array( 'token', 'key' ),
109 'option_labels' => array(
110 'token' => 'API Token',
111 'key' => 'Global API Key',
112 ),
113 'label' => __( 'Authentication', 'xspeed' ),
114 'description' => __( 'API Tokens (scoped, recommended) or the legacy Global API Key with your account email.', 'xspeed' ),
115 'dependsOn' => array( 'field' => 'enabled' ),
116 ),
117 'api_token' => array(
118 'type' => 'secret',
119 'default' => '',
120 'label' => __( 'API Token', 'xspeed' ),
121 'description' => __( 'Create a token at dash.cloudflare.com → My Profile → API Tokens. Needs "Zone → Cache Purge" + "Zone Settings" permissions.', 'xspeed' ),
122 // Only the token auth branch (and only while CF is enabled, via
123 // the transitive gate on auth_method → enabled).
124 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'token' ),
125 ),
126 'email' => array(
127 'type' => 'string',
128 'default' => '',
129 'label' => __( 'Account Email', 'xspeed' ),
130 'description' => __( 'Only used when Authentication is set to Global API Key.', 'xspeed' ),
131 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'key' ),
132 ),
133 'api_key' => array(
134 'type' => 'secret',
135 'default' => '',
136 'label' => __( 'Global API Key', 'xspeed' ),
137 'description' => __( 'Found at dash.cloudflare.com → My Profile → API Tokens → Global API Key.', 'xspeed' ),
138 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'key' ),
139 ),
140 'zone_id' => array(
141 'type' => 'string',
142 'default' => '',
143 'label' => __( 'Zone ID', 'xspeed' ),
144 'description' => __( 'The 32-character hex Zone ID from your domain overview page.', 'xspeed' ),
145 'dependsOn' => array( 'field' => 'enabled' ),
146 ),
147 'auto_purge_on_update' => array(
148 'type' => 'bool',
149 'default' => true,
150 'label' => __( 'Auto-purge Cloudflare on xSpeed purge', 'xspeed' ),
151 'description' => __( 'When xSpeed clears its own cache (post save, settings change, manual purge), trigger a Cloudflare purge too.', 'xspeed' ),
152 'dependsOn' => array( 'field' => 'enabled' ),
153 ),
154 );
155 }
156
157 /**
158 * Encrypt the pre-1.1.0 plaintext credentials on upgrade. api_token /
159 * api_key became `secret`-typed fields (encrypted at rest); this converts
160 * any already-stored plaintext in one pass. Idempotent — encrypt_for_storage
161 * skips a value that already carries the cipher marker. (#115)
162 */
163 public function migrations(): array {
164 return array(
165 '1.1.0' => static function ( array $opts ): array {
166 foreach ( array( 'api_token', 'api_key' ) as $key ) {
167 if ( isset( $opts[ $key ] ) && is_string( $opts[ $key ] ) && '' !== $opts[ $key ] ) {
168 $opts[ $key ] = \XSpeed\Settings_Manager::encrypt_for_storage( $opts[ $key ] );
169 }
170 }
171 return $opts;
172 },
173 );
174 }
175
176 public function rest_routes(): array {
177 $default = parent::rest_routes();
178 return array_merge(
179 $default,
180 array(
181 array(
182 'path' => '/verify',
183 'methods' => 'POST',
184 'callback' => array( $this, 'rest_verify' ),
185 ),
186 array(
187 'path' => '/purge',
188 'methods' => 'POST',
189 'callback' => array( $this, 'rest_purge' ),
190 ),
191 array(
192 'path' => '/dev-mode',
193 'methods' => 'POST',
194 'callback' => array( $this, 'rest_dev_mode' ),
195 ),
196 )
197 );
198 }
199
200 public function conflicts(): array {
201 return array(
202 array(
203 'plugin' => 'cloudflare/cloudflare.php',
204 'feature' => 'cloudflare.purge',
205 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_WARN,
206 'reason' => 'The official Cloudflare plugin also auto-purges; keep auto-purge enabled in only one to avoid double API calls.',
207 ),
208 );
209 }
210
211 public function boot(): void {
212 /*
213 * Deferred to `init`. This module reads its own settings to decide
214 * what to hook, and reading settings builds settings_schema(), whose
215 * labels are declared through __(). boot() runs on `plugins_loaded`,
216 * before `after_setup_theme` — the point WordPress 6.7+ treats as the
217 * earliest safe moment to translate — so doing that here fires
218 * _load_textdomain_just_in_time on every request AND resolves the
219 * labels against a domain that is not loaded yet.
220 *
221 * Everything below hooks actions that fire after `init`, so running
222 * one hook later is equivalent.
223 */
224 add_action( 'init', array( $this, 'boot_on_init' ) );
225 }
226
227 /**
228 * Leave no queued edge calls behind.
229 *
230 * A batch scheduled seconds before the module was switched off would
231 * otherwise fire against a zone the site no longer manages, and the
232 * event would sit in the cron table with no listener after that.
233 */
234 public function deactivate(): void {
235 // `wp_unschedule_hook()`, not `wp_clear_scheduled_hook()`. The latter
236 // keys on `md5( serialize( $args ) )` and defaults `$args` to an
237 // empty array, so it only ever clears the no-arguments key. Every
238 // event this module schedules carries the URL batch as its argument,
239 // so clear_scheduled_hook cleared nothing at all here.
240 wp_unschedule_hook( self::PURGE_URLS_EVENT );
241 wp_unschedule_hook( self::PURGE_ALL_EVENT );
242 }
243
244 /**
245 * The real boot body — see boot() for why it runs on `init`.
246 */
247 public function boot_on_init(): void {
248 $opts = $this->get_settings();
249 if ( empty( $opts['enabled'] ) ) {
250 return;
251 }
252 if ( ! empty( $opts['auto_purge_on_update'] ) ) {
253 // xSpeed fires this action whenever it purges its own
254 // cache (see Cache::purge_all). Listening here keeps
255 // CF in sync without any new wiring elsewhere.
256 add_action( 'xspeed_after_purge_all', array( $this, 'on_xspeed_purge' ), 10, 0 );
257 add_action( 'xspeed_after_purge_url', array( $this, 'on_xspeed_purge_url' ), 10, 1 );
258 add_action( self::PURGE_URLS_EVENT, array( $this, 'purge_edge_urls' ), 10, 1 );
259 add_action( self::PURGE_ALL_EVENT, array( $this, 'purge_edge_all' ), 10, 0 );
260 }
261 }
262
263 public function on_xspeed_purge(): void {
264 /*
265 * `wp xspeed purge` purges the edge itself, as its own reported line
266 * item, and the page step it runs first fires this action. Without
267 * this guard the zone is purged twice per command, and the SECOND
268 * call's outcome — the one nobody reported — is what lands in the
269 * health record the panel reads.
270 *
271 * Gated on covers(), not merely is_running(): on `--type=page` the
272 * action still fires but no edge target runs, so standing down there
273 * would leave the zone stale with nothing in the report to say so.
274 * That run is exactly the one this listener exists for.
275 */
276 if ( class_exists( '\\XSpeed\\Purge_Runner' ) && \XSpeed\Purge_Runner::covers( 'cloudflare' ) ) {
277 return;
278 }
279 if ( true !== $this->can_purge_edge() ) {
280 return;
281 }
282 $this->purge_edge( 'auto-purge' );
283 }
284
285 /**
286 * Mirror a single-URL purge at the edge.
287 *
288 * NOT about post edits — `on_save_post()` calls `purge_all()`, so those
289 * have always reached Cloudflare through the full-purge listener above.
290 * What reaches `purge_url()` is the narrower set: the two admin purge
291 * buttons, an approved comment, a user change, a WooCommerce product or
292 * stock change, `--url` on the CLI and REST, and MCP. Every one of those
293 * cleared xSpeed's copy and left Cloudflare's, so the page stayed stale
294 * at the edge until its lifetime ran out or somebody pressed Purge All —
295 * which is a whole-zone purge to fix one page.
296 *
297 * Single-file purge is also the cheap call, which is the opposite of how
298 * it looks. Cloudflare's tightest documented purge limit is the one on
299 * purge-everything, hostname, tag and prefix; file purges are metered
300 * separately and far more generously. The `purge_all` listener above is
301 * the one near a limit, not this.
302 *
303 * @param array<string,mixed> $context The event payload. See the
304 * `xspeed_after_purge_url` docblock.
305 */
306 public function on_xspeed_purge_url( $context ): void {
307 if ( ! is_array( $context ) || 'urls' !== ( $context['scope'] ?? '' ) ) {
308 return;
309 }
310 $urls = array_filter( array_map( 'strval', (array) ( $context['urls'] ?? array() ) ) );
311 if ( array() === $urls ) {
312 return;
313 }
314 // Same guard as the full-purge listener: `wp xspeed purge` reports
315 // the edge as its own line item, and purging here as well would make
316 // the outcome nobody reported the one that lands in the health record.
317 if ( class_exists( '\\XSpeed\\Purge_Runner' ) && \XSpeed\Purge_Runner::covers( 'cloudflare' ) ) {
318 return;
319 }
320 if ( true !== $this->can_purge_edge() ) {
321 return;
322 }
323
324 // Collected and sent once, not one API call per URL. `Purge_Ui`'s
325 // post purge and the WooCommerce product path both fire a handful of
326 // these in a loop, and a round trip each would be a wait each.
327 if ( array() === $this->pending_edge_urls ) {
328 add_action( 'shutdown', array( $this, 'flush_edge_url_purges' ), 20 );
329 }
330 $blog = function_exists( 'get_current_blog_id' ) ? (int) get_current_blog_id() : 0;
331 foreach ( $urls as $url ) {
332 $this->pending_edge_urls[ $blog ][ $url ] = true;
333 }
334 }
335
336 /**
337 * Hand whatever `on_xspeed_purge_url()` collected to cron.
338 *
339 * Three of the callers are ordinary visitor traffic — an approved
340 * comment, a user registration, a WooCommerce stock change during
341 * checkout — and none of them made an outbound request before this
342 * listener existed. Doing the HTTPS inline would put a blocking round
343 * trip to Cloudflare on the end of a shopper's checkout, once per
344 * request, with the timeout as the worst case. So the batch is scheduled
345 * and the request ends.
346 *
347 * Inline when there is nothing to defer to: cron cannot defer to itself,
348 * and a CLI run exits before a spawned cron request would be served.
349 * Both are contexts where a blocking call is the right answer anyway.
350 *
351 * Deliberately not what WP Rocket does — its Cloudflare add-on calls
352 * `purge_files()` straight from `after_rocket_clean_post`, so a visitor
353 * leaving a comment waits on Cloudflare. LiteSpeed sidesteps it by never
354 * purging Cloudflare per URL at all. Deferring is the same thing
355 * `Preloader` and `Cookie_Inspector` already do here for the same
356 * reason: outbound HTTP belongs in a later request, not on the one that
357 * happened to trigger it.
358 *
359 * Three ways a batch can still be lost, all silent because the health
360 * record is only written inside the flush: a PHP fatal (WordPress's own
361 * fatal handler is registered before `shutdown_action_hook` and ends the
362 * process first), another plugin calling `exit` from a `shutdown`
363 * callback at a priority below 20, and a `purge_url()` raised during
364 * `shutdown` ABOVE priority 20, which re-arms a hook that has already
365 * dispatched. Rare, but this is the note that saves the next person
366 * debugging "the edge kept a stale page" from rediscovering them.
367 *
368 * Public because it is a `shutdown` callback; not part of the module's
369 * contract.
370 */
371 public function flush_edge_url_purges(): void {
372 $batches = $this->pending_edge_urls;
373 $this->pending_edge_urls = array();
374 $current = function_exists( 'get_current_blog_id' ) ? (int) get_current_blog_id() : 0;
375
376 foreach ( $batches as $blog => $keyed ) {
377 $urls = array_keys( $keyed );
378 if ( array() === $urls ) {
379 continue;
380 }
381 // Each batch is scheduled and sent as the site that raised it,
382 // because the cron table, the settings, the health record and the
383 // activity log are all per-site.
384 $switched = (int) $blog !== $current && function_exists( 'switch_to_blog' );
385 if ( $switched ) {
386 switch_to_blog( (int) $blog );
387 }
388 try {
389 $this->dispatch_edge_url_batch( $urls );
390 } finally {
391 // A throwing adapter must not leave the rest of shutdown
392 // running as the wrong site.
393 if ( $switched ) {
394 restore_current_blog();
395 }
396 }
397 }
398 }
399
400 /** Schedule one site's batch, or send it now where there is nothing to defer to. */
401 private function dispatch_edge_url_batch( array $urls ): void {
402 // Too many to name. Purge the zone instead of carrying every URL in
403 // an autoloaded option, and say so, because a zone purge costs more
404 // origin traffic than the page purges it replaces and nobody should
405 // have to infer that it happened.
406 if ( count( $urls ) > self::max_deferred_urls() ) {
407 $this->dispatch_edge_purge_all( count( $urls ) );
408 return;
409 }
410
411 if ( ! self::must_purge_inline() && function_exists( 'wp_schedule_single_event' ) ) {
412 // `$wp_error = true`, because the bare form returns false for two
413 // opposite situations and only one of them is a failure.
414 //
415 // Scheduling at `time()` puts the timestamp in the past by the
416 // time core compares it, which sets core's `$min_timestamp` to 0
417 // (wp-includes/cron.php) — so ANY identical event anywhere in the
418 // cron table, however old, counts as a duplicate and the call
419 // returns false. Two comments on the same post produce
420 // byte-identical args, so the second one would have taken the
421 // inline fallback: a blocking call to Cloudflare on a visitor's
422 // request, which is the exact thing this deferral exists to
423 // avoid, while the already-queued event fired anyway and sent
424 // the batch twice.
425 //
426 // A duplicate means the work is already queued. That is success.
427 $scheduled = wp_schedule_single_event( time(), self::PURGE_URLS_EVENT, array( $urls ), true );
428 if ( true === $scheduled ) {
429 return;
430 }
431 if ( is_wp_error( $scheduled ) && 'duplicate_event' === $scheduled->get_error_code() ) {
432 return;
433 }
434 // Anything else — a filter vetoing the event, a broken cron
435 // table — is a real refusal, and dropping the purge silently
436 // would leave the edge stale with nothing to say so.
437 }
438
439 $this->purge_edge_urls( $urls );
440 }
441
442 /** Is this a context with no later request to defer the edge call to? */
443 private static function must_purge_inline(): bool {
444 if ( defined( 'WP_CLI' ) && WP_CLI ) {
445 return true;
446 }
447 return function_exists( 'wp_doing_cron' ) && wp_doing_cron();
448 }
449
450 /**
451 * How many URLs may ride along in a deferred batch.
452 *
453 * Filterable because the right answer depends on how long a site's cron
454 * backlog sits: the cost is the payload's time in `alloptions`, not the
455 * URL count itself.
456 */
457 private static function max_deferred_urls(): int {
458 if ( ! function_exists( 'apply_filters' ) ) {
459 return self::MAX_DEFERRED_URLS;
460 }
461
462 /**
463 * Filter the batch size above which a zone purge replaces named URLs.
464 *
465 * @param int $max URLs per deferred batch.
466 */
467 $max = (int) apply_filters( 'xspeed_cloudflare_max_deferred_purge_urls', self::MAX_DEFERRED_URLS );
468
469 // A filter of zero would send every single-page purge to the zone.
470 return $max > 0 ? $max : self::MAX_DEFERRED_URLS;
471 }
472
473 /** Queue the zone-wide fallback, or run it now where cron cannot. */
474 private function dispatch_edge_purge_all( int $url_count ): void {
475 if ( class_exists( '\\XSpeed\\Activity_Log' ) ) {
476 \XSpeed\Activity_Log::record(
477 'cache_purged',
478 sprintf(
479 /* translators: %d: number of URLs that changed at once. */
480 __( 'Purging the whole Cloudflare zone: %d URLs changed at once, too many to purge individually', 'xspeed' ),
481 $url_count
482 )
483 );
484 }
485
486 if ( ! self::must_purge_inline() && function_exists( 'wp_schedule_single_event' ) ) {
487 // No arguments, so every oversized batch in a request collapses
488 // onto one event. `duplicate_event` is the wanted outcome here,
489 // not a failure.
490 $scheduled = wp_schedule_single_event( time(), self::PURGE_ALL_EVENT, array(), true );
491 if ( true === $scheduled ) {
492 return;
493 }
494 if ( is_wp_error( $scheduled ) && 'duplicate_event' === $scheduled->get_error_code() ) {
495 return;
496 }
497 }
498
499 $this->purge_edge_all();
500 }
501
502 /**
503 * Purge the whole zone, as the fallback for an oversized batch.
504 *
505 * Public because it is the `PURGE_ALL_EVENT` cron callback. Re-checks the
506 * connection for the same reason the URL batch does: this runs in a later
507 * request than the one that queued it.
508 */
509 public function purge_edge_all(): void {
510 if ( true !== $this->can_purge_edge() ) {
511 return;
512 }
513 $this->purge_edge( 'auto-purge' );
514 }
515
516 /**
517 * Purge a batch of URLs at the edge and record the outcome.
518 *
519 * Public because it is the `PURGE_URLS_EVENT` cron callback.
520 *
521 * @param string[] $urls
522 */
523 public function purge_edge_urls( $urls ): void {
524 $urls = array_values( array_filter( array_map( 'strval', (array) $urls ) ) );
525 if ( array() === $urls ) {
526 return;
527 }
528 // Re-checked here rather than trusted from collect time: a scheduled
529 // batch runs in a later request, and the credentials or the switch
530 // may have changed between the two.
531 if ( true !== $this->can_purge_edge() ) {
532 // Said out loud, because otherwise "the credentials were removed
533 // between queueing and running" and "the purge succeeded" look
534 // identical from the panel, and the pages stay stale at the edge
535 // either way.
536 if ( class_exists( '\\XSpeed\\Activity_Log' ) ) {
537 \XSpeed\Activity_Log::record(
538 'cache_purge_skipped',
539 sprintf(
540 /* translators: %d: number of URLs. */
541 _n(
542 'Skipped a queued Cloudflare purge of %d URL: the connection is no longer available',
543 'Skipped a queued Cloudflare purge of %d URLs: the connection is no longer available',
544 count( $urls ),
545 'xspeed'
546 ),
547 count( $urls )
548 ),
549 \XSpeed\Activity_Log::WARN
550 );
551 }
552 return;
553 }
554
555 $result = Cloudflare::purge_urls( $this->get_settings(), $urls );
556 $ok = ! empty( $result['ok'] );
557 $reason = $ok ? '' : $this->message_of( $result );
558
559 // Recorded for the same reason the full purge is: a token that passes
560 // verify can still lack "Zone → Cache Purge", and a silent auth
561 // failure here means stale pages at the edge with nothing to say so.
562 $this->record_health( $ok, 'purge', $reason );
563
564 if ( ! class_exists( '\\XSpeed\\Activity_Log' ) ) {
565 return;
566 }
567 if ( $ok ) {
568 \XSpeed\Activity_Log::record(
569 'cache_purged',
570 sprintf(
571 /* translators: %d: number of URLs purged. */
572 _n(
573 'Purged %d URL from the Cloudflare edge cache',
574 'Purged %d URLs from the Cloudflare edge cache',
575 count( $urls ),
576 'xspeed'
577 ),
578 count( $urls )
579 ),
580 \XSpeed\Activity_Log::INFO
581 );
582 return;
583 }
584 \XSpeed\Activity_Log::record(
585 'cloudflare_purge_failed',
586 sprintf(
587 /* translators: 1: number of URLs, 2: failure reason. */
588 __( 'Cloudflare URL purge failed (%1$d URL(s)): %2$s', 'xspeed' ),
589 count( $urls ),
590 $reason ? $reason : __( 'unknown error', 'xspeed' )
591 ),
592 \XSpeed\Activity_Log::WARN
593 );
594 }
595
596 /**
597 * Whether this site can purge its Cloudflare zone right now.
598 *
599 * @return true|string True, or the reason it cannot — for the skip line
600 * in `wp xspeed purge`, which has to explain itself
601 * rather than silently do nothing.
602 */
603 public function can_purge_edge() {
604 $opts = $this->get_settings();
605 if ( empty( $opts['enabled'] ) ) {
606 return __( 'the Cloudflare integration is switched off', 'xspeed' );
607 }
608 if ( ! $this->has_credentials( $opts ) ) {
609 return __( 'no zone ID or API credentials are configured', 'xspeed' );
610 }
611
612 return true;
613 }
614
615 /**
616 * Purge the whole zone and record the outcome.
617 *
618 * The one edge-purge path: the auto-purge listener, `wp xspeed cf purge`
619 * and `wp xspeed purge` all land here, so the health record and the
620 * activity log say the same thing whichever one ran.
621 *
622 * @param string $cause Who asked.
623 * @return array{ok:bool,reason:string,status:int,body:mixed} The engine
624 * result plus a normalised `reason`, so the `cf` command can
625 * still print the raw body it always has.
626 */
627 public function purge_edge( string $cause = 'manual' ): array {
628 $result = Cloudflare::purge_all( $this->get_settings() );
629 $ok = ! empty( $result['ok'] );
630 $reason = $ok ? '' : $this->message_of( $result );
631
632 // A GET /zones verify can pass with a token that still lacks the
633 // "Zone → Cache Purge" permission, so the real purge is the only
634 // authoritative signal for purge capability. Record it either way so
635 // a silent auth failure becomes a visible, unresolved warning on the
636 // module rather than an entry buried in the activity log. (#119)
637 $this->record_health( $ok, 'purge', $reason );
638
639 if ( class_exists( '\\XSpeed\\Activity_Log' ) ) {
640 if ( $ok ) {
641 \XSpeed\Activity_Log::record(
642 'cache_purged',
643 sprintf(
644 /* translators: %s: what asked for the purge. */
645 __( 'Purged the Cloudflare edge cache (%s)', 'xspeed' ),
646 $cause
647 ),
648 \XSpeed\Activity_Log::INFO
649 );
650 } else {
651 \XSpeed\Activity_Log::record(
652 'cloudflare_purge_failed',
653 sprintf(
654 /* translators: 1: what asked for the purge, 2: failure reason. */
655 __( 'Cloudflare purge failed (%1$s): %2$s', 'xspeed' ),
656 $cause,
657 $reason ? $reason : __( 'unknown error', 'xspeed' )
658 ),
659 \XSpeed\Activity_Log::WARN
660 );
661 }
662 }
663
664 return array(
665 'ok' => $ok,
666 'reason' => $reason,
667 'status' => (int) ( $result['status'] ?? 0 ),
668 'body' => $result['body'] ?? array(),
669 );
670 }
671
672 /**
673 * Persist any settings sent with the save, then verify the credentials
674 * immediately so an invalid or newly-changed token surfaces on the panel
675 * instead of failing silently the next time xSpeed purges. Response shape
676 * is unchanged (flat settings) so the autosave client is unaffected. (#119)
677 */
678 public function rest_update_settings( \WP_REST_Request $request ) {
679 $params = $request->get_json_params();
680 if ( ! is_array( $params ) ) {
681 $params = $request->get_params();
682 }
683 $settings = $this->update_settings( is_array( $params ) ? $params : array() );
684 $this->verify_and_record();
685 return rest_ensure_response( $settings );
686 }
687
688 public function rest_verify( \WP_REST_Request $request ) {
689 $res = Cloudflare::verify( $this->get_settings() );
690 $this->record_health( ! empty( $res['ok'] ), 'verify', $this->message_of( $res ) );
691 return rest_ensure_response( $res );
692 }
693
694 public function rest_purge( \WP_REST_Request $request ) {
695 $params = $request->get_json_params();
696 if ( ! is_array( $params ) ) {
697 $params = array();
698 }
699 $opts = $this->get_settings();
700 if ( isset( $params['urls'] ) && is_array( $params['urls'] ) && ! empty( $params['urls'] ) ) {
701 return rest_ensure_response( Cloudflare::purge_urls( $opts, $params['urls'] ) );
702 }
703 return rest_ensure_response( Cloudflare::purge_all( $opts ) );
704 }
705
706 public function rest_dev_mode( \WP_REST_Request $request ) {
707 $params = $request->get_json_params();
708 $on = ! empty( $params['on'] );
709 return rest_ensure_response( Cloudflare::set_dev_mode( $this->get_settings(), $on ) );
710 }
711
712 /**
713 * Persistent callouts on the Cloudflare panel: a hard warning when the
714 * connection is enabled but silently failing (bad token, or a purge that
715 * was rejected for lack of the Cache-Purge permission), and a soft warning
716 * when it's enabled but not fully configured yet. (#119)
717 */
718 public function ui_notices(): array {
719 $opts = $this->get_settings();
720 if ( empty( $opts['enabled'] ) ) {
721 return array();
722 }
723 if ( ! $this->has_credentials( $opts ) ) {
724 return array(
725 array(
726 'tone' => 'warn',
727 'title' => __( 'Cloudflare is not fully configured.', 'xspeed' ),
728 'body' => __( 'Add your API token (or Global API Key + account email) and the Zone ID, then press Verify. Until then auto-purge does nothing.', 'xspeed' ),
729 ),
730 );
731 }
732 $health = get_option( self::HEALTH_OPTION, null );
733 if ( is_array( $health ) && array_key_exists( 'ok', $health ) && false === $health['ok'] ) {
734 $context = isset( $health['context'] ) ? (string) $health['context'] : 'verify';
735 $message = isset( $health['message'] ) ? (string) $health['message'] : '';
736 $suffix = '' !== $message ? ': ' . $message : '';
737 if ( 'purge' === $context ) {
738 return array(
739 array(
740 'tone' => 'danger',
741 'title' => __( 'Cloudflare purge is failing.', 'xspeed' ),
742 'body' => sprintf(
743 /* translators: %s: the Cloudflare API error message, or empty. */
744 __( 'The last edge purge was rejected by Cloudflare%s. Confirm the API token includes the "Zone → Cache Purge" permission for this zone — a token that can read the zone can still lack purge rights.', 'xspeed' ),
745 $suffix
746 ),
747 ),
748 );
749 }
750 return array(
751 array(
752 'tone' => 'danger',
753 'title' => __( 'Cloudflare credentials were rejected.', 'xspeed' ),
754 'body' => sprintf(
755 /* translators: %s: the Cloudflare API error message, or empty. */
756 __( 'The saved credentials could not verify this zone%s. Auto-purge will not work until this is fixed.', 'xspeed' ),
757 $suffix
758 ),
759 ),
760 );
761 }
762 return array();
763 }
764
765 /** Verify the current credentials and cache the outcome (save-time hook). */
766 private function verify_and_record(): void {
767 $opts = $this->get_settings();
768 if ( empty( $opts['enabled'] ) || ! $this->has_credentials( $opts ) ) {
769 // Nothing to verify — drop any stale health so an old failure notice
770 // doesn't linger after the user disables or clears the integration.
771 delete_option( self::HEALTH_OPTION );
772 return;
773 }
774 $res = Cloudflare::verify( $opts );
775 $this->record_health( ! empty( $res['ok'] ), 'verify', $this->message_of( $res ) );
776 }
777
778 /** Cache the last verify/purge outcome for ui_notices(). */
779 private function record_health( bool $ok, string $context, string $message ): void {
780 update_option(
781 self::HEALTH_OPTION,
782 array(
783 'ok' => $ok,
784 'context' => $context,
785 'message' => $message,
786 'checked_at' => time(),
787 ),
788 false
789 );
790 }
791
792 /** Whether the current auth branch has all the fields it needs. */
793 private function has_credentials( array $opts ): bool {
794 if ( empty( $opts['zone_id'] ) ) {
795 return false;
796 }
797 $method = isset( $opts['auth_method'] ) ? (string) $opts['auth_method'] : 'token';
798 if ( 'key' === $method ) {
799 return ! empty( $opts['api_key'] ) && ! empty( $opts['email'] );
800 }
801 return ! empty( $opts['api_token'] );
802 }
803
804 /** Human-readable failure reason from a Cloudflare engine result. */
805 private function message_of( array $res ): string {
806 if ( ! empty( $res['ok'] ) ) {
807 return '';
808 }
809 $body = isset( $res['body'] ) && is_array( $res['body'] ) ? $res['body'] : array();
810 if ( ! empty( $body['message'] ) ) {
811 return (string) $body['message'];
812 }
813 if ( ! empty( $body['errors'][0]['message'] ) ) {
814 return (string) $body['errors'][0]['message'];
815 }
816 return 'HTTP ' . ( isset( $res['status'] ) ? (string) $res['status'] : '0' );
817 }
818
819 public function cli_commands(): array {
820 return array(
821 array(
822 'name' => 'xspeed cf',
823 'callback' => array( $this, 'cli_handler' ),
824 'shortdesc' => 'Cloudflare verify / purge / dev-mode helpers.',
825 'ai_hint' => 'Cloudflare operations: verify the API credentials work, purge the edge cache, or toggle development mode. Use when a change is live on the origin but visitors still see the old version — that is usually the edge, not the local cache.',
826 'synopsis' => array(
827 array(
828 'type' => 'positional',
829 'name' => 'action',
830 'options' => array( 'verify', 'purge', 'dev-on', 'dev-off' ),
831 'optional' => false,
832 ),
833 ),
834 ),
835 );
836 }
837
838 public function cli_handler( array $args, array $assoc ): void {
839 $opts = $this->get_settings();
840 $action = $args[0] ?? 'verify';
841 switch ( $action ) {
842 case 'verify':
843 $res = Cloudflare::verify( $opts );
844 break;
845 case 'purge':
846 // Through purge_edge() so a CLI purge records the same health
847 // and activity-log entries as an auto-purge or `wp xspeed
848 // purge`. Calling the engine directly left the panel's health
849 // record showing whatever the last NON-CLI call found.
850 $res = $this->purge_edge( 'CLI' );
851 break;
852 case 'dev-on':
853 $res = Cloudflare::set_dev_mode( $opts, true );
854 break;
855 case 'dev-off':
856 $res = Cloudflare::set_dev_mode( $opts, false );
857 break;
858 default:
859 \WP_CLI::error( "Unknown action: $action" );
860 return;
861 }
862 \WP_CLI::log( 'HTTP ' . $res['status'] . '' . ( $res['ok'] ? 'ok' : 'failed' ) );
863 \WP_CLI::log( wp_json_encode( $res['body'] ) );
864
865 // A failed call must exit non-zero, or the MCP bridge reports the
866 // whole invocation as ok:true and an agent reads a rejected token
867 // or an empty Zone ID as a successful verification.
868 if ( empty( $res['ok'] ) ) {
869 $detail = '';
870 if ( is_array( $res['body'] ) && ! empty( $res['body']['message'] ) ) {
871 $detail = ': ' . $res['body']['message'];
872 }
873 \WP_CLI::error( sprintf( '%s failed (HTTP %s)%s', $action, $res['status'], $detail ) );
874 }
875 }
876 }
877