Skip to content

Commit 336a47b

Browse files
jorgefilipecostagziolojustlevinewestonruterjeffpaul
authored andcommitted
Add: Connectors screen (#75833)
Co-authored-by: jorgefilipecosta <jorgefilipecosta@git.wordpress.org> Co-authored-by: gziolo <gziolo@git.wordpress.org> Co-authored-by: justlevine <justlevine@git.wordpress.org> Co-authored-by: westonruter <westonruter@git.wordpress.org> Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org> Co-authored-by: JasonTheAdams <jason_the_adams@git.wordpress.org> Co-authored-by: audrasjb <audrasjb@git.wordpress.org> Co-authored-by: shaunandrews <shaunandrews@git.wordpress.org> Co-authored-by: felixarntz <flixos90@git.wordpress.org>
1 parent dcb6aa4 commit 336a47b

File tree

24 files changed

+1514
-1
lines changed

24 files changed

+1514
-1
lines changed
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
<?php
2+
/**
3+
* Default connectors backend logic.
4+
*
5+
* @package gutenberg
6+
*/
7+
8+
/**
9+
* Masks an API key, showing only the last 4 characters.
10+
*
11+
* @access private
12+
*
13+
* @param string $key The API key to mask.
14+
* @return string The masked key, e.g. "••••••••••••fj39".
15+
*/
16+
function _gutenberg_mask_api_key( string $key ): string {
17+
if ( strlen( $key ) <= 4 ) {
18+
return $key;
19+
}
20+
return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
21+
}
22+
23+
/**
24+
* Checks whether an API key is valid for a given provider.
25+
*
26+
* @access private
27+
*
28+
* @param string $key The API key to check.
29+
* @param string $provider_id The WP AI client provider ID.
30+
* @return bool|null True if valid, false if invalid, null if unable to determine.
31+
*/
32+
function _gutenberg_is_api_key_valid( string $key, string $provider_id ): ?bool {
33+
try {
34+
$registry = \WordPress\AiClient\AiClient::defaultRegistry();
35+
36+
if ( ! $registry->hasProvider( $provider_id ) ) {
37+
return null;
38+
}
39+
40+
$registry->setProviderRequestAuthentication(
41+
$provider_id,
42+
new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $key )
43+
);
44+
45+
return $registry->isProviderConfigured( $provider_id );
46+
} catch ( \Error $e ) {
47+
return null;
48+
}
49+
}
50+
51+
/**
52+
* Sets the API key authentication for a provider on the WP AI Client registry.
53+
*
54+
* @access private
55+
*
56+
* @param string $key The API key.
57+
* @param string $provider_id The WP AI client provider ID.
58+
*/
59+
function _gutenberg_set_provider_api_key( string $key, string $provider_id ): void {
60+
try {
61+
$registry = \WordPress\AiClient\AiClient::defaultRegistry();
62+
63+
if ( ! $registry->hasProvider( $provider_id ) ) {
64+
return;
65+
}
66+
67+
$registry->setProviderRequestAuthentication(
68+
$provider_id,
69+
new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $key )
70+
);
71+
} catch ( \Error $e ) {
72+
// WP AI Client not available.
73+
}
74+
}
75+
76+
/**
77+
* Retrieves the real (unmasked) value of a connector API key.
78+
*
79+
* Temporarily removes the masking filter, reads the option, then re-adds it.
80+
*
81+
* @access private
82+
*
83+
* @param string $option_name The option name for the API key.
84+
* @param callable $mask_callback The mask filter function.
85+
* @return string The real API key value.
86+
*/
87+
function _gutenberg_get_real_api_key( string $option_name, callable $mask_callback ): string {
88+
remove_filter( "option_{$option_name}", $mask_callback );
89+
$value = get_option( $option_name, '' );
90+
add_filter( "option_{$option_name}", $mask_callback );
91+
return $value;
92+
}
93+
94+
// --- Gemini (Google) ---
95+
96+
/**
97+
* Masks the Gemini API key on read.
98+
*
99+
* @access private
100+
*
101+
* @param string $value The raw option value.
102+
* @return string Masked key or empty string.
103+
*/
104+
function _gutenberg_mask_gemini_api_key( string $value ): string {
105+
if ( '' === $value ) {
106+
return $value;
107+
}
108+
return _gutenberg_mask_api_key( $value );
109+
}
110+
111+
/**
112+
* Sanitizes and validates the Gemini API key before saving.
113+
*
114+
* @access private
115+
*
116+
* @param string $value The new value.
117+
* @return string The sanitized value, or empty string if the key is not valid.
118+
*/
119+
function _gutenberg_sanitize_gemini_api_key( string $value ): string {
120+
$value = sanitize_text_field( $value );
121+
if ( '' === $value ) {
122+
return $value;
123+
}
124+
$valid = _gutenberg_is_api_key_valid( $value, 'google' );
125+
return true === $valid ? $value : '';
126+
}
127+
128+
// --- OpenAI ---
129+
130+
/**
131+
* Masks the OpenAI API key on read.
132+
*
133+
* @access private
134+
*
135+
* @param string $value The raw option value.
136+
* @return string Masked key or empty string.
137+
*/
138+
function _gutenberg_mask_openai_api_key( string $value ): string {
139+
if ( '' === $value ) {
140+
return $value;
141+
}
142+
return _gutenberg_mask_api_key( $value );
143+
}
144+
145+
/**
146+
* Sanitizes and validates the OpenAI API key before saving.
147+
*
148+
* @access private
149+
*
150+
* @param string $value The new value.
151+
* @return string The sanitized value, or empty string if the key is not valid.
152+
*/
153+
function _gutenberg_sanitize_openai_api_key( string $value ): string {
154+
$value = sanitize_text_field( $value );
155+
if ( '' === $value ) {
156+
return $value;
157+
}
158+
$valid = _gutenberg_is_api_key_valid( $value, 'openai' );
159+
return true === $valid ? $value : '';
160+
}
161+
162+
// --- Anthropic ---
163+
164+
/**
165+
* Masks the Anthropic API key on read.
166+
*
167+
* @access private
168+
*
169+
* @param string $value The raw option value.
170+
* @return string Masked key or empty string.
171+
*/
172+
function _gutenberg_mask_anthropic_api_key( string $value ): string {
173+
if ( '' === $value ) {
174+
return $value;
175+
}
176+
return _gutenberg_mask_api_key( $value );
177+
}
178+
179+
/**
180+
* Sanitizes and validates the Anthropic API key before saving.
181+
*
182+
* @access private
183+
*
184+
* @param string $value The new value.
185+
* @return string The sanitized value, or empty string if the key is not valid.
186+
*/
187+
function _gutenberg_sanitize_anthropic_api_key( string $value ): string {
188+
$value = sanitize_text_field( $value );
189+
if ( '' === $value ) {
190+
return $value;
191+
}
192+
$valid = _gutenberg_is_api_key_valid( $value, 'anthropic' );
193+
return true === $valid ? $value : '';
194+
}
195+
196+
// --- Connector definitions ---
197+
198+
/**
199+
* Gets the provider connectors.
200+
*
201+
* @access private
202+
*
203+
* @return array<string, array{ provider: string, mask: callable, sanitize: callable }> Connectors.
204+
*/
205+
function _gutenberg_get_connectors(): array {
206+
return array(
207+
'connectors_gemini_api_key' => array(
208+
'provider' => 'google',
209+
'mask' => '_gutenberg_mask_gemini_api_key',
210+
'sanitize' => '_gutenberg_sanitize_gemini_api_key',
211+
),
212+
'connectors_openai_api_key' => array(
213+
'provider' => 'openai',
214+
'mask' => '_gutenberg_mask_openai_api_key',
215+
'sanitize' => '_gutenberg_sanitize_openai_api_key',
216+
),
217+
'connectors_anthropic_api_key' => array(
218+
'provider' => 'anthropic',
219+
'mask' => '_gutenberg_mask_anthropic_api_key',
220+
'sanitize' => '_gutenberg_sanitize_anthropic_api_key',
221+
),
222+
);
223+
}
224+
225+
// --- REST API filtering ---
226+
227+
/**
228+
* Validates connector API keys in the REST response when explicitly requested.
229+
*
230+
* Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include
231+
* connector fields via `_fields`. For each requested connector field, reads
232+
* the real (unmasked) key, validates it against the provider, and replaces
233+
* the response value with 'invalid_key' if validation fails.
234+
*
235+
* @access private
236+
*
237+
* @param WP_REST_Response $response The response object.
238+
* @param WP_REST_Server $server The server instance.
239+
* @param WP_REST_Request $request The request object.
240+
* @return WP_REST_Response The potentially modified response.
241+
*/
242+
function _gutenberg_validate_connector_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
243+
if ( '/wp/v2/settings' !== $request->get_route() ) {
244+
return $response;
245+
}
246+
247+
$fields = $request->get_param( '_fields' );
248+
if ( ! $fields ) {
249+
return $response;
250+
}
251+
252+
$requested = array_map( 'trim', explode( ',', $fields ) );
253+
$data = $response->get_data();
254+
$connectors = _gutenberg_get_connectors();
255+
256+
foreach ( $connectors as $option_name => $config ) {
257+
if ( ! in_array( $option_name, $requested, true ) ) {
258+
continue;
259+
}
260+
$real_key = _gutenberg_get_real_api_key( $option_name, $config['mask'] );
261+
if ( '' === $real_key ) {
262+
continue;
263+
}
264+
if ( true !== _gutenberg_is_api_key_valid( $real_key, $config['provider'] ) ) {
265+
$data[ $option_name ] = 'invalid_key';
266+
}
267+
}
268+
269+
$response->set_data( $data );
270+
return $response;
271+
}
272+
add_filter( 'rest_post_dispatch', '_gutenberg_validate_connector_keys_in_rest', 10, 3 );
273+
274+
// --- Registration ---
275+
276+
/**
277+
* Registers the default connector settings, mask filters, and validation filters.
278+
*
279+
* @access private
280+
*/
281+
function _gutenberg_register_default_connector_settings(): void {
282+
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
283+
return;
284+
}
285+
286+
$connectors = _gutenberg_get_connectors();
287+
288+
foreach ( $connectors as $option_name => $config ) {
289+
register_setting(
290+
'connectors',
291+
$option_name,
292+
array(
293+
'type' => 'string',
294+
'default' => '',
295+
'show_in_rest' => true,
296+
'sanitize_callback' => $config['sanitize'],
297+
)
298+
);
299+
add_filter( "option_{$option_name}", $config['mask'] );
300+
}
301+
}
302+
add_action( 'init', '_gutenberg_register_default_connector_settings' );
303+
304+
/**
305+
* Passes the default connector API keys to the WP AI client.
306+
*
307+
* @access private
308+
*/
309+
function _gutenberg_pass_default_connector_keys_to_ai_client(): void {
310+
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
311+
return;
312+
}
313+
314+
$connectors = _gutenberg_get_connectors();
315+
316+
foreach ( $connectors as $option_name => $config ) {
317+
$api_key = _gutenberg_get_real_api_key( $option_name, $config['mask'] );
318+
if ( ! empty( $api_key ) ) {
319+
_gutenberg_set_provider_api_key( $api_key, $config['provider'] );
320+
}
321+
}
322+
}
323+
add_action( 'init', '_gutenberg_pass_default_connector_keys_to_ai_client' );
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
/**
3+
* Bootstraps the Connectors page in wp-admin.
4+
*
5+
* @package gutenberg
6+
*/
7+
8+
add_action( 'admin_menu', '_gutenberg_connectors_add_settings_menu_item' );
9+
10+
/**
11+
* Registers the Connectors menu item under Settings.
12+
*
13+
* @access private
14+
*/
15+
function _gutenberg_connectors_add_settings_menu_item(): void {
16+
add_submenu_page(
17+
'options-general.php',
18+
__( 'Connectors', 'gutenberg' ),
19+
__( 'Connectors', 'gutenberg' ),
20+
'manage_options',
21+
'connectors-wp-admin',
22+
'gutenberg_connectors_wp_admin_render_page',
23+
1
24+
);
25+
}
26+
27+
require __DIR__ . '/default-connectors.php';

lib/load.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ function gutenberg_is_experiment_enabled( $name ) {
119119
require __DIR__ . '/experimental/pages/site-editor.php';
120120
require __DIR__ . '/experimental/extensible-site-editor.php';
121121
require __DIR__ . '/experimental/fonts/load.php';
122+
if ( class_exists( '\WordPress\AiClient\AiClient' ) ) {
123+
require __DIR__ . '/experimental/connectors/load.php';
124+
}
122125

123126
if ( gutenberg_is_experiment_enabled( 'gutenberg-workflow-palette' ) ) {
124127
require __DIR__ . '/experimental/workflow-palette.php';

0 commit comments

Comments
 (0)