connection = $connection; $this->logger = $logger; } /** * Cap the total time every call from here on, in this request, may block. * An owner's click on Connect / Open runs connect, token exchange and * handoff in a row; this keeps the sum bounded, not only each call. * * @param int $seconds The total, from now. */ public function limit_total_time( int $seconds ): void { $this->deadline = microtime( true ) + max( 1, $seconds ); } /** * The timeout for the next call: its own, or what is left of the budget. * * @param int $preferred The call's own timeout, in seconds. * @return int|\WP_Error Seconds, or an error when the budget is spent. */ private function timeout_for( int $preferred ) { if ( null === $this->deadline ) { return $preferred; } $left = $this->deadline - microtime( true ); if ( $left <= 0 ) { return new \WP_Error( self::BUDGET_EXHAUSTED, 'The time allowed for this action ran out before WPPilot answered.' ); } return max( 1, min( $preferred, (int) ceil( $left ) ) ); } /** Whether WPPilot failed to answer within the last minute. */ public function recently_unreachable(): bool { return false !== get_transient( self::UNREACHABLE_KEY ); } /** * Record how WPPilot answered: transport failures and 5xx mark it * unreachable for a minute; any other answer clears that. * * @param array|\WP_Error $response What wp_remote_*() returned. */ private function note_answer( $response ): void { $failed = is_wp_error( $response ) || (int) wp_remote_retrieve_response_code( $response ) >= 500; if ( $failed ) { set_transient( self::UNREACHABLE_KEY, time(), self::UNREACHABLE_TTL ); } elseif ( $this->recently_unreachable() ) { delete_transient( self::UNREACHABLE_KEY ); } } /** * Register this site with the backend. The backend will call back to * /wp-json/wppilot/v1/auth/handshake to verify ownership. * * @param string $secret The raw per-site secret. * @param string $connect_nonce One-time nonce the backend must echo back in * the handshake request so the plugin can verify * the caller is the same backend that received * the connect request. * @return array{site_id: string, callback_token: string}|\WP_Error */ public function connect( string $secret, string $connect_nonce = '' ) { $body = array( 'site_url' => get_site_url(), 'secret' => $secret, 'connect_nonce' => $connect_nonce, // The exact REST URL of the handshake endpoint. rest_url() resolves the // correct form for this site (pretty permalinks vs. the ?rest_route= // fallback), so the backend never has to assume a permalink structure. 'handshake_url' => rest_url( 'wppilot/v1/auth/handshake' ), // REST base for wppilot/v1. rest_url() returns the correct form for // this site's permalink structure, so the backend can reach the meta // endpoints without assuming pretty permalinks. 'rest_url' => rest_url( 'wppilot/v1' ), 'plugin_version' => WPPILOT_VERSION, 'wp_version' => get_bloginfo( 'version' ), 'php_version' => PHP_VERSION, 'timezone' => wp_timezone_string(), ); $timeout = $this->timeout_for( self::CONNECT_TIMEOUT ); if ( is_wp_error( $timeout ) ) { return Connect_Failure::from_transport( 'wppilot_connect_failed', $timeout ); } $response = wp_remote_post( WPPILOT_BACKEND_URL . '/v1/sites/connect', array( 'headers' => array( 'content-type' => 'application/json' ), 'body' => wp_json_encode( $body ), 'timeout' => $timeout, ) ); $this->note_answer( $response ); if ( is_wp_error( $response ) ) { $this->logger->error( 'Backend connect request failed', array( 'error' => $response->get_error_message() ) ); return Connect_Failure::from_transport( 'wppilot_connect_failed', $response ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code || ! is_array( $data ) ) { $failure = Connect_Failure::from_connect_response( $code, $data ); $this->logger->error( 'Backend connect rejected', array( 'http_code' => $code, 'detail' => Connect_Failure::technical_of( $failure ) ) ); return $failure; } return array( 'site_id' => sanitize_text_field( $data['site_id'] ?? '' ), 'callback_token' => sanitize_text_field( $data['callback_token'] ?? '' ), ); } /** * Exchange the per-site secret for a short-lived JWT. * * @param string $secret The raw per-site secret. * @return array{token: string, expires_in: int}|\WP_Error */ public function get_token( string $secret ) { $body = array( 'site_url' => get_site_url(), 'secret' => $secret, ); $timeout = $this->timeout_for( self::AUTH_TIMEOUT ); if ( is_wp_error( $timeout ) ) { return $timeout; } $response = wp_remote_post( WPPILOT_BACKEND_URL . '/v1/auth/token', array( 'headers' => array( 'content-type' => 'application/json' ), 'body' => wp_json_encode( $body ), 'timeout' => $timeout, ) ); $this->note_answer( $response ); if ( is_wp_error( $response ) ) { $this->logger->error( 'Token exchange failed', array( 'error' => $response->get_error_message() ) ); return $response; } $code = wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code || ! is_array( $data ) ) { /* * A 401 here is the backend's VERDICT ON THE PAIRING, and the one * piece of evidence this plugin used to discard. * * This exchange presents the per-site secret and no JWT, so a 401 * cannot mean "your token expired" — it means the backend does not * accept this site_url/secret pair, which is what a backend that has * forgotten this site says. Recording it is what lets the admin page * offer the owner a way back; see Connection::is_pairing_rejected(). * * Every other outcome — a transport error above, a 5xx, a 429 — is * NOT a verdict and deliberately leaves the recorded state alone. An * unreachable backend must never look like a revoked pairing. */ if ( 401 === $code ) { $this->connection->set_pairing_rejected( true ); $this->logger->error( 'Backend does not accept this pairing; the site must be reconnected', array( 'http_code' => $code ) ); } else { $this->logger->error( 'Token exchange rejected', array( 'http_code' => $code ) ); } return new \WP_Error( 'wppilot_token_failed', 'Token exchange rejected' ); } // An exchange that works withdraws any earlier verdict: the pairing is // demonstrably live, whatever we recorded when it last was not. $this->connection->set_pairing_rejected( false ); return array( 'token' => sanitize_text_field( $data['token'] ?? '' ), 'expires_in' => absint( $data['expires_in'] ?? 900 ), ); } /** * Mint a one-time "authorize handoff" token bound to this site. The backend * authenticates with the per-site secret (same check as the JWT exchange) * and returns a single-use token (5-min TTL) that the SPA redeems for a * session. The raw token is never logged here. * * The WordPress user id travels with the request so the backend can tell * two administrators of the same site apart. It is an identifier local to * this WordPress — never an email address, and never a claim about who * somebody is in WPPilot. Which account it may reach is decided by a mapping * the owner established by signing in, not by anything asserted here. * * @param string $secret The raw per-site secret. * @param int $wp_user_id The WordPress user asking for the handoff. * @return array{handoff_token: string, expires_in: int}|\WP_Error */ public function create_handoff( string $secret, int $wp_user_id = 0 ) { $body = array( 'site_url' => get_site_url(), 'secret' => $secret, ); if ( $wp_user_id > 0 ) { $body['wp_user_id'] = $wp_user_id; } // The site's admin email, proposed as the account address on the // first-connect consent screen. It travels here, in a server-to-server // POST body, never in the authorize URL. The backend offers it back // only while no WPPilot account owns the site, so it cannot move a site // away from the account holding it, and a signed-in owner outranks it. // // It is sent on every handoff rather than "first connection only" // because only the backend knows whether the site was ever attached to // an account; a plugin-side "already paired" flag once withheld it // after a connection that never finished, leaving the site owned by // nobody. $admin_email = (string) get_option( 'admin_email', '' ); if ( '' !== $admin_email && is_email( $admin_email ) ) { $body['email_hint'] = $admin_email; } $timeout = $this->timeout_for( self::AUTH_TIMEOUT ); if ( is_wp_error( $timeout ) ) { return Connect_Failure::from_transport( 'wppilot_handoff_failed', $timeout ); } $response = wp_remote_post( WPPILOT_BACKEND_URL . '/v1/auth/handoff', array( 'headers' => array( 'content-type' => 'application/json' ), 'body' => wp_json_encode( $body ), 'timeout' => $timeout, ) ); $this->note_answer( $response ); if ( is_wp_error( $response ) ) { $this->logger->error( 'Handoff request failed', array( 'error' => $response->get_error_message() ) ); return Connect_Failure::from_transport( 'wppilot_handoff_failed', $response ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code || ! is_array( $data ) || empty( $data['handoff_token'] ) ) { $this->logger->error( 'Handoff request rejected', array( 'http_code' => $code ) ); return Connect_Failure::from_handoff_response( $code ); } return array( 'handoff_token' => sanitize_text_field( $data['handoff_token'] ), 'expires_in' => absint( $data['expires_in'] ?? 300 ), ); } /** * Get a valid JWT, refreshing transparently only when the cached token is * missing or has actually expired (sessions are 30-day, site-bound — there * is no proactive refresh window), or — when $max_age_seconds is given — * is older than that, so a token the backend revoked is replaced in time. * * @param int $max_age_seconds Renew a token older than this; 0 never does. * @return string|\WP_Error The JWT or an error. */ public function get_token_if_needed( int $max_age_seconds = 0 ) { $age = $this->connection->jwt_age(); $fresh = 0 === $max_age_seconds || ( null !== $age && $age < $max_age_seconds ); if ( $fresh && $this->connection->is_jwt_valid() ) { return $this->connection->get_jwt(); } $secret = $this->connection->get_secret(); if ( empty( $secret ) ) { return new \WP_Error( 'wppilot_no_secret', 'No per-site secret stored' ); } $result = $this->get_token( $secret ); if ( is_wp_error( $result ) ) { return $result; } $this->connection->set_jwt( $result['token'], $result['expires_in'] ); return $result['token']; } /** * Fetch the site's latest Briefing snapshot for the wp-admin dashboard. The * heavy lifting (scan + LLM) happens in the SPA; here we just read the cached * result via the authenticated JWT. Cached in a short transient so opening * the admin page does not hit the backend on every load. * * @return array{status: string, briefing: array|null}|null */ public function get_briefing_summary() { $cached = get_transient( 'wppilot_briefing_summary' ); if ( is_array( $cached ) ) { return $cached; } $data = $this->authed_get( '/v1/briefing' ); if ( is_wp_error( $data ) || ! is_array( $data ) ) { return null; } $summary = array( 'status' => isset( $data['status'] ) ? sanitize_text_field( $data['status'] ) : 'none', 'briefing' => isset( $data['briefing'] ) && is_array( $data['briefing'] ) ? $data['briefing'] : null, ); set_transient( 'wppilot_briefing_summary', $summary, 60 ); return $summary; } /** * Fetch the most recent activity events for the wp-admin dashboard. Cached in * a short transient to keep the admin page snappy. * * @param int $limit Maximum number of events to return. * @return array> Newest-first events (possibly empty). */ public function get_recent_activity( int $limit = 5 ): array { $limit = max( 1, min( 20, $limit ) ); $cache_key = 'wppilot_recent_activity_' . $limit; $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } $data = $this->authed_get( '/v1/activity?limit=' . $limit ); if ( is_wp_error( $data ) || ! is_array( $data ) || ! isset( $data['events'] ) || ! is_array( $data['events'] ) ) { return array(); } $events = array_slice( $data['events'], 0, $limit ); set_transient( $cache_key, $events, 60 ); return $events; } /** * The factual site status shown on the WPPilot admin screen. * * Returns the decoded payload, or a WP_Error the caller must render as * "we could not reach WPPilot" — never as zeroes. Deliberately uncached * here: freshness and staleness are decided by {@see Site_Summary}, which * is the only thing that knows how to present either honestly. * * @return array|\WP_Error */ public function get_site_summary() { return $this->authed_get( '/v1/site-summary' ); } /** * Perform an authenticated GET against the backend with the site JWT, decoding * the JSON body. Returns a WP_Error on transport failure, a non-200 response, * or an unusable body. * * On a 401 response the cached JWT is cleared and the request is retried once * with a freshly exchanged token. This handles stale tokens left over after * backend restarts or downtime without requiring the user to re-authorize. * * @param string $path Backend path beginning with a slash (e.g. '/v1/briefing'). * @return array|\WP_Error */ private function authed_get( string $path ) { // WPPilot did not answer a moment ago: do not make the page wait to // find that out again. The caller renders what it has cached. if ( $this->recently_unreachable() ) { return new \WP_Error( self::RECENTLY_UNREACHABLE, 'WPPilot did not answer within the last minute; not asking again yet.' ); } $result = $this->do_authed_get( $path ); if ( is_wp_error( $result ) ) { $error_data = $result->get_error_data(); if ( is_array( $error_data ) && isset( $error_data['http_code'] ) && 401 === $error_data['http_code'] ) { $this->connection->clear_jwt(); $result = $this->do_authed_get( $path ); } } return $result; } /** * Single-attempt authenticated GET (no retry logic). * * @param string $path Backend path beginning with a slash. * @return array|\WP_Error */ private function do_authed_get( string $path ) { $jwt = $this->get_token_if_needed(); if ( is_wp_error( $jwt ) ) { return $jwt; } $timeout = $this->timeout_for( self::READ_TIMEOUT ); if ( is_wp_error( $timeout ) ) { return $timeout; } $response = wp_remote_get( WPPILOT_BACKEND_URL . $path, array( 'headers' => array( 'authorization' => 'Bearer ' . $jwt ), 'timeout' => $timeout, ) ); $this->note_answer( $response ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code || ! is_array( $data ) ) { return new \WP_Error( 'wppilot_get_failed', 'Backend GET failed', array( 'http_code' => $code ) ); } return $data; } /** * Disconnect this site from the backend. Invalidates the JWT and revokes * the site's token version on the backend side. * * @return true|\WP_Error */ public function disconnect() { $jwt = $this->get_token_if_needed(); if ( is_wp_error( $jwt ) ) { return $jwt; } $response = wp_remote_post( WPPILOT_BACKEND_URL . '/v1/sites/disconnect', array( 'headers' => array( 'content-type' => 'application/json', 'authorization' => 'Bearer ' . $jwt, ), // Auth is carried entirely by the JWT; the endpoint needs no // payload, but a JSON content-type requires a valid JSON body, // so send an explicit empty object. 'body' => wp_json_encode( (object) array() ), 'timeout' => self::AUTH_TIMEOUT, ) ); if ( is_wp_error( $response ) ) { $this->logger->error( 'Backend disconnect failed', array( 'error' => $response->get_error_message() ) ); return $response; } return true; } }