| @@ -388,8 +388,17 @@ | ||
| 388 | 388 | if ( '' === Mcp_Pairing::site_token() ) { |
| 389 | 389 | Mcp_Pairing::connect( false ); |
| 390 | 390 | } |
| 391 | 391 | |
| 392 | + /* | |
| 393 | + * The Hub checks the token it is about to receive by calling this | |
| 394 | + * site with it. If its earlier checks with an old token locked it out, | |
| 395 | + * that check gets a 429 and the Hub keeps the old token, so the site | |
| 396 | + * could never be connected again. An admin started this attach; let | |
| 397 | + * every client try again. | |
| 398 | + */ | |
| 399 | + Mcp_Rate_Limiter::reset_all(); | |
| 400 | + | |
| 392 | 401 | return array( |
| 393 | 402 | 'site_url' => self::site_url_canonical(), |
| 394 | 403 | 'site_token' => Mcp_Pairing::site_token(), |
| 395 | 404 | 'user_id' => (int) $uid, |
| @@ -443,9 +452,14 @@ | ||
| 443 | 452 | return; |
| 444 | 453 | } |
| 445 | 454 | |
| 446 | 455 | $uid = get_current_user_id(); |
| 456 | + if ( empty( $body['attached'] ) && $uid && ! empty( self::state( $uid )['attached'] ) && self::keep_link_after_resync() ) { | |
| 457 | + return; | |
| 458 | + } | |
| 447 | 459 | if ( ! empty( $body['attached'] ) ) { |
| 460 | + // Any sync in flight has landed; the next mismatch deserves a new one. | |
| 461 | + delete_transient( self::RESYNC_SENT ); | |
| 448 | 462 | // The Hub says attached — mark THIS admin connected if not already. |
| 449 | 463 | $state = self::state( $uid ); |
| 450 | 464 | if ( empty( $state['attached'] ) ) { |
| 451 | 465 | self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid ); |
| @@ -461,8 +475,93 @@ | ||
| 461 | 475 | } |
| 462 | 476 | } |
| 463 | 477 | |
| 464 | 478 | /** |
| 479 | + * When the last token sync was sent (unix time), kept for 30 minutes so | |
| 480 | + * reconcile sends at most one sync per window. | |
| 481 | + */ | |
| 482 | + private const RESYNC_SENT = 'xspeed_hub_resync_sent'; | |
| 483 | + | |
| 484 | + /** How long the Hub gets to check a synced token before its silence counts. */ | |
| 485 | + private const RESYNC_GRACE = 2 * MINUTE_IN_SECONDS; | |
| 486 | + | |
| 487 | + /** | |
| 488 | + * Send this site's current token to the Hub. | |
| 489 | + * | |
| 490 | + * The Hub keeps a copy of the token and presents it on every call. Rotate, | |
| 491 | + * a Connect after Disconnect, or a reinstall replace the token here but | |
| 492 | + * not there, and every Hub check then failed with "token invalid" until | |
| 493 | + * the site was attached again. | |
| 494 | + * | |
| 495 | + * The Hub answers straight away (202) and checks the token afterwards by | |
| 496 | + * calling this site with it. It stores the token only if this site | |
| 497 | + * accepts it as the pairing token, so waiting here never holds a PHP | |
| 498 | + * worker the Hub's check needs. | |
| 499 | + * | |
| 500 | + * Only for a site that was attached: an unattached site makes no call. | |
| 501 | + * | |
| 502 | + * @return int The Hub's HTTP status, or 0 when no call was made or it failed. | |
| 503 | + */ | |
| 504 | + public static function push_token_to_hub(): int { | |
| 505 | + $token = Mcp_Pairing::site_token(); | |
| 506 | + if ( '' === $token || ! self::site_attached() ) { | |
| 507 | + return 0; | |
| 508 | + } | |
| 509 | + // The Hub checks this token by calling back with it. Its failures with | |
| 510 | + // the old token must not lock that check out (see verify_attach_nonce()). | |
| 511 | + Mcp_Rate_Limiter::reset_all(); | |
| 512 | + $resp = wp_remote_post( | |
| 513 | + self::hub_url() . '/api/site/token', | |
| 514 | + array( | |
| 515 | + 'timeout' => 5, | |
| 516 | + 'headers' => array( | |
| 517 | + 'Content-Type' => 'application/json', | |
| 518 | + 'X-XSpeed-Site-Token' => $token, | |
| 519 | + ), | |
| 520 | + 'body' => wp_json_encode( array( 'site_url' => self::site_url_canonical() ) ), | |
| 521 | + ) | |
| 522 | + ); | |
| 523 | + return is_wp_error( $resp ) ? 0 : (int) wp_remote_retrieve_response_code( $resp ); | |
| 524 | + } | |
| 525 | + | |
| 526 | + /** Mcp_Pairing::TOKEN_CHANGED_ACTION listener. */ | |
| 527 | + public static function on_token_changed(): void { | |
| 528 | + $code = self::push_token_to_hub(); | |
| 529 | + if ( $code >= 200 && $code < 300 ) { | |
| 530 | + set_transient( self::RESYNC_SENT, time(), 30 * MINUTE_IN_SECONDS ); | |
| 531 | + } | |
| 532 | + delete_transient( 'xspeed_hub_reconcile' ); | |
| 533 | + } | |
| 534 | + | |
| 535 | + /** | |
| 536 | + * The Hub no longer recognises this site's token, but this admin's link | |
| 537 | + * says attached. Decide whether the link stands. | |
| 538 | + * | |
| 539 | + * Only a 404 means the Hub has dropped the site. A timeout, a 429 or a | |
| 540 | + * 5xx says nothing about the link, and clearing it on those is how a | |
| 541 | + * connected site used to lose its badge. The Hub checks a synced token | |
| 542 | + * after it answers, so a sync sent in the last RESYNC_GRACE seconds is | |
| 543 | + * still pending. One sent earlier that has not restored the link ends | |
| 544 | + * it, so a site the Hub will not accept does not show Connected for ever. | |
| 545 | + * | |
| 546 | + * @return bool True to keep the link. | |
| 547 | + */ | |
| 548 | + private static function keep_link_after_resync(): bool { | |
| 549 | + $sent = get_transient( self::RESYNC_SENT ); | |
| 550 | + if ( false !== $sent ) { | |
| 551 | + return time() - (int) $sent < self::RESYNC_GRACE; | |
| 552 | + } | |
| 553 | + $code = self::push_token_to_hub(); | |
| 554 | + if ( 404 === $code ) { | |
| 555 | + return false; | |
| 556 | + } | |
| 557 | + if ( $code >= 200 && $code < 300 ) { | |
| 558 | + set_transient( self::RESYNC_SENT, time(), 30 * MINUTE_IN_SECONDS ); | |
| 559 | + } | |
| 560 | + return true; | |
| 561 | + } | |
| 562 | + | |
| 563 | + /** | |
| 465 | 564 | * One-click attach redirect (Method 2). The Hub logs the user in, approves, |
| 466 | 565 | * calls back to this site's /attach route to record the link, then bounces |
| 467 | 566 | * the browser to `return_url` so the user lands back in the plugin without |
| 468 | 567 | * navigating manually. |
| @@ -549,10 +648,11 @@ | ||
| 549 | 648 | |
| 550 | 649 | /** |
| 551 | 650 | * Disconnect the CURRENT admin from the hub: clear their per-user link. |
| 552 | 651 | * Other admins' connections are untouched. Does NOT rotate the site_token |
| 553 | - * (still used by the per-site connection); to fully cut off the hub the | |
| 554 | - * user rotates the token, which the Hub's stored copy then fails on. | |
| 652 | + * (still used by the per-site connection). Rotating no longer cuts the | |
| 653 | + * Hub off either: the new token is sent to the Hub (push_token_to_hub()), | |
| 654 | + * so removing the site from the Hub is what ends its access. | |
| 555 | 655 | */ |
| 556 | 656 | public static function disconnect(): array { |
| 557 | 657 | $user_id = get_current_user_id(); |
| 558 | 658 | $state = $user_id ? self::state( $user_id ) : array(); |