| @@ -32,8 +32,10 @@ | ||
| 32 | 32 | namespace XSpeed\Modules\Mcp; |
| 33 | 33 | |
| 34 | 34 | defined( 'ABSPATH' ) || exit; |
| 35 | 35 | |
| 36 | +use XSpeed\Score_Store; | |
| 37 | + | |
| 36 | 38 | final class Mcp_Hub { |
| 37 | 39 | |
| 38 | 40 | /** Option key holding hub-link state (separate from pairing state). */ |
| 39 | 41 | public const OPTION = 'xspeed_module_mcp_hub'; |
| @@ -42,8 +44,15 @@ | ||
| 42 | 44 | * can have many admins, each managing it from their own hub account, so |
| 43 | 45 | * the connection is per-user, not site-wide. */ |
| 44 | 46 | public const USER_META = 'xspeed_hub_link'; |
| 45 | 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 | + | |
| 46 | 55 | /** Default hub dashboard base — where the user manages their account. */ |
| 47 | 56 | public const DEFAULT_HUB_URL = 'https://app.xspeedcache.com'; |
| 48 | 57 | |
| 49 | 58 | /** |
| @@ -87,8 +96,93 @@ | ||
| 87 | 96 | ); |
| 88 | 97 | } |
| 89 | 98 | |
| 90 | 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 | + /** | |
| 91 | 185 | * Public snapshot for the dashboard "xSpeed Hub" card. |
| 92 | 186 | * |
| 93 | 187 | * Includes the paste-in values for Method 1 (this site's URL + token) |
| 94 | 188 | * and a link to the hub dashboard. The token is admin-only (the whole |
| @@ -98,28 +192,75 @@ | ||
| 98 | 192 | */ |
| 99 | 193 | public static function public_status( ?int $user_id = null ): array { |
| 100 | 194 | $state = self::state( $user_id ); |
| 101 | 195 | return array( |
| 102 | - 'attached' => $state['attached'], | |
| 103 | - 'account_email' => $state['account_email'], | |
| 104 | - 'attached_at' => $state['attached_at'], | |
| 105 | - 'site_url' => home_url( '/' ), | |
| 196 | + 'attached' => $state['attached'], | |
| 197 | + 'account_email' => $state['account_email'], | |
| 198 | + 'attached_at' => $state['attached_at'], | |
| 199 | + 'site_url' => Mcp_Pairing::absolute( home_url( '/' ) ), | |
| 106 | 200 | // Method 1 paste-in credential — the existing per-site token. |
| 107 | 201 | // Empty until generate_token() (or a per-site Connect) mints one. |
| 108 | - 'site_token' => Mcp_Pairing::site_token(), | |
| 109 | - 'hub_url' => self::hub_url(), | |
| 202 | + 'site_token' => Mcp_Pairing::site_token(), | |
| 203 | + 'hub_url' => self::hub_url(), | |
| 110 | 204 | // Where the user goes to paste the URL + token (Add site form). |
| 111 | - 'add_site_url' => self::hub_url() . '/sites/add', | |
| 205 | + 'add_site_url' => self::hub_url() . '/sites/add', | |
| 112 | 206 | // Method 2 (OAuth attach) — one-click redirect with a fresh nonce. |
| 113 | - 'attach_url' => self::attach_url(), | |
| 207 | + 'attach_url' => self::attach_url(), | |
| 114 | 208 | // Non-public site? Connecting still works (token returns via the |
| 115 | 209 | // browser redirect), but Hub-initiated AI control needs a public |
| 116 | 210 | // URL — surfaced as an honest note on the Connect surfaces. |
| 117 | - 'is_local' => self::is_local_site(), | |
| 211 | + 'is_local' => self::is_local_site(), | |
| 212 | + // Reasons this site should not be disconnected right now, so the | |
| 213 | + // card can warn BEFORE the click rather than after the POST. | |
| 214 | + 'disconnect_blockers' => self::disconnect_blockers(), | |
| 118 | 215 | ); |
| 119 | 216 | } |
| 120 | 217 | |
| 121 | 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 | + /** | |
| 122 | 263 | * Method 1 — ensure a site_token exists and return the paste-in values. |
| 123 | 264 | * |
| 124 | 265 | * Reuses Mcp_Pairing::connect() so the Hub credential is the SAME token |
| 125 | 266 | * the per-site path uses (no second secret, no drift). Idempotent: if a |
| @@ -157,9 +298,9 @@ | ||
| 157 | 298 | return wp_hash( 'xspeed_hub_attach|' . self::site_url_canonical() ); |
| 158 | 299 | } |
| 159 | 300 | |
| 160 | 301 | /** Canonical site URL used in the nonce + sent to the hub. */ |
| 161 | - private static function site_url_canonical(): string { | |
| 302 | + public static function site_url_canonical(): string { | |
| 162 | 303 | return untrailingslashit( home_url( '/' ) ); |
| 163 | 304 | } |
| 164 | 305 | |
| 165 | 306 | /** |
| @@ -173,12 +314,8 @@ | ||
| 173 | 314 | * True when WP reports a local environment, or the host is a well-known dev |
| 174 | 315 | * TLD / localhost / a private or loopback IP. |
| 175 | 316 | */ |
| 176 | 317 | 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 | 318 | $host = wp_parse_url( home_url( '/' ), PHP_URL_HOST ); |
| 182 | 319 | if ( ! is_string( $host ) || '' === $host ) { |
| 183 | 320 | return false; |
| 184 | 321 | } |
| @@ -201,8 +338,30 @@ | ||
| 201 | 338 | FILTER_VALIDATE_IP, |
| 202 | 339 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE |
| 203 | 340 | ); |
| 204 | 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 | + | |
| 205 | 364 | return false; |
| 206 | 365 | } |
| 207 | 366 | |
| 208 | 367 | /** |
| @@ -212,12 +371,26 @@ | ||
| 212 | 371 | * callback can record the connection PER-USER — each admin sees their own |
| 213 | 372 | * "Connected via <their account>" status. |
| 214 | 373 | */ |
| 215 | 374 | 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 | - } | |
| 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 | + */ | |
| 220 | 393 | $ts = time(); |
| 221 | 394 | $uid = get_current_user_id(); |
| 222 | 395 | $hmac = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() ); |
| 223 | 396 | return $ts . '.' . $uid . '.' . $hmac; |
| @@ -226,13 +399,39 @@ | ||
| 226 | 399 | /** |
| 227 | 400 | * Verify an attach nonce (constant-time, within TTL). On success returns |
| 228 | 401 | * the paste-in values (site_url + site_token) plus the minting admin's |
| 229 | 402 | * user ID; on failure returns null. Called by the token-authless |
| 230 | - * /mcp/attach route. | |
| 403 | + * /mcp/attach route. Works once per nonce. | |
| 231 | 404 | * |
| 232 | 405 | * @return array{site_url:string,site_token:string,user_id:int}|null |
| 233 | 406 | */ |
| 234 | 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 { | |
| 235 | 434 | $parts = explode( '.', $nonce, 3 ); |
| 236 | 435 | if ( 3 !== count( $parts ) ) { |
| 237 | 436 | return null; |
| 238 | 437 | } |
| @@ -246,12 +445,48 @@ | ||
| 246 | 445 | $expected = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() ); |
| 247 | 446 | if ( ! hash_equals( $expected, (string) $hmac ) ) { |
| 248 | 447 | return null; // bad signature |
| 249 | 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 | + | |
| 250 | 485 | return array( |
| 251 | 486 | 'site_url' => self::site_url_canonical(), |
| 252 | 487 | 'site_token' => Mcp_Pairing::site_token(), |
| 253 | - 'user_id' => (int) $uid, | |
| 488 | + 'user_id' => $user_id, | |
| 254 | 489 | ); |
| 255 | 490 | } |
| 256 | 491 | |
| 257 | 492 | /** |
| @@ -285,10 +520,16 @@ | ||
| 285 | 520 | ); |
| 286 | 521 | $resp = wp_remote_get( |
| 287 | 522 | $url, |
| 288 | 523 | array( |
| 289 | - 'timeout' => 8, | |
| 290 | - 'headers' => array( 'X-XSpeed-Site-Token' => $token ), | |
| 524 | + 'timeout' => 8, | |
| 525 | + // The site token is a bearer for this site's whole Hub link, and | |
| 526 | + // it rides in a header — WordPress re-sends headers on a | |
| 527 | + // redirect, so following one would hand the token to whatever | |
| 528 | + // host the response points at. Nothing legitimate about the Hub | |
| 529 | + // answers with a 3xx, so treat one as the failure it is. | |
| 530 | + 'redirection' => 0, | |
| 531 | + 'headers' => array( 'X-XSpeed-Site-Token' => $token ), | |
| 291 | 532 | ) |
| 292 | 533 | ); |
| 293 | 534 | // Cache for 5 min regardless — don't hammer the Hub on transient errors. |
| 294 | 535 | set_transient( $cache_key, 1, 5 * MINUTE_IN_SECONDS ); |
| @@ -301,9 +542,14 @@ | ||
| 301 | 542 | return; |
| 302 | 543 | } |
| 303 | 544 | |
| 304 | 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 | + } | |
| 305 | 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 ); | |
| 306 | 552 | // The Hub says attached — mark THIS admin connected if not already. |
| 307 | 553 | $state = self::state( $uid ); |
| 308 | 554 | if ( empty( $state['attached'] ) ) { |
| 309 | 555 | self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid ); |
| @@ -313,13 +559,99 @@ | ||
| 313 | 559 | // any stale local "connected" so the badge doesn't lie. |
| 314 | 560 | $state = self::state( $uid ); |
| 315 | 561 | if ( ! empty( $state['attached'] ) ) { |
| 316 | 562 | delete_user_meta( $uid, self::USER_META ); |
| 563 | + self::refresh_site_attached(); | |
| 317 | 564 | } |
| 318 | 565 | } |
| 319 | 566 | } |
| 320 | 567 | |
| 321 | 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 | + /** | |
| 322 | 654 | * One-click attach redirect (Method 2). The Hub logs the user in, approves, |
| 323 | 655 | * calls back to this site's /attach route to record the link, then bounces |
| 324 | 656 | * the browser to `return_url` so the user lands back in the plugin without |
| 325 | 657 | * navigating manually. |
| @@ -395,8 +727,10 @@ | ||
| 395 | 727 | 'attached_at' => time(), |
| 396 | 728 | ) |
| 397 | 729 | ); |
| 398 | 730 | } |
| 731 | + // Attaching makes the site-level answer unconditionally yes. | |
| 732 | + update_option( self::SITE_ATTACHED_OPTION, '1', false ); | |
| 399 | 733 | // Bust the reconcile cache so a reconnect reflects immediately (not the |
| 400 | 734 | // stale 'not attached' cached during the disconnected window). |
| 401 | 735 | delete_transient( 'xspeed_hub_reconcile' ); |
| 402 | 736 | return self::public_status( $user_id ); |
| @@ -404,16 +738,35 @@ | ||
| 404 | 738 | |
| 405 | 739 | /** |
| 406 | 740 | * Disconnect the CURRENT admin from the hub: clear their per-user link. |
| 407 | 741 | * 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. | |
| 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. | |
| 410 | 754 | */ |
| 411 | - public static function disconnect(): array { | |
| 755 | + public static function disconnect( bool $acknowledged = false ): array { | |
| 412 | 756 | $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 | 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 | + | |
| 416 | 769 | // Detach from the Hub for THIS admin's account only (multi-admin: other |
| 417 | 770 | // admins who attached keep their link). The site token proves ownership; |
| 418 | 771 | // account_email scopes the removal. |
| 419 | 772 | $token = Mcp_Pairing::site_token(); |
| @@ -420,10 +773,14 @@ | ||
| 420 | 773 | if ( '' !== $token && '' !== $email ) { |
| 421 | 774 | wp_remote_post( |
| 422 | 775 | self::hub_url() . '/api/site/detach', |
| 423 | 776 | array( |
| 424 | - 'timeout' => 8, | |
| 425 | - 'headers' => array( | |
| 777 | + 'timeout' => 8, | |
| 778 | + // Never follow a redirect with the site token attached — see | |
| 779 | + // reconcile_with_hub(). A detach that answers 3xx simply | |
| 780 | + // fails; the local state is cleared below either way. | |
| 781 | + 'redirection' => 0, | |
| 782 | + 'headers' => array( | |
| 426 | 783 | 'Content-Type' => 'application/json', |
| 427 | 784 | 'X-XSpeed-Site-Token' => $token, |
| 428 | 785 | ), |
| 429 | 786 | 'body' => wp_json_encode( |
| @@ -453,10 +810,240 @@ | ||
| 453 | 810 | * is what scopes multi-admin, and each admin's own meta is untouched. |
| 454 | 811 | */ |
| 455 | 812 | delete_option( self::OPTION ); |
| 456 | 813 | |
| 814 | + // Other admins may still be attached — recompute rather than assume no. | |
| 815 | + self::refresh_site_attached(); | |
| 816 | + | |
| 457 | 817 | // Bust the reconcile cache so the next status read reflects reality |
| 458 | 818 | // immediately (not the stale 'attached' cached before disconnect). |
| 459 | 819 | delete_transient( 'xspeed_hub_reconcile' ); |
| 460 | 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 ); | |
| 461 | 1048 | } |
| 462 | 1049 | } |