record = $record; } /** * The connection, or null when the site is not connected. * * @return Connection|null */ public static function get() { $record = get_option( self::OPTION, array() ); return is_array( $record ) && ! empty( $record['token'] ) ? new self( $record ) : null; } /** * The connection's state, also when there is none. * * @return string */ public static function current_state() { $connection = self::get(); return $connection ? $connection->state() : self::DISCONNECTED; } /** * The state: connected · left-account · expired · copied-site · cannot-decrypt. * * @return string */ public function state() { if ( ! empty( $this->record['state'] ) && in_array( $this->record['state'], array( self::LEFT_ACCOUNT, self::EXPIRED ), true ) ) { return $this->record['state']; } if ( ! empty( $this->record['home'] ) && self::normal_url( $this->record['home'] ) !== self::normal_url( home_url() ) ) { return self::COPIED_SITE; } if ( null === Crypto::open( $this->record['token'] ) ) { return self::CANNOT_DECRYPT; } return self::CONNECTED; } /** * The SR Anywhere account's id. * * @return string */ public function account_id() { return (string) ( $this->record['account_id'] ?? '' ); } /** * The host the connection was made for. * * @return string */ public function site() { return (string) ( $this->record['site'] ?? '' ); } /** * The e-mail address the API reports for the account, shown, not used. * * @return string */ public function connected_by() { return (string) ( $this->record['connected_by'] ?? '' ); } /** * The token, for Api only, and only while the connection is usable. For an OAuth connection this is the stored * access token as it is; OAuth::access_token() refreshes it when it is about to end. * * @internal * @return string|null */ public function token() { return self::CONNECTED === $this->state() ? Crypto::open( $this->record['token'] ) : null; } /** * Whether the site connected by OAuth (else by a pasted key). * * @return bool */ public function is_oauth() { return 'oauth' === ( $this->record['kind'] ?? '' ); } /** * The OAuth client's id, for refreshes. * * @internal * @return string */ public function client_id() { return (string) ( $this->record['client_id'] ?? '' ); } /** * When the stored access token ends (Unix time); 0 for a pasted key, which does not end. * * @return int */ public function expires_at() { return $this->is_oauth() ? (int) ( $this->record['expires_at'] ?? 0 ) : PHP_INT_MAX; } /** * The refresh token, for OAuth only; null when there is none or it cannot be read. * * @internal * @return string|null */ public function refresh_token() { if ( ! $this->is_oauth() || empty( $this->record['refresh'] ) ) { return null; } return Crypto::open( $this->record['refresh'] ); } /** * Connects with the tokens the return route exchanged (OAuth): checks them against the service first * (GET /api/connect/account), like a pasted key. * * @param array $tokens The token endpoint's answer (`access_token`, `refresh_token`, `expires_in`). * @param string $client_id The registered client's id. * @return Connection|\WP_Error */ public static function connect_with_tokens( array $tokens, $client_id ) { $access = (string) ( $tokens['access_token'] ?? '' ); $refresh = (string) ( $tokens['refresh_token'] ?? '' ); if ( '' === $access || '' === $refresh ) { return new \WP_Error( 'sranywhere_tokens_incomplete', __( 'SR Anywhere did not issue a refresh token. Start again from Settings → SR Anywhere.', 'sr-anywhere' ) ); } $account = Api::get_with_token( $access, '/api/connect/account' ); if ( is_wp_error( $account ) ) { return new \WP_Error( 'sranywhere_unreachable', __( 'SR Anywhere could not be reached. Please try again in a moment.', 'sr-anywhere' ) ); } $sealed_access = Crypto::seal( $access ); $sealed_refresh = Crypto::seal( $refresh ); if ( null === $sealed_access || null === $sealed_refresh ) { return new \WP_Error( 'sranywhere_no_sodium', __( 'This server cannot store the connection safely (libsodium is missing).', 'sr-anywhere' ) ); } $who = isset( $account['account'] ) && is_array( $account['account'] ) ? $account['account'] : array(); $record = array( 'token' => $sealed_access, 'refresh' => $sealed_refresh, 'expires_at' => time() + max( 60, (int) ( $tokens['expires_in'] ?? 3600 ) ), 'kind' => 'oauth', 'client_id' => sanitize_text_field( (string) $client_id ), 'home' => home_url(), 'site' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'environment' => wp_get_environment_type(), 'account_id' => isset( $who['id'] ) && is_string( $who['id'] ) ? sanitize_text_field( $who['id'] ) : '', 'connected_by' => isset( $who['owner'] ) && is_string( $who['owner'] ) ? sanitize_email( $who['owner'] ) : '', 'connected_at' => time(), ); update_option( self::OPTION, $record, false ); Cache::forget_all(); Rest::modules_answer(); // fills the allow-list of keys this site may print (Placeable) return new self( $record ); } /** * Keeps refreshed tokens (OAuth). The rest of the record stays. * * @internal * @param array $tokens The token endpoint's answer. * @return true|\WP_Error */ public function store_tokens( array $tokens ) { $access = (string) ( $tokens['access_token'] ?? '' ); $sealed_access = '' !== $access ? Crypto::seal( $access ) : null; $sealed_refresh = ! empty( $tokens['refresh_token'] ) && is_string( $tokens['refresh_token'] ) ? Crypto::seal( $tokens['refresh_token'] ) : $this->record['refresh']; if ( null === $sealed_access || null === $sealed_refresh ) { return new \WP_Error( 'sranywhere_no_sodium', __( 'This server cannot store the connection safely (libsodium is missing).', 'sr-anywhere' ) ); } $record = $this->record; $record['token'] = $sealed_access; $record['refresh'] = $sealed_refresh; $record['expires_at'] = time() + max( 60, (int) ( $tokens['expires_in'] ?? 3600 ) ); unset( $record['state'] ); update_option( self::OPTION, $record, false ); $this->record = $record; return true; } /** * Connects with a pasted connection key: checks it against the service first (GET /api/connect/account). * * @param string $key The key. * @return Connection|\WP_Error */ public static function connect_with_key( $key ) { $key = trim( (string) $key ); if ( ! Keys::is_connection_token( $key ) ) { return new \WP_Error( 'sranywhere_key_shape', __( 'That does not look like an SR Anywhere connection key. It starts with sra_con_.', 'sr-anywhere' ) ); } $account = Api::get_with_token( $key, '/api/connect/account' ); if ( is_wp_error( $account ) ) { $status = (int) ( $account->get_error_data()['status'] ?? 0 ); if ( 401 === $status || 403 === $status ) { return new \WP_Error( 'sranywhere_key_refused', __( 'SR Anywhere did not accept this key. Make a new one in SR Anywhere, Settings → Connections.', 'sr-anywhere' ) ); } return new \WP_Error( 'sranywhere_unreachable', __( 'SR Anywhere could not be reached. Please try again in a moment.', 'sr-anywhere' ) ); } $sealed = Crypto::seal( $key ); if ( null === $sealed ) { return new \WP_Error( 'sranywhere_no_sodium', __( 'This server cannot store the key safely (libsodium is missing).', 'sr-anywhere' ) ); } $who = isset( $account['account'] ) && is_array( $account['account'] ) ? $account['account'] : array(); $who = array_map( static function ( $value ) { return is_string( $value ) ? $value : ''; }, $who ); $record = array( 'token' => $sealed, 'kind' => 'key', 'home' => home_url(), 'site' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'environment' => wp_get_environment_type(), 'account_id' => isset( $who['id'] ) ? sanitize_text_field( $who['id'] ) : '', 'connected_by' => isset( $who['owner'] ) ? sanitize_email( $who['owner'] ) : '', 'connected_at' => time(), ); update_option( self::OPTION, $record, false ); Cache::forget_all(); Rest::modules_answer(); // fills the allow-list of keys this site may print (Placeable) return new self( $record ); } /** * Ends the connection on this site. Placed modules keep printing (decision 0002). An OAuth connection is ended at * SR Anywhere too, best effort; a pasted key has no revoke route (the person removes it in Settings → Connections). */ public static function disconnect() { $connection = self::get(); if ( $connection && $connection->is_oauth() ) { OAuth::revoke( $connection ); OAuth::unschedule(); } delete_option( self::OPTION ); Cache::forget_all(); } /** * "This is the same site, moved": the current address becomes the connection's (decision 0008). */ public static function confirm_moved_site() { $connection = self::get(); if ( ! $connection ) { return; } $record = $connection->record; $record['home'] = home_url(); $record['site'] = (string) wp_parse_url( home_url(), PHP_URL_HOST ); update_option( self::OPTION, $record, false ); } /** * Records what the service said about the token (a 401: the key was removed there, or its person left the account). * * @param string $state LEFT_ACCOUNT or EXPIRED. */ public static function mark( $state ) { $connection = self::get(); if ( ! $connection || ! in_array( $state, array( self::LEFT_ACCOUNT, self::EXPIRED ), true ) ) { return; } $record = $connection->record; $record['state'] = $state; update_option( self::OPTION, $record, false ); Cache::forget_all(); } /** * A URL without scheme, `www.` and trailing slash, for comparing addresses. * * @param string $url A URL. * @return string */ private static function normal_url( $url ) { return untrailingslashit( strtolower( preg_replace( '#^https?://(www\.)?#i', '', (string) $url ) ) ); } }