base = $base; add_action( 'wp_to_buffer_output_auth', array( $this, 'output_oauth' ) ); add_action( 'wp_to_buffer_pro_output_auth', array( $this, 'output_oauth' ) ); } /** * Outputs an Authorize Plugin button on Settings > General when the Plugin needs to be authenticated with Buffer. * * @since 4.2.0 */ public function output_oauth() { ?>
get_code_verifier(); if ( $code_verifier ) { return $code_verifier; } // Generate a random string. $code_verifier = random_bytes( 64 ); // Encode to Base64 string. $code_verifier = $this->base64_urlencode( $code_verifier ); // Store in database for later use. update_option( 'wp_to_buffer_pro_code_verifier', $code_verifier ); // Return. return $code_verifier; } /** * Base64URL the given code verifier, as PHP has no built in function for this. * * @since 6.0.0 * * @param string $code_verifier Code Verifier. * @return string Code Challenge. */ public function generate_code_challenge( $code_verifier ) { // Hash using S256. $code_challenge = hash( 'sha256', $code_verifier, true ); // Encode to Base64 string. $code_challenge = base64_encode( $code_challenge ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions // Convert Base64 to Base64URL by replacing “+” with “-” and “/” with “_”. $code_challenge = strtr( $code_challenge, '+/', '-_' ); // Remove padding character from the end of line. $code_challenge = rtrim( $code_challenge, '=' ); // Return. return $code_challenge; } /** * Returns the stored code verifier generated by generate_and_store_code_verifier(). * * @since 6.0.0 * * @return bool|string */ public function get_code_verifier() { return get_option( 'wp_to_buffer_pro_code_verifier' ); } /** * Deletes the stored code verifier generated by generate_code_verifier(). * * @since 6.0.0 * * @return bool */ private function delete_code_verifier() { return delete_option( 'wp_to_buffer_pro_code_verifier' ); } /** * Base64URL encode the given string. * * @since 6.0.0 * * @param string $str String to encode. * @return string Encoded string. */ public function base64_urlencode( $str ) { // Encode to Base64 string. $str = base64_encode( $str ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions // Convert Base64 to Base64URL by replacing “+” with “-” and “/” with “_”. $str = strtr( $str, '+/', '-_' ); // Remove padding character from the end of line. $str = rtrim( $str, '=' ); return $str; } /** * Returns the oAuth 2 URL used to begin the oAuth process * * @since 3.3.3 * * @param string $account_id Existing Account ID (if included, is passed in the `state` parameter and ultimately back to the WordPress site to replace an account later on). * @return string oAuth URL */ public function get_oauth_url( $account_id = '' ) { // Generate and store code verifier and challenge. $code_verifier = $this->generate_and_store_code_verifier(); $code_challenge = $this->generate_code_challenge( $code_verifier ); // Generate return URL, including a nonce to protect against OAuth callback CSRF. $return_url = add_query_arg( '_wpnonce', wp_create_nonce( $this->base->plugin->filter_name . '_oauth' ), admin_url( 'admin.php?page=' . $this->base->plugin->name . '-settings' ) ); if ( ! empty( $account_id ) ) { $return_url = add_query_arg( array( 'account_id' => $account_id, ), $return_url ); } // Build args. $args = array( 'client_id' => $this->client_id, 'redirect_uri' => $this->redirect_uri, 'response_type' => 'code', 'scope' => 'posts:write posts:read ideas:read ideas:write account:read account:write offline_access', 'state' => rawurlencode( $return_url ), 'code_challenge' => $code_challenge, 'code_challenge_method' => 'S256', 'prompt' => 'consent', ); // Return OAuth URL. return add_query_arg( $args, $this->oauth_authorize_url . 'auth' ); } /** * Returns the Buffer URL where the user can register for a Buffer account * * @since 4.6.4 * * @return string URL */ public function get_registration_url() { return 'https://join.buffer.com/wpzinc'; } /** * Returns the Buffer URL where the user can connect their social media accounts * to Buffer * * @since 3.8.4 * * @return string URL */ public function get_connect_profiles_url() { return 'https://publish.buffer.com/settings/channels'; } /** * Returns the Buffer URL where the user can change the timezone for the * given profile ID. * * @since 3.8.1 * * @param string $profile_id Profile ID. * @return string Timezone Settings URL */ public function get_timezone_settings_url( $profile_id ) { return 'https://publish.buffer.com/profile/' . $profile_id . '/tab/settings/postingSchedule'; } /** * Sets this class' access and refresh tokens * * @since 1.0.0 * * @param string $access_token Access Token. * @param string $refresh_token Refresh Token. * @param bool|int $token_expires Token Expiry. */ public function set_tokens( $access_token = '', $refresh_token = '', $token_expires = false ) { $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: set_tokens(): Started.' ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: set_tokens(): access_token = ' . $access_token ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: set_tokens(): refresh_token = ' . $refresh_token ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: set_tokens(): token_expires = ' . $token_expires ); $this->access_token = $access_token; $this->refresh_token = $refresh_token; $this->token_expires = $token_expires; } /** * Exchanges the given code for an access token, refresh token and other data. * * @since 6.0.0 * * @param string $authorization_code Authorization Code, returned from get_oauth_url() flow. * @return \WP_Error|array */ public function get_access_token( $authorization_code ) { $result = $this->oauth_request( $this->oauth_authorize_url . 'token', array( 'client_id' => $this->client_id, 'grant_type' => 'authorization_code', 'code' => $authorization_code, 'redirect_uri' => $this->redirect_uri, 'code_verifier' => $this->get_code_verifier(), ) ); // Delete code verifier, as it's no longer needed. // If the access token request fails, the user // will begin the process again, which generates a // new code verifier. $this->delete_code_verifier(); // If an error occured, return it now. if ( is_wp_error( $result ) ) { return $result; } // Update the access and refresh tokens in this class. $this->set_tokens( $result['access_token'], $result['refresh_token'], strtotime( '+' . $result['expires_in'] . ' seconds' ) ); // Return data. return array( 'access_token' => $this->access_token, 'refresh_token' => $this->refresh_token, 'token_expires' => $this->token_expires, ); } /** * Fetches a new access token using the supplied refresh token. * * @since 2.0.0 */ public function refresh_token() { $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): Started.' ); // Bail if we don't have a refresh token. if ( empty( $this->refresh_token ) ) { $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): Error: No refresh token available; cannot refresh.' ); return new \WP_Error( 'missing_refresh_token', __( 'No refresh token exists', 'wp-to-buffer' ) ); } $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): access_token = ' . $this->access_token ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): refresh_token = ' . $this->refresh_token ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): token_expires = ' . $this->token_expires ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): token_expires = ' . ( $this->token_expires && time() > (int) $this->token_expires ? 'expired ' . ( time() - (int) $this->token_expires ) . 's ago' : 'expires in ' . ( (int) $this->token_expires - time() ) . 's' ) ); // Bail if the access token hasn't yet expired. if ( strtotime( '+15 minutes' ) < $this->token_expires ) { $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): Skipped: Access token not yet within the 15 minute refresh window.' ); return false; } $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): Requesting new access token.' ); // Send request. $result = $this->oauth_request( $this->oauth_authorize_url . 'token', array( 'client_id' => $this->client_id, 'grant_type' => 'refresh_token', 'refresh_token' => $this->refresh_token, ) ); // If an error occured, log and return it now. if ( is_wp_error( $result ) ) { $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: refresh_token(): Error: [%s] %s', $result->get_error_code(), $result->get_error_message() ) ); /** * Perform any actions when refreshing an expired access token fails. * * @since 6.0.0 * * @param \WP_Error $result Error from API. * @param string $client_id OAuth Client ID. * @param string $access_token Access Token. * @param string $refresh_token Refresh Token. */ do_action( $this->base->plugin->filter_name . '_api_refresh_token_error', $result, $this->client_id, $this->access_token, $this->refresh_token ); return $result; } // Build result data. $result = array( 'access_token' => $result['access_token'], 'refresh_token' => $result['refresh_token'], 'token_expires' => strtotime( '+' . $result['expires_in'] . ' seconds' ), ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): Success' ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): New access_token = ' . $result['access_token'] ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): New refresh_token = ' . $result['refresh_token'] ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): New token_expires = ' . $result['token_expires'] ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): New token_expires = ' . ( $result['token_expires'] && time() > (int) $result['token_expires'] ? 'expired ' . ( time() - (int) $result['token_expires'] ) . 's ago' : 'expires in ' . ( (int) $result['token_expires'] - time() ) . 's' ) ); /** * Perform any actions with the new access token, such as saving it. * * @since 6.0.0 * * @param array $result New Access Token, Refresh Token and Expiry timestamp. * @param string $client_id OAuth Client ID. * @param string $previous_access_token Existing Access Token. * @param string $previous_refresh_token Existing Refresh Token. */ do_action( $this->base->plugin->filter_name . '_api_refresh_token', $result, $this->client_id, $this->access_token, $this->refresh_token ); // Update the access and refresh tokens in this class. $this->set_tokens( $result['access_token'], $result['refresh_token'], $result['token_expires'] ); // Return new access token, refresh token and expiry timestamp. return $result; } /** * Returns the organizations the current account is a member of. * * @since 6.0.1 * * @param bool $force Force API call (false = use stored option). * * @return \WP_Error|array */ public function organizations( $force = false ) { // Return stored organizations from the non-autoloaded option, unless // forcing a refresh from the API. $option_name = $this->base->plugin->name . '-organizations'; $organizations = get_option( $option_name ); if ( $force || ! is_array( $organizations ) ) { // Build GraphQL query. $query = ' query { account { organizations { id name ownerEmail limits { channels scheduledPosts } } } }'; // Query API. $result = $this->graphql_query( $query ); // Bail if an error occurred. if ( is_wp_error( $result ) ) { return $result; } // Build the organizations array. Reset to an empty array first, as // get_option() returns false when the option does not exist. $organizations = array(); foreach ( $result['data']['account']['organizations'] as $organization ) { $organizations[ $organization['id'] ] = array( 'id' => $organization['id'], 'name' => $organization['name'], 'email' => $organization['ownerEmail'], 'channel_limit' => $organization['limits']['channels'], 'plan' => $organization['limits']['scheduledPosts'] === 10 ? 'free' : 'paid', ); } // Store organizations in a non-autoloaded option so they persist // across object cache eviction, and don't load on every WP request. update_option( $option_name, $organizations, false ); } return $organizations; } /** * Returns the account within the organization with the given ID. * * @since 6.0.0 * * @param string $account_id Account ID. * @return \WP_Error|array */ public function account( $account_id = '' ) { $organizations = $this->organizations( false ); // Bail if an error occurred. if ( is_wp_error( $organizations ) ) { return $organizations; } // Bail if the organization is not found. if ( ! array_key_exists( $account_id, $organizations ) ) { return new \WP_Error( 'organization_not_found', __( 'Organization not found.', 'wp-to-buffer' ) ); } return $organizations[ $account_id ]; } /** * Returns a list of Social Media Profiles attached to the Buffer Account. * * Profiles are cached in a non-autoloaded option. Persistence survives * object cache eviction. Callers force a refresh via the Refresh Profiles * button in Settings > Authentication. * * @since 3.0.0 * * @param bool $force Force API call (false = use stored option). * @param string $account_id Account ID. * @return \WP_Error|array */ public function profiles( $force = false, $account_id = 'default' ) { // Return stored profiles if available and not forcing a refresh. $option_name = $this->base->plugin->name . '-profiles-' . $account_id; $profiles = get_option( $option_name ); if ( ! $force && is_array( $profiles ) ) { $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: profiles(): account=%s: returning %d cached profile(s).', $account_id, count( $profiles ) ) ); return $profiles; } $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: profiles(): account=%s: fetching profiles from Buffer (force=%s).', $account_id, $force ? 'yes' : 'no' ) ); // Build GraphQL query. $query = ' query GetChannels($organizationId: OrganizationId!) { channels(input: { organizationId: $organizationId }) { id descriptor name service serviceId timezone metadata { ... on PinterestMetadata { boards { id serviceId name } } } } }'; // Get profiles. $results = $this->graphql_query( $query, array( 'organizationId' => $account_id, ) ); // Check for errors. if ( is_wp_error( $results ) ) { $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: profiles(): account=%s: FAILED to fetch profiles: [%s] %s', $account_id, $results->get_error_code(), $results->get_error_message() ) ); return $results; } // Build profiles array from results. $profiles = array(); foreach ( $results['data']['channels'] as $channel ) { $profiles[ $channel['id'] ] = array( 'id' => $channel['id'], // Buffer ID. Buffer uses this for the ID when creating a post. 'formatted_service' => $channel['descriptor'], 'formatted_username' => $channel['name'], 'service' => $channel['service'], 'timezone' => $channel['timezone'], 'can_be_subprofile' => false, // For pinterest, the profile is the account, not the board. ); // Pinterest: Add Boards as Subprofiles. switch ( $channel['service'] ) { case 'pinterest': $profiles[ $channel['id'] ]['subprofiles'] = array(); foreach ( $channel['metadata']['boards'] as $board ) { $profiles[ $channel['id'] ]['subprofiles'][ $board['serviceId'] ] = array( 'id' => $board['serviceId'], // Social Network (Pinterest) ID. Buffer uses this for the ID when creating a Pin. Yes, it's different from ['id'] above. 'name' => $board['name'], 'service' => $channel['service'], ); } break; } } // Store profiles in a non-autoloaded option so they persist across // object cache eviction, and don't load on every WP request. update_option( $option_name, $profiles, false ); return $profiles; } /** * Returns a single post by its ID. * * @since 6.0.0 * * @param string $post_id Post ID. * @return \WP_Error|array */ public function get_post( $post_id ) { $query = ' query GetPost($postId: PostId!) { post(input: { id: $postId }) { id status text metadata { ... on FacebookPostMetadata { annotations { type content indices text url } } } } }'; $result = $this->graphql_query( $query, array( 'postId' => $post_id, ) ); if ( is_wp_error( $result ) ) { return $result; } return $result['data']['post']; } /** * Creates an update (status) * * @since 3.0.0 * * @param array $params Params. * @param string $service Service. * @return \WP_Error|array */ public function updates_create( $params, $service ) { // Build GraphQL variables. // assets defaults to an empty array; Buffer's schema requires a // non-null list even for text-only posts, so we always send this key. $variables = array( 'channelId' => $params['profile_ids'][0], 'text' => $params['text'], 'schedulingType' => 'automatic', 'mode' => 'addToQueue', 'source' => 'wp-buffer', 'assets' => array(), ); $assets = array(); // Scheduling. switch ( $params['schedule_type'] ) { case 'queue_end': $variables['mode'] = 'addToQueue'; break; case 'queue_start': $variables['mode'] = 'shareNext'; break; case 'immediate': $variables['mode'] = 'shareNow'; break; default: // If no scheduled_at is set, add to end of queue. if ( ! array_key_exists( 'scheduled_at', $params ) ) { $variables['mode'] = 'addToQueue'; break; } if ( empty( $params['scheduled_at'] ) ) { $variables['mode'] = 'addToQueue'; break; } $variables['mode'] = 'customScheduled'; $variables['dueAt'] = gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $params['scheduled_at'] ) ); break; } // Draft. if ( array_key_exists( 'is_draft', $params ) ) { $variables['saveToDraft'] = (bool) $params['is_draft']; } // Metadata. $metadata = array(); switch ( $service ) { case 'instagram': $metadata = array( 'type' => in_array( $params['post_type'], array( 'story', 'video_story' ), true ) ? 'story' : 'post', 'shouldShareToFeed' => true, ); // First Comment. if ( ! empty( $params['first_comment'] ) ) { $metadata['firstComment'] = $params['first_comment']; } // Shop Grid Link. if ( ! empty( $params['url'] ) ) { $metadata['link'] = $params['url']; } break; case 'facebook': $metadata = array( 'type' => 'post', ); // First Comment. if ( ! empty( $params['first_comment'] ) ) { $metadata['firstComment'] = $params['first_comment']; } // OpenGraph / Link Attachment. if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) { $metadata['linkAttachment'] = array( 'url' => $params['url'], ); } // Annotations. if ( array_key_exists( 'annotations', $params ) ) { $metadata['annotations'] = $params['annotations']; } break; case 'linkedin': // First Comment. if ( ! empty( $params['first_comment'] ) ) { $metadata['firstComment'] = $params['first_comment']; } // OpenGraph / Link Attachment. if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) { $metadata['linkAttachment'] = array( 'url' => $params['url'], ); } // Annotations. if ( array_key_exists( 'annotations', $params ) ) { $metadata['annotations'] = $params['annotations']; } break; case 'twitter': // First Comment. if ( ! empty( $params['first_comment'] ) ) { $metadata['thread'] = array( array( 'text' => $params['first_comment'], ), ); } // OpenGraph / Link Attachment. if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) { $variables['text'] .= ' ' . $params['url']; } break; case 'pinterest': $metadata = array( 'title' => $params[ $service ]['title'], 'url' => $params['url'], 'boardServiceId' => $params[ $service ]['board'], ); break; case 'googlebusiness': // Post Type. $metadata = array( 'type' => $params[ $service ]['post_type'], ); // Depending on the Post Type, add the appropriate metadata. switch ( $metadata['type'] ) { case 'whats_new': $metadata['detailsWhatsNew'] = array( 'button' => ! empty( $params[ $service ]['cta'] ) ? $params[ $service ]['cta'] : 'none', 'link' => $params['url'], ); break; case 'event': $metadata['detailsEvent'] = array( 'title' => $params[ $service ]['title'], 'startDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['start_date'] ), 'endDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['end_date'] ), 'isFullDayEvent' => false, 'button' => ! empty( $params[ $service ]['cta'] ) ? $params[ $service ]['cta'] : 'none', 'link' => $params['url'], ); break; case 'offer': $metadata['detailsOffer'] = array( 'title' => $params[ $service ]['title'], 'startDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['start_date'] ), 'endDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['end_date'] ), 'code' => $params[ $service ]['code'], 'link' => $params['url'], 'terms' => $params[ $service ]['terms'], ); break; } break; case 'threads': // OpenGraph / Link Attachment. if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) { $metadata['linkAttachment'] = array( 'url' => $params['url'], ); } // First Comment. if ( ! empty( $params['first_comment'] ) ) { $metadata['thread'] = array( array( 'text' => $params['first_comment'], ), ); } break; case 'bluesky': // OpenGraph / Link Attachment. if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) { $metadata['linkAttachment'] = array( 'url' => $params['url'], ); } // First Comment. if ( ! empty( $params['first_comment'] ) ) { $metadata['thread'] = array( array( 'text' => $params['first_comment'], ), ); } break; case 'mastodon': // First Comment. if ( ! empty( $params['first_comment'] ) ) { $metadata['thread'] = array( array( 'text' => $params['first_comment'], ), ); } // OpenGraph / Link Attachment. if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) { $variables['text'] .= ' ' . $params['url']; } break; } // Add metadata. if ( ! empty( $metadata ) ) { $variables['metadata'] = array( ( $service === 'googlebusiness' ? 'google' : $service ) => $metadata, ); } // Assets / Images. switch ( $params['post_type'] ) { case 'image': case 'story': case 'pin': case 'googlebusiness': // Bail if no images are defined. if ( ! array_key_exists( 'media_urls', $params ) ) { break; } // Build assets array. $assets = array(); foreach ( $params['media_urls'] as $media ) { // Skip anything that isn't an image, so we never send null asset data. if ( ! is_array( $media ) || empty( $media['image'] ) ) { continue; } $assets[] = array( 'image' => array( 'url' => $media['image'], 'thumbnailUrl' => $media['thumbnail'], 'metadata' => array( 'altText' => $media['alt_text'], ), ), ); } break; case 'video': case 'video_story': // Bail if no video URL is defined. if ( empty( $params['video_url'] ) ) { break; } // Buffer fetches the video from a public URL, so we only send the URL. $assets = array( array( 'video' => array( 'url' => $params['video_url'], ), ), ); break; } // Include assets. Always overwrites the default empty array // initialised above, if the service branch built any. if ( ! empty( $assets ) ) { $variables['assets'] = $assets; } // Build GraphQL query. $query = ' mutation CreatePost( $channelId: ChannelId! $text: String $schedulingType: SchedulingType! $mode: ShareMode! $source: String $dueAt: DateTime $saveToDraft: Boolean $assets: [AssetInput!]! $metadata: PostInputMetaData ) { createPost(input: { channelId: $channelId text: $text schedulingType: $schedulingType mode: $mode source: $source dueAt: $dueAt saveToDraft: $saveToDraft assets: $assets metadata: $metadata }) { ... on PostActionSuccess { post { id text status dueAt } } ... on MutationError { message } } }'; // Send update. $result = $this->graphql_query( $query, $variables ); // Bail if the result is an error. if ( is_wp_error( $result ) ) { return $result; } // Return array of just the data we need to send to the Plugin. return array( 'profile_id' => $params['profile_ids'][0], // API doesn't return the Profile ID. 'message' => $result['data']['createPost']['post']['status'], 'status_text' => $result['data']['createPost']['post']['text'], // Both must be UTC timestamps. 'status_created_at' => strtotime( 'now' ), // due_at won't exist if is_draft = 'true' when the update was created. 'due_at' => ( isset( $result['data']['createPost']['post']['dueAt'] ) ? strtotime( $result['data']['createPost']['post']['dueAt'] ) : '0000-00-00 00:00:00' ), ); } /** * Main function for fetching access tokens and refreshing existing tokens. * * All requests are sent using POST, as the Buffer API uses GraphQL: * https://developers.buffer.com/guides/rest-migration.html * * @since 3.0.0 * * @param string $url URL. * @param array $params Parameters (optional). * @return \WP_Error|array */ private function oauth_request( $url, $params = array() ) { // Send request. $result = wp_remote_post( $url, array( 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded', ), 'body' => $this->get_body( $params, 'application/x-www-form-urlencoded' ), 'timeout' => $this->get_timeout(), 'user-agent' => $this->get_user_agent(), ) ); // If an error occured, return it now. if ( is_wp_error( $result ) ) { return $result; } // Parse response and return. return $this->parse_response( $result ); } /** * Main function which handles sending requests to Buffer API's * GraphQL endpoints. * * All requests are sent using POST, as the Buffer API uses GraphQL: * https://developers.buffer.com/guides/rest-migration.html * * @since 6.0.0 * * @param string $query GraphQL Query. * @param array $variables GraphQL Variables. * @return \WP_Error|array */ private function graphql_query( $query, $variables = array() ) { $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): Started.' ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): query = ' . $query ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): variables = ' . print_r( $variables, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): access_token = ' . $this->access_token ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): refresh_token = ' . $this->refresh_token ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): token_expires = ' . $this->token_expires ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): token_expires = ' . ( $this->token_expires && time() > (int) $this->token_expires ? 'expired ' . ( time() - (int) $this->token_expires ) . 's ago' : 'expires in ' . ( (int) $this->token_expires - time() ) . 's' ) ); // Check required parameters exist. if ( empty( $this->access_token ) ) { $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): Error: No access token was specified' ); return new \WP_Error( 'missing_access_token', __( 'No access token was specified', 'wp-to-buffer' ) ); } // Fetch a new access token and refresh token. $result = $this->refresh_token(); // Bail if something went wrong. if ( is_wp_error( $result ) ) { $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): Error: ' . $result->get_error_message() ); return $result; } $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): access_token = ' . $this->access_token ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): refresh_token = ' . $this->refresh_token ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): token_expires = ' . $this->token_expires ); $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): token_expires = ' . ( $this->token_expires && time() > (int) $this->token_expires ? 'expired ' . ( time() - (int) $this->token_expires ) . 's ago' : 'expires in ' . ( (int) $this->token_expires - time() ) . 's' ) ); // Build body. $body = array( 'query' => $query ); if ( ! empty( $variables ) ) { $body['variables'] = $variables; } // Send request. $result = wp_remote_post( $this->api_endpoint, array( 'headers' => $this->get_request_headers( 'application/json' ), 'body' => $this->get_body( $body, 'application/json' ), 'timeout' => $this->get_timeout(), 'user-agent' => $this->get_user_agent(), ) ); // If an error occured, return it now. if ( is_wp_error( $result ) ) { $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: graphql_query(): Error: [%s] %s', $result->get_error_code(), $result->get_error_message() ) ); return $result; } // Parse the response. $response = $this->parse_response( $result ); // Log any API error, so failures that would otherwise be silent are captured. if ( is_wp_error( $response ) ) { $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: graphql_query(): API returned error: [%s] %s', $response->get_error_code(), $response->get_error_message() ) ); } return $response; } /** * Returns the headers to use in an authenticated GraphQL API request. * * @param string $type Accept and Content-Type Headers. * * @since 6.0.0 * * @return array */ private function get_request_headers( $type = 'application/json' ) { $headers = array( 'Accept' => 'application/json', 'Content-Type' => $type, ); // Add authorization header and return. if ( $this->access_token ) { $headers['Authorization'] = 'Bearer ' . $this->access_token; } return $headers; } /** * Returns the body to use in an API request. * * @param array $params Parameters. * @param string $content_type Content Type. * * @since 6.0.0 * * @return string */ private function get_body( $params = array(), $content_type = 'application/json' ) { return ( $content_type === 'application/x-www-form-urlencoded' ? http_build_query( $params ) : wp_json_encode( $params ) ); } /** * Returns the maximum amount of time to wait for * a response to the request before exiting. * * @since 1.0.0 * * @return int Timeout, in seconds. */ private function get_timeout() { $timeout = 10; /** * Defines the maximum time to allow the API request to run. * * @since 1.0.0 * * @param int $timeout Timeout, in seconds. */ $timeout = apply_filters( $this->base->plugin->filter_name . '_pro_api_get_timeout', $timeout ); return $timeout; } /** * Gets a customized version of the WordPress default user agent. * * @since 6.0.0 * * @return string User Agent */ private function get_user_agent() { return sprintf( '%1$s/%2$s (WordPress/%3$s; PHP/%4$s)', $this->base->plugin->name, $this->base->plugin->version, get_bloginfo( 'version' ), PHP_VERSION ); } /** * Parses the response body, returning a \WP_Error * if the response body contains an error. * * @since 3.9.8 * * @param array|\WP_Error $response HTTP Response. * @return \WP_Error|array */ private function parse_response( $response ) { // Get HTTP code and body. $http_code = wp_remote_retrieve_response_code( $response ); $http_body = wp_remote_retrieve_body( $response ); // Handle HTTP errors. switch ( $http_code ) { case 403: return new \WP_Error( 'buffer_api_error', '403 Forbidden' ); } // Retain-and-retry on server errors: return without touching stored tokens. if ( $http_code >= 500 ) { return new \WP_Error( 'buffer_api_server_error', $http_code . ' server error' ); } // Decode response. $body = json_decode( $http_body, true ); // Bail if the response isn't valid JSON. if ( ! is_array( $body ) ) { return new \WP_Error( 'buffer_api_invalid_response', 'Invalid API response' ); } // If an error is detected, return it. if ( array_key_exists( 'error', $body ) ) { return new \WP_Error( $body['error'], $body['error_description'] ); } // If multiple errors are detected, return them. if ( array_key_exists( 'errors', $body ) ) { // If the access token begins with '2/', it's from the old API. if ( strpos( $this->access_token, '2/' ) === 0 ) { return new \WP_Error( 'buffer_api_error', sprintf( /* translators: %1$s: Plugin Name, %2$s: Plugin Name */ __( '%1$s uses a new API. Please click the `Reconnect` button at %2$s Settings > Authentication to reconnect your account. You won\'t need to do this again.', 'wp-to-buffer' ), $this->base->plugin->displayName, $this->base->plugin->displayName ) ); } return new \WP_Error( $body['errors'][0]['extensions']['code'], $body['errors'][0]['message'] ); } // Check for mutation-level errors inside data. // Mutation responses are nested under data.{operationName}. // Success responses contain specific keys (e.g. 'post', 'idea'). // Error responses contain only 'message'. if ( isset( $body['data'] ) && is_array( $body['data'] ) ) { foreach ( $body['data'] as $operation => $result ) { if ( is_array( $result ) && isset( $result['message'] ) && count( $result ) === 1 ) { return new \WP_Error( 'buffer_api_error', $this->provide_verbose_error_message( $result['message'] ) ); } } } return $body; } /** * Provides a more actionable error message, based on the error message supplied * from the Buffer API MutationError. * * @since 6.0.0 * * @param string $message Error Message. * @return string */ private function provide_verbose_error_message( $message ) { switch ( $message ) { case 'Invalid post: First comment requires a paid plan. Please upgrade to use this feature.': return 'Your buffer.com plan does not support first comments. Please upgrade to a paid plan on buffer.com to use this feature.'; default: return $message; } } }