PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.2
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.2
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / myyoast-client / user-interface / management-route.php

management-route.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.2, at src/myyoast-client/user-interface/management-route.php

579 lines 18.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4
5 namespace Yoast\WP\SEO\MyYoast_Client\User_Interface;
6
7 use Throwable;
8 use WP_REST_Request;
9 use WP_REST_Response;
10 use Yoast\WP\SEO\Conditionals\MyYoast_Connection_Conditional;
11 use Yoast\WP\SEO\Main;
12 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Authorization_Flow_Exception;
13 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Discovery_Failed_Exception;
14 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Rate_Limited_Exception;
15 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Failed_Exception;
16 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Not_Found_Exception;
17 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception;
18 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Request_Failed_Exception;
19 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Storage_Exception;
20 use Yoast\WP\SEO\MyYoast_Client\Application\MyYoast_Client;
21 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
22 use Yoast\WP\SEO\MyYoast_Client\Domain\Exceptions\Invalid_Resource_Exception;
23 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Issuer_Config;
24 use Yoast\WP\SEO\Routes\Route_Interface;
25 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
26 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
27 use YoastSEO_Vendor\Psr\Log\NullLogger;
28
29 /**
30 * REST endpoints for managing the site's MyYoast OAuth client registration.
31 *
32 * UI-side counterpart to `wp yoast auth` — every endpoint dispatches to the
33 * same `MyYoast_Client` facade and returns the refreshed status payload on
34 * success so the client can update its local state without a follow-up GET.
35 */
36 class Management_Route implements Route_Interface, LoggerAwareInterface {
37
38 use LoggerAwareTrait;
39
40 public const ROUTE_NAMESPACE = Main::API_V1_NAMESPACE;
41
42 public const ROUTE_PREFIX = '/myyoast';
43
44 public const STATUS_ROUTE = '/status';
45 public const REFRESH_STATUS_ROUTE = '/refresh-status';
46 public const REGISTER_ROUTE = '/register';
47 public const REGISTRATION_ROUTE = '/registration';
48 public const AUTHORIZE_ROUTE = '/authorize';
49
50 /**
51 * How long a successful upstream status refresh suppresses further upstream
52 * calls, in seconds. The integrations page auto-refreshes on every load, and
53 * MyYoast rate-limits the RFC 7592 read aggressively, so we throttle our own
54 * calls. This caches no response data — only the fact that we checked — so it
55 * does not conflict with the endpoint's no-store header.
56 *
57 * @var int
58 */
59 private const REFRESH_THROTTLE_TTL_IN_SECONDS = \HOUR_IN_SECONDS;
60
61 /**
62 * Transient key prefix for the refresh throttle marker. Suffixed with the
63 * issuer key so switching issuers does not carry the marker across.
64 *
65 * @var string
66 */
67 private const REFRESH_THROTTLE_TRANSIENT_PREFIX = 'wpseo_myyoast_refresh_throttle';
68
69 /**
70 * The MyYoast client facade.
71 *
72 * @var MyYoast_Client
73 */
74 private $myyoast_client;
75
76 /**
77 * The status presenter.
78 *
79 * @var Status_Presenter
80 */
81 private $status_presenter;
82
83 /**
84 * The issuer configuration.
85 *
86 * @var Issuer_Config
87 */
88 private $issuer_config;
89
90 /**
91 * The client registration port.
92 *
93 * @var Client_Registration_Interface
94 */
95 private $client_registration;
96
97 /**
98 * The connection-management permission check.
99 *
100 * @var Connection_Permission
101 */
102 private $connection_permission;
103
104 /**
105 * Management_Route constructor.
106 *
107 * @param MyYoast_Client $myyoast_client The MyYoast client facade.
108 * @param Status_Presenter $status_presenter The status presenter.
109 * @param Issuer_Config $issuer_config The issuer configuration.
110 * @param Client_Registration_Interface $client_registration The client registration port.
111 * @param Connection_Permission $connection_permission The connection-management permission check.
112 */
113 public function __construct(
114 MyYoast_Client $myyoast_client,
115 Status_Presenter $status_presenter,
116 Issuer_Config $issuer_config,
117 Client_Registration_Interface $client_registration,
118 Connection_Permission $connection_permission
119 ) {
120 $this->myyoast_client = $myyoast_client;
121 $this->status_presenter = $status_presenter;
122 $this->issuer_config = $issuer_config;
123 $this->client_registration = $client_registration;
124 $this->connection_permission = $connection_permission;
125 $this->logger = new NullLogger();
126 }
127
128 /**
129 * Returns the conditionals on which this route should be registered.
130 *
131 * @return array<string>
132 */
133 public static function get_conditionals() {
134 return [ MyYoast_Connection_Conditional::class ];
135 }
136
137 /**
138 * Registers the routes with WordPress.
139 *
140 * @return void
141 */
142 public function register_routes() {
143 $permission_callback = [ $this, 'can_manage' ];
144
145 \register_rest_route(
146 Main::API_V1_NAMESPACE,
147 self::ROUTE_PREFIX . self::STATUS_ROUTE,
148 [
149 'methods' => 'GET',
150 'callback' => [ $this, 'get_status' ],
151 'permission_callback' => $permission_callback,
152 ],
153 );
154
155 \register_rest_route(
156 Main::API_V1_NAMESPACE,
157 self::ROUTE_PREFIX . self::REFRESH_STATUS_ROUTE,
158 [
159 'methods' => 'POST',
160 'callback' => [ $this, 'refresh_status' ],
161 'permission_callback' => $permission_callback,
162 ],
163 );
164
165 \register_rest_route(
166 Main::API_V1_NAMESPACE,
167 self::ROUTE_PREFIX . self::REGISTER_ROUTE,
168 [
169 'methods' => 'POST',
170 'callback' => [ $this, 'register' ],
171 'permission_callback' => $permission_callback,
172 ],
173 );
174
175 \register_rest_route(
176 Main::API_V1_NAMESPACE,
177 self::ROUTE_PREFIX . self::REGISTRATION_ROUTE,
178 [
179 [
180 'methods' => 'PUT',
181 'callback' => [ $this, 'update_registration' ],
182 'permission_callback' => $permission_callback,
183 ],
184 [
185 'methods' => 'DELETE',
186 'callback' => [ $this, 'deregister' ],
187 'permission_callback' => $permission_callback,
188 ],
189 ],
190 );
191
192 \register_rest_route(
193 Main::API_V1_NAMESPACE,
194 self::ROUTE_PREFIX . self::AUTHORIZE_ROUTE,
195 [
196 'methods' => 'POST',
197 'callback' => [ $this, 'authorize' ],
198 'permission_callback' => $permission_callback,
199 'args' => [
200 'return_url' => [
201 'type' => 'string',
202 'required' => false,
203 '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.',
204 'sanitize_callback' => 'esc_url_raw',
205 ],
206 ],
207 ],
208 );
209 }
210
211 /**
212 * Permission callback for every endpoint.
213 *
214 * @return bool
215 */
216 public function can_manage() {
217 return $this->connection_permission->can_manage();
218 }
219
220 /**
221 * GET /myyoast/status — returns the current status payload.
222 *
223 * @return WP_REST_Response
224 */
225 public function get_status() {
226 return $this->respond_with_connection_status( 200, null );
227 }
228
229 /**
230 * POST /myyoast/refresh-status — refreshes the registration status against the server.
231 *
232 * Throttled: a successful upstream refresh suppresses further upstream calls
233 * for an hour. Within that window the call is skipped and the locally-derived
234 * status is returned unchanged, so a page reload does not hit MyYoast's rate
235 * limit. The upstream response body is never stored — only the throttle marker.
236 *
237 * @return WP_REST_Response
238 */
239 public function refresh_status() {
240 if ( \get_transient( $this->get_refresh_throttle_key() ) !== false ) {
241 return $this->respond_with_connection_status( 200, null );
242 }
243
244 try {
245 $this->myyoast_client->refresh_registration_status();
246 } catch ( Throwable $e ) {
247 return $this->handle_exception( $e );
248 }
249
250 // Mark only on success: a failed or rate-limited attempt must not suppress the next retry.
251 \set_transient( $this->get_refresh_throttle_key(), 1, self::REFRESH_THROTTLE_TTL_IN_SECONDS );
252
253 return $this->respond_with_connection_status( 200, null );
254 }
255
256 /**
257 * POST /myyoast/register — connects the site to MyYoast.
258 *
259 * @return WP_REST_Response
260 */
261 public function register() {
262 $gate = $this->require_provisioned();
263 if ( $gate !== null ) {
264 return $gate;
265 }
266
267 try {
268 $this->myyoast_client->ensure_registered();
269 } catch ( Throwable $e ) {
270 return $this->handle_exception( $e );
271 }
272
273 $this->clear_refresh_throttle();
274
275 return $this->respond_with_connection_status( 200, 'connect_success' );
276 }
277
278 /**
279 * PUT /myyoast/registration — re-syncs the connection's redirect URIs.
280 *
281 * Used to recover the connection after the site's URL has changed. The client
282 * resolves the current redirect URIs itself and updates the registration in
283 * place (RFC 7592 PUT) when the set differs from what is stored.
284 *
285 * @return WP_REST_Response
286 */
287 public function update_registration() {
288 $gate = $this->require_provisioned();
289 if ( $gate !== null ) {
290 return $gate;
291 }
292
293 try {
294 $this->myyoast_client->ensure_registered();
295 } catch ( Throwable $e ) {
296 return $this->handle_exception( $e );
297 }
298
299 $this->clear_refresh_throttle();
300
301 return $this->respond_with_connection_status( 200, 'update_success' );
302 }
303
304 /**
305 * POST /myyoast/authorize — starts the authorization-code flow and returns
306 * the URL the browser should be sent to.
307 *
308 * Completing the round-trip verifies that the site's redirect URI is
309 * reachable and that the user is who they claim to be. The client resolves
310 * the redirect URI itself, and the authorization-code handler marks it
311 * validated once the returning code is exchanged.
312 *
313 * The optional `return_url` is where the browser is sent once the flow
314 * completes; the caller supplies it because the flow can be started from
315 * different admin pages. It is validated against the site's own host, so an
316 * off-site or tampered value is dropped (and the callback then surfaces a
317 * standalone outcome rather than redirecting anywhere).
318 *
319 * @param WP_REST_Request $request The REST request.
320 *
321 * @return WP_REST_Response
322 */
323 public function authorize( WP_REST_Request $request ): WP_REST_Response {
324 if ( $this->client_registration->get_registered_client() === null ) {
325 return $this->error_response( 'registration_gone' );
326 }
327
328 $user_id = \get_current_user_id();
329 if ( $user_id <= 0 ) {
330 // Return HTTP 200 with the error_code in the body like every other failure here:
331 // api-fetch rejects non-2xx, which would mask invalid_user as a generic unexpected_error.
332 return $this->error_response( 'invalid_user' );
333 }
334
335 $return_url = $this->resolve_return_url( $request->get_param( 'return_url' ) );
336
337 try {
338 $authorize_url = $this->myyoast_client->get_authorization_url(
339 $user_id,
340 [ 'openid' ],
341 null,
342 $return_url,
343 );
344 } catch ( Authorization_Flow_Exception $e ) {
345 return $this->error_response( 'registration_failed', $e );
346 } catch ( Invalid_Resource_Exception $e ) {
347 return $this->handle_exception( $e );
348 }
349
350 $body = [
351 'authorize_url' => $authorize_url,
352 'status' => $this->status_presenter->present(),
353 ];
354
355 return new WP_REST_Response( $body, 200 );
356 }
357
358 /**
359 * DELETE /myyoast/registration — disconnects the site server-side and locally.
360 *
361 * @return WP_REST_Response
362 */
363 public function deregister() {
364 // Disconnect is best-effort on the server but always authoritative
365 // locally: whatever happens with the remote RFC 7592 DELETE, the site
366 // ends up disconnected here. An orphaned server-side client is cleaned up
367 // automatically by MyYoast. deregister() already clears the local
368 // registration and returns false (rather than throwing) on transport
369 // failure.
370 $remote_cleared = false;
371 try {
372 $remote_cleared = $this->myyoast_client->deregister();
373 } catch ( Throwable $e ) {
374 $this->logger->warning(
375 'Unexpected error during MyYoast deregistration; disconnecting locally anyway: {error}',
376 [ 'error' => $e->getMessage() ],
377 );
378 } finally {
379 // Always clear site tokens, even when the remote call threw, so the
380 // site is never left half-connected.
381 $this->myyoast_client->clear_all_site_tokens();
382 }
383
384 if ( ! $remote_cleared ) {
385 $this->logger->warning( 'MyYoast server-side deregistration was not confirmed; the site was disconnected locally.' );
386 }
387
388 $this->clear_refresh_throttle();
389
390 return $this->respond_with_connection_status( 200, 'disconnect_success' );
391 }
392
393 /**
394 * Validates a caller-supplied return URL against the site's own host.
395 *
396 * The return URL is optional: callers that have nowhere meaningful to send
397 * the user back to omit it. Anything off-site or otherwise invalid is treated
398 * as absent rather than rewritten to a default — `wp_validate_redirect()` with
399 * an empty fallback yields an empty string, which we normalize to null. The
400 * callback re-validates the stored value before redirecting, so this is the
401 * first of two gates against an open redirect.
402 *
403 * @param string|null $return_url The sanitized `return_url` request parameter (the route's
404 * args schema coerces it to a string; absent when not sent).
405 *
406 * @return string|null The validated same-host URL, or null when none applies.
407 */
408 private function resolve_return_url( ?string $return_url ): ?string {
409 if ( $return_url === null || $return_url === '' ) {
410 return null;
411 }
412
413 $validated = \wp_validate_redirect( $return_url, '' );
414
415 return ( $validated === '' ) ? null : $validated;
416 }
417
418 /**
419 * Returns a "not provisioned" response when SS or IAT is empty.
420 *
421 * @return WP_REST_Response|null Response when blocked, null otherwise.
422 */
423 private function require_provisioned(): ?WP_REST_Response {
424 if ( $this->is_provisioned() ) {
425 return null;
426 }
427
428 return $this->error_response( 'not_provisioned' );
429 }
430
431 /**
432 * Whether the plugin is provisioned for OAuth (software statement + IAT).
433 *
434 * @return bool
435 */
436 private function is_provisioned(): bool {
437 return ( $this->issuer_config->get_software_statement() !== '' )
438 && ( $this->issuer_config->get_initial_access_token() !== '' );
439 }
440
441 /**
442 * Maps an exception to a REST error response.
443 *
444 * The REST endpoint itself executed correctly — what failed is an upstream
445 * call to MyYoast or a precondition. We therefore return HTTP 200 with an
446 * `error_code` in the body that the UI translates into actionable copy.
447 * Genuine request-validation failures return 4xx separately (see callers).
448 *
449 * @param Throwable $exception The exception to handle.
450 *
451 * @return WP_REST_Response
452 */
453 private function handle_exception( Throwable $exception ): WP_REST_Response {
454 if ( $exception instanceof Registration_Not_Found_Exception ) {
455 return $this->error_response( 'registration_gone', $exception );
456 }
457
458 if ( $exception instanceof Rate_Limited_Exception ) {
459 $retry_after = $exception->get_retry_after_seconds();
460 $details = ( $retry_after !== null ) ? [ 'retry_after_seconds' => $retry_after ] : [];
461 return $this->error_response( 'rate_limited', $exception, 200, $details );
462 }
463
464 if ( $exception instanceof Server_Capability_Exception ) {
465 return $this->error_response( 'server_capability', $exception );
466 }
467
468 if ( $exception instanceof Discovery_Failed_Exception ) {
469 return $this->error_response( 'myyoast_unreachable', $exception );
470 }
471
472 if ( $exception instanceof Token_Request_Failed_Exception ) {
473 $code = ( $exception->get_error_code() === 'invalid_grant' ) ? 'token_request_failed_invalid_grant' : 'token_request_failed';
474 return $this->error_response( $code, $exception );
475 }
476
477 if ( $exception instanceof Token_Storage_Exception ) {
478 return $this->error_response( 'token_storage_failed', $exception );
479 }
480
481 if ( $exception instanceof Invalid_Resource_Exception ) {
482 return $this->error_response( 'invalid_resource', $exception );
483 }
484
485 if ( $exception instanceof Registration_Failed_Exception ) {
486 return $this->error_response( 'registration_failed', $exception );
487 }
488
489 $this->logger->error(
490 'Unexpected exception in MyYoast management route: {message}',
491 [ 'message' => $exception->getMessage() ],
492 );
493
494 return $this->error_response( 'unexpected_error', $exception );
495 }
496
497 /**
498 * Returns the issuer-scoped transient key for the refresh throttle marker.
499 *
500 * @return string The transient key.
501 */
502 private function get_refresh_throttle_key(): string {
503 return \sprintf(
504 '%s_%s',
505 self::REFRESH_THROTTLE_TRANSIENT_PREFIX,
506 $this->issuer_config->get_issuer_key(),
507 );
508 }
509
510 /**
511 * Clears the refresh throttle marker so the next status read hits the server.
512 *
513 * Called after any endpoint that changes the registration (connect, re-sync,
514 * disconnect): the throttle exists only to spare MyYoast's rate limit on
515 * unchanged status, so a deliberate state change must invalidate it.
516 *
517 * @return void
518 */
519 private function clear_refresh_throttle(): void {
520 \delete_transient( $this->get_refresh_throttle_key() );
521 }
522
523 /**
524 * Builds a successful response carrying the refreshed status payload.
525 *
526 * @param int $status The HTTP status.
527 * @param string|null $message_key The key in the i18n message map for the success notice, or null when none applies.
528 *
529 * @return WP_REST_Response
530 */
531 private function respond_with_connection_status( int $status, ?string $message_key ): WP_REST_Response {
532 $body = [
533 'status' => $this->status_presenter->present(),
534 ];
535 if ( $message_key !== null ) {
536 $body['message_key'] = $message_key;
537 }
538
539 return new WP_REST_Response( $body, $status );
540 }
541
542 /**
543 * Builds an error response.
544 *
545 * Defaults to HTTP 200 — the REST endpoint succeeded; the failure is in
546 * an upstream call or precondition, and the UI keys off `error_code`,
547 * not the HTTP status. Genuine 4xx (e.g. validation failures) pass an
548 * explicit status.
549 *
550 * @param string $error_code The machine-readable error code (looked up client-side in the i18n map).
551 * @param Throwable|null $exception Optional exception (logged when present).
552 * @param int $status The HTTP status. Defaults to 200.
553 * @param array<string, scalar> $details Optional extra fields the UI may use to enrich the error message.
554 *
555 * @return WP_REST_Response
556 */
557 private function error_response( string $error_code, ?Throwable $exception = null, int $status = 200, array $details = [] ): WP_REST_Response {
558 if ( $exception !== null ) {
559 $this->logger->warning(
560 'MyYoast management error ({code}): {message}',
561 [
562 'code' => $error_code,
563 'message' => $exception->getMessage(),
564 ],
565 );
566 }
567
568 $body = [
569 'error_code' => $error_code,
570 'status' => $this->status_presenter->present(),
571 ];
572 if ( $details !== [] ) {
573 $body['details'] = $details;
574 }
575
576 return new WP_REST_Response( $body, $status );
577 }
578 }
579