PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.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 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Cloudflare / CloudflareModule.php

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

412 lines 14.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 public function ui_metadata(): array {
39 return array(
40 'label' => 'Cloudflare',
41 'icon' => 'Cloud',
42 'description' => 'Connect a Cloudflare zone for automatic edge purging when xSpeed clears its cache, plus a dev-mode toggle.',
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',
66 'description' => 'Use the credentials below to verify your zone and run purges.',
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',
77 'description' => 'API Tokens (scoped, recommended) or the legacy Global API Key with your account email.',
78 'dependsOn' => array( 'field' => 'enabled' ),
79 ),
80 'api_token' => array(
81 'type' => 'secret',
82 'default' => '',
83 'label' => 'API Token',
84 'description' => 'Create a token at dash.cloudflare.com → My Profile → API Tokens. Needs "Zone → Cache Purge" + "Zone Settings" permissions.',
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',
93 'description' => 'Only used when Authentication is set to Global API Key.',
94 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'key' ),
95 ),
96 'api_key' => array(
97 'type' => 'secret',
98 'default' => '',
99 'label' => 'Global API Key',
100 'description' => 'Found at dash.cloudflare.com → My Profile → API Tokens → Global API Key.',
101 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'key' ),
102 ),
103 'zone_id' => array(
104 'type' => 'string',
105 'default' => '',
106 'label' => 'Zone ID',
107 'description' => 'The 32-character hex Zone ID from your domain overview page.',
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',
114 'description' => 'When xSpeed clears its own cache (post save, settings change, manual purge), trigger a Cloudflare purge too.',
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 $opts = $this->get_settings();
176 if ( empty( $opts['enabled'] ) ) {
177 return;
178 }
179 if ( ! empty( $opts['auto_purge_on_update'] ) ) {
180 // xSpeed fires this action whenever it purges its own
181 // cache (see Cache::purge_all). Listening here keeps
182 // CF in sync without any new wiring elsewhere.
183 add_action( 'xspeed_after_purge_all', array( $this, 'on_xspeed_purge' ), 10, 0 );
184 }
185 }
186
187 public function on_xspeed_purge(): void {
188 $opts = $this->get_settings();
189 if ( empty( $opts['enabled'] ) || empty( $opts['zone_id'] ) ) {
190 return;
191 }
192 $result = Cloudflare::purge_all( $opts );
193 $ok = ! empty( $result['ok'] );
194
195 // A GET /zones verify can pass with a token that still lacks the
196 // "Zone → Cache Purge" permission, so the real purge is the only
197 // authoritative signal for purge capability. Record it either way so
198 // a silent auth failure becomes a visible, unresolved warning on the
199 // module rather than an entry buried in the activity log. (#119)
200 $this->record_health( $ok, 'purge', $ok ? '' : $this->message_of( $result ) );
201
202 if ( ! $ok && class_exists( '\\XSpeed\\Activity_Log' ) ) {
203 \XSpeed\Activity_Log::record(
204 'cloudflare_purge_failed',
205 'Cloudflare auto-purge failed: ' . ( $result['body']['message'] ?? 'unknown error' ),
206 \XSpeed\Activity_Log::WARN
207 );
208 }
209 }
210
211 /**
212 * Persist any settings sent with the save, then verify the credentials
213 * immediately so an invalid or newly-changed token surfaces on the panel
214 * instead of failing silently the next time xSpeed purges. Response shape
215 * is unchanged (flat settings) so the autosave client is unaffected. (#119)
216 */
217 public function rest_update_settings( \WP_REST_Request $request ) {
218 $params = $request->get_json_params();
219 if ( ! is_array( $params ) ) {
220 $params = $request->get_params();
221 }
222 $settings = $this->update_settings( is_array( $params ) ? $params : array() );
223 $this->verify_and_record();
224 return rest_ensure_response( $settings );
225 }
226
227 public function rest_verify( \WP_REST_Request $request ) {
228 $res = Cloudflare::verify( $this->get_settings() );
229 $this->record_health( ! empty( $res['ok'] ), 'verify', $this->message_of( $res ) );
230 return rest_ensure_response( $res );
231 }
232
233 public function rest_purge( \WP_REST_Request $request ) {
234 $params = $request->get_json_params();
235 if ( ! is_array( $params ) ) {
236 $params = array();
237 }
238 $opts = $this->get_settings();
239 if ( isset( $params['urls'] ) && is_array( $params['urls'] ) && ! empty( $params['urls'] ) ) {
240 return rest_ensure_response( Cloudflare::purge_urls( $opts, $params['urls'] ) );
241 }
242 return rest_ensure_response( Cloudflare::purge_all( $opts ) );
243 }
244
245 public function rest_dev_mode( \WP_REST_Request $request ) {
246 $params = $request->get_json_params();
247 $on = ! empty( $params['on'] );
248 return rest_ensure_response( Cloudflare::set_dev_mode( $this->get_settings(), $on ) );
249 }
250
251 /**
252 * Persistent callouts on the Cloudflare panel: a hard warning when the
253 * connection is enabled but silently failing (bad token, or a purge that
254 * was rejected for lack of the Cache-Purge permission), and a soft warning
255 * when it's enabled but not fully configured yet. (#119)
256 */
257 public function ui_notices(): array {
258 $opts = $this->get_settings();
259 if ( empty( $opts['enabled'] ) ) {
260 return array();
261 }
262 if ( ! $this->has_credentials( $opts ) ) {
263 return array(
264 array(
265 'tone' => 'warn',
266 'title' => __( 'Cloudflare is not fully configured.', 'xspeed' ),
267 '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' ),
268 ),
269 );
270 }
271 $health = get_option( self::HEALTH_OPTION, null );
272 if ( is_array( $health ) && array_key_exists( 'ok', $health ) && false === $health['ok'] ) {
273 $context = isset( $health['context'] ) ? (string) $health['context'] : 'verify';
274 $message = isset( $health['message'] ) ? (string) $health['message'] : '';
275 $suffix = '' !== $message ? ': ' . $message : '';
276 if ( 'purge' === $context ) {
277 return array(
278 array(
279 'tone' => 'danger',
280 'title' => __( 'Cloudflare purge is failing.', 'xspeed' ),
281 'body' => sprintf(
282 /* translators: %s: the Cloudflare API error message, or empty. */
283 __( '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' ),
284 $suffix
285 ),
286 ),
287 );
288 }
289 return array(
290 array(
291 'tone' => 'danger',
292 'title' => __( 'Cloudflare credentials were rejected.', 'xspeed' ),
293 'body' => sprintf(
294 /* translators: %s: the Cloudflare API error message, or empty. */
295 __( 'The saved credentials could not verify this zone%s. Auto-purge will not work until this is fixed.', 'xspeed' ),
296 $suffix
297 ),
298 ),
299 );
300 }
301 return array();
302 }
303
304 /** Verify the current credentials and cache the outcome (save-time hook). */
305 private function verify_and_record(): void {
306 $opts = $this->get_settings();
307 if ( empty( $opts['enabled'] ) || ! $this->has_credentials( $opts ) ) {
308 // Nothing to verify — drop any stale health so an old failure notice
309 // doesn't linger after the user disables or clears the integration.
310 delete_option( self::HEALTH_OPTION );
311 return;
312 }
313 $res = Cloudflare::verify( $opts );
314 $this->record_health( ! empty( $res['ok'] ), 'verify', $this->message_of( $res ) );
315 }
316
317 /** Cache the last verify/purge outcome for ui_notices(). */
318 private function record_health( bool $ok, string $context, string $message ): void {
319 update_option(
320 self::HEALTH_OPTION,
321 array(
322 'ok' => $ok,
323 'context' => $context,
324 'message' => $message,
325 'checked_at' => time(),
326 ),
327 false
328 );
329 }
330
331 /** Whether the current auth branch has all the fields it needs. */
332 private function has_credentials( array $opts ): bool {
333 if ( empty( $opts['zone_id'] ) ) {
334 return false;
335 }
336 $method = isset( $opts['auth_method'] ) ? (string) $opts['auth_method'] : 'token';
337 if ( 'key' === $method ) {
338 return ! empty( $opts['api_key'] ) && ! empty( $opts['email'] );
339 }
340 return ! empty( $opts['api_token'] );
341 }
342
343 /** Human-readable failure reason from a Cloudflare engine result. */
344 private function message_of( array $res ): string {
345 if ( ! empty( $res['ok'] ) ) {
346 return '';
347 }
348 $body = isset( $res['body'] ) && is_array( $res['body'] ) ? $res['body'] : array();
349 if ( ! empty( $body['message'] ) ) {
350 return (string) $body['message'];
351 }
352 if ( ! empty( $body['errors'][0]['message'] ) ) {
353 return (string) $body['errors'][0]['message'];
354 }
355 return 'HTTP ' . ( isset( $res['status'] ) ? (string) $res['status'] : '0' );
356 }
357
358 public function cli_commands(): array {
359 return array(
360 array(
361 'name' => 'xspeed cf',
362 'callback' => array( $this, 'cli_handler' ),
363 'shortdesc' => 'Cloudflare verify / purge / dev-mode helpers.',
364 '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.',
365 'synopsis' => array(
366 array(
367 'type' => 'positional',
368 'name' => 'action',
369 'options' => array( 'verify', 'purge', 'dev-on', 'dev-off' ),
370 'optional' => false,
371 ),
372 ),
373 ),
374 );
375 }
376
377 public function cli_handler( array $args, array $assoc ): void {
378 $opts = $this->get_settings();
379 $action = $args[0] ?? 'verify';
380 switch ( $action ) {
381 case 'verify':
382 $res = Cloudflare::verify( $opts );
383 break;
384 case 'purge':
385 $res = Cloudflare::purge_all( $opts );
386 break;
387 case 'dev-on':
388 $res = Cloudflare::set_dev_mode( $opts, true );
389 break;
390 case 'dev-off':
391 $res = Cloudflare::set_dev_mode( $opts, false );
392 break;
393 default:
394 \WP_CLI::error( "Unknown action: $action" );
395 return;
396 }
397 \WP_CLI::log( 'HTTP ' . $res['status'] . '' . ( $res['ok'] ? 'ok' : 'failed' ) );
398 \WP_CLI::log( wp_json_encode( $res['body'] ) );
399
400 // A failed call must exit non-zero, or the MCP bridge reports the
401 // whole invocation as ok:true and an agent reads a rejected token
402 // or an empty Zone ID as a successful verification.
403 if ( empty( $res['ok'] ) ) {
404 $detail = '';
405 if ( is_array( $res['body'] ) && ! empty( $res['body']['message'] ) ) {
406 $detail = ': ' . $res['body']['message'];
407 }
408 \WP_CLI::error( sprintf( '%s failed (HTTP %s)%s', $action, $res['status'], $detail ) );
409 }
410 }
411 }
412