PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 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 All 33 releases
xspeed / includes / modules / Cloudflare / CloudflareModule.php

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

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