[ 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 before masking.
 735          // Non-AI connectors accept keys as-is; the service plugin handles its own validation.
 736          if ( $is_update && is_string( $value ) && '' !== $value && 'ai_provider' === $connector_data['type'] ) {
 737              if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) {
 738                  update_option( $setting_name, '' );
 739                  $data[ $setting_name ] = '';
 740                  continue;
 741              }
 742          }
 743  
 744          // Mask the key in the response.
 745          if ( is_string( $value ) && '' !== $value ) {
 746              $data[ $setting_name ] = _wp_connectors_mask_api_key( $value );
 747          }
 748      }
 749  
 750      $response->set_data( $data );
 751      return $response;
 752  }
 753  add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 );
 754  
 755  /**
 756   * Registers default connector settings.
 757   *
 758   * @since 7.0.0
 759   * @access private
 760   */
 761  function _wp_register_default_connector_settings(): void {
 762      $registered_settings = get_registered_settings();
 763  
 764      foreach ( wp_get_connectors() as $connector_data ) {
 765          $auth = $connector_data['authentication'];
 766          if ( 'api_key' !== $auth['method'] && 'application_password' !== $auth['method'] ) {
 767              continue;
 768          }
 769  
 770          if ( empty( $auth['setting_name'] ) || isset( $registered_settings[ $auth['setting_name'] ] ) ) {
 771              continue;
 772          }
 773          $setting_name = $auth['setting_name'];
 774  
 775          if ( ! isset( $connector_data['plugin']['is_active'] ) || ! is_callable( $connector_data['plugin']['is_active'] ) ) {
 776              continue;
 777          }
 778  
 779          if ( ! call_user_func( $connector_data['plugin']['is_active'] ) ) {
 780              continue;
 781          }
 782  
 783          if ( 'api_key' === $auth['method'] ) {
 784              register_setting(
 785                  'connectors',
 786                  $setting_name,
 787                  array(
 788                      'type'              => 'string',
 789                      'label'             => sprintf(
 790                          /* translators: %s: Connector name. */
 791                          __( '%s API Key' ),
 792                          $connector_data['name']
 793                      ),
 794                      'description'       => sprintf(
 795                          /* translators: %s: Connector name. */
 796                          __( 'API key for the %s connector.' ),
 797                          $connector_data['name']
 798                      ),
 799                      'default'           => '',
 800                      'show_in_rest'      => true,
 801                      'sanitize_callback' => 'sanitize_text_field',
 802                  )
 803              );
 804          } elseif ( 'application_password' === $auth['method'] ) {
 805              register_setting(
 806                  'connectors',
 807                  $setting_name,
 808                  array(
 809                      'type'              => 'object',
 810                      'label'             => sprintf(
 811                          /* translators: %s: Connector name. */
 812                          __( '%s Credentials' ),
 813                          $connector_data['name']
 814                      ),
 815                      'description'       => sprintf(
 816                          /* translators: %s: Connector name. */
 817                          __( 'Application password credentials for the %s connector.' ),
 818                          $connector_data['name']
 819                      ),
 820                      'default'           => array(
 821                          'username' => '',
 822                          'password' => '',
 823                      ),
 824                      'show_in_rest'      => array(
 825                          'schema' => array(
 826                              'type'                 => 'object',
 827                              'properties'           => array(
 828                                  'username' => array(
 829                                      'type' => 'string',
 830                                  ),
 831                                  'password' => array(
 832                                      'type' => 'string',
 833                                  ),
 834                              ),
 835                              'additionalProperties' => false,
 836                          ),
 837                      ),
 838                      'sanitize_callback' => static function ( $value ) use ( $setting_name ) {
 839                          return wp_connectors_sanitize_application_password_credentials( $value, $setting_name );
 840                      },
 841                  )
 842              );
 843          }
 844      }
 845  }
 846  add_action( 'init', '_wp_register_default_connector_settings', 20 );
 847  
 848  /**
 849   * Passes stored connector API keys to the WP AI client.
 850   *
 851   * @since 7.0.0
 852   * @access private
 853   */
 854  function _wp_connectors_pass_default_keys_to_ai_client(): void {
 855      try {
 856          $ai_registry = AiClient::defaultRegistry();
 857          foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
 858              if ( 'ai_provider' !== $connector_data['type'] ) {
 859                  continue;
 860              }
 861  
 862              $auth = $connector_data['authentication'];
 863              if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
 864                  continue;
 865              }
 866  
 867              if ( ! $ai_registry->hasProvider( $connector_id ) ) {
 868                  continue;
 869              }
 870  
 871              // Skip if the key is already provided via env var or constant.
 872              $key_source = _wp_connectors_get_api_key_source( $auth['setting_name'], $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' );
 873              if ( 'env' === $key_source || 'constant' === $key_source ) {
 874                  continue;
 875              }
 876  
 877              $api_key = get_option( $auth['setting_name'], '' );
 878              if ( ! is_string( $api_key ) || '' === $api_key ) {
 879                  continue;
 880              }
 881  
 882              $ai_registry->setProviderRequestAuthentication(
 883                  $connector_id,
 884                  new ApiKeyRequestAuthentication( $api_key )
 885              );
 886          }
 887      } catch ( Exception $e ) {
 888          wp_trigger_error( __FUNCTION__, $e->getMessage() );
 889      }
 890  }
 891  add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 );
 892  
 893  /**
 894   * Exposes connector settings to the connectors-wp-admin script module.
 895   *
 896   * @since 7.0.0
 897   * @access private
 898   *
 899   * @param array<string, mixed> $data Existing script module data.
 900   * @return array<string, mixed> Script module data with connectors added.
 901   */
 902  function _wp_connectors_get_connector_script_module_data( array $data ): array {
 903      $registry = AiClient::defaultRegistry();
 904  
 905      if ( ! function_exists( 'validate_plugin' ) ) {
 906          require_once  ABSPATH . 'wp-admin/includes/plugin.php';
 907      }
 908  
 909      $connectors = array();
 910      foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
 911          $auth     = $connector_data['authentication'];
 912          $auth_out = array( 'method' => $auth['method'] );
 913  
 914          if ( 'api_key' === $auth['method'] ) {
 915              $auth_out['settingName']    = $auth['setting_name'] ?? '';
 916              $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
 917              $key_source                 = _wp_connectors_get_api_key_source( $auth['setting_name'] ?? '', $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' );
 918              $auth_out['keySource']      = $key_source;
 919  
 920              if ( 'ai_provider' === $connector_data['type'] ) {
 921                  try {
 922                      $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
 923                  } catch ( Exception $e ) {
 924                      $auth_out['isConnected'] = false;
 925                  }
 926              } else {
 927                  $auth_out['isConnected'] = 'none' !== $key_source;
 928              }
 929          } elseif ( 'application_password' === $auth['method'] ) {
 930              $credentials = wp_connectors_get_application_password_credentials( $auth );
 931  
 932              $auth_out['settingName']    = $auth['setting_name'] ?? '';
 933              $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
 934              $auth_out['keySource']      = $credentials['source'];
 935              $auth_out['isConnected']    = '' !== $credentials['username'] && '' !== $credentials['password'];
 936          }
 937  
 938          $connector_out = array(
 939              'name'           => $connector_data['name'],
 940              'description'    => $connector_data['description'],
 941              'logoUrl'        => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null,
 942              'type'           => $connector_data['type'],
 943              'authentication' => $auth_out,
 944          );
 945  
 946          if ( ! empty( $connector_data['plugin']['file'] ) ) {
 947              $file         = $connector_data['plugin']['file'];
 948              $is_activated = (bool) call_user_func( $connector_data['plugin']['is_active'] );
 949              $is_installed = $is_activated || 0 === validate_plugin( $file );
 950  
 951              $connector_out['plugin'] = array(
 952                  'file'        => $file,
 953                  'isInstalled' => $is_installed,
 954                  'isActivated' => $is_activated,
 955              );
 956          }
 957  
 958          $connectors[ $connector_id ] = $connector_out;
 959      }
 960      ksort( $connectors );
 961      $data['connectors']        = $connectors;
 962      $data['isFileModDisabled'] = ! wp_is_file_mod_allowed( 'install_plugins' );
 963      return $data;
 964  }
 965  add_filter( 'script_module_data_options-connectors-wp-admin', '_wp_connectors_get_connector_script_module_data' );


Generated : Fri Jul 24 08:20:19 2026 Cross-referenced by PHPXref