PluginProbe
Gutenberg / 23.7.2
Gutenberg v23.7.2
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 / compat / wordpress-7.0 / default-connectors.php

default-connectors.php in Gutenberg 23.7.2, at lib/compat/wordpress-7.0/default-connectors.php

784 lines 26.3 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 'is_active' => static function (): bool {
36 return defined( 'AKISMET_VERSION' );
37 },
38 ),
39 'authentication' => array(
40 'method' => 'api_key',
41 'credentials_url' => 'https://akismet.com/get/',
42 'setting_name' => 'wordpress_api_key',
43 'constant_name' => 'WPCOM_API_KEY',
44 ),
45 )
46 );
47
48 /**
49 * Fires when the connector registry is ready for plugins to register connectors.
50 *
51 * Built-in connectors and any AI providers auto-discovered from the WP AI Client
52 * registry have already been registered at this point and cannot be unhooked.
53 *
54 * AI provider plugins that register with the WP AI Client do not need to use
55 * this action — their connectors are created automatically. This action is
56 * primarily for registering non-AI-provider connectors or overriding metadata
57 * on existing connectors.
58 *
59 * Use `$registry->register()` within this action to add new connectors.
60 * To override an existing connector, unregister it first, then re-register
61 * with updated data.
62 *
63 * Example — overriding metadata on an auto-discovered connector:
64 *
65 * add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) {
66 * if ( $registry->is_registered( 'anthropic' ) ) {
67 * $connector = $registry->unregister( 'anthropic' );
68 * $connector['description'] = __( 'Custom description for Anthropic.', 'my-plugin' );
69 * $registry->register( 'anthropic', $connector );
70 * }
71 * } );
72 *
73 * @since 7.0.0
74 *
75 * @param WP_Connector_Registry $registry Connector registry instance.
76 */
77 do_action( 'wp_connectors_init', $registry );
78 }
79 remove_action( 'init', '_wp_connectors_init', 15 );
80 add_action( 'init', '_gutenberg_connectors_init', 15 );
81
82 /**
83 * Registers connectors for the built-in AI providers.
84 *
85 * @access private
86 * @since 7.0.0
87 *
88 * @param WP_Connector_Registry $registry The connector registry instance.
89 */
90 function _gutenberg_register_default_ai_providers( WP_Connector_Registry $registry ): void {
91 // Built-in connectors.
92 $defaults = array(
93 'anthropic' => array(
94 'name' => 'Anthropic',
95 'description' => __( 'Text generation with Claude.', 'gutenberg' ),
96 'type' => 'ai_provider',
97 'plugin' => array(
98 'file' => 'ai-provider-for-anthropic/plugin.php',
99 ),
100 'authentication' => array(
101 'method' => 'api_key',
102 'credentials_url' => 'https://platform.claude.com/settings/keys',
103 ),
104 ),
105 'google' => array(
106 'name' => 'Google',
107 'description' => __( 'Text and image generation with Gemini and Imagen.', 'gutenberg' ),
108 'type' => 'ai_provider',
109 'plugin' => array(
110 'file' => 'ai-provider-for-google/plugin.php',
111 ),
112 'authentication' => array(
113 'method' => 'api_key',
114 'credentials_url' => 'https://aistudio.google.com/api-keys',
115 ),
116 ),
117 'openai' => array(
118 'name' => 'OpenAI',
119 'description' => __( 'Text and image generation with GPT and Dall-E.', 'gutenberg' ),
120 'type' => 'ai_provider',
121 'plugin' => array(
122 'file' => 'ai-provider-for-openai/plugin.php',
123 ),
124 'authentication' => array(
125 'method' => 'api_key',
126 'credentials_url' => 'https://platform.openai.com/api-keys',
127 ),
128 ),
129 );
130
131 // Merge AI Client registry data on top of defaults.
132 // Registry values (from provider plugins) take precedence over hardcoded fallbacks.
133 $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry();
134
135 foreach ( array_filter( $ai_registry->getRegisteredProviderIds() ) as $connector_id ) {
136 $provider_class_name = $ai_registry->getProviderClassName( $connector_id );
137 $provider_metadata = $provider_class_name::metadata();
138
139 $auth_method = method_exists( $provider_metadata, 'getAuthenticationMethod' ) ? $provider_metadata->getAuthenticationMethod() : null;
140 $is_api_key = null !== $auth_method && $auth_method->isApiKey();
141
142 if ( $is_api_key ) {
143 $credentials_url = $provider_metadata->getCredentialsUrl();
144 $authentication = array(
145 'method' => 'api_key',
146 );
147 if ( $credentials_url ) {
148 $authentication['credentials_url'] = $credentials_url;
149 }
150 } else {
151 $authentication = array( 'method' => 'none' );
152 }
153
154 $name = $provider_metadata->getName();
155 $description = method_exists( $provider_metadata, 'getDescription' ) ? $provider_metadata->getDescription() : null;
156 $logo_url = method_exists( $provider_metadata, 'getLogoPath' ) && $provider_metadata->getLogoPath()
157 ? _wp_connectors_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() )
158 : null;
159
160 if ( isset( $defaults[ $connector_id ] ) ) {
161 // Override fields with non-empty registry values.
162 if ( $name ) {
163 $defaults[ $connector_id ]['name'] = $name;
164 }
165 if ( $description ) {
166 $defaults[ $connector_id ]['description'] = $description;
167 }
168 if ( $logo_url ) {
169 $defaults[ $connector_id ]['logo_url'] = $logo_url;
170 }
171 // Always update auth method; keep existing credentials_url as fallback.
172 $defaults[ $connector_id ]['authentication']['method'] = $authentication['method'];
173 if ( ! empty( $authentication['credentials_url'] ) ) {
174 $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];
175 }
176 } else {
177 $defaults[ $connector_id ] = array(
178 'name' => $name ? $name : ucwords( $connector_id ),
179 'description' => $description ? $description : '',
180 'type' => 'ai_provider',
181 'authentication' => $authentication,
182 );
183 if ( $logo_url ) {
184 $defaults[ $connector_id ]['logo_url'] = $logo_url;
185 }
186 }
187 }
188
189 // Register all default AI connectors directly on the registry.
190 foreach ( $defaults as $id => $args ) {
191 if ( 'api_key' === $args['authentication']['method'] ) {
192 $sanitized_id = str_replace( '-', '_', $id );
193
194 $args['authentication']['setting_name'] = "connectors_ai_{$sanitized_id}_api_key";
195
196 // All AI providers use the {CONSTANT_CASE_ID}_API_KEY naming convention.
197 $constant_case_key = strtoupper( (string) preg_replace( '/([a-z])([A-Z])/', '$1_$2', $sanitized_id ) ) . '_API_KEY';
198
199 $args['authentication']['constant_name'] = $constant_case_key;
200 $args['authentication']['env_var_name'] = $constant_case_key;
201 }
202
203 $args['plugin']['is_active'] = static function () use ( $ai_registry, $id ): bool {
204 try {
205 return $ai_registry->hasProvider( $id );
206 } catch ( Exception $e ) {
207 return false;
208 }
209 };
210
211 $registry->register( $id, $args );
212 }
213 }
214
215 /**
216 * Determines the source of an API key for a given connector.
217 *
218 * Checks in order: environment variable, PHP constant, database.
219 * Environment variable and PHP constant are only checked when explicitly
220 * provided in the connector's authentication config.
221 *
222 * @access private
223 *
224 * @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_api_key').
225 * @param string $env_var_name Optional. Environment variable name. Only checked when non-empty.
226 * @param string $constant_name Optional. PHP constant name. Only checked when non-empty.
227 * @return string The key source: 'env', 'constant', 'database', or 'none'.
228 */
229 function _gutenberg_get_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string {
230 // Check environment variable (only if explicitly configured).
231 if ( '' !== $env_var_name ) {
232 $env_value = getenv( $env_var_name );
233 if ( false !== $env_value && '' !== $env_value ) {
234 return 'env';
235 }
236 }
237
238 // Check PHP constant (only if explicitly configured).
239 if ( '' !== $constant_name && defined( $constant_name ) ) {
240 $const_value = constant( $constant_name );
241 if ( is_string( $const_value ) && '' !== $const_value ) {
242 return 'constant';
243 }
244 }
245
246 // Check database.
247 $db_value = get_option( $setting_name, '' );
248 if ( '' !== $db_value ) {
249 return 'database';
250 }
251
252 return 'none';
253 }
254
255 /**
256 * Parses a `username:password` credentials string.
257 *
258 * Splits on the first colon, matching the HTTP Basic authentication
259 * userinfo format, so passwords may contain colons.
260 *
261 * @access private
262 *
263 * @param string $value The raw credentials string.
264 * @return array{username: string, password: string} Parsed credentials. Both values
265 * are empty when the string is malformed.
266 */
267 function _gutenberg_parse_application_password_credentials( string $value ): array {
268 $separator = strpos( $value, ':' );
269 // Trim so surrounding whitespace or a trailing newline (common when the
270 // value comes from a file or `.env`) does not become part of the credentials.
271 $username = false === $separator ? '' : trim( substr( $value, 0, $separator ) );
272 $password = false === $separator ? '' : trim( substr( $value, $separator + 1 ) );
273
274 if ( '' === $username || '' === $password ) {
275 return array(
276 'username' => '',
277 'password' => '',
278 );
279 }
280
281 return array(
282 'username' => $username,
283 'password' => $password,
284 );
285 }
286
287 /**
288 * Resolves application-password credentials for a connector.
289 *
290 * Checks in order: environment variable, PHP constant, database. The
291 * environment variable and constant are only checked when their respective
292 * names are provided, and must contain the credentials as a single
293 * `username:password` string. A non-empty environment variable or constant
294 * that cannot be parsed as `username:password` is reported with
295 * `_doing_it_wrong()` and ignored, so resolution falls through to the next
296 * source.
297 *
298 * @access private
299 *
300 * @param array $auth The connector's authentication configuration.
301 * @return array{username: string, password: string, source: string} Resolved credentials and
302 * their source: 'env', 'constant',
303 * 'database', or 'none'.
304 */
305 function _gutenberg_get_application_password_credentials( array $auth ): array {
306 // Check environment variable (only if explicitly configured).
307 $env_var_name = $auth['env_var_name'] ?? '';
308 if ( '' !== $env_var_name ) {
309 $env_value = getenv( $env_var_name );
310 if ( false !== $env_value && '' !== $env_value ) {
311 $credentials = _gutenberg_parse_application_password_credentials( $env_value );
312 if ( '' !== $credentials['username'] && '' !== $credentials['password'] ) {
313 $credentials['source'] = 'env';
314 return $credentials;
315 }
316
317 _doing_it_wrong(
318 __FUNCTION__,
319 sprintf(
320 /* translators: %s: Environment variable name. */
321 __( 'The %s environment variable must contain application password credentials in "username:password" format.', 'gutenberg' ),
322 esc_html( $env_var_name )
323 ),
324 '7.0.0'
325 );
326 }
327 }
328
329 // Check PHP constant (only if explicitly configured).
330 $constant_name = $auth['constant_name'] ?? '';
331 if ( '' !== $constant_name && defined( $constant_name ) ) {
332 $const_value = constant( $constant_name );
333 if ( is_string( $const_value ) && '' !== $const_value ) {
334 $credentials = _gutenberg_parse_application_password_credentials( $const_value );
335 if ( '' !== $credentials['username'] && '' !== $credentials['password'] ) {
336 $credentials['source'] = 'constant';
337 return $credentials;
338 }
339
340 _doing_it_wrong(
341 __FUNCTION__,
342 sprintf(
343 /* translators: %s: PHP constant name. */
344 __( 'The %s constant must contain application password credentials in "username:password" format.', 'gutenberg' ),
345 esc_html( $constant_name )
346 ),
347 '7.0.0'
348 );
349 }
350 }
351
352 // Check database.
353 $stored = get_option( $auth['setting_name'] ?? '', array() );
354 $username = is_array( $stored ) && isset( $stored['username'] ) && is_string( $stored['username'] ) ? $stored['username'] : '';
355 $password = is_array( $stored ) && isset( $stored['password'] ) && is_string( $stored['password'] ) ? $stored['password'] : '';
356
357 return array(
358 'username' => $username,
359 'password' => $password,
360 'source' => '' !== $username && '' !== $password ? 'database' : 'none',
361 );
362 }
363
364 /**
365 * Masks an API key, showing only the last 4 characters.
366 *
367 * @access private
368 *
369 * @param string $key The API key to mask.
370 * @return string The masked key, e.g. "************fj39".
371 */
372 function _gutenberg_mask_api_key( string $key ): string {
373 if ( strlen( $key ) <= 4 ) {
374 return $key;
375 }
376
377 return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
378 }
379
380 /**
381 * Checks whether an API key is valid for a given provider.
382 *
383 * @access private
384 *
385 * @param string $key The API key to check.
386 * @param string $provider_id The WP AI client provider ID.
387 * @return bool|null True if valid, false if invalid, null if unable to determine.
388 */
389 function _gutenberg_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
390 try {
391 $registry = \WordPress\AiClient\AiClient::defaultRegistry();
392
393 if ( ! $registry->hasProvider( $provider_id ) ) {
394 _doing_it_wrong(
395 __FUNCTION__,
396 sprintf(
397 /* translators: %s: AI provider ID. */
398 __( 'The provider "%s" is not registered in the AI client registry.', 'gutenberg' ),
399 $provider_id
400 ),
401 '7.0.0'
402 );
403 return null;
404 }
405
406 $registry->setProviderRequestAuthentication(
407 $provider_id,
408 new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $key )
409 );
410
411 return $registry->isProviderConfigured( $provider_id );
412 } catch ( Exception $e ) {
413 wp_trigger_error( __FUNCTION__, $e->getMessage() );
414 return null;
415 }
416 }
417
418 /**
419 * Sanitizes stored application-password credentials for a connector.
420 *
421 * Credential fields that are missing or not strings keep their currently
422 * stored values, so partial updates cannot silently clear a stored secret.
423 * A password matching the mask that `_gutenberg_connectors_rest_settings_dispatch()`
424 * places in REST responses also keeps the stored password, so a masked
425 * settings response can be submitted back to the endpoint unchanged.
426 * Pass an empty string to clear a field.
427 * If the sanitized username is empty, both fields are discarded so partial
428 * credentials cannot leave an orphaned secret.
429 *
430 * @access private
431 *
432 * @param mixed $value The submitted setting value.
433 * @param string $option The option name being sanitized. Passed explicitly by the
434 * registered sanitize callback; falls back to the current
435 * `sanitize_option_{$option}` filter name when omitted.
436 * @return array{username: string, password: string} Sanitized credentials.
437 */
438 function _gutenberg_sanitize_application_password_credentials( $value, string $option = '' ): array {
439 if ( ! is_array( $value ) ) {
440 $value = array();
441 }
442
443 if ( '' === $option ) {
444 $option = str_replace( 'sanitize_option_', '', (string) current_filter() );
445 }
446
447 $stored = get_option( $option );
448 if ( ! is_array( $stored ) ) {
449 $stored = array();
450 }
451
452 $credentials = array();
453 foreach ( array( 'username', 'password' ) as $field ) {
454 if ( isset( $value[ $field ] ) && is_string( $value[ $field ] ) ) {
455 $credentials[ $field ] = sanitize_text_field( $value[ $field ] );
456 } else {
457 $credentials[ $field ] = isset( $stored[ $field ] ) && is_string( $stored[ $field ] ) ? $stored[ $field ] : '';
458 }
459 }
460
461 // A masked password means a client resubmitted a masked REST response.
462 if ( str_repeat( "\u{2022}", 16 ) === $credentials['password'] ) {
463 $credentials['password'] = isset( $stored['password'] ) && is_string( $stored['password'] ) ? $stored['password'] : '';
464 }
465
466 if ( '' === $credentials['username'] ) {
467 return array(
468 'username' => '',
469 'password' => '',
470 );
471 }
472
473 return $credentials;
474 }
475
476 /**
477 * Masks and validates connector credentials in REST responses.
478 *
479 * On every `/wp/v2/settings` response, masks connector API key values and the
480 * password field of default application-password credential objects.
481 *
482 * On POST or PUT requests, validates each updated AI provider API key before
483 * masking. If validation fails, the key is reverted to an empty string.
484 * Application password values are masked but not validated.
485 *
486 * @access private
487 *
488 * @param WP_REST_Response $response The response object.
489 * @param WP_REST_Server $server The server instance.
490 * @param WP_REST_Request $request The request object.
491 * @return WP_REST_Response The modified response with masked/validated keys.
492 */
493 function _gutenberg_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
494 if ( '/wp/v2/settings' !== $request->get_route() ) {
495 return $response;
496 }
497
498 if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
499 return $response;
500 }
501
502 $data = $response->get_data();
503 if ( ! is_array( $data ) ) {
504 return $response;
505 }
506
507 $is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method();
508
509 foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
510 $auth = $connector_data['authentication'];
511
512 if ( 'application_password' === $auth['method'] && ! empty( $auth['setting_name'] ) ) {
513 $setting_name = $auth['setting_name'];
514 if ( array_key_exists( $setting_name, $data ) && is_array( $data[ $setting_name ] ) ) {
515 $password = $data[ $setting_name ]['password'] ?? '';
516 if ( is_string( $password ) && '' !== $password ) {
517 $data[ $setting_name ]['password'] = str_repeat( "\u{2022}", 16 );
518 }
519 }
520 continue;
521 }
522
523 if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
524 continue;
525 }
526
527 $setting_name = $auth['setting_name'];
528 if ( ! array_key_exists( $setting_name, $data ) ) {
529 continue;
530 }
531
532 $value = $data[ $setting_name ];
533
534 // On update, validate AI provider keys before masking.
535 // Non-AI connectors accept keys as-is; the service plugin handles its own validation.
536 if ( $is_update && is_string( $value ) && '' !== $value && 'ai_provider' === $connector_data['type'] ) {
537 if ( true !== _gutenberg_is_ai_api_key_valid( $value, $connector_id ) ) {
538 update_option( $setting_name, '' );
539 $data[ $setting_name ] = '';
540 continue;
541 }
542 }
543
544 // Mask the key in the response.
545 if ( is_string( $value ) && '' !== $value ) {
546 $data[ $setting_name ] = _gutenberg_mask_api_key( $value );
547 }
548 }
549
550 $response->set_data( $data );
551 return $response;
552 }
553 remove_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10 );
554 remove_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10 );
555 add_filter( 'rest_post_dispatch', '_gutenberg_connectors_rest_settings_dispatch', 10, 3 );
556
557 /**
558 * Registers default connector settings.
559 *
560 * @access private
561 */
562 function _gutenberg_register_default_connector_settings(): void {
563 $existing_settings = get_registered_settings();
564
565 foreach ( wp_get_connectors() as $connector_data ) {
566 $auth = $connector_data['authentication'];
567 if ( 'api_key' !== $auth['method'] && 'application_password' !== $auth['method'] ) {
568 continue;
569 }
570
571 if ( empty( $auth['setting_name'] ) || isset( $existing_settings[ $auth['setting_name'] ] ) ) {
572 continue;
573 }
574 $setting_name = $auth['setting_name'];
575
576 if ( ! isset( $connector_data['plugin']['is_active'] ) || ! is_callable( $connector_data['plugin']['is_active'] ) ) {
577 continue;
578 }
579
580 if ( ! call_user_func( $connector_data['plugin']['is_active'] ) ) {
581 continue;
582 }
583
584 if ( 'api_key' === $auth['method'] ) {
585 register_setting(
586 'connectors',
587 $setting_name,
588 array(
589 'type' => 'string',
590 'label' => sprintf(
591 /* translators: %s: Connector name. */
592 __( '%s API Key', 'gutenberg' ),
593 $connector_data['name']
594 ),
595 'description' => sprintf(
596 /* translators: %s: Connector name. */
597 __( 'API key for the %s connector.', 'gutenberg' ),
598 $connector_data['name']
599 ),
600 'default' => '',
601 'show_in_rest' => true,
602 'sanitize_callback' => 'sanitize_text_field',
603 )
604 );
605 } elseif ( 'application_password' === $auth['method'] ) {
606 register_setting(
607 'connectors',
608 $setting_name,
609 array(
610 'type' => 'object',
611 'label' => sprintf(
612 /* translators: %s: Connector name. */
613 __( '%s Credentials', 'gutenberg' ),
614 $connector_data['name']
615 ),
616 'description' => sprintf(
617 /* translators: %s: Connector name. */
618 __( 'Application password credentials for the %s connector.', 'gutenberg' ),
619 $connector_data['name']
620 ),
621 'default' => array(
622 'username' => '',
623 'password' => '',
624 ),
625 'show_in_rest' => array(
626 'schema' => array(
627 'type' => 'object',
628 'properties' => array(
629 'username' => array(
630 'type' => 'string',
631 ),
632 'password' => array(
633 'type' => 'string',
634 ),
635 ),
636 'additionalProperties' => false,
637 ),
638 ),
639 'sanitize_callback' => static function ( $value ) use ( $setting_name ) {
640 return _gutenberg_sanitize_application_password_credentials( $value, $setting_name );
641 },
642 )
643 );
644 }
645 }
646 }
647 remove_action( 'init', '_wp_register_default_connector_settings', 20 );
648 add_action( 'init', '_gutenberg_register_default_connector_settings', 20 );
649
650 /**
651 * Passes stored connector API keys to the WP AI client.
652 *
653 * @access private
654 */
655 function _gutenberg_pass_default_connector_keys_to_ai_client(): void {
656 if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
657 return;
658 }
659
660 try {
661 $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry();
662 foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
663 if ( 'ai_provider' !== $connector_data['type'] ) {
664 continue;
665 }
666
667 $auth = $connector_data['authentication'];
668 if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
669 continue;
670 }
671
672 if ( ! $ai_registry->hasProvider( $connector_id ) ) {
673 continue;
674 }
675
676 // Skip if the key is already provided via env var or constant.
677 $key_source = _gutenberg_get_api_key_source(
678 $auth['setting_name'],
679 $auth['env_var_name'] ?? '',
680 $auth['constant_name'] ?? ''
681 );
682 if ( 'env' === $key_source || 'constant' === $key_source ) {
683 continue;
684 }
685
686 $api_key = get_option( $auth['setting_name'], '' );
687 if ( ! is_string( $api_key ) || '' === $api_key ) {
688 continue;
689 }
690
691 $ai_registry->setProviderRequestAuthentication(
692 $connector_id,
693 new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key )
694 );
695 }
696 } catch ( Exception $e ) {
697 wp_trigger_error( __FUNCTION__, $e->getMessage() );
698 }
699 }
700 remove_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );
701 add_action( 'init', '_gutenberg_pass_default_connector_keys_to_ai_client', 20 );
702
703 /**
704 * Exposes connector settings to the options-connectors-wp-admin script module.
705 *
706 * @access private
707 *
708 * @param array $data Existing script module data.
709 * @return array Script module data with connectors added.
710 */
711 function _gutenberg_get_connector_script_module_data( array $data ): array {
712 if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
713 return $data;
714 }
715
716 $registry = \WordPress\AiClient\AiClient::defaultRegistry();
717
718 if ( ! function_exists( 'validate_plugin' ) ) {
719 require_once ABSPATH . 'wp-admin/includes/plugin.php';
720 }
721
722 $connectors = array();
723 foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
724 $auth = $connector_data['authentication'];
725 $auth_out = array( 'method' => $auth['method'] );
726
727 if ( 'api_key' === $auth['method'] ) {
728 $auth_out['settingName'] = $auth['setting_name'] ?? '';
729 $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
730 $auth_out['keySource'] = _gutenberg_get_api_key_source(
731 $auth['setting_name'] ?? '',
732 $auth['env_var_name'] ?? '',
733 $auth['constant_name'] ?? ''
734 );
735
736 if ( 'ai_provider' === $connector_data['type'] ) {
737 try {
738 $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
739 } catch ( Exception $e ) {
740 $auth_out['isConnected'] = false;
741 }
742 } else {
743 // For non-AI connectors, consider connected if a key exists from any source.
744 $auth_out['isConnected'] = 'none' !== $auth_out['keySource'];
745 }
746 } elseif ( 'application_password' === $auth['method'] ) {
747 $credentials = _gutenberg_get_application_password_credentials( $auth );
748
749 $auth_out['settingName'] = $auth['setting_name'] ?? '';
750 $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
751 $auth_out['keySource'] = $credentials['source'];
752 $auth_out['isConnected'] = '' !== $credentials['username'] && '' !== $credentials['password'];
753 }
754
755 $connector_out = array(
756 'name' => $connector_data['name'],
757 'description' => $connector_data['description'],
758 'logoUrl' => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null,
759 'type' => $connector_data['type'],
760 'authentication' => $auth_out,
761 );
762
763 if ( ! empty( $connector_data['plugin']['file'] ) ) {
764 $file = $connector_data['plugin']['file'];
765 $is_activated = (bool) call_user_func( $connector_data['plugin']['is_active'] );
766 $is_installed = $is_activated || 0 === validate_plugin( $file );
767
768 $connector_out['plugin'] = array(
769 'file' => $file,
770 'isInstalled' => $is_installed,
771 'isActivated' => $is_activated,
772 );
773 }
774
775 $connectors[ $connector_id ] = $connector_out;
776 }
777 ksort( $connectors );
778 $data['connectors'] = $connectors;
779 $data['isFileModDisabled'] = ! wp_is_file_mod_allowed( 'install_plugins' );
780 return $data;
781 }
782 remove_filter( 'script_module_data_options-connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' );
783 add_filter( 'script_module_data_options-connectors-wp-admin', '_gutenberg_get_connector_script_module_data' );
784