-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Add: Connectors screen and API #75833
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2112fa8
6604d0d
eea90e2
2045880
2621e63
07694cb
fb4ba8d
8214ea7
1ead5a0
2e536aa
e4dc487
2392c0f
9e8398b
6a543af
1812aa7
56e05da
160e249
9f6d184
df48889
22c72e2
84bd4ee
b630db4
cc18956
9ada092
069a71c
d42370a
6a5806b
aaa082d
a3a491e
b3426bc
0b32e53
92ad29f
58d2b46
e6ec507
40ca346
eacfe66
727d144
d4c0d47
733dceb
ab5de8d
045d624
d4285b4
0349c34
9ba27a5
b94264c
d219ba3
6102089
3bec2be
9985d04
023afbd
dd111ba
82890c7
19757e7
8cdc3c5
579e63a
b72cea2
43ce449
93ae861
7c867b8
1bf97fb
5d9b28f
897aaab
007c9d4
67c84de
001c038
ad8ca56
f66b513
1e17207
ed83d19
c9b2ead
f369988
c4db60a
10d0205
e07d6a5
98388d5
a16e665
10f0dd4
7fd1ca7
5e47da7
550567d
a6670b9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,323 @@ | ||
| <?php | ||
| /** | ||
| * Default connectors backend logic. | ||
| * | ||
| * @package gutenberg | ||
| */ | ||
|
|
||
| /** | ||
| * Masks an API key, showing only the last 4 characters. | ||
| * | ||
| * @access private | ||
| * | ||
| * @param string $key The API key to mask. | ||
| * @return string The masked key, e.g. "••••••••••••fj39". | ||
| */ | ||
| function _gutenberg_mask_api_key( string $key ): string { | ||
| if ( strlen( $key ) <= 4 ) { | ||
| return $key; | ||
| } | ||
| return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 ); | ||
| } | ||
|
|
||
| /** | ||
| * Checks whether an API key is valid for a given provider. | ||
| * | ||
| * @access private | ||
| * | ||
| * @param string $key The API key to check. | ||
| * @param string $provider_id The WP AI client provider ID. | ||
| * @return bool|null True if valid, false if invalid, null if unable to determine. | ||
| */ | ||
| function _gutenberg_is_api_key_valid( string $key, string $provider_id ): ?bool { | ||
| try { | ||
| $registry = \WordPress\AiClient\AiClient::defaultRegistry(); | ||
|
|
||
| if ( ! $registry->hasProvider( $provider_id ) ) { | ||
| return null; | ||
| } | ||
|
|
||
| $registry->setProviderRequestAuthentication( | ||
| $provider_id, | ||
| new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $key ) | ||
| ); | ||
|
|
||
| return $registry->isProviderConfigured( $provider_id ); | ||
| } catch ( \Error $e ) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Sets the API key authentication for a provider on the WP AI Client registry. | ||
| * | ||
| * @access private | ||
| * | ||
| * @param string $key The API key. | ||
| * @param string $provider_id The WP AI client provider ID. | ||
| */ | ||
| function _gutenberg_set_provider_api_key( string $key, string $provider_id ): void { | ||
| try { | ||
| $registry = \WordPress\AiClient\AiClient::defaultRegistry(); | ||
|
|
||
| if ( ! $registry->hasProvider( $provider_id ) ) { | ||
| return; | ||
| } | ||
|
|
||
| $registry->setProviderRequestAuthentication( | ||
| $provider_id, | ||
| new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $key ) | ||
| ); | ||
| } catch ( \Error $e ) { | ||
| // WP AI Client not available. | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Retrieves the real (unmasked) value of a connector API key. | ||
| * | ||
| * Temporarily removes the masking filter, reads the option, then re-adds it. | ||
| * | ||
| * @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 _gutenberg_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 ); | ||
|
Comment on lines
+88
to
+90
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Masking is nice, but should we also encrypt out API keys before we store them in them the database? Seems like a bigger problem than before when giving AI access to your database is one of the bigger practical use cases for agentic site work. Also confirming - |
||
| return $value; | ||
| } | ||
|
|
||
| // --- Gemini (Google) --- | ||
|
|
||
| /** | ||
| * Masks the Gemini API key on read. | ||
| * | ||
| * @access private | ||
| * | ||
| * @param string $value The raw option value. | ||
| * @return string Masked key or empty string. | ||
| */ | ||
| function _gutenberg_mask_gemini_api_key( string $value ): string { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are all private anyway, why even bother? I say we ditch the boilerplate and just call |
||
| if ( '' === $value ) { | ||
| return $value; | ||
| } | ||
| return _gutenberg_mask_api_key( $value ); | ||
| } | ||
|
|
||
| /** | ||
| * Sanitizes and validates the Gemini API key before saving. | ||
| * | ||
| * @access private | ||
| * | ||
| * @param string $value The new value. | ||
| * @return string The sanitized value, or empty string if the key is not valid. | ||
| */ | ||
| function _gutenberg_sanitize_gemini_api_key( string $value ): string { | ||
| $value = sanitize_text_field( $value ); | ||
| if ( '' === $value ) { | ||
| return $value; | ||
| } | ||
| $valid = _gutenberg_is_api_key_valid( $value, 'google' ); | ||
| return true === $valid ? $value : ''; | ||
| } | ||
|
|
||
| // --- OpenAI --- | ||
|
|
||
| /** | ||
| * Masks the OpenAI API key on read. | ||
| * | ||
| * @access private | ||
| * | ||
| * @param string $value The raw option value. | ||
| * @return string Masked key or empty string. | ||
| */ | ||
| function _gutenberg_mask_openai_api_key( string $value ): string { | ||
| if ( '' === $value ) { | ||
| return $value; | ||
| } | ||
| return _gutenberg_mask_api_key( $value ); | ||
| } | ||
|
|
||
| /** | ||
| * Sanitizes and validates the OpenAI API key before saving. | ||
| * | ||
| * @access private | ||
| * | ||
| * @param string $value The new value. | ||
| * @return string The sanitized value, or empty string if the key is not valid. | ||
| */ | ||
| function _gutenberg_sanitize_openai_api_key( string $value ): string { | ||
| $value = sanitize_text_field( $value ); | ||
| if ( '' === $value ) { | ||
| return $value; | ||
| } | ||
| $valid = _gutenberg_is_api_key_valid( $value, 'openai' ); | ||
| return true === $valid ? $value : ''; | ||
| } | ||
|
|
||
| // --- Anthropic --- | ||
|
|
||
| /** | ||
| * Masks the Anthropic API key on read. | ||
| * | ||
| * @access private | ||
| * | ||
| * @param string $value The raw option value. | ||
| * @return string Masked key or empty string. | ||
| */ | ||
| function _gutenberg_mask_anthropic_api_key( string $value ): string { | ||
| if ( '' === $value ) { | ||
| return $value; | ||
| } | ||
| return _gutenberg_mask_api_key( $value ); | ||
| } | ||
|
|
||
| /** | ||
| * Sanitizes and validates the Anthropic API key before saving. | ||
| * | ||
| * @access private | ||
| * | ||
| * @param string $value The new value. | ||
| * @return string The sanitized value, or empty string if the key is not valid. | ||
| */ | ||
| function _gutenberg_sanitize_anthropic_api_key( string $value ): string { | ||
| $value = sanitize_text_field( $value ); | ||
| if ( '' === $value ) { | ||
| return $value; | ||
| } | ||
| $valid = _gutenberg_is_api_key_valid( $value, 'anthropic' ); | ||
| return true === $valid ? $value : ''; | ||
| } | ||
|
|
||
| // --- Connector definitions --- | ||
|
|
||
| /** | ||
| * Gets the provider connectors. | ||
| * | ||
| * @access private | ||
| * | ||
| * @return array<string, array{ provider: string, mask: callable, sanitize: callable }> Connectors. | ||
| */ | ||
| function _gutenberg_get_connectors(): array { | ||
| return array( | ||
| 'connectors_gemini_api_key' => array( | ||
| 'provider' => 'google', | ||
| 'mask' => '_gutenberg_mask_gemini_api_key', | ||
| 'sanitize' => '_gutenberg_sanitize_gemini_api_key', | ||
| ), | ||
| 'connectors_openai_api_key' => array( | ||
| 'provider' => 'openai', | ||
| 'mask' => '_gutenberg_mask_openai_api_key', | ||
| 'sanitize' => '_gutenberg_sanitize_openai_api_key', | ||
| ), | ||
| 'connectors_anthropic_api_key' => array( | ||
| 'provider' => 'anthropic', | ||
| 'mask' => '_gutenberg_mask_anthropic_api_key', | ||
| 'sanitize' => '_gutenberg_sanitize_anthropic_api_key', | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| // --- REST API filtering --- | ||
|
|
||
| /** | ||
| * Validates connector API keys in the REST response when explicitly requested. | ||
| * | ||
| * Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include | ||
| * connector fields via `_fields`. For each requested connector field, reads | ||
| * the real (unmasked) key, validates it against the provider, and replaces | ||
| * the response value with 'invalid_key' if validation fails. | ||
| * | ||
| * @access private | ||
| * | ||
| * @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. | ||
| */ | ||
| function _gutenberg_validate_connector_keys_in_rest( 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; | ||
| } | ||
|
|
||
| $requested = array_map( 'trim', explode( ',', $fields ) ); | ||
| $data = $response->get_data(); | ||
| $connectors = _gutenberg_get_connectors(); | ||
|
|
||
| foreach ( $connectors as $option_name => $config ) { | ||
| if ( ! in_array( $option_name, $requested, true ) ) { | ||
| continue; | ||
| } | ||
| $real_key = _gutenberg_get_real_api_key( $option_name, $config['mask'] ); | ||
| if ( '' === $real_key ) { | ||
| continue; | ||
| } | ||
| if ( true !== _gutenberg_is_api_key_valid( $real_key, $config['provider'] ) ) { | ||
| $data[ $option_name ] = 'invalid_key'; | ||
| } | ||
| } | ||
|
|
||
| $response->set_data( $data ); | ||
| return $response; | ||
| } | ||
| add_filter( 'rest_post_dispatch', '_gutenberg_validate_connector_keys_in_rest', 10, 3 ); | ||
|
|
||
| // --- Registration --- | ||
|
|
||
| /** | ||
| * Registers the default connector settings, mask filters, and validation filters. | ||
| * | ||
| * @access private | ||
| */ | ||
| function _gutenberg_register_default_connector_settings(): void { | ||
| if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { | ||
| return; | ||
| } | ||
|
|
||
| $connectors = _gutenberg_get_connectors(); | ||
|
|
||
| foreach ( $connectors as $option_name => $config ) { | ||
| register_setting( | ||
| 'connectors', | ||
| $option_name, | ||
| array( | ||
| 'type' => 'string', | ||
| 'default' => '', | ||
| 'show_in_rest' => true, | ||
| 'sanitize_callback' => $config['sanitize'], | ||
| ) | ||
| ); | ||
| add_filter( "option_{$option_name}", $config['mask'] ); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It sounds like these API key options should always be masked by default, but the filter is instead temporarily removed when passing to the AI provider class when auth is being configured.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, we need a proper security audit here, because we try to mirror how Claude and OpenAI manage API keys. However, we also noticed that Google allows displaying the stored API key for Gemini, so maybe it's fine to skip that masking.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small nuance to consider: just because you can use an API Key on one site (or are authorized to connect a new one) doesn't mean you can use that key off site in your own tooling. (related https://github.com/WordPress/gutenberg/pull/75833/files#r2855648259 ) |
||
| } | ||
| } | ||
| add_action( 'init', '_gutenberg_register_default_connector_settings' ); | ||
|
|
||
| /** | ||
| * Passes the default connector API keys to the WP AI client. | ||
| * | ||
| * @access private | ||
| */ | ||
| function _gutenberg_pass_default_connector_keys_to_ai_client(): void { | ||
| if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { | ||
| return; | ||
| } | ||
|
|
||
| $connectors = _gutenberg_get_connectors(); | ||
|
|
||
| foreach ( $connectors as $option_name => $config ) { | ||
| $api_key = _gutenberg_get_real_api_key( $option_name, $config['mask'] ); | ||
| if ( ! empty( $api_key ) ) { | ||
| _gutenberg_set_provider_api_key( $api_key, $config['provider'] ); | ||
| } | ||
| } | ||
| } | ||
| add_action( 'init', '_gutenberg_pass_default_connector_keys_to_ai_client' ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| <?php | ||
| /** | ||
| * Bootstraps the Connectors page in wp-admin. | ||
| * | ||
| * @package gutenberg | ||
| */ | ||
|
|
||
| add_action( 'admin_menu', '_gutenberg_connectors_add_settings_menu_item' ); | ||
|
|
||
| /** | ||
| * Registers the Connectors menu item under Settings. | ||
| * | ||
| * @access private | ||
| */ | ||
| function _gutenberg_connectors_add_settings_menu_item(): void { | ||
| add_submenu_page( | ||
| 'options-general.php', | ||
| __( 'Connectors', 'gutenberg' ), | ||
| __( 'Connectors', 'gutenberg' ), | ||
| 'manage_options', | ||
| 'connectors-wp-admin', | ||
| 'gutenberg_connectors_wp_admin_render_page', | ||
| 1 | ||
| ); | ||
| } | ||
|
|
||
| require __DIR__ . '/default-connectors.php'; |

Uh oh!
There was an error while loading. Please reload this page.