Yoast\WP\SEO\MyYoast_Client\User_Interface
Management_Route{} │ Yoast 1.0└─ Route_Interface, LoggerAwareInterface
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.
Хуков нет.
Использование
$Management_Route = new Management_Route(); // use class methods
Методы
- public __construct(
- public authorize( WP_REST_Request $request )
- public can_manage()
- public deregister()
- public static get_conditionals()
- public get_status()
- public refresh_status()
- public register()
- public register_routes()
- public update_registration()
- private clear_refresh_throttle()
- private error_response( string $error_code, ?Throwable $exception = null, int $status = 200, array $details = [] )
- private get_refresh_throttle_key()
- private handle_exception( Throwable $exception )
- private is_provisioned()
- private require_provisioned()
- private resolve_return_url( ?string $return_url )
- private respond_with_connection_status( int $status, ?string $message_key )
Код Management_Route{} Management Route{} Yoast 28.3
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 );
}
}