JezK
Edit File: management-route.php
<?php // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. namespace Yoast\WP\SEO\MyYoast_Client\User_Interface; use Throwable; use WP_REST_Request; use WP_REST_Response; use Yoast\WP\SEO\Conditionals\MyYoast_Connection_Conditional; use Yoast\WP\SEO\Main; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Authorization_Flow_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Discovery_Failed_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Rate_Limited_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Failed_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Not_Found_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Request_Failed_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Storage_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\MyYoast_Client; use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface; use Yoast\WP\SEO\MyYoast_Client\Domain\Exceptions\Invalid_Resource_Exception; use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Issuer_Config; use Yoast\WP\SEO\Routes\Route_Interface; use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface; use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait; use YoastSEO_Vendor\Psr\Log\NullLogger; /** * REST endpoints for managing the site's MyYoast OAuth client registration. * * UI-side counterpart to `wp yoast auth` — every endpoint dispatches to the * same `MyYoast_Client` facade and returns the refreshed status payload on * success so the client can update its local state without a follow-up GET. */ class Management_Route implements Route_Interface, LoggerAwareInterface { use LoggerAwareTrait; public const ROUTE_NAMESPACE = Main::API_V1_NAMESPACE; public const ROUTE_PREFIX = '/myyoast'; public const STATUS_ROUTE = '/status'; public const REFRESH_STATUS_ROUTE = '/refresh-status'; public const REGISTER_ROUTE = '/register'; public const REGISTRATION_ROUTE = '/registration'; public const AUTHORIZE_ROUTE = '/authorize'; /** * How long a successful upstream status refresh suppresses further upstream * calls, in seconds. The integrations page auto-refreshes on every load, and * MyYoast rate-limits the RFC 7592 read aggressively, so we throttle our own * calls. This caches no response data — only the fact that we checked — so it * does not conflict with the endpoint's no-store header. * * @var int */ private const REFRESH_THROTTLE_TTL_IN_SECONDS = \HOUR_IN_SECONDS; /** * Transient key prefix for the refresh throttle marker. Suffixed with the * issuer key so switching issuers does not carry the marker across. * * @var string */ private const REFRESH_THROTTLE_TRANSIENT_PREFIX = 'wpseo_myyoast_refresh_throttle'; /** * The MyYoast client facade. * * @var MyYoast_Client */ private $myyoast_client; /** * The status presenter. * * @var Status_Presenter */ private $status_presenter; /** * The issuer configuration. * * @var Issuer_Config */ private $issuer_config; /** * The client registration port. * * @var Client_Registration_Interface */ private $client_registration; /** * The connection-management permission check. * * @var Connection_Permission */ private $connection_permission; /** * Management_Route constructor. * * @param MyYoast_Client $myyoast_client The MyYoast client facade. * @param Status_Presenter $status_presenter The status presenter. * @param Issuer_Config $issuer_config The issuer configuration. * @param Client_Registration_Interface $client_registration The client registration port. * @param Connection_Permission $connection_permission The connection-management permission check. */ public function __construct( MyYoast_Client $myyoast_client, Status_Presenter $status_presenter, Issuer_Config $issuer_config, Client_Registration_Interface $client_registration, Connection_Permission $connection_permission ) { $this->myyoast_client = $myyoast_client; $this->status_presenter = $status_presenter; $this->issuer_config = $issuer_config; $this->client_registration = $client_registration; $this->connection_permission = $connection_permission; $this->logger = new NullLogger(); } /** * Returns the conditionals on which this route should be registered. * * @return array<string> */ public static function get_conditionals() { return [ MyYoast_Connection_Conditional::class ]; } /** * Registers the routes with WordPress. * * @return void */ public function register_routes() { $permission_callback = [ $this, 'can_manage' ]; \register_rest_route( Main::API_V1_NAMESPACE, self::ROUTE_PREFIX . self::STATUS_ROUTE, [ 'methods' => 'GET', 'callback' => [ $this, 'get_status' ], 'permission_callback' => $permission_callback, ], ); \register_rest_route( Main::API_V1_NAMESPACE, self::ROUTE_PREFIX . self::REFRESH_STATUS_ROUTE, [ 'methods' => 'POST', 'callback' => [ $this, 'refresh_status' ], 'permission_callback' => $permission_callback, ], ); \register_rest_route( Main::API_V1_NAMESPACE, self::ROUTE_PREFIX . self::REGISTER_ROUTE, [ 'methods' => 'POST', 'callback' => [ $this, 'register' ], 'permission_callback' => $permission_callback, ], ); \register_rest_route( Main::API_V1_NAMESPACE, self::ROUTE_PREFIX . self::REGISTRATION_ROUTE, [ [ 'methods' => 'PUT', 'callback' => [ $this, 'update_registration' ], 'permission_callback' => $permission_callback, ], [ 'methods' => 'DELETE', 'callback' => [ $this, 'deregister' ], 'permission_callback' => $permission_callback, ], ], ); \register_rest_route( Main::API_V1_NAMESPACE, self::ROUTE_PREFIX . self::AUTHORIZE_ROUTE, [ 'methods' => 'POST', 'callback' => [ $this, 'authorize' ], 'permission_callback' => $permission_callback, 'args' => [ 'return_url' => [ 'type' => 'string', 'required' => false, 'description' => 'URL to send the browser back to once the flow completes. Validated against the site host; an invalid or off-site URL is ignored.', 'sanitize_callback' => 'esc_url_raw', ], ], ], ); } /** * Permission callback for every endpoint. * * @return bool */ public function can_manage() { return $this->connection_permission->can_manage(); } /** * GET /myyoast/status — returns the current status payload. * * @return WP_REST_Response */ public function get_status() { return $this->respond_with_connection_status( 200, null ); } /** * POST /myyoast/refresh-status — refreshes the registration status against the server. * * Throttled: a successful upstream refresh suppresses further upstream calls * for an hour. Within that window the call is skipped and the locally-derived * status is returned unchanged, so a page reload does not hit MyYoast's rate * limit. The upstream response body is never stored — only the throttle marker. * * @return WP_REST_Response */ public function refresh_status() { if ( \get_transient( $this->get_refresh_throttle_key() ) !== false ) { return $this->respond_with_connection_status( 200, null ); } try { $this->myyoast_client->refresh_registration_status(); } catch ( Throwable $e ) { return $this->handle_exception( $e ); } // Mark only on success: a failed or rate-limited attempt must not suppress the next retry. \set_transient( $this->get_refresh_throttle_key(), 1, self::REFRESH_THROTTLE_TTL_IN_SECONDS ); return $this->respond_with_connection_status( 200, null ); } /** * POST /myyoast/register — connects the site to MyYoast. * * @return WP_REST_Response */ public function register() { $gate = $this->require_provisioned(); if ( $gate !== null ) { return $gate; } try { $this->myyoast_client->ensure_registered(); } catch ( Throwable $e ) { return $this->handle_exception( $e ); } $this->clear_refresh_throttle(); return $this->respond_with_connection_status( 200, 'connect_success' ); } /** * PUT /myyoast/registration — re-syncs the connection's redirect URIs. * * Used to recover the connection after the site's URL has changed. The client * resolves the current redirect URIs itself and updates the registration in * place (RFC 7592 PUT) when the set differs from what is stored. * * @return WP_REST_Response */ public function update_registration() { $gate = $this->require_provisioned(); if ( $gate !== null ) { return $gate; } try { $this->myyoast_client->ensure_registered(); } catch ( Throwable $e ) { return $this->handle_exception( $e ); } $this->clear_refresh_throttle(); return $this->respond_with_connection_status( 200, 'update_success' ); } /** * POST /myyoast/authorize — starts the authorization-code flow and returns * the URL the browser should be sent to. * * Completing the round-trip verifies that the site's redirect URI is * reachable and that the user is who they claim to be. The client resolves * the redirect URI itself, and the authorization-code handler marks it * validated once the returning code is exchanged. * * The optional `return_url` is where the browser is sent once the flow * completes; the caller supplies it because the flow can be started from * different admin pages. It is validated against the site's own host, so an * off-site or tampered value is dropped (and the callback then surfaces a * standalone outcome rather than redirecting anywhere). * * @param WP_REST_Request $request The REST request. * * @return WP_REST_Response */ public function authorize( WP_REST_Request $request ): WP_REST_Response { if ( $this->client_registration->get_registered_client() === null ) { return $this->error_response( 'registration_gone' ); } $user_id = \get_current_user_id(); if ( $user_id <= 0 ) { // Return HTTP 200 with the error_code in the body like every other failure here: // api-fetch rejects non-2xx, which would mask invalid_user as a generic unexpected_error. return $this->error_response( 'invalid_user' ); } $return_url = $this->resolve_return_url( $request->get_param( 'return_url' ) ); try { $authorize_url = $this->myyoast_client->get_authorization_url( $user_id, [ 'openid' ], null, $return_url, ); } catch ( Authorization_Flow_Exception $e ) { return $this->error_response( 'registration_failed', $e ); } catch ( Invalid_Resource_Exception $e ) { return $this->handle_exception( $e ); } $body = [ 'authorize_url' => $authorize_url, 'status' => $this->status_presenter->present(), ]; return new WP_REST_Response( $body, 200 ); } /** * DELETE /myyoast/registration — disconnects the site server-side and locally. * * @return WP_REST_Response */ public function deregister() { // Disconnect is best-effort on the server but always authoritative // locally: whatever happens with the remote RFC 7592 DELETE, the site // ends up disconnected here. An orphaned server-side client is cleaned up // automatically by MyYoast. deregister() already clears the local // registration and returns false (rather than throwing) on transport // failure. $remote_cleared = false; try { $remote_cleared = $this->myyoast_client->deregister(); } catch ( Throwable $e ) { $this->logger->warning( 'Unexpected error during MyYoast deregistration; disconnecting locally anyway: {error}', [ 'error' => $e->getMessage() ], ); } finally { // Always clear site tokens, even when the remote call threw, so the // site is never left half-connected. $this->myyoast_client->clear_all_site_tokens(); } if ( ! $remote_cleared ) { $this->logger->warning( 'MyYoast server-side deregistration was not confirmed; the site was disconnected locally.' ); } $this->clear_refresh_throttle(); return $this->respond_with_connection_status( 200, 'disconnect_success' ); } /** * Validates a caller-supplied return URL against the site's own host. * * The return URL is optional: callers that have nowhere meaningful to send * the user back to omit it. Anything off-site or otherwise invalid is treated * as absent rather than rewritten to a default — `wp_validate_redirect()` with * an empty fallback yields an empty string, which we normalize to null. The * callback re-validates the stored value before redirecting, so this is the * first of two gates against an open redirect. * * @param string|null $return_url The sanitized `return_url` request parameter (the route's * args schema coerces it to a string; absent when not sent). * * @return string|null The validated same-host URL, or null when none applies. */ private function resolve_return_url( ?string $return_url ): ?string { if ( $return_url === null || $return_url === '' ) { return null; } $validated = \wp_validate_redirect( $return_url, '' ); return ( $validated === '' ) ? null : $validated; } /** * Returns a "not provisioned" response when SS or IAT is empty. * * @return WP_REST_Response|null Response when blocked, null otherwise. */ private function require_provisioned(): ?WP_REST_Response { if ( $this->is_provisioned() ) { return null; } return $this->error_response( 'not_provisioned' ); } /** * Whether the plugin is provisioned for OAuth (software statement + IAT). * * @return bool */ private function is_provisioned(): bool { return ( $this->issuer_config->get_software_statement() !== '' ) && ( $this->issuer_config->get_initial_access_token() !== '' ); } /** * Maps an exception to a REST error response. * * The REST endpoint itself executed correctly — what failed is an upstream * call to MyYoast or a precondition. We therefore return HTTP 200 with an * `error_code` in the body that the UI translates into actionable copy. * Genuine request-validation failures return 4xx separately (see callers). * * @param Throwable $exception The exception to handle. * * @return WP_REST_Response */ private function handle_exception( Throwable $exception ): WP_REST_Response { if ( $exception instanceof Registration_Not_Found_Exception ) { return $this->error_response( 'registration_gone', $exception ); } if ( $exception instanceof Rate_Limited_Exception ) { $retry_after = $exception->get_retry_after_seconds(); $details = ( $retry_after !== null ) ? [ 'retry_after_seconds' => $retry_after ] : []; return $this->error_response( 'rate_limited', $exception, 200, $details ); } if ( $exception instanceof Server_Capability_Exception ) { return $this->error_response( 'server_capability', $exception ); } if ( $exception instanceof Discovery_Failed_Exception ) { return $this->error_response( 'myyoast_unreachable', $exception ); } if ( $exception instanceof Token_Request_Failed_Exception ) { $code = ( $exception->get_error_code() === 'invalid_grant' ) ? 'token_request_failed_invalid_grant' : 'token_request_failed'; return $this->error_response( $code, $exception ); } if ( $exception instanceof Token_Storage_Exception ) { return $this->error_response( 'token_storage_failed', $exception ); } if ( $exception instanceof Invalid_Resource_Exception ) { return $this->error_response( 'invalid_resource', $exception ); } if ( $exception instanceof Registration_Failed_Exception ) { return $this->error_response( 'registration_failed', $exception ); } $this->logger->error( 'Unexpected exception in MyYoast management route: {message}', [ 'message' => $exception->getMessage() ], ); return $this->error_response( 'unexpected_error', $exception ); } /** * Returns the issuer-scoped transient key for the refresh throttle marker. * * @return string The transient key. */ private function get_refresh_throttle_key(): string { return \sprintf( '%s_%s', self::REFRESH_THROTTLE_TRANSIENT_PREFIX, $this->issuer_config->get_issuer_key(), ); } /** * Clears the refresh throttle marker so the next status read hits the server. * * Called after any endpoint that changes the registration (connect, re-sync, * disconnect): the throttle exists only to spare MyYoast's rate limit on * unchanged status, so a deliberate state change must invalidate it. * * @return void */ private function clear_refresh_throttle(): void { \delete_transient( $this->get_refresh_throttle_key() ); } /** * Builds a successful response carrying the refreshed status payload. * * @param int $status The HTTP status. * @param string|null $message_key The key in the i18n message map for the success notice, or null when none applies. * * @return WP_REST_Response */ private function respond_with_connection_status( int $status, ?string $message_key ): WP_REST_Response { $body = [ 'status' => $this->status_presenter->present(), ]; if ( $message_key !== null ) { $body['message_key'] = $message_key; } return new WP_REST_Response( $body, $status ); } /** * Builds an error response. * * Defaults to HTTP 200 — the REST endpoint succeeded; the failure is in * an upstream call or precondition, and the UI keys off `error_code`, * not the HTTP status. Genuine 4xx (e.g. validation failures) pass an * explicit status. * * @param string $error_code The machine-readable error code (looked up client-side in the i18n map). * @param Throwable|null $exception Optional exception (logged when present). * @param int $status The HTTP status. Defaults to 200. * @param array<string, scalar> $details Optional extra fields the UI may use to enrich the error message. * * @return WP_REST_Response */ private function error_response( string $error_code, ?Throwable $exception = null, int $status = 200, array $details = [] ): WP_REST_Response { if ( $exception !== null ) { $this->logger->warning( 'MyYoast management error ({code}): {message}', [ 'code' => $error_code, 'message' => $exception->getMessage(), ], ); } $body = [ 'error_code' => $error_code, 'status' => $this->status_presenter->present(), ]; if ( $details !== [] ) { $body['details'] = $details; } return new WP_REST_Response( $body, $status ); } }