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

463 lines 17.4 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 final class Mcp_Hub {
37
38 /** Option key holding hub-link state (separate from pairing state). */
39 public const OPTION = 'xspeed_module_mcp_hub';
40
41 /** Per-user meta key holding THIS admin's hub connection state. A WP site
42 * can have many admins, each managing it from their own hub account, so
43 * the connection is per-user, not site-wide. */
44 public const USER_META = 'xspeed_hub_link';
45
46 /** Default hub dashboard base — where the user manages their account. */
47 public const DEFAULT_HUB_URL = 'https://app.xspeedcache.com';
48
49 /**
50 * The hub dashboard base URL. Overridable via the XSPEED_HUB_URL
51 * constant (wp-config.php) and the `xspeed_hub_url` filter so dev /
52 * self-hosted deployments can point elsewhere.
53 */
54 public static function hub_url(): string {
55 $url = self::DEFAULT_HUB_URL;
56 if ( defined( 'XSPEED_HUB_URL' ) && is_string( constant( 'XSPEED_HUB_URL' ) ) && '' !== constant( 'XSPEED_HUB_URL' ) ) {
57 $url = (string) constant( 'XSPEED_HUB_URL' );
58 }
59 /** Filter the xSpeed Hub base URL. */
60 $url = (string) apply_filters( 'xspeed_hub_url', $url );
61 return untrailingslashit( $url );
62 }
63
64 /**
65 * Hub-link state for the CURRENT admin (per-user). Falls back to the legacy
66 * site-wide option for sites attached before the per-user migration, so an
67 * existing connection still shows until re-attached.
68 *
69 * @param int|null $user_id Which user (defaults to the current user).
70 * @return array{attached:bool,account_email:string,attached_at:int}
71 */
72 public static function state( ?int $user_id = null ): array {
73 $user_id = $user_id ?? get_current_user_id();
74 $stored = $user_id ? get_user_meta( $user_id, self::USER_META, true ) : array();
75
76 // Backward-compat: fall back to the old site-wide option if this user
77 // has no per-user record yet (pre-1.1 attach).
78 if ( ! is_array( $stored ) || empty( $stored ) ) {
79 $legacy = get_option( self::OPTION, array() );
80 $stored = is_array( $legacy ) ? $legacy : array();
81 }
82
83 return array(
84 'attached' => ! empty( $stored['attached'] ),
85 'account_email' => isset( $stored['account_email'] ) ? (string) $stored['account_email'] : '',
86 'attached_at' => isset( $stored['attached_at'] ) ? (int) $stored['attached_at'] : 0,
87 );
88 }
89
90 /**
91 * Public snapshot for the dashboard "xSpeed Hub" card.
92 *
93 * Includes the paste-in values for Method 1 (this site's URL + token)
94 * and a link to the hub dashboard. The token is admin-only (the whole
95 * REST route is gated by manage_options in McpModule).
96 *
97 * @return array<string,mixed>
98 */
99 public static function public_status( ?int $user_id = null ): array {
100 $state = self::state( $user_id );
101 return array(
102 'attached' => $state['attached'],
103 'account_email' => $state['account_email'],
104 'attached_at' => $state['attached_at'],
105 'site_url' => home_url( '/' ),
106 // Method 1 paste-in credential — the existing per-site token.
107 // Empty until generate_token() (or a per-site Connect) mints one.
108 'site_token' => Mcp_Pairing::site_token(),
109 'hub_url' => self::hub_url(),
110 // Where the user goes to paste the URL + token (Add site form).
111 'add_site_url' => self::hub_url() . '/sites/add',
112 // Method 2 (OAuth attach) — one-click redirect with a fresh nonce.
113 'attach_url' => self::attach_url(),
114 // Non-public site? Connecting still works (token returns via the
115 // browser redirect), but Hub-initiated AI control needs a public
116 // URL — surfaced as an honest note on the Connect surfaces.
117 'is_local' => self::is_local_site(),
118 );
119 }
120
121 /**
122 * Method 1 — ensure a site_token exists and return the paste-in values.
123 *
124 * Reuses Mcp_Pairing::connect() so the Hub credential is the SAME token
125 * the per-site path uses (no second secret, no drift). Idempotent: if a
126 * token already exists it is reused, not rotated, so an already-attached
127 * hub keeps working.
128 *
129 * @return array<string,mixed> Public status including site_url + site_token.
130 */
131 public static function generate_token(): array {
132 if ( '' === Mcp_Pairing::site_token() ) {
133 // Mint (read-write by default) so the token exists to hand over.
134 Mcp_Pairing::connect( false );
135 }
136 return self::public_status();
137 }
138
139 /**
140 * Method 2 (OAuth attach) — the one-click flow.
141 *
142 * The admin clicks "Connect via OAuth" in the Hub tab. We mint a
143 * short-lived signed nonce and redirect the browser to the hub's
144 * /attach page carrying { site_url, nonce }. The hub (after the user
145 * logs into their account) calls BACK to this site's
146 * /xspeed/v1/mcp/attach with the nonce; verify_attach_nonce() checks
147 * it and hands the hub the site_token. Because the nonce is HMAC-signed
148 * with this site's secret AND minting is admin-only, only a site admin
149 * can start an attach — no token is ever pasted or shown.
150 */
151
152 /** Nonce validity window (seconds). */
153 private const NONCE_TTL = 600;
154
155 /** Per-site signing secret for attach nonces (derived from WP salts). */
156 private static function nonce_secret(): string {
157 return wp_hash( 'xspeed_hub_attach|' . self::site_url_canonical() );
158 }
159
160 /** Canonical site URL used in the nonce + sent to the hub. */
161 private static function site_url_canonical(): string {
162 return untrailingslashit( home_url( '/' ) );
163 }
164
165 /**
166 * Heuristic: is this site NOT publicly reachable from the internet? A local /
167 * dev / firewalled site can still CONNECT (the token comes back through the
168 * admin's own browser redirect), but the Hub's servers can't reach it back,
169 * so Hub-initiated AI control won't work until it's on a public URL. We use
170 * this only to show an honest heads-up on the Connect surfaces — never to
171 * block connecting.
172 *
173 * True when WP reports a local environment, or the host is a well-known dev
174 * TLD / localhost / a private or loopback IP.
175 */
176 public static function is_local_site(): bool {
177 if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) {
178 return true;
179 }
180
181 $host = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
182 if ( ! is_string( $host ) || '' === $host ) {
183 return false;
184 }
185 $host = strtolower( $host );
186
187 if ( 'localhost' === $host ) {
188 return true;
189 }
190 // Common local/dev TLDs used by local WP stacks (sandbox .sb, Local by
191 // Flywheel .local, *.test, *.dev, *.example, *.invalid).
192 foreach ( array( '.sb', '.test', '.local', '.localhost', '.dev', '.example', '.invalid' ) as $suffix ) {
193 if ( substr( $host, -strlen( $suffix ) ) === $suffix ) {
194 return true;
195 }
196 }
197 // Loopback / private-range IP literal (10/8, 172.16/12, 192.168/16, 127/8).
198 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
199 return ! filter_var(
200 $host,
201 FILTER_VALIDATE_IP,
202 FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
203 );
204 }
205 return false;
206 }
207
208 /**
209 * Mint a signed, time-bound attach nonce. Format: <ts>.<uid>.<hmac>.
210 * Admin-only (the REST route that calls this is gated by manage_options).
211 * The minting admin's user ID is embedded so the (WP-userless) attach
212 * callback can record the connection PER-USER — each admin sees their own
213 * "Connected via <their account>" status.
214 */
215 public static function mint_attach_nonce(): string {
216 // Ensure a site_token exists to hand over on the callback.
217 if ( '' === Mcp_Pairing::site_token() ) {
218 Mcp_Pairing::connect( false );
219 }
220 $ts = time();
221 $uid = get_current_user_id();
222 $hmac = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
223 return $ts . '.' . $uid . '.' . $hmac;
224 }
225
226 /**
227 * Verify an attach nonce (constant-time, within TTL). On success returns
228 * the paste-in values (site_url + site_token) plus the minting admin's
229 * user ID; on failure returns null. Called by the token-authless
230 * /mcp/attach route.
231 *
232 * @return array{site_url:string,site_token:string,user_id:int}|null
233 */
234 public static function verify_attach_nonce( string $nonce ): ?array {
235 $parts = explode( '.', $nonce, 3 );
236 if ( 3 !== count( $parts ) ) {
237 return null;
238 }
239 list( $ts, $uid, $hmac ) = $parts;
240 if ( ! ctype_digit( (string) $ts ) || ! ctype_digit( (string) $uid ) ) {
241 return null;
242 }
243 if ( abs( time() - (int) $ts ) > self::NONCE_TTL ) {
244 return null; // expired
245 }
246 $expected = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
247 if ( ! hash_equals( $expected, (string) $hmac ) ) {
248 return null; // bad signature
249 }
250 return array(
251 'site_url' => self::site_url_canonical(),
252 'site_token' => Mcp_Pairing::site_token(),
253 'user_id' => (int) $uid,
254 );
255 }
256
257 /**
258 * The URL to redirect the admin to for OAuth attach. Carries the site
259 * URL + a fresh nonce; the hub reads these, logs the user in, and calls
260 * back to confirm.
261 */
262 /**
263 * Self-heal: ask the Hub whether THIS site (by its own token) is attached,
264 * and reconcile the local per-user state. This makes the "Connected" badge
265 * reliable even if the attach callback never fired (failed/slow/cached) —
266 * the Hub is the source of truth. Cached in a short transient so the panel
267 * doesn't make an outbound call on every render.
268 *
269 * @param bool $force Skip the cache (e.g. right after a Connect attempt).
270 */
271 public static function reconcile_with_hub( bool $force = false ): void {
272 $token = Mcp_Pairing::site_token();
273 if ( '' === $token ) {
274 return; // no token minted yet → definitely not attached
275 }
276
277 $cache_key = 'xspeed_hub_reconcile';
278 if ( ! $force && false !== get_transient( $cache_key ) ) {
279 return; // reconciled recently
280 }
281
282 $url = add_query_arg(
283 array( 'site_url' => rawurlencode( self::site_url_canonical() ) ),
284 self::hub_url() . '/api/site/attached'
285 );
286 $resp = wp_remote_get(
287 $url,
288 array(
289 'timeout' => 8,
290 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
291 )
292 );
293 // Cache for 5 min regardless — don't hammer the Hub on transient errors.
294 set_transient( $cache_key, 1, 5 * MINUTE_IN_SECONDS );
295
296 if ( is_wp_error( $resp ) || 200 !== wp_remote_retrieve_response_code( $resp ) ) {
297 return; // leave local state as-is on any error
298 }
299 $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
300 if ( ! is_array( $body ) ) {
301 return;
302 }
303
304 $uid = get_current_user_id();
305 if ( ! empty( $body['attached'] ) ) {
306 // The Hub says attached — mark THIS admin connected if not already.
307 $state = self::state( $uid );
308 if ( empty( $state['attached'] ) ) {
309 self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid );
310 }
311 } elseif ( $uid ) {
312 // The Hub says NOT attached (e.g. removed on the dashboard) — clear
313 // any stale local "connected" so the badge doesn't lie.
314 $state = self::state( $uid );
315 if ( ! empty( $state['attached'] ) ) {
316 delete_user_meta( $uid, self::USER_META );
317 }
318 }
319 }
320
321 /**
322 * One-click attach redirect (Method 2). The Hub logs the user in, approves,
323 * calls back to this site's /attach route to record the link, then bounces
324 * the browser to `return_url` so the user lands back in the plugin without
325 * navigating manually.
326 *
327 * @param string $return_url Where the Hub should send the browser after a
328 * successful attach. Defaults to the dashboard.
329 * Callers pass the wizard URL during onboarding so
330 * the user returns mid-flow. Must be a local admin
331 * URL — we never hand the Hub an off-site redirect.
332 */
333 public static function attach_url( string $return_url = '' ): string {
334 $args = array(
335 'site_url' => self::site_url_canonical(),
336 'nonce' => self::mint_attach_nonce(),
337 );
338 // Prefill hint only — the current admin's email, so a brand-new user
339 // can create/sign into their Hub account in one click without typing.
340 // The Hub NEVER trusts this for auth; it only pre-populates the field
341 // and still requires the user to verify (magic-link / Google).
342 $email = self::current_admin_email();
343 if ( '' !== $email ) {
344 $args['email'] = $email;
345 }
346 // Where to send the user after they approve. Constrained to a local
347 // admin URL so a tampered value can't turn this into an open redirect.
348 $args['return_url'] = self::safe_return_url( $return_url );
349 return self::hub_url() . '/attach?' . http_build_query( $args );
350 }
351
352 /**
353 * Sanitize a caller-supplied return URL down to a safe, local admin URL.
354 * Falls back to the dashboard for anything off-site or empty, so the value
355 * we hand the Hub can never become an open redirect back into this site.
356 */
357 private static function safe_return_url( string $return_url ): string {
358 $default = admin_url( 'admin.php?page=' . \XSpeed\Admin::PAGE_SLUG );
359 if ( '' === $return_url ) {
360 return $default;
361 }
362 // wp_validate_redirect() returns the fallback for any host not in the
363 // allowed list (defaults to this site's host), so an attacker-supplied
364 // absolute URL to another domain collapses to the dashboard.
365 return wp_validate_redirect( $return_url, $default );
366 }
367
368 /** The logged-in admin's email (used only as a Hub sign-in prefill hint). */
369 private static function current_admin_email(): string {
370 $user = wp_get_current_user();
371 if ( $user && ! empty( $user->user_email ) && is_email( $user->user_email ) ) {
372 return (string) $user->user_email;
373 }
374 $admin = get_option( 'admin_email' );
375 return is_string( $admin ) && is_email( $admin ) ? $admin : '';
376 }
377
378 /**
379 * Record that this site is attached to a hub account, PER USER. Called
380 * from the attach callback with the minting admin's user id (from the
381 * nonce), so each admin gets their own status. Bookkeeping only.
382 *
383 * @param string $account_email The hub account the site was attached to.
384 * @param int|null $user_id The admin who attached (defaults to current).
385 */
386 public static function mark_attached( string $account_email, ?int $user_id = null ): array {
387 $user_id = $user_id ?? get_current_user_id();
388 if ( $user_id ) {
389 update_user_meta(
390 $user_id,
391 self::USER_META,
392 array(
393 'attached' => true,
394 'account_email' => sanitize_email( $account_email ),
395 'attached_at' => time(),
396 )
397 );
398 }
399 // Bust the reconcile cache so a reconnect reflects immediately (not the
400 // stale 'not attached' cached during the disconnected window).
401 delete_transient( 'xspeed_hub_reconcile' );
402 return self::public_status( $user_id );
403 }
404
405 /**
406 * Disconnect the CURRENT admin from the hub: clear their per-user link.
407 * Other admins' connections are untouched. Does NOT rotate the site_token
408 * (still used by the per-site connection); to fully cut off the hub the
409 * user rotates the token, which the Hub's stored copy then fails on.
410 */
411 public static function disconnect(): array {
412 $user_id = get_current_user_id();
413 $state = $user_id ? self::state( $user_id ) : array();
414 $email = isset( $state['account_email'] ) ? (string) $state['account_email'] : '';
415
416 // Detach from the Hub for THIS admin's account only (multi-admin: other
417 // admins who attached keep their link). The site token proves ownership;
418 // account_email scopes the removal.
419 $token = Mcp_Pairing::site_token();
420 if ( '' !== $token && '' !== $email ) {
421 wp_remote_post(
422 self::hub_url() . '/api/site/detach',
423 array(
424 'timeout' => 8,
425 'headers' => array(
426 'Content-Type' => 'application/json',
427 'X-XSpeed-Site-Token' => $token,
428 ),
429 'body' => wp_json_encode(
430 array(
431 'site_url' => self::site_url_canonical(),
432 'account_email' => $email,
433 )
434 ),
435 )
436 );
437 }
438
439 if ( $user_id ) {
440 delete_user_meta( $user_id, self::USER_META );
441 }
442
443 /*
444 * Also clear the legacy site-wide option. state() falls back to it when
445 * a user has no per-user record, so deleting only the user meta left
446 * that fallback intact — disconnect() returned attached:true and the
447 * card stayed "Connected", making the button look broken. Anyone who
448 * attached before 1.1 (or via the redirect-return handler, which writes
449 * the option) hit this. (FBS-84086)
450 *
451 * The option is a single site-wide record, not per-admin, so there is
452 * no other admin's link being discarded here — the per-user meta above
453 * is what scopes multi-admin, and each admin's own meta is untouched.
454 */
455 delete_option( self::OPTION );
456
457 // Bust the reconcile cache so the next status read reflects reality
458 // immediately (not the stale 'attached' cached before disconnect).
459 delete_transient( 'xspeed_hub_reconcile' );
460 return self::public_status( $user_id );
461 }
462 }
463