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 +244 -28 1.3.3 → 1.4.0 View file →
@@ -192,28 +192,75 @@
192 192 */
193 193 public static function public_status( ?int $user_id = null ): array {
194 194 $state = self::state( $user_id );
195 195 return array(
196 - 'attached' => $state['attached'],
197 - 'account_email' => $state['account_email'],
198 - 'attached_at' => $state['attached_at'],
199 - '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( '/' ) ),
200 200 // Method 1 paste-in credential — the existing per-site token.
201 201 // Empty until generate_token() (or a per-site Connect) mints one.
202 - 'site_token' => Mcp_Pairing::site_token(),
203 - 'hub_url' => self::hub_url(),
202 + 'site_token' => Mcp_Pairing::site_token(),
203 + 'hub_url' => self::hub_url(),
204 204 // Where the user goes to paste the URL + token (Add site form).
205 - 'add_site_url' => self::hub_url() . '/sites/add',
205 + 'add_site_url' => self::hub_url() . '/sites/add',
206 206 // Method 2 (OAuth attach) — one-click redirect with a fresh nonce.
207 - 'attach_url' => self::attach_url(),
207 + 'attach_url' => self::attach_url(),
208 208 // Non-public site? Connecting still works (token returns via the
209 209 // browser redirect), but Hub-initiated AI control needs a public
210 210 // URL — surfaced as an honest note on the Connect surfaces.
211 - 'is_local' => self::is_local_site(),
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(),
212 215 );
213 216 }
214 217
215 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 + /**
216 263 * Method 1 — ensure a site_token exists and return the paste-in values.
217 264 *
218 265 * Reuses Mcp_Pairing::connect() so the Hub credential is the SAME token
219 266 * the per-site path uses (no second secret, no drift). Idempotent: if a
@@ -251,9 +298,9 @@
251 298 return wp_hash( 'xspeed_hub_attach|' . self::site_url_canonical() );
252 299 }
253 300
254 301 /** Canonical site URL used in the nonce + sent to the hub. */
255 - private static function site_url_canonical(): string {
302 + public static function site_url_canonical(): string {
256 303 return untrailingslashit( home_url( '/' ) );
257 304 }
258 305
259 306 /**
@@ -352,13 +399,39 @@
352 399 /**
353 400 * Verify an attach nonce (constant-time, within TTL). On success returns
354 401 * the paste-in values (site_url + site_token) plus the minting admin's
355 402 * user ID; on failure returns null. Called by the token-authless
356 - * /mcp/attach route.
403 + * /mcp/attach route. Works once per nonce.
357 404 *
358 405 * @return array{site_url:string,site_token:string,user_id:int}|null
359 406 */
360 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 {
361 434 $parts = explode( '.', $nonce, 3 );
362 435 if ( 3 !== count( $parts ) ) {
363 436 return null;
364 437 }
@@ -372,16 +445,27 @@
372 445 $expected = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
373 446 if ( ! hash_equals( $expected, (string) $hmac ) ) {
374 447 return null; // bad signature
375 448 }
449 + return (int) $uid;
450 + }
376 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 {
377 461 /*
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
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
380 464 * Hub and approved. This is the one point in the attach flow where the
381 465 * user has unambiguously asked to connect, so it is where the token is
382 466 * created; minting it earlier (at nonce time) meant a page render could
383 - * do it. An invalid nonce returns above without minting.
467 + * do it. An invalid nonce or code never reaches this.
384 468 *
385 469 * connect() reuses an existing token, so a re-attach or a duplicate
386 470 * callback is idempotent and never rotates a paired client's secret.
387 471 */
@@ -388,12 +472,21 @@
388 472 if ( '' === Mcp_Pairing::site_token() ) {
389 473 Mcp_Pairing::connect( false );
390 474 }
391 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 +
392 485 return array(
393 486 'site_url' => self::site_url_canonical(),
394 487 'site_token' => Mcp_Pairing::site_token(),
395 - 'user_id' => (int) $uid,
488 + 'user_id' => $user_id,
396 489 );
397 490 }
398 491
399 492 /**
@@ -427,10 +520,16 @@
427 520 );
428 521 $resp = wp_remote_get(
429 522 $url,
430 523 array(
431 - 'timeout' => 8,
432 - '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 ),
433 532 )
434 533 );
435 534 // Cache for 5 min regardless — don't hammer the Hub on transient errors.
436 535 set_transient( $cache_key, 1, 5 * MINUTE_IN_SECONDS );
@@ -443,9 +542,14 @@
443 542 return;
444 543 }
445 544
446 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 + }
447 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 );
448 552 // The Hub says attached — mark THIS admin connected if not already.
449 553 $state = self::state( $uid );
450 554 if ( empty( $state['attached'] ) ) {
451 555 self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid );
@@ -461,8 +565,93 @@
461 565 }
462 566 }
463 567
464 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 + /**
465 654 * One-click attach redirect (Method 2). The Hub logs the user in, approves,
466 655 * calls back to this site's /attach route to record the link, then bounces
467 656 * the browser to `return_url` so the user lands back in the plugin without
468 657 * navigating manually.
@@ -549,16 +738,35 @@
549 738
550 739 /**
551 740 * Disconnect the CURRENT admin from the hub: clear their per-user link.
552 741 * Other admins' connections are untouched. Does NOT rotate the site_token
553 - * (still used by the per-site connection); to fully cut off the hub the
554 - * user rotates the token, which the Hub's stored copy then fails on.
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.
555 754 */
556 - public static function disconnect(): array {
755 + public static function disconnect( bool $acknowledged = false ): array {
557 756 $user_id = get_current_user_id();
558 - $state = $user_id ? self::state( $user_id ) : array();
559 - $email = isset( $state['account_email'] ) ? (string) $state['account_email'] : '';
560 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 +
561 769 // Detach from the Hub for THIS admin's account only (multi-admin: other
562 770 // admins who attached keep their link). The site token proves ownership;
563 771 // account_email scopes the removal.
564 772 $token = Mcp_Pairing::site_token();
@@ -565,10 +773,14 @@
565 773 if ( '' !== $token && '' !== $email ) {
566 774 wp_remote_post(
567 775 self::hub_url() . '/api/site/detach',
568 776 array(
569 - 'timeout' => 8,
570 - '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(
571 783 'Content-Type' => 'application/json',
572 784 'X-XSpeed-Site-Token' => $token,
573 785 ),
574 786 'body' => wp_json_encode(
@@ -773,12 +985,16 @@
773 985 }
774 986
775 987 $site_url = self::site_url_canonical();
776 988 $args = array(
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.
779 - 'timeout' => 15,
780 - 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
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 ),
781 997 );
782 998
783 999 if ( 'POST' === $method ) {
784 1000 $args['headers']['Content-Type'] = 'application/json';