Skip to content

Commit ee1559e

Browse files
Connectors: Add API key source detection and refactor REST dispatch
Add `_wp_connectors_get_api_key_source()` to detect whether an API key comes from an environment variable, PHP constant, or the database. This enables the UI to show the key source and hide the remove button for externally configured keys. Refactor API key validation and masking from `sanitize_callback` and `option_` filters into a single `rest_post_dispatch` handler (`_wp_connectors_rest_settings_dispatch`). This ensures raw keys are never exposed via the REST API and simplifies the validation flow. Enrich `_wp_connectors_get_connector_settings()` with plugin installation/activation status and static memoization. Update `_wp_connectors_get_connector_script_module_data()` to expose `keySource`, `isConnected`, `logoUrl`, and plugin status to the admin. Backports WordPress/gutenberg#76266 Backports WordPress/gutenberg#76327 updates include ref update
1 parent 2307f48 commit ee1559e

File tree

2 files changed

+111
-62
lines changed

2 files changed

+111
-62
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://develop.svn.wordpress.org/trunk"
88
},
99
"gutenberg": {
10-
"sha": "9b8144036fa5faf75de43d4502ff9809fcf689ad",
10+
"sha": "74a4f254a45f7a303bd27b8f8e104786380e8103",
1111
"ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build"
1212
},
1313
"engines": {

src/wp-includes/connectors.php

Lines changed: 110 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,50 @@ function _wp_connectors_mask_api_key( string $key ): string {
266266
return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
267267
}
268268

269+
/**
270+
* Determines the source of an API key for a given provider.
271+
*
272+
* Checks in order: environment variable, PHP constant, database.
273+
* Uses the same naming convention as the WP AI Client ProviderRegistry.
274+
*
275+
* @since 7.0.0
276+
* @access private
277+
*
278+
* @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google').
279+
* @return string The key source: 'env', 'constant', 'database', or 'none'.
280+
*/
281+
function _wp_connectors_get_api_key_source( string $provider_id ): string {
282+
// Convert provider ID to CONSTANT_CASE for env var name.
283+
// e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'.
284+
$constant_case_id = strtoupper(
285+
preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) )
286+
);
287+
$env_var_name = "{$constant_case_id}_API_KEY";
288+
289+
// Check environment variable first.
290+
$env_value = getenv( $env_var_name );
291+
if ( false !== $env_value && '' !== $env_value ) {
292+
return 'env';
293+
}
294+
295+
// Check PHP constant.
296+
if ( defined( $env_var_name ) ) {
297+
$const_value = constant( $env_var_name );
298+
if ( is_string( $const_value ) && '' !== $const_value ) {
299+
return 'constant';
300+
}
301+
}
302+
303+
// Check database.
304+
$setting_name = "connectors_ai_{$provider_id}_api_key";
305+
$db_value = get_option( $setting_name, '' );
306+
if ( '' !== $db_value ) {
307+
return 'database';
308+
}
309+
310+
return 'none';
311+
}
312+
269313
/**
270314
* Checks whether an API key is valid for a given provider.
271315
*
@@ -305,25 +349,6 @@ function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ):
305349
}
306350
}
307351

308-
/**
309-
* Retrieves the real (unmasked) value of a connector API key.
310-
*
311-
* Temporarily removes the masking filter, reads the option, then re-adds it.
312-
*
313-
* @since 7.0.0
314-
* @access private
315-
*
316-
* @param string $option_name The option name for the API key.
317-
* @param callable $mask_callback The mask filter function.
318-
* @return string The real API key value.
319-
*/
320-
function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string {
321-
remove_filter( "option_{$option_name}", $mask_callback );
322-
$value = get_option( $option_name, '' );
323-
add_filter( "option_{$option_name}", $mask_callback );
324-
return (string) $value;
325-
}
326-
327352
/**
328353
* Gets the registered connector settings.
329354
*
@@ -360,70 +385,69 @@ function _wp_connectors_get_connector_settings(): array {
360385
}
361386

362387
/**
363-
* Validates connector API keys in the REST response when explicitly requested.
388+
* Masks and validates connector API keys in REST responses.
389+
*
390+
* On every `/wp/v2/settings` response, masks connector API key values so raw
391+
* keys are never exposed via the REST API.
364392
*
365-
* Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector
366-
* fields via `_fields`. For each requested connector field, it validates the unmasked
367-
* key against the provider and replaces the response value with `invalid_key` if
368-
* validation fails.
393+
* On POST or PUT requests, validates each updated key against the provider
394+
* before masking. If validation fails, the key is reverted to an empty string.
369395
*
370396
* @since 7.0.0
371397
* @access private
372398
*
373399
* @param WP_REST_Response $response The response object.
374400
* @param WP_REST_Server $server The server instance.
375401
* @param WP_REST_Request $request The request object.
376-
* @return WP_REST_Response The potentially modified response.
402+
* @return WP_REST_Response The modified response with masked/validated keys.
377403
*/
378-
function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
404+
function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
379405
if ( '/wp/v2/settings' !== $request->get_route() ) {
380406
return $response;
381407
}
382408

383-
$fields = $request->get_param( '_fields' );
384-
if ( ! $fields ) {
385-
return $response;
386-
}
387-
388-
if ( is_array( $fields ) ) {
389-
$requested = $fields;
390-
} else {
391-
$requested = array_map( 'trim', explode( ',', $fields ) );
392-
}
393-
394409
$data = $response->get_data();
395410
if ( ! is_array( $data ) ) {
396411
return $response;
397412
}
398413

414+
$is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method();
415+
399416
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
400417
$auth = $connector_data['authentication'];
401-
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
418+
if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
402419
continue;
403420
}
404421

405422
$setting_name = $auth['setting_name'];
406-
if ( ! in_array( $setting_name, $requested, true ) ) {
423+
if ( ! array_key_exists( $setting_name, $data ) ) {
407424
continue;
408425
}
409426

410-
$real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' );
411-
if ( '' === $real_key ) {
412-
continue;
427+
$value = $data[ $setting_name ];
428+
429+
// On update, validate the key before masking.
430+
if ( $is_update && is_string( $value ) && '' !== $value ) {
431+
if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) {
432+
update_option( $setting_name, '' );
433+
$data[ $setting_name ] = '';
434+
continue;
435+
}
413436
}
414437

415-
if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) {
416-
$data[ $setting_name ] = 'invalid_key';
438+
// Mask the key in the response.
439+
if ( is_string( $value ) && '' !== $value ) {
440+
$data[ $setting_name ] = _wp_connectors_mask_api_key( $value );
417441
}
418442
}
419443

420444
$response->set_data( $data );
421445
return $response;
422446
}
423-
add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 );
447+
add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 );
424448

425449
/**
426-
* Registers default connector settings and mask/sanitize filters.
450+
* Registers default connector settings.
427451
*
428452
* @since 7.0.0
429453
* @access private
@@ -442,10 +466,9 @@ function _wp_register_default_connector_settings(): void {
442466
continue;
443467
}
444468

445-
$setting_name = $auth['setting_name'];
446469
register_setting(
447470
'connectors',
448-
$setting_name,
471+
$auth['setting_name'],
449472
array(
450473
'type' => 'string',
451474
'label' => sprintf(
@@ -460,18 +483,9 @@ function _wp_register_default_connector_settings(): void {
460483
),
461484
'default' => '',
462485
'show_in_rest' => true,
463-
'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string {
464-
$value = sanitize_text_field( $value );
465-
if ( '' === $value ) {
466-
return $value;
467-
}
468-
469-
$valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id );
470-
return true === $valid ? $value : '';
471-
},
486+
'sanitize_callback' => 'sanitize_text_field',
472487
)
473488
);
474-
add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' );
475489
}
476490
}
477491
add_action( 'init', '_wp_register_default_connector_settings', 20 );
@@ -499,7 +513,13 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
499513
continue;
500514
}
501515

502-
$api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' );
516+
// Skip if the key is already provided via env var or constant.
517+
$key_source = _wp_connectors_get_api_key_source( $connector_id );
518+
if ( 'env' === $key_source || 'constant' === $key_source ) {
519+
continue;
520+
}
521+
522+
$api_key = get_option( $auth['setting_name'], '' );
503523
if ( '' === $api_key ) {
504524
continue;
505525
}
@@ -525,6 +545,18 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
525545
* @return array Script module data with connectors added.
526546
*/
527547
function _wp_connectors_get_connector_script_module_data( array $data ): array {
548+
$registry = AiClient::defaultRegistry();
549+
550+
// Build a slug-to-file map for plugin installation status.
551+
if ( ! function_exists( 'get_plugins' ) ) {
552+
require_once ABSPATH . 'wp-admin/includes/plugin.php';
553+
}
554+
$plugin_files_by_slug = array();
555+
foreach ( array_keys( get_plugins() ) as $plugin_file ) {
556+
$slug = str_contains( $plugin_file, '/' ) ? dirname( $plugin_file ) : str_replace( '.php', '', $plugin_file );
557+
$plugin_files_by_slug[ $slug ] = $plugin_file;
558+
}
559+
528560
$connectors = array();
529561
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
530562
$auth = $connector_data['authentication'];
@@ -533,17 +565,34 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array {
533565
if ( 'api_key' === $auth['method'] ) {
534566
$auth_out['settingName'] = $auth['setting_name'] ?? '';
535567
$auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
568+
$auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id );
569+
try {
570+
$auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
571+
} catch ( Exception $e ) {
572+
$auth_out['isConnected'] = false;
573+
}
536574
}
537575

538576
$connector_out = array(
539577
'name' => $connector_data['name'],
540578
'description' => $connector_data['description'],
579+
'logoUrl' => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null,
541580
'type' => $connector_data['type'],
542581
'authentication' => $auth_out,
543582
);
544583

545-
if ( ! empty( $connector_data['plugin'] ) ) {
546-
$connector_out['plugin'] = $connector_data['plugin'];
584+
if ( ! empty( $connector_data['plugin']['slug'] ) ) {
585+
$plugin_slug = $connector_data['plugin']['slug'];
586+
$plugin_file = $plugin_files_by_slug[ $plugin_slug ] ?? null;
587+
588+
$is_installed = null !== $plugin_file;
589+
$is_activated = $is_installed && is_plugin_active( $plugin_file );
590+
591+
$connector_out['plugin'] = array(
592+
'slug' => $plugin_slug,
593+
'isInstalled' => $is_installed,
594+
'isActivated' => $is_activated,
595+
);
547596
}
548597

549598
$connectors[ $connector_id ] = $connector_out;

0 commit comments

Comments
 (0)