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
xspeed / includes / modules / Mcp / Mcp_Hub.php

Mcp_Hub.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.0, at includes/modules/Mcp/Mcp_Hub.php

1,050 lines 40.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * xSpeed Hub — the site side of the multi-site hub attach flow.
4 *
5 * The Hub (xspeedcache.com) lets one AI connection manage many sites. A
6 * site is always attached FROM the plugin (the admin has proof of
7 * ownership here). Two methods, both surfaced in the MCP Server panel's
8 * "xSpeed Hub" card:
9 *
10 * Method 1 — Token: the plugin surfaces this site's URL + its MCP
11 * `site_token`; the admin pastes both into their xspeedcache.com
12 * account. The Hub then presents that token back to us on every call
13 * (X-XSpeed-MCP-Token, validated by Mcp_Auth) — identical to the
14 * per-site broker credential, so nothing new is trusted.
15 *
16 * Method 2 — OAuth (one click): redirect to the Hub to log in + approve.
17 * Lands with the OAuth front door (Phase 4). Not wired here yet.
18 *
19 * This class holds ONLY the hub-link bookkeeping (which account this site
20 * reports being attached to, for the panel's status line). The credential
21 * itself is the existing Mcp_Pairing::site_token() — we do not mint a
22 * second secret.
23 *
24 * State lives in its own option so it is orthogonal to the per-site
25 * pairing state (disconnecting one never disturbs the other).
26 *
27 * @package XSpeed
28 */
29
30 declare(strict_types=1);
31
32 namespace XSpeed\Modules\Mcp;
33
34 defined( 'ABSPATH' ) || exit;
35
36 use XSpeed\Score_Store;
37
38 final class Mcp_Hub {
39
40 /** Option key holding hub-link state (separate from pairing state). */
41 public const OPTION = 'xspeed_module_mcp_hub';
42
43 /** Per-user meta key holding THIS admin's hub connection state. A WP site
44 * can have many admins, each managing it from their own hub account, so
45 * the connection is per-user, not site-wide. */
46 public const USER_META = 'xspeed_hub_link';
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
55 /** Default hub dashboard base — where the user manages their account. */
56 public const DEFAULT_HUB_URL = 'https://app.xspeedcache.com';
57
58 /**
59 * The hub dashboard base URL. Overridable via the XSPEED_HUB_URL
60 * constant (wp-config.php) and the `xspeed_hub_url` filter so dev /
61 * self-hosted deployments can point elsewhere.
62 */
63 public static function hub_url(): string {
64 $url = self::DEFAULT_HUB_URL;
65 if ( defined( 'XSPEED_HUB_URL' ) && is_string( constant( 'XSPEED_HUB_URL' ) ) && '' !== constant( 'XSPEED_HUB_URL' ) ) {
66 $url = (string) constant( 'XSPEED_HUB_URL' );
67 }
68 /** Filter the xSpeed Hub base URL. */
69 $url = (string) apply_filters( 'xspeed_hub_url', $url );
70 return untrailingslashit( $url );
71 }
72
73 /**
74 * Hub-link state for the CURRENT admin (per-user). Falls back to the legacy
75 * site-wide option for sites attached before the per-user migration, so an
76 * existing connection still shows until re-attached.
77 *
78 * @param int|null $user_id Which user (defaults to the current user).
79 * @return array{attached:bool,account_email:string,attached_at:int}
80 */
81 public static function state( ?int $user_id = null ): array {
82 $user_id = $user_id ?? get_current_user_id();
83 $stored = $user_id ? get_user_meta( $user_id, self::USER_META, true ) : array();
84
85 // Backward-compat: fall back to the old site-wide option if this user
86 // has no per-user record yet (pre-1.1 attach).
87 if ( ! is_array( $stored ) || empty( $stored ) ) {
88 $legacy = get_option( self::OPTION, array() );
89 $stored = is_array( $legacy ) ? $legacy : array();
90 }
91
92 return array(
93 'attached' => ! empty( $stored['attached'] ),
94 'account_email' => isset( $stored['account_email'] ) ? (string) $stored['account_email'] : '',
95 'attached_at' => isset( $stored['attached_at'] ) ? (int) $stored['attached_at'] : 0,
96 );
97 }
98
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 /**
185 * Public snapshot for the dashboard "xSpeed Hub" card.
186 *
187 * Includes the paste-in values for Method 1 (this site's URL + token)
188 * and a link to the hub dashboard. The token is admin-only (the whole
189 * REST route is gated by manage_options in McpModule).
190 *
191 * @return array<string,mixed>
192 */
193 public static function public_status( ?int $user_id = null ): array {
194 $state = self::state( $user_id );
195 return array(
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 // Method 1 paste-in credential — the existing per-site token.
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(),
204 // Where the user goes to paste the URL + token (Add site form).
205 'add_site_url' => self::hub_url() . '/sites/add',
206 // Method 2 (OAuth attach) — one-click redirect with a fresh nonce.
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(),
215 );
216 }
217
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 /**
263 * Method 1 — ensure a site_token exists and return the paste-in values.
264 *
265 * Reuses Mcp_Pairing::connect() so the Hub credential is the SAME token
266 * the per-site path uses (no second secret, no drift). Idempotent: if a
267 * token already exists it is reused, not rotated, so an already-attached
268 * hub keeps working.
269 *
270 * @return array<string,mixed> Public status including site_url + site_token.
271 */
272 public static function generate_token(): array {
273 if ( '' === Mcp_Pairing::site_token() ) {
274 // Mint (read-write by default) so the token exists to hand over.
275 Mcp_Pairing::connect( false );
276 }
277 return self::public_status();
278 }
279
280 /**
281 * Method 2 (OAuth attach) — the one-click flow.
282 *
283 * The admin clicks "Connect via OAuth" in the Hub tab. We mint a
284 * short-lived signed nonce and redirect the browser to the hub's
285 * /attach page carrying { site_url, nonce }. The hub (after the user
286 * logs into their account) calls BACK to this site's
287 * /xspeed/v1/mcp/attach with the nonce; verify_attach_nonce() checks
288 * it and hands the hub the site_token. Because the nonce is HMAC-signed
289 * with this site's secret AND minting is admin-only, only a site admin
290 * can start an attach — no token is ever pasted or shown.
291 */
292
293 /** Nonce validity window (seconds). */
294 private const NONCE_TTL = 600;
295
296 /** Per-site signing secret for attach nonces (derived from WP salts). */
297 private static function nonce_secret(): string {
298 return wp_hash( 'xspeed_hub_attach|' . self::site_url_canonical() );
299 }
300
301 /** Canonical site URL used in the nonce + sent to the hub. */
302 public static function site_url_canonical(): string {
303 return untrailingslashit( home_url( '/' ) );
304 }
305
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 /**
368 * Mint a signed, time-bound attach nonce. Format: <ts>.<uid>.<hmac>.
369 * Admin-only (the REST route that calls this is gated by manage_options).
370 * The minting admin's user ID is embedded so the (WP-userless) attach
371 * callback can record the connection PER-USER — each admin sees their own
372 * "Connected via <their account>" status.
373 */
374 public static function mint_attach_nonce(): string {
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 */
393 $ts = time();
394 $uid = get_current_user_id();
395 $hmac = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
396 return $ts . '.' . $uid . '.' . $hmac;
397 }
398
399 /**
400 * Verify an attach nonce (constant-time, within TTL). On success returns
401 * the paste-in values (site_url + site_token) plus the minting admin's
402 * user ID; on failure returns null. Called by the token-authless
403 * /mcp/attach route. Works once per nonce.
404 *
405 * @return array{site_url:string,site_token:string,user_id:int}|null
406 */
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 {
434 $parts = explode( '.', $nonce, 3 );
435 if ( 3 !== count( $parts ) ) {
436 return null;
437 }
438 list( $ts, $uid, $hmac ) = $parts;
439 if ( ! ctype_digit( (string) $ts ) || ! ctype_digit( (string) $uid ) ) {
440 return null;
441 }
442 if ( abs( time() - (int) $ts ) > self::NONCE_TTL ) {
443 return null; // expired
444 }
445 $expected = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
446 if ( ! hash_equals( $expected, (string) $hmac ) ) {
447 return null; // bad signature
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
485 return array(
486 'site_url' => self::site_url_canonical(),
487 'site_token' => Mcp_Pairing::site_token(),
488 'user_id' => $user_id,
489 );
490 }
491
492 /**
493 * The URL to redirect the admin to for OAuth attach. Carries the site
494 * URL + a fresh nonce; the hub reads these, logs the user in, and calls
495 * back to confirm.
496 */
497 /**
498 * Self-heal: ask the Hub whether THIS site (by its own token) is attached,
499 * and reconcile the local per-user state. This makes the "Connected" badge
500 * reliable even if the attach callback never fired (failed/slow/cached) —
501 * the Hub is the source of truth. Cached in a short transient so the panel
502 * doesn't make an outbound call on every render.
503 *
504 * @param bool $force Skip the cache (e.g. right after a Connect attempt).
505 */
506 public static function reconcile_with_hub( bool $force = false ): void {
507 $token = Mcp_Pairing::site_token();
508 if ( '' === $token ) {
509 return; // no token minted yet → definitely not attached
510 }
511
512 $cache_key = 'xspeed_hub_reconcile';
513 if ( ! $force && false !== get_transient( $cache_key ) ) {
514 return; // reconciled recently
515 }
516
517 $url = add_query_arg(
518 array( 'site_url' => rawurlencode( self::site_url_canonical() ) ),
519 self::hub_url() . '/api/site/attached'
520 );
521 $resp = wp_remote_get(
522 $url,
523 array(
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 ),
532 )
533 );
534 // Cache for 5 min regardless — don't hammer the Hub on transient errors.
535 set_transient( $cache_key, 1, 5 * MINUTE_IN_SECONDS );
536
537 if ( is_wp_error( $resp ) || 200 !== wp_remote_retrieve_response_code( $resp ) ) {
538 return; // leave local state as-is on any error
539 }
540 $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
541 if ( ! is_array( $body ) ) {
542 return;
543 }
544
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 }
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 );
552 // The Hub says attached — mark THIS admin connected if not already.
553 $state = self::state( $uid );
554 if ( empty( $state['attached'] ) ) {
555 self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid );
556 }
557 } elseif ( $uid ) {
558 // The Hub says NOT attached (e.g. removed on the dashboard) — clear
559 // any stale local "connected" so the badge doesn't lie.
560 $state = self::state( $uid );
561 if ( ! empty( $state['attached'] ) ) {
562 delete_user_meta( $uid, self::USER_META );
563 self::refresh_site_attached();
564 }
565 }
566 }
567
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 {
666 $args = array(
667 'site_url' => self::site_url_canonical(),
668 'nonce' => self::mint_attach_nonce(),
669 );
670 // Prefill hint only — the current admin's email, so a brand-new user
671 // can create/sign into their Hub account in one click without typing.
672 // The Hub NEVER trusts this for auth; it only pre-populates the field
673 // and still requires the user to verify (magic-link / Google).
674 $email = self::current_admin_email();
675 if ( '' !== $email ) {
676 $args['email'] = $email;
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 );
681 return self::hub_url() . '/attach?' . http_build_query( $args );
682 }
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
700 /** The logged-in admin's email (used only as a Hub sign-in prefill hint). */
701 private static function current_admin_email(): string {
702 $user = wp_get_current_user();
703 if ( $user && ! empty( $user->user_email ) && is_email( $user->user_email ) ) {
704 return (string) $user->user_email;
705 }
706 $admin = get_option( 'admin_email' );
707 return is_string( $admin ) && is_email( $admin ) ? $admin : '';
708 }
709
710 /**
711 * Record that this site is attached to a hub account, PER USER. Called
712 * from the attach callback with the minting admin's user id (from the
713 * nonce), so each admin gets their own status. Bookkeeping only.
714 *
715 * @param string $account_email The hub account the site was attached to.
716 * @param int|null $user_id The admin who attached (defaults to current).
717 */
718 public static function mark_attached( string $account_email, ?int $user_id = null ): array {
719 $user_id = $user_id ?? get_current_user_id();
720 if ( $user_id ) {
721 update_user_meta(
722 $user_id,
723 self::USER_META,
724 array(
725 'attached' => true,
726 'account_email' => sanitize_email( $account_email ),
727 'attached_at' => time(),
728 )
729 );
730 }
731 // Attaching makes the site-level answer unconditionally yes.
732 update_option( self::SITE_ATTACHED_OPTION, '1', false );
733 // Bust the reconcile cache so a reconnect reflects immediately (not the
734 // stale 'not attached' cached during the disconnected window).
735 delete_transient( 'xspeed_hub_reconcile' );
736 return self::public_status( $user_id );
737 }
738
739 /**
740 * Disconnect the CURRENT admin from the hub: clear their per-user link.
741 * Other admins' connections are untouched. Does NOT rotate the site_token
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.
754 */
755 public static function disconnect( bool $acknowledged = false ): array {
756 $user_id = get_current_user_id();
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
769 // Detach from the Hub for THIS admin's account only (multi-admin: other
770 // admins who attached keep their link). The site token proves ownership;
771 // account_email scopes the removal.
772 $token = Mcp_Pairing::site_token();
773 if ( '' !== $token && '' !== $email ) {
774 wp_remote_post(
775 self::hub_url() . '/api/site/detach',
776 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(
783 'Content-Type' => 'application/json',
784 'X-XSpeed-Site-Token' => $token,
785 ),
786 'body' => wp_json_encode(
787 array(
788 'site_url' => self::site_url_canonical(),
789 'account_email' => $email,
790 )
791 ),
792 )
793 );
794 }
795
796 if ( $user_id ) {
797 delete_user_meta( $user_id, self::USER_META );
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
817 // Bust the reconcile cache so the next status read reflects reality
818 // immediately (not the stale 'attached' cached before disconnect).
819 delete_transient( 'xspeed_hub_reconcile' );
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 );
1048 }
1049 }
1050