PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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 1.1.5 1.1.6 All 31 releases
← All changes | includes/modules/Mcp/Mcp_Hub.php +184 -15 1.1.4 → 1.3.2 View file →
@@ -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
@@ -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,24 @@
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 +
270 392 return array(
271 393 'site_url' => self::site_url_canonical(),
272 394 'site_token' => Mcp_Pairing::site_token(),
273 395 'user_id' => (int) $uid,
@@ -333,8 +455,9 @@
333 455 // any stale local "connected" so the badge doesn't lie.
334 456 $state = self::state( $uid );
335 457 if ( ! empty( $state['attached'] ) ) {
336 458 delete_user_meta( $uid, self::USER_META );
459 + self::refresh_site_attached();
337 460 }
338 461 }
339 462 }
340 463
@@ -415,8 +538,10 @@
415 538 'attached_at' => time(),
416 539 )
417 540 );
418 541 }
542 + // Attaching makes the site-level answer unconditionally yes.
543 + update_option( self::SITE_ATTACHED_OPTION, '1', false );
419 544 // Bust the reconcile cache so a reconnect reflects immediately (not the
420 545 // stale 'not attached' cached during the disconnected window).
421 546 delete_transient( 'xspeed_hub_reconcile' );
422 547 return self::public_status( $user_id );
@@ -473,8 +598,11 @@
473 598 * is what scopes multi-admin, and each admin's own meta is untouched.
474 599 */
475 600 delete_option( self::OPTION );
476 601
602 + // Other admins may still be attached — recompute rather than assume no.
603 + self::refresh_site_attached();
604 +
477 605 // Bust the reconcile cache so the next status read reflects reality
478 606 // immediately (not the stale 'attached' cached before disconnect).
479 607 delete_transient( 'xspeed_hub_reconcile' );
480 608 return self::public_status( $user_id );
@@ -496,12 +624,48 @@
496 624 *
497 625 * @return array<string,mixed>|\WP_Error
498 626 */
499 627 public static function gtmetrix_test() {
500 - return self::gtmetrix_request( 'POST', '/api/site/gtmetrix/test' );
628 + return self::hub_request( 'POST', '/api/site/gtmetrix/test' );
501 629 }
502 630
503 631 /**
632 + * Ask the Hub to run a PageSpeed Insights audit for this site.
633 + *
634 + * The PSI twin of gtmetrix_test(): the Hub holds a real Google API key, so
635 + * routing the audit through it is what makes a keyless site's test work —
636 + * an unkeyed call straight to Google shares one anonymous per-IP pool with
637 + * every other unkeyed caller and refuses with "Quota exceeded" under any
638 + * real load (issue #426).
639 + *
640 + * The Hub answers 202 with a run row and audits in the background; the
641 + * result arrives via psi_runs().
642 + *
643 + * @param string $strategy 'mobile', 'desktop' or 'both'.
644 + * @return array<string,mixed>|\WP_Error
645 + */
646 + public static function psi_test( string $strategy = 'mobile' ) {
647 + $strategy = in_array( $strategy, array( 'mobile', 'desktop', 'both' ), true ) ? $strategy : 'mobile';
648 + return self::hub_request( 'POST', '/api/site/psi/test', array( 'strategy' => $strategy ) );
649 + }
650 +
651 + /**
652 + * PSI runs for this site, finished ones copied into the local history.
653 + *
654 + * The polling half of psi_test() — that route answers before the audit
655 + * runs, so without this the plugin would never learn the score.
656 + *
657 + * @return array<string,mixed>|\WP_Error
658 + */
659 + public static function psi_runs() {
660 + $result = self::hub_request( 'GET', '/api/site/psi/runs' );
661 + if ( ! is_wp_error( $result ) ) {
662 + self::store_hub_results( $result );
663 + }
664 + return $result;
665 + }
666 +
667 + /**
504 668 * Recent Hub-run tests for this site, plus the remaining allowance.
505 669 *
506 670 * Polled while a run is in flight, and read once on load so the button can
507 671 * show the count before anyone presses anything.
@@ -508,9 +672,9 @@
508 672 *
509 673 * @return array<string,mixed>|\WP_Error
510 674 */
511 675 public static function gtmetrix_runs() {
512 - $result = self::gtmetrix_request( 'GET', '/api/site/gtmetrix/runs' );
676 + $result = self::hub_request( 'GET', '/api/site/gtmetrix/runs' );
513 677 if ( ! is_wp_error( $result ) ) {
514 678 self::store_hub_results( $result );
515 679 }
516 680 return $result;
@@ -554,15 +718,19 @@
554 718 if ( $ts <= 0 || '' === $remote_id || Score_Store::exists_remote( $remote_id ) ) {
555 719 continue;
556 720 }
557 721
722 + // The runs table is shared between providers on the Hub too — a
723 + // PSI run must not be recorded as a GTmetrix row.
724 + $provider = 'psi' === ( $run['provider'] ?? '' ) ? 'psi' : 'gtmetrix';
725 +
558 726 Score_Store::insert(
559 727 array(
560 728 'ok' => true,
561 - 'provider' => 'gtmetrix',
729 + 'provider' => $provider,
562 730 'ts' => $ts,
563 731 'url' => (string) ( $r['url'] ?? '' ),
564 - 'strategy' => (string) ( $r['strategy'] ?? 'desktop' ),
732 + 'strategy' => (string) ( $r['strategy'] ?? ( 'psi' === $provider ? 'mobile' : 'desktop' ) ),
565 733 'score' => $r['score'] ?? null,
566 734 'metrics' => array(
567 735 'lcp' => $r['lcp'] ?? null,
568 736 'fcp' => $r['fcp'] ?? null,
@@ -589,25 +757,26 @@
589 757 * failure into a stable error code — must behave identically for both. A
590 758 * divergence there would show up as the UI handling a quota error on one
591 759 * path and not the other.
592 760 *
593 - * @param string $method HTTP method.
594 - * @param string $path Path under the hub base URL.
761 + * @param string $method HTTP method.
762 + * @param string $path Path under the hub base URL.
763 + * @param array<string,mixed> $body Extra POST body fields beside site_url.
595 764 * @return array<string,mixed>|\WP_Error
596 765 */
597 - private static function gtmetrix_request( string $method, string $path ) {
766 + private static function hub_request( string $method, string $path, array $body = array() ) {
598 767 $token = Mcp_Pairing::site_token();
599 768 if ( '' === $token ) {
600 769 return new \WP_Error(
601 770 'not_connected',
602 - __( 'Connect this site to xSpeed Hub to run a free GTmetrix test.', 'xspeed' )
771 + __( 'Connect this site to xSpeed Hub to run a free speed test.', 'xspeed' )
603 772 );
604 773 }
605 774
606 775 $site_url = self::site_url_canonical();
607 776 $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.
777 + // A test takes a minute, but the Hub answers as soon as it has
778 + // ACCEPTED the job — this waits for that handshake only.
610 779 'timeout' => 15,
611 780 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
612 781 );
613 782
@@ -612,9 +781,9 @@
612 781 );
613 782
614 783 if ( 'POST' === $method ) {
615 784 $args['headers']['Content-Type'] = 'application/json';
616 - $args['body'] = wp_json_encode( array( 'site_url' => $site_url ) );
785 + $args['body'] = wp_json_encode( array_merge( array( 'site_url' => $site_url ), $body ) );
617 786 $resp = wp_remote_post( self::hub_url() . $path, $args );
618 787 } else {
619 788 $resp = wp_remote_get(
620 789 add_query_arg( array( 'site_url' => rawurlencode( $site_url ) ), self::hub_url() . $path ),