diff --git a/package.json b/package.json index 117cf46f5455f..a8ba7e94cdbc3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "sha": "9b8144036fa5faf75de43d4502ff9809fcf689ad", + "sha": "74a4f254a45f7a303bd27b8f8e104786380e8103", "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" }, "engines": { diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index b8b9004354652..575f71da7766c 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -338,6 +338,50 @@ function _wp_connectors_mask_api_key( string $key ): string { return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 ); } +/** + * Determines the source of an API key for a given provider. + * + * Checks in order: environment variable, PHP constant, database. + * Uses the same naming convention as the WP AI Client ProviderRegistry. + * + * @since 7.0.0 + * @access private + * + * @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google'). + * @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_api_key'). + * @return string The key source: 'env', 'constant', 'database', or 'none'. + */ +function _wp_connectors_get_api_key_source( string $provider_id, string $setting_name ): string { + // Convert provider ID to CONSTANT_CASE for env var name. + // e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'. + $constant_case_id = strtoupper( + preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) ) + ); + $env_var_name = "{$constant_case_id}_API_KEY"; + + // Check environment variable first. + $env_value = getenv( $env_var_name ); + if ( false !== $env_value && '' !== $env_value ) { + return 'env'; + } + + // Check PHP constant. + if ( defined( $env_var_name ) ) { + $const_value = constant( $env_var_name ); + if ( is_string( $const_value ) && '' !== $const_value ) { + return 'constant'; + } + } + + // Check database. + $db_value = get_option( $setting_name, '' ); + if ( '' !== $db_value ) { + return 'database'; + } + + return 'none'; +} + /** * Checks whether an API key is valid for a given provider. * @@ -378,31 +422,13 @@ function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): } /** - * Retrieves the real (unmasked) value of a connector API key. + * Masks and validates connector API keys in REST responses. * - * Temporarily removes the masking filter, reads the option, then re-adds it. - * - * @since 7.0.0 - * @access private - * - * @param string $option_name The option name for the API key. - * @param callable $mask_callback The mask filter function. - * @return string The real API key value. - */ -function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string { - remove_filter( "option_{$option_name}", $mask_callback ); - $value = get_option( $option_name, '' ); - add_filter( "option_{$option_name}", $mask_callback ); - return (string) $value; -} - -/** - * Validates connector API keys in the REST response when explicitly requested. + * On every `/wp/v2/settings` response, masks connector API key values so raw + * keys are never exposed via the REST API. * - * Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector - * fields via `_fields`. For each requested connector field, it validates the unmasked - * key against the provider and replaces the response value with `invalid_key` if - * validation fails. + * On POST or PUT requests, validates each updated key against the provider + * before masking. If validation fails, the key is reverted to an empty string. * * @since 7.0.0 * @access private @@ -410,29 +436,20 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca * @param WP_REST_Response $response The response object. * @param WP_REST_Server $server The server instance. * @param WP_REST_Request $request The request object. - * @return WP_REST_Response The potentially modified response. + * @return WP_REST_Response The modified response with masked/validated keys. */ -function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { +function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { if ( '/wp/v2/settings' !== $request->get_route() ) { return $response; } - $fields = $request->get_param( '_fields' ); - if ( ! $fields ) { - return $response; - } - - if ( is_array( $fields ) ) { - $requested = $fields; - } else { - $requested = array_map( 'trim', explode( ',', $fields ) ); - } - $data = $response->get_data(); if ( ! is_array( $data ) ) { return $response; } + $is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method(); + foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { @@ -440,27 +457,34 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE } $setting_name = $auth['setting_name']; - if ( ! in_array( $setting_name, $requested, true ) ) { + if ( ! array_key_exists( $setting_name, $data ) ) { continue; } - $real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' ); - if ( '' === $real_key ) { - continue; + $value = $data[ $setting_name ]; + + // On update, validate the key before masking. + if ( $is_update && is_string( $value ) && '' !== $value ) { + if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) { + update_option( $setting_name, '' ); + $data[ $setting_name ] = ''; + continue; + } } - if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) { - $data[ $setting_name ] = 'invalid_key'; + // Mask the key in the response. + if ( is_string( $value ) && '' !== $value ) { + $data[ $setting_name ] = _wp_connectors_mask_api_key( $value ); } } $response->set_data( $data ); return $response; } -add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 ); +add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 ); /** - * Registers default connector settings and mask/sanitize filters. + * Registers default connector settings. * * @since 7.0.0 * @access private @@ -479,10 +503,9 @@ function _wp_register_default_connector_settings(): void { continue; } - $setting_name = $auth['setting_name']; register_setting( 'connectors', - $setting_name, + $auth['setting_name'], array( 'type' => 'string', 'label' => sprintf( @@ -497,18 +520,9 @@ function _wp_register_default_connector_settings(): void { ), 'default' => '', 'show_in_rest' => true, - 'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string { - $value = sanitize_text_field( $value ); - if ( '' === $value ) { - return $value; - } - - $valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id ); - return true === $valid ? $value : ''; - }, + 'sanitize_callback' => 'sanitize_text_field', ) ); - add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); } } add_action( 'init', '_wp_register_default_connector_settings', 20 ); @@ -536,7 +550,13 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } - $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); + // Skip if the key is already provided via env var or constant. + $key_source = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ); + if ( 'env' === $key_source || 'constant' === $key_source ) { + continue; + } + + $api_key = get_option( $auth['setting_name'], '' ); if ( '' === $api_key ) { continue; } @@ -562,6 +582,18 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { * @return array Script module data with connectors added. */ function _wp_connectors_get_connector_script_module_data( array $data ): array { + $registry = AiClient::defaultRegistry(); + + // Build a slug-to-file map for plugin installation status. + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + $plugin_files_by_slug = array(); + foreach ( array_keys( get_plugins() ) as $plugin_file ) { + $slug = str_contains( $plugin_file, '/' ) ? dirname( $plugin_file ) : str_replace( '.php', '', $plugin_file ); + $plugin_files_by_slug[ $slug ] = $plugin_file; + } + $connectors = array(); foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; @@ -570,17 +602,34 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { if ( 'api_key' === $auth['method'] ) { $auth_out['settingName'] = $auth['setting_name'] ?? ''; $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null; + $auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ?? '' ); + try { + $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); + } catch ( Exception $e ) { + $auth_out['isConnected'] = false; + } } $connector_out = array( 'name' => $connector_data['name'], 'description' => $connector_data['description'], + 'logoUrl' => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null, 'type' => $connector_data['type'], 'authentication' => $auth_out, ); - if ( ! empty( $connector_data['plugin'] ) ) { - $connector_out['plugin'] = $connector_data['plugin']; + if ( ! empty( $connector_data['plugin']['slug'] ) ) { + $plugin_slug = $connector_data['plugin']['slug']; + $plugin_file = $plugin_files_by_slug[ $plugin_slug ] ?? null; + + $is_installed = null !== $plugin_file; + $is_activated = $is_installed && is_plugin_active( $plugin_file ); + + $connector_out['plugin'] = array( + 'slug' => $plugin_slug, + 'isInstalled' => $is_installed, + 'isActivated' => $is_activated, + ); } $connectors[ $connector_id ] = $connector_out; diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 2b5d18923a8eb..dd3786aef5059 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -540,7 +540,7 @@ add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); // Connectors API. -add_action( 'init', '_wp_connectors_init' ); +add_action( 'init', '_wp_connectors_init', 15 ); // Sitemaps actions. add_action( 'init', 'wp_sitemaps_get_server' );