HEX
Server: Apache/2.4.41 (Ubuntu)
System: Linux wordpress-ubuntu-s-2vcpu-4gb-fra1-01 5.4.0-169-generic #187-Ubuntu SMP Thu Nov 23 14:52:28 UTC 2023 x86_64
User: root (0)
PHP: 7.4.33
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
File: /var/www/hcv/wp-content/plugins/wordpress-seo-premium/src/actions/ai-generator-action.php
<?php

namespace Yoast\WP\SEO\Premium\Actions;

use RuntimeException;
use WP_User;
use WPSEO_Addon_Manager;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\User_Helper;
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Forbidden_Exception;
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Unauthorized_Exception;
use Yoast\WP\SEO\Premium\Helpers\AI_Generator_Helper;

/**
 * Handles the actual requests to our API endpoints.
 */
class AI_Generator_Action {

	/**
	 * The AI_Generator helper.
	 *
	 * @var AI_Generator_Helper
	 */
	protected $ai_generator_helper;

	/**
	 * The Options helper.
	 *
	 * @var Options_Helper
	 */
	protected $options_helper;

	/**
	 * The User helper.
	 *
	 * @var User_Helper
	 */
	protected $user_helper;

	/**
	 * The add-on manager.
	 *
	 * @var WPSEO_Addon_Manager
	 */
	private $addon_manager;

	/**
	 * AI_Generator_Action constructor.
	 *
	 * @param AI_Generator_Helper $ai_generator_helper The AI_Generator helper.
	 * @param Options_Helper      $options_helper      The Options helper.
	 * @param User_Helper         $user_helper         The User helper.
	 * @param WPSEO_Addon_Manager $addon_manager       The add-on manager.
	 */
	public function __construct(
		AI_Generator_Helper $ai_generator_helper,
		Options_Helper $options_helper,
		User_Helper $user_helper,
		WPSEO_Addon_Manager $addon_manager
	) {
		$this->ai_generator_helper = $ai_generator_helper;
		$this->options_helper      = $options_helper;
		$this->user_helper         = $user_helper;
		$this->addon_manager       = $addon_manager;
	}

	/**
	 * Requests a new set of JWT tokens.
	 *
	 * Requests a new JWT access and refresh token for a user from the Yoast AI Service and stores it in the database
	 * under usermeta. The storing of the token happens in a HTTP callback that is triggered by this request.
	 *
	 * @param \WP_User $user The WP user.
	 *
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Bad_Request_Exception Bad_Request_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Forbidden_Exception Forbidden_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Internal_Server_Error_Exception Internal_Server_Error_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Not_Found_Exception Not_Found_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Payment_Required_Exception Payment_Required_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Request_Timeout_Exception Request_Timeout_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Service_Unavailable_Exception Service_Unavailable_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Too_Many_Requests_Exception Too_Many_Requests_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Unauthorized_Exception Unauthorized_Exception.
	 *
	 * @return void
	 */
	public function token_request( WP_User $user ): void {
		// Ensure the user has given consent.
		if ( $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_consent', true ) !== '1' ) {
			throw $this->handle_consent_revoked( $user->ID );
		}

		// Generate a verification code and store it in the database.
		$code_verifier = $this->ai_generator_helper->generate_code_verifier( $user );
		$this->ai_generator_helper->set_code_verifier( $user->ID, $code_verifier );

		$request_body = [
			'service'              => 'openai',
			'code_challenge'       => \hash( 'sha256', $code_verifier ),
			'license_site_url'     => $this->ai_generator_helper->get_license_url(),
			'user_id'              => (string) $user->ID,
			'callback_url'         => $this->ai_generator_helper->get_callback_url(),
			'refresh_callback_url' => $this->ai_generator_helper->get_refresh_callback_url(),
		];

		$this->ai_generator_helper->request( '/token/request', $request_body );

		// The callback saves the metadata. Because that is in another session, we need to delete the current cache here. Or we may get the old token.
		\wp_cache_delete( $user->ID, 'user_meta' );
	}

	/**
	 * Refreshes the JWT access token.
	 *
	 * Refreshes a stored JWT access token for a user with the Yoast AI Service and stores it in the database under
	 * usermeta. The storing of the token happens in a HTTP callback that is triggered by this request.
	 *
	 * @param \WP_User $user The WP user.
	 *
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Bad_Request_Exception Bad_Request_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Forbidden_Exception Forbidden_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Internal_Server_Error_Exception Internal_Server_Error_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Not_Found_Exception Not_Found_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Payment_Required_Exception Payment_Required_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Request_Timeout_Exception Request_Timeout_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Service_Unavailable_Exception Service_Unavailable_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Too_Many_Requests_Exception Too_Many_Requests_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Unauthorized_Exception Unauthorized_Exception.
	 * @throws \RuntimeException Unable to retrieve the refresh token.
	 *
	 * @return void
	 */
	public function token_refresh( WP_User $user ): void {
		$refresh_jwt = $this->ai_generator_helper->get_refresh_token( $user->ID );

		// Generate a verification code and store it in the database.
		$code_verifier = $this->ai_generator_helper->generate_code_verifier( $user );
		$this->ai_generator_helper->set_code_verifier( $user->ID, $code_verifier );

		$request_body    = [
			'code_challenge' => \hash( 'sha256', $code_verifier ),
		];
		$request_headers = [
			'Authorization' => "Bearer $refresh_jwt",
		];

		$this->ai_generator_helper->request( '/token/refresh', $request_body, $request_headers );

		// The callback saves the metadata. Because that is in another session, we need to delete the current cache here. Or we may get the old token.
		\wp_cache_delete( $user->ID, 'user_meta' );
	}

	/**
	 * Callback function that will be invoked by our API.
	 *
	 * @param string $access_jwt     The access JWT.
	 * @param string $refresh_jwt    The refresh JWT.
	 * @param string $code_challenge The verification code.
	 * @param int    $user_id        The user ID.
	 *
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Unauthorized_Exception Unauthorized_Exception.
	 *
	 * @return string The code verifier.
	 */
	public function callback(
		string $access_jwt,
		string $refresh_jwt,
		string $code_challenge,
		int $user_id
	): string {
		try {
			$code_verifier = $this->ai_generator_helper->get_code_verifier( $user_id );
		} catch ( RuntimeException $exception ) {
			throw new Unauthorized_Exception( 'Unauthorized' );
		}

		if ( $code_challenge !== \hash( 'sha256', $code_verifier ) ) {
			throw new Unauthorized_Exception( 'Unauthorized' );
		}
		$this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_generator_access_jwt', $access_jwt );
		$this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_generator_refresh_jwt', $refresh_jwt );
		$this->ai_generator_helper->delete_code_verifier( $user_id );

		return $code_verifier;
	}

	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods.

	/**
	 * Action used to generate suggestions through AI.
	 *
	 * @param \WP_User $user                  The WP user.
	 * @param string   $suggestion_type       The type of the requested suggestion.
	 * @param string   $prompt_content        The excerpt taken from the post.
	 * @param string   $focus_keyphrase       The focus keyphrase associated to the post.
	 * @param string   $language              The language of the post.
	 * @param string   $platform              The platform the post is intended for.
	 * @param bool     $retry_on_unauthorized Whether to retry when unauthorized (mechanism to retry once).
	 *
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Bad_Request_Exception Bad_Request_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Forbidden_Exception Forbidden_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Internal_Server_Error_Exception Internal_Server_Error_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Not_Found_Exception Not_Found_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Payment_Required_Exception Payment_Required_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Request_Timeout_Exception Request_Timeout_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Service_Unavailable_Exception Service_Unavailable_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Too_Many_Requests_Exception Too_Many_Requests_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Unauthorized_Exception Unauthorized_Exception.
	 * @throws \RuntimeException Unable to retrieve the access token.
	 *
	 * @return array The suggestions.
	 */
	public function get_suggestions(
		WP_User $user,
		string $suggestion_type,
		string $prompt_content,
		string $focus_keyphrase,
		string $language,
		string $platform,
		bool $retry_on_unauthorized = true
	): array {
		$token = $this->get_or_request_access_token( $user );

		$request_body    = [
			'service' => 'openai',
			'user_id' => (string) $user->ID,
			'subject' => [
				'content'         => $prompt_content,
				'focus_keyphrase' => $focus_keyphrase,
				'language'        => $language,
				'platform'        => $platform,
			],
		];
		$request_headers = [
			'Authorization' => "Bearer $token",
		];

		try {
			$response = $this->ai_generator_helper->request( "/openai/suggestions/$suggestion_type", $request_body, $request_headers );
		} catch ( Unauthorized_Exception $exception ) {
			// Delete the stored JWT tokens, as they appear to be no longer valid.
			$this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt' );
			$this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_refresh_jwt' );

			if ( ! $retry_on_unauthorized ) {
				throw $exception;
			}

			// Try again once more by fetching a new set of tokens and trying the suggestions endpoint again.
			return $this->get_suggestions( $user, $suggestion_type, $prompt_content, $focus_keyphrase, $language, $platform, false );
		} catch ( Forbidden_Exception $exception ) {
			// Follow the API in the consent being revoked (Use case: user sent an e-mail to revoke?).
			throw $this->handle_consent_revoked( $user->ID, $exception->getCode() );
		}

		return $this->ai_generator_helper->build_suggestions_array( $response );
	}

	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

	/**
	 * Stores the consent given or revoked by the user.
	 *
	 * @param int  $user_id The user ID.
	 * @param bool $consent Whether the consent has been given.
	 *
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Bad_Request_Exception Bad_Request_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Internal_Server_Error_Exception Internal_Server_Error_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Not_Found_Exception Not_Found_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Payment_Required_Exception Payment_Required_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Request_Timeout_Exception Request_Timeout_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Service_Unavailable_Exception Service_Unavailable_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Too_Many_Requests_Exception Too_Many_Requests_Exception.
	 * @throws \RuntimeException Unable to retrieve the access token.
	 *
	 * @return void
	 */
	public function consent( int $user_id, bool $consent ): void {
		if ( $consent ) {
			// Store the consent at user level.
			$this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_consent', true );
		}
		else {
			$this->token_invalidate( $user_id );

			// Delete the consent at user level.
			$this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' );
		}
	}

	/**
	 * Busts the subscription cache.
	 *
	 * @return void
	 */
	public function bust_subscription_cache(): void {
		$this->addon_manager->remove_site_information_transients();
	}

	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods.

	/**
	 * Retrieves the access token.
	 *
	 * @param WP_User $user The WP user.
	 *
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Bad_Request_Exception Bad_Request_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Forbidden_Exception Forbidden_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Internal_Server_Error_Exception Internal_Server_Error_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Not_Found_Exception Not_Found_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Payment_Required_Exception Payment_Required_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Request_Timeout_Exception Request_Timeout_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Service_Unavailable_Exception Service_Unavailable_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Too_Many_Requests_Exception Too_Many_Requests_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Unauthorized_Exception Unauthorized_Exception.
	 * @throws \RuntimeException Unable to retrieve the access or refresh token.
	 *
	 * @return string The access token.
	 */
	private function get_or_request_access_token( WP_User $user ): string {
		$access_jwt = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt', true );
		if ( ! \is_string( $access_jwt ) || $access_jwt === '' ) {
			$this->token_request( $user );
			$access_jwt = $this->ai_generator_helper->get_access_token( $user->ID );
		}
		elseif ( $this->ai_generator_helper->has_token_expired( $access_jwt ) ) {
			try {
				$this->token_refresh( $user );
			} catch ( Unauthorized_Exception $exception ) {
				$this->token_request( $user );
			} catch ( Forbidden_Exception $exception ) {
				// Follow the API in the consent being revoked (Use case: user sent an e-mail to revoke?).
				throw $this->handle_consent_revoked( $user->ID, $exception->getCode() );
			}
			$access_jwt = $this->ai_generator_helper->get_access_token( $user->ID );
		}

		return $access_jwt;
	}

	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

	/**
	 * Invalidates the access token.
	 *
	 * @param string $user_id The user ID.
	 *
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Bad_Request_Exception Bad_Request_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Internal_Server_Error_Exception Internal_Server_Error_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Not_Found_Exception Not_Found_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Payment_Required_Exception Payment_Required_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Request_Timeout_Exception Request_Timeout_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Service_Unavailable_Exception Service_Unavailable_Exception.
	 * @throws \Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Too_Many_Requests_Exception Too_Many_Requests_Exception.
	 * @throws \RuntimeException Unable to retrieve the access token.
	 *
	 * @return void
	 */
	private function token_invalidate( string $user_id ): void {
		try {
			$access_jwt = $this->ai_generator_helper->get_access_token( $user_id );
		} catch ( RuntimeException $e ) {
			$access_jwt = '';
		}

		$request_body    = [
			'user_id' => (string) $user_id,
		];
		$request_headers = [
			'Authorization' => "Bearer $access_jwt",
		];

		try {
			$this->ai_generator_helper->request( '/token/invalidate', $request_body, $request_headers );
		} catch ( Unauthorized_Exception | Forbidden_Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose.
			// We do nothing in this case, we trust nonce verification and try to remove the user data anyway.
			// I.e. we fallthrough to the same logic as if we got a 200 OK.
		}

		// Delete the stored JWT tokens.
		$this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_generator_access_jwt' );
		$this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_generator_refresh_jwt' );
	}

	/**
	 * Handles consent revoked.
	 *
	 * By deleting the consent user metadata from the database.
	 * And then throwing a Forbidden_Exception.
	 *
	 * @param int $user_id     The user ID.
	 * @param int $status_code The status code. Defaults to 403.
	 *
	 * @return Forbidden_Exception The Forbidden_Exception.
	 */
	private function handle_consent_revoked( int $user_id, int $status_code = 403 ): Forbidden_Exception {
		$this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' );

		return new Forbidden_Exception( 'CONSENT_REVOKED', $status_code );
	}
}