Make WordPress Core

Changeset 61985


Ignore:
Timestamp:
03/12/2026 12:05:33 PM (6 months ago)
Author:
jorgefilipecosta
Message:

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 https://github.com/WordPress/gutenberg/pull/76266.
Backports https://github.com/WordPress/gutenberg/pull/76327.

Props jorgefilipecosta, gziolo, swissspidy, flixos90.
Fixes #64819.

Location:
trunk/src/wp-includes
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/connectors.php

    r61983 r61985  
    340340
    341341/**
     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 */
     354function _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
     385/**
    342386 * Checks whether an API key is valid for a given provider.
    343387 *
     
    379423
    380424/**
    381  * Retrieves the real (unmasked) value of a connector API key.
    382  *
    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.
    401  *
    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.
     425 * Masks and validates connector API keys in REST responses.
     426 *
     427 * On every `/wp/v2/settings` response, masks connector API key values so raw
     428 * keys are never exposed via the REST API.
     429 *
     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
     
    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.
    414  */
    415 function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
     439 * @return WP_REST_Response The modified response with masked/validated keys.
     440 */
     441function _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;
    418         }
    419 
    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 ) );
    429444        }
    430445
     
    433448                return $response;
    434449        }
     450
     451        $is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method();
    435452
    436453        foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
     
    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;
    450                 }
    451 
    452                 if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) {
    453                         $data[ $setting_name ] = 'invalid_key';
     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                        }
     473                }
     474
     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        }
     
    458482        return $response;
    459483}
    460 add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 );
    461 
    462 /**
    463  * Registers default connector settings and mask/sanitize filters.
     484add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 );
     485
     486/**
     487 * Registers default connector settings.
    464488 *
    465489 * @since 7.0.0
     
    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',
     
    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}
     
    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;
     
    563583 */
    564584function _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 ) {
     
    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
     
    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
  • trunk/src/wp-includes/default-filters.php

    r61981 r61985  
    541541
    542542// Connectors API.
    543 add_action( 'init', '_wp_connectors_init' );
     543add_action( 'init', '_wp_connectors_init', 15 );
    544544
    545545// Sitemaps actions.
Note: See TracChangeset for help on using the changeset viewer.