Skip to content

Commit a47dc58

Browse files
Connectors: Add API key source detection and refactor REST behaviour/masking.
Add `_wp_connectors_get_api_key_source()` to detect whether an API key is configured via environment variable, PHP constant, or database. The UI uses this to show the key source and hide "Remove and replace" for externally configured keys. Replace `_wp_connectors_validate_keys_in_rest()` and `_wp_connectors_get_real_api_key()` with a single `rest_post_dispatch` handler, `_wp_connectors_rest_settings_dispatch()`, that masks keys in all `/wp/v2/settings` responses and validates on POST/PUT, reverting invalid keys. Simplify `_wp_register_default_connector_settings()` by replacing the closure-based `sanitize_callback` and `option_` mask filter with plain `sanitize_text_field`, since masking is now handled at the REST layer. Enrich `_wp_connectors_get_connector_script_module_data()` to expose `keySource`, `isConnected`, `logoUrl`, and plugin `isInstalled` / `isActivated` status to the admin screen. Update `_wp_connectors_pass_default_keys_to_ai_client()` to skip keys sourced from environment variables or constants and read the database directly via `get_option()`. Set `_wp_connectors_init` priority to 15 so the registry is ready before settings are registered at priority 20. Backports WordPress/gutenberg#76266. Backports WordPress/gutenberg#76327. Props jorgefilipecosta, gziolo, swissspidy, flixos90. Fixes #64819. git-svn-id: https://develop.svn.wordpress.org/trunk@61985 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 500fa46 commit a47dc58

2 files changed

Lines changed: 109 additions & 60 deletions

File tree

src/wp-includes/connectors.php

Lines changed: 108 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,50 @@ function _wp_connectors_mask_api_key( string $key ): string {
338338
return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
339339
}
340340

341+
/**
342+
* Determines the source of an API key for a given provider.
343+
*
344+
* Checks in order: environment variable, PHP constant, database.
345+
* Uses the same naming convention as the WP AI Client ProviderRegistry.
346+
*
347+
* @since 7.0.0
348+
* @access private
349+
*
350+
* @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google').
351+
* @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_api_key').
352+
* @return string The key source: 'env', 'constant', 'database', or 'none'.
353+
*/
354+
function _wp_connectors_get_api_key_source( string $provider_id, string $setting_name ): string {
355+
// Convert provider ID to CONSTANT_CASE for env var name.
356+
// e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'.
357+
$constant_case_id = strtoupper(
358+
preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) )
359+
);
360+
$env_var_name = "{$constant_case_id}_API_KEY";
361+
362+
// Check environment variable first.
363+
$env_value = getenv( $env_var_name );
364+
if ( false !== $env_value && '' !== $env_value ) {
365+
return 'env';
366+
}
367+
368+
// Check PHP constant.
369+
if ( defined( $env_var_name ) ) {
370+
$const_value = constant( $env_var_name );
371+
if ( is_string( $const_value ) && '' !== $const_value ) {
372+
return 'constant';
373+
}
374+
}
375+
376+
// Check database.
377+
$db_value = get_option( $setting_name, '' );
378+
if ( '' !== $db_value ) {
379+
return 'database';
380+
}
381+
382+
return 'none';
383+
}
384+
341385
/**
342386
* Checks whether an API key is valid for a given provider.
343387
*
@@ -378,89 +422,69 @@ function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ):
378422
}
379423

380424
/**
381-
* Retrieves the real (unmasked) value of a connector API key.
425+
* Masks and validates connector API keys in REST responses.
382426
*
383-
* Temporarily removes the masking filter, reads the option, then re-adds it.
384-
*
385-
* @since 7.0.0
386-
* @access private
387-
*
388-
* @param string $option_name The option name for the API key.
389-
* @param callable $mask_callback The mask filter function.
390-
* @return string The real API key value.
391-
*/
392-
function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string {
393-
remove_filter( "option_{$option_name}", $mask_callback );
394-
$value = get_option( $option_name, '' );
395-
add_filter( "option_{$option_name}", $mask_callback );
396-
return (string) $value;
397-
}
398-
399-
/**
400-
* Validates connector API keys in the REST response when explicitly requested.
427+
* On every `/wp/v2/settings` response, masks connector API key values so raw
428+
* keys are never exposed via the REST API.
401429
*
402-
* Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector
403-
* fields via `_fields`. For each requested connector field, it validates the unmasked
404-
* key against the provider and replaces the response value with `invalid_key` if
405-
* validation fails.
430+
* On POST or PUT requests, validates each updated key against the provider
431+
* before masking. If validation fails, the key is reverted to an empty string.
406432
*
407433
* @since 7.0.0
408434
* @access private
409435
*
410436
* @param WP_REST_Response $response The response object.
411437
* @param WP_REST_Server $server The server instance.
412438
* @param WP_REST_Request $request The request object.
413-
* @return WP_REST_Response The potentially modified response.
439+
* @return WP_REST_Response The modified response with masked/validated keys.
414440
*/
415-
function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
441+
function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
416442
if ( '/wp/v2/settings' !== $request->get_route() ) {
417443
return $response;
418444
}
419445

420-
$fields = $request->get_param( '_fields' );
421-
if ( ! $fields ) {
422-
return $response;
423-
}
424-
425-
if ( is_array( $fields ) ) {
426-
$requested = $fields;
427-
} else {
428-
$requested = array_map( 'trim', explode( ',', $fields ) );
429-
}
430-
431446
$data = $response->get_data();
432447
if ( ! is_array( $data ) ) {
433448
return $response;
434449
}
435450

451+
$is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method();
452+
436453
foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
437454
$auth = $connector_data['authentication'];
438455
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
439456
continue;
440457
}
441458

442459
$setting_name = $auth['setting_name'];
443-
if ( ! in_array( $setting_name, $requested, true ) ) {
460+
if ( ! array_key_exists( $setting_name, $data ) ) {
444461
continue;
445462
}
446463

447-
$real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' );
448-
if ( '' === $real_key ) {
449-
continue;
464+
$value = $data[ $setting_name ];
465+
466+
// On update, validate the key before masking.
467+
if ( $is_update && is_string( $value ) && '' !== $value ) {
468+
if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) {
469+
update_option( $setting_name, '' );
470+
$data[ $setting_name ] = '';
471+
continue;
472+
}
450473
}
451474

452-
if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) {
453-
$data[ $setting_name ] = 'invalid_key';
475+
// Mask the key in the response.
476+
if ( is_string( $value ) && '' !== $value ) {
477+
$data[ $setting_name ] = _wp_connectors_mask_api_key( $value );
454478
}
455479
}
456480

457481
$response->set_data( $data );
458482
return $response;
459483
}
460-
add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 );
484+
add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 );
461485

462486
/**
463-
* Registers default connector settings and mask/sanitize filters.
487+
* Registers default connector settings.
464488
*
465489
* @since 7.0.0
466490
* @access private
@@ -479,10 +503,9 @@ function _wp_register_default_connector_settings(): void {
479503
continue;
480504
}
481505

482-
$setting_name = $auth['setting_name'];
483506
register_setting(
484507
'connectors',
485-
$setting_name,
508+
$auth['setting_name'],
486509
array(
487510
'type' => 'string',
488511
'label' => sprintf(
@@ -497,18 +520,9 @@ function _wp_register_default_connector_settings(): void {
497520
),
498521
'default' => '',
499522
'show_in_rest' => true,
500-
'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string {
501-
$value = sanitize_text_field( $value );
502-
if ( '' === $value ) {
503-
return $value;
504-
}
505-
506-
$valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id );
507-
return true === $valid ? $value : '';
508-
},
523+
'sanitize_callback' => 'sanitize_text_field',
509524
)
510525
);
511-
add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' );
512526
}
513527
}
514528
add_action( 'init', '_wp_register_default_connector_settings', 20 );
@@ -536,7 +550,13 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
536550
continue;
537551
}
538552

539-
$api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' );
553+
// Skip if the key is already provided via env var or constant.
554+
$key_source = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] );
555+
if ( 'env' === $key_source || 'constant' === $key_source ) {
556+
continue;
557+
}
558+
559+
$api_key = get_option( $auth['setting_name'], '' );
540560
if ( '' === $api_key ) {
541561
continue;
542562
}
@@ -562,6 +582,18 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
562582
* @return array<string, mixed> Script module data with connectors added.
563583
*/
564584
function _wp_connectors_get_connector_script_module_data( array $data ): array {
585+
$registry = AiClient::defaultRegistry();
586+
587+
// Build a slug-to-file map for plugin installation status.
588+
if ( ! function_exists( 'get_plugins' ) ) {
589+
require_once ABSPATH . 'wp-admin/includes/plugin.php';
590+
}
591+
$plugin_files_by_slug = array();
592+
foreach ( array_keys( get_plugins() ) as $plugin_file ) {
593+
$slug = str_contains( $plugin_file, '/' ) ? dirname( $plugin_file ) : str_replace( '.php', '', $plugin_file );
594+
$plugin_files_by_slug[ $slug ] = $plugin_file;
595+
}
596+
565597
$connectors = array();
566598
foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
567599
$auth = $connector_data['authentication'];
@@ -570,17 +602,34 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array {
570602
if ( 'api_key' === $auth['method'] ) {
571603
$auth_out['settingName'] = $auth['setting_name'] ?? '';
572604
$auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
605+
$auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ?? '' );
606+
try {
607+
$auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
608+
} catch ( Exception $e ) {
609+
$auth_out['isConnected'] = false;
610+
}
573611
}
574612

575613
$connector_out = array(
576614
'name' => $connector_data['name'],
577615
'description' => $connector_data['description'],
616+
'logoUrl' => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null,
578617
'type' => $connector_data['type'],
579618
'authentication' => $auth_out,
580619
);
581620

582-
if ( ! empty( $connector_data['plugin'] ) ) {
583-
$connector_out['plugin'] = $connector_data['plugin'];
621+
if ( ! empty( $connector_data['plugin']['slug'] ) ) {
622+
$plugin_slug = $connector_data['plugin']['slug'];
623+
$plugin_file = $plugin_files_by_slug[ $plugin_slug ] ?? null;
624+
625+
$is_installed = null !== $plugin_file;
626+
$is_activated = $is_installed && is_plugin_active( $plugin_file );
627+
628+
$connector_out['plugin'] = array(
629+
'slug' => $plugin_slug,
630+
'isInstalled' => $is_installed,
631+
'isActivated' => $is_activated,
632+
);
584633
}
585634

586635
$connectors[ $connector_id ] = $connector_out;

src/wp-includes/default-filters.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,7 @@
540540
add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' );
541541

542542
// Connectors API.
543-
add_action( 'init', '_wp_connectors_init' );
543+
add_action( 'init', '_wp_connectors_init', 15 );
544544

545545
// Sitemaps actions.
546546
add_action( 'init', 'wp_sitemaps_get_server' );

0 commit comments

Comments
 (0)