PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
← All changes | includes/modules/Mcp/Mcp_Hub.php +482 -11 1.1.2 → 1.3.7 View file →
@@ -32,8 +32,10 @@
32 32 namespace XSpeed\Modules\Mcp;
33 33
34 34 defined( 'ABSPATH' ) || exit;
35 35
36 +use XSpeed\Score_Store;
37 +
36 38 final class Mcp_Hub {
37 39
38 40 /** Option key holding hub-link state (separate from pairing state). */
39 41 public const OPTION = 'xspeed_module_mcp_hub';
@@ -42,8 +44,15 @@
42 44 * can have many admins, each managing it from their own hub account, so
43 45 * the connection is per-user, not site-wide. */
44 46 public const USER_META = 'xspeed_hub_link';
45 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 +
46 55 /** Default hub dashboard base — where the user manages their account. */
47 56 public const DEFAULT_HUB_URL = 'https://app.xspeedcache.com';
48 57
49 58 /**
@@ -87,8 +96,93 @@
87 96 );
88 97 }
89 98
90 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 + /**
91 185 * Public snapshot for the dashboard "xSpeed Hub" card.
92 186 *
93 187 * Includes the paste-in values for Method 1 (this site's URL + token)
94 188 * and a link to the hub dashboard. The token is admin-only (the whole
@@ -101,9 +195,9 @@
101 195 return array(
102 196 'attached' => $state['attached'],
103 197 'account_email' => $state['account_email'],
104 198 'attached_at' => $state['attached_at'],
105 - 'site_url' => home_url( '/' ),
199 + 'site_url' => Mcp_Pairing::absolute( home_url( '/' ) ),
106 200 // Method 1 paste-in credential — the existing per-site token.
107 201 // Empty until generate_token() (or a per-site Connect) mints one.
108 202 'site_token' => Mcp_Pairing::site_token(),
109 203 'hub_url' => self::hub_url(),
@@ -173,12 +267,8 @@
173 267 * True when WP reports a local environment, or the host is a well-known dev
174 268 * TLD / localhost / a private or loopback IP.
175 269 */
176 270 public static function is_local_site(): bool {
177 - if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) {
178 - return true;
179 - }
180 -
181 271 $host = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
182 272 if ( ! is_string( $host ) || '' === $host ) {
183 273 return false;
184 274 }
@@ -201,8 +291,30 @@
201 291 FILTER_VALIDATE_IP,
202 292 FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
203 293 );
204 294 }
295 +
296 + /*
297 + * WP_ENVIRONMENT_TYPE is deliberately NOT trusted on its own.
298 + *
299 + * It describes a WORKFLOW — local / development / staging /
300 + * production — not whether the internet can reach this site. Plenty
301 + * of real, publicly served sites are marked 'local' by their stack:
302 + * our own xsdev.1wp.site does exactly that, and told every visitor
303 + * "this site looks local" on a public HTTPS domain.
304 + *
305 + * The hostname above is the honest signal. This only corroborates it,
306 + * for a site whose name gives nothing away (an IP-less internal
307 + * hostname on a private network, say) — and only when the name is
308 + * also not a public FQDN.
309 + */
310 + if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) {
311 + // A dotted name that resolves publicly is reachable whatever the
312 + // environment type claims; a bare hostname ("wordpress", "web")
313 + // is not resolvable from outside and genuinely is local.
314 + return false === strpos( $host, '.' );
315 + }
316 +
205 317 return false;
206 318 }
207 319
208 320 /**
@@ -212,12 +324,26 @@
212 324 * callback can record the connection PER-USER — each admin sees their own
213 325 * "Connected via <their account>" status.
214 326 */
215 327 public static function mint_attach_nonce(): string {
216 - // Ensure a site_token exists to hand over on the callback.
217 - if ( '' === Mcp_Pairing::site_token() ) {
218 - Mcp_Pairing::connect( false );
219 - }
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 + */
220 346 $ts = time();
221 347 $uid = get_current_user_id();
222 348 $hmac = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
223 349 return $ts . '.' . $uid . '.' . $hmac;
@@ -246,8 +372,33 @@
246 372 $expected = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
247 373 if ( ! hash_equals( $expected, (string) $hmac ) ) {
248 374 return null; // bad signature
249 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 +
250 401 return array(
251 402 'site_url' => self::site_url_canonical(),
252 403 'site_token' => Mcp_Pairing::site_token(),
253 404 'user_id' => (int) $uid,
@@ -301,9 +452,14 @@
301 452 return;
302 453 }
303 454
304 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 + }
305 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 );
306 462 // The Hub says attached — mark THIS admin connected if not already.
307 463 $state = self::state( $uid );
308 464 if ( empty( $state['attached'] ) ) {
309 465 self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid );
@@ -313,13 +469,99 @@
313 469 // any stale local "connected" so the badge doesn't lie.
314 470 $state = self::state( $uid );
315 471 if ( ! empty( $state['attached'] ) ) {
316 472 delete_user_meta( $uid, self::USER_META );
473 + self::refresh_site_attached();
317 474 }
318 475 }
319 476 }
320 477
321 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 + /**
322 564 * One-click attach redirect (Method 2). The Hub logs the user in, approves,
323 565 * calls back to this site's /attach route to record the link, then bounces
324 566 * the browser to `return_url` so the user lands back in the plugin without
325 567 * navigating manually.
@@ -395,8 +637,10 @@
395 637 'attached_at' => time(),
396 638 )
397 639 );
398 640 }
641 + // Attaching makes the site-level answer unconditionally yes.
642 + update_option( self::SITE_ATTACHED_OPTION, '1', false );
399 643 // Bust the reconcile cache so a reconnect reflects immediately (not the
400 644 // stale 'not attached' cached during the disconnected window).
401 645 delete_transient( 'xspeed_hub_reconcile' );
402 646 return self::public_status( $user_id );
@@ -404,10 +648,11 @@
404 648
405 649 /**
406 650 * Disconnect the CURRENT admin from the hub: clear their per-user link.
407 651 * Other admins' connections are untouched. Does NOT rotate the site_token
408 - * (still used by the per-site connection); to fully cut off the hub the
409 - * 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.
410 655 */
411 656 public static function disconnect(): array {
412 657 $user_id = get_current_user_id();
413 658 $state = $user_id ? self::state( $user_id ) : array();
@@ -453,10 +698,236 @@
453 698 * is what scopes multi-admin, and each admin's own meta is untouched.
454 699 */
455 700 delete_option( self::OPTION );
456 701
702 + // Other admins may still be attached — recompute rather than assume no.
703 + self::refresh_site_attached();
704 +
457 705 // Bust the reconcile cache so the next status read reflects reality
458 706 // immediately (not the stale 'attached' cached before disconnect).
459 707 delete_transient( 'xspeed_hub_reconcile' );
460 708 return self::public_status( $user_id );
709 + }
710 +
711 + /**
712 + * Ask the Hub to run a GTmetrix test for this site.
713 + *
714 + * The Hub owns the GTmetrix account, the credits and the quota — this site
715 + * only proves who it is, with the same site_token it uses everywhere else.
716 + * That is the whole point of the feature: the site owner needs no GTmetrix
717 + * account and no API key.
718 + *
719 + * Returns the Hub's decoded body on success (a run row plus the remaining
720 + * allowance). On failure returns a WP_Error whose CODE is stable and
721 + * machine-readable, so the UI can respond to "you're out of tests this
722 + * month" differently from "this site isn't verified" instead of printing
723 + * whatever sentence came back.
724 + *
725 + * @return array<string,mixed>|\WP_Error
726 + */
727 + public static function gtmetrix_test() {
728 + return self::hub_request( 'POST', '/api/site/gtmetrix/test' );
729 + }
730 +
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 + /**
768 + * Recent Hub-run tests for this site, plus the remaining allowance.
769 + *
770 + * Polled while a run is in flight, and read once on load so the button can
771 + * show the count before anyone presses anything.
772 + *
773 + * @return array<string,mixed>|\WP_Error
774 + */
775 + public static function gtmetrix_runs() {
776 + $result = self::hub_request( 'GET', '/api/site/gtmetrix/runs' );
777 + if ( ! is_wp_error( $result ) ) {
778 + self::store_hub_results( $result );
779 + }
780 + return $result;
781 + }
782 +
783 + /**
784 + * Copy any finished Hub runs into THIS SITE's own score history.
785 + *
786 + * The Hub stores the result too, but that is its copy, not ours. Without
787 + * this the plugin would have to ask the Hub every time it wanted to draw
788 + * a score it already paid for — and a site that later disconnects would
789 + * lose its history entirely. The run belongs to the site.
790 + *
791 + * Idempotent: the Hub reports a finished run on every poll after it
792 + * completes, so each result is matched on provider + timestamp and stored
793 + * once.
794 + *
795 + * @param array<string,mixed> $payload Decoded /site/gtmetrix/runs body.
796 + */
797 + private static function store_hub_results( array $payload ): void {
798 + $runs = isset( $payload['runs'] ) && is_array( $payload['runs'] ) ? $payload['runs'] : array();
799 + if ( empty( $runs ) ) {
800 + return;
801 + }
802 +
803 + foreach ( $runs as $run ) {
804 + if ( ! is_array( $run ) || 'done' !== ( $run['status'] ?? '' ) ) {
805 + continue;
806 + }
807 + $r = isset( $run['result'] ) && is_array( $run['result'] ) ? $run['result'] : array();
808 + if ( empty( $r ) ) {
809 + continue;
810 + }
811 +
812 + // The Hub works in milliseconds; the plugin's history is seconds.
813 + $ts = isset( $r['ran_at'] ) ? (int) round( ( (int) $r['ran_at'] ) / 1000 ) : 0;
814 + $remote_id = isset( $run['id'] ) ? (string) $run['id'] : '';
815 + // Keyed on the Hub's run id, not the timestamp: a retry and the
816 + // original delivery can differ by milliseconds and both looked
817 + // new, so one test appeared twice in the history.
818 + if ( $ts <= 0 || '' === $remote_id || Score_Store::exists_remote( $remote_id ) ) {
819 + continue;
820 + }
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 +
826 + Score_Store::insert(
827 + array(
828 + 'ok' => true,
829 + 'provider' => $provider,
830 + 'ts' => $ts,
831 + 'url' => (string) ( $r['url'] ?? '' ),
832 + 'strategy' => (string) ( $r['strategy'] ?? ( 'psi' === $provider ? 'mobile' : 'desktop' ) ),
833 + 'score' => $r['score'] ?? null,
834 + 'metrics' => array(
835 + 'lcp' => $r['lcp'] ?? null,
836 + 'fcp' => $r['fcp'] ?? null,
837 + 'cls' => $r['cls'] ?? null,
838 + 'tbt' => $r['tbt'] ?? null,
839 + 'si' => $r['si'] ?? null,
840 + 'ttfb' => $r['ttfb'] ?? null,
841 + ),
842 + 'report_url' => $r['report_url'] ?? null,
843 + 'remote_id' => $remote_id,
844 + // What the report said to fix — stored here so the panel
845 + // can show it without sending anyone to GTmetrix's page.
846 + 'opportunities' => $r['opportunities'] ?? null,
847 + ),
848 + 'hub'
849 + );
850 + }
851 + }
852 +
853 + /**
854 + * Shared transport for the two calls above.
855 + *
856 + * Kept private and shared because the interesting part — turning an HTTP
857 + * failure into a stable error code — must behave identically for both. A
858 + * divergence there would show up as the UI handling a quota error on one
859 + * path and not the other.
860 + *
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.
864 + * @return array<string,mixed>|\WP_Error
865 + */
866 + private static function hub_request( string $method, string $path, array $body = array() ) {
867 + $token = Mcp_Pairing::site_token();
868 + if ( '' === $token ) {
869 + return new \WP_Error(
870 + 'not_connected',
871 + __( 'Connect this site to xSpeed Hub to run a free speed test.', 'xspeed' )
872 + );
873 + }
874 +
875 + $site_url = self::site_url_canonical();
876 + $args = array(
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.
879 + 'timeout' => 15,
880 + 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
881 + );
882 +
883 + if ( 'POST' === $method ) {
884 + $args['headers']['Content-Type'] = 'application/json';
885 + $args['body'] = wp_json_encode( array_merge( array( 'site_url' => $site_url ), $body ) );
886 + $resp = wp_remote_post( self::hub_url() . $path, $args );
887 + } else {
888 + $resp = wp_remote_get(
889 + add_query_arg( array( 'site_url' => rawurlencode( $site_url ) ), self::hub_url() . $path ),
890 + $args
891 + );
892 + }
893 +
894 + if ( is_wp_error( $resp ) ) {
895 + return new \WP_Error(
896 + 'hub_unreachable',
897 + __( 'Could not reach xSpeed Hub. Please try again.', 'xspeed' )
898 + );
899 + }
900 +
901 + $code = (int) wp_remote_retrieve_response_code( $resp );
902 + $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
903 + $body = is_array( $body ) ? $body : array();
904 +
905 + if ( $code >= 200 && $code < 300 ) {
906 + return $body;
907 + }
908 +
909 + // Prefer the Hub's own error code — it is already stable and specific
910 + // (site_not_verified, gtmetrix_quota_exceeded, gtmetrix_run_active,
911 + // gtmetrix_not_configured). Fall back to the status class so an
912 + // unexpected response still produces something the UI can branch on.
913 + $code_key = isset( $body['error'] ) && is_string( $body['error'] ) ? $body['error'] : '';
914 + if ( '' === $code_key ) {
915 + $code_key = 401 === $code ? 'not_connected' : 'hub_error';
916 + }
917 +
918 + $message = isset( $body['message'] ) && is_string( $body['message'] ) && '' !== $body['message']
919 + ? $body['message']
920 + : __( 'The test could not be started.', 'xspeed' );
921 +
922 + // Carry the quota numbers through on a 429 so the panel can say
923 + // "0 of 5 left" rather than just refusing.
924 + $data = array( 'status' => $code );
925 + foreach ( array( 'used', 'limit', 'quota', 'run' ) as $key ) {
926 + if ( isset( $body[ $key ] ) ) {
927 + $data[ $key ] = $body[ $key ];
928 + }
929 + }
930 +
931 + return new \WP_Error( $code_key, $message, $data );
461 932 }
462 933 }