[ 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      foreach ( $defaults as $id => $args ) {
 388          if ( 'api_key' === $args['authentication']['method'] ) {
 389              $sanitized_id = str_replace( '-', '_', $id );
 390  
 391              $args['authentication']['setting_name'] = "connectors_ai_{$sanitized_id}_api_key";
 392  
 393              // All AI providers use the {CONSTANT_CASE_ID}_API_KEY naming convention.
 394              $constant_case_key = strtoupper( (string) preg_replace( '/([a-z])([A-Z])/', '$1_$2', $sanitized_id ) ) . '_API_KEY';
 395  
 396              $args['authentication']['constant_name'] = $constant_case_key;
 397              $args['authentication']['env_var_name']  = $constant_case_key;
 398          }
 399  
 400          $args['plugin']['is_active'] = static function () use ( $ai_registry, $id ): bool {
 401              try {
 402                  return $ai_registry->hasProvider( $id );
 403              } catch ( Exception $e ) {
 404                  return false;
 405              }
 406          };
 407  
 408          $registry->register( $id, $args );
 409      }
 410  }
 411  
 412  /**
 413   * Masks an API key, showing only the last 4 characters.
 414   *
 415   * @since 7.0.0
 416   * @access private
 417   *
 418   * @param string $key The API key to mask.
 419   * @return string The masked key, e.g. "************fj39".
 420   */
 421  function _wp_connectors_mask_api_key( string $key ): string {
 422      if ( strlen( $key ) <= 4 ) {
 423          return $key;
 424      }
 425  
 426      return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
 427  }
 428  
 429  /**
 430   * Determines the source of an API key for a given connector.
 431   *
 432   * Checks in order: environment variable, PHP constant, database.
 433   * Environment variable and constant are only checked when their
 434   * respective names are provided.
 435   *
 436   * @since 7.0.0
 437   * @access private
 438   *
 439   * @param string $setting_name  The option name for the API key (e.g., 'connectors_spam_filtering_my_plugin_api_key').
 440   * @param string $env_var_name  Optional. Environment variable name to check (e.g., 'MY_PLUGIN_API_KEY').
 441   * @param string $constant_name Optional. PHP constant name to check (e.g., 'MY_PLUGIN_API_KEY').
 442   * @return string The key source: 'env', 'constant', 'database', or 'none'.
 443   */
 444  function _wp_connectors_get_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string {
 445      // Check environment variable first.
 446      if ( '' !== $env_var_name ) {
 447          $env_value = getenv( $env_var_name );
 448          if ( false !== $env_value && '' !== $env_value ) {
 449              return 'env';
 450          }
 451      }
 452  
 453      // Check PHP constant.
 454      if ( '' !== $constant_name && defined( $constant_name ) ) {
 455          $const_value = constant( $constant_name );
 456          if ( is_string( $const_value ) && '' !== $const_value ) {
 457              return 'constant';
 458          }
 459      }
 460  
 461      // Check database.
 462      $db_value = get_option( $setting_name, '' );
 463      if ( '' !== $db_value ) {
 464          return 'database';
 465      }
 466  
 467      return 'none';
 468  }
 469  
 470  /**
 471   * Parses a `username:password` credentials string.
 472   *
 473   * Splits on the first colon, matching the HTTP Basic authentication
 474   * userinfo format, so passwords may contain colons.
 475   *
 476   * @since 7.1.0
 477   * @access private
 478   *
 479   * @param string $value The raw credentials string.
 480   * @return array{username: string, password: string} Parsed credentials. Both values
 481   *                                                   are empty when the string is malformed.
 482   */
 483  function wp_connectors_parse_application_password_credentials( string $value ): array {
 484      $separator = strpos( $value, ':' );
 485      // Trim so surrounding whitespace or a trailing newline (common when the
 486      // value comes from a file or `.env`) does not become part of the credentials.
 487      $username = false === $separator ? '' : trim( substr( $value, 0, $separator ) );
 488      $password = false === $separator ? '' : trim( substr( $value, $separator + 1 ) );
 489  
 490      if ( '' === $username || '' === $password ) {
 491          return array(
 492              'username' => '',
 493              'password' => '',
 494          );
 495      }
 496  
 497      return array(
 498          'username' => $username,
 499          'password' => $password,
 500      );
 501  }
 502  
 503  /**
 504   * Resolves application-password credentials for a connector.
 505   *
 506   * Checks in order: environment variable, PHP constant, database. The
 507   * environment variable and constant are only checked when their respective
 508   * names are provided, and must contain the credentials as a single
 509   * `username:password` string. A non-empty environment variable or constant
 510   * that cannot be parsed as `username:password` is reported with
 511   * `_doing_it_wrong()` and ignored, so resolution falls through to the next
 512   * source.
 513   *
 514   * @since 7.1.0
 515   * @access private
 516   *
 517   * @param array $auth The connector's authentication configuration.
 518   * @return array{username: string, password: string, source: string} Resolved credentials and
 519   *                                                                   their source: 'env', 'constant',
 520   *                                                                   'database', or 'none'.
 521   */
 522  function wp_connectors_get_application_password_credentials( array $auth ): array {
 523      // Check environment variable first.
 524      $env_var_name = $auth['env_var_name'] ?? '';
 525      if ( '' !== $env_var_name ) {
 526          $env_value = getenv( $env_var_name );
 527          if ( false !== $env_value && '' !== $env_value ) {
 528              $credentials = wp_connectors_parse_application_password_credentials( $env_value );
 529              if ( '' !== $credentials['username'] && '' !== $credentials['password'] ) {
 530                  $credentials['source'] = 'env';
 531                  return $credentials;
 532              }
 533  
 534              _doing_it_wrong(
 535                  __FUNCTION__,
 536                  sprintf(
 537                      /* translators: %s: Environment variable name. */
 538                      __( 'The %s environment variable must contain application password credentials in "username:password" format.' ),
 539                      esc_html( $env_var_name )
 540                  ),
 541                  '7.1.0'
 542              );
 543          }
 544      }
 545  
 546      // Check PHP constant.
 547      $constant_name = $auth['constant_name'] ?? '';
 548      if ( '' !== $constant_name && defined( $constant_name ) ) {
 549          $const_value = constant( $constant_name );
 550          if ( is_string( $const_value ) && '' !== $const_value ) {
 551              $credentials = wp_connectors_parse_application_password_credentials( $const_value );
 552              if ( '' !== $credentials['username'] && '' !== $credentials['password'] ) {
 553                  $credentials['source'] = 'constant';
 554                  return $credentials;
 555              }
 556  
 557              _doing_it_wrong(
 558                  __FUNCTION__,
 559                  sprintf(
 560                      /* translators: %s: PHP constant name. */
 561                      __( 'The %s constant must contain application password credentials in "username:password" format.' ),
 562                      esc_html( $constant_name )
 563                  ),
 564                  '7.1.0'
 565              );
 566          }
 567      }
 568  
 569      // Check database.
 570      $stored   = get_option( $auth['setting_name'] ?? '', array() );
 571      $username = is_array( $stored ) && isset( $stored['username'] ) && is_string( $stored['username'] ) ? $stored['username'] : '';
 572      $password = is_array( $stored ) && isset( $stored['password'] ) && is_string( $stored['password'] ) ? $stored['password'] : '';
 573  
 574      return array(
 575          'username' => $username,
 576          'password' => $password,
 577          'source'   => '' !== $username && '' !== $password ? 'database' : 'none',
 578      );
 579  }
 580  
 581  /**
 582   * Checks whether an API key is valid for a given provider.
 583   *
 584   * @since 7.0.0
 585   * @access private
 586   *
 587   * @param string $key         The API key to check.
 588   * @param string $provider_id The WP AI client provider ID.
 589   * @return bool|null True if valid, false if invalid, null if unable to determine.
 590   */
 591  function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool {
 592      try {
 593          $registry = AiClient::defaultRegistry();
 594  
 595          if ( ! $registry->hasProvider( $provider_id ) ) {
 596              _doing_it_wrong(
 597                  __FUNCTION__,
 598                  sprintf(
 599                      /* translators: %s: AI provider ID. */
 600                      __( 'The provider "%s" is not registered in the AI client registry.' ),
 601                      $provider_id
 602                  ),
 603                  '7.0.0'
 604              );
 605              return null;
 606          }
 607  
 608          $registry->setProviderRequestAuthentication(
 609              $provider_id,
 610              new ApiKeyRequestAuthentication( $key )
 611          );
 612  
 613          return $registry->isProviderConfigured( $provider_id );
 614      } catch ( Exception $e ) {
 615          wp_trigger_error( __FUNCTION__, $e->getMessage() );
 616          return null;
 617      }
 618  }
 619  
 620  /**
 621   * Sanitizes stored application-password credentials for a connector.
 622   *
 623   * Credential fields that are missing or not strings keep their currently
 624   * stored values, so partial updates cannot silently clear a stored secret.
 625   * A password matching the mask that `_wp_connectors_rest_settings_dispatch()`
 626   * places in REST responses also keeps the stored password, so a masked
 627   * settings response can be submitted back to the endpoint unchanged.
 628   * Pass an empty string to clear a field.
 629   * If the sanitized username is empty, both fields are discarded so partial
 630   * credentials cannot leave an orphaned secret.
 631   *
 632   * @since 7.1.0
 633   * @access private
 634   *
 635   * @param mixed  $value  The submitted setting value.
 636   * @param string $option The option name being sanitized. Passed explicitly by the
 637   *                       registered sanitize callback; falls back to the current
 638   *                       `sanitize_option_{$option}` filter name when omitted.
 639   * @return array{username: string, password: string} Sanitized credentials.
 640   */
 641  function wp_connectors_sanitize_application_password_credentials( $value, string $option = '' ): array {
 642      if ( ! is_array( $value ) ) {
 643          $value = array();
 644      }
 645  
 646      if ( '' === $option ) {
 647          $option = str_replace( 'sanitize_option_', '', (string) current_filter() );
 648      }
 649  
 650      $stored = get_option( $option );
 651      if ( ! is_array( $stored ) ) {
 652          $stored = array();
 653      }
 654  
 655      $credentials = array();
 656      foreach ( array( 'username', 'password' ) as $field ) {
 657          if ( isset( $value[ $field ] ) && is_string( $value[ $field ] ) ) {
 658              $credentials[ $field ] = sanitize_text_field( $value[ $field ] );
 659          } else {
 660              $credentials[ $field ] = isset( $stored[ $field ] ) && is_string( $stored[ $field ] ) ? $stored[ $field ] : '';
 661          }
 662      }
 663  
 664      // A masked password means a client resubmitted a masked REST response.
 665      if ( str_repeat( "\u{2022}", 16 ) === $credentials['password'] ) {
 666          $credentials['password'] = isset( $stored['password'] ) && is_string( $stored['password'] ) ? $stored['password'] : '';
 667      }
 668  
 669      if ( '' === $credentials['username'] ) {
 670          return array(
 671              'username' => '',
 672              'password' => '',
 673          );
 674      }
 675  
 676      return $credentials;
 677  }
 678  
 679  /**
 680   * Masks and validates connector credentials in REST responses.
 681   *
 682   * On every `/wp/v2/settings` response, masks connector API key values and the
 683   * password field of default application-password credential objects.
 684   *
 685   * On POST or PUT requests, validates each updated AI provider API key before
 686   * masking. If validation fails, the key is reverted to an empty string.
 687   * Application password values are masked but not validated.
 688   *
 689   * @since 7.0.0
 690   * @access private
 691   *
 692   * @param WP_REST_Response $response The response object.
 693   * @param WP_REST_Server   $server   The server instance.
 694   * @param WP_REST_Request  $request  The request object.
 695   * @return WP_REST_Response The modified response with masked/validated keys.
 696   */
 697  function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
 698      if ( '/wp/v2/settings' !== $request->get_route() ) {
 699          return $response;
 700      }
 701  
 702      $data = $response->get_data();
 703      if ( ! is_array( $data ) ) {
 704          return $response;
 705      }
 706  
 707      $is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method();
 708  
 709      foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
 710          $auth = $connector_data['authentication'];
 711  
 712          if ( 'application_password' === $auth['method'] && ! empty( $auth['setting_name'] ) ) {
 713              $setting_name = $auth['setting_name'];
 714              if ( array_key_exists( $setting_name, $data ) && is_array( $data[ $setting_name ] ) ) {
 715                  $password = $data[ $setting_name ]['password'] ?? '';
 716                  if ( is_string( $password ) && '' !== $password ) {
 717                      $data[ $setting_name ]['password'] = str_repeat( "\u{2022}", 16 );
 718                  }
 719              }
 720              continue;
 721          }
 722  
 723          if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
 724              continue;
 725          }
 726  
 727          $setting_name = $auth['setting_name'];
 728          if ( ! array_key_exists( $setting_name, $data ) ) {
 729              continue;
 730          }
 731  
 732          $value = $data[ $setting_name ];
 733  
 734          // On update, validate AI provider keys submitted in the request before masking.
 735          // Non-AI connectors accept keys as-is; the service plugin handles its own validation.
 736          if ( $is_update
 737              && $request->has_param( $setting_name )
 738              && is_string( $value ) && '' !== $value
 739              && 'ai_provider' === $connector_data['type']
 740          ) {
 741              if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) {
 742                  update_option( $setting_name, '' );
 743                  $data[ $setting_name ] = '';
 744                  continue;
 745              }
 746          }
 747  
 748          // Mask the key in the response.
 749          if ( is_string( $value ) && '' !== $value ) {
 750              $data[ $setting_name ] = _wp_connectors_mask_api_key( $value );
 751          }
 752      }
 753  
 754      $response->set_data( $data );
 755      return $response;
 756  }
 757  add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 );
 758  
 759  /**
 760   * Registers default connector settings.
 761   *
 762   * @since 7.0.0
 763   * @access private
 764   */
 765  function _wp_register_default_connector_settings(): void {
 766      $registered_settings = get_registered_settings();
 767  
 768      foreach ( wp_get_connectors() as $connector_data ) {
 769          $auth = $connector_data['authentication'];
 770          if ( 'api_key' !== $auth['method'] && 'application_password' !== $auth['method'] ) {
 771              continue;
 772          }
 773  
 774          if ( empty( $auth['setting_name'] ) || isset( $registered_settings[ $auth['setting_name'] ] ) ) {
 775              continue;
 776          }
 777          $setting_name = $auth['setting_name'];
 778  
 779          if ( ! isset( $connector_data['plugin']['is_active'] ) || ! is_callable( $connector_data['plugin']['is_active'] ) ) {
 780              continue;
 781          }
 782  
 783          if ( ! call_user_func( $connector_data['plugin']['is_active'] ) ) {
 784              continue;
 785          }
 786  
 787          if ( 'api_key' === $auth['method'] ) {
 788              register_setting(
 789                  'connectors',
 790                  $setting_name,
 791                  array(
 792                      'type'              => 'string',
 793                      'label'             => sprintf(
 794                          /* translators: %s: Connector name. */
 795                          __( '%s API Key' ),
 796                          $connector_data['name']
 797                      ),
 798                      'description'       => sprintf(
 799                          /* translators: %s: Connector name. */
 800                          __( 'API key for the %s connector.' ),
 801                          $connector_data['name']
 802                      ),
 803                      'default'           => '',
 804                      'show_in_rest'      => true,
 805                      'sanitize_callback' => 'sanitize_text_field',
 806                  )
 807              );
 808          } elseif ( 'application_password' === $auth['method'] ) {
 809              register_setting(
 810                  'connectors',
 811                  $setting_name,
 812                  array(
 813                      'type'              => 'object',
 814                      'label'             => sprintf(
 815                          /* translators: %s: Connector name. */
 816                          __( '%s Credentials' ),
 817                          $connector_data['name']
 818                      ),
 819                      'description'       => sprintf(
 820                          /* translators: %s: Connector name. */
 821                          __( 'Application password credentials for the %s connector.' ),
 822                          $connector_data['name']
 823                      ),
 824                      'default'           => array(
 825                          'username' => '',
 826                          'password' => '',
 827                      ),
 828                      'show_in_rest'      => array(
 829                          'schema' => array(
 830                              'type'                 => 'object',
 831                              'properties'           => array(
 832                                  'username' => array(
 833                                      'type' => 'string',
 834                                  ),
 835                                  'password' => array(
 836                                      'type' => 'string',
 837                                  ),
 838                              ),
 839                              'additionalProperties' => false,
 840                          ),
 841                      ),
 842                      'sanitize_callback' => static function ( $value ) use ( $setting_name ) {
 843                          return wp_connectors_sanitize_application_password_credentials( $value, $setting_name );
 844                      },
 845                  )
 846              );
 847          }
 848      }
 849  }
 850  add_action( 'init', '_wp_register_default_connector_settings', 20 );
 851  
 852  /**
 853   * Passes stored connector API keys to the WP AI client.
 854   *
 855   * @since 7.0.0
 856   * @access private
 857   */
 858  function _wp_connectors_pass_default_keys_to_ai_client(): void {
 859      try {
 860          $ai_registry = AiClient::defaultRegistry();
 861          foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
 862              if ( 'ai_provider' !== $connector_data['type'] ) {
 863                  continue;
 864              }
 865  
 866              $auth = $connector_data['authentication'];
 867              if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
 868                  continue;
 869              }
 870  
 871              if ( ! $ai_registry->hasProvider( $connector_id ) ) {
 872                  continue;
 873              }
 874  
 875              // Skip if the key is already provided via env var or constant.
 876              $key_source = _wp_connectors_get_api_key_source( $auth['setting_name'], $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' );
 877              if ( 'env' === $key_source || 'constant' === $key_source ) {
 878                  continue;
 879              }
 880  
 881              $api_key = get_option( $auth['setting_name'], '' );
 882              if ( ! is_string( $api_key ) || '' === $api_key ) {
 883                  continue;
 884              }
 885  
 886              $ai_registry->setProviderRequestAuthentication(
 887                  $connector_id,
 888                  new ApiKeyRequestAuthentication( $api_key )
 889              );
 890          }
 891      } catch ( Exception $e ) {
 892          wp_trigger_error( __FUNCTION__, $e->getMessage() );
 893      }
 894  }
 895  add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );
 896  
 897  /**
 898   * Exposes connector settings to the connectors-wp-admin script module.
 899   *
 900   * @since 7.0.0
 901   * @access private
 902   *
 903   * @param array<string, mixed> $data Existing script module data.
 904   * @return array<string, mixed> Script module data with connectors added.
 905   */
 906  function _wp_connectors_get_connector_script_module_data( array $data ): array {
 907      $registry = AiClient::defaultRegistry();
 908  
 909      if ( ! function_exists( 'validate_plugin' ) ) {
 910          require_once  ABSPATH . 'wp-admin/includes/plugin.php';
 911      }
 912  
 913      $connectors = array();
 914      foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
 915          $auth     = $connector_data['authentication'];
 916          $auth_out = array( 'method' => $auth['method'] );
 917  
 918          if ( 'api_key' === $auth['method'] ) {
 919              $auth_out['settingName']    = $auth['setting_name'] ?? '';
 920              $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
 921              $key_source                 = _wp_connectors_get_api_key_source( $auth['setting_name'] ?? '', $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' );
 922              $auth_out['keySource']      = $key_source;
 923  
 924              if ( 'ai_provider' === $connector_data['type'] ) {
 925                  try {
 926                      $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
 927                  } catch ( Exception $e ) {
 928                      $auth_out['isConnected'] = false;
 929                  }
 930              } else {
 931                  $auth_out['isConnected'] = 'none' !== $key_source;
 932              }
 933          } elseif ( 'application_password' === $auth['method'] ) {
 934              $credentials = wp_connectors_get_application_password_credentials( $auth );
 935  
 936              $auth_out['settingName']    = $auth['setting_name'] ?? '';
 937              $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
 938              $auth_out['keySource']      = $credentials['source'];
 939              $auth_out['isConnected']    = '' !== $credentials['username'] && '' !== $credentials['password'];
 940          }
 941  
 942          $connector_out = array(
 943              'name'           => $connector_data['name'],
 944              'description'    => $connector_data['description'],
 945              'logoUrl'        => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null,
 946              'type'           => $connector_data['type'],
 947              'authentication' => $auth_out,
 948          );
 949  
 950          if ( ! empty( $connector_data['plugin']['file'] ) ) {
 951              $file         = $connector_data['plugin']['file'];
 952              $is_activated = (bool) call_user_func( $connector_data['plugin']['is_active'] );
 953              $is_installed = $is_activated || 0 === validate_plugin( $file );
 954  
 955              $connector_out['plugin'] = array(
 956                  'file'        => $file,
 957                  'isInstalled' => $is_installed,
 958                  'isActivated' => $is_activated,
 959              );
 960          }
 961  
 962          $connectors[ $connector_id ] = $connector_out;
 963      }
 964      ksort( $connectors );
 965      $data['connectors']        = $connectors;
 966      $data['isFileModDisabled'] = ! wp_is_file_mod_allowed( 'install_plugins' );
 967      return $data;
 968  }
 969  add_filter( 'script_module_data_options-connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' );


Generated : Wed Sep 2 08:20:30 2026 Cross-referenced by PHPXref