Make WordPress Core

Changeset 61985


Ignore:
Timestamp:
03/12/2026 12:05:33 PM (3 weeks 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.