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.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
← All changes | includes/modules/Cloudflare/CloudflareModule.php +302 -30 1.1.21.3.2 View file →
@@ -24,26 +24,47 @@
24 24 final class CloudflareModule extends Module {
25 25
26 26 public const SLUG = 'cloudflare';
27 27 public const TIER = self::TIER_FREE;
28 - public const VERSION = '1.0.0';
28 + public const VERSION = '1.1.0';
29 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 +
30 38 public function ui_metadata(): array {
31 39 return array(
32 - 'label' => 'Cloudflare',
40 + 'label' => __( 'Cloudflare', 'xspeed' ),
33 41 'icon' => 'Cloud',
34 - 'description' => 'Connect a Cloudflare zone for automatic edge purging when xSpeed clears its cache, plus a dev-mode toggle.',
42 + 'description' => __( 'Connect a Cloudflare zone for automatic edge purging when xSpeed clears its cache, plus a dev-mode toggle.', 'xspeed' ),
35 43 'custom_panel' => 'CloudflarePanel',
36 44 );
37 45 }
38 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 +
39 60 public function settings_schema(): array {
40 61 return array(
41 62 'enabled' => array(
42 63 'type' => 'bool',
43 64 'default' => false,
44 - 'label' => 'Enable Cloudflare integration',
45 - 'description' => 'Use the credentials below to verify your zone and run purges.',
65 + 'label' => __( 'Enable Cloudflare integration', 'xspeed' ),
66 + 'description' => __( 'Use the credentials below to verify your zone and run purges.', 'xspeed' ),
46 67 ),
47 68 'auth_method' => array(
48 69 'type' => 'enum',
49 70 'default' => 'token',
@@ -51,17 +72,17 @@
51 72 'option_labels' => array(
52 73 'token' => 'API Token',
53 74 'key' => 'Global API Key',
54 75 ),
55 - 'label' => 'Authentication',
56 - 'description' => 'API Tokens (scoped, recommended) or the legacy Global API Key with your account email.',
76 + 'label' => __( 'Authentication', 'xspeed' ),
77 + 'description' => __( 'API Tokens (scoped, recommended) or the legacy Global API Key with your account email.', 'xspeed' ),
57 78 'dependsOn' => array( 'field' => 'enabled' ),
58 79 ),
59 80 'api_token' => array(
60 - 'type' => 'string',
81 + 'type' => 'secret',
61 82 'default' => '',
62 - 'label' => 'API Token',
63 - 'description' => 'Create a token at dash.cloudflare.com → My Profile → API Tokens. Needs "Zone → Cache Purge" + "Zone Settings" permissions.',
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' ),
64 85 // Only the token auth branch (and only while CF is enabled, via
65 86 // the transitive gate on auth_method → enabled).
66 87 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'token' ),
67 88 ),
@@ -67,36 +88,55 @@
67 88 ),
68 89 'email' => array(
69 90 'type' => 'string',
70 91 'default' => '',
71 - 'label' => 'Account Email',
72 - 'description' => 'Only used when Authentication is set to Global API Key.',
92 + 'label' => __( 'Account Email', 'xspeed' ),
93 + 'description' => __( 'Only used when Authentication is set to Global API Key.', 'xspeed' ),
73 94 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'key' ),
74 95 ),
75 96 'api_key' => array(
76 - 'type' => 'string',
97 + 'type' => 'secret',
77 98 'default' => '',
78 - 'label' => 'Global API Key',
79 - 'description' => 'Found at dash.cloudflare.com → My Profile → API Tokens → Global API Key.',
99 + 'label' => __( 'Global API Key', 'xspeed' ),
100 + 'description' => __( 'Found at dash.cloudflare.com → My Profile → API Tokens → Global API Key.', 'xspeed' ),
80 101 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'key' ),
81 102 ),
82 103 'zone_id' => array(
83 104 'type' => 'string',
84 105 'default' => '',
85 - 'label' => 'Zone ID',
86 - 'description' => 'The 32-character hex Zone ID from your domain overview page.',
106 + 'label' => __( 'Zone ID', 'xspeed' ),
107 + 'description' => __( 'The 32-character hex Zone ID from your domain overview page.', 'xspeed' ),
87 108 'dependsOn' => array( 'field' => 'enabled' ),
88 109 ),
89 110 'auto_purge_on_update' => array(
90 111 'type' => 'bool',
91 112 'default' => true,
92 - 'label' => 'Auto-purge Cloudflare on xSpeed purge',
93 - 'description' => 'When xSpeed clears its own cache (post save, settings change, manual purge), trigger a Cloudflare purge too.',
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' ),
94 115 'dependsOn' => array( 'field' => 'enabled' ),
95 116 ),
96 117 );
97 118 }
98 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 +
99 139 public function rest_routes(): array {
100 140 $default = parent::rest_routes();
101 141 return array_merge(
102 142 $default,
@@ -131,8 +171,27 @@
131 171 );
132 172 }
133 173
134 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 {
135 194 $opts = $this->get_settings();
136 195 if ( empty( $opts['enabled'] ) ) {
137 196 return;
138 197 }
@@ -144,24 +203,125 @@
144 203 }
145 204 }
146 205
147 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() {
148 236 $opts = $this->get_settings();
149 - if ( empty( $opts['enabled'] ) || empty( $opts['zone_id'] ) ) {
150 - return;
237 + if ( empty( $opts['enabled'] ) ) {
238 + return __( 'the Cloudflare integration is switched off', 'xspeed' );
151 239 }
152 - $result = Cloudflare::purge_all( $opts );
153 - if ( ! $result['ok'] && class_exists( '\\XSpeed\\Activity_Log' ) ) {
154 - \XSpeed\Activity_Log::record(
155 - 'cloudflare_purge_failed',
156 - 'Cloudflare auto-purge failed: ' . ( $result['body']['message'] ?? 'unknown error' ),
157 - \XSpeed\Activity_Log::WARN
158 - );
240 + if ( ! $this->has_credentials( $opts ) ) {
241 + return __( 'no zone ID or API credentials are configured', 'xspeed' );
159 242 }
243 +
244 + return true;
160 245 }
161 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 +
162 320 public function rest_verify( \WP_REST_Request $request ) {
163 - return rest_ensure_response( Cloudflare::verify( $this->get_settings() ) );
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 );
164 324 }
165 325
166 326 public function rest_purge( \WP_REST_Request $request ) {
167 327 $params = $request->get_json_params();
@@ -180,8 +340,115 @@
180 340 $on = ! empty( $params['on'] );
181 341 return rest_ensure_response( Cloudflare::set_dev_mode( $this->get_settings(), $on ) );
182 342 }
183 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 +
184 451 public function cli_commands(): array {
185 452 return array(
186 453 array(
187 454 'name' => 'xspeed cf',
@@ -186,8 +453,9 @@
186 453 array(
187 454 'name' => 'xspeed cf',
188 455 'callback' => array( $this, 'cli_handler' ),
189 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.',
190 458 'synopsis' => array(
191 459 array(
192 460 'type' => 'positional',
193 461 'name' => 'action',
@@ -206,9 +474,13 @@
206 474 case 'verify':
207 475 $res = Cloudflare::verify( $opts );
208 476 break;
209 477 case 'purge':
210 - $res = Cloudflare::purge_all( $opts );
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' );
211 483 break;
212 484 case 'dev-on':
213 485 $res = Cloudflare::set_dev_mode( $opts, true );
214 486 break;