PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.5
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.5
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.1.5, at includes/modules/Mcp/Mcp_Hub.php

665 lines 24.7 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 /** Default hub dashboard base — where the user manages their account. */
49 public const DEFAULT_HUB_URL = 'https://app.xspeedcache.com';
50
51 /**
52 * The hub dashboard base URL. Overridable via the XSPEED_HUB_URL
53 * constant (wp-config.php) and the `xspeed_hub_url` filter so dev /
54 * self-hosted deployments can point elsewhere.
55 */
56 public static function hub_url(): string {
57 $url = self::DEFAULT_HUB_URL;
58 if ( defined( 'XSPEED_HUB_URL' ) && is_string( constant( 'XSPEED_HUB_URL' ) ) && '' !== constant( 'XSPEED_HUB_URL' ) ) {
59 $url = (string) constant( 'XSPEED_HUB_URL' );
60 }
61 /** Filter the xSpeed Hub base URL. */
62 $url = (string) apply_filters( 'xspeed_hub_url', $url );
63 return untrailingslashit( $url );
64 }
65
66 /**
67 * Hub-link state for the CURRENT admin (per-user). Falls back to the legacy
68 * site-wide option for sites attached before the per-user migration, so an
69 * existing connection still shows until re-attached.
70 *
71 * @param int|null $user_id Which user (defaults to the current user).
72 * @return array{attached:bool,account_email:string,attached_at:int}
73 */
74 public static function state( ?int $user_id = null ): array {
75 $user_id = $user_id ?? get_current_user_id();
76 $stored = $user_id ? get_user_meta( $user_id, self::USER_META, true ) : array();
77
78 // Backward-compat: fall back to the old site-wide option if this user
79 // has no per-user record yet (pre-1.1 attach).
80 if ( ! is_array( $stored ) || empty( $stored ) ) {
81 $legacy = get_option( self::OPTION, array() );
82 $stored = is_array( $legacy ) ? $legacy : array();
83 }
84
85 return array(
86 'attached' => ! empty( $stored['attached'] ),
87 'account_email' => isset( $stored['account_email'] ) ? (string) $stored['account_email'] : '',
88 'attached_at' => isset( $stored['attached_at'] ) ? (int) $stored['attached_at'] : 0,
89 );
90 }
91
92 /**
93 * Public snapshot for the dashboard "xSpeed Hub" card.
94 *
95 * Includes the paste-in values for Method 1 (this site's URL + token)
96 * and a link to the hub dashboard. The token is admin-only (the whole
97 * REST route is gated by manage_options in McpModule).
98 *
99 * @return array<string,mixed>
100 */
101 public static function public_status( ?int $user_id = null ): array {
102 $state = self::state( $user_id );
103 return array(
104 'attached' => $state['attached'],
105 'account_email' => $state['account_email'],
106 'attached_at' => $state['attached_at'],
107 'site_url' => home_url( '/' ),
108 // Method 1 paste-in credential — the existing per-site token.
109 // Empty until generate_token() (or a per-site Connect) mints one.
110 'site_token' => Mcp_Pairing::site_token(),
111 'hub_url' => self::hub_url(),
112 // Where the user goes to paste the URL + token (Add site form).
113 'add_site_url' => self::hub_url() . '/sites/add',
114 // Method 2 (OAuth attach) — one-click redirect with a fresh nonce.
115 'attach_url' => self::attach_url(),
116 // Non-public site? Connecting still works (token returns via the
117 // browser redirect), but Hub-initiated AI control needs a public
118 // URL — surfaced as an honest note on the Connect surfaces.
119 'is_local' => self::is_local_site(),
120 );
121 }
122
123 /**
124 * Method 1 — ensure a site_token exists and return the paste-in values.
125 *
126 * Reuses Mcp_Pairing::connect() so the Hub credential is the SAME token
127 * the per-site path uses (no second secret, no drift). Idempotent: if a
128 * token already exists it is reused, not rotated, so an already-attached
129 * hub keeps working.
130 *
131 * @return array<string,mixed> Public status including site_url + site_token.
132 */
133 public static function generate_token(): array {
134 if ( '' === Mcp_Pairing::site_token() ) {
135 // Mint (read-write by default) so the token exists to hand over.
136 Mcp_Pairing::connect( false );
137 }
138 return self::public_status();
139 }
140
141 /**
142 * Method 2 (OAuth attach) — the one-click flow.
143 *
144 * The admin clicks "Connect via OAuth" in the Hub tab. We mint a
145 * short-lived signed nonce and redirect the browser to the hub's
146 * /attach page carrying { site_url, nonce }. The hub (after the user
147 * logs into their account) calls BACK to this site's
148 * /xspeed/v1/mcp/attach with the nonce; verify_attach_nonce() checks
149 * it and hands the hub the site_token. Because the nonce is HMAC-signed
150 * with this site's secret AND minting is admin-only, only a site admin
151 * can start an attach — no token is ever pasted or shown.
152 */
153
154 /** Nonce validity window (seconds). */
155 private const NONCE_TTL = 600;
156
157 /** Per-site signing secret for attach nonces (derived from WP salts). */
158 private static function nonce_secret(): string {
159 return wp_hash( 'xspeed_hub_attach|' . self::site_url_canonical() );
160 }
161
162 /** Canonical site URL used in the nonce + sent to the hub. */
163 private static function site_url_canonical(): string {
164 return untrailingslashit( home_url( '/' ) );
165 }
166
167 /**
168 * Heuristic: is this site NOT publicly reachable from the internet? A local /
169 * dev / firewalled site can still CONNECT (the token comes back through the
170 * admin's own browser redirect), but the Hub's servers can't reach it back,
171 * so Hub-initiated AI control won't work until it's on a public URL. We use
172 * this only to show an honest heads-up on the Connect surfaces — never to
173 * block connecting.
174 *
175 * True when WP reports a local environment, or the host is a well-known dev
176 * TLD / localhost / a private or loopback IP.
177 */
178 public static function is_local_site(): bool {
179 $host = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
180 if ( ! is_string( $host ) || '' === $host ) {
181 return false;
182 }
183 $host = strtolower( $host );
184
185 if ( 'localhost' === $host ) {
186 return true;
187 }
188 // Common local/dev TLDs used by local WP stacks (sandbox .sb, Local by
189 // Flywheel .local, *.test, *.dev, *.example, *.invalid).
190 foreach ( array( '.sb', '.test', '.local', '.localhost', '.dev', '.example', '.invalid' ) as $suffix ) {
191 if ( substr( $host, -strlen( $suffix ) ) === $suffix ) {
192 return true;
193 }
194 }
195 // Loopback / private-range IP literal (10/8, 172.16/12, 192.168/16, 127/8).
196 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
197 return ! filter_var(
198 $host,
199 FILTER_VALIDATE_IP,
200 FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
201 );
202 }
203
204 /*
205 * WP_ENVIRONMENT_TYPE is deliberately NOT trusted on its own.
206 *
207 * It describes a WORKFLOW — local / development / staging /
208 * production — not whether the internet can reach this site. Plenty
209 * of real, publicly served sites are marked 'local' by their stack:
210 * our own xsdev.1wp.site does exactly that, and told every visitor
211 * "this site looks local" on a public HTTPS domain.
212 *
213 * The hostname above is the honest signal. This only corroborates it,
214 * for a site whose name gives nothing away (an IP-less internal
215 * hostname on a private network, say) — and only when the name is
216 * also not a public FQDN.
217 */
218 if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) {
219 // A dotted name that resolves publicly is reachable whatever the
220 // environment type claims; a bare hostname ("wordpress", "web")
221 // is not resolvable from outside and genuinely is local.
222 return false === strpos( $host, '.' );
223 }
224
225 return false;
226 }
227
228 /**
229 * Mint a signed, time-bound attach nonce. Format: <ts>.<uid>.<hmac>.
230 * Admin-only (the REST route that calls this is gated by manage_options).
231 * The minting admin's user ID is embedded so the (WP-userless) attach
232 * callback can record the connection PER-USER — each admin sees their own
233 * "Connected via <their account>" status.
234 */
235 public static function mint_attach_nonce(): string {
236 // Ensure a site_token exists to hand over on the callback.
237 if ( '' === Mcp_Pairing::site_token() ) {
238 Mcp_Pairing::connect( false );
239 }
240 $ts = time();
241 $uid = get_current_user_id();
242 $hmac = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
243 return $ts . '.' . $uid . '.' . $hmac;
244 }
245
246 /**
247 * Verify an attach nonce (constant-time, within TTL). On success returns
248 * the paste-in values (site_url + site_token) plus the minting admin's
249 * user ID; on failure returns null. Called by the token-authless
250 * /mcp/attach route.
251 *
252 * @return array{site_url:string,site_token:string,user_id:int}|null
253 */
254 public static function verify_attach_nonce( string $nonce ): ?array {
255 $parts = explode( '.', $nonce, 3 );
256 if ( 3 !== count( $parts ) ) {
257 return null;
258 }
259 list( $ts, $uid, $hmac ) = $parts;
260 if ( ! ctype_digit( (string) $ts ) || ! ctype_digit( (string) $uid ) ) {
261 return null;
262 }
263 if ( abs( time() - (int) $ts ) > self::NONCE_TTL ) {
264 return null; // expired
265 }
266 $expected = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
267 if ( ! hash_equals( $expected, (string) $hmac ) ) {
268 return null; // bad signature
269 }
270 return array(
271 'site_url' => self::site_url_canonical(),
272 'site_token' => Mcp_Pairing::site_token(),
273 'user_id' => (int) $uid,
274 );
275 }
276
277 /**
278 * The URL to redirect the admin to for OAuth attach. Carries the site
279 * URL + a fresh nonce; the hub reads these, logs the user in, and calls
280 * back to confirm.
281 */
282 /**
283 * Self-heal: ask the Hub whether THIS site (by its own token) is attached,
284 * and reconcile the local per-user state. This makes the "Connected" badge
285 * reliable even if the attach callback never fired (failed/slow/cached) —
286 * the Hub is the source of truth. Cached in a short transient so the panel
287 * doesn't make an outbound call on every render.
288 *
289 * @param bool $force Skip the cache (e.g. right after a Connect attempt).
290 */
291 public static function reconcile_with_hub( bool $force = false ): void {
292 $token = Mcp_Pairing::site_token();
293 if ( '' === $token ) {
294 return; // no token minted yet → definitely not attached
295 }
296
297 $cache_key = 'xspeed_hub_reconcile';
298 if ( ! $force && false !== get_transient( $cache_key ) ) {
299 return; // reconciled recently
300 }
301
302 $url = add_query_arg(
303 array( 'site_url' => rawurlencode( self::site_url_canonical() ) ),
304 self::hub_url() . '/api/site/attached'
305 );
306 $resp = wp_remote_get(
307 $url,
308 array(
309 'timeout' => 8,
310 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
311 )
312 );
313 // Cache for 5 min regardless — don't hammer the Hub on transient errors.
314 set_transient( $cache_key, 1, 5 * MINUTE_IN_SECONDS );
315
316 if ( is_wp_error( $resp ) || 200 !== wp_remote_retrieve_response_code( $resp ) ) {
317 return; // leave local state as-is on any error
318 }
319 $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
320 if ( ! is_array( $body ) ) {
321 return;
322 }
323
324 $uid = get_current_user_id();
325 if ( ! empty( $body['attached'] ) ) {
326 // The Hub says attached — mark THIS admin connected if not already.
327 $state = self::state( $uid );
328 if ( empty( $state['attached'] ) ) {
329 self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid );
330 }
331 } elseif ( $uid ) {
332 // The Hub says NOT attached (e.g. removed on the dashboard) — clear
333 // any stale local "connected" so the badge doesn't lie.
334 $state = self::state( $uid );
335 if ( ! empty( $state['attached'] ) ) {
336 delete_user_meta( $uid, self::USER_META );
337 }
338 }
339 }
340
341 /**
342 * One-click attach redirect (Method 2). The Hub logs the user in, approves,
343 * calls back to this site's /attach route to record the link, then bounces
344 * the browser to `return_url` so the user lands back in the plugin without
345 * navigating manually.
346 *
347 * @param string $return_url Where the Hub should send the browser after a
348 * successful attach. Defaults to the dashboard.
349 * Callers pass the wizard URL during onboarding so
350 * the user returns mid-flow. Must be a local admin
351 * URL — we never hand the Hub an off-site redirect.
352 */
353 public static function attach_url( string $return_url = '' ): string {
354 $args = array(
355 'site_url' => self::site_url_canonical(),
356 'nonce' => self::mint_attach_nonce(),
357 );
358 // Prefill hint only — the current admin's email, so a brand-new user
359 // can create/sign into their Hub account in one click without typing.
360 // The Hub NEVER trusts this for auth; it only pre-populates the field
361 // and still requires the user to verify (magic-link / Google).
362 $email = self::current_admin_email();
363 if ( '' !== $email ) {
364 $args['email'] = $email;
365 }
366 // Where to send the user after they approve. Constrained to a local
367 // admin URL so a tampered value can't turn this into an open redirect.
368 $args['return_url'] = self::safe_return_url( $return_url );
369 return self::hub_url() . '/attach?' . http_build_query( $args );
370 }
371
372 /**
373 * Sanitize a caller-supplied return URL down to a safe, local admin URL.
374 * Falls back to the dashboard for anything off-site or empty, so the value
375 * we hand the Hub can never become an open redirect back into this site.
376 */
377 private static function safe_return_url( string $return_url ): string {
378 $default = admin_url( 'admin.php?page=' . \XSpeed\Admin::PAGE_SLUG );
379 if ( '' === $return_url ) {
380 return $default;
381 }
382 // wp_validate_redirect() returns the fallback for any host not in the
383 // allowed list (defaults to this site's host), so an attacker-supplied
384 // absolute URL to another domain collapses to the dashboard.
385 return wp_validate_redirect( $return_url, $default );
386 }
387
388 /** The logged-in admin's email (used only as a Hub sign-in prefill hint). */
389 private static function current_admin_email(): string {
390 $user = wp_get_current_user();
391 if ( $user && ! empty( $user->user_email ) && is_email( $user->user_email ) ) {
392 return (string) $user->user_email;
393 }
394 $admin = get_option( 'admin_email' );
395 return is_string( $admin ) && is_email( $admin ) ? $admin : '';
396 }
397
398 /**
399 * Record that this site is attached to a hub account, PER USER. Called
400 * from the attach callback with the minting admin's user id (from the
401 * nonce), so each admin gets their own status. Bookkeeping only.
402 *
403 * @param string $account_email The hub account the site was attached to.
404 * @param int|null $user_id The admin who attached (defaults to current).
405 */
406 public static function mark_attached( string $account_email, ?int $user_id = null ): array {
407 $user_id = $user_id ?? get_current_user_id();
408 if ( $user_id ) {
409 update_user_meta(
410 $user_id,
411 self::USER_META,
412 array(
413 'attached' => true,
414 'account_email' => sanitize_email( $account_email ),
415 'attached_at' => time(),
416 )
417 );
418 }
419 // Bust the reconcile cache so a reconnect reflects immediately (not the
420 // stale 'not attached' cached during the disconnected window).
421 delete_transient( 'xspeed_hub_reconcile' );
422 return self::public_status( $user_id );
423 }
424
425 /**
426 * Disconnect the CURRENT admin from the hub: clear their per-user link.
427 * Other admins' connections are untouched. Does NOT rotate the site_token
428 * (still used by the per-site connection); to fully cut off the hub the
429 * user rotates the token, which the Hub's stored copy then fails on.
430 */
431 public static function disconnect(): array {
432 $user_id = get_current_user_id();
433 $state = $user_id ? self::state( $user_id ) : array();
434 $email = isset( $state['account_email'] ) ? (string) $state['account_email'] : '';
435
436 // Detach from the Hub for THIS admin's account only (multi-admin: other
437 // admins who attached keep their link). The site token proves ownership;
438 // account_email scopes the removal.
439 $token = Mcp_Pairing::site_token();
440 if ( '' !== $token && '' !== $email ) {
441 wp_remote_post(
442 self::hub_url() . '/api/site/detach',
443 array(
444 'timeout' => 8,
445 'headers' => array(
446 'Content-Type' => 'application/json',
447 'X-XSpeed-Site-Token' => $token,
448 ),
449 'body' => wp_json_encode(
450 array(
451 'site_url' => self::site_url_canonical(),
452 'account_email' => $email,
453 )
454 ),
455 )
456 );
457 }
458
459 if ( $user_id ) {
460 delete_user_meta( $user_id, self::USER_META );
461 }
462
463 /*
464 * Also clear the legacy site-wide option. state() falls back to it when
465 * a user has no per-user record, so deleting only the user meta left
466 * that fallback intact — disconnect() returned attached:true and the
467 * card stayed "Connected", making the button look broken. Anyone who
468 * attached before 1.1 (or via the redirect-return handler, which writes
469 * the option) hit this. (FBS-84086)
470 *
471 * The option is a single site-wide record, not per-admin, so there is
472 * no other admin's link being discarded here — the per-user meta above
473 * is what scopes multi-admin, and each admin's own meta is untouched.
474 */
475 delete_option( self::OPTION );
476
477 // Bust the reconcile cache so the next status read reflects reality
478 // immediately (not the stale 'attached' cached before disconnect).
479 delete_transient( 'xspeed_hub_reconcile' );
480 return self::public_status( $user_id );
481 }
482
483 /**
484 * Ask the Hub to run a GTmetrix test for this site.
485 *
486 * The Hub owns the GTmetrix account, the credits and the quota — this site
487 * only proves who it is, with the same site_token it uses everywhere else.
488 * That is the whole point of the feature: the site owner needs no GTmetrix
489 * account and no API key.
490 *
491 * Returns the Hub's decoded body on success (a run row plus the remaining
492 * allowance). On failure returns a WP_Error whose CODE is stable and
493 * machine-readable, so the UI can respond to "you're out of tests this
494 * month" differently from "this site isn't verified" instead of printing
495 * whatever sentence came back.
496 *
497 * @return array<string,mixed>|\WP_Error
498 */
499 public static function gtmetrix_test() {
500 return self::gtmetrix_request( 'POST', '/api/site/gtmetrix/test' );
501 }
502
503 /**
504 * Recent Hub-run tests for this site, plus the remaining allowance.
505 *
506 * Polled while a run is in flight, and read once on load so the button can
507 * show the count before anyone presses anything.
508 *
509 * @return array<string,mixed>|\WP_Error
510 */
511 public static function gtmetrix_runs() {
512 $result = self::gtmetrix_request( 'GET', '/api/site/gtmetrix/runs' );
513 if ( ! is_wp_error( $result ) ) {
514 self::store_hub_results( $result );
515 }
516 return $result;
517 }
518
519 /**
520 * Copy any finished Hub runs into THIS SITE's own score history.
521 *
522 * The Hub stores the result too, but that is its copy, not ours. Without
523 * this the plugin would have to ask the Hub every time it wanted to draw
524 * a score it already paid for — and a site that later disconnects would
525 * lose its history entirely. The run belongs to the site.
526 *
527 * Idempotent: the Hub reports a finished run on every poll after it
528 * completes, so each result is matched on provider + timestamp and stored
529 * once.
530 *
531 * @param array<string,mixed> $payload Decoded /site/gtmetrix/runs body.
532 */
533 private static function store_hub_results( array $payload ): void {
534 $runs = isset( $payload['runs'] ) && is_array( $payload['runs'] ) ? $payload['runs'] : array();
535 if ( empty( $runs ) ) {
536 return;
537 }
538
539 foreach ( $runs as $run ) {
540 if ( ! is_array( $run ) || 'done' !== ( $run['status'] ?? '' ) ) {
541 continue;
542 }
543 $r = isset( $run['result'] ) && is_array( $run['result'] ) ? $run['result'] : array();
544 if ( empty( $r ) ) {
545 continue;
546 }
547
548 // The Hub works in milliseconds; the plugin's history is seconds.
549 $ts = isset( $r['ran_at'] ) ? (int) round( ( (int) $r['ran_at'] ) / 1000 ) : 0;
550 $remote_id = isset( $run['id'] ) ? (string) $run['id'] : '';
551 // Keyed on the Hub's run id, not the timestamp: a retry and the
552 // original delivery can differ by milliseconds and both looked
553 // new, so one test appeared twice in the history.
554 if ( $ts <= 0 || '' === $remote_id || Score_Store::exists_remote( $remote_id ) ) {
555 continue;
556 }
557
558 Score_Store::insert(
559 array(
560 'ok' => true,
561 'provider' => 'gtmetrix',
562 'ts' => $ts,
563 'url' => (string) ( $r['url'] ?? '' ),
564 'strategy' => (string) ( $r['strategy'] ?? 'desktop' ),
565 'score' => $r['score'] ?? null,
566 'metrics' => array(
567 'lcp' => $r['lcp'] ?? null,
568 'fcp' => $r['fcp'] ?? null,
569 'cls' => $r['cls'] ?? null,
570 'tbt' => $r['tbt'] ?? null,
571 'si' => $r['si'] ?? null,
572 'ttfb' => $r['ttfb'] ?? null,
573 ),
574 'report_url' => $r['report_url'] ?? null,
575 'remote_id' => $remote_id,
576 // What the report said to fix — stored here so the panel
577 // can show it without sending anyone to GTmetrix's page.
578 'opportunities' => $r['opportunities'] ?? null,
579 ),
580 'hub'
581 );
582 }
583 }
584
585 /**
586 * Shared transport for the two calls above.
587 *
588 * Kept private and shared because the interesting part — turning an HTTP
589 * failure into a stable error code — must behave identically for both. A
590 * divergence there would show up as the UI handling a quota error on one
591 * path and not the other.
592 *
593 * @param string $method HTTP method.
594 * @param string $path Path under the hub base URL.
595 * @return array<string,mixed>|\WP_Error
596 */
597 private static function gtmetrix_request( string $method, string $path ) {
598 $token = Mcp_Pairing::site_token();
599 if ( '' === $token ) {
600 return new \WP_Error(
601 'not_connected',
602 __( 'Connect this site to xSpeed Hub to run a free GTmetrix test.', 'xspeed' )
603 );
604 }
605
606 $site_url = self::site_url_canonical();
607 $args = array(
608 // A GTmetrix test takes a minute, but the Hub answers as soon as it
609 // has ACCEPTED the job — this waits for that handshake only.
610 'timeout' => 15,
611 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
612 );
613
614 if ( 'POST' === $method ) {
615 $args['headers']['Content-Type'] = 'application/json';
616 $args['body'] = wp_json_encode( array( 'site_url' => $site_url ) );
617 $resp = wp_remote_post( self::hub_url() . $path, $args );
618 } else {
619 $resp = wp_remote_get(
620 add_query_arg( array( 'site_url' => rawurlencode( $site_url ) ), self::hub_url() . $path ),
621 $args
622 );
623 }
624
625 if ( is_wp_error( $resp ) ) {
626 return new \WP_Error(
627 'hub_unreachable',
628 __( 'Could not reach xSpeed Hub. Please try again.', 'xspeed' )
629 );
630 }
631
632 $code = (int) wp_remote_retrieve_response_code( $resp );
633 $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
634 $body = is_array( $body ) ? $body : array();
635
636 if ( $code >= 200 && $code < 300 ) {
637 return $body;
638 }
639
640 // Prefer the Hub's own error code — it is already stable and specific
641 // (site_not_verified, gtmetrix_quota_exceeded, gtmetrix_run_active,
642 // gtmetrix_not_configured). Fall back to the status class so an
643 // unexpected response still produces something the UI can branch on.
644 $code_key = isset( $body['error'] ) && is_string( $body['error'] ) ? $body['error'] : '';
645 if ( '' === $code_key ) {
646 $code_key = 401 === $code ? 'not_connected' : 'hub_error';
647 }
648
649 $message = isset( $body['message'] ) && is_string( $body['message'] ) && '' !== $body['message']
650 ? $body['message']
651 : __( 'The test could not be started.', 'xspeed' );
652
653 // Carry the quota numbers through on a 429 so the panel can say
654 // "0 of 5 left" rather than just refusing.
655 $data = array( 'status' => $code );
656 foreach ( array( 'used', 'limit', 'quota', 'run' ) as $key ) {
657 if ( isset( $body[ $key ] ) ) {
658 $data[ $key ] = $body[ $key ];
659 }
660 }
661
662 return new \WP_Error( $code_key, $message, $data );
663 }
664 }
665