__( 'Display your latest Instagram photos.', 'jetpack' ), 'show_instance_in_rest' => true, ) ); if ( is_active_widget( false, false, self::ID_BASE ) || is_active_widget( false, false, 'monster' ) || is_customize_preview() ) { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_css' ) ); } add_action( 'wp_ajax_wpcom_instagram_widget_update_widget_token_id', array( $this, 'ajax_update_widget_token_id' ) ); $this->valid_options = array( /** * Allow changing the maximum number of columns available for the Instagram widget. * * @module widgets * * @since 8.8.0 * * @param int $max_columns maximum number of columns. */ 'max_columns' => apply_filters( 'wpcom_instagram_widget_max_columns', 3 ), 'max_count' => 20, ); $this->defaults = array( 'token_id' => null, 'title' => __( 'Instagram', 'jetpack' ), 'columns' => 2, 'count' => 6, ); add_filter( 'widget_types_to_hide_from_legacy_widget_block', array( $this, 'hide_widget_in_block_editor' ) ); } /** * Remove Instagram widget from Legacy Widget block. * * @param array $widget_types Widget type data. * This only applies to new blocks being added. */ public function hide_widget_in_block_editor( $widget_types ) { $widget_types[] = self::ID_BASE; return $widget_types; } /** * Enqueues the widget's frontend CSS but only if the widget is currently in use. */ public function enqueue_css() { wp_enqueue_style( self::ID_BASE, plugins_url( 'instagram/instagram.css', __FILE__ ), array(), JETPACK__VERSION ); } /** * Updates the widget's option in the database to have the passed Keyring token ID. * This is so the user doesn't have to click the "Save" button when we want to set it. * * @param int $token_id A Keyring token ID. * @param int $number The widget ID. */ public function update_widget_token_id( $token_id, $number = null ) { $widget_options = $this->get_settings(); if ( empty( $number ) ) { $number = $this->number; } if ( ! isset( $widget_options[ $number ] ) || ! is_array( $widget_options[ $number ] ) ) { $widget_options[ $number ] = $this->defaults; } $widget_options[ $number ]['token_id'] = (int) $token_id; $this->save_settings( $widget_options ); } /** * Updates the widget's option in the database to have the passed Keyring token ID. * * Sends a json success or error response. */ public function ajax_update_widget_token_id() { if ( ! check_ajax_referer( 'instagram-widget-save-token', 'savetoken', false ) ) { wp_send_json_error( array( 'message' => 'bad_nonce' ), 403 ); } if ( ! current_user_can( 'customize' ) ) { wp_send_json_error( array( 'message' => 'not_authorized' ), 403 ); } $token_id = ! empty( $_POST['keyring_id'] ) ? (int) $_POST['keyring_id'] : null; $widget_id = ! empty( $_POST['instagram_widget_id'] ) ? (int) $_POST['instagram_widget_id'] : null; // For Simple sites check if the token is valid. // (For Atomic sites, this check is done via the api: wpcom/v2/instagram/). if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { $token = Keyring::init()->get_token_store()->get_token( array( 'type' => 'access', 'id' => $token_id, ) ); if ( get_current_user_id() !== (int) $token->meta['user_id'] ) { return wp_send_json_error( array( 'message' => 'not_authorized' ), 403 ); } } $this->update_widget_token_id( $token_id, $widget_id ); $this->update_widget_token_legacy_status( false ); return wp_send_json_success( null, 200 ); } /** * Updates the widget's option in the database to show if it is for legacy API or not. * * @param bool $is_legacy_token A flag to indicate if a token is for the legacy Instagram API. */ public function update_widget_token_legacy_status( $is_legacy_token ) { $widget_options = $this->get_settings(); if ( ! is_array( $widget_options[ $this->number ] ) ) { $widget_options[ $this->number ] = $this->defaults; } $widget_options[ $this->number ]['is_legacy_token'] = $is_legacy_token; $this->save_settings( $widget_options ); return $is_legacy_token; } /** * Get's the status of the token from the API * * @param int $token_id A Keyring token ID. * @return array The status of the token's connection. */ private function get_token_status( $token_id ) { if ( empty( $token_id ) ) { return array( 'valid' => false ); } if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { $token = Keyring::init()->get_token_store()->get_token( array( 'type' => 'access', 'id' => $token_id, ) ); return array( 'valid' => ! empty( $token ), 'legacy' => $token && 'instagram' === $token->name, ); } $site = Jetpack_Options::get_option( 'id' ); $path = sprintf( '/sites/%s/instagram/%s/check-token', $site, $token_id ); $result = Client::wpcom_json_api_request_as_blog( $path, 2, array( 'headers' => array( 'content-type' => 'application/json' ) ), null, 'wpcom' ); $response_code = wp_remote_retrieve_response_code( $result ); if ( 200 !== $response_code ) { return array( // We assume the token is valid if the response_code is anything but the invalid // token codes we send back. This is to make sure it's not reset, if the API is down // or something. 'valid' => ! ( 403 === $response_code || 401 === $response_code ), 'legacy' => 'ERROR', ); } $status = json_decode( $result['body'], true ); return $status; } /** * Validates the widget instance's token ID and then uses it to fetch images from Instagram. * It then caches the result which it will use on subsequent pageviews. * Keyring is not loaded nor is a remote request is not made in the event of a cache hit. * * @param array $instance A widget $instance, as passed to a widget's widget() method. * @return WP_Error|array A WP_Error on error, an array of images on success. */ public function get_data( $instance ) { if ( empty( $instance['token_id'] ) ) { return new WP_Error( 'empty_token', esc_html__( 'The token id was empty', 'jetpack' ), 403 ); } $transient_key = implode( '|', array( 'jetpack_instagram_widget', $instance['token_id'], $instance['count'] ) ); $cached_images = get_transient( $transient_key ); if ( $cached_images ) { return $cached_images; } $site = Jetpack_Options::get_option( 'id' ); $path = sprintf( '/sites/%s/instagram/%s?count=%s', $site, $instance['token_id'], $instance['count'] ); $result = Client::wpcom_json_api_request_as_blog( $path, 2, array( 'headers' => array( 'content-type' => 'application/json' ) ), null, 'wpcom' ); $response_code = wp_remote_retrieve_response_code( $result ); if ( 200 !== $response_code ) { return new WP_Error( 'invalid_response', esc_html__( 'The response was invalid', 'jetpack' ), $response_code ); } $data = json_decode( wp_remote_retrieve_body( $result ), true ); if ( ! isset( $data['images'] ) || ! is_array( $data['images'] ) ) { return new WP_Error( 'missing_images', esc_html__( 'The images were missing', 'jetpack' ), $response_code ); } set_transient( $transient_key, $data, HOUR_IN_SECONDS ); return $data; } /** * Outputs the contents of the widget on the front end. * * If the widget is unconfigured, a configuration message is displayed to users with admin access * and the entire widget is hidden from everyone else to avoid displaying an empty widget. * * @param array $args The sidebar arguments that control the wrapping HTML. * @param array $instance The widget instance (configuration options). */ public function widget( $args, $instance ) { $instance = wp_parse_args( $instance, $this->defaults ); $data = $this->get_data( $instance ); if ( is_wp_error( $data ) ) { return; } $images = $data['images']; $status = $this->get_token_status( $instance['token_id'] ); // Don't display anything to non-blog admins if the widgets is unconfigured or API call fails. if ( ( ! $status['valid'] || ! is_array( $images ) ) && ! current_user_can( 'edit_theme_options' ) ) { return; } echo $args['before_widget']; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped // Always show a title on an unconfigured widget. if ( ! $status['valid'] && empty( $instance['title'] ) ) { $instance['title'] = $this->defaults['title']; } if ( ! empty( $instance['title'] ) ) { echo $args['before_title']; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo esc_html( $instance['title'] ); echo $args['after_title']; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } if ( $status['valid'] && current_user_can( 'edit_theme_options' ) && $status['legacy'] ) { echo '

' . sprintf( wp_kses( /* translators: %s is a link to reconnect the Instagram widget */ __( 'In order to continue using this widget you must reconnect to Instagram.', 'jetpack' ), array( 'a' => array( 'href' => array(), ), ) ), esc_url( add_query_arg( 'instagram_widget_id', $this->number, admin_url( 'widgets.php' ) ) ) ) . '

'; } if ( ! $status['valid'] ) { echo '

' . sprintf( wp_kses( /* translators: %s is a link to configure the Instagram widget */ __( 'In order to use this Instagram widget, you must configure it first.', 'jetpack' ), array( 'a' => array( 'href' => array(), ), ) ), esc_url( add_query_arg( 'instagram_widget_id', $this->number, admin_url( 'widgets.php' ) ) ) ) . '

'; } else { if ( ! is_array( $images ) ) { echo '

' . esc_html__( 'There was an error retrieving images from Instagram. An attempt will be remade in a few minutes.', 'jetpack' ) . '

'; } elseif ( ! $images ) { echo '

' . esc_html__( 'No Instagram images were found.', 'jetpack' ) . '

'; } else { echo '
' . "\n"; foreach ( $images as $image ) { /** * Filter how Instagram image links open in the Instagram widget. * * @module widgets * * @since 8.8.0 * * @param string $target Target attribute. */ $image_target = apply_filters( 'wpcom_instagram_widget_target', '_self' ); echo '
' . esc_attr( $image['title'] ) . '
' . "\n"; } echo "
\n"; } } echo $args['after_widget']; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped /** This action is already documented in modules/widgets/gravatar-profile.php */ do_action( 'jetpack_stats_extra', 'widget_view', 'instagram' ); } /** * Get the URL to connect the widget to Instagram * * @return string the conneciton URL. */ private function get_connect_url() { $connect_url = ''; if ( defined( 'IS_WPCOM' ) && IS_WPCOM && function_exists( 'wpcom_keyring_get_connect_URL' ) ) { $connect_url = wpcom_keyring_get_connect_URL( 'instagram-basic-display', 'instagram-widget' ); } else { $jetpack_blog_id = Jetpack_Options::get_option( 'id' ); $response = Client::wpcom_json_api_request_as_user( sprintf( '/sites/%d/external-services', $jetpack_blog_id ) ); if ( is_wp_error( $response ) ) { return $response; } $body = json_decode( $response['body'] ); $connect_url = new WP_Error( 'connect_url_not_found', 'Connect URL not found' ); if ( ! empty( $body->services->{'instagram-basic-display'}->connect_URL ) ) { $connect_url = $body->services->{'instagram-basic-display'}->connect_URL; } } return $connect_url; } /** * Is this request trying to remove the widgets stored id? * * @param array $status The status of the token's connection. * @return bool if this request trying to remove the widgets stored id. */ public function removing_widgets_stored_id( $status ) { return $status['valid'] && isset( $_GET['instagram_widget_id'] ) && (int) $_GET['instagram_widget_id'] === (int) $this->number && ! empty( $_GET['instagram_widget'] ) && 'remove_token' === $_GET['instagram_widget']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended } /** * Outputs the widget configuration form for the widget administration page. * Allows the user to add new Instagram Keyring tokens and more. * * @param array $instance The widget instance (configuration options). */ public function form( $instance ) { $instance = wp_parse_args( $instance, $this->defaults ); if ( ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) && ! ( new Manager() )->is_user_connected() ) { echo '

'; printf( // translators: %1$1 and %2$s are the opening and closing a tags creating a link to the Jetpack dashboard. esc_html__( 'In order to use this widget you need to %1$scomplete your Jetpack connection%2$s by authorizing your user.', 'jetpack' ), '', '' ); echo '

'; return; } // If coming back to the widgets page from an action, expand this widget. if ( isset( $_GET['instagram_widget_id'] ) && (int) $_GET['instagram_widget_id'] === (int) $this->number ) { echo ''; } $status = $this->get_token_status( $instance['token_id'] ); // If removing the widget's stored token ID. if ( $this->removing_widgets_stored_id( $status ) ) { if ( empty( $_GET['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_GET['nonce'] ), 'instagram-widget-remove-token-' . $this->number . '-' . $instance['token_id'] ) ) { wp_die( esc_html__( 'Missing or invalid security nonce.', 'jetpack' ) ); } $instance['token_id'] = $this->defaults['token_id']; $this->update_widget_token_id( $instance['token_id'] ); $this->update_widget_token_legacy_status( false ); } elseif ( $status['valid'] && ( ! isset( $instance['is_legacy_token'] ) || 'ERROR' === $instance['is_legacy_token'] ) ) { // If a token ID is stored, check if we know if it is a legacy API token or not. $instance['is_legacy_token'] = $this->update_widget_token_legacy_status( $status['legacy'] ); } elseif ( ! $status['valid'] ) { // If the token isn't valid reset it. $instance['token_id'] = $this->defaults['token_id']; $this->update_widget_token_id( $instance['token_id'] ); } // No connection, or a legacy API token? Display a connection link. $is_legacy_token = ( isset( $instance['is_legacy_token'] ) && true === $instance['is_legacy_token'] ); if ( $is_legacy_token ) { echo '

' . esc_html__( 'In order to continue using this widget you must reconnect to Instagram.', 'jetpack' ) . '

'; } if ( is_customize_preview() && ! $instance['token_id'] ) { echo '

'; echo wp_kses( __( 'Important: You must first click Publish to activate this widget before connecting your account. After saving the widget, click the button below to connect your Instagram account.', 'jetpack' ), array( 'strong' => array(), 'em' => array(), ) ); echo '

'; } if ( ! $instance['token_id'] || $is_legacy_token ) { ?> get_connect_url(); if ( is_wp_error( $connect_url ) ) { echo '

' . esc_html__( 'Instagram is currently experiencing connectivity issues, please try again later to connect.', 'jetpack' ) . '

'; return; } ?>

' . sprintf( wp_kses( /* translators: %s is a link to log in to Instagram */ __( 'Having trouble? Try logging into the correct account on Instagram.com first.', 'jetpack' ), array( 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array(), ), ) ), 'https://instagram.com/accounts/login/' ) . '

'; return; } // Connected account. $page = ( is_customize_preview() ) ? 'customize.php' : 'widgets.php'; $query_args = array( 'instagram_widget_id' => $this->number, 'instagram_widget' => 'remove_token', 'nonce' => wp_create_nonce( 'instagram-widget-remove-token-' . $this->number . '-' . $instance['token_id'] ), ); if ( is_customize_preview() ) { $query_args['autofocus[panel]'] = 'widgets'; } $remove_token_id_url = add_query_arg( $query_args, admin_url( $page ) ); $data = $this->get_data( $instance ); // TODO: Revisit the error handling. I think we should be using WP_Error here and // Jetpack::Client is the legacy check. if ( is_wp_error( $data ) || 'ERROR' === $instance['is_legacy_token'] ) { echo '

' . esc_html__( 'Instagram is currently experiencing connectivity issues, please try again later to connect.', 'jetpack' ) . '

'; return; } echo '

'; echo sprintf( wp_kses( /* translators: %1$s is the URL of the connected Instagram account, %2$s is the username of the connected Instagram account, %3$s is the URL to disconnect the account. */ __( 'Connected Instagram Account
%2$s | remove', 'jetpack' ), array( 'a' => array( 'href' => array(), 'rel' => array(), 'target' => array(), ), 'strong' => array(), 'br' => array(), ) ), esc_url( 'https://instagram.com/' . $data['external_name'] ), esc_html( $data['external_name'] ), esc_url( $remove_token_id_url ) ); echo '

'; // Title. echo '

'; // Number of images to show. echo '

'; // Columns. echo '

'; echo '

' . esc_html__( 'New images may take up to 15 minutes to show up on your site.', 'jetpack' ) . '

'; } /** * Validates and sanitizes the user-supplied widget options. * * @param array $new_instance The user-supplied values. * @param array $old_instance The existing widget options. * @return array A validated and sanitized version of $new_instance. */ public function update( $new_instance, $old_instance ) { $instance = $this->defaults; if ( ! empty( $old_instance['token_id'] ) ) { $instance['token_id'] = $old_instance['token_id']; } if ( isset( $new_instance['title'] ) ) { $instance['title'] = wp_strip_all_tags( $new_instance['title'] ); } if ( isset( $new_instance['columns'] ) ) { $instance['columns'] = max( 1, min( $this->valid_options['max_columns'], (int) $new_instance['columns'] ) ); } if ( isset( $new_instance['count'] ) ) { $instance['count'] = max( 1, min( $this->valid_options['max_count'], (int) $new_instance['count'] ) ); } return $instance; } } add_action( 'widgets_init', function () { if ( Jetpack::is_connection_ready() ) { register_widget( 'Jetpack_Instagram_Widget' ); } } );