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

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

556 lines 18.1 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 * Masks an API key, showing only the last 4 characters.
257 *
258 * @access private
259 *
260 * @param string $key The API key to mask.
261 * @return string The masked key, e.g. "************fj39".
262 */
263 function _gutenberg_mask_api_key( string $key ): string {
264 if ( strlen( $key ) <= 4 ) {
265 return $key;
266 }
267
268 return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
269 }
270
271 /**
272 * Checks whether an API key is valid for a given provider.
273 *
274 * @access private
275 *
276 * @param string $key The API key to check.
277 * @param string $provider_id The WP AI client provider ID.
278 * @return bool|null True if valid, false if invalid, null if unable to determine.
279 */
280 function _gutenberg_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
281 try {
282 $registry = \WordPress\AiClient\AiClient::defaultRegistry();
283
284 if ( ! $registry->hasProvider( $provider_id ) ) {
285 _doing_it_wrong(
286 __FUNCTION__,
287 sprintf(
288 /* translators: %s: AI provider ID. */
289 __( 'The provider "%s" is not registered in the AI client registry.', 'gutenberg' ),
290 $provider_id
291 ),
292 '7.0.0'
293 );
294 return null;
295 }
296
297 $registry->setProviderRequestAuthentication(
298 $provider_id,
299 new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $key )
300 );
301
302 return $registry->isProviderConfigured( $provider_id );
303 } catch ( Exception $e ) {
304 wp_trigger_error( __FUNCTION__, $e->getMessage() );
305 return null;
306 }
307 }
308
309 /**
310 * Masks and validates connector API keys in REST responses.
311 *
312 * On every `/wp/v2/settings` response, masks connector API key values so raw
313 * keys are never exposed via the REST API.
314 *
315 * On POST or PUT requests, validates each updated key against the provider
316 * before masking. If validation fails, the key is reverted to an empty string.
317 *
318 * @access private
319 *
320 * @param WP_REST_Response $response The response object.
321 * @param WP_REST_Server $server The server instance.
322 * @param WP_REST_Request $request The request object.
323 * @return WP_REST_Response The modified response with masked/validated keys.
324 */
325 function _gutenberg_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
326 if ( '/wp/v2/settings' !== $request->get_route() ) {
327 return $response;
328 }
329
330 if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
331 return $response;
332 }
333
334 $data = $response->get_data();
335 if ( ! is_array( $data ) ) {
336 return $response;
337 }
338
339 $is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method();
340
341 foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
342 $auth = $connector_data['authentication'];
343 if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
344 continue;
345 }
346
347 $setting_name = $auth['setting_name'];
348 if ( ! array_key_exists( $setting_name, $data ) ) {
349 continue;
350 }
351
352 $value = $data[ $setting_name ];
353
354 // On update, validate AI provider keys before masking.
355 // Non-AI connectors accept keys as-is; the service plugin handles its own validation.
356 if ( $is_update && is_string( $value ) && '' !== $value && 'ai_provider' === $connector_data['type'] ) {
357 if ( true !== _gutenberg_is_ai_api_key_valid( $value, $connector_id ) ) {
358 update_option( $setting_name, '' );
359 $data[ $setting_name ] = '';
360 continue;
361 }
362 }
363
364 // Mask the key in the response.
365 if ( is_string( $value ) && '' !== $value ) {
366 $data[ $setting_name ] = _gutenberg_mask_api_key( $value );
367 }
368 }
369
370 $response->set_data( $data );
371 return $response;
372 }
373 remove_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10 );
374 remove_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10 );
375 add_filter( 'rest_post_dispatch', '_gutenberg_connectors_rest_settings_dispatch', 10, 3 );
376
377 /**
378 * Registers default connector settings.
379 *
380 * @access private
381 */
382 function _gutenberg_register_default_connector_settings(): void {
383 $existing_settings = get_registered_settings();
384
385 foreach ( wp_get_connectors() as $connector_data ) {
386 $auth = $connector_data['authentication'];
387 if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
388 continue;
389 }
390
391 // Skip if the setting is already registered (e.g. by the connector's plugin).
392 if ( isset( $existing_settings[ $auth['setting_name'] ] ) ) {
393 continue;
394 }
395
396 if ( ! isset( $connector_data['plugin']['is_active'] ) || ! is_callable( $connector_data['plugin']['is_active'] ) ) {
397 continue;
398 }
399
400 if ( ! call_user_func( $connector_data['plugin']['is_active'] ) ) {
401 continue;
402 }
403
404 register_setting(
405 'connectors',
406 $auth['setting_name'],
407 array(
408 'type' => 'string',
409 'label' => sprintf(
410 /* translators: %s: Connector name. */
411 __( '%s API Key', 'gutenberg' ),
412 $connector_data['name']
413 ),
414 'description' => sprintf(
415 /* translators: %s: Connector name. */
416 __( 'API key for the %s connector.', 'gutenberg' ),
417 $connector_data['name']
418 ),
419 'default' => '',
420 'show_in_rest' => true,
421 'sanitize_callback' => 'sanitize_text_field',
422 )
423 );
424 }
425 }
426 remove_action( 'init', '_wp_register_default_connector_settings', 20 );
427 add_action( 'init', '_gutenberg_register_default_connector_settings', 20 );
428
429 /**
430 * Passes stored connector API keys to the WP AI client.
431 *
432 * @access private
433 */
434 function _gutenberg_pass_default_connector_keys_to_ai_client(): void {
435 if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
436 return;
437 }
438
439 try {
440 $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry();
441 foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
442 if ( 'ai_provider' !== $connector_data['type'] ) {
443 continue;
444 }
445
446 $auth = $connector_data['authentication'];
447 if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
448 continue;
449 }
450
451 if ( ! $ai_registry->hasProvider( $connector_id ) ) {
452 continue;
453 }
454
455 // Skip if the key is already provided via env var or constant.
456 $key_source = _gutenberg_get_api_key_source(
457 $auth['setting_name'],
458 $auth['env_var_name'] ?? '',
459 $auth['constant_name'] ?? ''
460 );
461 if ( 'env' === $key_source || 'constant' === $key_source ) {
462 continue;
463 }
464
465 $api_key = get_option( $auth['setting_name'], '' );
466 if ( ! is_string( $api_key ) || '' === $api_key ) {
467 continue;
468 }
469
470 $ai_registry->setProviderRequestAuthentication(
471 $connector_id,
472 new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key )
473 );
474 }
475 } catch ( Exception $e ) {
476 wp_trigger_error( __FUNCTION__, $e->getMessage() );
477 }
478 }
479 remove_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );
480 add_action( 'init', '_gutenberg_pass_default_connector_keys_to_ai_client', 20 );
481
482 /**
483 * Exposes connector settings to the options-connectors-wp-admin script module.
484 *
485 * @access private
486 *
487 * @param array $data Existing script module data.
488 * @return array Script module data with connectors added.
489 */
490 function _gutenberg_get_connector_script_module_data( array $data ): array {
491 if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
492 return $data;
493 }
494
495 $registry = \WordPress\AiClient\AiClient::defaultRegistry();
496
497 if ( ! function_exists( 'validate_plugin' ) ) {
498 require_once ABSPATH . 'wp-admin/includes/plugin.php';
499 }
500
501 $connectors = array();
502 foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
503 $auth = $connector_data['authentication'];
504 $auth_out = array( 'method' => $auth['method'] );
505
506 if ( 'api_key' === $auth['method'] ) {
507 $auth_out['settingName'] = $auth['setting_name'] ?? '';
508 $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
509 $auth_out['keySource'] = _gutenberg_get_api_key_source(
510 $auth['setting_name'] ?? '',
511 $auth['env_var_name'] ?? '',
512 $auth['constant_name'] ?? ''
513 );
514
515 if ( 'ai_provider' === $connector_data['type'] ) {
516 try {
517 $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
518 } catch ( Exception $e ) {
519 $auth_out['isConnected'] = false;
520 }
521 } else {
522 // For non-AI connectors, consider connected if a key exists from any source.
523 $auth_out['isConnected'] = 'none' !== $auth_out['keySource'];
524 }
525 }
526
527 $connector_out = array(
528 'name' => $connector_data['name'],
529 'description' => $connector_data['description'],
530 'logoUrl' => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null,
531 'type' => $connector_data['type'],
532 'authentication' => $auth_out,
533 );
534
535 if ( ! empty( $connector_data['plugin']['file'] ) ) {
536 $file = $connector_data['plugin']['file'];
537 $is_activated = (bool) call_user_func( $connector_data['plugin']['is_active'] );
538 $is_installed = $is_activated || 0 === validate_plugin( $file );
539
540 $connector_out['plugin'] = array(
541 'file' => $file,
542 'isInstalled' => $is_installed,
543 'isActivated' => $is_activated,
544 );
545 }
546
547 $connectors[ $connector_id ] = $connector_out;
548 }
549 ksort( $connectors );
550 $data['connectors'] = $connectors;
551 $data['isFileModDisabled'] = ! wp_is_file_mod_allowed( 'install_plugins' );
552 return $data;
553 }
554 remove_filter( 'script_module_data_options-connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' );
555 add_filter( 'script_module_data_options-connectors-wp-admin', '_gutenberg_get_connector_script_module_data' );
556