plugin = $plugin; $this->hooks(); } /** * Initiate our hooks. * * @since 0.6.0 */ public function hooks() { // } public function client_init() { if ( !empty($this->client_id) || !empty($this->client_secret) ) { return $this; } $settings = ssa()->settings->get(); $google_calendar_settings = $settings['google_calendar']; // Only initialize if we're not using the new ssa_quick_connect auth flow // any method besides this one should access the client_id and client_secret directly on this class if( !$google_calendar_settings['quick_connect_gcal_mode'] ){ $this->client_id = $this->plugin->google_calendar->get_client_id(); $this->client_secret = $this->plugin->google_calendar->get_client_secret(); } else { // if ssa_quick_connect enabled get our own client_id if( !defined( 'SSA_QUICK_CONNECT_GCAL_CLIENT_ID' ) ){ ssa_debug_log( 'SSA_QUICK_CONNECT_GCAL_CLIENT_ID not defined!', 10 ); return false; } $this->client_id = SSA_QUICK_CONNECT_GCAL_CLIENT_ID; } return $this; } public function service_init( $staff_id = 0 ) { $client = (new self( $this->plugin ))->client_init(); $client->staff_id = $staff_id; $client->authorize(); return $client; } /** * Call this to authorize the client * updates the access token in the settings as well * * @since 6.6.5 * * @return void */ private function authorize() { $staff_access_token = $this->get_access_token_for_staff_id(); if( $staff_access_token != $this->access_token ) { $this->access_token = $staff_access_token; } // check also if the access token is the correct one if( !$this->is_access_token_expired( $this->access_token ) ) { // no need to refresh access token return; } // if quick connect enabled, get quick connect access token $google_calendar_settings = $this->plugin->google_calendar_settings->get(); $google_quick_connect_gcal_mode = $google_calendar_settings['quick_connect_gcal_mode'] == true; if( true == $google_quick_connect_gcal_mode ){ $this->authorize_with_quick_connect( $this->staff_id ); } else { $this->authorize_with_client_id_and_secret(); } if ( empty( $this->access_token ) ) { ssa_debug_log( 'missing_access_token for staff id '.$this->staff_id, 10 ); return; } // if still expired if( $this->is_access_token_expired( $this->access_token ) ) { ssa_debug_log( 'expired_access_token for staff id '.$this->staff_id, 10 ); ssa_debug_log( ssa_get_stack_trace(), 10 ); throw new Exception( 'Failed to authorize with Google Calendar' ); } } private function authorize_with_client_id_and_secret() { $access_token = $this->get_access_token_for_staff_id(); // throwing the exception here to avoid fatal error of accessing an offset on a non-array if( empty( $access_token ) || !is_array( $access_token ) ) { throw new Exception( 'Empty access token for staff id '.$this->staff_id ); } if( $this->is_access_token_expired( $access_token ) ) { $this->access_token = $this->refresh_access_token( $access_token ); $this->update_token_in_database(); } else { $this->access_token = $access_token; } } private function get_access_token_for_staff_id() { // get access token from settings if( empty( $this->staff_id ) ) { $google_calendar_settings = $this->plugin->google_calendar_settings->get(); return $google_calendar_settings['access_token']; } else { $staff = SSA_Staff_Object::instance( $this->staff_id ); return $staff->google_access_token; } } /** * Quick Connect is assumed to always return a valid access token * This should shortcut the method that sets the access token and just set the access token directly * * @param [type] $staff_id * @return void */ private function authorize_with_quick_connect() { $this->access_token = $this->plugin->google_calendar->get_quick_connect_access_token( $this->staff_id ); // no need to update the token in database, because get_quick_connect_access_token handles that } private function get_request_headers( ){ if( empty( $this->access_token ) ) { $this->authorize(); } $headers = array( 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $this->access_token['access_token'], ); return $headers; } /** * Test and confirm that the access token * Makes an API call and confirms that the access token is valid * * @param array $options * @return void */ public function validate_access_token( array $access_token ) { $gcal_api_endpoint = 'https://www.googleapis.com/calendar/v3/users/me/calendarList'; $response = wp_remote_get( $gcal_api_endpoint, array( 'headers' => array( 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $access_token['access_token'], ), 'timeout' => 60 ) ); if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) > 299 ) { if( wp_remote_retrieve_response_code( $response ) == 401 ){ // expired token return false; } ssa_debug_log( print_r( $response, true ), 10); // phpcs:ignore throw new Exception( 'Failed to validate Google Calendar access token' ); } return true; } /** * use in place of ->calendarList->listCalendarList( $options = array() ) {} * this method will return all calendars, not just the first page * * @since 6.6.5 * * @return array */ public function get_calendar_list( $options = array() ) { $calendar_list = array(); $gcal_api_endpoint = 'https://www.googleapis.com/calendar/v3/users/me/calendarList' . '?' . $this->get_params_from_options( $options ); $current_endpoint = $gcal_api_endpoint; // get all pages of calendar list while(true){ try { $response = wp_remote_get( $current_endpoint, array( 'headers' => $this->get_request_headers(), 'timeout' => 60 ) ); if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) > 299 ) { ssa_debug_log( print_r( $response, true ), 10); // phpcs:ignore return false; } $data = json_decode( wp_remote_retrieve_body( $response ) ); // add calendar list to array $calendar_list = array_merge( $calendar_list, $data->items ); if(empty($data->items)){ ssa_debug_log( 'No calendars found in calendar list', 10 ); ssa_debug_log( print_r( $response, true ), 10); // phpcs:ignore } if ( empty( $data->nextPageToken ) ) { break; } else { $current_endpoint = $gcal_api_endpoint . '&pageToken=' . $data->nextPageToken; } } catch ( \Throwable $th ) { ssa_debug_log( print_r( $th, true ), 10 ); // phpcs:ignore break; } } // Success // return calendar list return $calendar_list; } /** * * use in place of ->calendarList->get( $calendar_id, $options = array() ) {} */ public function get_calendar_from_calendar_list ( $calendar_id, $options = array() ) { $gcal_api_endpoint = "https://www.googleapis.com/calendar/v3/users/me/calendarList/" . urlencode( $calendar_id ) . "?" . $this->get_params_from_options( $options ); try { $response = wp_remote_get( $gcal_api_endpoint, array( 'headers' => $this->get_request_headers(), 'timeout' => 60 ) ); // we don't want to log 404 errors, because we expect them if the calendar is not found if ( is_wp_error( $response ) || ( wp_remote_retrieve_response_code( $response ) > 299 && wp_remote_retrieve_response_code( $response ) != 404 ) ) { ssa_debug_log( print_r( $response, true ), 10 ); // phpcs:ignore return false; } $data = json_decode( wp_remote_retrieve_body( $response ) ); // Success return $data; } catch ( \Throwable $th ) { ssa_debug_log( print_r( $th, true ), 10 ); // phpcs:ignore return false; } } /** * use in place of ->events->listEvents( $calendar_id, $options = array() ) {} */ public function get_events_from_calendar( $calendar_id, $options = array() ) { // if is a holiday caledar, pull events in english locale so that we have a way to identiy public holidays if( false !== strpos( $calendar_id, 'holiday' ) ){ $calendar_id_parts = explode( '.', $calendar_id ); array_shift( $calendar_id_parts ); $calendar_id = 'en.' . implode( '.', $calendar_id_parts ); } // exclude workingLocation events - these are not useful for availability $event_types_query = 'eventTypes=default&eventTypes=outOfOffice&eventTypes=focusTime&eventTypes=fromGmail'; $gcal_api_endpoint = "https://www.googleapis.com/calendar/v3/calendars/" . urlencode( $calendar_id ) . "/events?" . $event_types_query . '&' . $this->get_params_from_options( $options ); try { $response = wp_remote_get( $gcal_api_endpoint, array( 'headers' => $this->get_request_headers(), 'timeout' => 60 ) ); if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) > 299 ) { if( wp_remote_retrieve_response_code($response) == 404 ){ ssa_debug_log( 'Received 404, getting events for ' . $calendar_id . " from " . $gcal_api_endpoint . " working with staff id " . $this->staff_id ); // phpcs:ignore ssa_debug_log( ssa_get_stack_trace(), 10 ); } else { ssa_debug_log( print_r( $response, true ), 10 ); // phpcs:ignore } return []; } $data = json_decode( wp_remote_retrieve_body( $response ) ); // Success return $data->items; } catch ( \Throwable $th ) { ssa_debug_log( print_r( $th, true ), 10 ); // phpcs:ignore return []; } } /** * * use in place of ->events->insert( $calendar_id, $event, $options = array() ) {} * */ public function insert_event_into_calendar( $calendar_id, $event, $options = array() ) { $gcal_api_endpoint = "https://www.googleapis.com/calendar/v3/calendars/" . urlencode( $calendar_id ) . "/events?" . $this->get_params_from_options( $options ); try { $response = wp_remote_post( $gcal_api_endpoint, array( 'headers' => $this->get_request_headers(), 'timeout' => 60, 'body' => json_encode($event), ) ); if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) > 299 ) { ssa_debug_log( print_r( $response, true ), 10 ); // phpcs:ignore return false; } $event = json_decode(wp_remote_retrieve_body($response) ); // Success // return event ID return $event; } catch ( \Throwable $th ) { ssa_debug_log( print_r( $th, true ), 10 ); // phpcs:ignore return false; } } /** * * use in place of ->events->get( $calendar_id, $event_id, $options = array() ) {} * */ public function get_event_from_calendar( $calendar_id, $event_id, $options = array() ) { if(empty($calendar_id) || empty($event_id)){ ssa_debug_log( 'Warning: called get_event_from_calendar with calendar_id:' . $calendar_id . ' & event_id:' . $event_id , 10 ); return false; } $gcal_api_endpoint = "https://www.googleapis.com/calendar/v3/calendars/" . urlencode( $calendar_id ) . "/events/" . $event_id . "?" . $this->get_params_from_options( $options ); try { $response = wp_remote_get( $gcal_api_endpoint, array( 'headers' => $this->get_request_headers(), 'timeout' => 60 ) ); if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) > 299 ) { ssa_debug_log( print_r( $response, true ), 10 ); // phpcs:ignore return false; } $data = json_decode(wp_remote_retrieve_body($response) ); // Success return $data; } catch ( \Throwable $th ) { ssa_debug_log( print_r( $th, true ), 10 ); // phpcs:ignore return false; } } /** * * use in place of ->events->update( $calendar_id, $event_id, $event_updated, $options = array() ) { } */ public function update_event_in_calendar( $calendar_id, $event_id, $event_updated, $options = array() ) { $gcal_api_endpoint = "https://www.googleapis.com/calendar/v3/calendars/" . urlencode( $calendar_id ) . "/events/" . $event_id . "?" . $this->get_params_from_options( $options ); try { $response = wp_remote_request( $gcal_api_endpoint, array( 'headers' => $this->get_request_headers(), 'timeout' => 60, 'body' => json_encode( $event_updated ), 'method' => 'PUT' ) ); if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) > 299 ) { ssa_debug_log( print_r( $response, true ), 10 ); // phpcs:ignore return false; } $data = json_decode(wp_remote_retrieve_body($response) ); // Success return $data; } catch ( \Throwable $th ) { ssa_debug_log( print_r( $th, true ), 10 ); // phpcs:ignore return false; } } /** * use in place of ->events->delete( $calendar_id, $event_id, $options = array() ) {} */ public function delete_event_from_calendar( $calendar_id, $event_id, $options = array() ) { $gcal_api_endpoint = "https://www.googleapis.com/calendar/v3/calendars/" . urlencode( $calendar_id ) . "/events/" . $event_id . "?" . $this->get_params_from_options( $options ); try { $response = wp_remote_request( $gcal_api_endpoint, array( 'headers' => $this->get_request_headers(), 'timeout' => 60, 'method' => 'DELETE' ) ); if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) > 299 ) { ssa_debug_log( print_r( $response, true ), 10 ); // phpcs:ignore return false; } // Success // the delete method returns an empty body return true; } catch ( \Throwable $th ) { ssa_debug_log( print_r( $th, true ), 10 ); // phpcs:ignore return false; } } /** * description: this is the same logic used by the PHP OAuth client * with a minor difference, this takes the $token as an argument * * @param array $token * @return bool Returns True if the access_token is expired. */ public function is_access_token_expired( $token ) { if ( !$token ) { return true; } if ( is_object( $token ) ) { $token = (array) $token; } // if less than 300 seconds remaining, refresh the token anyways $created = 0; if ( isset( $token['created'] ) ) { $created = $token['created']; } elseif ( isset( $token['id_token'] ) ) { // check the ID token for "iat" // signature verification is not required here, as we are just // using this for convenience to save a round trip request // to the Google API server $idToken = $token['id_token']; if ( substr_count( $idToken, '.' ) == 2 ) { $parts = explode( '.', $idToken ); $payload = json_decode( base64_decode( $parts[1] ), true ); if ( $payload && isset( $payload['iat'] ) ) { $created = $payload['iat']; } } } if( $created > 0 ){ $buffer = 300; $expires_in = 3599; // access tokens usually expire in 3599 seconds if( $created + $expires_in - $buffer < time() ){ // consider expired to stay on the safe side return true; } } // invert return ! $this->validate_access_token( $token ); } /** * Exchange the refresh token for an access token * * @param string $client_id * @param string $client_secret * @param string $refresh_token * @return bool|array */ private function exchange_refresh_token( $client_id, $client_secret, $refresh_token ){ $gcal_api_endpoint = 'https://www.googleapis.com/oauth2/v4/token'; try { $response = wp_remote_post( $gcal_api_endpoint, array( 'body' => array( 'refresh_token' => $refresh_token, 'client_id' => $client_id, 'client_secret' => $client_secret, 'grant_type' => 'refresh_token', // return also the refresh token 'access_type' => 'offline', ), 'timeout' => 60 ) ); if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) > 299 ) { ssa_debug_log( print_r( $response, true ), 10 ); // phpcs:ignore return false; } $data = json_decode(wp_remote_retrieve_body($response), true); if( empty( $data['refresh_token'] ) ) { // attach the refresh token to the access token $data['refresh_token'] = $refresh_token; } // Success return $data; } catch ( \Throwable $th ) { ssa_debug_log( print_r( $th, true ), 10 ); // phpcs:ignore return false; } } /** * We never call this with the quick connect flow, because we don't have a refresh token * * @return void */ private function refresh_access_token($access_token) { $client_id = $this->client_id; $client_secret = $this->client_secret; $refresh_token = $access_token['refresh_token']; $response = $this->exchange_refresh_token( $client_id, $client_secret, $refresh_token ); if( empty( $response ) || ! is_array( $response ) || empty( $response['access_token'] ) ) { ssa_debug_log( 'Failed to refresh access token for staff id ' . (string) $this->staff_id . print_r($response, true), 10); // phpcs:ignore throw new Exception( 'Failed to refresh access token' ); } return $response; } private function update_token_in_database(){ $staff_id = $this->staff_id; $access_token = $this->access_token; if(empty($staff_id)){ if(empty($access_token['refresh_token'])){ // log that we received an access token without a refresh token ssa_debug_log('Received an access token without a refresh token ' . ' for staff id ' . (string) $staff_id . print_r($access_token, true), 10 ); // phpcs:ignore $google_calendar_settings = $this->plugin->google_calendar_settings->get(); $access_token['refresh_token'] = !empty($google_calendar_settings['access_token']['refresh_token']) ? $google_calendar_settings['access_token']['refresh_token'] : ''; } $this->plugin->google_calendar_settings->update( array( 'access_token' => $access_token ) ); } else { if(empty($access_token['refresh_token'])){ // log that we received an access token without a refresh token ssa_debug_log('Received an access token without a refresh token ' . ' for staff id ' . (string) $staff_id . print_r($access_token, true), 10 ); // phpcs:ignore $staff = $this->plugin->staff_model->get( $staff_id ); $access_token['refresh_token'] = !empty($staff['google_access_token']['refresh_token']) ? $staff['google_access_token']['refresh_token'] : ''; } $this->plugin->staff_model->update( $staff_id, array( 'google_access_token' => $access_token, ) ); } } public function get_auth_url( $staff_id, $wp_next_ssa_uri = null, $wp_next_base_uri = null ) { $this->client_init(); $gcal_api_endpoint = 'https://accounts.google.com/o/oauth2/auth?'; // need to store the exact home url returned at this point // because some plugins can affect the home url, causing the quick-connect domain to be invalid $site_home_url = get_home_url(); $this->plugin->google_calendar_settings->update( array( 'quick_connect_home_url' => $site_home_url, ) ); $license_settings = $this->plugin->license_settings->get(); $license = ''; // https://accounts.google.com/o/oauth2/auth? $params = array( 'response_type'=>'code', 'client_id'=> $this->client_id, 'redirect_uri'=> $this->plugin->google_calendar->get_redirect_uri(), 'scope'=> 'https://www.googleapis.com/auth/calendar openid', 'approval_prompt'=>'force', 'access_type'=>'offline', ); if ( empty( $wp_next_ssa_uri ) ) { $wp_next_ssa_uri = 'ssa/settings/google-calendar'; } if ( empty( $wp_next_base_uri ) ) { $wp_next_base_uri = $this->plugin->wp_admin->url(); } $params['state'] = strtr( base64_encode( json_encode( array( 'authorize' => 'google', 'staff_id' => $staff_id, 'staff_token' => SSA_Utils::site_unique_hash( $staff_id ), 'token' => $license, 'redirect_uri' => $this->plugin->google_calendar->get_redirect_uri(), 'wp_callback_uri' => $this->plugin->google_calendar::get_wp_callback_uri(), 'wp_next_ssa_uri' => $wp_next_ssa_uri, 'wp_next_base_uri' => $wp_next_base_uri, // grab from the parent page (example: /my-account/), like we do for booking_url in booking-app // used for ssa_quick_connect - staff_id as well 'domain' => $site_home_url, 'license_key'=> $license_settings['license_filtered'], ) ) ), '+/=', '-_,' ); return $gcal_api_endpoint . $this->get_params_from_options( $params ); } /** * * @since 6.6.5 * * @return bool */ public function exchange_auth_code( $code ) { $this->client_init(); $gcal_api_endpoint = 'https://www.googleapis.com/oauth2/v4/token?'; $params = array( 'code' => $code, 'grant_type' => 'authorization_code', 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, 'redirect_uri' => $this->plugin->google_calendar->get_redirect_uri(), ); try { $response = wp_remote_post( $gcal_api_endpoint . $this->get_params_from_options( $params ), array( 'timeout' => 20 )); if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) > 299 ) { throw new \Throwable( $response ); } $data = json_decode(wp_remote_retrieve_body($response), true); $this->access_token = $data; return true; } catch ( \Throwable $th ) { ssa_debug_log( print_r( $th, true ), 10 ); // phpcs:ignore return false; } } public function get_exchange_response() { return $this->access_token; } public function get_access_token() { return $this->access_token; } public function get_params_from_options ( $options ) { if( empty( $options ) ) { return ''; } $params_string = ''; // avoid $this->get_params_from_options( $options ); it will convert true to 1, and google api does not like that foreach( $options as $key => $value ) { // if boolean replace with string equivalent if( is_bool( $value ) ) { $value = $value ? 'true' : 'false'; } $params_string .= http_build_query([$key => $value]) . '&'; } return $params_string; } public function revoke_token( $token ) { $response = wp_remote_post( 'https://oauth2.googleapis.com/revoke', array( 'body' => array( 'token' => $token, ), ) ); } }