PluginProbe
Gutenberg / 22.9.0
Gutenberg v22.9.0
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / experimental / connectors / default-connectors.php

default-connectors.php in Gutenberg 22.9.0, at lib/experimental/connectors/default-connectors.php

546 lines 18.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Default connectors backend logic.
4 *
5 * @package gutenberg
6 */
7
8 /**
9 * Initializes the connector registry with default connectors and fires the registration action.
10 *
11 * Creates the registry instance, registers built-in connectors (which cannot be unhooked),
12 * and then fires the `wp_connectors_init` action for plugins to register their own connectors.
13 *
14 * @access private
15 * @since 7.0.0
16 */
17 function _gutenberg_connectors_init(): void {
18 $registry = new WP_Connector_Registry();
19 WP_Connector_Registry::set_instance( $registry );
20
21 // Only register default AI providers if AI support is available.
22 if ( class_exists( '\WordPress\AiClient\AiClient' ) ) {
23 _gutenberg_register_default_ai_providers( $registry );
24 }
25
26 // Non-AI default connectors.
27 $registry->register(
28 'akismet',
29 array(
30 'name' => __( 'Akismet Anti-Spam', 'gutenberg' ),
31 'description' => __( 'Protect your site from spam.', 'gutenberg' ),
32 'type' => 'spam_filtering',
33 'plugin' => array(
34 'file' => 'akismet/akismet.php',
35 ),
36 'authentication' => array(
37 'method' => 'api_key',
38 'credentials_url' => 'https://akismet.com/get/',
39 'setting_name' => 'wordpress_api_key',
40 'constant_name' => 'WPCOM_API_KEY',
41 ),
42 )
43 );
44
45 /**
46 * Fires when the connector registry is ready for plugins to register connectors.
47 *
48 * Built-in connectors and any AI providers auto-discovered from the WP AI Client
49 * registry have already been registered at this point and cannot be unhooked.
50 *
51 * AI provider plugins that register with the WP AI Client do not need to use
52 * this action — their connectors are created automatically. This action is
53 * primarily for registering non-AI-provider connectors or overriding metadata
54 * on existing connectors.
55 *
56 * Use `$registry->register()` within this action to add new connectors.
57 * To override an existing connector, unregister it first, then re-register
58 * with updated data.
59 *
60 * Example — overriding metadata on an auto-discovered connector:
61 *
62 * add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) {
63 * if ( $registry->is_registered( 'anthropic' ) ) {
64 * $connector = $registry->unregister( 'anthropic' );
65 * $connector['description'] = __( 'Custom description for Anthropic.', 'my-plugin' );
66 * $registry->register( 'anthropic', $connector );
67 * }
68 * } );
69 *
70 * @since 7.0.0
71 *
72 * @param WP_Connector_Registry $registry Connector registry instance.
73 */
74 do_action( 'wp_connectors_init', $registry );
75 }
76 remove_action( 'init', '_wp_connectors_init', 15 );
77 add_action( 'init', '_gutenberg_connectors_init', 15 );
78
79 /**
80 * Registers connectors for the built-in AI providers.
81 *
82 * @access private
83 * @since 7.0.0
84 *
85 * @param WP_Connector_Registry $registry The connector registry instance.
86 */
87 function _gutenberg_register_default_ai_providers( WP_Connector_Registry $registry ): void {
88 // Built-in connectors.
89 $defaults = array(
90 'anthropic' => array(
91 'name' => 'Anthropic',
92 'description' => __( 'Text generation with Claude.', 'gutenberg' ),
93 'type' => 'ai_provider',
94 'plugin' => array(
95 'file' => 'ai-provider-for-anthropic/plugin.php',
96 ),
97 'authentication' => array(
98 'method' => 'api_key',
99 'credentials_url' => 'https://platform.claude.com/settings/keys',
100 ),
101 ),
102 'google' => array(
103 'name' => 'Google',
104 'description' => __( 'Text and image generation with Gemini and Imagen.', 'gutenberg' ),
105 'type' => 'ai_provider',
106 'plugin' => array(
107 'file' => 'ai-provider-for-google/plugin.php',
108 ),
109 'authentication' => array(
110 'method' => 'api_key',
111 'credentials_url' => 'https://aistudio.google.com/api-keys',
112 ),
113 ),
114 'openai' => array(
115 'name' => 'OpenAI',
116 'description' => __( 'Text and image generation with GPT and Dall-E.', 'gutenberg' ),
117 'type' => 'ai_provider',
118 'plugin' => array(
119 'file' => 'ai-provider-for-openai/plugin.php',
120 ),
121 'authentication' => array(
122 'method' => 'api_key',
123 'credentials_url' => 'https://platform.openai.com/api-keys',
124 ),
125 ),
126 );
127
128 // Merge AI Client registry data on top of defaults.
129 // Registry values (from provider plugins) take precedence over hardcoded fallbacks.
130 $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry();
131
132 foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) {
133 $provider_class_name = $ai_registry->getProviderClassName( $connector_id );
134 $provider_metadata = $provider_class_name::metadata();
135
136 $auth_method = method_exists( $provider_metadata, 'getAuthenticationMethod' ) ? $provider_metadata->getAuthenticationMethod() : null;
137 $is_api_key = null !== $auth_method && $auth_method->isApiKey();
138
139 if ( $is_api_key ) {
140 $credentials_url = $provider_metadata->getCredentialsUrl();
141 $authentication = array(
142 'method' => 'api_key',
143 'credentials_url' => $credentials_url ? $credentials_url : null,
144 );
145 } else {
146 $authentication = array( 'method' => 'none' );
147 }
148
149 $name = $provider_metadata->getName();
150 $description = method_exists( $provider_metadata, 'getDescription' ) ? $provider_metadata->getDescription() : null;
151 $logo_url = method_exists( $provider_metadata, 'getLogoPath' ) && $provider_metadata->getLogoPath()
152 ? _wp_connectors_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() )
153 : null;
154
155 if ( isset( $defaults[ $connector_id ] ) ) {
156 // Override fields with non-empty registry values.
157 if ( $name ) {
158 $defaults[ $connector_id ]['name'] = $name;
159 }
160 if ( $description ) {
161 $defaults[ $connector_id ]['description'] = $description;
162 }
163 if ( $logo_url ) {
164 $defaults[ $connector_id ]['logo_url'] = $logo_url;
165 }
166 // Always update auth method; keep existing credentials_url as fallback.
167 $defaults[ $connector_id ]['authentication']['method'] = $authentication['method'];
168 if ( ! empty( $authentication['credentials_url'] ) ) {
169 $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];
170 }
171 } else {
172 $defaults[ $connector_id ] = array(
173 'name' => $name ? $name : ucwords( $connector_id ),
174 'description' => $description ? $description : '',
175 'type' => 'ai_provider',
176 'authentication' => $authentication,
177 'logo_url' => $logo_url,
178 );
179 }
180 }
181
182 // Register all default AI connectors directly on the registry.
183 foreach ( $defaults as $id => $args ) {
184 if ( 'api_key' === $args['authentication']['method'] ) {
185 $sanitized_id = str_replace( '-', '_', $id );
186
187 if ( ! isset( $args['authentication']['setting_name'] ) ) {
188 $args['authentication']['setting_name'] = "connectors_ai_{$sanitized_id}_api_key";
189 }
190
191 // All AI providers use the {CONSTANT_CASE_ID}_API_KEY naming convention.
192 if ( ! isset( $args['authentication']['constant_name'] ) || ! isset( $args['authentication']['env_var_name'] ) ) {
193 $constant_case_key = strtoupper( preg_replace( '/([a-z])([A-Z])/', '$1_$2', $sanitized_id ) ) . '_API_KEY';
194
195 if ( ! isset( $args['authentication']['constant_name'] ) ) {
196 $args['authentication']['constant_name'] = $constant_case_key;
197 }
198
199 if ( ! isset( $args['authentication']['env_var_name'] ) ) {
200 $args['authentication']['env_var_name'] = $constant_case_key;
201 }
202 }
203 }
204 $registry->register( $id, $args );
205 }
206 }
207
208 /**
209 * Determines the source of an API key for a given connector.
210 *
211 * Checks in order: environment variable, PHP constant, database.
212 * Environment variable and PHP constant are only checked when explicitly
213 * provided in the connector's authentication config.
214 *
215 * @access private
216 *
217 * @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_api_key').
218 * @param string $env_var_name Optional. Environment variable name. Only checked when non-empty.
219 * @param string $constant_name Optional. PHP constant name. Only checked when non-empty.
220 * @return string The key source: 'env', 'constant', 'database', or 'none'.
221 */
222 function _gutenberg_get_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string {
223 // Check environment variable (only if explicitly configured).
224 if ( '' !== $env_var_name ) {
225 $env_value = getenv( $env_var_name );
226 if ( false !== $env_value && '' !== $env_value ) {
227 return 'env';
228 }
229 }
230
231 // Check PHP constant (only if explicitly configured).
232 if ( '' !== $constant_name && defined( $constant_name ) ) {
233 $const_value = constant( $constant_name );
234 if ( is_string( $const_value ) && '' !== $const_value ) {
235 return 'constant';
236 }
237 }
238
239 // Check database.
240 $db_value = get_option( $setting_name, '' );
241 if ( '' !== $db_value ) {
242 return 'database';
243 }
244
245 return 'none';
246 }
247
248 /**
249 * Masks an API key, showing only the last 4 characters.
250 *
251 * @access private
252 *
253 * @param string $key The API key to mask.
254 * @return string The masked key, e.g. "************fj39".
255 */
256 function _gutenberg_mask_api_key( string $key ): string {
257 if ( strlen( $key ) <= 4 ) {
258 return $key;
259 }
260
261 return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
262 }
263
264 /**
265 * Checks whether an API key is valid for a given provider.
266 *
267 * @access private
268 *
269 * @param string $key The API key to check.
270 * @param string $provider_id The WP AI client provider ID.
271 * @return bool|null True if valid, false if invalid, null if unable to determine.
272 */
273 function _gutenberg_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
274 try {
275 $registry = \WordPress\AiClient\AiClient::defaultRegistry();
276
277 if ( ! $registry->hasProvider( $provider_id ) ) {
278 _doing_it_wrong(
279 __FUNCTION__,
280 sprintf(
281 /* translators: %s: AI provider ID. */
282 __( 'The provider "%s" is not registered in the AI client registry.', 'gutenberg' ),
283 $provider_id
284 ),
285 '7.0.0'
286 );
287 return null;
288 }
289
290 $registry->setProviderRequestAuthentication(
291 $provider_id,
292 new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $key )
293 );
294
295 return $registry->isProviderConfigured( $provider_id );
296 } catch ( Exception $e ) {
297 wp_trigger_error( __FUNCTION__, $e->getMessage() );
298 return null;
299 }
300 }
301
302 /**
303 * Masks and validates connector API keys in REST responses.
304 *
305 * On every `/wp/v2/settings` response, masks connector API key values so raw
306 * keys are never exposed via the REST API.
307 *
308 * On POST or PUT requests, validates each updated key against the provider
309 * before masking. If validation fails, the key is reverted to an empty string.
310 *
311 * @access private
312 *
313 * @param WP_REST_Response $response The response object.
314 * @param WP_REST_Server $server The server instance.
315 * @param WP_REST_Request $request The request object.
316 * @return WP_REST_Response The modified response with masked/validated keys.
317 */
318 function _gutenberg_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
319 if ( '/wp/v2/settings' !== $request->get_route() ) {
320 return $response;
321 }
322
323 if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
324 return $response;
325 }
326
327 $data = $response->get_data();
328 if ( ! is_array( $data ) ) {
329 return $response;
330 }
331
332 $is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method();
333
334 foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
335 $auth = $connector_data['authentication'];
336 if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
337 continue;
338 }
339
340 $setting_name = $auth['setting_name'];
341 if ( ! array_key_exists( $setting_name, $data ) ) {
342 continue;
343 }
344
345 $value = $data[ $setting_name ];
346
347 // On update, validate AI provider keys before masking.
348 // Non-AI connectors accept keys as-is; the service plugin handles its own validation.
349 if ( $is_update && is_string( $value ) && '' !== $value && 'ai_provider' === $connector_data['type'] ) {
350 if ( true !== _gutenberg_is_ai_api_key_valid( $value, $connector_id ) ) {
351 update_option( $setting_name, '' );
352 $data[ $setting_name ] = '';
353 continue;
354 }
355 }
356
357 // Mask the key in the response.
358 if ( is_string( $value ) && '' !== $value ) {
359 $data[ $setting_name ] = _gutenberg_mask_api_key( $value );
360 }
361 }
362
363 $response->set_data( $data );
364 return $response;
365 }
366 remove_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10 );
367 remove_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10 );
368 add_filter( 'rest_post_dispatch', '_gutenberg_connectors_rest_settings_dispatch', 10, 3 );
369
370 /**
371 * Registers default connector settings.
372 *
373 * @access private
374 */
375 function _gutenberg_register_default_connector_settings(): void {
376 $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry();
377 $existing_settings = get_registered_settings();
378
379 foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
380 $auth = $connector_data['authentication'];
381 if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
382 continue;
383 }
384
385 // Skip if the setting is already registered (e.g. by the connector's plugin).
386 if ( isset( $existing_settings[ $auth['setting_name'] ] ) ) {
387 continue;
388 }
389
390 // For AI providers, skip if the provider is not in the AI Client registry.
391 if ( 'ai_provider' === $connector_data['type'] && ! $ai_registry->hasProvider( $connector_id ) ) {
392 continue;
393 }
394
395 register_setting(
396 'connectors',
397 $auth['setting_name'],
398 array(
399 'type' => 'string',
400 'label' => sprintf(
401 /* translators: %s: Connector name. */
402 __( '%s API Key', 'gutenberg' ),
403 $connector_data['name']
404 ),
405 'description' => sprintf(
406 /* translators: %s: Connector name. */
407 __( 'API key for the %s connector.', 'gutenberg' ),
408 $connector_data['name']
409 ),
410 'default' => '',
411 'show_in_rest' => true,
412 'sanitize_callback' => 'sanitize_text_field',
413 )
414 );
415 }
416 }
417 remove_action( 'init', '_wp_register_default_connector_settings', 20 );
418 add_action( 'init', '_gutenberg_register_default_connector_settings', 20 );
419
420 /**
421 * Passes stored connector API keys to the WP AI client.
422 *
423 * @access private
424 */
425 function _gutenberg_pass_default_connector_keys_to_ai_client(): void {
426 if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
427 return;
428 }
429
430 try {
431 $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry();
432 foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
433 if ( 'ai_provider' !== $connector_data['type'] ) {
434 continue;
435 }
436
437 $auth = $connector_data['authentication'];
438 if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
439 continue;
440 }
441
442 if ( ! $ai_registry->hasProvider( $connector_id ) ) {
443 continue;
444 }
445
446 // Skip if the key is already provided via env var or constant.
447 $key_source = _gutenberg_get_api_key_source(
448 $auth['setting_name'],
449 $auth['env_var_name'] ?? '',
450 $auth['constant_name'] ?? ''
451 );
452 if ( 'env' === $key_source || 'constant' === $key_source ) {
453 continue;
454 }
455
456 $api_key = get_option( $auth['setting_name'], '' );
457 if ( '' === $api_key ) {
458 continue;
459 }
460
461 $ai_registry->setProviderRequestAuthentication(
462 $connector_id,
463 new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key )
464 );
465 }
466 } catch ( Exception $e ) {
467 wp_trigger_error( __FUNCTION__, $e->getMessage() );
468 }
469 }
470 remove_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );
471 add_action( 'init', '_gutenberg_pass_default_connector_keys_to_ai_client', 20 );
472
473 /**
474 * Exposes connector settings to the options-connectors-wp-admin script module.
475 *
476 * @access private
477 *
478 * @param array $data Existing script module data.
479 * @return array Script module data with connectors added.
480 */
481 function _gutenberg_get_connector_script_module_data( array $data ): array {
482 if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
483 return $data;
484 }
485
486 $registry = \WordPress\AiClient\AiClient::defaultRegistry();
487
488 if ( ! function_exists( 'is_plugin_active' ) ) {
489 require_once ABSPATH . 'wp-admin/includes/plugin.php';
490 }
491
492 $connectors = array();
493 foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
494 $auth = $connector_data['authentication'];
495 $auth_out = array( 'method' => $auth['method'] );
496
497 if ( 'api_key' === $auth['method'] ) {
498 $auth_out['settingName'] = $auth['setting_name'] ?? '';
499 $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
500 $auth_out['keySource'] = _gutenberg_get_api_key_source(
501 $auth['setting_name'] ?? '',
502 $auth['env_var_name'] ?? '',
503 $auth['constant_name'] ?? ''
504 );
505
506 if ( 'ai_provider' === $connector_data['type'] ) {
507 try {
508 $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
509 } catch ( Exception $e ) {
510 $auth_out['isConnected'] = false;
511 }
512 } else {
513 // For non-AI connectors, consider connected if a key exists from any source.
514 $auth_out['isConnected'] = 'none' !== $auth_out['keySource'];
515 }
516 }
517
518 $connector_out = array(
519 'name' => $connector_data['name'],
520 'description' => $connector_data['description'],
521 'logoUrl' => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null,
522 'type' => $connector_data['type'],
523 'authentication' => $auth_out,
524 );
525
526 if ( ! empty( $connector_data['plugin']['file'] ) ) {
527 $file = $connector_data['plugin']['file'];
528 $is_installed = file_exists( WP_PLUGIN_DIR . '/' . $file );
529 $is_activated = $is_installed && is_plugin_active( $file );
530
531 $connector_out['plugin'] = array(
532 'file' => $file,
533 'isInstalled' => $is_installed,
534 'isActivated' => $is_activated,
535 );
536 }
537
538 $connectors[ $connector_id ] = $connector_out;
539 }
540 ksort( $connectors );
541 $data['connectors'] = $connectors;
542 return $data;
543 }
544 remove_filter( 'script_module_data_options-connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' );
545 add_filter( 'script_module_data_options-connectors-wp-admin', '_gutenberg_get_connector_script_module_data' );
546