Phase 5: Content and SEO - Yoast SEO, Schema.org markup, Open Graph, favicon support, XML sitemap

This commit is contained in:
Hanson.xyz Dev
2025-11-28 17:10:24 -06:00
parent c4f29a3152
commit 91de533da4
1552 changed files with 171432 additions and 7 deletions
@@ -0,0 +1,33 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Application;
use Yoast\WP\SEO\AI_Authorization\Domain\Code_Verifier;
/**
* Interface Code_Verifier_Handler_Interface
*
* This interface defines the methods for handling code verifier.
*/
interface Code_Verifier_Handler_Interface {
/**
* Generate a code verifier for a user.
*
* @param string $user_email The user email.
*
* @return Code_Verifier The generated code verifier.
*/
public function generate( string $user_email ): Code_Verifier;
/**
* Validate the code verifier for a user.
*
* @param int $user_id The user ID.
*
* @return string The code verifier.
*
* @throws RuntimeException If the code verifier is expired or invalid.
*/
public function validate( int $user_id ): string;
}
@@ -0,0 +1,75 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Application;
use RuntimeException;
use Yoast\WP\SEO\AI_Authorization\Domain\Code_Verifier;
use Yoast\WP\SEO\AI_Authorization\Infrastructure\Code_Verifier_User_Meta_Repository;
use Yoast\WP\SEO\Helpers\Date_Helper;
/**
* Class Code_Verifier_Service
* Handles the generation and validation of code verifiers for users.
*/
class Code_Verifier_Handler implements Code_Verifier_Handler_Interface {
private const VALIDITY_IN_SECONDS = 300; // 5 minutes
/**
* The date helper.
*
* @var Date_Helper
*/
private $date_helper;
/**
* The code verifier repository.
*
* @var Code_Verifier_User_Meta_Repository
*/
private $code_verifier_repository;
/**
* Code_Verifier_Service constructor.
*
* @param Date_Helper $date_helper The date helper.
* @param Code_Verifier_User_Meta_Repository $code_verifier_repository The code verifier repository.
*/
public function __construct( Date_Helper $date_helper, Code_Verifier_User_Meta_Repository $code_verifier_repository ) {
$this->date_helper = $date_helper;
$this->code_verifier_repository = $code_verifier_repository;
}
/**
* Generate a code verifier for a user.
*
* @param string $user_email The user email.
*
* @return Code_Verifier The generated code verifier.
*/
public function generate( string $user_email ): Code_Verifier {
$random_string = \substr( \str_shuffle( '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' ), 1, 10 );
$code = \hash( 'sha256', $user_email . $random_string );
$created_at = $this->date_helper->current_time();
return new Code_Verifier( $code, $created_at );
}
/**
* Validate the code verifier for a user.
*
* @param int $user_id The user ID.
*
* @return string The code verifier.
*
* @throws RuntimeException If the code verifier is expired or invalid.
*/
public function validate( int $user_id ): string {
$code_verifier = $this->code_verifier_repository->get_code_verifier( $user_id );
if ( $code_verifier === null || $code_verifier->is_expired( self::VALIDITY_IN_SECONDS ) ) {
$this->code_verifier_repository->delete_code_verifier( $user_id );
throw new RuntimeException( 'Code verifier has expired or is invalid.' );
}
return $code_verifier->get_code();
}
}
@@ -0,0 +1,113 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Application;
use RuntimeException;
use WP_User;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Bad_Request_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Forbidden_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Not_Found_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Payment_Required_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Request_Timeout_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Service_Unavailable_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Too_Many_Requests_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Unauthorized_Exception;
/**
* Interface Token_Manager_Interface
*/
interface Token_Manager_Interface {
/**
* Invalidates the access token.
*
* @param string $user_id The user ID.
*
* @return void
*
* @throws Bad_Request_Exception Bad_Request_Exception.
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
* @throws Not_Found_Exception Not_Found_Exception.
* @throws Payment_Required_Exception Payment_Required_Exception.
* @throws Request_Timeout_Exception Request_Timeout_Exception.
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
* @throws RuntimeException Unable to retrieve the access token.
*/
public function token_invalidate( string $user_id ): void;
/**
* 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.
*
* @return void
*
* @throws Bad_Request_Exception Bad_Request_Exception.
* @throws Forbidden_Exception Forbidden_Exception.
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
* @throws Not_Found_Exception Not_Found_Exception.
* @throws Payment_Required_Exception Payment_Required_Exception.
* @throws Request_Timeout_Exception Request_Timeout_Exception.
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
* @throws Unauthorized_Exception Unauthorized_Exception.
*/
public function token_request( WP_User $user ): void;
/**
* 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.
*
* @return void
*
* @throws Bad_Request_Exception Bad_Request_Exception.
* @throws Forbidden_Exception Forbidden_Exception.
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
* @throws Not_Found_Exception Not_Found_Exception.
* @throws Payment_Required_Exception Payment_Required_Exception.
* @throws Request_Timeout_Exception Request_Timeout_Exception.
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
* @throws Unauthorized_Exception Unauthorized_Exception.
* @throws RuntimeException Unable to retrieve the refresh token.
*/
public function token_refresh( WP_User $user ): void;
/**
* Checks whether the token has expired.
*
* @param string $jwt The JWT.
*
* @return bool Whether the token has expired.
*/
public function has_token_expired( string $jwt ): bool;
/**
* Retrieves the access token.
*
* @param WP_User $user The WP user.
*
* @return string The access token.
*
* @throws Bad_Request_Exception Bad_Request_Exception.
* @throws Forbidden_Exception Forbidden_Exception.
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
* @throws Not_Found_Exception Not_Found_Exception.
* @throws Payment_Required_Exception Payment_Required_Exception.
* @throws Request_Timeout_Exception Request_Timeout_Exception.
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
* @throws Unauthorized_Exception Unauthorized_Exception.
* @throws RuntimeException Unable to retrieve the access or refresh token.
*/
public function get_or_request_access_token( WP_User $user ): string;
}
@@ -0,0 +1,333 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Application;
use RuntimeException;
use WP_User;
use WPSEO_Utils;
use Yoast\WP\SEO\AI_Authorization\Infrastructure\Access_Token_User_Meta_Repository_Interface;
use Yoast\WP\SEO\AI_Authorization\Infrastructure\Code_Verifier_User_Meta_Repository;
use Yoast\WP\SEO\AI_Authorization\Infrastructure\Refresh_Token_User_Meta_Repository_Interface;
use Yoast\WP\SEO\AI_Consent\Application\Consent_Handler;
use Yoast\WP\SEO\AI_Generator\Infrastructure\WordPress_URLs;
use Yoast\WP\SEO\AI_HTTP_Request\Application\Request_Handler;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Bad_Request_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Forbidden_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Not_Found_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Payment_Required_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Request_Timeout_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Service_Unavailable_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Too_Many_Requests_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Unauthorized_Exception;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Request;
use Yoast\WP\SEO\Helpers\User_Helper;
/**
* Class Token_Manager
* Handles the management of JWT tokens used in the authorization process.
*
* @makePublic
*/
class Token_Manager implements Token_Manager_Interface {
/**
* The access token repository.
*
* @var Access_Token_User_Meta_Repository_Interface
*/
private $access_token_repository;
/**
* The code verifier service.
*
* @var Code_Verifier_Handler
*/
private $code_verifier;
/**
* The consent handler.
*
* @var Consent_Handler
*/
private $consent_handler;
/**
* The refresh token repository.
*
* @var Refresh_Token_User_Meta_Repository_Interface
*/
private $refresh_token_repository;
/**
* The user helper.
*
* @var User_Helper
*/
private $user_helper;
/**
* The code verifier repository.
*
* @var Code_Verifier_User_Meta_Repository
*/
private $code_verifier_repository;
/**
* The URLs service.
*
* @var WordPress_URLs
*/
private $urls;
/**
* The request handler.
*
* @var Request_Handler
*/
private $request_handler;
/**
* Token_Manager constructor.
*
* @param Access_Token_User_Meta_Repository_Interface $access_token_repository The access token repository.
* @param Code_Verifier_Handler $code_verifier The code verifier service.
* @param Consent_Handler $consent_handler The consent handler.
* @param Refresh_Token_User_Meta_Repository_Interface $refresh_token_repository The refresh token repository.
* @param User_Helper $user_helper The user helper.
* @param Request_Handler $request_handler The request handler.
* @param Code_Verifier_User_Meta_Repository $code_verifier_repository The code verifier repository.
* @param WordPress_URLs $urls The URLs service.
*/
public function __construct(
Access_Token_User_Meta_Repository_Interface $access_token_repository,
Code_Verifier_Handler $code_verifier,
Consent_Handler $consent_handler,
Refresh_Token_User_Meta_Repository_Interface $refresh_token_repository,
User_Helper $user_helper,
Request_Handler $request_handler,
Code_Verifier_User_Meta_Repository $code_verifier_repository,
WordPress_URLs $urls
) {
$this->access_token_repository = $access_token_repository;
$this->code_verifier = $code_verifier;
$this->consent_handler = $consent_handler;
$this->refresh_token_repository = $refresh_token_repository;
$this->user_helper = $user_helper;
$this->request_handler = $request_handler;
$this->code_verifier_repository = $code_verifier_repository;
$this->urls = $urls;
}
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods.
/**
* Invalidates the access token.
*
* @param string $user_id The user ID.
*
* @return void
*
* @throws Bad_Request_Exception Bad_Request_Exception.
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
* @throws Not_Found_Exception Not_Found_Exception.
* @throws Payment_Required_Exception Payment_Required_Exception.
* @throws Request_Timeout_Exception Request_Timeout_Exception.
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
* @throws RuntimeException Unable to retrieve the access token.
*/
public function token_invalidate( string $user_id ): void {
try {
$access_jwt = $this->access_token_repository->get_token( $user_id );
} catch ( RuntimeException $e ) {
$access_jwt = '';
}
$request_body = [
'user_id' => (string) $user_id,
];
$request_headers = [
'Authorization' => "Bearer $access_jwt",
];
try {
$this->request_handler->handle(
new Request(
'/token/invalidate',
$request_body,
$request_headers
)
);
} catch ( Unauthorized_Exception | Forbidden_Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose.
// If the credentials in our request were already invalid, our job is done and we continue to remove the tokens client-side.
}
// 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' );
}
/**
* 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.
*
* @return void
*
* @throws Bad_Request_Exception Bad_Request_Exception.
* @throws Forbidden_Exception Forbidden_Exception.
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
* @throws Not_Found_Exception Not_Found_Exception.
* @throws Payment_Required_Exception Payment_Required_Exception.
* @throws Request_Timeout_Exception Request_Timeout_Exception.
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
* @throws Unauthorized_Exception Unauthorized_Exception.
*/
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' ) {
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive.
$this->consent_handler->revoke_consent( $user->ID );
throw new Forbidden_Exception( 'CONSENT_REVOKED', 403 );
// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
// Generate a code verifier and store it in the database.
$code_verifier = $this->code_verifier->generate( $user->user_email );
$this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );
$request_body = [
'service' => 'openai',
'code_challenge' => \hash( 'sha256', $code_verifier->get_code() ),
'license_site_url' => WPSEO_Utils::get_home_url(),
'user_id' => (string) $user->ID,
'callback_url' => $this->urls->get_callback_url(),
'refresh_callback_url' => $this->urls->get_refresh_callback_url(),
];
$this->request_handler->handle( new 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.
*
* @return void
*
* @throws Bad_Request_Exception Bad_Request_Exception.
* @throws Forbidden_Exception Forbidden_Exception.
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
* @throws Not_Found_Exception Not_Found_Exception.
* @throws Payment_Required_Exception Payment_Required_Exception.
* @throws Request_Timeout_Exception Request_Timeout_Exception.
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
* @throws Unauthorized_Exception Unauthorized_Exception.
* @throws RuntimeException Unable to retrieve the refresh token.
*/
public function token_refresh( WP_User $user ): void {
$refresh_jwt = $this->refresh_token_repository->get_token( $user->ID );
// Generate a code verifier and store it in the database.
$code_verifier = $this->code_verifier->generate( $user->ID, $user->user_email );
$this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );
$request_body = [
'code_challenge' => \hash( 'sha256', $code_verifier->get_code() ),
];
$request_headers = [
'Authorization' => "Bearer $refresh_jwt",
];
$this->request_handler->handle( new 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' );
}
/**
* Checks whether the token has expired.
*
* @param string $jwt The JWT.
*
* @return bool Whether the token has expired.
*/
public function has_token_expired( string $jwt ): bool {
$parts = \explode( '.', $jwt );
if ( \count( $parts ) !== 3 ) {
// Headers, payload and signature parts are not detected.
return true;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Reason: Decoding the payload of the JWT.
$payload = \base64_decode( $parts[1] );
$json = \json_decode( $payload );
if ( $json === null || ! isset( $json->exp ) ) {
return true;
}
// Ensure exp is a valid numeric value.
if ( ! \is_numeric( $json->exp ) ) {
return true;
}
return $json->exp < \time();
}
/**
* Retrieves the access token.
*
* @param WP_User $user The WP user.
*
* @return string The access token.
*
* @throws Bad_Request_Exception Bad_Request_Exception.
* @throws Forbidden_Exception Forbidden_Exception.
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
* @throws Not_Found_Exception Not_Found_Exception.
* @throws Payment_Required_Exception Payment_Required_Exception.
* @throws Request_Timeout_Exception Request_Timeout_Exception.
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
* @throws Unauthorized_Exception Unauthorized_Exception.
* @throws RuntimeException Unable to retrieve the access or refresh token.
*/
public 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->access_token_repository->get_token( $user->ID );
}
elseif ( $this->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?).
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive.
$this->consent_handler->revoke_consent( $user->ID );
throw new Forbidden_Exception( 'CONSENT_REVOKED', 403 );
// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
$access_jwt = $this->access_token_repository->get_token( $user->ID );
}
return $access_jwt;
}
// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
}
@@ -0,0 +1,64 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Domain;
/**
* Class Code_Verifier representing a challenge code and its creation time.
* This is used during the authorization process to verify the user requesting a token.
*/
class Code_Verifier {
/**
* The code.
*
* @var string
*/
private $code;
/**
* The time the code was created.
*
* @var int
*/
private $created_at;
/**
* Code_Verifier constructor.
*
* @param string $code The code.
* @param int $created_at The time the code was created.
*/
public function __construct( string $code, int $created_at ) {
$this->code = $code;
$this->created_at = $created_at;
}
/**
* Get the code.
*
* @return string The code.
*/
public function get_code(): string {
return $this->code;
}
/**
* Get the creation time of the code.
*
* @return int The creation time of the code.
*/
public function get_created_at(): int {
return $this->created_at;
}
/**
* Check if the code is expired.
*
* @param int $validity_in_seconds The validity of the code in seconds.
*
* @return bool True if the code is expired, false otherwise.
*/
public function is_expired( int $validity_in_seconds ): bool {
return $this->created_at < ( \time() - $validity_in_seconds );
}
}
@@ -0,0 +1,53 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Domain;
/**
* Class Token
* Represents a token used for authentication with the AI Generator API.
*/
class Token {
/**
* The token value.
*
* @var string
*/
private $value;
/**
* The expiration time.
*
* @var int
*/
private $expiration;
/**
* Token constructor.
*
* @param string $value The token value.
* @param int $expiration The expiration time.
*/
public function __construct( string $value, int $expiration ) {
$this->value = $value;
$this->expiration = $expiration;
}
/**
* Get the token value.
*
* @return string The token value.
*/
public function get_value(): string {
return $this->value;
}
/**
* Whether the token is expired.
*
* @return bool True if the token is expired, false otherwise.
*/
public function is_expired(): bool {
return $this->expiration < \time();
}
}
@@ -0,0 +1,12 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Infrastructure;
/**
* Interface Access_Token_User_Meta_Repository_Interface
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
interface Access_Token_User_Meta_Repository_Interface extends Token_User_Meta_Repository_Interface {
public const META_KEY = '_yoast_wpseo_ai_generator_access_jwt';
}
@@ -0,0 +1,73 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Infrastructure;
use RuntimeException;
use Yoast\WP\SEO\Helpers\User_Helper;
/**
* Class Access_Token_Repository
* Handles the storage and retrieval of access tokens for users.
*/
class Access_Token_User_Meta_Repository implements Access_Token_User_Meta_Repository_Interface {
/**
* The user helper.
*
* @var User_Helper
*/
private $user_helper;
/**
* Access_Token_Repository constructor.
*
* @param User_Helper $user_helper The user helper.
*/
public function __construct( User_Helper $user_helper ) {
$this->user_helper = $user_helper;
}
/**
* Get the token for a user.
*
* @param int $user_id The user ID.
*
* @return string The token data.
*
* @throws RuntimeException If the token is not found or invalid.
*/
public function get_token( int $user_id ): string {
$access_jwt = $this->user_helper->get_meta( $user_id, self::META_KEY, true );
if ( ! \is_string( $access_jwt ) || $access_jwt === '' ) {
throw new RuntimeException( 'Unable to retrieve the access token.' );
}
return $access_jwt;
}
/**
* Store the token for a user.
*
* @param int $user_id The user ID.
* @param string $value The token value.
*
* @return void
*/
public function store_token( int $user_id, string $value ): void {
$this->user_helper->update_meta(
$user_id,
self::META_KEY,
$value
); }
/**
* Delete the token for a user.
*
* @param int $user_id The user ID.
*
* @return void
*/
public function delete_token( int $user_id ): void {
$this->user_helper->delete_meta( $user_id, self::META_KEY );
}
}
@@ -0,0 +1,46 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Infrastructure;
use RuntimeException;
use Yoast\WP\SEO\AI_Authorization\Domain\Code_Verifier;
// phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
/**
* Interface for the Code Verifier User Meta Repository.
*
* This interface defines methods for managing code verifiers associated with users.
*/
interface Code_Verifier_User_Meta_Repository_Interface {
/**
* Get the verification code for a user.
*
* @param int $user_id The user ID.
*
* @throws RuntimeException If the code verifier is not found or has expired.
* @return Code_Verifier The verification code or null if not found.
*/
public function get_code_verifier( int $user_id ): ?Code_Verifier;
/**
* Store the verification code for a user.
*
* @param int $user_id The user ID.
* @param string $code The code verifier.
* @param int $created_at The time the code was created.
*
* @return void
*/
public function store_code_verifier( int $user_id, string $code, int $created_at ): void;
/**
* Delete the verification code for a user.
*
* @param int $user_id The user ID.
*
* @return void
*/
public function delete_code_verifier( int $user_id ): void;
}
//phpcs:enable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
@@ -0,0 +1,94 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Infrastructure;
use RuntimeException;
use Yoast\WP\SEO\AI_Authorization\Domain\Code_Verifier;
use Yoast\WP\SEO\Helpers\Date_Helper;
use Yoast\WP\SEO\Helpers\User_Helper;
/**
* Class Code_Verifier_Repository
*/
class Code_Verifier_User_Meta_Repository implements Code_Verifier_User_Meta_Repository_Interface {
private const CODE_VERIFIER_VALIDITY = 300; // 5 minutes
/**
* The date helper.
*
* @var Date_Helper
*/
private $date_helper;
/**
* The user helper.
*
* @var User_Helper
*/
private $user_helper;
/**
* Code_Verifier_Repository constructor.
*
* @param Date_Helper $date_helper The date helper.
* @param User_Helper $user_helper The user helper.
*/
public function __construct( Date_Helper $date_helper, User_Helper $user_helper ) {
$this->date_helper = $date_helper;
$this->user_helper = $user_helper;
}
/**
* Store the verification code for a user.
*
* @param int $user_id The user ID.
* @param string $code The code verifier.
* @param int $created_at The time the code was created.
*
* @return void
*/
public function store_code_verifier( int $user_id, string $code, int $created_at ): void {
$this->user_helper->update_meta(
$user_id,
'yoast_wpseo_ai_generator_code_verifier_for_blog_' . \get_current_blog_id(),
[
'code' => $code,
'created_at' => $created_at,
]
);
}
/**
* Get the verification code for a user.
*
* @param int $user_id The user ID.
*
* @throws RuntimeException If the code verifier is not found or has expired.
* @return Code_Verifier The verification code or null if not found.
*/
public function get_code_verifier( int $user_id ): ?Code_Verifier {
$data = $this->user_helper->get_meta( $user_id, 'yoast_wpseo_ai_generator_code_verifier_for_blog_' . \get_current_blog_id(), true );
if ( ! \is_array( $data ) || ! isset( $data['code'] ) || $data['code'] === '' ) {
throw new RuntimeException( 'Unable to retrieve the verification code.' );
}
if ( ! isset( $data['created_at'] ) || $data['created_at'] < ( $this->date_helper->current_time() - self::CODE_VERIFIER_VALIDITY ) ) {
$this->delete_code_verifier( $user_id );
throw new RuntimeException( 'Code verifier has expired.' );
}
return new Code_Verifier( $data['code'], $data['created_at'] );
}
/**
* Delete the verification code for a user.
*
* @param int $user_id The user ID.
*
* @return void
*/
public function delete_code_verifier( int $user_id ): void {
$this->user_helper->delete_meta( $user_id, 'yoast_wpseo_ai_generator_code_verifier_for_blog_' . \get_current_blog_id() );
}
}
@@ -0,0 +1,12 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Infrastructure;
/**
* Interface Refresh_Token_User_Meta_Repository_Interface
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
interface Refresh_Token_User_Meta_Repository_Interface extends Token_User_Meta_Repository_Interface {
public const META_KEY = '_yoast_wpseo_ai_generator_refresh_jwt';
}
@@ -0,0 +1,73 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Infrastructure;
use RuntimeException;
use Yoast\WP\SEO\Helpers\User_Helper;
/**
* Class Refresh_Token_Repository
* Handles the storage and retrieval of refresh tokens for users.
*/
class Refresh_Token_User_Meta_Repository implements Refresh_Token_User_Meta_Repository_Interface {
/**
* The user helper.
*
* @var User_Helper
*/
private $user_helper;
/**
* Refresh_Token_Repository constructor.
*
* @param User_Helper $user_helper The user helper.
*/
public function __construct( User_Helper $user_helper ) {
$this->user_helper = $user_helper;
}
/**
* Get the token for a user.
*
* @param int $user_id The user ID.
*
* @return string The token data.
*
* @throws RuntimeException If the token is not found or invalid.
*/
public function get_token( int $user_id ): string {
$refresh_jwt = $this->user_helper->get_meta( $user_id, self::META_KEY, true );
if ( ! \is_string( $refresh_jwt ) || $refresh_jwt === '' ) {
throw new RuntimeException( 'Unable to retrieve the refresh token.' );
}
return $refresh_jwt;
}
/**
* Store the token for a user.
*
* @param int $user_id The user ID.
* @param string $value The token value.
*
* @return void
*/
public function store_token( int $user_id, string $value ): void {
$this->user_helper->update_meta(
$user_id,
self::META_KEY,
$value
); }
/**
* Delete the token for a user.
*
* @param int $user_id The user ID.
*
* @return void
*/
public function delete_token( int $user_id ): void {
$this->user_helper->delete_meta( $user_id, self::META_KEY );
}
}
@@ -0,0 +1,37 @@
<?php
namespace Yoast\WP\SEO\AI_Authorization\Infrastructure;
/**
* Interface Token_Repository_Interface
*/
interface Token_User_Meta_Repository_Interface {
/**
* Get the token for a user.
*
* @param int $user_id The user ID.
*
* @return string The token data.
*/
public function get_token( int $user_id ): string;
/**
* Store the token for a user.
*
* @param int $user_id The user ID.
* @param string $value The token value.
*
* @return void
*/
public function store_token( int $user_id, string $value ): void;
/**
* Delete the token for a user.
*
* @param int $user_id The user ID.
*
* @return void
*/
public function delete_token( int $user_id ): void;
}
@@ -0,0 +1,108 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_Authorization\User_Interface;
use RuntimeException;
use WP_REST_Request;
use WP_REST_Response;
use Yoast\WP\SEO\AI_Authorization\Infrastructure\Access_Token_User_Meta_Repository_Interface;
use Yoast\WP\SEO\AI_Authorization\Infrastructure\Code_Verifier_User_Meta_Repository_Interface;
use Yoast\WP\SEO\AI_Authorization\Infrastructure\Refresh_Token_User_Meta_Repository_Interface;
use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Unauthorized_Exception;
use Yoast\WP\SEO\Conditionals\AI_Conditional;
use Yoast\WP\SEO\Main;
use Yoast\WP\SEO\Routes\Route_Interface;
/**
* The base class for the callback routes.
*/
abstract class Abstract_Callback_Route implements Route_Interface {
/**
* The namespace for this route.
*
* @var string
*/
public const ROUTE_NAMESPACE = Main::API_V1_NAMESPACE;
/**
* The access token repository instance.
*
* @var Access_Token_User_Meta_Repository_Interface
*/
protected $access_token_repository;
/**
* The refresh token repository instance.
*
* @var Refresh_Token_User_Meta_Repository_Interface
*/
protected $refresh_token_repository;
/**
* The code verifier instance.
*
* @var Code_Verifier_User_Meta_Repository_Interface
*/
protected $code_verifier_repository;
/**
* Returns the conditionals based in which this loadable should be active.
*
* @return array<string> The conditionals.
*/
public static function get_conditionals() {
return [ AI_Conditional::class ];
}
/**
* Callback_Route constructor.
*
* @param Access_Token_User_Meta_Repository_Interface $access_token_repository The access token repository instance.
* @param Refresh_Token_User_Meta_Repository_Interface $refresh_token_repository The refresh token repository instance.
* @param Code_Verifier_User_Meta_Repository_Interface $code_verifier_repository The code verifier instance.
*/
public function __construct( Access_Token_User_Meta_Repository_Interface $access_token_repository, Refresh_Token_User_Meta_Repository_Interface $refresh_token_repository, Code_Verifier_User_Meta_Repository_Interface $code_verifier_repository ) {
$this->access_token_repository = $access_token_repository;
$this->refresh_token_repository = $refresh_token_repository;
$this->code_verifier_repository = $code_verifier_repository;
}
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods.
/**
* Runs the callback to store connection credentials and the tokens locally.
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The response of the callback action.
*
* @throws Unauthorized_Exception If the code challenge is not valid.
* @throws RuntimeException If the verification code is not found.
*/
public function callback( WP_REST_Request $request ): WP_REST_Response {
$user_id = $request->get_param( 'user_id' );
try {
$code_verifier = $this->code_verifier_repository->get_code_verifier( $user_id );
if ( $request->get_param( 'code_challenge' ) !== \hash( 'sha256', $code_verifier->get_code() ) ) {
throw new Unauthorized_Exception( 'Unauthorized' );
}
$this->access_token_repository->store_token( $user_id, $request->get_param( 'access_jwt' ) );
$this->refresh_token_repository->store_token( $user_id, $request->get_param( 'refresh_jwt' ) );
$this->code_verifier_repository->delete_code_verifier( $user_id );
} catch ( Unauthorized_Exception | RuntimeException $e ) {
return new WP_REST_Response( 'Unauthorized.', 401 );
}
return new WP_REST_Response(
[
'message' => 'Tokens successfully stored.',
'code_verifier' => $code_verifier->get_code(),
]
);
}
// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods.
}
@@ -0,0 +1,58 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_Authorization\User_Interface;
/**
* Registers the callback route used in the authorization process.
*
* @makePublic
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
class Callback_Route extends Abstract_Callback_Route {
/**
* The prefix for this route.
*
* @var string
*/
public const ROUTE_PREFIX = '/ai_generator/callback';
/**
* Registers routes with WordPress.
*
* @return void
*/
public function register_routes() {
\register_rest_route(
parent::ROUTE_NAMESPACE,
self::ROUTE_PREFIX,
[
'methods' => 'POST',
'args' => [
'access_jwt' => [
'required' => true,
'type' => 'string',
'description' => 'The access JWT.',
],
'refresh_jwt' => [
'required' => true,
'type' => 'string',
'description' => 'The JWT to be used when the access JWT needs to be refreshed.',
],
'code_challenge' => [
'required' => true,
'type' => 'string',
'description' => 'The SHA266 of the verification code used to check the authenticity of a callback call.',
],
'user_id' => [
'required' => true,
'type' => 'integer',
'description' => 'The id of the user associated to the code verifier.',
],
],
'callback' => [ $this, 'callback' ],
'permission_callback' => '__return_true',
]
);
}
}
@@ -0,0 +1,58 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\AI_Authorization\User_Interface;
/**
* Registers the callback route used in the authorization process.
*
* @makePublic
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
class Refresh_Callback_Route extends Abstract_Callback_Route {
/**
* The prefix for this route.
*
* @var string
*/
public const ROUTE_PREFIX = '/ai_generator/refresh_callback';
/**
* Registers routes with WordPress.
*
* @return void
*/
public function register_routes() {
\register_rest_route(
parent::ROUTE_NAMESPACE,
self::ROUTE_PREFIX,
[
'methods' => 'POST',
'args' => [
'access_jwt' => [
'required' => true,
'type' => 'string',
'description' => 'The access JWT.',
],
'refresh_jwt' => [
'required' => true,
'type' => 'string',
'description' => 'The JWT to be used when the access JWT needs to be refreshed.',
],
'code_challenge' => [
'required' => true,
'type' => 'string',
'description' => 'The SHA266 of the verification code used to check the authenticity of a callback call.',
],
'user_id' => [
'required' => true,
'type' => 'integer',
'description' => 'The id of the user associated to the code verifier.',
],
],
'callback' => [ $this, 'callback' ],
'permission_callback' => '__return_true',
]
);
}
}