identifier = $identifier; $this->store = $store; $this->key_index = $key_index; } /** * Registers needed hooks. * * @since 1.0.0 */ public function register(): void { add_filter( 'pre_http_request', array( $this, 'maybe_block_request' ), 5, 3 ); add_filter( 'rest_request_after_callbacks', array( $this, 'explain_blocked_rest_error' ) ); } /** * Blocks the outbound request when the caller isn't approved for the * connector whose credential the request is carrying. * * @since 1.0.0 * * @param mixed $preempt Current short-circuit value from earlier filter callbacks. * @param array $args Parsed request arguments. * @param string $url Request URL. * @return mixed `WP_Error` to deny the request, or the incoming $preempt value otherwise. */ public function maybe_block_request( $preempt, $args, $url ) { if ( false !== $preempt ) { return $preempt; } if ( $this->in_filter ) { return $preempt; } if ( ! is_array( $args ) ) { $args = array(); } if ( ! is_string( $url ) ) { $url = ''; } $connector_id = $this->key_index->lookup( $args, $url ); if ( null === $connector_id ) { return $preempt; } $this->in_filter = true; try { $caller = $this->identifier->identify( $this->provider_extension_keys( $connector_id ) ); } finally { $this->in_filter = false; } if ( null === $caller ) { // No identifiable plugin/theme/mu-plugin on the stack — allow through // so core maintenance, wp-cli, and REST-originated requests aren't blocked. return $preempt; } if ( $this->store->is_approved( $caller['basename'], $connector_id ) ) { return $preempt; } $this->in_filter = true; try { $this->store->record_pending( $caller, $connector_id ); } finally { $this->in_filter = false; } $this->blocked = new WP_Error( 'wpai_connector_not_approved', sprintf( /* translators: 1: Connector ID. 2: Calling plugin/theme basename. */ __( 'The "%1$s" AI connector has not been approved for use by "%2$s".', 'ai' ), $connector_id, $caller['basename'] ), array( 'status' => 403, 'connector_id' => $connector_id, 'caller' => $caller, ) ); return $this->blocked; } /** * Replaces a REST error with an approval message when this request was blocked. * * @since 1.5.0 * * @param mixed $response Result to send to the client. * @return mixed The approval `WP_Error`, or the incoming $response otherwise. */ public function explain_blocked_rest_error( $response ) { if ( null === $this->blocked || ! is_wp_error( $response ) ) { return $response; } $connector_id = (string) $this->blocked->get_error_data()['connector_id']; $this->blocked = null; $connector = wp_get_connector( $connector_id ); $name = is_string( $connector['name'] ?? null ) ? $connector['name'] : $connector_id; if ( current_user_can( 'manage_options' ) ) { /* translators: %s: Connector name. */ $message = sprintf( __( 'The %s connector is pending approval. Please approve it under Tools > Connector Approvals.', 'ai' ), $name ); } else { /* translators: %s: Connector name. */ $message = sprintf( __( 'The %s connector is pending approval. Please ask a site administrator to approve it.', 'ai' ), $name ); } return new WP_Error( 'wpai_connector_not_approved', $message, array( 'status' => 403, 'connector_id' => $connector_id, ) ); } /** * Returns the extension that provides a connector, as a Caller_Identifier key. * * @since 1.4.0 * * @param string $connector_id Connector ID. * @return list The provider's extension key, or an empty list when it * cannot be determined. */ private function provider_extension_keys( string $connector_id ): array { $connector = function_exists( 'wp_get_connector' ) ? wp_get_connector( $connector_id ) : null; $declared = $connector['plugin']['file'] ?? ''; if ( is_string( $declared ) && '' !== $declared ) { return array( Caller_Identifier::TYPE_PLUGIN . ':' . explode( '/', $declared )[0] ); } try { $registry = AiClient::defaultRegistry(); if ( ! $registry->hasProvider( $connector_id ) ) { return array(); } $file = ( new ReflectionClass( $registry->getProviderClassName( $connector_id ) ) )->getFileName(); } catch ( Throwable $e ) { return array(); } if ( ! is_string( $file ) ) { return array(); } return $this->provider_keys_for_file( $file ); } /** * Returns the extension key for a provider class file. * * @since 1.4.0 * * @param string $file Absolute path of the file defining the provider class. * @return list The owning extension's key, or an empty list when the * file is bundled in a `vendor/` directory or belongs to * no plugin, mu-plugin, or theme. */ private function provider_keys_for_file( string $file ): array { $file = wp_normalize_path( $file ); if ( str_contains( $file, '/vendor/' ) ) { return array(); } $extension = $this->identifier->classify_file( $file ); if ( null === $extension ) { return array(); } return array( Caller_Identifier::extension_key( $extension ) ); } }