PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.4
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / Mcp_Hub.php

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

793 lines 30.1 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' => 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 );
213 }
214
215 /**
216 * Method 1 — ensure a site_token exists and return the paste-in values.
217 *
218 * Reuses Mcp_Pairing::connect() so the Hub credential is the SAME token
219 * the per-site path uses (no second secret, no drift). Idempotent: if a
220 * token already exists it is reused, not rotated, so an already-attached
221 * hub keeps working.
222 *
223 * @return array<string,mixed> Public status including site_url + site_token.
224 */
225 public static function generate_token(): array {
226 if ( '' === Mcp_Pairing::site_token() ) {
227 // Mint (read-write by default) so the token exists to hand over.
228 Mcp_Pairing::connect( false );
229 }
230 return self::public_status();
231 }
232
233 /**
234 * Method 2 (OAuth attach) — the one-click flow.
235 *
236 * The admin clicks "Connect via OAuth" in the Hub tab. We mint a
237 * short-lived signed nonce and redirect the browser to the hub's
238 * /attach page carrying { site_url, nonce }. The hub (after the user
239 * logs into their account) calls BACK to this site's
240 * /xspeed/v1/mcp/attach with the nonce; verify_attach_nonce() checks
241 * it and hands the hub the site_token. Because the nonce is HMAC-signed
242 * with this site's secret AND minting is admin-only, only a site admin
243 * can start an attach — no token is ever pasted or shown.
244 */
245
246 /** Nonce validity window (seconds). */
247 private const NONCE_TTL = 600;
248
249 /** Per-site signing secret for attach nonces (derived from WP salts). */
250 private static function nonce_secret(): string {
251 return wp_hash( 'xspeed_hub_attach|' . self::site_url_canonical() );
252 }
253
254 /** Canonical site URL used in the nonce + sent to the hub. */
255 private static function site_url_canonical(): string {
256 return untrailingslashit( home_url( '/' ) );
257 }
258
259 /**
260 * Heuristic: is this site NOT publicly reachable from the internet? A local /
261 * dev / firewalled site can still CONNECT (the token comes back through the
262 * admin's own browser redirect), but the Hub's servers can't reach it back,
263 * so Hub-initiated AI control won't work until it's on a public URL. We use
264 * this only to show an honest heads-up on the Connect surfaces — never to
265 * block connecting.
266 *
267 * True when WP reports a local environment, or the host is a well-known dev
268 * TLD / localhost / a private or loopback IP.
269 */
270 public static function is_local_site(): bool {
271 $host = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
272 if ( ! is_string( $host ) || '' === $host ) {
273 return false;
274 }
275 $host = strtolower( $host );
276
277 if ( 'localhost' === $host ) {
278 return true;
279 }
280 // Common local/dev TLDs used by local WP stacks (sandbox .sb, Local by
281 // Flywheel .local, *.test, *.dev, *.example, *.invalid).
282 foreach ( array( '.sb', '.test', '.local', '.localhost', '.dev', '.example', '.invalid' ) as $suffix ) {
283 if ( substr( $host, -strlen( $suffix ) ) === $suffix ) {
284 return true;
285 }
286 }
287 // Loopback / private-range IP literal (10/8, 172.16/12, 192.168/16, 127/8).
288 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
289 return ! filter_var(
290 $host,
291 FILTER_VALIDATE_IP,
292 FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
293 );
294 }
295
296 /*
297 * WP_ENVIRONMENT_TYPE is deliberately NOT trusted on its own.
298 *
299 * It describes a WORKFLOW — local / development / staging /
300 * production — not whether the internet can reach this site. Plenty
301 * of real, publicly served sites are marked 'local' by their stack:
302 * our own xsdev.1wp.site does exactly that, and told every visitor
303 * "this site looks local" on a public HTTPS domain.
304 *
305 * The hostname above is the honest signal. This only corroborates it,
306 * for a site whose name gives nothing away (an IP-less internal
307 * hostname on a private network, say) — and only when the name is
308 * also not a public FQDN.
309 */
310 if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) {
311 // A dotted name that resolves publicly is reachable whatever the
312 // environment type claims; a bare hostname ("wordpress", "web")
313 // is not resolvable from outside and genuinely is local.
314 return false === strpos( $host, '.' );
315 }
316
317 return false;
318 }
319
320 /**
321 * Mint a signed, time-bound attach nonce. Format: <ts>.<uid>.<hmac>.
322 * Admin-only (the REST route that calls this is gated by manage_options).
323 * The minting admin's user ID is embedded so the (WP-userless) attach
324 * callback can record the connection PER-USER — each admin sees their own
325 * "Connected via <their account>" status.
326 */
327 public static function mint_attach_nonce(): string {
328 /*
329 * Deliberately does NOT create a credential.
330 *
331 * This used to call Mcp_Pairing::connect( false ) here so a site_token
332 * would exist "to hand over on the callback". But this runs on a READ:
333 * public_status() embeds attach_url(), attach_url() mints a nonce, and
334 * public_status() is what the dashboard bootstrap, the Overview, the
335 * MCP drawer and GET /mcp/hub all call. The result was that merely
336 * opening xSpeed established a live read-write MCP connection nobody
337 * asked for — the site reported `connected` before the user had gone
338 * anywhere near an AI client.
339 *
340 * The token is only ever CONSUMED in verify_attach_nonce(), which runs
341 * when the Hub calls back after the user has clicked through, signed in
342 * and approved. Minting it there keeps this function pure and keeps
343 * credential creation on a path the user actually walked. The nonce
344 * itself needs no token: nonce_secret() is derived from the site URL.
345 */
346 $ts = time();
347 $uid = get_current_user_id();
348 $hmac = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
349 return $ts . '.' . $uid . '.' . $hmac;
350 }
351
352 /**
353 * Verify an attach nonce (constant-time, within TTL). On success returns
354 * the paste-in values (site_url + site_token) plus the minting admin's
355 * user ID; on failure returns null. Called by the token-authless
356 * /mcp/attach route.
357 *
358 * @return array{site_url:string,site_token:string,user_id:int}|null
359 */
360 public static function verify_attach_nonce( string $nonce ): ?array {
361 $parts = explode( '.', $nonce, 3 );
362 if ( 3 !== count( $parts ) ) {
363 return null;
364 }
365 list( $ts, $uid, $hmac ) = $parts;
366 if ( ! ctype_digit( (string) $ts ) || ! ctype_digit( (string) $uid ) ) {
367 return null;
368 }
369 if ( abs( time() - (int) $ts ) > self::NONCE_TTL ) {
370 return null; // expired
371 }
372 $expected = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
373 if ( ! hash_equals( $expected, (string) $hmac ) ) {
374 return null; // bad signature
375 }
376
377 /*
378 * Only NOW mint the credential the callback hands over — after a valid,
379 * unexpired, correctly-signed nonce has proved the user went through the
380 * Hub and approved. This is the one point in the attach flow where the
381 * user has unambiguously asked to connect, so it is where the token is
382 * created; minting it earlier (at nonce time) meant a page render could
383 * do it. An invalid nonce returns above without minting.
384 *
385 * connect() reuses an existing token, so a re-attach or a duplicate
386 * callback is idempotent and never rotates a paired client's secret.
387 */
388 if ( '' === Mcp_Pairing::site_token() ) {
389 Mcp_Pairing::connect( false );
390 }
391
392 return array(
393 'site_url' => self::site_url_canonical(),
394 'site_token' => Mcp_Pairing::site_token(),
395 'user_id' => (int) $uid,
396 );
397 }
398
399 /**
400 * The URL to redirect the admin to for OAuth attach. Carries the site
401 * URL + a fresh nonce; the hub reads these, logs the user in, and calls
402 * back to confirm.
403 */
404 /**
405 * Self-heal: ask the Hub whether THIS site (by its own token) is attached,
406 * and reconcile the local per-user state. This makes the "Connected" badge
407 * reliable even if the attach callback never fired (failed/slow/cached) —
408 * the Hub is the source of truth. Cached in a short transient so the panel
409 * doesn't make an outbound call on every render.
410 *
411 * @param bool $force Skip the cache (e.g. right after a Connect attempt).
412 */
413 public static function reconcile_with_hub( bool $force = false ): void {
414 $token = Mcp_Pairing::site_token();
415 if ( '' === $token ) {
416 return; // no token minted yet → definitely not attached
417 }
418
419 $cache_key = 'xspeed_hub_reconcile';
420 if ( ! $force && false !== get_transient( $cache_key ) ) {
421 return; // reconciled recently
422 }
423
424 $url = add_query_arg(
425 array( 'site_url' => rawurlencode( self::site_url_canonical() ) ),
426 self::hub_url() . '/api/site/attached'
427 );
428 $resp = wp_remote_get(
429 $url,
430 array(
431 'timeout' => 8,
432 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
433 )
434 );
435 // Cache for 5 min regardless — don't hammer the Hub on transient errors.
436 set_transient( $cache_key, 1, 5 * MINUTE_IN_SECONDS );
437
438 if ( is_wp_error( $resp ) || 200 !== wp_remote_retrieve_response_code( $resp ) ) {
439 return; // leave local state as-is on any error
440 }
441 $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
442 if ( ! is_array( $body ) ) {
443 return;
444 }
445
446 $uid = get_current_user_id();
447 if ( ! empty( $body['attached'] ) ) {
448 // The Hub says attached — mark THIS admin connected if not already.
449 $state = self::state( $uid );
450 if ( empty( $state['attached'] ) ) {
451 self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid );
452 }
453 } elseif ( $uid ) {
454 // The Hub says NOT attached (e.g. removed on the dashboard) — clear
455 // any stale local "connected" so the badge doesn't lie.
456 $state = self::state( $uid );
457 if ( ! empty( $state['attached'] ) ) {
458 delete_user_meta( $uid, self::USER_META );
459 self::refresh_site_attached();
460 }
461 }
462 }
463
464 /**
465 * One-click attach redirect (Method 2). The Hub logs the user in, approves,
466 * calls back to this site's /attach route to record the link, then bounces
467 * the browser to `return_url` so the user lands back in the plugin without
468 * navigating manually.
469 *
470 * @param string $return_url Where the Hub should send the browser after a
471 * successful attach. Defaults to the dashboard.
472 * Callers pass the wizard URL during onboarding so
473 * the user returns mid-flow. Must be a local admin
474 * URL — we never hand the Hub an off-site redirect.
475 */
476 public static function attach_url( string $return_url = '' ): string {
477 $args = array(
478 'site_url' => self::site_url_canonical(),
479 'nonce' => self::mint_attach_nonce(),
480 );
481 // Prefill hint only — the current admin's email, so a brand-new user
482 // can create/sign into their Hub account in one click without typing.
483 // The Hub NEVER trusts this for auth; it only pre-populates the field
484 // and still requires the user to verify (magic-link / Google).
485 $email = self::current_admin_email();
486 if ( '' !== $email ) {
487 $args['email'] = $email;
488 }
489 // Where to send the user after they approve. Constrained to a local
490 // admin URL so a tampered value can't turn this into an open redirect.
491 $args['return_url'] = self::safe_return_url( $return_url );
492 return self::hub_url() . '/attach?' . http_build_query( $args );
493 }
494
495 /**
496 * Sanitize a caller-supplied return URL down to a safe, local admin URL.
497 * Falls back to the dashboard for anything off-site or empty, so the value
498 * we hand the Hub can never become an open redirect back into this site.
499 */
500 private static function safe_return_url( string $return_url ): string {
501 $default = admin_url( 'admin.php?page=' . \XSpeed\Admin::PAGE_SLUG );
502 if ( '' === $return_url ) {
503 return $default;
504 }
505 // wp_validate_redirect() returns the fallback for any host not in the
506 // allowed list (defaults to this site's host), so an attacker-supplied
507 // absolute URL to another domain collapses to the dashboard.
508 return wp_validate_redirect( $return_url, $default );
509 }
510
511 /** The logged-in admin's email (used only as a Hub sign-in prefill hint). */
512 private static function current_admin_email(): string {
513 $user = wp_get_current_user();
514 if ( $user && ! empty( $user->user_email ) && is_email( $user->user_email ) ) {
515 return (string) $user->user_email;
516 }
517 $admin = get_option( 'admin_email' );
518 return is_string( $admin ) && is_email( $admin ) ? $admin : '';
519 }
520
521 /**
522 * Record that this site is attached to a hub account, PER USER. Called
523 * from the attach callback with the minting admin's user id (from the
524 * nonce), so each admin gets their own status. Bookkeeping only.
525 *
526 * @param string $account_email The hub account the site was attached to.
527 * @param int|null $user_id The admin who attached (defaults to current).
528 */
529 public static function mark_attached( string $account_email, ?int $user_id = null ): array {
530 $user_id = $user_id ?? get_current_user_id();
531 if ( $user_id ) {
532 update_user_meta(
533 $user_id,
534 self::USER_META,
535 array(
536 'attached' => true,
537 'account_email' => sanitize_email( $account_email ),
538 'attached_at' => time(),
539 )
540 );
541 }
542 // Attaching makes the site-level answer unconditionally yes.
543 update_option( self::SITE_ATTACHED_OPTION, '1', false );
544 // Bust the reconcile cache so a reconnect reflects immediately (not the
545 // stale 'not attached' cached during the disconnected window).
546 delete_transient( 'xspeed_hub_reconcile' );
547 return self::public_status( $user_id );
548 }
549
550 /**
551 * Disconnect the CURRENT admin from the hub: clear their per-user link.
552 * 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.
555 */
556 public static function disconnect(): array {
557 $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
561 // Detach from the Hub for THIS admin's account only (multi-admin: other
562 // admins who attached keep their link). The site token proves ownership;
563 // account_email scopes the removal.
564 $token = Mcp_Pairing::site_token();
565 if ( '' !== $token && '' !== $email ) {
566 wp_remote_post(
567 self::hub_url() . '/api/site/detach',
568 array(
569 'timeout' => 8,
570 'headers' => array(
571 'Content-Type' => 'application/json',
572 'X-XSpeed-Site-Token' => $token,
573 ),
574 'body' => wp_json_encode(
575 array(
576 'site_url' => self::site_url_canonical(),
577 'account_email' => $email,
578 )
579 ),
580 )
581 );
582 }
583
584 if ( $user_id ) {
585 delete_user_meta( $user_id, self::USER_META );
586 }
587
588 /*
589 * Also clear the legacy site-wide option. state() falls back to it when
590 * a user has no per-user record, so deleting only the user meta left
591 * that fallback intact — disconnect() returned attached:true and the
592 * card stayed "Connected", making the button look broken. Anyone who
593 * attached before 1.1 (or via the redirect-return handler, which writes
594 * the option) hit this. (FBS-84086)
595 *
596 * The option is a single site-wide record, not per-admin, so there is
597 * no other admin's link being discarded here — the per-user meta above
598 * is what scopes multi-admin, and each admin's own meta is untouched.
599 */
600 delete_option( self::OPTION );
601
602 // Other admins may still be attached — recompute rather than assume no.
603 self::refresh_site_attached();
604
605 // Bust the reconcile cache so the next status read reflects reality
606 // immediately (not the stale 'attached' cached before disconnect).
607 delete_transient( 'xspeed_hub_reconcile' );
608 return self::public_status( $user_id );
609 }
610
611 /**
612 * Ask the Hub to run a GTmetrix test for this site.
613 *
614 * The Hub owns the GTmetrix account, the credits and the quota — this site
615 * only proves who it is, with the same site_token it uses everywhere else.
616 * That is the whole point of the feature: the site owner needs no GTmetrix
617 * account and no API key.
618 *
619 * Returns the Hub's decoded body on success (a run row plus the remaining
620 * allowance). On failure returns a WP_Error whose CODE is stable and
621 * machine-readable, so the UI can respond to "you're out of tests this
622 * month" differently from "this site isn't verified" instead of printing
623 * whatever sentence came back.
624 *
625 * @return array<string,mixed>|\WP_Error
626 */
627 public static function gtmetrix_test() {
628 return self::gtmetrix_request( 'POST', '/api/site/gtmetrix/test' );
629 }
630
631 /**
632 * Recent Hub-run tests for this site, plus the remaining allowance.
633 *
634 * Polled while a run is in flight, and read once on load so the button can
635 * show the count before anyone presses anything.
636 *
637 * @return array<string,mixed>|\WP_Error
638 */
639 public static function gtmetrix_runs() {
640 $result = self::gtmetrix_request( 'GET', '/api/site/gtmetrix/runs' );
641 if ( ! is_wp_error( $result ) ) {
642 self::store_hub_results( $result );
643 }
644 return $result;
645 }
646
647 /**
648 * Copy any finished Hub runs into THIS SITE's own score history.
649 *
650 * The Hub stores the result too, but that is its copy, not ours. Without
651 * this the plugin would have to ask the Hub every time it wanted to draw
652 * a score it already paid for — and a site that later disconnects would
653 * lose its history entirely. The run belongs to the site.
654 *
655 * Idempotent: the Hub reports a finished run on every poll after it
656 * completes, so each result is matched on provider + timestamp and stored
657 * once.
658 *
659 * @param array<string,mixed> $payload Decoded /site/gtmetrix/runs body.
660 */
661 private static function store_hub_results( array $payload ): void {
662 $runs = isset( $payload['runs'] ) && is_array( $payload['runs'] ) ? $payload['runs'] : array();
663 if ( empty( $runs ) ) {
664 return;
665 }
666
667 foreach ( $runs as $run ) {
668 if ( ! is_array( $run ) || 'done' !== ( $run['status'] ?? '' ) ) {
669 continue;
670 }
671 $r = isset( $run['result'] ) && is_array( $run['result'] ) ? $run['result'] : array();
672 if ( empty( $r ) ) {
673 continue;
674 }
675
676 // The Hub works in milliseconds; the plugin's history is seconds.
677 $ts = isset( $r['ran_at'] ) ? (int) round( ( (int) $r['ran_at'] ) / 1000 ) : 0;
678 $remote_id = isset( $run['id'] ) ? (string) $run['id'] : '';
679 // Keyed on the Hub's run id, not the timestamp: a retry and the
680 // original delivery can differ by milliseconds and both looked
681 // new, so one test appeared twice in the history.
682 if ( $ts <= 0 || '' === $remote_id || Score_Store::exists_remote( $remote_id ) ) {
683 continue;
684 }
685
686 Score_Store::insert(
687 array(
688 'ok' => true,
689 'provider' => 'gtmetrix',
690 'ts' => $ts,
691 'url' => (string) ( $r['url'] ?? '' ),
692 'strategy' => (string) ( $r['strategy'] ?? 'desktop' ),
693 'score' => $r['score'] ?? null,
694 'metrics' => array(
695 'lcp' => $r['lcp'] ?? null,
696 'fcp' => $r['fcp'] ?? null,
697 'cls' => $r['cls'] ?? null,
698 'tbt' => $r['tbt'] ?? null,
699 'si' => $r['si'] ?? null,
700 'ttfb' => $r['ttfb'] ?? null,
701 ),
702 'report_url' => $r['report_url'] ?? null,
703 'remote_id' => $remote_id,
704 // What the report said to fix — stored here so the panel
705 // can show it without sending anyone to GTmetrix's page.
706 'opportunities' => $r['opportunities'] ?? null,
707 ),
708 'hub'
709 );
710 }
711 }
712
713 /**
714 * Shared transport for the two calls above.
715 *
716 * Kept private and shared because the interesting part — turning an HTTP
717 * failure into a stable error code — must behave identically for both. A
718 * divergence there would show up as the UI handling a quota error on one
719 * path and not the other.
720 *
721 * @param string $method HTTP method.
722 * @param string $path Path under the hub base URL.
723 * @return array<string,mixed>|\WP_Error
724 */
725 private static function gtmetrix_request( string $method, string $path ) {
726 $token = Mcp_Pairing::site_token();
727 if ( '' === $token ) {
728 return new \WP_Error(
729 'not_connected',
730 __( 'Connect this site to xSpeed Hub to run a free GTmetrix test.', 'xspeed' )
731 );
732 }
733
734 $site_url = self::site_url_canonical();
735 $args = array(
736 // A GTmetrix test takes a minute, but the Hub answers as soon as it
737 // has ACCEPTED the job — this waits for that handshake only.
738 'timeout' => 15,
739 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
740 );
741
742 if ( 'POST' === $method ) {
743 $args['headers']['Content-Type'] = 'application/json';
744 $args['body'] = wp_json_encode( array( 'site_url' => $site_url ) );
745 $resp = wp_remote_post( self::hub_url() . $path, $args );
746 } else {
747 $resp = wp_remote_get(
748 add_query_arg( array( 'site_url' => rawurlencode( $site_url ) ), self::hub_url() . $path ),
749 $args
750 );
751 }
752
753 if ( is_wp_error( $resp ) ) {
754 return new \WP_Error(
755 'hub_unreachable',
756 __( 'Could not reach xSpeed Hub. Please try again.', 'xspeed' )
757 );
758 }
759
760 $code = (int) wp_remote_retrieve_response_code( $resp );
761 $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
762 $body = is_array( $body ) ? $body : array();
763
764 if ( $code >= 200 && $code < 300 ) {
765 return $body;
766 }
767
768 // Prefer the Hub's own error code — it is already stable and specific
769 // (site_not_verified, gtmetrix_quota_exceeded, gtmetrix_run_active,
770 // gtmetrix_not_configured). Fall back to the status class so an
771 // unexpected response still produces something the UI can branch on.
772 $code_key = isset( $body['error'] ) && is_string( $body['error'] ) ? $body['error'] : '';
773 if ( '' === $code_key ) {
774 $code_key = 401 === $code ? 'not_connected' : 'hub_error';
775 }
776
777 $message = isset( $body['message'] ) && is_string( $body['message'] ) && '' !== $body['message']
778 ? $body['message']
779 : __( 'The test could not be started.', 'xspeed' );
780
781 // Carry the quota numbers through on a 429 so the panel can say
782 // "0 of 5 left" rather than just refusing.
783 $data = array( 'status' => $code );
784 foreach ( array( 'used', 'limit', 'quota', 'run' ) as $key ) {
785 if ( isset( $body[ $key ] ) ) {
786 $data[ $key ] = $body[ $key ];
787 }
788 }
789
790 return new \WP_Error( $code_key, $message, $data );
791 }
792 }
793