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

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

509 lines 17.8 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 public function ui_metadata(): array {
39 return array(
40 'label' => __( 'Cloudflare', 'xspeed' ),
41 'icon' => 'Cloud',
42 'description' => __( 'Connect a Cloudflare zone for automatic edge purging when xSpeed clears its cache, plus a dev-mode toggle.', 'xspeed' ),
43 'custom_panel' => 'CloudflarePanel',
44 );
45 }
46
47 /**
48 * @inheritDoc
49 *
50 * Nothing exempt. It is inert without Cloudflare credentials, and where
51 * credentials exist the user set them up for a CDN rather than for the page
52 * cache we stood down from — but "inert today" is a weak reason to leave a
53 * switch on that nobody asked for, and on a site where the host DID take
54 * the page cache it is not inert at all.
55 */
56 public function conflict_safe_exempt(): array {
57 return array();
58 }
59
60 public function settings_schema(): array {
61 return array(
62 'enabled' => array(
63 'type' => 'bool',
64 'default' => false,
65 'label' => __( 'Enable Cloudflare integration', 'xspeed' ),
66 'description' => __( 'Use the credentials below to verify your zone and run purges.', 'xspeed' ),
67 ),
68 'auth_method' => array(
69 'type' => 'enum',
70 'default' => 'token',
71 'options' => array( 'token', 'key' ),
72 'option_labels' => array(
73 'token' => 'API Token',
74 'key' => 'Global API Key',
75 ),
76 'label' => __( 'Authentication', 'xspeed' ),
77 'description' => __( 'API Tokens (scoped, recommended) or the legacy Global API Key with your account email.', 'xspeed' ),
78 'dependsOn' => array( 'field' => 'enabled' ),
79 ),
80 'api_token' => array(
81 'type' => 'secret',
82 'default' => '',
83 'label' => __( 'API Token', 'xspeed' ),
84 'description' => __( 'Create a token at dash.cloudflare.com → My Profile → API Tokens. Needs "Zone → Cache Purge" + "Zone Settings" permissions.', 'xspeed' ),
85 // Only the token auth branch (and only while CF is enabled, via
86 // the transitive gate on auth_method → enabled).
87 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'token' ),
88 ),
89 'email' => array(
90 'type' => 'string',
91 'default' => '',
92 'label' => __( 'Account Email', 'xspeed' ),
93 'description' => __( 'Only used when Authentication is set to Global API Key.', 'xspeed' ),
94 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'key' ),
95 ),
96 'api_key' => array(
97 'type' => 'secret',
98 'default' => '',
99 'label' => __( 'Global API Key', 'xspeed' ),
100 'description' => __( 'Found at dash.cloudflare.com → My Profile → API Tokens → Global API Key.', 'xspeed' ),
101 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'key' ),
102 ),
103 'zone_id' => array(
104 'type' => 'string',
105 'default' => '',
106 'label' => __( 'Zone ID', 'xspeed' ),
107 'description' => __( 'The 32-character hex Zone ID from your domain overview page.', 'xspeed' ),
108 'dependsOn' => array( 'field' => 'enabled' ),
109 ),
110 'auto_purge_on_update' => array(
111 'type' => 'bool',
112 'default' => true,
113 'label' => __( 'Auto-purge Cloudflare on xSpeed purge', 'xspeed' ),
114 'description' => __( 'When xSpeed clears its own cache (post save, settings change, manual purge), trigger a Cloudflare purge too.', 'xspeed' ),
115 'dependsOn' => array( 'field' => 'enabled' ),
116 ),
117 );
118 }
119
120 /**
121 * Encrypt the pre-1.1.0 plaintext credentials on upgrade. api_token /
122 * api_key became `secret`-typed fields (encrypted at rest); this converts
123 * any already-stored plaintext in one pass. Idempotent — encrypt_for_storage
124 * skips a value that already carries the cipher marker. (#115)
125 */
126 public function migrations(): array {
127 return array(
128 '1.1.0' => static function ( array $opts ): array {
129 foreach ( array( 'api_token', 'api_key' ) as $key ) {
130 if ( isset( $opts[ $key ] ) && is_string( $opts[ $key ] ) && '' !== $opts[ $key ] ) {
131 $opts[ $key ] = \XSpeed\Settings_Manager::encrypt_for_storage( $opts[ $key ] );
132 }
133 }
134 return $opts;
135 },
136 );
137 }
138
139 public function rest_routes(): array {
140 $default = parent::rest_routes();
141 return array_merge(
142 $default,
143 array(
144 array(
145 'path' => '/verify',
146 'methods' => 'POST',
147 'callback' => array( $this, 'rest_verify' ),
148 ),
149 array(
150 'path' => '/purge',
151 'methods' => 'POST',
152 'callback' => array( $this, 'rest_purge' ),
153 ),
154 array(
155 'path' => '/dev-mode',
156 'methods' => 'POST',
157 'callback' => array( $this, 'rest_dev_mode' ),
158 ),
159 )
160 );
161 }
162
163 public function conflicts(): array {
164 return array(
165 array(
166 'plugin' => 'cloudflare/cloudflare.php',
167 'feature' => 'cloudflare.purge',
168 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_WARN,
169 'reason' => 'The official Cloudflare plugin also auto-purges; keep auto-purge enabled in only one to avoid double API calls.',
170 ),
171 );
172 }
173
174 public function boot(): void {
175 /*
176 * Deferred to `init`. This module reads its own settings to decide
177 * what to hook, and reading settings builds settings_schema(), whose
178 * labels are declared through __(). boot() runs on `plugins_loaded`,
179 * before `after_setup_theme` — the point WordPress 6.7+ treats as the
180 * earliest safe moment to translate — so doing that here fires
181 * _load_textdomain_just_in_time on every request AND resolves the
182 * labels against a domain that is not loaded yet.
183 *
184 * Everything below hooks actions that fire after `init`, so running
185 * one hook later is equivalent.
186 */
187 add_action( 'init', array( $this, 'boot_on_init' ) );
188 }
189
190 /**
191 * The real boot body — see boot() for why it runs on `init`.
192 */
193 public function boot_on_init(): void {
194 $opts = $this->get_settings();
195 if ( empty( $opts['enabled'] ) ) {
196 return;
197 }
198 if ( ! empty( $opts['auto_purge_on_update'] ) ) {
199 // xSpeed fires this action whenever it purges its own
200 // cache (see Cache::purge_all). Listening here keeps
201 // CF in sync without any new wiring elsewhere.
202 add_action( 'xspeed_after_purge_all', array( $this, 'on_xspeed_purge' ), 10, 0 );
203 }
204 }
205
206 public function on_xspeed_purge(): void {
207 /*
208 * `wp xspeed purge` purges the edge itself, as its own reported line
209 * item, and the page step it runs first fires this action. Without
210 * this guard the zone is purged twice per command, and the SECOND
211 * call's outcome — the one nobody reported — is what lands in the
212 * health record the panel reads.
213 *
214 * Gated on covers(), not merely is_running(): on `--type=page` the
215 * action still fires but no edge target runs, so standing down there
216 * would leave the zone stale with nothing in the report to say so.
217 * That run is exactly the one this listener exists for.
218 */
219 if ( class_exists( '\\XSpeed\\Purge_Runner' ) && \XSpeed\Purge_Runner::covers( 'cloudflare' ) ) {
220 return;
221 }
222 if ( true !== $this->can_purge_edge() ) {
223 return;
224 }
225 $this->purge_edge( 'auto-purge' );
226 }
227
228 /**
229 * Whether this site can purge its Cloudflare zone right now.
230 *
231 * @return true|string True, or the reason it cannot — for the skip line
232 * in `wp xspeed purge`, which has to explain itself
233 * rather than silently do nothing.
234 */
235 public function can_purge_edge() {
236 $opts = $this->get_settings();
237 if ( empty( $opts['enabled'] ) ) {
238 return __( 'the Cloudflare integration is switched off', 'xspeed' );
239 }
240 if ( ! $this->has_credentials( $opts ) ) {
241 return __( 'no zone ID or API credentials are configured', 'xspeed' );
242 }
243
244 return true;
245 }
246
247 /**
248 * Purge the whole zone and record the outcome.
249 *
250 * The one edge-purge path: the auto-purge listener, `wp xspeed cf purge`
251 * and `wp xspeed purge` all land here, so the health record and the
252 * activity log say the same thing whichever one ran.
253 *
254 * @param string $cause Who asked.
255 * @return array{ok:bool,reason:string,status:int,body:mixed} The engine
256 * result plus a normalised `reason`, so the `cf` command can
257 * still print the raw body it always has.
258 */
259 public function purge_edge( string $cause = 'manual' ): array {
260 $result = Cloudflare::purge_all( $this->get_settings() );
261 $ok = ! empty( $result['ok'] );
262 $reason = $ok ? '' : $this->message_of( $result );
263
264 // A GET /zones verify can pass with a token that still lacks the
265 // "Zone → Cache Purge" permission, so the real purge is the only
266 // authoritative signal for purge capability. Record it either way so
267 // a silent auth failure becomes a visible, unresolved warning on the
268 // module rather than an entry buried in the activity log. (#119)
269 $this->record_health( $ok, 'purge', $reason );
270
271 if ( class_exists( '\\XSpeed\\Activity_Log' ) ) {
272 if ( $ok ) {
273 \XSpeed\Activity_Log::record(
274 'cache_purged',
275 sprintf(
276 /* translators: %s: what asked for the purge. */
277 __( 'Purged the Cloudflare edge cache (%s)', 'xspeed' ),
278 $cause
279 ),
280 \XSpeed\Activity_Log::INFO
281 );
282 } else {
283 \XSpeed\Activity_Log::record(
284 'cloudflare_purge_failed',
285 sprintf(
286 /* translators: 1: what asked for the purge, 2: failure reason. */
287 __( 'Cloudflare purge failed (%1$s): %2$s', 'xspeed' ),
288 $cause,
289 $reason ? $reason : __( 'unknown error', 'xspeed' )
290 ),
291 \XSpeed\Activity_Log::WARN
292 );
293 }
294 }
295
296 return array(
297 'ok' => $ok,
298 'reason' => $reason,
299 'status' => (int) ( $result['status'] ?? 0 ),
300 'body' => $result['body'] ?? array(),
301 );
302 }
303
304 /**
305 * Persist any settings sent with the save, then verify the credentials
306 * immediately so an invalid or newly-changed token surfaces on the panel
307 * instead of failing silently the next time xSpeed purges. Response shape
308 * is unchanged (flat settings) so the autosave client is unaffected. (#119)
309 */
310 public function rest_update_settings( \WP_REST_Request $request ) {
311 $params = $request->get_json_params();
312 if ( ! is_array( $params ) ) {
313 $params = $request->get_params();
314 }
315 $settings = $this->update_settings( is_array( $params ) ? $params : array() );
316 $this->verify_and_record();
317 return rest_ensure_response( $settings );
318 }
319
320 public function rest_verify( \WP_REST_Request $request ) {
321 $res = Cloudflare::verify( $this->get_settings() );
322 $this->record_health( ! empty( $res['ok'] ), 'verify', $this->message_of( $res ) );
323 return rest_ensure_response( $res );
324 }
325
326 public function rest_purge( \WP_REST_Request $request ) {
327 $params = $request->get_json_params();
328 if ( ! is_array( $params ) ) {
329 $params = array();
330 }
331 $opts = $this->get_settings();
332 if ( isset( $params['urls'] ) && is_array( $params['urls'] ) && ! empty( $params['urls'] ) ) {
333 return rest_ensure_response( Cloudflare::purge_urls( $opts, $params['urls'] ) );
334 }
335 return rest_ensure_response( Cloudflare::purge_all( $opts ) );
336 }
337
338 public function rest_dev_mode( \WP_REST_Request $request ) {
339 $params = $request->get_json_params();
340 $on = ! empty( $params['on'] );
341 return rest_ensure_response( Cloudflare::set_dev_mode( $this->get_settings(), $on ) );
342 }
343
344 /**
345 * Persistent callouts on the Cloudflare panel: a hard warning when the
346 * connection is enabled but silently failing (bad token, or a purge that
347 * was rejected for lack of the Cache-Purge permission), and a soft warning
348 * when it's enabled but not fully configured yet. (#119)
349 */
350 public function ui_notices(): array {
351 $opts = $this->get_settings();
352 if ( empty( $opts['enabled'] ) ) {
353 return array();
354 }
355 if ( ! $this->has_credentials( $opts ) ) {
356 return array(
357 array(
358 'tone' => 'warn',
359 'title' => __( 'Cloudflare is not fully configured.', 'xspeed' ),
360 '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' ),
361 ),
362 );
363 }
364 $health = get_option( self::HEALTH_OPTION, null );
365 if ( is_array( $health ) && array_key_exists( 'ok', $health ) && false === $health['ok'] ) {
366 $context = isset( $health['context'] ) ? (string) $health['context'] : 'verify';
367 $message = isset( $health['message'] ) ? (string) $health['message'] : '';
368 $suffix = '' !== $message ? ': ' . $message : '';
369 if ( 'purge' === $context ) {
370 return array(
371 array(
372 'tone' => 'danger',
373 'title' => __( 'Cloudflare purge is failing.', 'xspeed' ),
374 'body' => sprintf(
375 /* translators: %s: the Cloudflare API error message, or empty. */
376 __( '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' ),
377 $suffix
378 ),
379 ),
380 );
381 }
382 return array(
383 array(
384 'tone' => 'danger',
385 'title' => __( 'Cloudflare credentials were rejected.', 'xspeed' ),
386 'body' => sprintf(
387 /* translators: %s: the Cloudflare API error message, or empty. */
388 __( 'The saved credentials could not verify this zone%s. Auto-purge will not work until this is fixed.', 'xspeed' ),
389 $suffix
390 ),
391 ),
392 );
393 }
394 return array();
395 }
396
397 /** Verify the current credentials and cache the outcome (save-time hook). */
398 private function verify_and_record(): void {
399 $opts = $this->get_settings();
400 if ( empty( $opts['enabled'] ) || ! $this->has_credentials( $opts ) ) {
401 // Nothing to verify — drop any stale health so an old failure notice
402 // doesn't linger after the user disables or clears the integration.
403 delete_option( self::HEALTH_OPTION );
404 return;
405 }
406 $res = Cloudflare::verify( $opts );
407 $this->record_health( ! empty( $res['ok'] ), 'verify', $this->message_of( $res ) );
408 }
409
410 /** Cache the last verify/purge outcome for ui_notices(). */
411 private function record_health( bool $ok, string $context, string $message ): void {
412 update_option(
413 self::HEALTH_OPTION,
414 array(
415 'ok' => $ok,
416 'context' => $context,
417 'message' => $message,
418 'checked_at' => time(),
419 ),
420 false
421 );
422 }
423
424 /** Whether the current auth branch has all the fields it needs. */
425 private function has_credentials( array $opts ): bool {
426 if ( empty( $opts['zone_id'] ) ) {
427 return false;
428 }
429 $method = isset( $opts['auth_method'] ) ? (string) $opts['auth_method'] : 'token';
430 if ( 'key' === $method ) {
431 return ! empty( $opts['api_key'] ) && ! empty( $opts['email'] );
432 }
433 return ! empty( $opts['api_token'] );
434 }
435
436 /** Human-readable failure reason from a Cloudflare engine result. */
437 private function message_of( array $res ): string {
438 if ( ! empty( $res['ok'] ) ) {
439 return '';
440 }
441 $body = isset( $res['body'] ) && is_array( $res['body'] ) ? $res['body'] : array();
442 if ( ! empty( $body['message'] ) ) {
443 return (string) $body['message'];
444 }
445 if ( ! empty( $body['errors'][0]['message'] ) ) {
446 return (string) $body['errors'][0]['message'];
447 }
448 return 'HTTP ' . ( isset( $res['status'] ) ? (string) $res['status'] : '0' );
449 }
450
451 public function cli_commands(): array {
452 return array(
453 array(
454 'name' => 'xspeed cf',
455 'callback' => array( $this, 'cli_handler' ),
456 'shortdesc' => 'Cloudflare verify / purge / dev-mode helpers.',
457 '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.',
458 'synopsis' => array(
459 array(
460 'type' => 'positional',
461 'name' => 'action',
462 'options' => array( 'verify', 'purge', 'dev-on', 'dev-off' ),
463 'optional' => false,
464 ),
465 ),
466 ),
467 );
468 }
469
470 public function cli_handler( array $args, array $assoc ): void {
471 $opts = $this->get_settings();
472 $action = $args[0] ?? 'verify';
473 switch ( $action ) {
474 case 'verify':
475 $res = Cloudflare::verify( $opts );
476 break;
477 case 'purge':
478 // Through purge_edge() so a CLI purge records the same health
479 // and activity-log entries as an auto-purge or `wp xspeed
480 // purge`. Calling the engine directly left the panel's health
481 // record showing whatever the last NON-CLI call found.
482 $res = $this->purge_edge( 'CLI' );
483 break;
484 case 'dev-on':
485 $res = Cloudflare::set_dev_mode( $opts, true );
486 break;
487 case 'dev-off':
488 $res = Cloudflare::set_dev_mode( $opts, false );
489 break;
490 default:
491 \WP_CLI::error( "Unknown action: $action" );
492 return;
493 }
494 \WP_CLI::log( 'HTTP ' . $res['status'] . '' . ( $res['ok'] ? 'ok' : 'failed' ) );
495 \WP_CLI::log( wp_json_encode( $res['body'] ) );
496
497 // A failed call must exit non-zero, or the MCP bridge reports the
498 // whole invocation as ok:true and an agent reads a rejected token
499 // or an empty Zone ID as a successful verification.
500 if ( empty( $res['ok'] ) ) {
501 $detail = '';
502 if ( is_array( $res['body'] ) && ! empty( $res['body']['message'] ) ) {
503 $detail = ': ' . $res['body']['message'];
504 }
505 \WP_CLI::error( sprintf( '%s failed (HTTP %s)%s', $action, $res['status'], $detail ) );
506 }
507 }
508 }
509