api ) { $this->api = new Api_Client( $this ); } return $this->api; } /** * The client ID WordPress signs in with. */ public static function client_id(): string { return defined( 'STAND_CHAT_OAUTH_CLIENT' ) && is_string( STAND_CHAT_OAUTH_CLIENT ) && '' !== STAND_CHAT_OAUTH_CLIENT ? STAND_CHAT_OAUTH_CLIENT : self::CLIENT_ID; } /** * Stored connection details (no secrets). They're autoloaded: public pages * check the Site's domain on every view. * * @return array */ public static function stored_metadata(): array { $meta = get_option( self::META_OPTION, array() ); return is_array( $meta ) ? $meta : array(); } /** * Whether a connection is stored (it may still need reconnecting). */ public function is_connected(): bool { return '' !== $this->site_id() && '' !== (string) get_option( self::TOKEN_OPTION, '' ); } /** * Whether a connection is stored now, read past WordPress's cache: another * request may have connected since this one first looked. */ public function is_connected_now(): bool { return '' !== (string) ( Option_Queue::read( self::META_OPTION )['site']['siteId'] ?? '' ) && '' !== (string) Option_Queue::stored( self::TOKEN_OPTION ); } /** * Whether the stored connection no longer works. */ public function needs_reconnect(): bool { if ( ! $this->is_connected() ) { return false; } return 'reconnect' === ( self::stored_metadata()['status'] ?? '' ) || null === $this->tokens(); } /** * Whether calls to Stand can be made now. */ public function is_usable(): bool { return $this->is_connected() && ! $this->needs_reconnect(); } /** * Whether the person signed in but hasn't finished setting up their Stand * account yet, so WordPress can't pick a Site. */ public function awaits_onboarding(): bool { return ! $this->is_connected() && 'onboarding' === ( self::stored_metadata()['status'] ?? '' ) && null !== $this->tokens(); } /** * The connected Stand Site ID. */ public function site_id(): string { $meta = self::stored_metadata(); return isset( $meta['site']['siteId'] ) ? (string) $meta['site']['siteId'] : ''; } /** * Who is connected, to which Site, read past the cache. Unlike the stored * session, it stays the same when the session is renewed, and changes * when WordPress is connected again as another person or to another Site. */ public function identity(): string { $meta = Option_Queue::read( self::META_OPTION ); return (string) ( $meta['user']['userId'] ?? '' ) . '|' . (string) ( $meta['site']['siteId'] ?? '' ); } /** * The identity() this request first read, from WordPress's cache. When it * differs from identity(), the request began on a connection that has * been replaced since. */ public function identity_seen(): string { $meta = self::stored_metadata(); return (string) ( $meta['user']['userId'] ?? '' ) . '|' . (string) ( $meta['site']['siteId'] ?? '' ); } /** * A short fingerprint of an identity(), which a page loaded on that * connection sends back with each change (see Stand::on_loaded_connection()). * * @param string $identity identity() or identity_seen(). */ public static function fingerprint( string $identity ): string { return substr( md5( $identity ), 0, 16 ); } /** * Records what a request did for a connection only while that's still the * one connected. It holds the claim on connecting meanwhile, so no * connection is made or ended between the check and the change. * * @param string $identity identity() when the request began. * @param callable $change The change to what WordPress keeps. * @return mixed|WP_Error What the change returns, or why it wasn't made. */ public function as_connected( string $identity, callable $change ) { if ( '' !== $this->held ) { // Held already for a change made in steps: nobody connects meanwhile. $now = $this->identity(); return $now === $identity ? $change() : Stand::reconnected( $now ); } $claim = $this->hold( MINUTE_IN_SECONDS ); if ( '' === $claim ) { return self::connecting(); } try { $now = $this->identity(); return $now === $identity ? $change() : Stand::reconnected( $now ); } finally { $this->let_go( $claim ); } } /** * Keeps what Stand last said about the connected Site, which the status * falls back on while Stand can't be reached. Only for that Site: a * connection to another one made meanwhile keeps its own. * * @param array $site The Site, as Stand answered. */ public function remember_site( array $site ): void { $id = (string) ( $site['siteId'] ?? '' ); if ( '' === $id ) { return; } Option_Queue::change( self::META_OPTION, static function ( array $meta ) use ( $id, $site ): array { if ( (string) ( $meta['site']['siteId'] ?? '' ) !== $id ) { return $meta; } $meta['site'] = array_merge( (array) $meta['site'], array( 'domain' => (string) ( $site['domain'] ?? '' ), 'name' => (string) ( $site['name'] ?? '' ), 'enabled' => ! isset( $site['enabled'] ) || (bool) $site['enabled'], 'installed' => ! empty( $site['installed'] ), ) ); return $meta; }, true ); } /** * Takes the claim on connecting for a change in this request: no connection * is made or ended until it's let go. Changes in other requests share it * meanwhile. Renew it with Option_Queue::renew() while the change takes * long. * * @param int $ttl Seconds it holds without being renewed. * @return string The claim's token; '' while another request connects or * disconnects. */ public function hold( int $ttl ): string { $claim = Option_Queue::claim( Stand::OPTION_CLAIMS, 'connect', $ttl, true ); if ( '' !== $claim ) { $this->held = $claim; } return $claim; } /** * Lets go of the claim hold() took. * * @param string $claim The claim's token. */ public function let_go( string $claim ): void { Option_Queue::release( Stand::OPTION_CLAIMS, 'connect', $claim ); if ( $this->held === $claim ) { $this->held = ''; } } /** * Why a change waits: another request is connecting or disconnecting. Or * why connecting or disconnecting waits: another request is too, or is * making a change on the connection. */ public static function connecting(): WP_Error { return new WP_Error( 'stand_busy', __( 'Someone else is connecting or changing Stand Chat right now. Try again in a moment.', 'stand-chat' ) ); } /** * Whether the connected Stand user owns their organization. */ public function is_owner(): bool { return 'owner' === ( self::stored_metadata()['role'] ?? '' ); } /** * Connection details for the admin screens. * * @return array */ public function summary(): array { $meta = self::stored_metadata(); if ( ! $this->is_connected() ) { return array( 'connected' => false, 'onboarding' => $this->awaits_onboarding(), ); } return array( 'connected' => true, 'reconnect' => $this->needs_reconnect(), 'reconnectCause' => (string) ( $meta['status_reason'] ?? '' ), 'site' => array( 'siteId' => $this->site_id(), 'domain' => (string) ( $meta['site']['domain'] ?? '' ), 'name' => (string) ( $meta['site']['name'] ?? '' ), ), 'user' => array( 'email' => (string) ( $meta['user']['email'] ?? '' ), 'name' => (string) ( $meta['user']['name'] ?? '' ), ), 'role' => (string) ( $meta['role'] ?? 'member' ), 'isOwner' => $this->is_owner(), 'ownerName' => (string) ( $meta['owner_name'] ?? '' ), 'scopes' => array_values( (array) ( $meta['scopes'] ?? array() ) ), 'connectedAt' => (string) ( $meta['connected_at'] ?? '' ), 'lastUsedAt' => (string) ( $meta['last_used_at'] ?? '' ), 'expiresAt' => (string) ( $meta['expires_at'] ?? '' ), 'connectedBy' => (int) ( $meta['connected_by'] ?? 0 ), ); } /** * Starts connecting: asks Stand's sign-in service for a device code. * Account registration starts separately on Stand's website. * * @return array{verificationUrl: string, userCode: string, expiresAt: string}|WP_Error */ public function start() { // Read before asking: a disconnect while the code is on its way cancels it. $since = self::disconnects(); // One start at a time for each person: two tabs starting at once would // each get a code, and only the one kept last could complete. $key = 'start_' . get_current_user_id(); $claim = Option_Queue::claim( Stand::OPTION_CLAIMS, $key, MINUTE_IN_SECONDS ); if ( '' === $claim ) { return new WP_Error( 'stand_busy', __( 'Connecting is starting in another tab. Try again in a moment.', 'stand-chat' ) ); } try { return $this->start_now( $since ); } finally { Option_Queue::release( Stand::OPTION_CLAIMS, $key, $claim ); } } /** * Shows the person's sign-in code, asking Stand for one when none is * waiting, while this request holds the claim on starting. * * @param string $since disconnects() before asking. * @return array{verificationUrl: string, userCode: string, expiresAt: string}|WP_Error */ private function start_now( string $since ) { // One sign-in at a time for each person: another tab opened meanwhile // shows the code the first one shows, so either window completes it. $flow = $this->flow(); if ( null !== $flow && (int) $flow['expires_at'] > time() + MINUTE_IN_SECONDS && (string) ( $flow['since'] ?? '' ) === $since ) { return array( 'verificationUrl' => (string) $flow['url'], 'userCode' => (string) $flow['user_code'], 'expiresAt' => gmdate( 'c', (int) $flow['expires_at'] ), ); } $response = $this->auth_request( 'auth/device', array( 'scope' => implode( ' ', self::SCOPES ) ) ); if ( is_wp_error( $response ) ) { $oauth = (string) ( ( (array) $response->get_error_data() )['oauth'] ?? '' ); if ( in_array( $oauth, array( 'invalid_client', 'unauthorized_client', 'invalid_scope' ), true ) ) { return new WP_Error( 'stand_connect_unavailable', __( 'Connecting from WordPress isn\'t available on Stand yet. Paste a Site ID instead.', 'stand-chat' ), (array) $response->get_error_data() ); } return $response; } $device = (string) ( $response['device_code'] ?? '' ); $code = (string) ( $response['user_code'] ?? '' ); $url = self::on_sign_in( (string) ( $response['verification_uri_complete'] ?? ( $response['verification_uri'] ?? '' ) ) ); if ( '' === $device || '' === $code || '' === $url ) { return new WP_Error( 'stand_server_error', __( 'Stand had a problem. Try again in a moment.', 'stand-chat' ) ); } $expires = time() + max( 60, (int) ( $response['expires_in'] ?? 600 ) ); $this->save_flow( array( 'device_code' => $device, 'user_code' => $code, 'url' => $url, 'interval' => max( 1, (int) ( $response['interval'] ?? 5 ) ), 'polled_at' => 0, 'expires_at' => $expires, 'since' => $since, ) ); return array( 'verificationUrl' => $url, 'userCode' => $code, 'expiresAt' => gmdate( 'c', $expires ), ); } /** * Stand's sign-in page, when it's on the sign-in service this plugin uses. * * @param string $url verification_uri_complete from Stand. * @return string The address, or '' when it's somewhere else. */ private static function on_sign_in( string $url ): string { $page = wp_parse_url( $url ); $service = wp_parse_url( Service_Urls::get( 'auth' ) ); if ( empty( $page['host'] ) || empty( $service['host'] ) ) { return ''; } $origin = static function ( array $parts ): string { $scheme = strtolower( (string) ( $parts['scheme'] ?? '' ) ); $port = isset( $parts['port'] ) ? (int) $parts['port'] : ( 'https' === $scheme ? 443 : 80 ); return $scheme . '://' . strtolower( (string) $parts['host'] ) . ':' . $port; }; $loopback = in_array( strtolower( (string) $page['host'] ), array( 'localhost', '127.0.0.1', '[::1]' ), true ); if ( $origin( $page ) !== $origin( $service ) || ( 'https' !== ( $page['scheme'] ?? '' ) && ! $loopback ) ) { return ''; } return esc_url_raw( $url, array( 'http', 'https' ) ); } /** * While the Stand window is open: asks for the tokens with the device code, * no more often than Stand allows. * * @return array{status: string, message?: string} status: connected, * waiting, onboarding, cancelled, expired, failed or none. */ public function poll(): array { // One poll at a time for each person: a code redeemed twice at once // would fail one of them, after the other connected. $key = 'poll_' . get_current_user_id(); $claim = Option_Queue::claim( Stand::OPTION_CLAIMS, $key, 5 * MINUTE_IN_SECONDS ); if ( '' === $claim ) { // Another tab, or this one's last poll, is asking Stand right now. return array( 'status' => 'waiting' ); } try { return $this->poll_now(); } finally { Option_Queue::release( Stand::OPTION_CLAIMS, $key, $claim ); } } /** * Asks for the tokens, while this request holds the claim on polling. * * @return array{status: string, message?: string} */ private function poll_now(): array { $flow = $this->flow(); if ( null === $flow ) { if ( $this->awaits_onboarding() ) { return array( 'status' => 'onboarding' ); } // Another tab may have finished it. return array( 'status' => $this->is_usable() ? 'connected' : 'none' ); } if ( time() > (int) $flow['expires_at'] ) { $this->end_flow(); return array( 'status' => 'expired' ); } if ( time() < (int) $flow['polled_at'] + (int) $flow['interval'] ) { return array( 'status' => 'waiting' ); } // Asked only while no change is made on the connection, and held until // the sign-in is used: one completed while another request makes a // change would have to be thrown away. This poll waits instead, and // the next one asks. $claim = Option_Queue::claim( Stand::OPTION_CLAIMS, 'connect', 5 * MINUTE_IN_SECONDS ); if ( '' === $claim ) { return array( 'status' => 'waiting' ); } try { return $this->redeem( $flow ); } finally { Option_Queue::release( Stand::OPTION_CLAIMS, 'connect', $claim ); } } /** * Asks for the tokens and, once Stand gives them, connects, while this * request holds the claims on polling and connecting. * * @param array $flow The current user's attempt. * @return array{status: string, message?: string} */ private function redeem( array $flow ): array { $flow['polled_at'] = time(); $this->save_flow( $flow ); $response = $this->auth_request( 'token', array( 'grant_type' => self::DEVICE_GRANT, 'device_code' => (string) $flow['device_code'], ) ); if ( is_wp_error( $response ) ) { $oauth = (string) ( ( (array) $response->get_error_data() )['oauth'] ?? '' ); if ( 'authorization_pending' === $oauth || 'stand_unreachable' === $response->get_error_code() ) { return array( 'status' => 'waiting' ); } if ( 'slow_down' === $oauth ) { $flow['interval'] = (int) $flow['interval'] + 5; $this->save_flow( $flow ); return array( 'status' => 'waiting' ); } $this->end_flow(); if ( 'access_denied' === $oauth ) { return array( 'status' => 'cancelled' ); } return array( 'status' => 'expired_token' === $oauth ? 'expired' : 'failed' ); } $this->end_flow(); $finished = $this->finish_now( $response, (string) ( $flow['since'] ?? '' ) ); if ( is_wp_error( $finished ) ) { return array( 'status' => 'stand_onboarding' === $finished->get_error_code() ? 'onboarding' : 'failed', 'message' => $finished->get_error_message(), ); } return array( 'status' => 'connected' ); } /** * Keeps the new session, then finds the Stand Site for this address, or * creates it. Called again, with the tokens it kept, once a new account * has finished setting up in Stand. One connection is made at a time, and * none while a change is made on the connection: two administrators * connecting different accounts at once would mix one's details, recovery * or revocation with the other's session. * * @param array|null $tokens Token response, or null to use the kept session. * @param string|null $since For a sign-in, what disconnects() was when it * began: it doesn't complete after a disconnect. * @return true|WP_Error */ public function finish( ?array $tokens = null, ?string $since = null ) { $claim = Option_Queue::claim( Stand::OPTION_CLAIMS, 'connect', 5 * MINUTE_IN_SECONDS ); if ( '' === $claim ) { if ( null !== $tokens ) { // Not used, so it ends in Stand. $this->revoke( (string) ( $tokens['refresh_token'] ?? '' ) ); } return self::connecting(); } try { return $this->finish_now( $tokens, $since ); } finally { Option_Queue::release( Stand::OPTION_CLAIMS, 'connect', $claim ); } } /** * Keeps the new session, then finds or creates the Stand Site, while this * request holds the claim on connecting. * * @param array|null $tokens Token response, or null to use the kept session. * @param string|null $since As for finish(). * @return true|WP_Error */ private function finish_now( ?array $tokens, ?string $since = null ) { if ( null !== $since && self::disconnects() !== $since ) { if ( null !== $tokens ) { $this->revoke( (string) ( $tokens['refresh_token'] ?? '' ) ); } return new WP_Error( 'stand_disconnected', __( 'Connecting was cancelled, or Stand Chat was disconnected, while you signed in. Connect again to use it.', 'stand-chat' ) ); } $previous = null; $working = null; if ( null !== $tokens ) { $previous = $this->tokens(); // Disconnecting forgets the connection, but not which Site's and // whose pages were read. A Site ID pasted since says nothing of that. $previous_site = '' !== $this->site_id() ? $this->site_id() : (string) State::get( 'last_site_id', Settings::get( 'site_id' ) ); $previous_user = (string) ( self::stored_metadata()['user']['userId'] ?? State::get( 'last_user_id', '' ) ); if ( $this->is_usable() ) { // Kept until the new session has its Site, so a failure on the // way doesn't leave WordPress disconnected. $working = array( get_option( self::TOKEN_OPTION ), get_option( self::META_OPTION ) ); } if ( null === $this->save_tokens( $tokens, true ) ) { return new WP_Error( 'stand_server_error', __( 'Stand had a problem. Try again in a moment.', 'stand-chat' ) ); } $meta = self::stored_metadata(); $meta['previous_site'] = $previous_site; $meta['previous_user'] = $previous_user; update_option( self::META_OPTION, $meta, true ); } elseif ( null === $this->tokens() ) { return new WP_Error( 'stand_reconnect_needed', __( 'Connect again from Stand Chat.', 'stand-chat' ) ); } $result = $this->settle(); if ( null !== $tokens && is_wp_error( $result ) && 'stand_onboarding' !== $result->get_error_code() ) { // The new session can't be used, so it ends in Stand. WordPress goes // back to the connection that worked, or keeps none. $this->revoke( (string) $tokens['refresh_token'] ); if ( null !== $working ) { update_option( self::TOKEN_OPTION, $working[0], false ); update_option( self::META_OPTION, $working[1], true ); return $result; } $this->forget(); } if ( null !== $previous ) { // The session this one replaces ends in Stand too, so no // credential stays alive unused. $this->revoke( (string) $previous['refresh_token'] ); } return $result; } /** * Reads who signed in, then finds or creates the Stand Site. * * @return true|WP_Error */ private function settle() { $profile = $this->api()->request( 'GET', '/v1/reps/profile' ); if ( is_wp_error( $profile ) ) { return $profile; } $meta = self::stored_metadata(); $rep = isset( $profile['profile'] ) && is_array( $profile['profile'] ) ? $profile['profile'] : array(); $name = trim( (string) ( $rep['firstName'] ?? '' ) . ' ' . (string) ( $rep['lastName'] ?? '' ) ); $meta['user'] = array( 'userId' => (string) ( $rep['userId'] ?? '' ), 'email' => (string) ( $rep['email'] ?? '' ), 'name' => '' !== $name ? $name : (string) ( $rep['displayName'] ?? '' ), ); if ( empty( $profile['completed'] ) ) { // A new account: the person finishes their profile in the Stand app // first, which also sets up their organization. $meta['status'] = 'onboarding'; update_option( self::META_OPTION, $meta, true ); return new WP_Error( 'stand_onboarding', __( 'Finish your profile in Stand, then come back here and continue.', 'stand-chat' ) ); } update_option( self::META_OPTION, $meta, true ); if ( ! empty( $profile['accountDisabled'] ) ) { return new WP_Error( 'stand_account_disabled', __( 'Your organization\'s owner turned off your Stand account. Ask them to turn it back on, or connect another account.', 'stand-chat' ) ); } $site = $this->find_or_create_site(); if ( is_wp_error( $site ) ) { return $site; } $meta = self::stored_metadata(); $previous_site = (string) ( $meta['previous_site'] ?? '' ); $previous_user = (string) ( $meta['previous_user'] ?? '' ); $meta['site'] = $site['site']; $meta['role'] = $site['owner'] ? 'owner' : 'member'; $meta['owner_name'] = $site['owner_name']; $meta['status'] = 'ok'; $meta['connected_at'] = gmdate( 'c' ); $meta['connected_by'] = get_current_user_id(); unset( $meta['previous_site'], $meta['previous_user'] ); update_option( self::META_OPTION, $meta, true ); Settings::update( array( 'site_id' => $this->site_id() ) ); $other_site = '' !== $previous_site && $previous_site !== $this->site_id(); $other_user = '' !== $previous_user && $previous_user !== (string) $meta['user']['userId']; State::set( 'last_user_id', (string) $meta['user']['userId'] ); State::set( 'last_site_id', $this->site_id() ); if ( $other_site || $other_user ) { // The AI assistant, knowledge base and read pages were the other // Site's, or the other person's: Stand-ins and knowledge bases are // each person's own. State::forget( 'standin_id', 'knowledge_base_id', 'standin_preview', 'setup' ); Knowledge::forget_all(); } if ( $other_user ) { // Only the other person can remove what they had read. delete_option( Knowledge::OPTION_REMOVALS ); } State::mark_setup( 'account' ); Cache::flush(); $this->adopt_standin(); if ( ! $site['created'] ) { $this->read_connected_site(); } return true; } /** * Reads the Site WordPress has just connected to, when Stand already had it: * what WordPress keeps about the Site is then the Site's own answer, and * Stand sees that WordPress works with this Site from the moment it connects * (adding a Site says so by itself). A failed read changes nothing. */ private function read_connected_site(): void { $read = $this->api()->request( 'GET', '/v1/sites/' . rawurlencode( $this->site_id() ) ); if ( ! is_wp_error( $read ) && is_array( $read ) ) { $this->remember_site( $read ); } } /** * The Stand Site for this WordPress: the one whose Site ID is set here, or * the one for this address, or a new one. * * @return array{site: array, owner: bool, owner_name: string, created: bool}|WP_Error */ private function find_or_create_site() { $list = $this->api()->request( 'GET', '/v1/sites' ); if ( is_wp_error( $list ) ) { return $list; } $owner = ! empty( $list['isOwner'] ); $domain = Environment::home_domain(); $known = (string) Settings::get( 'site_id' ); $match = null; $same = array(); foreach ( (array) ( $list['sites'] ?? array() ) as $site ) { if ( ! is_array( $site ) || empty( $site['siteId'] ) ) { continue; } if ( '' !== $known && (string) $site['siteId'] === $known ) { $match = $site; break; } if ( Environment::normalize_domain( (string) ( $site['domain'] ?? '' ) ) === $domain ) { $same[] = $site; } } if ( null === $match && count( $same ) > 1 ) { // Which one is for this WordPress only the person knows: the Site ID // pasted before connecting says it. return new WP_Error( 'stand_site_choice', '' === $known ? sprintf( /* translators: %s: domain */ __( 'Your Stand organization has more than one Site for %s. Paste the Site ID of the one to use, from Sites in Stand, then connect again.', 'stand-chat' ), $domain ) : sprintf( /* translators: 1: domain, 2: Site ID */ __( 'Your Stand organization has more than one Site for %1$s, and Site ID %2$s saved here isn\'t one of them. Remove that Site ID, paste the one to use, from Sites in Stand, then connect again.', 'stand-chat' ), $domain, $known ) ); } $match = $match ?? ( $same[0] ?? null ); $created = null === $match; if ( $created ) { $match = $this->api()->request( 'POST', '/v1/sites', array( 'body' => array( 'domain' => $domain, 'name' => mb_substr( wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ), 0, 120 ), ), ) ); if ( is_wp_error( $match ) ) { return $match; } } return array( 'site' => array( 'siteId' => (string) ( $match['siteId'] ?? '' ), 'domain' => (string) ( $match['domain'] ?? '' ), 'name' => (string) ( $match['name'] ?? '' ), 'enabled' => ! isset( $match['enabled'] ) || (bool) $match['enabled'], 'installed' => ! empty( $match['installed'] ), ), 'owner' => $owner, 'owner_name' => (string) ( $list['ownerName'] ?? '' ), 'created' => $created, ); } /** * Picks up the AI assistant the person already has for this Site (they * disconnected and connected again, or set it up in Stand), so setup * doesn't make a second one. */ private function adopt_standin(): void { if ( '' !== (string) State::get( 'standin_id', '' ) ) { return; } $list = $this->api()->request( 'GET', '/v1/reps/me/standins' ); if ( is_wp_error( $list ) ) { return; } $site = $this->site_id(); $mine = array_values( array_filter( (array) ( $list['standins'] ?? array() ), static function ( $standin ) use ( $site ) { return is_array( $standin ) && empty( $standin['deleted'] ) && ( $standin['siteId'] ?? null ) === $site; } ) ); if ( empty( $mine ) ) { return; } // One that's on, if any. usort( $mine, static function ( $a, $b ) { return (int) ! empty( $b['enabled'] ) - (int) ! empty( $a['enabled'] ); } ); State::set( 'standin_id', (string) $mine[0]['standinProfileId'] ); State::mark_setup( 'brief' ); if ( Settings::get( 'chat_enabled' ) ) { State::mark_setup( 'live' ); } } /** * Cancels connecting: the current user's attempt, and the session kept * while a new account sets up in Stand. As Disconnect does, it waits for a * connection under way, and a sign-in begun before it doesn't complete, * even one another request is already redeeming. * * @return WP_Error|null Why it didn't cancel, or null. */ public function cancel(): ?WP_Error { $claim = Option_Queue::claim( Stand::OPTION_CLAIMS, 'connect', 5 * MINUTE_IN_SECONDS ); if ( '' === $claim ) { return self::connecting(); } try { State::set( 'disconnected', wp_generate_uuid4() ); $this->end_flow(); if ( $this->awaits_onboarding() ) { $tokens = $this->tokens(); if ( null !== $tokens ) { $this->revoke( (string) $tokens['refresh_token'] ); } $this->forget(); } } finally { Option_Queue::release( Stand::OPTION_CLAIMS, 'connect', $claim ); } return null; } /** * Forgets the current user's attempt. */ private function end_flow(): void { delete_transient( self::FLOW_TRANSIENT . get_current_user_id() ); } /** * The current user's attempt, if any. * * @return array|null */ private function flow(): ?array { $flow = get_transient( self::FLOW_TRANSIENT . get_current_user_id() ); return is_array( $flow ) && ! empty( $flow['device_code'] ) ? $flow : null; } /** * Keeps the current user's attempt until its code expires. * * @param array $flow Attempt. */ private function save_flow( array $flow ): void { set_transient( self::FLOW_TRANSIENT . get_current_user_id(), $flow, max( MINUTE_IN_SECONDS, (int) $flow['expires_at'] - time() + MINUTE_IN_SECONDS ) ); } /** * Re-reads the Site and the person's role from Stand, renewing the session. * * @return true|WP_Error */ public function refresh_status() { if ( ! $this->is_usable() ) { return new WP_Error( 'stand_not_connected', __( 'Stand Chat isn\'t connected.', 'stand-chat' ) ); } $list = $this->api()->request( 'GET', '/v1/sites' ); if ( is_wp_error( $list ) ) { return $list; } // Into the details stored now, and only while the session that asked // is still the one stored: a connection made meanwhile, such as with // another account, keeps its own. $session = $this->token_session; Option_Queue::change( self::META_OPTION, static function ( array $meta ) use ( $list, $session ): array { if ( '' === $session || (string) Option_Queue::stored( self::TOKEN_OPTION ) !== $session ) { return $meta; } $meta['role'] = ! empty( $list['isOwner'] ) ? 'owner' : 'member'; $meta['owner_name'] = (string) ( $list['ownerName'] ?? '' ); foreach ( (array) ( $list['sites'] ?? array() ) as $site ) { if ( is_array( $site ) && (string) ( $site['siteId'] ?? '' ) === (string) ( $meta['site']['siteId'] ?? '' ) ) { $meta['site'] = array_merge( (array) $meta['site'], array( 'domain' => (string) ( $site['domain'] ?? '' ), 'name' => (string) ( $site['name'] ?? '' ), 'enabled' => ! isset( $site['enabled'] ) || (bool) $site['enabled'], 'installed' => ! empty( $site['installed'] ), ) ); } } return $meta; }, true ); return true; } /** * The stored session as it is now, read past the cache, to tell later * whether it's still the one stored. */ public function session(): string { return (string) Option_Queue::stored( self::TOKEN_OPTION ); } /** * Records that the connection stopped working. * * @param string $reason Stand's error code. * @param string|null $session The stored session that stopped working; * another one connected meanwhile stays as it is. */ public function mark_reconnect_needed( string $reason, ?string $session = null ): void { // Into the details stored now, as save_tokens() does. Option_Queue::change( self::META_OPTION, static function ( array $meta ) use ( $reason, $session ): array { if ( null !== $session && (string) Option_Queue::stored( self::TOKEN_OPTION ) !== $session ) { return $meta; } $meta['status'] = 'reconnect'; $meta['status_reason'] = substr( sanitize_key( $reason ), 0, 60 ); return $meta; }, true ); Cache::flush(); } /** * Disconnects: ends the session in Stand (later, if Stand can't be reached * now) and forgets it. A connection that's under way waits for this, and * one begun before it, by anyone, doesn't complete after it. * * @param bool $keep_chat Keep printing the chat with the Site ID. * @param string|null $loaded fingerprint() of the connection the asking * page was loaded on, or null when the * request doesn't say: one loaded before * WordPress was connected again doesn't end * the new connection. * @return WP_Error|null Why it didn't disconnect, or null. */ public function disconnect( bool $keep_chat, ?string $loaded = null ): ?WP_Error { $claim = Option_Queue::claim( Stand::OPTION_CLAIMS, 'connect', 5 * MINUTE_IN_SECONDS ); if ( '' === $claim ) { return self::connecting(); } try { $identity = $this->identity(); if ( null !== $loaded && self::fingerprint( $identity ) !== $loaded ) { return Stand::reconnected( $identity ); } State::set( 'disconnected', wp_generate_uuid4() ); $this->disconnect_now( $keep_chat ); } finally { Option_Queue::release( Stand::OPTION_CLAIMS, 'connect', $claim ); } return null; } /** * What changes each time Stand Chat is disconnected, read past the cache: * a connection begun before a disconnect doesn't complete. */ private static function disconnects(): string { return (string) ( Option_Queue::read( State::OPTION )['disconnected'] ?? '' ); } /** * Ends the session and forgets it, while this request holds the claim on * connecting. * * @param bool $keep_chat Keep printing the chat with the Site ID. */ private function disconnect_now( bool $keep_chat ): void { // On another address than the Site's, the Site ID can't show chat, so it // goes too: connecting again finds or creates the Site for this address. $moved = Environment::domain_mismatch(); $tokens = $this->tokens(); if ( null !== $tokens ) { $this->revoke( (string) $tokens['refresh_token'] ); } $this->forget(); if ( $moved ) { Settings::update( array( 'site_id' => '', 'chat_enabled' => false, ) ); } elseif ( ! $keep_chat ) { Settings::update( array( 'chat_enabled' => false ) ); } // The knowledge base stays known, like the pages it reads: connecting // again to the same Site as the same person keeps using it, and // finish() forgets both for another Site or person. A site that moved // gets a new Site, and starts afresh. State::forget( 'standin_id', 'setup', 'install_check' ); if ( $moved ) { State::forget( 'knowledge_base_id' ); Knowledge::forget_all(); } Cache::flush(); } /** * Removes the stored connection without calling Stand. */ public function forget(): void { delete_option( self::META_OPTION ); delete_option( self::TOKEN_OPTION ); } /** * An access token for a call to Stand, renewed from the refresh token when * it's about to expire. * * @param bool $renew Renew it even when the kept one hasn't expired, such * as after Stand turned it away. * @return string|null Null when there's none; token_error() says why. */ public function access_token( bool $renew = false ): ?string { $this->token_error = null; $this->token_session = ''; // From one reading of the stored session, past this request's cache, so // the token and the session it's from go together. $renewing = $this->session(); $tokens = $this->tokens( $renewing ); if ( null === $tokens ) { return null; } if ( ! $renew && '' !== (string) $tokens['access_token'] && (int) $tokens['access_expires'] > time() + 30 ) { $this->token_session = $renewing; return (string) $tokens['access_token']; } if ( 'reconnect' === ( Option_Queue::read( self::META_OPTION )['status'] ?? '' ) ) { return null; } // The renewal is kept only while it's still the session stored: a // connection made meanwhile, such as with another account, stays. $response = $this->auth_request( 'token', array( 'grant_type' => 'refresh_token', 'refresh_token' => (string) $tokens['refresh_token'], ) ); if ( is_wp_error( $response ) ) { $oauth = self::oauth_error( $response ); if ( 'invalid_grant' === $oauth ) { // Another request may have renewed it meanwhile. $now = $this->session(); $again = $this->tokens( $now ); if ( null !== $again && $again['refresh_token'] !== $tokens['refresh_token'] ) { $this->token_session = $now; return (string) $again['access_token']; } // The session ended: unused too long, at its longest, or revoked. $this->mark_reconnect_needed( $oauth, $renewing ); } elseif ( '' !== $oauth ) { // Turned away for now, as while Stand updates its sign-in // service (invalid_client): keep the session, try again later. $response = new WP_Error( 'stand_unreachable', __( 'Stand is temporarily unreachable. The chat on your site is not affected.', 'stand-chat' ), array( 'oauth' => $oauth ) ); } $this->token_error = $response; return null; } $saved = $this->save_tokens( $response, false, $renewing ); if ( null === $saved ) { // Renewed or replaced meanwhile by another request: its session // goes on, and this renewal goes unused. $now = $this->session(); $again = $this->tokens( $now ); if ( null === $again ) { return null; } $this->token_session = $now; return (string) $again['access_token']; } $this->token_session = $saved; return (string) $response['access_token']; } /** * Why access_token() last returned null: stand_unreachable when Stand's * sign-in service couldn't be reached or turned WordPress away for now, * else null. */ public function token_error(): ?WP_Error { return $this->token_error; } /** * The stored session the token access_token() last returned is from, * as session() read it then. */ public function token_session(): string { return $this->token_session; } /** * The kept tokens, or null when missing or unreadable (salts changed). * * @param string|null $stored The stored value to read them from, rather * than the option as this request has it. * @return array{access_token: string, access_expires: int, refresh_token: string}|null */ private function tokens( ?string $stored = null ): ?array { $stored = $stored ?? get_option( self::TOKEN_OPTION, '' ); if ( ! is_string( $stored ) || '' === $stored ) { return null; } $plain = Secret_Store::decrypt( $stored ); $data = null === $plain ? null : json_decode( $plain, true ); if ( ! is_array( $data ) || empty( $data['refresh_token'] ) ) { return null; } return array( 'access_token' => (string) ( $data['access_token'] ?? '' ), 'access_expires' => (int) ( $data['access_expires'] ?? 0 ), 'refresh_token' => (string) $data['refresh_token'], ); } /** * Keeps tokens from Stand's sign-in service, and when the session ends. * * @param array $response Token response. * @param bool $new_session Whether it's a new session. * @param string|null $renewed For a renewal, the stored value * it renewed, which must still be * the one stored. * @return string|null The session as stored now, or null when they weren't kept. */ private function save_tokens( array $response, bool $new_session, ?string $renewed = null ): ?string { $kept = $new_session ? null : $this->tokens( $renewed ); $access = (string) ( $response['access_token'] ?? '' ); $refresh = (string) ( $response['refresh_token'] ?? ( null === $kept ? '' : $kept['refresh_token'] ) ); if ( '' === $access || '' === $refresh ) { return null; } $tokens = array( 'access_token' => $access, 'access_expires' => time() + max( 0, (int) ( $response['expires_in'] ?? 60 ) ), 'refresh_token' => $refresh, ); $value = Secret_Store::encrypt( (string) wp_json_encode( $tokens ) ); if ( null === $renewed ) { update_option( self::TOKEN_OPTION, $value, false ); } elseif ( ! Option_Queue::swap( self::TOKEN_OPTION, $renewed, $value ) ) { return null; } // Stand says with every token when the session ends: unused for a // while, or at its longest lifetime, whichever is first. $ends = (int) ( $response['refresh_expires_in'] ?? 0 ); $meta = array( 'expires_at' => $ends > 0 ? gmdate( 'c', time() + $ends ) : '', 'last_used_at' => gmdate( 'c' ), ); if ( isset( $response['scope'] ) ) { $meta['scopes'] = array_values( array_filter( explode( ' ', (string) $response['scope'] ) ) ); } if ( $new_session ) { $meta['status'] = 'new'; update_option( self::META_OPTION, $meta, true ); } else { // Into the details stored now, not this request's copy of them, and // only while the renewed session is still the one stored: details // of a connection made meanwhile stay its own. Option_Queue::change( self::META_OPTION, static function ( array $stored ) use ( $meta, $value ): array { return (string) Option_Queue::stored( self::TOKEN_OPTION ) === $value ? array_merge( $stored, $meta ) : $stored; }, true ); } return $value; } /** * Ends a session in Stand. When Stand can't be reached, or turns the client * away for now, the refresh token is kept, encrypted, and revoked later, * hourly until Stand confirms. * * @param string $refresh_token Refresh token. */ private function revoke( string $refresh_token ): void { if ( '' === $refresh_token ) { return; } $result = $this->auth_request( 'revoke', array( 'token' => $refresh_token, 'token_type_hint' => 'refresh_token', ) ); if ( is_wp_error( $result ) && self::turned_away_for_now( $result ) ) { $stored = Secret_Store::encrypt( $refresh_token ); Option_Queue::change( self::REVOKE_OPTION, static function ( array $queue ) use ( $stored ): array { $queue[] = $stored; return $queue; } ); if ( ! wp_next_scheduled( self::REVOKE_HOOK ) ) { wp_schedule_single_event( time() + HOUR_IN_SECONDS, self::REVOKE_HOOK ); } } } /** * Cron: ends the sessions Stand couldn't be told about before. */ public function retry_revocations(): void { // Takes exactly the queue it read: one queued meanwhile waits for the next run. $queue = array(); Option_Queue::change( self::REVOKE_OPTION, static function ( array $stored ) use ( &$queue ): array { $queue = $stored; return array(); } ); foreach ( $queue as $stored ) { $token = Secret_Store::decrypt( (string) $stored ); if ( null !== $token ) { $this->revoke( $token ); } } } /** * The OAuth error code Stand's sign-in service answered with, or ''. * * @param WP_Error $error Error from auth_request(). */ private static function oauth_error( WP_Error $error ): string { return 'stand_oauth' === $error->get_error_code() ? (string) ( ( (array) $error->get_error_data() )['oauth'] ?? '' ) : ''; } /** * Whether Stand's sign-in service didn't take a request only for now: * not reached, busy (429), or turning the client away while Stand * updates it, so the request is worth trying again. * * @param WP_Error $error Error from auth_request(). */ private static function turned_away_for_now( WP_Error $error ): bool { $oauth = self::oauth_error( $error ); return '' === $oauth || 429 === (int) ( ( (array) $error->get_error_data() )['status'] ?? 0 ) || in_array( $oauth, array( 'invalid_client', 'temporarily_unavailable', 'server_error' ), true ); } /** * Calls Stand's sign-in service. * * @param string $endpoint auth/device, token or revoke. * @param array $fields Form fields; client_id is added. * @return array|WP_Error An OAuth error is stand_oauth, with * its code in the error data's oauth. */ private function auth_request( string $endpoint, array $fields ) { $headers = array( 'Accept' => 'application/json', 'User-Agent' => Api_Client::user_agent(), ); // When WordPress reaches the sign-in service at another address than // people do, such as from Docker to a local Stand, Keycloak answers for // the public one: the sign-in page it links and its tokens' issuer. $public = wp_parse_url( Service_Urls::get( 'auth' ) ); $server = wp_parse_url( Service_Urls::get( 'server_auth' ) ); $host = static function ( $parts ): string { return is_array( $parts ) && ! empty( $parts['host'] ) ? $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) : ''; }; if ( '' !== $host( $public ) && $host( $public ) !== $host( $server ) ) { $headers['X-Forwarded-Host'] = $host( $public ); $headers['X-Forwarded-Proto'] = (string) ( $public['scheme'] ?? 'https' ); } $response = wp_remote_post( untrailingslashit( Service_Urls::get( 'server_auth' ) ) . '/protocol/openid-connect/' . $endpoint, array( 'body' => array_merge( array( 'client_id' => self::client_id() ), $fields ), 'timeout' => 10, 'redirection' => 0, 'headers' => $headers, ) ); $unreachable = new WP_Error( 'stand_unreachable', __( 'Stand is temporarily unreachable. The chat on your site is not affected.', 'stand-chat' ) ); if ( is_wp_error( $response ) ) { $unreachable->add_data( array( 'status' => 0, 'reason' => $response->get_error_message(), ) ); return $unreachable; } $status = (int) wp_remote_retrieve_response_code( $response ); $raw = (string) wp_remote_retrieve_body( $response ); $body = json_decode( $raw, true ); $json = is_array( $body ); $body = $json ? $body : array(); if ( $status >= 200 && $status < 300 ) { // Tokens and codes come as JSON; a revocation may answer with // nothing. Anything else is from something in between, such as a // proxy's page: the sign-in service wasn't reached. if ( ! $json && ! ( 'revoke' === $endpoint && '' === trim( $raw ) ) ) { $unreachable->add_data( array( 'status' => $status, 'reason' => 'not_json', ) ); return $unreachable; } return $body; } $oauth = (string) ( $body['error'] ?? '' ); if ( $status >= 500 || '' === $oauth ) { $unreachable->add_data( array( 'status' => $status ) ); return $unreachable; } return new WP_Error( 'stand_oauth', (string) ( $body['error_description'] ?? $oauth ), array( 'status' => $status, 'oauth' => $oauth, ) ); } }