| @@ -44,8 +44,15 @@ | ||
| 44 | 44 | * can have many admins, each managing it from their own hub account, so |
| 45 | 45 | * the connection is per-user, not site-wide. */ |
| 46 | 46 | public const USER_META = 'xspeed_hub_link'; |
| 47 | 47 | |
| 48 | + /** | |
| 49 | + * Site-level mirror of "any admin attached" — '1'/'0'. Maintained by | |
| 50 | + * every attach/detach path so the public scan-signals route answers from | |
| 51 | + * one option row instead of scanning users. See site_attached(). | |
| 52 | + */ | |
| 53 | + public const SITE_ATTACHED_OPTION = 'xspeed_hub_site_attached'; | |
| 54 | + | |
| 48 | 55 | /** Default hub dashboard base — where the user manages their account. */ |
| 49 | 56 | public const DEFAULT_HUB_URL = 'https://app.xspeedcache.com'; |
| 50 | 57 | |
| 51 | 58 | /** |
| @@ -89,8 +96,93 @@ | ||
| 89 | 96 | ); |
| 90 | 97 | } |
| 91 | 98 | |
| 92 | 99 | /** |
| 100 | + * Site-level Hub answer: is ANY admin on this site attached? | |
| 101 | + * | |
| 102 | + * `state()` is per-user because the attach credential belongs to the | |
| 103 | + * admin who approved it — but "is this SITE managed through the Hub" is | |
| 104 | + * a site-level fact, and it is what the public scan-signals route | |
| 105 | + * reports. The answer is a mirror option maintained by every attach and | |
| 106 | + * detach path, so the unauthenticated route reads one option row and | |
| 107 | + * never scans users. A bounded user scan was the first implementation | |
| 108 | + * and it answered WRONGLY: WP_User_Query orders by user_login, so an | |
| 109 | + * attached admin sorting past the bound was invisible. | |
| 110 | + * | |
| 111 | + * Sites attached before the mirror existed have no option row yet; that | |
| 112 | + * one absent-row case recomputes (over only the users carrying the | |
| 113 | + * hub-link meta — a handful of admins, never the whole user table) and | |
| 114 | + * writes the mirror, so the scan runs once per site ever. | |
| 115 | + * | |
| 116 | + * @return bool | |
| 117 | + */ | |
| 118 | + public static function site_attached(): bool { | |
| 119 | + $legacy = get_option( self::OPTION, array() ); | |
| 120 | + if ( is_array( $legacy ) && ! empty( $legacy['attached'] ) ) { | |
| 121 | + return true; | |
| 122 | + } | |
| 123 | + $mirror = get_option( self::SITE_ATTACHED_OPTION, false ); | |
| 124 | + if ( false !== $mirror ) { | |
| 125 | + return '1' === $mirror; | |
| 126 | + } | |
| 127 | + return self::refresh_site_attached(); | |
| 128 | + } | |
| 129 | + | |
| 130 | + /** | |
| 131 | + * Recompute the site-level attached mirror from the per-user records and | |
| 132 | + * persist it. Called by every path that changes attachment state, and | |
| 133 | + * lazily by site_attached() for pre-mirror installs. | |
| 134 | + * | |
| 135 | + * @return bool The recomputed answer. | |
| 136 | + */ | |
| 137 | + public static function refresh_site_attached(): bool { | |
| 138 | + $attached = false; | |
| 139 | + $legacy = get_option( self::OPTION, array() ); | |
| 140 | + if ( is_array( $legacy ) && ! empty( $legacy['attached'] ) ) { | |
| 141 | + $attached = true; | |
| 142 | + } else { | |
| 143 | + // Unbounded over users CARRYING the hub-link meta (the JOIN | |
| 144 | + // restricts to those rows — a handful of admins, not the user | |
| 145 | + // table). Deliberately no 'number' cap: a cap plus WP_User_Query's | |
| 146 | + // user_login ordering is exactly the wrong-answer bug this mirror | |
| 147 | + // replaced. | |
| 148 | + $user_ids = get_users( | |
| 149 | + array( | |
| 150 | + 'meta_key' => self::USER_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- runs only on attach/detach and once for pre-mirror installs; scans only rows carrying this meta. | |
| 151 | + 'fields' => 'ids', | |
| 152 | + ) | |
| 153 | + ); | |
| 154 | + foreach ( $user_ids as $user_id ) { | |
| 155 | + $stored = get_user_meta( (int) $user_id, self::USER_META, true ); | |
| 156 | + if ( is_array( $stored ) && ! empty( $stored['attached'] ) ) { | |
| 157 | + $attached = true; | |
| 158 | + break; | |
| 159 | + } | |
| 160 | + } | |
| 161 | + } | |
| 162 | + update_option( self::SITE_ATTACHED_OPTION, $attached ? '1' : '0', false ); | |
| 163 | + return $attached; | |
| 164 | + } | |
| 165 | + | |
| 166 | + /** | |
| 167 | + * A user is being removed from this site (multisite Users → Remove). | |
| 168 | + * | |
| 169 | + * remove_user_from_blog is core's ONLY removal action and it fires | |
| 170 | + * BEFORE WP drops the user — there is no post-removal hook — so a plain | |
| 171 | + * recompute here would still count the departing admin and keep the | |
| 172 | + * mirror stale. Clear their own hub-link record first (the right | |
| 173 | + * cleanup regardless: their attachment to this site is ending), then | |
| 174 | + * recompute over whoever remains, so a second attached admin keeps the | |
| 175 | + * site reading attached. | |
| 176 | + * | |
| 177 | + * @param int $user_id The user being removed from the site. | |
| 178 | + */ | |
| 179 | + public static function handle_user_removed( $user_id ): void { | |
| 180 | + delete_user_meta( (int) $user_id, self::USER_META ); | |
| 181 | + self::refresh_site_attached(); | |
| 182 | + } | |
| 183 | + | |
| 184 | + /** | |
| 93 | 185 | * Public snapshot for the dashboard "xSpeed Hub" card. |
| 94 | 186 | * |
| 95 | 187 | * Includes the paste-in values for Method 1 (this site's URL + token) |
| 96 | 188 | * and a link to the hub dashboard. The token is admin-only (the whole |
| @@ -103,9 +195,9 @@ | ||
| 103 | 195 | return array( |
| 104 | 196 | 'attached' => $state['attached'], |
| 105 | 197 | 'account_email' => $state['account_email'], |
| 106 | 198 | 'attached_at' => $state['attached_at'], |
| 107 | - 'site_url' => home_url( '/' ), | |
| 199 | + 'site_url' => Mcp_Pairing::absolute( home_url( '/' ) ), | |
| 108 | 200 | // Method 1 paste-in credential — the existing per-site token. |
| 109 | 201 | // Empty until generate_token() (or a per-site Connect) mints one. |
| 110 | 202 | 'site_token' => Mcp_Pairing::site_token(), |
| 111 | 203 | 'hub_url' => self::hub_url(), |
| @@ -232,12 +324,26 @@ | ||
| 232 | 324 | * callback can record the connection PER-USER — each admin sees their own |
| 233 | 325 | * "Connected via <their account>" status. |
| 234 | 326 | */ |
| 235 | 327 | public static function mint_attach_nonce(): string { |
| 236 | - // Ensure a site_token exists to hand over on the callback. | |
| 237 | - if ( '' === Mcp_Pairing::site_token() ) { | |
| 238 | - Mcp_Pairing::connect( false ); | |
| 239 | - } | |
| 328 | + /* | |
| 329 | + * Deliberately does NOT create a credential. | |
| 330 | + * | |
| 331 | + * This used to call Mcp_Pairing::connect( false ) here so a site_token | |
| 332 | + * would exist "to hand over on the callback". But this runs on a READ: | |
| 333 | + * public_status() embeds attach_url(), attach_url() mints a nonce, and | |
| 334 | + * public_status() is what the dashboard bootstrap, the Overview, the | |
| 335 | + * MCP drawer and GET /mcp/hub all call. The result was that merely | |
| 336 | + * opening xSpeed established a live read-write MCP connection nobody | |
| 337 | + * asked for — the site reported `connected` before the user had gone | |
| 338 | + * anywhere near an AI client. | |
| 339 | + * | |
| 340 | + * The token is only ever CONSUMED in verify_attach_nonce(), which runs | |
| 341 | + * when the Hub calls back after the user has clicked through, signed in | |
| 342 | + * and approved. Minting it there keeps this function pure and keeps | |
| 343 | + * credential creation on a path the user actually walked. The nonce | |
| 344 | + * itself needs no token: nonce_secret() is derived from the site URL. | |
| 345 | + */ | |
| 240 | 346 | $ts = time(); |
| 241 | 347 | $uid = get_current_user_id(); |
| 242 | 348 | $hmac = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() ); |
| 243 | 349 | return $ts . '.' . $uid . '.' . $hmac; |
| @@ -266,8 +372,33 @@ | ||
| 266 | 372 | $expected = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() ); |
| 267 | 373 | if ( ! hash_equals( $expected, (string) $hmac ) ) { |
| 268 | 374 | return null; // bad signature |
| 269 | 375 | } |
| 376 | + | |
| 377 | + /* | |
| 378 | + * Only NOW mint the credential the callback hands over — after a valid, | |
| 379 | + * unexpired, correctly-signed nonce has proved the user went through the | |
| 380 | + * Hub and approved. This is the one point in the attach flow where the | |
| 381 | + * user has unambiguously asked to connect, so it is where the token is | |
| 382 | + * created; minting it earlier (at nonce time) meant a page render could | |
| 383 | + * do it. An invalid nonce returns above without minting. | |
| 384 | + * | |
| 385 | + * connect() reuses an existing token, so a re-attach or a duplicate | |
| 386 | + * callback is idempotent and never rotates a paired client's secret. | |
| 387 | + */ | |
| 388 | + if ( '' === Mcp_Pairing::site_token() ) { | |
| 389 | + Mcp_Pairing::connect( false ); | |
| 390 | + } | |
| 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 | + | |
| 270 | 401 | return array( |
| 271 | 402 | 'site_url' => self::site_url_canonical(), |
| 272 | 403 | 'site_token' => Mcp_Pairing::site_token(), |
| 273 | 404 | 'user_id' => (int) $uid, |
| @@ -321,9 +452,14 @@ | ||
| 321 | 452 | return; |
| 322 | 453 | } |
| 323 | 454 | |
| 324 | 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 | + } | |
| 325 | 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 ); | |
| 326 | 462 | // The Hub says attached — mark THIS admin connected if not already. |
| 327 | 463 | $state = self::state( $uid ); |
| 328 | 464 | if ( empty( $state['attached'] ) ) { |
| 329 | 465 | self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid ); |
| @@ -333,13 +469,99 @@ | ||
| 333 | 469 | // any stale local "connected" so the badge doesn't lie. |
| 334 | 470 | $state = self::state( $uid ); |
| 335 | 471 | if ( ! empty( $state['attached'] ) ) { |
| 336 | 472 | delete_user_meta( $uid, self::USER_META ); |
| 473 | + self::refresh_site_attached(); | |
| 337 | 474 | } |
| 338 | 475 | } |
| 339 | 476 | } |
| 340 | 477 | |
| 341 | 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 | + /** | |
| 342 | 564 | * One-click attach redirect (Method 2). The Hub logs the user in, approves, |
| 343 | 565 | * calls back to this site's /attach route to record the link, then bounces |
| 344 | 566 | * the browser to `return_url` so the user lands back in the plugin without |
| 345 | 567 | * navigating manually. |
| @@ -415,8 +637,10 @@ | ||
| 415 | 637 | 'attached_at' => time(), |
| 416 | 638 | ) |
| 417 | 639 | ); |
| 418 | 640 | } |
| 641 | + // Attaching makes the site-level answer unconditionally yes. | |
| 642 | + update_option( self::SITE_ATTACHED_OPTION, '1', false ); | |
| 419 | 643 | // Bust the reconcile cache so a reconnect reflects immediately (not the |
| 420 | 644 | // stale 'not attached' cached during the disconnected window). |
| 421 | 645 | delete_transient( 'xspeed_hub_reconcile' ); |
| 422 | 646 | return self::public_status( $user_id ); |
| @@ -424,10 +648,11 @@ | ||
| 424 | 648 | |
| 425 | 649 | /** |
| 426 | 650 | * Disconnect the CURRENT admin from the hub: clear their per-user link. |
| 427 | 651 | * Other admins' connections are untouched. Does NOT rotate the site_token |
| 428 | - * (still used by the per-site connection); to fully cut off the hub the | |
| 429 | - * 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. | |
| 430 | 655 | */ |
| 431 | 656 | public static function disconnect(): array { |
| 432 | 657 | $user_id = get_current_user_id(); |
| 433 | 658 | $state = $user_id ? self::state( $user_id ) : array(); |
| @@ -473,8 +698,11 @@ | ||
| 473 | 698 | * is what scopes multi-admin, and each admin's own meta is untouched. |
| 474 | 699 | */ |
| 475 | 700 | delete_option( self::OPTION ); |
| 476 | 701 | |
| 702 | + // Other admins may still be attached — recompute rather than assume no. | |
| 703 | + self::refresh_site_attached(); | |
| 704 | + | |
| 477 | 705 | // Bust the reconcile cache so the next status read reflects reality |
| 478 | 706 | // immediately (not the stale 'attached' cached before disconnect). |
| 479 | 707 | delete_transient( 'xspeed_hub_reconcile' ); |
| 480 | 708 | return self::public_status( $user_id ); |
| @@ -496,12 +724,48 @@ | ||
| 496 | 724 | * |
| 497 | 725 | * @return array<string,mixed>|\WP_Error |
| 498 | 726 | */ |
| 499 | 727 | public static function gtmetrix_test() { |
| 500 | - return self::gtmetrix_request( 'POST', '/api/site/gtmetrix/test' ); | |
| 728 | + return self::hub_request( 'POST', '/api/site/gtmetrix/test' ); | |
| 501 | 729 | } |
| 502 | 730 | |
| 503 | 731 | /** |
| 732 | + * Ask the Hub to run a PageSpeed Insights audit for this site. | |
| 733 | + * | |
| 734 | + * The PSI twin of gtmetrix_test(): the Hub holds a real Google API key, so | |
| 735 | + * routing the audit through it is what makes a keyless site's test work — | |
| 736 | + * an unkeyed call straight to Google shares one anonymous per-IP pool with | |
| 737 | + * every other unkeyed caller and refuses with "Quota exceeded" under any | |
| 738 | + * real load (issue #426). | |
| 739 | + * | |
| 740 | + * The Hub answers 202 with a run row and audits in the background; the | |
| 741 | + * result arrives via psi_runs(). | |
| 742 | + * | |
| 743 | + * @param string $strategy 'mobile', 'desktop' or 'both'. | |
| 744 | + * @return array<string,mixed>|\WP_Error | |
| 745 | + */ | |
| 746 | + public static function psi_test( string $strategy = 'mobile' ) { | |
| 747 | + $strategy = in_array( $strategy, array( 'mobile', 'desktop', 'both' ), true ) ? $strategy : 'mobile'; | |
| 748 | + return self::hub_request( 'POST', '/api/site/psi/test', array( 'strategy' => $strategy ) ); | |
| 749 | + } | |
| 750 | + | |
| 751 | + /** | |
| 752 | + * PSI runs for this site, finished ones copied into the local history. | |
| 753 | + * | |
| 754 | + * The polling half of psi_test() — that route answers before the audit | |
| 755 | + * runs, so without this the plugin would never learn the score. | |
| 756 | + * | |
| 757 | + * @return array<string,mixed>|\WP_Error | |
| 758 | + */ | |
| 759 | + public static function psi_runs() { | |
| 760 | + $result = self::hub_request( 'GET', '/api/site/psi/runs' ); | |
| 761 | + if ( ! is_wp_error( $result ) ) { | |
| 762 | + self::store_hub_results( $result ); | |
| 763 | + } | |
| 764 | + return $result; | |
| 765 | + } | |
| 766 | + | |
| 767 | + /** | |
| 504 | 768 | * Recent Hub-run tests for this site, plus the remaining allowance. |
| 505 | 769 | * |
| 506 | 770 | * Polled while a run is in flight, and read once on load so the button can |
| 507 | 771 | * show the count before anyone presses anything. |
| @@ -508,9 +772,9 @@ | ||
| 508 | 772 | * |
| 509 | 773 | * @return array<string,mixed>|\WP_Error |
| 510 | 774 | */ |
| 511 | 775 | public static function gtmetrix_runs() { |
| 512 | - $result = self::gtmetrix_request( 'GET', '/api/site/gtmetrix/runs' ); | |
| 776 | + $result = self::hub_request( 'GET', '/api/site/gtmetrix/runs' ); | |
| 513 | 777 | if ( ! is_wp_error( $result ) ) { |
| 514 | 778 | self::store_hub_results( $result ); |
| 515 | 779 | } |
| 516 | 780 | return $result; |
| @@ -554,15 +818,19 @@ | ||
| 554 | 818 | if ( $ts <= 0 || '' === $remote_id || Score_Store::exists_remote( $remote_id ) ) { |
| 555 | 819 | continue; |
| 556 | 820 | } |
| 557 | 821 | |
| 822 | + // The runs table is shared between providers on the Hub too — a | |
| 823 | + // PSI run must not be recorded as a GTmetrix row. | |
| 824 | + $provider = 'psi' === ( $run['provider'] ?? '' ) ? 'psi' : 'gtmetrix'; | |
| 825 | + | |
| 558 | 826 | Score_Store::insert( |
| 559 | 827 | array( |
| 560 | 828 | 'ok' => true, |
| 561 | - 'provider' => 'gtmetrix', | |
| 829 | + 'provider' => $provider, | |
| 562 | 830 | 'ts' => $ts, |
| 563 | 831 | 'url' => (string) ( $r['url'] ?? '' ), |
| 564 | - 'strategy' => (string) ( $r['strategy'] ?? 'desktop' ), | |
| 832 | + 'strategy' => (string) ( $r['strategy'] ?? ( 'psi' === $provider ? 'mobile' : 'desktop' ) ), | |
| 565 | 833 | 'score' => $r['score'] ?? null, |
| 566 | 834 | 'metrics' => array( |
| 567 | 835 | 'lcp' => $r['lcp'] ?? null, |
| 568 | 836 | 'fcp' => $r['fcp'] ?? null, |
| @@ -589,25 +857,26 @@ | ||
| 589 | 857 | * failure into a stable error code — must behave identically for both. A |
| 590 | 858 | * divergence there would show up as the UI handling a quota error on one |
| 591 | 859 | * path and not the other. |
| 592 | 860 | * |
| 593 | - * @param string $method HTTP method. | |
| 594 | - * @param string $path Path under the hub base URL. | |
| 861 | + * @param string $method HTTP method. | |
| 862 | + * @param string $path Path under the hub base URL. | |
| 863 | + * @param array<string,mixed> $body Extra POST body fields beside site_url. | |
| 595 | 864 | * @return array<string,mixed>|\WP_Error |
| 596 | 865 | */ |
| 597 | - private static function gtmetrix_request( string $method, string $path ) { | |
| 866 | + private static function hub_request( string $method, string $path, array $body = array() ) { | |
| 598 | 867 | $token = Mcp_Pairing::site_token(); |
| 599 | 868 | if ( '' === $token ) { |
| 600 | 869 | return new \WP_Error( |
| 601 | 870 | 'not_connected', |
| 602 | - __( 'Connect this site to xSpeed Hub to run a free GTmetrix test.', 'xspeed' ) | |
| 871 | + __( 'Connect this site to xSpeed Hub to run a free speed test.', 'xspeed' ) | |
| 603 | 872 | ); |
| 604 | 873 | } |
| 605 | 874 | |
| 606 | 875 | $site_url = self::site_url_canonical(); |
| 607 | 876 | $args = array( |
| 608 | - // A GTmetrix test takes a minute, but the Hub answers as soon as it | |
| 609 | - // has ACCEPTED the job — this waits for that handshake only. | |
| 877 | + // A test takes a minute, but the Hub answers as soon as it has | |
| 878 | + // ACCEPTED the job — this waits for that handshake only. | |
| 610 | 879 | 'timeout' => 15, |
| 611 | 880 | 'headers' => array( 'X-XSpeed-Site-Token' => $token ), |
| 612 | 881 | ); |
| 613 | 882 | |
| @@ -612,9 +881,9 @@ | ||
| 612 | 881 | ); |
| 613 | 882 | |
| 614 | 883 | if ( 'POST' === $method ) { |
| 615 | 884 | $args['headers']['Content-Type'] = 'application/json'; |
| 616 | - $args['body'] = wp_json_encode( array( 'site_url' => $site_url ) ); | |
| 885 | + $args['body'] = wp_json_encode( array_merge( array( 'site_url' => $site_url ), $body ) ); | |
| 617 | 886 | $resp = wp_remote_post( self::hub_url() . $path, $args ); |
| 618 | 887 | } else { |
| 619 | 888 | $resp = wp_remote_get( |
| 620 | 889 | add_query_arg( array( 'site_url' => rawurlencode( $site_url ) ), self::hub_url() . $path ), |