PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 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 All 35 releases
← All changes | includes/modules/Mcp/Mcp_Hub.php +705 -25 1.1.0 → 1.4.0 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
@@ -98,24 +192,75 @@
98 192 */
99 193 public static function public_status( ?int $user_id = null ): array {
100 194 $state = self::state( $user_id );
101 195 return array(
102 - 'attached' => $state['attached'],
103 - 'account_email' => $state['account_email'],
104 - 'attached_at' => $state['attached_at'],
105 - 'site_url' => home_url( '/' ),
196 + 'attached' => $state['attached'],
197 + 'account_email' => $state['account_email'],
198 + 'attached_at' => $state['attached_at'],
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 - 'site_token' => Mcp_Pairing::site_token(),
109 - 'hub_url' => self::hub_url(),
202 + 'site_token' => Mcp_Pairing::site_token(),
203 + 'hub_url' => self::hub_url(),
110 204 // Where the user goes to paste the URL + token (Add site form).
111 - 'add_site_url' => self::hub_url() . '/sites/add',
205 + 'add_site_url' => self::hub_url() . '/sites/add',
112 206 // Method 2 (OAuth attach) — one-click redirect with a fresh nonce.
113 - 'attach_url' => self::attach_url(),
207 + 'attach_url' => self::attach_url(),
208 + // Non-public site? Connecting still works (token returns via the
209 + // browser redirect), but Hub-initiated AI control needs a public
210 + // URL — surfaced as an honest note on the Connect surfaces.
211 + 'is_local' => self::is_local_site(),
212 + // Reasons this site should not be disconnected right now, so the
213 + // card can warn BEFORE the click rather than after the POST.
214 + 'disconnect_blockers' => self::disconnect_blockers(),
114 215 );
115 216 }
116 217
117 218 /**
219 + * Reasons disconnecting this site would cost the owner something.
220 + *
221 + * Disconnect is not local bookkeeping: it POSTs to the Hub. Anything that
222 + * stops working when the link goes — a feature this site drives THROUGH
223 + * the Hub connection — is a fact only the feature knows, so this is a
224 + * filter and nothing here knows what any blocker is about.
225 + *
226 + * A blocker is `array( 'code' => string, 'message' => string )`. `code`
227 + * is a slug for the UI to key on; `message` is one sentence shown to the
228 + * admin verbatim. Entries that are not that shape are dropped rather than
229 + * repaired — a half-read warning is worse than none.
230 + *
231 + * @return array<int,array{code:string,message:string}>
232 + */
233 + public static function disconnect_blockers(): array {
234 + $raw = apply_filters( 'xspeed_hub_disconnect_blockers', array() );
235 + if ( ! is_array( $raw ) ) {
236 + return array();
237 + }
238 +
239 + $out = array();
240 + foreach ( $raw as $entry ) {
241 + if ( ! is_array( $entry ) ) {
242 + continue;
243 + }
244 + $code = isset( $entry['code'] ) && is_scalar( $entry['code'] )
245 + ? sanitize_key( (string) $entry['code'] )
246 + : '';
247 + $message = isset( $entry['message'] ) && is_scalar( $entry['message'] )
248 + ? trim( wp_strip_all_tags( (string) $entry['message'] ) )
249 + : '';
250 + if ( '' === $code || '' === $message ) {
251 + continue;
252 + }
253 + $out[] = array(
254 + 'code' => $code,
255 + 'message' => $message,
256 + );
257 + }
258 +
259 + return $out;
260 + }
261 +
262 + /**
118 263 * Method 1 — ensure a site_token exists and return the paste-in values.
119 264 *
120 265 * Reuses Mcp_Pairing::connect() so the Hub credential is the SAME token
121 266 * the per-site path uses (no second secret, no drift). Idempotent: if a
@@ -153,13 +298,74 @@
153 298 return wp_hash( 'xspeed_hub_attach|' . self::site_url_canonical() );
154 299 }
155 300
156 301 /** Canonical site URL used in the nonce + sent to the hub. */
157 - private static function site_url_canonical(): string {
302 + public static function site_url_canonical(): string {
158 303 return untrailingslashit( home_url( '/' ) );
159 304 }
160 305
161 306 /**
307 + * Heuristic: is this site NOT publicly reachable from the internet? A local /
308 + * dev / firewalled site can still CONNECT (the token comes back through the
309 + * admin's own browser redirect), but the Hub's servers can't reach it back,
310 + * so Hub-initiated AI control won't work until it's on a public URL. We use
311 + * this only to show an honest heads-up on the Connect surfaces — never to
312 + * block connecting.
313 + *
314 + * True when WP reports a local environment, or the host is a well-known dev
315 + * TLD / localhost / a private or loopback IP.
316 + */
317 + public static function is_local_site(): bool {
318 + $host = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
319 + if ( ! is_string( $host ) || '' === $host ) {
320 + return false;
321 + }
322 + $host = strtolower( $host );
323 +
324 + if ( 'localhost' === $host ) {
325 + return true;
326 + }
327 + // Common local/dev TLDs used by local WP stacks (sandbox .sb, Local by
328 + // Flywheel .local, *.test, *.dev, *.example, *.invalid).
329 + foreach ( array( '.sb', '.test', '.local', '.localhost', '.dev', '.example', '.invalid' ) as $suffix ) {
330 + if ( substr( $host, -strlen( $suffix ) ) === $suffix ) {
331 + return true;
332 + }
333 + }
334 + // Loopback / private-range IP literal (10/8, 172.16/12, 192.168/16, 127/8).
335 + if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
336 + return ! filter_var(
337 + $host,
338 + FILTER_VALIDATE_IP,
339 + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
340 + );
341 + }
342 +
343 + /*
344 + * WP_ENVIRONMENT_TYPE is deliberately NOT trusted on its own.
345 + *
346 + * It describes a WORKFLOW — local / development / staging /
347 + * production — not whether the internet can reach this site. Plenty
348 + * of real, publicly served sites are marked 'local' by their stack:
349 + * our own xsdev.1wp.site does exactly that, and told every visitor
350 + * "this site looks local" on a public HTTPS domain.
351 + *
352 + * The hostname above is the honest signal. This only corroborates it,
353 + * for a site whose name gives nothing away (an IP-less internal
354 + * hostname on a private network, say) — and only when the name is
355 + * also not a public FQDN.
356 + */
357 + if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) {
358 + // A dotted name that resolves publicly is reachable whatever the
359 + // environment type claims; a bare hostname ("wordpress", "web")
360 + // is not resolvable from outside and genuinely is local.
361 + return false === strpos( $host, '.' );
362 + }
363 +
364 + return false;
365 + }
366 +
367 + /**
162 368 * Mint a signed, time-bound attach nonce. Format: <ts>.<uid>.<hmac>.
163 369 * Admin-only (the REST route that calls this is gated by manage_options).
164 370 * The minting admin's user ID is embedded so the (WP-userless) attach
165 371 * callback can record the connection PER-USER — each admin sees their own
@@ -165,12 +371,26 @@
165 371 * callback can record the connection PER-USER — each admin sees their own
166 372 * "Connected via <their account>" status.
167 373 */
168 374 public static function mint_attach_nonce(): string {
169 - // Ensure a site_token exists to hand over on the callback.
170 - if ( '' === Mcp_Pairing::site_token() ) {
171 - Mcp_Pairing::connect( false );
172 - }
375 + /*
376 + * Deliberately does NOT create a credential.
377 + *
378 + * This used to call Mcp_Pairing::connect( false ) here so a site_token
379 + * would exist "to hand over on the callback". But this runs on a READ:
380 + * public_status() embeds attach_url(), attach_url() mints a nonce, and
381 + * public_status() is what the dashboard bootstrap, the Overview, the
382 + * MCP drawer and GET /mcp/hub all call. The result was that merely
383 + * opening xSpeed established a live read-write MCP connection nobody
384 + * asked for — the site reported `connected` before the user had gone
385 + * anywhere near an AI client.
386 + *
387 + * The token is only ever CONSUMED in verify_attach_nonce(), which runs
388 + * when the Hub calls back after the user has clicked through, signed in
389 + * and approved. Minting it there keeps this function pure and keeps
390 + * credential creation on a path the user actually walked. The nonce
391 + * itself needs no token: nonce_secret() is derived from the site URL.
392 + */
173 393 $ts = time();
174 394 $uid = get_current_user_id();
175 395 $hmac = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
176 396 return $ts . '.' . $uid . '.' . $hmac;
@@ -179,13 +399,39 @@
179 399 /**
180 400 * Verify an attach nonce (constant-time, within TTL). On success returns
181 401 * the paste-in values (site_url + site_token) plus the minting admin's
182 402 * user ID; on failure returns null. Called by the token-authless
183 - * /mcp/attach route.
403 + * /mcp/attach route. Works once per nonce.
184 404 *
185 405 * @return array{site_url:string,site_token:string,user_id:int}|null
186 406 */
187 407 public static function verify_attach_nonce( string $nonce ): ?array {
408 + $uid = self::check_attach_nonce( $nonce );
409 + if ( null === $uid ) {
410 + return null;
411 + }
412 +
413 + /*
414 + * One credential per nonce. The nonce rides in a browser URL (history,
415 + * referrer, logs) and stays signed for NONCE_TTL, so without this
416 + * anyone who saw it could call /mcp/attach in that window and get the
417 + * site token. The marker outlives the nonce, so it is never re-usable.
418 + */
419 + $spent = 'xspeed_hubn_' . hash( 'sha256', $nonce );
420 + if ( false !== get_transient( $spent ) ) {
421 + return null;
422 + }
423 + set_transient( $spent, 1, 2 * self::NONCE_TTL );
424 +
425 + return self::grant_attach_credential( $uid );
426 + }
427 +
428 + /**
429 + * The minting admin's user id when the nonce is ours, signed and unexpired;
430 + * null otherwise. Hands out nothing, so the browser-return handler can
431 + * use it after the Hub has already spent the nonce on the callback.
432 + */
433 + public static function check_attach_nonce( string $nonce ): ?int {
188 434 $parts = explode( '.', $nonce, 3 );
189 435 if ( 3 !== count( $parts ) ) {
190 436 return null;
191 437 }
@@ -199,12 +445,48 @@
199 445 $expected = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
200 446 if ( ! hash_equals( $expected, (string) $hmac ) ) {
201 447 return null; // bad signature
202 448 }
449 + return (int) $uid;
450 + }
451 +
452 + /**
453 + * Hand the Hub this site's credential, once an attach has been proved:
454 + * by a valid nonce (verify_attach_nonce()) or by a redeemed connect code
455 + * (Mcp_Hub_Connect::redeem_code()).
456 + *
457 + * @param int $user_id The admin who started or approved the attach.
458 + * @return array{site_url:string,site_token:string,user_id:int}
459 + */
460 + public static function grant_attach_credential( int $user_id ): array {
461 + /*
462 + * Only NOW mint the credential the callback hands over — after a valid
463 + * nonce or a redeemed connect code has proved an admin went through the
464 + * Hub and approved. This is the one point in the attach flow where the
465 + * user has unambiguously asked to connect, so it is where the token is
466 + * created; minting it earlier (at nonce time) meant a page render could
467 + * do it. An invalid nonce or code never reaches this.
468 + *
469 + * connect() reuses an existing token, so a re-attach or a duplicate
470 + * callback is idempotent and never rotates a paired client's secret.
471 + */
472 + if ( '' === Mcp_Pairing::site_token() ) {
473 + Mcp_Pairing::connect( false );
474 + }
475 +
476 + /*
477 + * The Hub checks the token it is about to receive by calling this
478 + * site with it. If its earlier checks with an old token locked it out,
479 + * that check gets a 429 and the Hub keeps the old token, so the site
480 + * could never be connected again. An admin started this attach; let
481 + * every client try again.
482 + */
483 + Mcp_Rate_Limiter::reset_all();
484 +
203 485 return array(
204 486 'site_url' => self::site_url_canonical(),
205 487 'site_token' => Mcp_Pairing::site_token(),
206 - 'user_id' => (int) $uid,
488 + 'user_id' => $user_id,
207 489 );
208 490 }
209 491
210 492 /**
@@ -238,10 +520,16 @@
238 520 );
239 521 $resp = wp_remote_get(
240 522 $url,
241 523 array(
242 - 'timeout' => 8,
243 - 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
524 + 'timeout' => 8,
525 + // The site token is a bearer for this site's whole Hub link, and
526 + // it rides in a header — WordPress re-sends headers on a
527 + // redirect, so following one would hand the token to whatever
528 + // host the response points at. Nothing legitimate about the Hub
529 + // answers with a 3xx, so treat one as the failure it is.
530 + 'redirection' => 0,
531 + 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
244 532 )
245 533 );
246 534 // Cache for 5 min regardless — don't hammer the Hub on transient errors.
247 535 set_transient( $cache_key, 1, 5 * MINUTE_IN_SECONDS );
@@ -254,9 +542,14 @@
254 542 return;
255 543 }
256 544
257 545 $uid = get_current_user_id();
546 + if ( empty( $body['attached'] ) && $uid && ! empty( self::state( $uid )['attached'] ) && self::keep_link_after_resync() ) {
547 + return;
548 + }
258 549 if ( ! empty( $body['attached'] ) ) {
550 + // Any sync in flight has landed; the next mismatch deserves a new one.
551 + delete_transient( self::RESYNC_SENT );
259 552 // The Hub says attached — mark THIS admin connected if not already.
260 553 $state = self::state( $uid );
261 554 if ( empty( $state['attached'] ) ) {
262 555 self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid );
@@ -266,13 +559,111 @@
266 559 // any stale local "connected" so the badge doesn't lie.
267 560 $state = self::state( $uid );
268 561 if ( ! empty( $state['attached'] ) ) {
269 562 delete_user_meta( $uid, self::USER_META );
563 + self::refresh_site_attached();
270 564 }
271 565 }
272 566 }
273 567
274 - public static function attach_url(): string {
568 + /**
569 + * When the last token sync was sent (unix time), kept for 30 minutes so
570 + * reconcile sends at most one sync per window.
571 + */
572 + private const RESYNC_SENT = 'xspeed_hub_resync_sent';
573 +
574 + /** How long the Hub gets to check a synced token before its silence counts. */
575 + private const RESYNC_GRACE = 2 * MINUTE_IN_SECONDS;
576 +
577 + /**
578 + * Send this site's current token to the Hub.
579 + *
580 + * The Hub keeps a copy of the token and presents it on every call. Rotate,
581 + * a Connect after Disconnect, or a reinstall replace the token here but
582 + * not there, and every Hub check then failed with "token invalid" until
583 + * the site was attached again.
584 + *
585 + * The Hub answers straight away (202) and checks the token afterwards by
586 + * calling this site with it. It stores the token only if this site
587 + * accepts it as the pairing token, so waiting here never holds a PHP
588 + * worker the Hub's check needs.
589 + *
590 + * Only for a site that was attached: an unattached site makes no call.
591 + *
592 + * @return int The Hub's HTTP status, or 0 when no call was made or it failed.
593 + */
594 + public static function push_token_to_hub(): int {
595 + $token = Mcp_Pairing::site_token();
596 + if ( '' === $token || ! self::site_attached() ) {
597 + return 0;
598 + }
599 + // The Hub checks this token by calling back with it. Its failures with
600 + // the old token must not lock that check out (see verify_attach_nonce()).
601 + Mcp_Rate_Limiter::reset_all();
602 + $resp = wp_remote_post(
603 + self::hub_url() . '/api/site/token',
604 + array(
605 + 'timeout' => 5,
606 + 'headers' => array(
607 + 'Content-Type' => 'application/json',
608 + 'X-XSpeed-Site-Token' => $token,
609 + ),
610 + 'body' => wp_json_encode( array( 'site_url' => self::site_url_canonical() ) ),
611 + )
612 + );
613 + return is_wp_error( $resp ) ? 0 : (int) wp_remote_retrieve_response_code( $resp );
614 + }
615 +
616 + /** Mcp_Pairing::TOKEN_CHANGED_ACTION listener. */
617 + public static function on_token_changed(): void {
618 + $code = self::push_token_to_hub();
619 + if ( $code >= 200 && $code < 300 ) {
620 + set_transient( self::RESYNC_SENT, time(), 30 * MINUTE_IN_SECONDS );
621 + }
622 + delete_transient( 'xspeed_hub_reconcile' );
623 + }
624 +
625 + /**
626 + * The Hub no longer recognises this site's token, but this admin's link
627 + * says attached. Decide whether the link stands.
628 + *
629 + * Only a 404 means the Hub has dropped the site. A timeout, a 429 or a
630 + * 5xx says nothing about the link, and clearing it on those is how a
631 + * connected site used to lose its badge. The Hub checks a synced token
632 + * after it answers, so a sync sent in the last RESYNC_GRACE seconds is
633 + * still pending. One sent earlier that has not restored the link ends
634 + * it, so a site the Hub will not accept does not show Connected for ever.
635 + *
636 + * @return bool True to keep the link.
637 + */
638 + private static function keep_link_after_resync(): bool {
639 + $sent = get_transient( self::RESYNC_SENT );
640 + if ( false !== $sent ) {
641 + return time() - (int) $sent < self::RESYNC_GRACE;
642 + }
643 + $code = self::push_token_to_hub();
644 + if ( 404 === $code ) {
645 + return false;
646 + }
647 + if ( $code >= 200 && $code < 300 ) {
648 + set_transient( self::RESYNC_SENT, time(), 30 * MINUTE_IN_SECONDS );
649 + }
650 + return true;
651 + }
652 +
653 + /**
654 + * One-click attach redirect (Method 2). The Hub logs the user in, approves,
655 + * calls back to this site's /attach route to record the link, then bounces
656 + * the browser to `return_url` so the user lands back in the plugin without
657 + * navigating manually.
658 + *
659 + * @param string $return_url Where the Hub should send the browser after a
660 + * successful attach. Defaults to the dashboard.
661 + * Callers pass the wizard URL during onboarding so
662 + * the user returns mid-flow. Must be a local admin
663 + * URL — we never hand the Hub an off-site redirect.
664 + */
665 + public static function attach_url( string $return_url = '' ): string {
275 666 $args = array(
276 667 'site_url' => self::site_url_canonical(),
277 668 'nonce' => self::mint_attach_nonce(),
278 669 );
@@ -283,11 +674,30 @@
283 674 $email = self::current_admin_email();
284 675 if ( '' !== $email ) {
285 676 $args['email'] = $email;
286 677 }
678 + // Where to send the user after they approve. Constrained to a local
679 + // admin URL so a tampered value can't turn this into an open redirect.
680 + $args['return_url'] = self::safe_return_url( $return_url );
287 681 return self::hub_url() . '/attach?' . http_build_query( $args );
288 682 }
289 683
684 + /**
685 + * Sanitize a caller-supplied return URL down to a safe, local admin URL.
686 + * Falls back to the dashboard for anything off-site or empty, so the value
687 + * we hand the Hub can never become an open redirect back into this site.
688 + */
689 + private static function safe_return_url( string $return_url ): string {
690 + $default = admin_url( 'admin.php?page=' . \XSpeed\Admin::PAGE_SLUG );
691 + if ( '' === $return_url ) {
692 + return $default;
693 + }
694 + // wp_validate_redirect() returns the fallback for any host not in the
695 + // allowed list (defaults to this site's host), so an attacker-supplied
696 + // absolute URL to another domain collapses to the dashboard.
697 + return wp_validate_redirect( $return_url, $default );
698 + }
699 +
290 700 /** The logged-in admin's email (used only as a Hub sign-in prefill hint). */
291 701 private static function current_admin_email(): string {
292 702 $user = wp_get_current_user();
293 703 if ( $user && ! empty( $user->user_email ) && is_email( $user->user_email ) ) {
@@ -317,8 +727,10 @@
317 727 'attached_at' => time(),
318 728 )
319 729 );
320 730 }
731 + // Attaching makes the site-level answer unconditionally yes.
732 + update_option( self::SITE_ATTACHED_OPTION, '1', false );
321 733 // Bust the reconcile cache so a reconnect reflects immediately (not the
322 734 // stale 'not attached' cached during the disconnected window).
323 735 delete_transient( 'xspeed_hub_reconcile' );
324 736 return self::public_status( $user_id );
@@ -326,16 +738,35 @@
326 738
327 739 /**
328 740 * Disconnect the CURRENT admin from the hub: clear their per-user link.
329 741 * Other admins' connections are untouched. Does NOT rotate the site_token
330 - * (still used by the per-site connection); to fully cut off the hub the
331 - * user rotates the token, which the Hub's stored copy then fails on.
742 + * (still used by the per-site connection). Rotating no longer cuts the
743 + * Hub off either: the new token is sent to the Hub (push_token_to_hub()),
744 + * so removing the site from the Hub is what ends its access.
745 + *
746 + * Refuses while `disconnect_blockers()` is non-empty and the caller has
747 + * not acknowledged them: it returns `blocked => true` plus the blockers
748 + * and changes nothing — no POST, no deletions. The check sits HERE and
749 + * not in the REST handler so every caller (the card, the route, a future
750 + * CLI path) is covered by the same veto.
751 + *
752 + * @param bool $acknowledged The caller has shown the blockers to a human
753 + * who chose to continue anyway.
332 754 */
333 - public static function disconnect(): array {
755 + public static function disconnect( bool $acknowledged = false ): array {
334 756 $user_id = get_current_user_id();
335 - $state = $user_id ? self::state( $user_id ) : array();
336 - $email = isset( $state['account_email'] ) ? (string) $state['account_email'] : '';
337 757
758 + $blockers = self::disconnect_blockers();
759 + if ( ! $acknowledged && array() !== $blockers ) {
760 + return array(
761 + 'blocked' => true,
762 + 'blockers' => $blockers,
763 + ) + self::public_status( $user_id );
764 + }
765 +
766 + $state = $user_id ? self::state( $user_id ) : array();
767 + $email = isset( $state['account_email'] ) ? (string) $state['account_email'] : '';
768 +
338 769 // Detach from the Hub for THIS admin's account only (multi-admin: other
339 770 // admins who attached keep their link). The site token proves ownership;
340 771 // account_email scopes the removal.
341 772 $token = Mcp_Pairing::site_token();
@@ -342,10 +773,14 @@
342 773 if ( '' !== $token && '' !== $email ) {
343 774 wp_remote_post(
344 775 self::hub_url() . '/api/site/detach',
345 776 array(
346 - 'timeout' => 8,
347 - 'headers' => array(
777 + 'timeout' => 8,
778 + // Never follow a redirect with the site token attached — see
779 + // reconcile_with_hub(). A detach that answers 3xx simply
780 + // fails; the local state is cleared below either way.
781 + 'redirection' => 0,
782 + 'headers' => array(
348 783 'Content-Type' => 'application/json',
349 784 'X-XSpeed-Site-Token' => $token,
350 785 ),
351 786 'body' => wp_json_encode(
@@ -360,10 +795,255 @@
360 795
361 796 if ( $user_id ) {
362 797 delete_user_meta( $user_id, self::USER_META );
363 798 }
799 +
800 + /*
801 + * Also clear the legacy site-wide option. state() falls back to it when
802 + * a user has no per-user record, so deleting only the user meta left
803 + * that fallback intact — disconnect() returned attached:true and the
804 + * card stayed "Connected", making the button look broken. Anyone who
805 + * attached before 1.1 (or via the redirect-return handler, which writes
806 + * the option) hit this. (FBS-84086)
807 + *
808 + * The option is a single site-wide record, not per-admin, so there is
809 + * no other admin's link being discarded here — the per-user meta above
810 + * is what scopes multi-admin, and each admin's own meta is untouched.
811 + */
812 + delete_option( self::OPTION );
813 +
814 + // Other admins may still be attached — recompute rather than assume no.
815 + self::refresh_site_attached();
816 +
364 817 // Bust the reconcile cache so the next status read reflects reality
365 818 // immediately (not the stale 'attached' cached before disconnect).
366 819 delete_transient( 'xspeed_hub_reconcile' );
367 820 return self::public_status( $user_id );
821 + }
822 +
823 + /**
824 + * Ask the Hub to run a GTmetrix test for this site.
825 + *
826 + * The Hub owns the GTmetrix account, the credits and the quota — this site
827 + * only proves who it is, with the same site_token it uses everywhere else.
828 + * That is the whole point of the feature: the site owner needs no GTmetrix
829 + * account and no API key.
830 + *
831 + * Returns the Hub's decoded body on success (a run row plus the remaining
832 + * allowance). On failure returns a WP_Error whose CODE is stable and
833 + * machine-readable, so the UI can respond to "you're out of tests this
834 + * month" differently from "this site isn't verified" instead of printing
835 + * whatever sentence came back.
836 + *
837 + * @return array<string,mixed>|\WP_Error
838 + */
839 + public static function gtmetrix_test() {
840 + return self::hub_request( 'POST', '/api/site/gtmetrix/test' );
841 + }
842 +
843 + /**
844 + * Ask the Hub to run a PageSpeed Insights audit for this site.
845 + *
846 + * The PSI twin of gtmetrix_test(): the Hub holds a real Google API key, so
847 + * routing the audit through it is what makes a keyless site's test work —
848 + * an unkeyed call straight to Google shares one anonymous per-IP pool with
849 + * every other unkeyed caller and refuses with "Quota exceeded" under any
850 + * real load (issue #426).
851 + *
852 + * The Hub answers 202 with a run row and audits in the background; the
853 + * result arrives via psi_runs().
854 + *
855 + * @param string $strategy 'mobile', 'desktop' or 'both'.
856 + * @return array<string,mixed>|\WP_Error
857 + */
858 + public static function psi_test( string $strategy = 'mobile' ) {
859 + $strategy = in_array( $strategy, array( 'mobile', 'desktop', 'both' ), true ) ? $strategy : 'mobile';
860 + return self::hub_request( 'POST', '/api/site/psi/test', array( 'strategy' => $strategy ) );
861 + }
862 +
863 + /**
864 + * PSI runs for this site, finished ones copied into the local history.
865 + *
866 + * The polling half of psi_test() — that route answers before the audit
867 + * runs, so without this the plugin would never learn the score.
868 + *
869 + * @return array<string,mixed>|\WP_Error
870 + */
871 + public static function psi_runs() {
872 + $result = self::hub_request( 'GET', '/api/site/psi/runs' );
873 + if ( ! is_wp_error( $result ) ) {
874 + self::store_hub_results( $result );
875 + }
876 + return $result;
877 + }
878 +
879 + /**
880 + * Recent Hub-run tests for this site, plus the remaining allowance.
881 + *
882 + * Polled while a run is in flight, and read once on load so the button can
883 + * show the count before anyone presses anything.
884 + *
885 + * @return array<string,mixed>|\WP_Error
886 + */
887 + public static function gtmetrix_runs() {
888 + $result = self::hub_request( 'GET', '/api/site/gtmetrix/runs' );
889 + if ( ! is_wp_error( $result ) ) {
890 + self::store_hub_results( $result );
891 + }
892 + return $result;
893 + }
894 +
895 + /**
896 + * Copy any finished Hub runs into THIS SITE's own score history.
897 + *
898 + * The Hub stores the result too, but that is its copy, not ours. Without
899 + * this the plugin would have to ask the Hub every time it wanted to draw
900 + * a score it already paid for — and a site that later disconnects would
901 + * lose its history entirely. The run belongs to the site.
902 + *
903 + * Idempotent: the Hub reports a finished run on every poll after it
904 + * completes, so each result is matched on provider + timestamp and stored
905 + * once.
906 + *
907 + * @param array<string,mixed> $payload Decoded /site/gtmetrix/runs body.
908 + */
909 + private static function store_hub_results( array $payload ): void {
910 + $runs = isset( $payload['runs'] ) && is_array( $payload['runs'] ) ? $payload['runs'] : array();
911 + if ( empty( $runs ) ) {
912 + return;
913 + }
914 +
915 + foreach ( $runs as $run ) {
916 + if ( ! is_array( $run ) || 'done' !== ( $run['status'] ?? '' ) ) {
917 + continue;
918 + }
919 + $r = isset( $run['result'] ) && is_array( $run['result'] ) ? $run['result'] : array();
920 + if ( empty( $r ) ) {
921 + continue;
922 + }
923 +
924 + // The Hub works in milliseconds; the plugin's history is seconds.
925 + $ts = isset( $r['ran_at'] ) ? (int) round( ( (int) $r['ran_at'] ) / 1000 ) : 0;
926 + $remote_id = isset( $run['id'] ) ? (string) $run['id'] : '';
927 + // Keyed on the Hub's run id, not the timestamp: a retry and the
928 + // original delivery can differ by milliseconds and both looked
929 + // new, so one test appeared twice in the history.
930 + if ( $ts <= 0 || '' === $remote_id || Score_Store::exists_remote( $remote_id ) ) {
931 + continue;
932 + }
933 +
934 + // The runs table is shared between providers on the Hub too — a
935 + // PSI run must not be recorded as a GTmetrix row.
936 + $provider = 'psi' === ( $run['provider'] ?? '' ) ? 'psi' : 'gtmetrix';
937 +
938 + Score_Store::insert(
939 + array(
940 + 'ok' => true,
941 + 'provider' => $provider,
942 + 'ts' => $ts,
943 + 'url' => (string) ( $r['url'] ?? '' ),
944 + 'strategy' => (string) ( $r['strategy'] ?? ( 'psi' === $provider ? 'mobile' : 'desktop' ) ),
945 + 'score' => $r['score'] ?? null,
946 + 'metrics' => array(
947 + 'lcp' => $r['lcp'] ?? null,
948 + 'fcp' => $r['fcp'] ?? null,
949 + 'cls' => $r['cls'] ?? null,
950 + 'tbt' => $r['tbt'] ?? null,
951 + 'si' => $r['si'] ?? null,
952 + 'ttfb' => $r['ttfb'] ?? null,
953 + ),
954 + 'report_url' => $r['report_url'] ?? null,
955 + 'remote_id' => $remote_id,
956 + // What the report said to fix — stored here so the panel
957 + // can show it without sending anyone to GTmetrix's page.
958 + 'opportunities' => $r['opportunities'] ?? null,
959 + ),
960 + 'hub'
961 + );
962 + }
963 + }
964 +
965 + /**
966 + * Shared transport for the two calls above.
967 + *
968 + * Kept private and shared because the interesting part — turning an HTTP
969 + * failure into a stable error code — must behave identically for both. A
970 + * divergence there would show up as the UI handling a quota error on one
971 + * path and not the other.
972 + *
973 + * @param string $method HTTP method.
974 + * @param string $path Path under the hub base URL.
975 + * @param array<string,mixed> $body Extra POST body fields beside site_url.
976 + * @return array<string,mixed>|\WP_Error
977 + */
978 + private static function hub_request( string $method, string $path, array $body = array() ) {
979 + $token = Mcp_Pairing::site_token();
980 + if ( '' === $token ) {
981 + return new \WP_Error(
982 + 'not_connected',
983 + __( 'Connect this site to xSpeed Hub to run a free speed test.', 'xspeed' )
984 + );
985 + }
986 +
987 + $site_url = self::site_url_canonical();
988 + $args = array(
989 + // A GTmetrix test takes a minute, but the Hub answers as soon as it
990 + // has ACCEPTED the job — this waits for that handshake only.
991 + 'timeout' => 15,
992 + // Never follow a redirect with the site token attached — see
993 + // reconcile_with_hub(). A 3xx falls through to the non-2xx branch
994 + // below and becomes a hub_error the panel can show.
995 + 'redirection' => 0,
996 + 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
997 + );
998 +
999 + if ( 'POST' === $method ) {
1000 + $args['headers']['Content-Type'] = 'application/json';
1001 + $args['body'] = wp_json_encode( array_merge( array( 'site_url' => $site_url ), $body ) );
1002 + $resp = wp_remote_post( self::hub_url() . $path, $args );
1003 + } else {
1004 + $resp = wp_remote_get(
1005 + add_query_arg( array( 'site_url' => rawurlencode( $site_url ) ), self::hub_url() . $path ),
1006 + $args
1007 + );
1008 + }
1009 +
1010 + if ( is_wp_error( $resp ) ) {
1011 + return new \WP_Error(
1012 + 'hub_unreachable',
1013 + __( 'Could not reach xSpeed Hub. Please try again.', 'xspeed' )
1014 + );
1015 + }
1016 +
1017 + $code = (int) wp_remote_retrieve_response_code( $resp );
1018 + $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
1019 + $body = is_array( $body ) ? $body : array();
1020 +
1021 + if ( $code >= 200 && $code < 300 ) {
1022 + return $body;
1023 + }
1024 +
1025 + // Prefer the Hub's own error code — it is already stable and specific
1026 + // (site_not_verified, gtmetrix_quota_exceeded, gtmetrix_run_active,
1027 + // gtmetrix_not_configured). Fall back to the status class so an
1028 + // unexpected response still produces something the UI can branch on.
1029 + $code_key = isset( $body['error'] ) && is_string( $body['error'] ) ? $body['error'] : '';
1030 + if ( '' === $code_key ) {
1031 + $code_key = 401 === $code ? 'not_connected' : 'hub_error';
1032 + }
1033 +
1034 + $message = isset( $body['message'] ) && is_string( $body['message'] ) && '' !== $body['message']
1035 + ? $body['message']
1036 + : __( 'The test could not be started.', 'xspeed' );
1037 +
1038 + // Carry the quota numbers through on a 429 so the panel can say
1039 + // "0 of 5 left" rather than just refusing.
1040 + $data = array( 'status' => $code );
1041 + foreach ( array( 'used', 'limit', 'quota', 'run' ) as $key ) {
1042 + if ( isset( $body[ $key ] ) ) {
1043 + $data[ $key ] = $body[ $key ];
1044 + }
1045 + }
1046 +
1047 + return new \WP_Error( $code_key, $message, $data );
368 1048 }
369 1049 }