| @@ -3,13 +3,29 @@ | ||
| 3 | 3 | // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. |
| 4 | 4 | |
| 5 | 5 | namespace Yoast\WP\SEO\AI\Consent\Application; |
| 6 | 6 | |
| 7 | +use RuntimeException; | |
| 8 | +use WP_User; | |
| 9 | +use Yoast\WP\SEO\AI\Authentication\Application\AI_Request_Sender_Factory; | |
| 10 | +use Yoast\WP\SEO\AI\Authorization\Application\Token_Manager; | |
| 11 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Bad_Request_Exception; | |
| 12 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Consent_Required_Exception; | |
| 13 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Forbidden_Exception; | |
| 14 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Insufficient_Scope_Exception; | |
| 15 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception; | |
| 16 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Not_Found_Exception; | |
| 17 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Payment_Required_Exception; | |
| 18 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Request_Timeout_Exception; | |
| 19 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Service_Unavailable_Exception; | |
| 20 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Too_Many_Requests_Exception; | |
| 21 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Unauthorized_Exception; | |
| 22 | +use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\WP_Request_Exception; | |
| 7 | 23 | use Yoast\WP\SEO\Helpers\User_Helper; |
| 8 | 24 | |
| 9 | 25 | /** |
| 10 | 26 | * Class Consent_Handler |
| 11 | - * Handles the consent given or revoked by the user. | |
| 27 | + * Handles the consent given or revoked by the user, both locally (user meta) and remotely (Yoast AI service). | |
| 12 | 28 | * |
| 13 | 29 | * @makePublic |
| 14 | 30 | */ |
| 15 | 31 | class Consent_Handler implements Consent_Handler_Interface { |
| @@ -21,34 +37,123 @@ | ||
| 21 | 37 | */ |
| 22 | 38 | private $user_helper; |
| 23 | 39 | |
| 24 | 40 | /** |
| 41 | + * The AI request sender factory, used to dispatch the consent calls through the active auth strategy. | |
| 42 | + * | |
| 43 | + * @var AI_Request_Sender_Factory | |
| 44 | + */ | |
| 45 | + private $ai_request_sender_factory; | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * The token manager, used to invalidate leftover legacy JWTs when consent is revoked. | |
| 49 | + * | |
| 50 | + * @var Token_Manager | |
| 51 | + */ | |
| 52 | + private $token_manager; | |
| 53 | + | |
| 54 | + /** | |
| 25 | 55 | * Class constructor. |
| 26 | 56 | * |
| 27 | - * @param User_Helper $user_helper The user helper. | |
| 57 | + * @param User_Helper $user_helper The user helper. | |
| 58 | + * @param AI_Request_Sender_Factory $ai_request_sender_factory The AI request sender factory. | |
| 59 | + * @param Token_Manager $token_manager The token manager. | |
| 28 | 60 | */ |
| 29 | - public function __construct( User_Helper $user_helper ) { | |
| 30 | - $this->user_helper = $user_helper; | |
| 61 | + public function __construct( | |
| 62 | + User_Helper $user_helper, | |
| 63 | + AI_Request_Sender_Factory $ai_request_sender_factory, | |
| 64 | + Token_Manager $token_manager | |
| 65 | + ) { | |
| 66 | + $this->user_helper = $user_helper; | |
| 67 | + $this->ai_request_sender_factory = $ai_request_sender_factory; | |
| 68 | + $this->token_manager = $token_manager; | |
| 31 | 69 | } |
| 32 | 70 | |
| 71 | + // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods. | |
| 72 | + | |
| 33 | 73 | /** |
| 34 | - * Handles consent revoked by deleting the consent user metadata from the database. | |
| 74 | + * Records the user's consent on the Yoast AI service and, on success, in the local user meta. | |
| 35 | 75 | * |
| 76 | + * Transactional: any HTTP-layer exception is propagated and the local meta is left untouched, so | |
| 77 | + * the local and server state stay in sync. | |
| 78 | + * | |
| 36 | 79 | * @param int $user_id The user ID. |
| 37 | 80 | * |
| 38 | 81 | * @return void |
| 82 | + * | |
| 83 | + * @throws Bad_Request_Exception When the AI service responds with 400. | |
| 84 | + * @throws Consent_Required_Exception When the AI service responds with a 403 indicating consent is required. | |
| 85 | + * @throws Insufficient_Scope_Exception When the AI service responds with a 403 insufficient_scope. | |
| 86 | + * @throws Forbidden_Exception When the AI service responds with any other 403. | |
| 87 | + * @throws Internal_Server_Error_Exception When the AI service responds with 500. | |
| 88 | + * @throws Not_Found_Exception When the AI service responds with 404. | |
| 89 | + * @throws Payment_Required_Exception When the AI service responds with 402. | |
| 90 | + * @throws Request_Timeout_Exception When the AI service responds with 408. | |
| 91 | + * @throws Service_Unavailable_Exception When the AI service responds with 503. | |
| 92 | + * @throws Too_Many_Requests_Exception When the AI service responds with 429. | |
| 93 | + * @throws Unauthorized_Exception When the AI service responds with 401. | |
| 94 | + * @throws WP_Request_Exception When the underlying WordPress HTTP call fails. | |
| 95 | + * @throws RuntimeException When the user is not found. | |
| 39 | 96 | */ |
| 40 | - public function revoke_consent( int $user_id ) { | |
| 41 | - $this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' ); | |
| 97 | + public function grant_consent( int $user_id ) { | |
| 98 | + $user = \get_user_by( 'id', $user_id ); | |
| 99 | + if ( ! $user instanceof WP_User ) { | |
| 100 | + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive. | |
| 101 | + throw new RuntimeException( "User not found: $user_id" ); | |
| 102 | + } | |
| 103 | + | |
| 104 | + $this->ai_request_sender_factory->create( $user )->grant_consent( $user ); | |
| 105 | + | |
| 106 | + $this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_consent', true ); | |
| 42 | 107 | } |
| 43 | 108 | |
| 44 | 109 | /** |
| 45 | - * Handles consent granted by adding the consent user metadata to the database. | |
| 110 | + * Revokes the user's consent on the Yoast AI service and clears the local user meta. | |
| 46 | 111 | * |
| 112 | + * Security-first: the local meta is always cleared before the remote call, so consent is | |
| 113 | + * revoked locally even if the remote `DELETE /user/consent` fails. Any locally stored legacy | |
| 114 | + * JWTs are then invalidated regardless of the remote outcome — credentials must not outlive | |
| 115 | + * consent. The invalidation runs after the DELETE on purpose: the legacy Token path may mint | |
| 116 | + * a fresh JWT to authenticate the DELETE, and invalidating afterwards catches that token too. | |
| 117 | + * Any HTTP-layer exception is propagated and its management is deferred to the caller. | |
| 118 | + * | |
| 47 | 119 | * @param int $user_id The user ID. |
| 48 | 120 | * |
| 49 | 121 | * @return void |
| 122 | + * | |
| 123 | + * @throws Bad_Request_Exception When the AI service responds with 400. | |
| 124 | + * @throws Consent_Required_Exception When the AI service responds with a 403 indicating consent is required. | |
| 125 | + * @throws Insufficient_Scope_Exception When the AI service responds with a 403 insufficient_scope. | |
| 126 | + * @throws Forbidden_Exception When the AI service responds with any other 403. | |
| 127 | + * @throws Internal_Server_Error_Exception When the AI service responds with 500. | |
| 128 | + * @throws Not_Found_Exception When the AI service responds with 404. | |
| 129 | + * @throws Payment_Required_Exception When the AI service responds with 402. | |
| 130 | + * @throws Request_Timeout_Exception When the AI service responds with 408. | |
| 131 | + * @throws Service_Unavailable_Exception When the AI service responds with 503. | |
| 132 | + * @throws Too_Many_Requests_Exception When the AI service responds with 429. | |
| 133 | + * @throws Unauthorized_Exception When the AI service responds with 401. | |
| 134 | + * @throws WP_Request_Exception When the underlying WordPress HTTP call fails. | |
| 135 | + * @throws RuntimeException When the user is not found. | |
| 50 | 136 | */ |
| 51 | - public function grant_consent( int $user_id ) { | |
| 52 | - $this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_consent', true ); | |
| 137 | + public function revoke_consent( int $user_id ) { | |
| 138 | + $user = \get_user_by( 'id', $user_id ); | |
| 139 | + if ( ! $user instanceof WP_User ) { | |
| 140 | + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive. | |
| 141 | + throw new RuntimeException( "User not found: $user_id" ); | |
| 142 | + } | |
| 143 | + // Local consent is always revoked regardless of remote failures. | |
| 144 | + $this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' ); | |
| 145 | + | |
| 146 | + try { | |
| 147 | + $this->ai_request_sender_factory->create( $user )->revoke_consent( $user ); | |
| 148 | + } finally { | |
| 149 | + // Invalidate the legacy JWTs — including ones minted to authenticate the DELETE above — | |
| 150 | + // so credentials never outlive consent. Skipped when no local JWTs exist (the OAuth path | |
| 151 | + // without a leftover pre-OAuth grant). | |
| 152 | + if ( $this->token_manager->has_local_tokens( $user_id ) ) { | |
| 153 | + $this->token_manager->token_invalidate( $user_id ); | |
| 154 | + } | |
| 155 | + } | |
| 53 | 156 | } |
| 157 | + | |
| 158 | + // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber | |
| 54 | 159 | } |