PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 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 All 35 releases
xspeed / includes / modules / Cloudflare / CloudflareModule.php

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

892 lines 32.6 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 // Something else on the site is already the Cloudflare layer in front
254 // of it. The switch stays as the owner left it, and this zone is not
255 // purged while the block lasts. See Module::blocked_by().
256 if ( null !== $this->blocked_by() ) {
257 return;
258 }
259 if ( ! empty( $opts['auto_purge_on_update'] ) ) {
260 // xSpeed fires this action whenever it purges its own
261 // cache (see Cache::purge_all). Listening here keeps
262 // CF in sync without any new wiring elsewhere.
263 add_action( 'xspeed_after_purge_all', array( $this, 'on_xspeed_purge' ), 10, 0 );
264 add_action( 'xspeed_after_purge_url', array( $this, 'on_xspeed_purge_url' ), 10, 1 );
265 add_action( self::PURGE_URLS_EVENT, array( $this, 'purge_edge_urls' ), 10, 1 );
266 add_action( self::PURGE_ALL_EVENT, array( $this, 'purge_edge_all' ), 10, 0 );
267 }
268 }
269
270 public function on_xspeed_purge(): void {
271 /*
272 * `wp xspeed purge` purges the edge itself, as its own reported line
273 * item, and the page step it runs first fires this action. Without
274 * this guard the zone is purged twice per command, and the SECOND
275 * call's outcome — the one nobody reported — is what lands in the
276 * health record the panel reads.
277 *
278 * Gated on covers(), not merely is_running(): on `--type=page` the
279 * action still fires but no edge target runs, so standing down there
280 * would leave the zone stale with nothing in the report to say so.
281 * That run is exactly the one this listener exists for.
282 */
283 if ( class_exists( '\\XSpeed\\Purge_Runner' ) && \XSpeed\Purge_Runner::covers( 'cloudflare' ) ) {
284 return;
285 }
286 if ( true !== $this->can_purge_edge() ) {
287 return;
288 }
289 $this->purge_edge( 'auto-purge' );
290 }
291
292 /**
293 * Mirror a single-URL purge at the edge.
294 *
295 * Post edits arrive here too when they clear only their affected pages
296 * (`Cache::purge_urls()` publishes one event with every URL); an edit
297 * that clears the whole site comes through the full-purge listener
298 * above. What else reaches `purge_url()` is the narrower set: the two admin purge
299 * buttons, an approved comment, a user change, a WooCommerce product or
300 * stock change, `--url` on the CLI and REST, and MCP. Every one of those
301 * cleared xSpeed's copy and left Cloudflare's, so the page stayed stale
302 * at the edge until its lifetime ran out or somebody pressed Purge All —
303 * which is a whole-zone purge to fix one page.
304 *
305 * Single-file purge is also the cheap call, which is the opposite of how
306 * it looks. Cloudflare's tightest documented purge limit is the one on
307 * purge-everything, hostname, tag and prefix; file purges are metered
308 * separately and far more generously. The `purge_all` listener above is
309 * the one near a limit, not this.
310 *
311 * @param array<string,mixed> $context The event payload. See the
312 * `xspeed_after_purge_url` docblock.
313 */
314 public function on_xspeed_purge_url( $context ): void {
315 if ( ! is_array( $context ) || 'urls' !== ( $context['scope'] ?? '' ) ) {
316 return;
317 }
318 $urls = array_filter( array_map( 'strval', (array) ( $context['urls'] ?? array() ) ) );
319 if ( array() === $urls ) {
320 return;
321 }
322 // Same guard as the full-purge listener: `wp xspeed purge` reports
323 // the edge as its own line item, and purging here as well would make
324 // the outcome nobody reported the one that lands in the health record.
325 if ( class_exists( '\\XSpeed\\Purge_Runner' ) && \XSpeed\Purge_Runner::covers( 'cloudflare' ) ) {
326 return;
327 }
328 if ( true !== $this->can_purge_edge() ) {
329 return;
330 }
331
332 // Collected and sent once, not one API call per URL. A request can
333 // raise several of these (a bulk edit, a stock change on each item
334 // of an order), and a round trip each would be a wait each.
335 if ( array() === $this->pending_edge_urls ) {
336 add_action( 'shutdown', array( $this, 'flush_edge_url_purges' ), 20 );
337 }
338 $blog = function_exists( 'get_current_blog_id' ) ? (int) get_current_blog_id() : 0;
339 foreach ( $urls as $url ) {
340 $this->pending_edge_urls[ $blog ][ $url ] = true;
341 }
342 \XSpeed\Cache::note_purge_forwarded( 'Cloudflare' );
343 }
344
345 /**
346 * Hand whatever `on_xspeed_purge_url()` collected to cron.
347 *
348 * Three of the callers are ordinary visitor traffic — an approved
349 * comment, a user registration, a WooCommerce stock change during
350 * checkout — and none of them made an outbound request before this
351 * listener existed. Doing the HTTPS inline would put a blocking round
352 * trip to Cloudflare on the end of a shopper's checkout, once per
353 * request, with the timeout as the worst case. So the batch is scheduled
354 * and the request ends.
355 *
356 * Inline when there is nothing to defer to: cron cannot defer to itself,
357 * and a CLI run exits before a spawned cron request would be served.
358 * Both are contexts where a blocking call is the right answer anyway.
359 *
360 * Deliberately not what WP Rocket does — its Cloudflare add-on calls
361 * `purge_files()` straight from `after_rocket_clean_post`, so a visitor
362 * leaving a comment waits on Cloudflare. LiteSpeed sidesteps it by never
363 * purging Cloudflare per URL at all. Deferring is the same thing
364 * `Preloader` and `Cookie_Inspector` already do here for the same
365 * reason: outbound HTTP belongs in a later request, not on the one that
366 * happened to trigger it.
367 *
368 * Three ways a batch can still be lost, all silent because the health
369 * record is only written inside the flush: a PHP fatal (WordPress's own
370 * fatal handler is registered before `shutdown_action_hook` and ends the
371 * process first), another plugin calling `exit` from a `shutdown`
372 * callback at a priority below 20, and a `purge_url()` raised during
373 * `shutdown` ABOVE priority 20, which re-arms a hook that has already
374 * dispatched. Rare, but this is the note that saves the next person
375 * debugging "the edge kept a stale page" from rediscovering them.
376 *
377 * Public because it is a `shutdown` callback; not part of the module's
378 * contract.
379 */
380 public function flush_edge_url_purges(): void {
381 $batches = $this->pending_edge_urls;
382 $this->pending_edge_urls = array();
383 $current = function_exists( 'get_current_blog_id' ) ? (int) get_current_blog_id() : 0;
384
385 foreach ( $batches as $blog => $keyed ) {
386 $urls = array_keys( $keyed );
387 if ( array() === $urls ) {
388 continue;
389 }
390 // Each batch is scheduled and sent as the site that raised it,
391 // because the cron table, the settings, the health record and the
392 // activity log are all per-site.
393 $switched = (int) $blog !== $current && function_exists( 'switch_to_blog' );
394 if ( $switched ) {
395 switch_to_blog( (int) $blog );
396 }
397 try {
398 $this->dispatch_edge_url_batch( $urls );
399 } finally {
400 // A throwing adapter must not leave the rest of shutdown
401 // running as the wrong site.
402 if ( $switched ) {
403 restore_current_blog();
404 }
405 }
406 }
407 }
408
409 /** Schedule one site's batch, or send it now where there is nothing to defer to. */
410 private function dispatch_edge_url_batch( array $urls ): void {
411 // Too many to name. Purge the zone instead of carrying every URL in
412 // an autoloaded option, and say so, because a zone purge costs more
413 // origin traffic than the page purges it replaces and nobody should
414 // have to infer that it happened.
415 if ( count( $urls ) > self::max_deferred_urls() ) {
416 $this->dispatch_edge_purge_all( count( $urls ) );
417 return;
418 }
419
420 if ( ! self::must_purge_inline() && function_exists( 'wp_schedule_single_event' ) ) {
421 // `$wp_error = true`, because the bare form returns false for two
422 // opposite situations and only one of them is a failure.
423 //
424 // Scheduling at `time()` puts the timestamp in the past by the
425 // time core compares it, which sets core's `$min_timestamp` to 0
426 // (wp-includes/cron.php) — so ANY identical event anywhere in the
427 // cron table, however old, counts as a duplicate and the call
428 // returns false. Two comments on the same post produce
429 // byte-identical args, so the second one would have taken the
430 // inline fallback: a blocking call to Cloudflare on a visitor's
431 // request, which is the exact thing this deferral exists to
432 // avoid, while the already-queued event fired anyway and sent
433 // the batch twice.
434 //
435 // A duplicate means the work is already queued. That is success.
436 $scheduled = wp_schedule_single_event( time(), self::PURGE_URLS_EVENT, array( $urls ), true );
437 if ( true === $scheduled ) {
438 return;
439 }
440 if ( is_wp_error( $scheduled ) && 'duplicate_event' === $scheduled->get_error_code() ) {
441 return;
442 }
443 // Anything else — a filter vetoing the event, a broken cron
444 // table — is a real refusal, and dropping the purge silently
445 // would leave the edge stale with nothing to say so.
446 }
447
448 $this->purge_edge_urls( $urls );
449 }
450
451 /** Is this a context with no later request to defer the edge call to? */
452 private static function must_purge_inline(): bool {
453 if ( defined( 'WP_CLI' ) && WP_CLI ) {
454 return true;
455 }
456 return function_exists( 'wp_doing_cron' ) && wp_doing_cron();
457 }
458
459 /**
460 * How many URLs may ride along in a deferred batch.
461 *
462 * Filterable because the right answer depends on how long a site's cron
463 * backlog sits: the cost is the payload's time in `alloptions`, not the
464 * URL count itself.
465 */
466 private static function max_deferred_urls(): int {
467 if ( ! function_exists( 'apply_filters' ) ) {
468 return self::MAX_DEFERRED_URLS;
469 }
470
471 /**
472 * Filter the batch size above which a zone purge replaces named URLs.
473 *
474 * @param int $max URLs per deferred batch.
475 */
476 $max = (int) apply_filters( 'xspeed_cloudflare_max_deferred_purge_urls', self::MAX_DEFERRED_URLS );
477
478 // A filter of zero would send every single-page purge to the zone.
479 return $max > 0 ? $max : self::MAX_DEFERRED_URLS;
480 }
481
482 /** Queue the zone-wide fallback, or run it now where cron cannot. */
483 private function dispatch_edge_purge_all( int $url_count ): void {
484 if ( class_exists( '\\XSpeed\\Activity_Log' ) ) {
485 \XSpeed\Activity_Log::record(
486 'cache_purged',
487 sprintf(
488 /* translators: %d: number of URLs that changed at once. */
489 __( 'Purging the whole Cloudflare zone: %d URLs changed at once, too many to purge individually', 'xspeed' ),
490 $url_count
491 )
492 );
493 }
494
495 if ( ! self::must_purge_inline() && function_exists( 'wp_schedule_single_event' ) ) {
496 // No arguments, so every oversized batch in a request collapses
497 // onto one event. `duplicate_event` is the wanted outcome here,
498 // not a failure.
499 $scheduled = wp_schedule_single_event( time(), self::PURGE_ALL_EVENT, array(), true );
500 if ( true === $scheduled ) {
501 return;
502 }
503 if ( is_wp_error( $scheduled ) && 'duplicate_event' === $scheduled->get_error_code() ) {
504 return;
505 }
506 }
507
508 $this->purge_edge_all();
509 }
510
511 /**
512 * Purge the whole zone, as the fallback for an oversized batch.
513 *
514 * Public because it is the `PURGE_ALL_EVENT` cron callback. Re-checks the
515 * connection for the same reason the URL batch does: this runs in a later
516 * request than the one that queued it.
517 */
518 public function purge_edge_all(): void {
519 if ( true !== $this->can_purge_edge() ) {
520 return;
521 }
522 $this->purge_edge( 'auto-purge' );
523 }
524
525 /**
526 * Purge a batch of URLs at the edge and record the outcome.
527 *
528 * Public because it is the `PURGE_URLS_EVENT` cron callback.
529 *
530 * @param string[] $urls
531 */
532 public function purge_edge_urls( $urls ): void {
533 $urls = array_values( array_filter( array_map( 'strval', (array) $urls ) ) );
534 if ( array() === $urls ) {
535 return;
536 }
537 // Re-checked here rather than trusted from collect time: a scheduled
538 // batch runs in a later request, and the credentials or the switch
539 // may have changed between the two.
540 if ( true !== $this->can_purge_edge() ) {
541 // Said out loud, because otherwise "the credentials were removed
542 // between queueing and running" and "the purge succeeded" look
543 // identical from the panel, and the pages stay stale at the edge
544 // either way.
545 if ( class_exists( '\\XSpeed\\Activity_Log' ) ) {
546 \XSpeed\Activity_Log::record(
547 'cache_purge_skipped',
548 sprintf(
549 /* translators: %d: number of URLs. */
550 _n(
551 'Skipped a queued Cloudflare purge of %d URL: the connection is no longer available',
552 'Skipped a queued Cloudflare purge of %d URLs: the connection is no longer available',
553 count( $urls ),
554 'xspeed'
555 ),
556 count( $urls )
557 ),
558 \XSpeed\Activity_Log::WARN
559 );
560 }
561 return;
562 }
563
564 $result = Cloudflare::purge_urls( $this->get_settings(), $urls );
565 $ok = ! empty( $result['ok'] );
566 $reason = $ok ? '' : $this->message_of( $result );
567
568 // Recorded for the same reason the full purge is: a token that passes
569 // verify can still lack "Zone → Cache Purge", and a silent auth
570 // failure here means stale pages at the edge with nothing to say so.
571 $this->record_health( $ok, 'purge', $reason );
572
573 if ( ! class_exists( '\\XSpeed\\Activity_Log' ) ) {
574 return;
575 }
576 if ( $ok ) {
577 \XSpeed\Activity_Log::record(
578 'cache_purged',
579 sprintf(
580 /* translators: %d: number of URLs purged. */
581 _n(
582 'Purged %d URL from the Cloudflare edge cache',
583 'Purged %d URLs from the Cloudflare edge cache',
584 count( $urls ),
585 'xspeed'
586 ),
587 count( $urls )
588 ),
589 \XSpeed\Activity_Log::INFO
590 );
591 return;
592 }
593 \XSpeed\Activity_Log::record(
594 'cloudflare_purge_failed',
595 sprintf(
596 /* translators: 1: number of URLs, 2: failure reason. */
597 __( 'Cloudflare URL purge failed (%1$d URL(s)): %2$s', 'xspeed' ),
598 count( $urls ),
599 $reason ? $reason : __( 'unknown error', 'xspeed' )
600 ),
601 \XSpeed\Activity_Log::WARN
602 );
603 }
604
605 /**
606 * Whether this site can purge its Cloudflare zone right now.
607 *
608 * @return true|string True, or the reason it cannot — for the skip line
609 * in `wp xspeed purge`, which has to explain itself
610 * rather than silently do nothing.
611 */
612 public function can_purge_edge() {
613 $opts = $this->get_settings();
614 if ( empty( $opts['enabled'] ) ) {
615 return __( 'the Cloudflare integration is switched off', 'xspeed' );
616 }
617 $blocked = $this->blocked_by();
618 if ( null !== $blocked ) {
619 return $blocked;
620 }
621 if ( ! $this->has_credentials( $opts ) ) {
622 return __( 'no zone ID or API credentials are configured', 'xspeed' );
623 }
624
625 return true;
626 }
627
628 /**
629 * Purge the whole zone and record the outcome.
630 *
631 * The one edge-purge path: the auto-purge listener, `wp xspeed cf purge`
632 * and `wp xspeed purge` all land here, so the health record and the
633 * activity log say the same thing whichever one ran.
634 *
635 * @param string $cause Who asked.
636 * @return array{ok:bool,reason:string,status:int,body:mixed} The engine
637 * result plus a normalised `reason`, so the `cf` command can
638 * still print the raw body it always has.
639 */
640 public function purge_edge( string $cause = 'manual' ): array {
641 $result = Cloudflare::purge_all( $this->get_settings() );
642 $ok = ! empty( $result['ok'] );
643 $reason = $ok ? '' : $this->message_of( $result );
644
645 // A GET /zones verify can pass with a token that still lacks the
646 // "Zone → Cache Purge" permission, so the real purge is the only
647 // authoritative signal for purge capability. Record it either way so
648 // a silent auth failure becomes a visible, unresolved warning on the
649 // module rather than an entry buried in the activity log. (#119)
650 $this->record_health( $ok, 'purge', $reason );
651
652 if ( class_exists( '\\XSpeed\\Activity_Log' ) ) {
653 if ( $ok ) {
654 \XSpeed\Activity_Log::record(
655 'cache_purged',
656 sprintf(
657 /* translators: %s: what asked for the purge. */
658 __( 'Purged the Cloudflare edge cache (%s)', 'xspeed' ),
659 $cause
660 ),
661 \XSpeed\Activity_Log::INFO
662 );
663 } else {
664 \XSpeed\Activity_Log::record(
665 'cloudflare_purge_failed',
666 sprintf(
667 /* translators: 1: what asked for the purge, 2: failure reason. */
668 __( 'Cloudflare purge failed (%1$s): %2$s', 'xspeed' ),
669 $cause,
670 $reason ? $reason : __( 'unknown error', 'xspeed' )
671 ),
672 \XSpeed\Activity_Log::WARN
673 );
674 }
675 }
676
677 return array(
678 'ok' => $ok,
679 'reason' => $reason,
680 'status' => (int) ( $result['status'] ?? 0 ),
681 'body' => $result['body'] ?? array(),
682 );
683 }
684
685 /**
686 * Persist any settings sent with the save, then verify the credentials
687 * immediately so an invalid or newly-changed token surfaces on the panel
688 * instead of failing silently the next time xSpeed purges. Response shape
689 * is unchanged (flat settings) so the autosave client is unaffected. (#119)
690 *
691 * The parent writes the settings, so its licence, blocked-by and
692 * wp-config refusals apply here too; a refused write is not verified.
693 */
694 public function rest_update_settings( \WP_REST_Request $request ) {
695 $response = parent::rest_update_settings( $request );
696 if ( is_wp_error( $response ) ) {
697 return $response;
698 }
699 $this->verify_and_record();
700 return $response;
701 }
702
703 public function rest_verify( \WP_REST_Request $request ) {
704 $res = Cloudflare::verify( $this->get_settings() );
705 $this->record_health( ! empty( $res['ok'] ), 'verify', $this->message_of( $res ) );
706 return rest_ensure_response( $res );
707 }
708
709 public function rest_purge( \WP_REST_Request $request ) {
710 $params = $request->get_json_params();
711 if ( ! is_array( $params ) ) {
712 $params = array();
713 }
714 $opts = $this->get_settings();
715 if ( isset( $params['urls'] ) && is_array( $params['urls'] ) && ! empty( $params['urls'] ) ) {
716 return rest_ensure_response( Cloudflare::purge_urls( $opts, $params['urls'] ) );
717 }
718 return rest_ensure_response( Cloudflare::purge_all( $opts ) );
719 }
720
721 public function rest_dev_mode( \WP_REST_Request $request ) {
722 $params = $request->get_json_params();
723 $on = ! empty( $params['on'] );
724 return rest_ensure_response( Cloudflare::set_dev_mode( $this->get_settings(), $on ) );
725 }
726
727 /**
728 * Persistent callouts on the Cloudflare panel: a hard warning when the
729 * connection is enabled but silently failing (bad token, or a purge that
730 * was rejected for lack of the Cache-Purge permission), and a soft warning
731 * when it's enabled but not fully configured yet. (#119)
732 */
733 public function ui_notices(): array {
734 $opts = $this->get_settings();
735 if ( empty( $opts['enabled'] ) ) {
736 return array();
737 }
738 if ( ! $this->has_credentials( $opts ) ) {
739 return array(
740 array(
741 'tone' => 'warn',
742 'title' => __( 'Cloudflare is not fully configured.', 'xspeed' ),
743 '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' ),
744 ),
745 );
746 }
747 $health = get_option( self::HEALTH_OPTION, null );
748 if ( is_array( $health ) && array_key_exists( 'ok', $health ) && false === $health['ok'] ) {
749 $context = isset( $health['context'] ) ? (string) $health['context'] : 'verify';
750 $message = isset( $health['message'] ) ? (string) $health['message'] : '';
751 $suffix = '' !== $message ? ': ' . $message : '';
752 if ( 'purge' === $context ) {
753 return array(
754 array(
755 'tone' => 'danger',
756 'title' => __( 'Cloudflare purge is failing.', 'xspeed' ),
757 'body' => sprintf(
758 /* translators: %s: the Cloudflare API error message, or empty. */
759 __( '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' ),
760 $suffix
761 ),
762 ),
763 );
764 }
765 return array(
766 array(
767 'tone' => 'danger',
768 'title' => __( 'Cloudflare credentials were rejected.', 'xspeed' ),
769 'body' => sprintf(
770 /* translators: %s: the Cloudflare API error message, or empty. */
771 __( 'The saved credentials could not verify this zone%s. Auto-purge will not work until this is fixed.', 'xspeed' ),
772 $suffix
773 ),
774 ),
775 );
776 }
777 return array();
778 }
779
780 /** Verify the current credentials and cache the outcome (save-time hook). */
781 private function verify_and_record(): void {
782 $opts = $this->get_settings();
783 if ( empty( $opts['enabled'] ) || ! $this->has_credentials( $opts ) ) {
784 // Nothing to verify — drop any stale health so an old failure notice
785 // doesn't linger after the user disables or clears the integration.
786 delete_option( self::HEALTH_OPTION );
787 return;
788 }
789 $res = Cloudflare::verify( $opts );
790 $this->record_health( ! empty( $res['ok'] ), 'verify', $this->message_of( $res ) );
791 }
792
793 /** Cache the last verify/purge outcome for ui_notices(). */
794 private function record_health( bool $ok, string $context, string $message ): void {
795 update_option(
796 self::HEALTH_OPTION,
797 array(
798 'ok' => $ok,
799 'context' => $context,
800 'message' => $message,
801 'checked_at' => time(),
802 ),
803 false
804 );
805 }
806
807 /** Whether the current auth branch has all the fields it needs. */
808 private function has_credentials( array $opts ): bool {
809 if ( empty( $opts['zone_id'] ) ) {
810 return false;
811 }
812 $method = isset( $opts['auth_method'] ) ? (string) $opts['auth_method'] : 'token';
813 if ( 'key' === $method ) {
814 return ! empty( $opts['api_key'] ) && ! empty( $opts['email'] );
815 }
816 return ! empty( $opts['api_token'] );
817 }
818
819 /** Human-readable failure reason from a Cloudflare engine result. */
820 private function message_of( array $res ): string {
821 if ( ! empty( $res['ok'] ) ) {
822 return '';
823 }
824 $body = isset( $res['body'] ) && is_array( $res['body'] ) ? $res['body'] : array();
825 if ( ! empty( $body['message'] ) ) {
826 return (string) $body['message'];
827 }
828 if ( ! empty( $body['errors'][0]['message'] ) ) {
829 return (string) $body['errors'][0]['message'];
830 }
831 return 'HTTP ' . ( isset( $res['status'] ) ? (string) $res['status'] : '0' );
832 }
833
834 public function cli_commands(): array {
835 return array(
836 array(
837 'name' => 'xspeed cf',
838 'callback' => array( $this, 'cli_handler' ),
839 'shortdesc' => 'Cloudflare verify / purge / dev-mode helpers.',
840 '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.',
841 'synopsis' => array(
842 array(
843 'type' => 'positional',
844 'name' => 'action',
845 'options' => array( 'verify', 'purge', 'dev-on', 'dev-off' ),
846 'optional' => false,
847 ),
848 ),
849 ),
850 );
851 }
852
853 public function cli_handler( array $args, array $assoc ): void {
854 $opts = $this->get_settings();
855 $action = $args[0] ?? 'verify';
856 switch ( $action ) {
857 case 'verify':
858 $res = Cloudflare::verify( $opts );
859 break;
860 case 'purge':
861 // Through purge_edge() so a CLI purge records the same health
862 // and activity-log entries as an auto-purge or `wp xspeed
863 // purge`. Calling the engine directly left the panel's health
864 // record showing whatever the last NON-CLI call found.
865 $res = $this->purge_edge( 'CLI' );
866 break;
867 case 'dev-on':
868 $res = Cloudflare::set_dev_mode( $opts, true );
869 break;
870 case 'dev-off':
871 $res = Cloudflare::set_dev_mode( $opts, false );
872 break;
873 default:
874 \WP_CLI::error( "Unknown action: $action" );
875 return;
876 }
877 \WP_CLI::log( 'HTTP ' . $res['status'] . ' — ' . ( $res['ok'] ? 'ok' : 'failed' ) );
878 \WP_CLI::log( wp_json_encode( $res['body'] ) );
879
880 // A failed call must exit non-zero, or the MCP bridge reports the
881 // whole invocation as ok:true and an agent reads a rejected token
882 // or an empty Zone ID as a successful verification.
883 if ( empty( $res['ok'] ) ) {
884 $detail = '';
885 if ( is_array( $res['body'] ) && ! empty( $res['body']['message'] ) ) {
886 $detail = ': ' . $res['body']['message'];
887 }
888 \WP_CLI::error( sprintf( '%s failed (HTTP %s)%s', $action, $res['status'], $detail ) );
889 }
890 }
891 }
892