PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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.0, at includes/modules/Cloudflare/CloudflareModule.php

398 lines 13.4 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 public function settings_schema(): array {
48 return array(
49 'enabled' => array(
50 'type' => 'bool',
51 'default' => false,
52 'label' => 'Enable Cloudflare integration',
53 'description' => 'Use the credentials below to verify your zone and run purges.',
54 ),
55 'auth_method' => array(
56 'type' => 'enum',
57 'default' => 'token',
58 'options' => array( 'token', 'key' ),
59 'option_labels' => array(
60 'token' => 'API Token',
61 'key' => 'Global API Key',
62 ),
63 'label' => 'Authentication',
64 'description' => 'API Tokens (scoped, recommended) or the legacy Global API Key with your account email.',
65 'dependsOn' => array( 'field' => 'enabled' ),
66 ),
67 'api_token' => array(
68 'type' => 'secret',
69 'default' => '',
70 'label' => 'API Token',
71 'description' => 'Create a token at dash.cloudflare.com → My Profile → API Tokens. Needs "Zone → Cache Purge" + "Zone Settings" permissions.',
72 // Only the token auth branch (and only while CF is enabled, via
73 // the transitive gate on auth_method → enabled).
74 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'token' ),
75 ),
76 'email' => array(
77 'type' => 'string',
78 'default' => '',
79 'label' => 'Account Email',
80 'description' => 'Only used when Authentication is set to Global API Key.',
81 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'key' ),
82 ),
83 'api_key' => array(
84 'type' => 'secret',
85 'default' => '',
86 'label' => 'Global API Key',
87 'description' => 'Found at dash.cloudflare.com → My Profile → API Tokens → Global API Key.',
88 'dependsOn' => array( 'field' => 'auth_method', 'value' => 'key' ),
89 ),
90 'zone_id' => array(
91 'type' => 'string',
92 'default' => '',
93 'label' => 'Zone ID',
94 'description' => 'The 32-character hex Zone ID from your domain overview page.',
95 'dependsOn' => array( 'field' => 'enabled' ),
96 ),
97 'auto_purge_on_update' => array(
98 'type' => 'bool',
99 'default' => true,
100 'label' => 'Auto-purge Cloudflare on xSpeed purge',
101 'description' => 'When xSpeed clears its own cache (post save, settings change, manual purge), trigger a Cloudflare purge too.',
102 'dependsOn' => array( 'field' => 'enabled' ),
103 ),
104 );
105 }
106
107 /**
108 * Encrypt the pre-1.1.0 plaintext credentials on upgrade. api_token /
109 * api_key became `secret`-typed fields (encrypted at rest); this converts
110 * any already-stored plaintext in one pass. Idempotent — encrypt_for_storage
111 * skips a value that already carries the cipher marker. (#115)
112 */
113 public function migrations(): array {
114 return array(
115 '1.1.0' => static function ( array $opts ): array {
116 foreach ( array( 'api_token', 'api_key' ) as $key ) {
117 if ( isset( $opts[ $key ] ) && is_string( $opts[ $key ] ) && '' !== $opts[ $key ] ) {
118 $opts[ $key ] = \XSpeed\Settings_Manager::encrypt_for_storage( $opts[ $key ] );
119 }
120 }
121 return $opts;
122 },
123 );
124 }
125
126 public function rest_routes(): array {
127 $default = parent::rest_routes();
128 return array_merge(
129 $default,
130 array(
131 array(
132 'path' => '/verify',
133 'methods' => 'POST',
134 'callback' => array( $this, 'rest_verify' ),
135 ),
136 array(
137 'path' => '/purge',
138 'methods' => 'POST',
139 'callback' => array( $this, 'rest_purge' ),
140 ),
141 array(
142 'path' => '/dev-mode',
143 'methods' => 'POST',
144 'callback' => array( $this, 'rest_dev_mode' ),
145 ),
146 )
147 );
148 }
149
150 public function conflicts(): array {
151 return array(
152 array(
153 'plugin' => 'cloudflare/cloudflare.php',
154 'feature' => 'cloudflare.purge',
155 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_WARN,
156 'reason' => 'The official Cloudflare plugin also auto-purges; keep auto-purge enabled in only one to avoid double API calls.',
157 ),
158 );
159 }
160
161 public function boot(): void {
162 $opts = $this->get_settings();
163 if ( empty( $opts['enabled'] ) ) {
164 return;
165 }
166 if ( ! empty( $opts['auto_purge_on_update'] ) ) {
167 // xSpeed fires this action whenever it purges its own
168 // cache (see Cache::purge_all). Listening here keeps
169 // CF in sync without any new wiring elsewhere.
170 add_action( 'xspeed_after_purge_all', array( $this, 'on_xspeed_purge' ), 10, 0 );
171 }
172 }
173
174 public function on_xspeed_purge(): void {
175 $opts = $this->get_settings();
176 if ( empty( $opts['enabled'] ) || empty( $opts['zone_id'] ) ) {
177 return;
178 }
179 $result = Cloudflare::purge_all( $opts );
180 $ok = ! empty( $result['ok'] );
181
182 // A GET /zones verify can pass with a token that still lacks the
183 // "Zone → Cache Purge" permission, so the real purge is the only
184 // authoritative signal for purge capability. Record it either way so
185 // a silent auth failure becomes a visible, unresolved warning on the
186 // module rather than an entry buried in the activity log. (#119)
187 $this->record_health( $ok, 'purge', $ok ? '' : $this->message_of( $result ) );
188
189 if ( ! $ok && class_exists( '\\XSpeed\\Activity_Log' ) ) {
190 \XSpeed\Activity_Log::record(
191 'cloudflare_purge_failed',
192 'Cloudflare auto-purge failed: ' . ( $result['body']['message'] ?? 'unknown error' ),
193 \XSpeed\Activity_Log::WARN
194 );
195 }
196 }
197
198 /**
199 * Persist any settings sent with the save, then verify the credentials
200 * immediately so an invalid or newly-changed token surfaces on the panel
201 * instead of failing silently the next time xSpeed purges. Response shape
202 * is unchanged (flat settings) so the autosave client is unaffected. (#119)
203 */
204 public function rest_update_settings( \WP_REST_Request $request ) {
205 $params = $request->get_json_params();
206 if ( ! is_array( $params ) ) {
207 $params = $request->get_params();
208 }
209 $settings = $this->update_settings( is_array( $params ) ? $params : array() );
210 $this->verify_and_record();
211 return rest_ensure_response( $settings );
212 }
213
214 public function rest_verify( \WP_REST_Request $request ) {
215 $res = Cloudflare::verify( $this->get_settings() );
216 $this->record_health( ! empty( $res['ok'] ), 'verify', $this->message_of( $res ) );
217 return rest_ensure_response( $res );
218 }
219
220 public function rest_purge( \WP_REST_Request $request ) {
221 $params = $request->get_json_params();
222 if ( ! is_array( $params ) ) {
223 $params = array();
224 }
225 $opts = $this->get_settings();
226 if ( isset( $params['urls'] ) && is_array( $params['urls'] ) && ! empty( $params['urls'] ) ) {
227 return rest_ensure_response( Cloudflare::purge_urls( $opts, $params['urls'] ) );
228 }
229 return rest_ensure_response( Cloudflare::purge_all( $opts ) );
230 }
231
232 public function rest_dev_mode( \WP_REST_Request $request ) {
233 $params = $request->get_json_params();
234 $on = ! empty( $params['on'] );
235 return rest_ensure_response( Cloudflare::set_dev_mode( $this->get_settings(), $on ) );
236 }
237
238 /**
239 * Persistent callouts on the Cloudflare panel: a hard warning when the
240 * connection is enabled but silently failing (bad token, or a purge that
241 * was rejected for lack of the Cache-Purge permission), and a soft warning
242 * when it's enabled but not fully configured yet. (#119)
243 */
244 public function ui_notices(): array {
245 $opts = $this->get_settings();
246 if ( empty( $opts['enabled'] ) ) {
247 return array();
248 }
249 if ( ! $this->has_credentials( $opts ) ) {
250 return array(
251 array(
252 'tone' => 'warn',
253 'title' => __( 'Cloudflare is not fully configured.', 'xspeed' ),
254 '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' ),
255 ),
256 );
257 }
258 $health = get_option( self::HEALTH_OPTION, null );
259 if ( is_array( $health ) && array_key_exists( 'ok', $health ) && false === $health['ok'] ) {
260 $context = isset( $health['context'] ) ? (string) $health['context'] : 'verify';
261 $message = isset( $health['message'] ) ? (string) $health['message'] : '';
262 $suffix = '' !== $message ? ': ' . $message : '';
263 if ( 'purge' === $context ) {
264 return array(
265 array(
266 'tone' => 'danger',
267 'title' => __( 'Cloudflare purge is failing.', 'xspeed' ),
268 'body' => sprintf(
269 /* translators: %s: the Cloudflare API error message, or empty. */
270 __( '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' ),
271 $suffix
272 ),
273 ),
274 );
275 }
276 return array(
277 array(
278 'tone' => 'danger',
279 'title' => __( 'Cloudflare credentials were rejected.', 'xspeed' ),
280 'body' => sprintf(
281 /* translators: %s: the Cloudflare API error message, or empty. */
282 __( 'The saved credentials could not verify this zone%s. Auto-purge will not work until this is fixed.', 'xspeed' ),
283 $suffix
284 ),
285 ),
286 );
287 }
288 return array();
289 }
290
291 /** Verify the current credentials and cache the outcome (save-time hook). */
292 private function verify_and_record(): void {
293 $opts = $this->get_settings();
294 if ( empty( $opts['enabled'] ) || ! $this->has_credentials( $opts ) ) {
295 // Nothing to verify — drop any stale health so an old failure notice
296 // doesn't linger after the user disables or clears the integration.
297 delete_option( self::HEALTH_OPTION );
298 return;
299 }
300 $res = Cloudflare::verify( $opts );
301 $this->record_health( ! empty( $res['ok'] ), 'verify', $this->message_of( $res ) );
302 }
303
304 /** Cache the last verify/purge outcome for ui_notices(). */
305 private function record_health( bool $ok, string $context, string $message ): void {
306 update_option(
307 self::HEALTH_OPTION,
308 array(
309 'ok' => $ok,
310 'context' => $context,
311 'message' => $message,
312 'checked_at' => time(),
313 ),
314 false
315 );
316 }
317
318 /** Whether the current auth branch has all the fields it needs. */
319 private function has_credentials( array $opts ): bool {
320 if ( empty( $opts['zone_id'] ) ) {
321 return false;
322 }
323 $method = isset( $opts['auth_method'] ) ? (string) $opts['auth_method'] : 'token';
324 if ( 'key' === $method ) {
325 return ! empty( $opts['api_key'] ) && ! empty( $opts['email'] );
326 }
327 return ! empty( $opts['api_token'] );
328 }
329
330 /** Human-readable failure reason from a Cloudflare engine result. */
331 private function message_of( array $res ): string {
332 if ( ! empty( $res['ok'] ) ) {
333 return '';
334 }
335 $body = isset( $res['body'] ) && is_array( $res['body'] ) ? $res['body'] : array();
336 if ( ! empty( $body['message'] ) ) {
337 return (string) $body['message'];
338 }
339 if ( ! empty( $body['errors'][0]['message'] ) ) {
340 return (string) $body['errors'][0]['message'];
341 }
342 return 'HTTP ' . ( isset( $res['status'] ) ? (string) $res['status'] : '0' );
343 }
344
345 public function cli_commands(): array {
346 return array(
347 array(
348 'name' => 'xspeed cf',
349 'callback' => array( $this, 'cli_handler' ),
350 'shortdesc' => 'Cloudflare verify / purge / dev-mode helpers.',
351 'synopsis' => array(
352 array(
353 'type' => 'positional',
354 'name' => 'action',
355 'options' => array( 'verify', 'purge', 'dev-on', 'dev-off' ),
356 'optional' => false,
357 ),
358 ),
359 ),
360 );
361 }
362
363 public function cli_handler( array $args, array $assoc ): void {
364 $opts = $this->get_settings();
365 $action = $args[0] ?? 'verify';
366 switch ( $action ) {
367 case 'verify':
368 $res = Cloudflare::verify( $opts );
369 break;
370 case 'purge':
371 $res = Cloudflare::purge_all( $opts );
372 break;
373 case 'dev-on':
374 $res = Cloudflare::set_dev_mode( $opts, true );
375 break;
376 case 'dev-off':
377 $res = Cloudflare::set_dev_mode( $opts, false );
378 break;
379 default:
380 \WP_CLI::error( "Unknown action: $action" );
381 return;
382 }
383 \WP_CLI::log( 'HTTP ' . $res['status'] . '' . ( $res['ok'] ? 'ok' : 'failed' ) );
384 \WP_CLI::log( wp_json_encode( $res['body'] ) );
385
386 // A failed call must exit non-zero, or the MCP bridge reports the
387 // whole invocation as ok:true and an agent reads a rejected token
388 // or an empty Zone ID as a successful verification.
389 if ( empty( $res['ok'] ) ) {
390 $detail = '';
391 if ( is_array( $res['body'] ) && ! empty( $res['body']['message'] ) ) {
392 $detail = ': ' . $res['body']['message'];
393 }
394 \WP_CLI::error( sprintf( '%s failed (HTTP %s)%s', $action, $res['status'], $detail ) );
395 }
396 }
397 }
398