[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/ -> connectors.php (source)

   1  <?php
   2  /**
   3   * Connectors API.
   4   *
   5   * @package WordPress
   6   * @subpackage Connectors
   7   * @since 7.0.0
   8   */
   9  
  10  use WordPress\AiClient\AiClient;
  11  use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;
  12  
  13  /**
  14   * Checks if a connector is registered.
  15   *
  16   * @since 7.0.0
  17   *
  18   * @see WP_Connector_Registry::is_registered()
  19   *
  20   * @param string $id The connector identifier.
  21   * @return bool True if the connector is registered, false otherwise.
  22   */
  23  function wp_is_connector_registered( string $id ): bool {
  24      $registry = WP_Connector_Registry::get_instance();
  25      if ( null === $registry ) {
  26          return false;
  27      }
  28  
  29      return $registry->is_registered( $id );
  30  }
  31  
  32  /**
  33   * Retrieves a registered connector.
  34   *
  35   * @since 7.0.0
  36   *
  37   * @see WP_Connector_Registry::get_registered()
  38   *
  39   * @param string $id The connector identifier.
  40   * @return array|null {
  41   *     Connector data, or null if not registered.
  42   *
  43   *     @type string $name           The connector's display name.
  44   *     @type string $description    The connector's description.
  45   *     @type string $logo_url       Optional. URL to the connector's logo image.
  46   *     @type string $type           The connector type, e.g. 'ai_provider'.
  47   *     @type array  $authentication {
  48   *         Authentication configuration. When method is 'api_key' or
  49   *         'application_password', includes credentials_url, setting_name, and
  50   *         optionally constant_name and env_var_name. When 'none', only method
  51   *         is present.
  52   *
  53   *         @type string $method          The authentication method: 'api_key',
  54   *                                       'application_password', or 'none'.
  55   *         @type string $credentials_url Optional. URL where users can obtain API credentials.
  56   *         @type string $setting_name    Optional. The setting name for the API key or application-password credentials.
  57   *         @type string $constant_name   Optional. PHP constant name for the API key or application-password credentials.
  58   *         @type string $env_var_name    Optional. Environment variable name for the API key or application-password credentials.
  59   *     }
  60   *     @type array  $plugin         {
  61   *         Optional. Plugin data for install/activate UI.
  62   *
  63   *         @type string   $file      The plugin's main file path relative to the plugins
  64   *                                   directory (e.g. 'my-plugin/my-plugin.php' or 'hello.php').
  65   *         @type callable $is_active Callback to determine whether the plugin is active. Receives no arguments and must return bool.
  66   *                                   Defaults to `__return_true`.
  67   *     }
  68   * }
  69   * @phpstan-return ?array{
  70   *     name: non-empty-string,
  71   *     description: string,
  72   *     logo_url?: non-empty-string,
  73   *     type: non-empty-string,
  74   *     authentication: array{
  75   *         method: 'api_key'|'application_password'|'none',
  76   *         credentials_url?: non-empty-string,
  77   *         setting_name?: non-empty-string,
  78   *         constant_name?: non-empty-string,
  79   *         env_var_name?: non-empty-string
  80   *     },
  81   *     plugin: array{
  82   *         file?: non-empty-string,
  83   *         is_active: callable(): bool,
  84   *     }
  85   * }
  86   */
  87  function wp_get_connector( string $id ): ?array {
  88      $registry = WP_Connector_Registry::get_instance();
  89      if ( null === $registry ) {
  90          return null;
  91      }
  92  
  93      return $registry->get_registered( $id );
  94  }
  95  
  96  /**
  97   * Retrieves all registered connectors.
  98   *
  99   * @since 7.0.0
 100   *
 101   * @see WP_Connector_Registry::get_all_registered()
 102   *
 103   * @return array {
 104   *     Connector settings keyed by connector ID.
 105   *
 106   *     @type array ...$0 {
 107   *         Data for a single connector.
 108   *
 109   *         @type string      $name           The connector's display name.
 110   *         @type string      $description    The connector's description.
 111   *         @type string      $logo_url       Optional. URL to the connector's logo image.
 112   *         @type string      $type           The connector type, e.g. 'ai_provider'.
 113   *         @type array       $authentication {
 114   *             Authentication configuration. When method is 'api_key' or
 115   *             'application_password', includes credentials_url, setting_name,
 116   *             and optionally constant_name and env_var_name. When 'none', only
 117   *             method is present.
 118   *
 119   *             @type string $method          The authentication method: 'api_key',
 120   *                                           'application_password', or 'none'.
 121   *             @type string $credentials_url Optional. URL where users can obtain API credentials.
 122   *             @type string $setting_name    Optional. The setting name for the API key or application-password credentials.
 123   *             @type string $constant_name   Optional. PHP constant name for the API key or application-password credentials.
 124   *             @type string $env_var_name    Optional. Environment variable name for the API key or application-password credentials.
 125   *         }
 126   *         @type array       $plugin         {
 127   *             Optional. Plugin data for install/activate UI.
 128   *
 129   *             @type string   $file      The plugin's main file path relative to the plugins
 130   *                                       directory (e.g. 'my-plugin/my-plugin.php' or 'hello.php').
 131   *             @type callable $is_active Callback to determine whether the plugin is active. Receives no arguments and must return bool.
 132   *                                       Defaults to `__return_true`.
 133   *         }
 134   *     }
 135   * }
 136   * @phpstan-return array<string, array{
 137   *     name: non-empty-string,
 138   *     description: string,
 139   *     logo_url?: non-empty-string,
 140   *     type: non-empty-string,
 141   *     authentication: array{
 142   *         method: 'api_key'|'application_password'|'none',
 143   *         credentials_url?: non-empty-string,
 144   *         setting_name?: non-empty-string,
 145   *         constant_name?: non-empty-string,
 146   *         env_var_name?: non-empty-string
 147   *     },
 148   *     plugin: array{
 149   *         file?: non-empty-string,
 150   *         is_active: callable(): bool,
 151   *     }
 152   * }>
 153   */
 154  function wp_get_connectors(): array {
 155      $registry = WP_Connector_Registry::get_instance();
 156      if ( null === $registry ) {
 157          return array();
 158      }
 159  
 160      return $registry->get_all_registered();
 161  }
 162  
 163  /**
 164   * Resolves an AI provider logo file path to a URL.
 165   *
 166   * Converts an absolute file path to a plugin URL. The path must reside within
 167   * the plugins or must-use plugins directory.
 168   *
 169   * @since 7.0.0
 170   * @access private
 171   *
 172   * @param string $path Absolute path to the logo file.
 173   * @return non-empty-string|null The URL to the logo file, or null if the path is invalid.
 174   */
 175  function _wp_connectors_resolve_ai_provider_logo_url( string $path ): ?string {
 176      if ( ! $path ) {
 177          return null;
 178      }
 179  
 180      $path = wp_normalize_path( $path );
 181  
 182      if ( ! file_exists( $path ) ) {
 183          return null;
 184      }
 185  
 186      $mu_plugin_dir = wp_normalize_path( WPMU_PLUGIN_DIR );
 187      if ( str_starts_with( $path, $mu_plugin_dir . '/' ) ) {
 188          $logo_url = plugins_url( substr( $path, strlen( $mu_plugin_dir ) ), WPMU_PLUGIN_DIR . '/.' );
 189          return $logo_url ? $logo_url : null;
 190      }
 191  
 192      $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR );
 193      if ( str_starts_with( $path, $plugin_dir . '/' ) ) {
 194          $logo_url = plugins_url( substr( $path, strlen( $plugin_dir ) ) );
 195          return $logo_url ? $logo_url : null;
 196      }
 197  
 198      _doing_it_wrong(
 199          __FUNCTION__,
 200          __( 'Provider logo path must be located within the plugins or must-use plugins directory.' ),
 201          '7.0.0'
 202      );
 203  
 204      return null;
 205  }
 206  
 207  /**
 208   * Initializes the connector registry with default connectors and fires the registration action.
 209   *
 210   * Creates the registry instance, registers built-in connectors (which cannot be unhooked),
 211   * and then fires the `wp_connectors_init` action for plugins to register their own connectors.
 212   *
 213   * @since 7.0.0
 214   * @access private
 215   */
 216  function _wp_connectors_init(): void {
 217      $registry = new WP_Connector_Registry();
 218      WP_Connector_Registry::set_instance( $registry );
 219  
 220      // Only register default AI providers if AI support is enabled.
 221      if ( wp_supports_ai() ) {
 222          _wp_connectors_register_default_ai_providers( $registry );
 223      }
 224  
 225      // Non-AI default connectors.
 226      $registry->register(
 227          'akismet',
 228          array(
 229              'name'           => __( 'Akismet Anti-spam' ),
 230              'description'    => __( 'Protect your site from spam.' ),
 231              'type'           => 'spam_filtering',
 232              'plugin'         => array(
 233                  'file'      => 'akismet/akismet.php',
 234                  'is_active' => static function () {
 235                      return defined( 'AKISMET_VERSION' );
 236                  },
 237              ),
 238              'authentication' => array(
 239                  'method'          => 'api_key',
 240                  'credentials_url' => 'https://akismet.com/get/',
 241                  'setting_name'    => 'wordpress_api_key',
 242                  'constant_name'   => 'WPCOM_API_KEY',
 243              ),
 244          )
 245      );
 246  
 247      /**
 248       * Fires when the connector registry is ready for plugins to register connectors.
 249       *
 250       * Built-in connectors and any AI providers auto-discovered from the WP AI Client
 251       * registry have already been registered at this point and cannot be unhooked.
 252       *
 253       * AI provider plugins that register with the WP AI Client do not need to use
 254       * this action — their connectors are created automatically. This action is
 255       * primarily for registering non-AI-provider connectors or overriding metadata
 256       * on existing connectors.
 257       *
 258       * Use `$registry->register()` within this action to add new connectors.
 259       * To override an existing connector, unregister it first, then re-register
 260       * with updated data.
 261       *
 262       * Example — overriding metadata on an auto-discovered connector:
 263       *
 264       *     add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) {
 265       *         if ( $registry->is_registered( 'anthropic' ) ) {
 266       *             $connector = $registry->unregister( 'anthropic' );
 267       *             $connector['description'] = __( 'Custom description for Anthropic.', 'my-plugin' );
 268       *             $registry->register( 'anthropic', $connector );
 269       *         }
 270       *     } );
 271       *
 272       * @since 7.0.0
 273       *
 274       * @param WP_Connector_Registry $registry Connector registry instance.
 275       */
 276      do_action( 'wp_connectors_init', $registry );
 277  }
 278  
 279  /**
 280   * Registers connectors for the built-in AI providers.
 281   *
 282   * @since 7.0.0
 283   * @access private
 284   *
 285   * @param WP_Connector_Registry $registry The connector registry instance.
 286   */
 287  function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $registry ): void {
 288      // Built-in connectors.
 289      $defaults = array(
 290          'anthropic' => array(
 291              'name'           => 'Anthropic',
 292              'description'    => __( 'Text generation with Claude.' ),
 293              'type'           => 'ai_provider',
 294              'plugin'         => array(
 295                  'file' => 'ai-provider-for-anthropic/plugin.php',
 296              ),
 297              'authentication' => array(
 298                  'method'          => 'api_key',
 299                  'credentials_url' => 'https://platform.claude.com/settings/keys',
 300              ),
 301          ),
 302          'google'    => array(
 303              'name'           => 'Google',
 304              'description'    => __( 'Text and image generation with Gemini and Imagen.' ),
 305              'type'           => 'ai_provider',
 306              'plugin'         => array(
 307                  'file' => 'ai-provider-for-google/plugin.php',
 308              ),
 309              'authentication' => array(
 310                  'method'          => 'api_key',
 311                  'credentials_url' => 'https://aistudio.google.com/api-keys',
 312              ),
 313          ),
 314          'openai'    => array(
 315              'name'           => 'OpenAI',
 316              'description'    => __( 'Text and image generation with GPT and Dall-E.' ),
 317              'type'           => 'ai_provider',
 318              'plugin'         => array(
 319                  'file' => 'ai-provider-for-openai/plugin.php',
 320              ),
 321              'authentication' => array(
 322                  'method'          => 'api_key',
 323                  'credentials_url' => 'https://platform.openai.com/api-keys',
 324              ),
 325          ),
 326      );
 327  
 328      // Merge AI Client registry data on top of defaults.
 329      // Registry values (from provider plugins) take precedence over hardcoded fallbacks.
 330      $ai_registry = AiClient::defaultRegistry();
 331  
 332      foreach ( array_filter( $ai_registry->getRegisteredProviderIds() ) as $connector_id ) {
 333          $provider_class_name = $ai_registry->getProviderClassName( $connector_id );
 334          $provider_metadata   = $provider_class_name::metadata();
 335  
 336          $auth_method = $provider_metadata->getAuthenticationMethod();
 337          $is_api_key  = null !== $auth_method && $auth_method->isApiKey();
 338  
 339          if ( $is_api_key ) {
 340              $credentials_url = $provider_metadata->getCredentialsUrl();
 341              $authentication  = array(
 342                  'method' => 'api_key',
 343              );
 344              if ( $credentials_url ) {
 345                  $authentication['credentials_url'] = $credentials_url;
 346              }
 347          } else {
 348              $authentication = array( 'method' => 'none' );
 349          }
 350  
 351          $name        = $provider_metadata->getName();
 352          $description = $provider_metadata->getDescription();
 353          $logo_url    = $provider_metadata->getLogoPath()
 354              ? _wp_connectors_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() )
 355              : null;
 356  
 357          if ( isset( $defaults[ $connector_id ] ) ) {
 358              // Override fields with non-empty registry values.
 359              if ( $name ) {
 360                  $defaults[ $connector_id ]['name'] = $name;
 361              }
 362              if ( $description ) {
 363                  $defaults[ $connector_id ]['description'] = $description;
 364              }
 365              if ( $logo_url ) {
 366                  $defaults[ $connector_id ]['logo_url'] = $logo_url;
 367              }
 368              // Always update auth method; keep existing credentials_url as fallback.
 369              $defaults[ $connector_id ]['authentication']['method'] = $authentication['method'];
 370              if ( ! empty( $authentication['credentials_url'] ) ) {
 371                  $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url'];
 372              }
 373          } else {
 374              $defaults[ $connector_id ] = array(
 375                  'name'           => $name ? $name : ucwords( $connector_id ),
 376                  'description'    => $description ? $description : '',
 377                  'type'           => 'ai_provider',
 378                  'authentication' => $authentication,
 379              );
 380              if ( $logo_url ) {
 381                  $defaults[ $connector_id ]['logo_url'] = $logo_url;
 382              }
 383          }
 384      }
 385  
 386      // Register all default connectors directly on the registry.
 387      /** @var lowercase-string&non-empty-string $id AI Client provider IDs are validated as lowercase by ProviderMetadata. */
 388      foreach ( $defaults as $id => $args ) {
 389          if ( 'api_key' === $args['authentication']['method'] ) {
 390              $sanitized_id = str_replace( '-', '_', $id );
 391  
 392              $args['authentication']['setting_name'] = "connectors_ai_{$sanitized_id}_api_key";
 393  
 394              // All AI providers use the {CONSTANT_CASE_ID}_API_KEY naming convention.
 395              $constant_case_key = strtoupper( (string) preg_replace( '/([a-z])([A-Z])/', '$1_$2', $sanitized_id ) ) . '_API_KEY';
 396  
 397              $args['authentication']['constant_name'] = $constant_case_key;
 398              $args['authentication']['env_var_name']  = $constant_case_key;
 399          }
 400  
 401          $args['plugin']['is_active'] = static function () use ( $ai_registry, $id ): bool {
 402              try {
 403                  return $ai_registry->hasProvider( $id );
 404              } catch ( Exception $e ) {
 405                  return false;
 406              }
 407          };
 408  
 409          $registry->register( $id, $args );
 410      }
 411  }
 412  
 413  /**
 414   * Masks an API key, showing only the last 4 characters.
 415   *
 416   * @since 7.0.0
 417   * @access private
 418   *
 419   * @param string $key The API key to mask.
 420   * @return string The masked key, e.g. "************fj39".
 421   */
 422  function _wp_connectors_mask_api_key( string $key ): string {
 423      if ( strlen( $key ) <= 4 ) {
 424          return $key;
 425      }
 426  
 427      return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
 428  }
 429  
 430  /**
 431   * Determines the source of an API key for a given connector.
 432   *
 433   * Checks in order: environment variable, PHP constant, database.
 434   * Environment variable and constant are only checked when their
 435   * respective names are provided.
 436   *
 437   * @since 7.0.0
 438   * @access private
 439   *
 440   * @param string $setting_name  The option name for the API key (e.g., 'connectors_spam_filtering_my_plugin_api_key').
 441   * @param string $env_var_name  Optional. Environment variable name to check (e.g., 'MY_PLUGIN_API_KEY').
 442   * @param string $constant_name Optional. PHP constant name to check (e.g., 'MY_PLUGIN_API_KEY').
 443   * @return string The key source: 'env', 'constant', 'database', or 'none'.
 444   */
 445  function _wp_connectors_get_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string {
 446      // Check environment variable first.
 447      if ( '' !== $env_var_name ) {
 448          $env_value = getenv( $env_var_name );
 449          if ( false !== $env_value && '' !== $env_value ) {
 450              return 'env';
 451          }
 452      }
 453  
 454      // Check PHP constant.
 455      if ( '' !== $constant_name && defined( $constant_name ) ) {
 456          $const_value = constant( $constant_name );
 457          if ( is_string( $const_value ) && '' !== $const_value ) {
 458              return 'constant';
 459          }
 460      }
 461  
 462      // Check database.
 463      $db_value = get_option( $setting_name, '' );
 464      if ( '' !== $db_value ) {
 465          return 'database';
 466      }
 467  
 468      return 'none';
 469  }
 470  
 471  /**
 472   * Parses a `username:password` credentials string.
 473   *
 474   * Splits on the first colon, matching the HTTP Basic authentication
 475   * userinfo format, so passwords may contain colons.
 476   *
 477   * @since 7.1.0
 478   * @access private
 479   *
 480   * @param string $value The raw credentials string.
 481   * @return array{username: string, password: string} Parsed credentials. Both values
 482   *                                                   are empty when the string is malformed.
 483   */
 484  function wp_connectors_parse_application_password_credentials( string $value ): array {
 485      $separator = strpos( $value, ':' );
 486      // Trim so surrounding whitespace or a trailing newline (common when the
 487      // value comes from a file or `.env`) does not become part of the credentials.
 488      $username = false === $separator ? '' : trim( substr( $value, 0, $separator ) );
 489      $password = false === $separator ? '' : trim( substr( $value, $separator + 1 ) );
 490  
 491      if ( '' === $username || '' === $password ) {
 492          return array(
 493              'username' => '',
 494              'password' => '',
 495          );
 496      }
 497  
 498      return array(
 499          'username' => $username,
 500          'password' => $password,
 501      );
 502  }
 503  
 504  /**
 505   * Resolves application-password credentials for a connector.
 506   *
 507   * Checks in order: environment variable, PHP constant, database. The
 508   * environment variable and constant are only checked when their respective
 509   * names are provided, and must contain the credentials as a single
 510   * `username:password` string. A non-empty environment variable or constant
 511   * that cannot be parsed as `username:password` is reported with
 512   * `_doing_it_wrong()` and ignored, so resolution falls through to the next
 513   * source.
 514   *
 515   * @since 7.1.0
 516   * @access private
 517   *
 518   * @param array $auth The connector's authentication configuration.
 519   * @return array{username: string, password: string, source: string} Resolved credentials and
 520   *                                                                   their source: 'env', 'constant',
 521   *                                                                   'database', or 'none'.
 522   */
 523  function wp_connectors_get_application_password_credentials( array $auth ): array {
 524      // Check environment variable first.
 525      $env_var_name = $auth['env_var_name'] ?? '';
 526      if ( '' !== $env_var_name ) {
 527          $env_value = getenv( $env_var_name );
 528          if ( false !== $env_value && '' !== $env_value ) {
 529              $credentials = wp_connectors_parse_application_password_credentials( $env_value );
 530              if ( '' !== $credentials['username'] && '' !== $credentials['password'] ) {
 531                  $credentials['source'] = 'env';
 532                  return $credentials;
 533              }
 534  
 535              _doing_it_wrong(
 536                  __FUNCTION__,
 537                  sprintf(
 538                      /* translators: %s: Environment variable name. */
 539                      __( 'The %s environment variable must contain application password credentials in "username:password" format.' ),
 540                      esc_html( $env_var_name )
 541                  ),
 542                  '7.1.0'
 543              );
 544          }
 545      }
 546  
 547      // Check PHP constant.
 548      $constant_name = $auth['constant_name'] ?? '';
 549      if ( '' !== $constant_name && defined( $constant_name ) ) {
 550          $const_value = constant( $constant_name );
 551          if ( is_string( $const_value ) && '' !== $const_value ) {
 552              $credentials = wp_connectors_parse_application_password_credentials( $const_value );
 553              if ( '' !== $credentials['username'] && '' !== $credentials['password'] ) {
 554                  $credentials['source'] = 'constant';
 555                  return $credentials;
 556              }
 557  
 558              _doing_it_wrong(
 559                  __FUNCTION__,
 560                  sprintf(
 561                      /* translators: %s: PHP constant name. */
 562                      __( 'The %s constant must contain application password credentials in "username:password" format.' ),
 563                      esc_html( $constant_name )
 564                  ),
 565                  '7.1.0'
 566              );
 567          }
 568      }
 569  
 570      // Check database.
 571      $stored   = get_option( $auth['setting_name'] ?? '', array() );
 572      $username = is_array( $stored ) && isset( $stored['username'] ) && is_string( $stored['username'] ) ? $stored['username'] : '';
 573      $password = is_array( $stored ) && isset( $stored['password'] ) && is_string( $stored['password'] ) ? $stored['password'] : '';
 574  
 575      return array(
 576          'username' => $username,
 577          'password' => $password,
 578          'source'   => '' !== $username && '' !== $password ? 'database' : 'none',
 579      );
 580  }
 581  
 582  /**
 583   * Checks whether an API key is valid for a given provider.
 584   *
 585   * @since 7.0.0
 586   * @access private
 587   *
 588   * @param string $key         The API key to check.
 589   * @param string $provider_id The WP AI client provider ID.
 590   * @return bool|null True if valid, false if invalid, null if unable to determine.
 591   */
 592  function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
 593      try {
 594          $registry = AiClient::defaultRegistry();
 595  
 596          if ( ! $registry->hasProvider( $provider_id ) ) {
 597              _doing_it_wrong(
 598                  __FUNCTION__,
 599                  sprintf(
 600                      /* translators: %s: AI provider ID. */
 601                      __( 'The provider "%s" is not registered in the AI client registry.' ),
 602                      $provider_id
 603                  ),
 604                  '7.0.0'
 605              );
 606              return null;
 607          }
 608  
 609          $registry->setProviderRequestAuthentication(
 610              $provider_id,
 611              new ApiKeyRequestAuthentication( $key )
 612          );
 613  
 614          return $registry->isProviderConfigured( $provider_id );
 615      } catch ( Exception $e ) {
 616          wp_trigger_error( __FUNCTION__, $e->getMessage() );
 617          return null;
 618      }
 619  }
 620  
 621  /**
 622   * Sanitizes stored application-password credentials for a connector.
 623   *
 624   * Credential fields that are missing or not strings keep their currently
 625   * stored values, so partial updates cannot silently clear a stored secret.
 626   * A password matching the mask that `_wp_connectors_rest_settings_dispatch()`
 627   * places in REST responses also keeps the stored password, so a masked
 628   * settings response can be submitted back to the endpoint unchanged.
 629   * Pass an empty string to clear a field.
 630   * If the sanitized username is empty, both fields are discarded so partial
 631   * credentials cannot leave an orphaned secret.
 632   *
 633   * @since 7.1.0
 634   * @access private
 635   *
 636   * @param mixed  $value  The submitted setting value.
 637   * @param string $option The option name being sanitized. Passed explicitly by the
 638   *                       registered sanitize callback; falls back to the current
 639   *                       `sanitize_option_{$option}` filter name when omitted.
 640   * @return array{username: string, password: string} Sanitized credentials.
 641   */
 642  function wp_connectors_sanitize_application_password_credentials( $value, string $option = '' ): array {
 643      if ( ! is_array( $value ) ) {
 644          $value = array();
 645      }
 646  
 647      if ( '' === $option ) {
 648          $option = str_replace( 'sanitize_option_', '', (string) current_filter() );
 649      }
 650  
 651      $stored = get_option( $option );
 652      if ( ! is_array( $stored ) ) {
 653          $stored = array();
 654      }
 655  
 656      $credentials = array();
 657      foreach ( array( 'username', 'password' ) as $field ) {
 658          if ( isset( $value[ $field ] ) && is_string( $value[ $field ] ) ) {
 659              $credentials[ $field ] = sanitize_text_field( $value[ $field ] );
 660          } else {
 661              $credentials[ $field ] = isset( $stored[ $field ] ) && is_string( $stored[ $field ] ) ? $stored[ $field ] : '';
 662          }
 663      }
 664  
 665      // A masked password means a client resubmitted a masked REST response.
 666      if ( str_repeat( "\u{2022}", 16 ) === $credentials['password'] ) {
 667          $credentials['password'] = isset( $stored['password'] ) && is_string( $stored['password'] ) ? $stored['password'] : '';
 668      }
 669  
 670      if ( '' === $credentials['username'] ) {
 671          return array(
 672              'username' => '',
 673              'password' => '',
 674          );
 675      }
 676  
 677      return $credentials;
 678  }
 679  
 680  /**
 681   * Masks and validates connector credentials in REST responses.
 682   *
 683   * On every `/wp/v2/settings` response, masks connector API key values and the
 684   * password field of default application-password credential objects.
 685   *
 686   * On POST or PUT requests, validates each updated AI provider API key before
 687   * masking. If validation fails, the key is reverted to an empty string.
 688   * Application password values are masked but not validated.
 689   *
 690   * @since 7.0.0
 691   * @access private
 692   *
 693   * @param WP_REST_Response $response The response object.
 694   * @param WP_REST_Server   $server   The server instance.
 695   * @param WP_REST_Request  $request  The request object.
 696   * @return WP_REST_Response The modified response with masked/validated keys.
 697   */
 698  function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
 699      if ( '/wp/v2/settings' !== $request->get_route() ) {
 700          return $response;
 701      }
 702  
 703      $data = $response->get_data();
 704      if ( ! is_array( $data ) ) {
 705          return $response;
 706      }
 707  
 708      $is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method();
 709  
 710      foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
 711          $auth = $connector_data['authentication'];
 712  
 713          if ( 'application_password' === $auth['method'] && ! empty( $auth['setting_name'] ) ) {
 714              $setting_name = $auth['setting_name'];
 715              if ( array_key_exists( $setting_name, $data ) && is_array( $data[ $setting_name ] ) ) {
 716                  $password = $data[ $setting_name ]['password'] ?? '';
 717                  if ( is_string( $password ) && '' !== $password ) {
 718                      $data[ $setting_name ]['password'] = str_repeat( "\u{2022}", 16 );
 719                  }
 720              }
 721              continue;
 722          }
 723  
 724          if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
 725              continue;
 726          }
 727  
 728          $setting_name = $auth['setting_name'];
 729          if ( ! array_key_exists( $setting_name, $data ) ) {
 730              continue;
 731          }
 732  
 733          $value = $data[ $setting_name ];
 734  
 735          // On update, validate AI provider keys submitted in the request before masking.
 736          // Non-AI connectors accept keys as-is; the service plugin handles its own validation.
 737          if ( $is_update
 738              && $request->has_param( $setting_name )
 739              && is_string( $value ) && '' !== $value
 740              && 'ai_provider' === $connector_data['type']
 741          ) {
 742              if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) {
 743                  update_option( $setting_name, '' );
 744                  $data[ $setting_name ] = '';
 745                  continue;
 746              }
 747          }
 748  
 749          // Mask the key in the response.
 750          if ( is_string( $value ) && '' !== $value ) {
 751              $data[ $setting_name ] = _wp_connectors_mask_api_key( $value );
 752          }
 753      }
 754  
 755      $response->set_data( $data );
 756      return $response;
 757  }
 758  add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 );
 759  
 760  /**
 761   * Registers default connector settings.
 762   *
 763   * @since 7.0.0
 764   * @access private
 765   */
 766  function _wp_register_default_connector_settings(): void {
 767      $registered_settings = get_registered_settings();
 768  
 769      foreach ( wp_get_connectors() as $connector_data ) {
 770          $auth = $connector_data['authentication'];
 771          if ( 'api_key' !== $auth['method'] && 'application_password' !== $auth['method'] ) {
 772              continue;
 773          }
 774  
 775          if ( empty( $auth['setting_name'] ) || isset( $registered_settings[ $auth['setting_name'] ] ) ) {
 776              continue;
 777          }
 778          $setting_name = $auth['setting_name'];
 779  
 780          if ( ! isset( $connector_data['plugin']['is_active'] ) || ! is_callable( $connector_data['plugin']['is_active'] ) ) {
 781              continue;
 782          }
 783  
 784          if ( ! call_user_func( $connector_data['plugin']['is_active'] ) ) {
 785              continue;
 786          }
 787  
 788          if ( 'api_key' === $auth['method'] ) {
 789              register_setting(
 790                  'connectors',
 791                  $setting_name,
 792                  array(
 793                      'type'              => 'string',
 794                      'label'             => sprintf(
 795                          /* translators: %s: Connector name. */
 796                          __( '%s API Key' ),
 797                          $connector_data['name']
 798                      ),
 799                      'description'       => sprintf(
 800                          /* translators: %s: Connector name. */
 801                          __( 'API key for the %s connector.' ),
 802                          $connector_data['name']
 803                      ),
 804                      'default'           => '',
 805                      'show_in_rest'      => true,
 806                      'sanitize_callback' => 'sanitize_text_field',
 807                  )
 808              );
 809          } elseif ( 'application_password' === $auth['method'] ) {
 810              register_setting(
 811                  'connectors',
 812                  $setting_name,
 813                  array(
 814                      'type'              => 'object',
 815                      'label'             => sprintf(
 816                          /* translators: %s: Connector name. */
 817                          __( '%s Credentials' ),
 818                          $connector_data['name']
 819                      ),
 820                      'description'       => sprintf(
 821                          /* translators: %s: Connector name. */
 822                          __( 'Application password credentials for the %s connector.' ),
 823                          $connector_data['name']
 824                      ),
 825                      'default'           => array(
 826                          'username' => '',
 827                          'password' => '',
 828                      ),
 829                      'show_in_rest'      => array(
 830                          'schema' => array(
 831                              'type'                 => 'object',
 832                              'properties'           => array(
 833                                  'username' => array(
 834                                      'type' => 'string',
 835                                  ),
 836                                  'password' => array(
 837                                      'type' => 'string',
 838                                  ),
 839                              ),
 840                              'additionalProperties' => false,
 841                          ),
 842                      ),
 843                      'sanitize_callback' => static function ( $value ) use ( $setting_name ) {
 844                          return wp_connectors_sanitize_application_password_credentials( $value, $setting_name );
 845                      },
 846                  )
 847              );
 848          }
 849      }
 850  }
 851  add_action( 'init', '_wp_register_default_connector_settings', 20 );
 852  
 853  /**
 854   * Passes stored connector API keys to the WP AI client.
 855   *
 856   * @since 7.0.0
 857   * @access private
 858   */
 859  function _wp_connectors_pass_default_keys_to_ai_client(): void {
 860      try {
 861          $ai_registry = AiClient::defaultRegistry();
 862          foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
 863              if ( 'ai_provider' !== $connector_data['type'] ) {
 864                  continue;
 865              }
 866  
 867              $auth = $connector_data['authentication'];
 868              if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
 869                  continue;
 870              }
 871  
 872              if ( ! $ai_registry->hasProvider( $connector_id ) ) {
 873                  continue;
 874              }
 875  
 876              // Skip if the key is already provided via env var or constant.
 877              $key_source = _wp_connectors_get_api_key_source( $auth['setting_name'], $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' );
 878              if ( 'env' === $key_source || 'constant' === $key_source ) {
 879                  continue;
 880              }
 881  
 882              $api_key = get_option( $auth['setting_name'], '' );
 883              if ( ! is_string( $api_key ) || '' === $api_key ) {
 884                  continue;
 885              }
 886  
 887              $ai_registry->setProviderRequestAuthentication(
 888                  $connector_id,
 889                  new ApiKeyRequestAuthentication( $api_key )
 890              );
 891          }
 892      } catch ( Exception $e ) {
 893          wp_trigger_error( __FUNCTION__, $e->getMessage() );
 894      }
 895  }
 896  add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );
 897  
 898  /**
 899   * Exposes connector settings to the connectors-wp-admin script module.
 900   *
 901   * @since 7.0.0
 902   * @access private
 903   *
 904   * @param array<string, mixed> $data Existing script module data.
 905   * @return array<string, mixed> Script module data with connectors added.
 906   */
 907  function _wp_connectors_get_connector_script_module_data( array $data ): array {
 908      $registry = AiClient::defaultRegistry();
 909  
 910      if ( ! function_exists( 'validate_plugin' ) ) {
 911          require_once  ABSPATH . 'wp-admin/includes/plugin.php';
 912      }
 913  
 914      $connectors = array();
 915      foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
 916          $auth     = $connector_data['authentication'];
 917          $auth_out = array( 'method' => $auth['method'] );
 918  
 919          if ( 'api_key' === $auth['method'] ) {
 920              $auth_out['settingName']    = $auth['setting_name'] ?? '';
 921              $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
 922              $key_source                 = _wp_connectors_get_api_key_source( $auth['setting_name'] ?? '', $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' );
 923              $auth_out['keySource']      = $key_source;
 924  
 925              if ( 'ai_provider' === $connector_data['type'] ) {
 926                  try {
 927                      $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
 928                  } catch ( Exception $e ) {
 929                      $auth_out['isConnected'] = false;
 930                  }
 931              } else {
 932                  $auth_out['isConnected'] = 'none' !== $key_source;
 933              }
 934          } elseif ( 'application_password' === $auth['method'] ) {
 935              $credentials = wp_connectors_get_application_password_credentials( $auth );
 936  
 937              $auth_out['settingName']    = $auth['setting_name'] ?? '';
 938              $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
 939              $auth_out['keySource']      = $credentials['source'];
 940              $auth_out['isConnected']    = '' !== $credentials['username'] && '' !== $credentials['password'];
 941          }
 942  
 943          $connector_out = array(
 944              'name'           => $connector_data['name'],
 945              'description'    => $connector_data['description'],
 946              'logoUrl'        => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null,
 947              'type'           => $connector_data['type'],
 948              'authentication' => $auth_out,
 949          );
 950  
 951          if ( ! empty( $connector_data['plugin']['file'] ) ) {
 952              $file         = $connector_data['plugin']['file'];
 953              $is_activated = (bool) call_user_func( $connector_data['plugin']['is_active'] );
 954              $is_installed = $is_activated || 0 === validate_plugin( $file );
 955  
 956              $connector_out['plugin'] = array(
 957                  'file'        => $file,
 958                  'isInstalled' => $is_installed,
 959                  'isActivated' => $is_activated,
 960              );
 961          }
 962  
 963          $connectors[ $connector_id ] = $connector_out;
 964      }
 965      ksort( $connectors );
 966      $data['connectors']        = $connectors;
 967      $data['isFileModDisabled'] = ! wp_is_file_mod_allowed( 'install_plugins' );
 968      return $data;
 969  }
 970  add_filter( 'script_module_data_options-connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' );


Generated : Mon Oct 12 08:20:33 2026 Cross-referenced by PHPXref