diff options
Diffstat (limited to 'plugins/jetpack/_inc/lib')
58 files changed, 22503 insertions, 0 deletions
diff --git a/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-about-page.php b/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-about-page.php new file mode 100644 index 00000000..b9987a0f --- /dev/null +++ b/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-about-page.php @@ -0,0 +1,679 @@ +<?php +/** + * Class for the Jetpack About Page within the wp-admin. + * + * @package Jetpack + */ + +/** + * Disable direct access and execution. + */ +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +require_once 'class.jetpack-admin-page.php'; + +/** + * Builds the landing page and its menu. + */ +class Jetpack_About_Page extends Jetpack_Admin_Page { + + /** + * Show the settings page only when Jetpack is connected or in dev mode. + * + * @var bool If the page should be shown. + */ + protected $dont_show_if_not_active = true; + + /** + * Add a submenu item to the Jetpack admin menu. + * + * @return string + */ + public function get_page_hook() { + // Add the main admin Jetpack menu. + return add_submenu_page( + 'jetpack', + esc_html__( 'About Jetpack', 'jetpack' ), + esc_html__( 'About Jetpack', 'jetpack' ), + 'jetpack_admin_page', + 'jetpack_about', + array( $this, 'render' ) + ); + } + + /** + * Add page action + * + * @param string $hook Hook of current page, unused. + */ + public function add_page_actions( $hook ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + // Place the Jetpack menu item on top and others in the order they appear. + add_filter( 'custom_menu_order', '__return_true' ); + add_filter( 'menu_order', array( $this, 'submenu_order' ) ); + } + + /** + * Enqueues scripts and styles for the admin page. + */ + public function page_admin_scripts() { + wp_enqueue_style( 'plugin-install' ); + wp_enqueue_script( 'plugin-install' ); + // required for plugin modal action button functionality. + wp_enqueue_script( 'updates' ); + // required for modal popup JS and styling. + wp_enqueue_style( 'thickbox' ); + wp_enqueue_script( 'thickbox' ); + } + + /** + * Load styles for static page. + */ + public function additional_styles() { + Jetpack_Admin_Page::load_wrapper_styles(); + } + + /** + * Render the page with a common top and bottom part, and page specific content + */ + public function render() { + Jetpack_Admin_Page::wrap_ui( array( $this, 'page_render' ), array( 'show-nav' => false ) ); + } + + /** + * Change order of menu item so the About page menu item is below Site Stats. + * + * @param array $menu_order List of menu slugs. It's unaffected. This filter is used to reorder the Jetpack submenu items. + * + * @return array + */ + public function submenu_order( $menu_order ) { + global $submenu; + + $stats_key = null; + $about_key = null; + + foreach ( $submenu['jetpack'] as $index => $menu_item ) { + if ( false !== array_search( 'stats', $menu_item, true ) ) { + $stats_key = $index; + } + if ( false !== array_search( 'jetpack_about', $menu_item, true ) ) { + $about_key = $index; + } + } + + if ( $stats_key && $about_key ) { + $temp = $submenu['jetpack'][ $stats_key ]; + $submenu['jetpack'][ $stats_key ] = $submenu['jetpack'][ $about_key ]; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $submenu['jetpack'][ $about_key ] = $temp; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + } + + return $menu_order; + } + + /** + * Render the page content + */ + public function page_render() { + ?> + <div class="jp-lower"> + <div class="jetpack-about__link-back"> + <a href="<?php echo esc_url( admin_url( 'admin.php?page=jetpack' ) ); ?>"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="0" fill="none" width="24" height="24"/><g><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></g></svg> + <?php esc_html_e( 'Back to Jetpack Dashboard', 'jetpack' ); ?> + </a> + </div> + <div class="jetpack-about__main"> + <div class="jetpack-about__logo"> + <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 800 96" style="enable-background:new 0 0 800 96;" xml:space="preserve"> + <g> + <path style="fill: #39c;" d="M292.922,78c-19.777,0-32.598-14.245-32.598-29.078V47.08c0-15.086,12.821-29.08,32.598-29.08 + c19.861,0,32.682,13.994,32.682,29.08v1.843C325.604,63.755,312.783,78,292.922,78z M315.044,47.245 + c0-10.808-7.877-20.447-22.122-20.447s-22.04,9.639-22.04,20.447v1.341c0,10.811,7.795,20.614,22.04,20.614 + s22.122-9.803,22.122-20.614V47.245z"/> + <path d="M69.602,75.821l-7.374-13.826H29.463l-7.124,13.826H11.277l30.167-55.81h8.715l30.671,55.81H69.602z M45.552,30.906 + L33.401,54.369h24.72L45.552,30.906z"/> + <path d="M128.427,78c-20.028,0-29.329-10.894-29.329-25.391V20.012h10.391v32.765c0,10.308,6.788,16.424,19.692,16.424 + c13.242,0,18.687-6.116,18.687-16.424V20.012h10.475v32.598C158.342,66.436,149.46,78,128.427,78z"/> + <path d="M216.667,28.727v47.094h-10.475V28.727h-24.386v-8.715h59.245v8.715H216.667z"/> + <path d="M418.955,75.821V31.659l-2.766,4.861l-23.379,39.301h-5.112L364.569,36.52l-2.765-4.861v44.162h-10.224v-55.81h14.497 + l22.038,38.296L390.713,63l2.599-4.692l21.786-38.296h14.331v55.81H418.955z"/> + <path d="M508.619,75.821l-7.374-13.826H468.48l-7.123,13.826h-11.061l30.167-55.81h8.715l30.669,55.81H508.619z M484.569,30.906 + l-12.151,23.464h24.72L484.569,30.906z"/> + <path d="M562.081,28.727v47.094h-10.474V28.727h-24.386v-8.715h59.245v8.715H562.081z"/> + <path d="M638.924,28.727v47.094H628.45V28.727h-24.386v-8.715h59.245v8.715H638.924z"/> + <path d="M689.118,75.821v-50.53c4.19,0,5.866-2.263,5.866-5.28h4.442v55.81H689.118z"/> + <path d="M781.464,35.765c-5.028-4.609-12.402-8.967-22.374-8.967c-14.916,0-23.296,10.225-23.296,20.867v1.089 + c0,10.558,8.464,20.445,24.05,20.445c9.303,0,17.012-4.441,21.872-8.965L788,66.854C781.883,72.887,771.492,78,759.174,78 + c-21.118,0-33.939-13.743-33.939-28.828v-1.843c0-15.084,13.993-29.329,34.44-29.329c11.816,0,22.541,4.944,28.324,11.146 + L781.464,35.765z"/> + <path d="M299.82,37.417c1.889,1.218,2.418,3.749,1.192,5.648l-9.553,14.797c-1.226,1.901-3.752,2.452-5.637,1.234l0,0 + c-1.886-1.22-2.421-3.745-1.192-5.647l9.553-14.797C295.41,36.753,297.935,36.201,299.82,37.417L299.82,37.417z"/> + </g> + </svg> + </div> + <div class="jetpack-about__content"> + <div class="jetpack-about__images"> + <ul class="jetpack-about__gravatars"> + <?php $this->display_gravatars(); ?> + </ul> + <p class="meet-the-team"> + <a href="https://automattic.com/about/" target="_blank" rel="noopener noreferrer" class="jptracks" data-jptracks-name="jetpack_about_meet_the_team"><?php esc_html_e( 'Meet the Automattic team', 'jetpack' ); ?></a> + </p> + </div> + + <div class="jetpack-about__text"> + <p> + <?php esc_html_e( 'We are the people behind WordPress.com, WooCommerce, Jetpack, Simplenote, Longreads, VaultPress, Akismet, Gravatar, Crowdsignal, Cloudup, and more. We believe in making the web a better place.', 'jetpack' ); ?> + <a href="https://automattic.com/" target="_blank" rel="noopener noreferrer" class="jptracks" data-jptracks-name="jetpack_about_learn_more"> + <?php esc_html_e( 'Learn more about us.', 'jetpack' ); ?> + </a> + </p> + <p> + <?php esc_html_e( 'We’re a distributed company with over 875 Automatticians in more than 67 countries speaking at least 83 different languages. Our common goal is to democratize publishing so that anyone with a story can tell it, regardless of income, gender, politics, language, or where they live in the world.', 'jetpack' ); ?> + </p> + <p> + <?php esc_html_e( 'We believe in Open Source and the vast majority of our work is available under the GPL.', 'jetpack' ); ?> + </p> + <p> + <?php + // Maybe use printf() because we'll want to escape the string but still allow for the link, so we can't use esc_html_e(). + echo wp_kses( + __( 'We strive to live by the <a href="https://automattic.com/creed/" target="_blank" class="jptracks" data-jptracks-name="jetpack_about_creed" rel="noopener noreferrer">Automattic Creed</a>.', 'jetpack' ), + array( + 'a' => array( + 'href' => array(), + 'class' => array(), + 'target' => array(), + 'rel' => array(), + 'data-jptracks-name' => array(), + ), + ) + ); + ?> + </p> + <p> + <a href="https://automattic.com/work-with-us" target="_blank" rel="noopener noreferrer" class="jptracks" data-jptracks-name="jetpack_about_work_with_us"> + <?php esc_html_e( 'Come work with us', 'jetpack' ); ?> + </a> + </p> + </div> + </div> + </div> + + <div class="jetpack-about__colophon"> + <h3><?php esc_html_e( 'Popular WordPress services by Automattic', 'jetpack' ); ?></h3> + <ul class="jetpack-about__services"> + <?php $this->display_plugins(); ?> + </ul> + + <p class="jetpack-about__services-more"> + <?php + echo wp_kses( + __( 'For even more of our WordPress plugins, please <a href="https://profiles.wordpress.org/automattic/#content-plugins" target="_blank" rel="noopener noreferrer" class="jptracks" data-jptracks-name="jetpack_about_wporg_profile">take a look at our WordPress.org profile</a>.', 'jetpack' ), + array( + 'a' => array( + 'href' => array(), + 'target' => array(), + 'rel' => array(), + 'class' => array(), + 'data-jptracks-name' => array(), + ), + ) + ); + ?> + </p> + </div> + </div> + <?php + } + + /** + * Add information cards for a8c plugins. + */ + public function display_plugins() { + $plugins_allowedtags = array( + 'a' => array( + 'href' => array(), + 'title' => array(), + 'target' => array(), + ), + 'abbr' => array( 'title' => array() ), + 'acronym' => array( 'title' => array() ), + 'code' => array(), + 'pre' => array(), + 'em' => array(), + 'strong' => array(), + 'ul' => array(), + 'ol' => array(), + 'li' => array(), + 'p' => array(), + 'br' => array(), + ); + + // slugs for plugins we want to display. + $a8c_plugins = array( + 'woocommerce', + 'wp-super-cache', + 'wp-job-manager', + 'co-authors-plus', + ); + + // need this to access the plugins_api() function. + include_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + + $plugins = array(); + foreach ( $a8c_plugins as $slug ) { + $args = array( + 'slug' => $slug, + 'fields' => array( + 'added' => false, + 'author' => false, + 'author_profile' => false, + 'banners' => false, + 'contributors' => false, + 'donate_link' => false, + 'homepage' => false, + 'reviews' => false, + 'screenshots' => false, + 'support_threads' => false, + 'support_threads_resolved' => false, + 'sections' => false, + 'tags' => false, + 'versions' => false, + + 'compatibility' => true, + 'downloaded' => true, + 'downloadlink' => true, + 'icons' => true, + 'last_updated' => true, + 'num_ratings' => true, + 'rating' => true, + 'requires' => true, + 'requires_php' => true, + 'short_description' => true, + 'tested' => true, + ), + ); + + // should probably add some error checking here too. + $api = plugins_api( 'plugin_information', $args ); + $plugins[] = $api; + } + + foreach ( $plugins as $plugin ) { + if ( is_object( $plugin ) ) { + $plugin = (array) $plugin; + } + + $title = wp_kses( $plugin['name'], $plugins_allowedtags ); + $version = wp_kses( $plugin['version'], $plugins_allowedtags ); + + $name = wp_strip_all_tags( $title . ' ' . $version ); + + // Remove any HTML from the description. + $description = wp_strip_all_tags( $plugin['short_description'] ); + + $wp_version = get_bloginfo( 'version' ); + + $compatible_php = ( empty( $plugin['requires_php'] ) || version_compare( phpversion(), $plugin['requires_php'], '>=' ) ); + $compatible_wp = ( empty( $plugin['requires'] ) || version_compare( $wp_version, $plugin['requires'], '>=' ) ); + + $action_links = array(); + + // install button. + if ( current_user_can( 'install_plugins' ) || current_user_can( 'update_plugins' ) ) { + $status = install_plugin_install_status( $plugin ); + switch ( $status['status'] ) { + case 'install': + if ( $status['url'] ) { + if ( $compatible_php && $compatible_wp ) { + $action_links[] = sprintf( + '<a class="install-now button jptracks" data-slug="%1$s" href="%2$s" aria-label="%3$s" data-name="%4$s" data-jptracks-name="jetpack_about_install_button" data-jptracks-prop="%4$s">%5$s</a>', + esc_attr( $plugin['slug'] ), + esc_url( $status['url'] ), + /* translators: %s: plugin name and version */ + esc_attr( sprintf( __( 'Install %s now', 'jetpack' ), $name ) ), + esc_attr( $name ), + esc_html__( 'Install Now', 'jetpack' ) + ); + } else { + $action_links[] = sprintf( + '<button type="button" class="button button-disabled" disabled="disabled">%s</button>', + _x( 'Cannot Install', 'plugin', 'jetpack' ) + ); + } + } + break; + + case 'update_available': + if ( $status['url'] ) { + $action_links[] = sprintf( + '<a class="update-now button aria-button-if-js jptracks" data-plugin="%1$s" data-slug="%2$s" href="%3$s" aria-label="%4$s" data-name="%5$s" data-jptracks-name="jetpack_about_update_button" data-jptracks-prop="%5$s">%6$s</a>', + esc_attr( $status['file'] ), + esc_attr( $plugin['slug'] ), + esc_url( $status['url'] ), + /* translators: %s: plugin name and version */ + esc_attr( sprintf( __( 'Update %s now', 'jetpack' ), $name ) ), + esc_attr( $name ), + __( 'Update Now', 'jetpack' ) + ); + } + break; + + case 'latest_installed': + case 'newer_installed': + if ( is_plugin_active( $status['file'] ) ) { + $action_links[] = sprintf( + '<button type="button" class="button button-disabled" disabled="disabled">%s</button>', + _x( 'Active', 'plugin', 'jetpack' ) + ); + } elseif ( current_user_can( 'activate_plugin', $status['file'] ) ) { + $button_text = __( 'Activate', 'jetpack' ); + /* translators: %s: plugin name */ + $button_label = _x( 'Activate %s', 'plugin', 'jetpack' ); + $activate_url = add_query_arg( + array( + '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $status['file'] ), + 'action' => 'activate', + 'plugin' => $status['file'], + ), + network_admin_url( 'plugins.php' ) + ); + + if ( is_network_admin() ) { + $button_text = __( 'Network Activate', 'jetpack' ); + /* translators: %s: plugin name */ + $button_label = _x( 'Network Activate %s', 'plugin', 'jetpack' ); + $activate_url = add_query_arg( array( 'networkwide' => 1 ), $activate_url ); + } + + $action_links[] = sprintf( + '<a href="%1$s" class="button activate-now" aria-label="%2$s" data-jptracks-name="jetpack_about_activate_button" data-jptracks-prop="%3$s">%4$s</a>', + esc_url( $activate_url ), + esc_attr( sprintf( $button_label, $plugin['name'] ) ), + esc_attr( $plugin['name'] ), + $button_text + ); + } else { + $action_links[] = sprintf( + '<button type="button" class="button button-disabled" disabled="disabled">%s</button>', + _x( 'Installed', 'plugin', 'jetpack' ) + ); + } + break; + } + } + + $plugin_install = "plugin-install.php?tab=plugin-information&plugin={$plugin['slug']}&TB_iframe=true&width=600&height=550"; + $details_link = is_multisite() + ? network_admin_url( $plugin_install ) + : admin_url( $plugin_install ); + + if ( ! empty( $plugin['icons']['svg'] ) ) { + $plugin_icon_url = $plugin['icons']['svg']; + } elseif ( ! empty( $plugin['icons']['2x'] ) ) { + $plugin_icon_url = $plugin['icons']['2x']; + } elseif ( ! empty( $plugin['icons']['1x'] ) ) { + $plugin_icon_url = $plugin['icons']['1x']; + } else { + $plugin_icon_url = $plugin['icons']['default']; + } + ?> + + <li class="jetpack-about__plugin plugin-card-<?php echo sanitize_html_class( $plugin['slug'] ); ?>"> + <?php + if ( ! $compatible_php || ! $compatible_wp ) { + echo '<div class="notice inline notice-error notice-alt"><p>'; + if ( ! $compatible_php && ! $compatible_wp ) { + esc_html_e( 'This plugin doesn’t work with your versions of WordPress and PHP.', 'jetpack' ); + if ( current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) { + printf( + /* translators: 1: "Update WordPress" screen URL, 2: "Update PHP" page URL */ + ' ' . wp_kses( __( '<a href="%1$s">Please update WordPress</a>, and then <a href="%2$s">learn more about updating PHP</a>.', 'jetpack' ), array( 'a' => array( 'href' => true ) ) ), + esc_url( self_admin_url( 'update-core.php' ) ), + esc_url( $this->jp_get_update_php_url() ) + ); + $this->jp_update_php_annotation(); + } elseif ( current_user_can( 'update_core' ) ) { + printf( + /* translators: %s: "Update WordPress" screen URL */ + ' ' . wp_kses( __( '<a href="%s">Please update WordPress</a>.', 'jetpack' ), array( 'a' => array( 'href' => true ) ) ), + esc_url( self_admin_url( 'update-core.php' ) ) + ); + } elseif ( current_user_can( 'update_php' ) ) { + printf( + /* translators: %s: "Update PHP" page URL */ + ' ' . wp_kses( __( '<a href="%s">Learn more about updating PHP</a>.', 'jetpack' ), array( 'a' => array( 'href' => true ) ) ), + esc_url( $this->jp_get_update_php_url() ) + ); + $this->jp_update_php_annotation(); + } + } elseif ( ! $compatible_wp ) { + esc_html_e( 'This plugin doesn’t work with your version of WordPress.', 'jetpack' ); + if ( current_user_can( 'update_core' ) ) { + printf( + /* translators: %s: "Update WordPress" screen URL */ + ' ' . wp_kses( __( '<a href="%s">Please update WordPress</a>.', 'jetpack' ), array( 'a' => array( 'href' => true ) ) ), + esc_url( self_admin_url( 'update-core.php' ) ) + ); + } + } elseif ( ! $compatible_php ) { + esc_html_e( 'This plugin doesn’t work with your version of PHP.', 'jetpack' ); + if ( current_user_can( 'update_php' ) ) { + printf( + /* translators: %s: "Update PHP" page URL */ + ' ' . wp_kses( __( '<a href="%s">Learn more about updating PHP</a>.', 'jetpack' ), array( 'a' => array( 'href' => true ) ) ), + esc_url( $this->jp_get_update_php_url() ) + ); + $this->jp_update_php_annotation(); + } + } + echo '</p></div>'; + } + ?> + + <div class="plugin-card-top"> + <div class="name column-name"> + <h3> + <a href="<?php echo esc_url( $details_link ); ?>" class="jptracks thickbox open-plugin-details-modal" data-jptracks-name="jetpack_about_plugin_modal" data-jptracks-prop="<?php echo esc_attr( $plugin['slug'] ); ?>"> + <?php echo esc_html( $title ); ?> + <img src="<?php echo esc_attr( $plugin_icon_url ); ?>" class="plugin-icon" alt=""> + </a> + </h3> + </div> + <div class="desc column-description"> + <p><?php echo esc_html( $description ); ?></p> + </div> + + <div class="details-link"> + <a class="jptracks thickbox open-plugin-details-modal" href="<?php echo esc_url( $details_link ); ?>" data-jptracks-name="jetpack_about_plugin_details_modal" data-jptracks-prop="<?php echo esc_attr( $plugin['slug'] ); ?>"><?php esc_html_e( 'More Details', 'jetpack' ); ?></a> + </div> + </div> + + <div class="plugin-card-bottom"> + <div class="meta"> + <?php + wp_star_rating( + array( + 'rating' => $plugin['rating'], + 'type' => 'percent', + 'number' => $plugin['num_ratings'], + ) + ); + ?> + <span class="num-ratings" aria-hidden="true">(<?php echo esc_html( number_format_i18n( $plugin['num_ratings'] ) ); ?> <?php esc_html_e( 'ratings', 'jetpack' ); ?>)</span> + <div class="downloaded"> + <?php + if ( $plugin['active_installs'] >= 1000000 ) { + $active_installs_millions = floor( $plugin['active_installs'] / 1000000 ); + $active_installs_text = sprintf( + /* translators: number of millions of installs. */ + _nx( '%s+ Million', '%s+ Million', $active_installs_millions, 'Active plugin installations', 'jetpack' ), + number_format_i18n( $active_installs_millions ) + ); + } elseif ( 0 === $plugin['active_installs'] ) { + $active_installs_text = _x( 'Less Than 10', 'Active plugin installations', 'jetpack' ); + } else { + $active_installs_text = number_format_i18n( $plugin['active_installs'] ) . '+'; + } + /* translators: number of active installs */ + printf( esc_html__( '%s Active Installations', 'jetpack' ), esc_html( $active_installs_text ) ); + ?> + </div> + </div> + + <div class="action-links"> + <?php + if ( $action_links ) { + // The var simply collects strings that have already been sanitized. + // phpcs:ignore WordPress.Security.EscapeOutput + echo '<ul class="action-buttons"><li>' . implode( '</li><li>', $action_links ) . '</li></ul>'; + } + ?> + </div> + </div> + </li> + <?php + + } + + } + + /** + * Fetch Gravatar hashes for public A12s from wpcom and display them as a list. + * + * @since 7.3 + */ + public function display_gravatars() { + $hashes = array( + 'https://1.gravatar.com/avatar/d2ab03dbab0c97740be75f290a2e3190', + 'https://2.gravatar.com/avatar/b0b357b291ac72bc7da81b4d74430fe6', + 'https://2.gravatar.com/avatar/9e149207a0e0818abed0edbb1fb2d0bf', + 'https://2.gravatar.com/avatar/9f376366854d750124dffe057dda99c9', + 'https://1.gravatar.com/avatar/1c75d26ad0d38624f02b15accc1f20cd', + 'https://1.gravatar.com/avatar/c510e69d83c7d10be4df64feeff4e46a', + 'https://0.gravatar.com/avatar/88ec0dcadea38adf5f30a17e54e9b248', + 'https://1.gravatar.com/avatar/bc45834430c5b0936d76e3f468f9ca57', + 'https://0.gravatar.com/avatar/032677e4115f3a38dc7785529e8cc4d9', + 'https://0.gravatar.com/avatar/72a638c2520ea177976e8eafb201a82f', + 'https://0.gravatar.com/avatar/b3618d70c63bbc5cc7caee0beded5ff0', + 'https://1.gravatar.com/avatar/4d346581a3340e32cf93703c9ce46bd4', + 'https://2.gravatar.com/avatar/9c2f6b95a00dfccfadc6a912a2b859ba', + 'https://1.gravatar.com/avatar/1a33e7a69df4f675fcd799edca088ac2', + 'https://2.gravatar.com/avatar/d5dc443845c134f365519568d5d80e62', + 'https://0.gravatar.com/avatar/c0ccdd53794779bcc07fcae7b79c4d80', + ); + $output = ''; + foreach ( $hashes as $hash ) { + $output .= '<li><img src="' . esc_url( $hash ) . '?s=150"></li>' . "\n"; + } + echo wp_kses( + $output, + array( + 'li' => true, + 'img' => array( + 'src' => true, + ), + ) + ); + } + + // The following methods jp_get_update_php_url, jp_get_default_update_php_url, and jp_update_php_annotation, + // are copies of functions introduced in WP 5.1 + // At the time of releasing this, we're still supporting WP 5.0, so we needed + // to have them here to avoid fatal errors in old installations. + + /** + * Gets the URL to learn more about updating the PHP version the site is running on. + * + * This URL can be overridden by specifying an environment variable `WP_UPDATE_PHP_URL` or by using the + * {@see 'wp_update_php_url'} filter. Providing an empty string is not allowed and will result in the + * default URL being used. Furthermore the page the URL links to should preferably be localized in the + * site language. + * + * @todo: Remove when 5.1 is minimum WP version. + * @since 5.1.0 + * + * @return string URL to learn more about updating PHP. + */ + private function jp_get_update_php_url() { + $default_url = $this->jp_get_default_update_php_url(); + + $update_url = $default_url; + if ( false !== getenv( 'WP_UPDATE_PHP_URL' ) ) { + $update_url = getenv( 'WP_UPDATE_PHP_URL' ); + } + + /** + * Filters the URL to learn more about updating the PHP version the site is running on. + * + * Providing an empty string is not allowed and will result in the default URL being used. Furthermore + * the page the URL links to should preferably be localized in the site language. + * + * @since 5.1.0 + * + * @param string $update_url URL to learn more about updating PHP. + */ + $update_url = apply_filters( 'wp_update_php_url', $update_url ); + + if ( empty( $update_url ) ) { + $update_url = $default_url; + } + + return $update_url; + } + + /** + * Gets the default URL to learn more about updating the PHP version the site is running on. + * + * Do not use this function to retrieve this URL. Instead, use {@see wp_get_update_php_url()} when relying on the URL. + * This function does not allow modifying the returned URL, and is only used to compare the actually used URL with the + * default one. + * + * @todo: Remove when 5.1 is minimum WP version. + * @since 5.1.0 + * @access private + * + * @return string Default URL to learn more about updating PHP. + */ + private function jp_get_default_update_php_url() { + return _x( 'https://wordpress.org/support/update-php/', 'localized PHP upgrade information page', 'jetpack' ); + } + + /** + * Prints the default annotation for the web host altering the "Update PHP" page URL. + * + * This function is to be used after {@see wp_get_update_php_url()} to display a consistent + * annotation if the web host has altered the default "Update PHP" page URL. + * + * @todo: Remove when 5.1 is minimum WP version. + * @since 5.1.0 + */ + private function jp_update_php_annotation() { + $update_url = $this->jp_get_update_php_url(); + $default_url = $this->jp_get_default_update_php_url(); + + if ( $update_url === $default_url ) { + return; + } + + echo '<p class="description">'; + printf( + wp_kses( + /* translators: %s: default Update PHP page URL */ + __( 'This resource is provided by your web host, and is specific to your site. For more information, <a href="%s" target="_blank" rel="noopener noreferrer">see the official WordPress documentation</a>.', 'jetpack' ), + array( + 'a' => array( + 'href' => true, + 'rel' => true, + ), + ) + ), + esc_url( $default_url ) + ); + echo '</p>'; + } + +} diff --git a/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-admin-page.php b/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-admin-page.php new file mode 100644 index 00000000..a6822678 --- /dev/null +++ b/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-admin-page.php @@ -0,0 +1,353 @@ +<?php + +// Shared logic between Jetpack admin pages +abstract class Jetpack_Admin_Page { + // Add page specific actions given the page hook + abstract function add_page_actions( $hook ); + + // Create a menu item for the page and returns the hook + abstract function get_page_hook(); + + // Enqueue and localize page specific scripts + abstract function page_admin_scripts(); + + // Render page specific HTML + abstract function page_render(); + + /** + * Should we block the page rendering because the site is in IDC? + * + * @var bool + */ + static $block_page_rendering_for_idc; + + /** + * Function called after admin_styles to load any additional needed styles. + * + * @since 4.3.0 + */ + function additional_styles() {} + + function __construct() { + $this->jetpack = Jetpack::init(); + self::$block_page_rendering_for_idc = ( + Jetpack::validate_sync_error_idc_option() && ! Jetpack_Options::get_option( 'safe_mode_confirmed' ) + ); + } + + function add_actions() { + global $pagenow; + + // If user is not an admin and site is in Dev Mode, don't do anything + if ( ! current_user_can( 'manage_options' ) && Jetpack::is_development_mode() ) { + return; + } + + // Don't add in the modules page unless modules are available! + if ( $this->dont_show_if_not_active && ! Jetpack::is_active() && ! Jetpack::is_development_mode() ) { + return; + } + + // Initialize menu item for the page in the admin + $hook = $this->get_page_hook(); + + // Attach hooks common to all Jetpack admin pages based on the created + // hook + add_action( "load-$hook", array( $this, 'admin_help' ) ); + add_action( "load-$hook", array( $this, 'admin_page_load' ) ); + add_action( "admin_print_styles-$hook", array( $this, 'admin_styles' ) ); + add_action( "admin_print_scripts-$hook", array( $this, 'admin_scripts' ) ); + + if ( ! self::$block_page_rendering_for_idc ) { + add_action( "admin_print_styles-$hook", array( $this, 'additional_styles' ) ); + } + // If someone just activated Jetpack, let's show them a fullscreen connection banner. + if ( + ( 'admin.php' === $pagenow && isset( $_GET['page'] ) && 'jetpack' === $_GET['page'] ) + && ! Jetpack::is_active() + && current_user_can( 'jetpack_connect' ) + && ! Jetpack::is_development_mode() + ) { + add_action( 'admin_enqueue_scripts', array( 'Jetpack_Connection_Banner', 'enqueue_banner_scripts' ) ); + add_action( 'admin_print_styles', array( Jetpack::init(), 'admin_banner_styles' ) ); + add_action( 'admin_notices', array( 'Jetpack_Connection_Banner', 'render_connect_prompt_full_screen' ) ); + delete_transient( 'activated_jetpack' ); + } + + // Check if the site plan changed and deactivate modules accordingly. + add_action( 'current_screen', array( $this, 'check_plan_deactivate_modules' ) ); + + // Attach page specific actions in addition to the above + $this->add_page_actions( $hook ); + } + + // Render the page with a common top and bottom part, and page specific content + function render() { + // We're in an IDC: we need a decision made before we show the UI again. + if ( self::$block_page_rendering_for_idc ) { + return; + } + + // Check if we are looking at the main dashboard + if ( isset( $_GET['page'] ) && 'jetpack' === $_GET['page'] ) { + $this->page_render(); + return; + } + self::wrap_ui( array( $this, 'page_render' ) ); + } + + function admin_help() { + $this->jetpack->admin_help(); + } + + function admin_page_load() { + // This is big. For the moment, just call the existing one. + $this->jetpack->admin_page_load(); + } + + // Add page specific scripts and jetpack stats for all menu pages + function admin_scripts() { + $this->page_admin_scripts(); // Delegate to inheriting class + add_action( 'admin_footer', array( $this->jetpack, 'do_stats' ) ); + } + + // Enqueue the Jetpack admin stylesheet + function admin_styles() { + $min = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; + + wp_enqueue_style( 'jetpack-admin', plugins_url( "css/jetpack-admin{$min}.css", JETPACK__PLUGIN_FILE ), array( 'genericons' ), JETPACK__VERSION . '-20121016' ); + wp_style_add_data( 'jetpack-admin', 'rtl', 'replace' ); + wp_style_add_data( 'jetpack-admin', 'suffix', $min ); + } + + /** + * Checks if REST API is enabled. + * + * @since 4.4.2 + * + * @return bool + */ + function is_rest_api_enabled() { + return /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ + apply_filters( 'rest_enabled', true ) && + /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ + apply_filters( 'rest_jsonp_enabled', true ) && + /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ + apply_filters( 'rest_authentication_errors', true ); + } + + /** + * Checks the site plan and deactivates modules that were active but are no longer included in the plan. + * + * @since 4.4.0 + * + * @param $page + * + * @return array + */ + function check_plan_deactivate_modules( $page ) { + if ( + Jetpack::is_development_mode() + || ! in_array( + $page->base, + array( + 'toplevel_page_jetpack', + 'admin_page_jetpack_modules', + 'jetpack_page_vaultpress', + 'jetpack_page_stats', + 'jetpack_page_akismet-key-config', + ) + ) + ) { + return false; + } + + $current = Jetpack_Plan::get(); + + $to_deactivate = array(); + if ( isset( $current['product_slug'] ) ) { + $active = Jetpack::get_active_modules(); + switch ( $current['product_slug'] ) { + case 'jetpack_free': + $to_deactivate = array( 'seo-tools', 'videopress', 'google-analytics', 'wordads', 'search' ); + break; + case 'jetpack_personal': + case 'jetpack_personal_monthly': + $to_deactivate = array( 'seo-tools', 'videopress', 'google-analytics', 'wordads', 'search' ); + break; + case 'jetpack_premium': + case 'jetpack_premium_monthly': + $to_deactivate = array( 'seo-tools', 'google-analytics', 'search' ); + break; + } + $to_deactivate = array_intersect( $active, $to_deactivate ); + + $to_leave_enabled = array(); + foreach ( $to_deactivate as $feature ) { + if ( Jetpack_Plan::supports( $feature ) ) { + $to_leave_enabled [] = $feature; + } + } + $to_deactivate = array_diff( $to_deactivate, $to_leave_enabled ); + + if ( ! empty( $to_deactivate ) ) { + Jetpack::update_active_modules( array_filter( array_diff( $active, $to_deactivate ) ) ); + } + } + return array( + 'current' => $current, + 'deactivate' => $to_deactivate, + ); + } + + static function load_wrapper_styles() { + $rtl = is_rtl() ? '.rtl' : ''; + wp_enqueue_style( 'dops-css', plugins_url( "_inc/build/admin.dops-style{$rtl}.css", JETPACK__PLUGIN_FILE ), array(), JETPACK__VERSION ); + wp_enqueue_style( 'components-css', plugins_url( "_inc/build/style.min{$rtl}.css", JETPACK__PLUGIN_FILE ), array(), JETPACK__VERSION ); + $custom_css = ' + #wpcontent { + padding-left: 0 !important; + } + #wpbody-content { + background-color: #f6f6f6; + } + + #jp-plugin-container .wrap { + margin: 0 auto; + max-width:45rem; + padding: 0 1.5rem; + } + #jp-plugin-container.is-wide .wrap { + max-width: 1040px; + } + #jp-plugin-container .wrap .jetpack-wrap-container { + margin-top: 1em; + } + .wp-admin #dolly { + float: none; + position: relative; + right: 0; + left: 0; + top: 0; + padding: .625rem; + text-align: right; + background: #fff; + font-size: .75rem; + font-style: italic; + color: #87a6bc; + border-bottom: 1px #e9eff3 solid; + } + '; + wp_add_inline_style( 'dops-css', $custom_css ); + } + + public static function wrap_ui( $callback, $args = array() ) { + $defaults = array( + 'is-wide' => false, + 'show-nav' => true, + ); + $args = wp_parse_args( $args, $defaults ); + $jetpack_admin_url = admin_url( 'admin.php?page=jetpack' ); + + ?> + <div id="jp-plugin-container" class=" + <?php + if ( $args['is-wide'] ) { + echo 'is-wide'; } + ?> + "> + + <div class="jp-masthead"> + <div class="jp-masthead__inside-container"> + <div class="jp-masthead__logo-container"> + <a class="jp-masthead__logo-link" href="<?php echo esc_url( $jetpack_admin_url ); ?>"> + <svg class="jetpack-logo__masthead" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" height="32" viewBox="0 0 118 32"><path fill="#00BE28" d="M16,0C7.2,0,0,7.2,0,16s7.2,16,16,16s16-7.2,16-16S24.8,0,16,0z M15,19H7l8-16V19z M17,29V13h8L17,29z"></path><path d="M41.3,26.6c-0.5-0.7-0.9-1.4-1.3-2.1c2.3-1.4,3-2.5,3-4.6V8h-3V6h6v13.4C46,22.8,45,24.8,41.3,26.6z"></path><path d="M65,18.4c0,1.1,0.8,1.3,1.4,1.3c0.5,0,2-0.2,2.6-0.4v2.1c-0.9,0.3-2.5,0.5-3.7,0.5c-1.5,0-3.2-0.5-3.2-3.1V12H60v-2h2.1V7.1 H65V10h4v2h-4V18.4z"></path><path d="M71,10h3v1.3c1.1-0.8,1.9-1.3,3.3-1.3c2.5,0,4.5,1.8,4.5,5.6s-2.2,6.3-5.8,6.3c-0.9,0-1.3-0.1-2-0.3V28h-3V10z M76.5,12.3 c-0.8,0-1.6,0.4-2.5,1.2v5.9c0.6,0.1,0.9,0.2,1.8,0.2c2,0,3.2-1.3,3.2-3.9C79,13.4,78.1,12.3,76.5,12.3z"></path><path d="M93,22h-3v-1.5c-0.9,0.7-1.9,1.5-3.5,1.5c-1.5,0-3.1-1.1-3.1-3.2c0-2.9,2.5-3.4,4.2-3.7l2.4-0.3v-0.3c0-1.5-0.5-2.3-2-2.3 c-0.7,0-2.3,0.5-3.7,1.1L84,11c1.2-0.4,3-1,4.4-1c2.7,0,4.6,1.4,4.6,4.7L93,22z M90,16.4l-2.2,0.4c-0.7,0.1-1.4,0.5-1.4,1.6 c0,0.9,0.5,1.4,1.3,1.4s1.5-0.5,2.3-1V16.4z"></path><path d="M104.5,21.3c-1.1,0.4-2.2,0.6-3.5,0.6c-4.2,0-5.9-2.4-5.9-5.9c0-3.7,2.3-6,6.1-6c1.4,0,2.3,0.2,3.2,0.5V13 c-0.8-0.3-2-0.6-3.2-0.6c-1.7,0-3.2,0.9-3.2,3.6c0,2.9,1.5,3.8,3.3,3.8c0.9,0,1.9-0.2,3.2-0.7V21.3z"></path><path d="M110,15.2c0.2-0.3,0.2-0.8,3.8-5.2h3.7l-4.6,5.7l5,6.3h-3.7l-4.2-5.8V22h-3V6h3V15.2z"></path><path d="M58.5,21.3c-1.5,0.5-2.7,0.6-4.2,0.6c-3.6,0-5.8-1.8-5.8-6c0-3.1,1.9-5.9,5.5-5.9s4.9,2.5,4.9,4.9c0,0.8,0,1.5-0.1,2h-7.3 c0.1,2.5,1.5,2.8,3.6,2.8c1.1,0,2.2-0.3,3.4-0.7C58.5,19,58.5,21.3,58.5,21.3z M56,15c0-1.4-0.5-2.9-2-2.9c-1.4,0-2.3,1.3-2.4,2.9 C51.6,15,56,15,56,15z"></path></svg> + </a> + </div> + <?php + if ( $args['show-nav'] ) : + ?> + <div class="jp-masthead__nav"> + <?php + if ( is_network_admin() ) { + $current_screen = get_current_screen(); + + $highlight_current_sites = ( 'toplevel_page_jetpack-network' === $current_screen->id ? 'is-primary' : '' ); + $highlight_current_settings = ( 'jetpack_page_jetpack-settings-network' === $current_screen->id ? 'is-primary' : '' ); + ?> + <span class="dops-button-group"> + <?php + if ( current_user_can( 'jetpack_network_sites_page' ) ) { + ?> + <a href="<?php echo esc_url( network_admin_url( 'admin.php?page=jetpack' ) ); ?>" type="button" class="<?php echo esc_attr( $highlight_current_sites ); ?> dops-button is-compact" title="<?php esc_html_e( "Manage your network's Jetpack Sites.", 'jetpack' ); ?>"><?php echo esc_html_x( 'Sites', 'Navigation item', 'jetpack' ); ?></a> + <?php + } if ( current_user_can( 'jetpack_network_settings_page' ) ) { + ?> + <a href="<?php echo esc_url( network_admin_url( 'admin.php?page=jetpack-settings' ) ); ?>" type="button" class="<?php echo esc_attr( $highlight_current_settings ); ?> dops-button is-compact" title="<?php esc_html_e( "Manage your network's Jetpack Sites.", 'jetpack' ); ?>"><?php echo esc_html_x( 'Network Settings', 'Navigation item', 'jetpack' ); ?></a> + <?php + } + ?> + </span> + <?php } else { ?> + <span class="dops-button-group"> + <a href="<?php echo esc_url( $jetpack_admin_url ); ?>" type="button" class="dops-button is-compact"><?php esc_html_e( 'Dashboard', 'jetpack' ); ?></a> + <?php + if ( current_user_can( 'jetpack_manage_modules' ) ) { + ?> + <a href="<?php echo esc_url( $jetpack_admin_url . '#/settings' ); ?>" type="button" class="dops-button is-compact"><?php esc_html_e( 'Settings', 'jetpack' ); ?></a> + <?php + } + ?> + </span> + <?php } ?> + </div> + <?php endif; ?> + </div> + </div> + <div class="wrap"><div id="jp-admin-notices" aria-live="polite"></div></div> + <!-- START OF CALLBACK --> + <?php + ob_start(); + call_user_func( $callback ); + $callback_ui = ob_get_contents(); + ob_end_clean(); + echo $callback_ui; + ?> + <!-- END OF CALLBACK --> + <div class="jp-footer"> + <div class="jp-footer__a8c-attr-container"><a href="https://automattic.com" target="_blank" rel="noopener noreferrer"><svg role="img" class="jp-footer__a8c-attr" x="0" y="0" viewBox="0 0 935 38.2" enable-background="new 0 0 935 38.2" aria-labelledby="a8c-svg-title"><title id="a8c-svg-title">An Automattic Airline</title><path d="M317.1 38.2c-12.6 0-20.7-9.1-20.7-18.5v-1.2c0-9.6 8.2-18.5 20.7-18.5 12.6 0 20.8 8.9 20.8 18.5v1.2C337.9 29.1 329.7 38.2 317.1 38.2zM331.2 18.6c0-6.9-5-13-14.1-13s-14 6.1-14 13v0.9c0 6.9 5 13.1 14 13.1s14.1-6.2 14.1-13.1V18.6zM175 36.8l-4.7-8.8h-20.9l-4.5 8.8h-7L157 1.3h5.5L182 36.8H175zM159.7 8.2L152 23.1h15.7L159.7 8.2zM212.4 38.2c-12.7 0-18.7-6.9-18.7-16.2V1.3h6.6v20.9c0 6.6 4.3 10.5 12.5 10.5 8.4 0 11.9-3.9 11.9-10.5V1.3h6.7V22C231.4 30.8 225.8 38.2 212.4 38.2zM268.6 6.8v30h-6.7v-30h-15.5V1.3h37.7v5.5H268.6zM397.3 36.8V8.7l-1.8 3.1 -14.9 25h-3.3l-14.7-25 -1.8-3.1v28.1h-6.5V1.3h9.2l14 24.4 1.7 3 1.7-3 13.9-24.4h9.1v35.5H397.3zM454.4 36.8l-4.7-8.8h-20.9l-4.5 8.8h-7l19.2-35.5h5.5l19.5 35.5H454.4zM439.1 8.2l-7.7 14.9h15.7L439.1 8.2zM488.4 6.8v30h-6.7v-30h-15.5V1.3h37.7v5.5H488.4zM537.3 6.8v30h-6.7v-30h-15.5V1.3h37.7v5.5H537.3zM569.3 36.8V4.6c2.7 0 3.7-1.4 3.7-3.4h2.8v35.5L569.3 36.8 569.3 36.8zM628 11.3c-3.2-2.9-7.9-5.7-14.2-5.7 -9.5 0-14.8 6.5-14.8 13.3v0.7c0 6.7 5.4 13 15.3 13 5.9 0 10.8-2.8 13.9-5.7l4 4.2c-3.9 3.8-10.5 7.1-18.3 7.1 -13.4 0-21.6-8.7-21.6-18.3v-1.2c0-9.6 8.9-18.7 21.9-18.7 7.5 0 14.3 3.1 18 7.1L628 11.3zM321.5 12.4c1.2 0.8 1.5 2.4 0.8 3.6l-6.1 9.4c-0.8 1.2-2.4 1.6-3.6 0.8l0 0c-1.2-0.8-1.5-2.4-0.8-3.6l6.1-9.4C318.7 11.9 320.3 11.6 321.5 12.4L321.5 12.4z"></path><path d="M37.5 36.7l-4.7-8.9H11.7l-4.6 8.9H0L19.4 0.8H25l19.7 35.9H37.5zM22 7.8l-7.8 15.1h15.9L22 7.8zM82.8 36.7l-23.3-24 -2.3-2.5v26.6h-6.7v-36H57l22.6 24 2.3 2.6V0.8h6.7v35.9H82.8z"></path><path d="M719.9 37l-4.8-8.9H694l-4.6 8.9h-7.1l19.5-36h5.6l19.8 36H719.9zM704.4 8l-7.8 15.1h15.9L704.4 8zM733 37V1h6.8v36H733zM781 37c-1.8 0-2.6-2.5-2.9-5.8l-0.2-3.7c-0.2-3.6-1.7-5.1-8.4-5.1h-12.8V37H750V1h19.6c10.8 0 15.7 4.3 15.7 9.9 0 3.9-2 7.7-9 9 7 0.5 8.5 3.7 8.6 7.9l0.1 3c0.1 2.5 0.5 4.3 2.2 6.1V37H781zM778.5 11.8c0-2.6-2.1-5.1-7.9-5.1h-13.8v10.8h14.4c5 0 7.3-2.4 7.3-5.2V11.8zM794.8 37V1h6.8v30.4h28.2V37H794.8zM836.7 37V1h6.8v36H836.7zM886.2 37l-23.4-24.1 -2.3-2.5V37h-6.8V1h6.5l22.7 24.1 2.3 2.6V1h6.8v36H886.2zM902.3 37V1H935v5.6h-26v9.2h20v5.5h-20v10.1h26V37H902.3z"></path></svg></a></div> + <ul class="jp-footer__links"> + <li class="jp-footer__link-item"> + <a href="https://jetpack.com" target="_blank" rel="noopener noreferrer" class="jp-footer__link" title="<?php esc_html_e( 'Jetpack version', 'jetpack' ); ?>">Jetpack <?php echo JETPACK__VERSION; ?></a> + </li> + <li class="jp-footer__link-item"> + <a href="https://wordpress.com/tos/" target="_blank" rel="noopener noreferrer" title="<?php esc_html__( 'WordPress.com Terms of Service', 'jetpack' ); ?>" class="jp-footer__link"><?php echo esc_html_x( 'Terms', 'Navigation item', 'jetpack' ); ?></a> + </li> + <li class="jp-footer__link-item"> + <a href="<?php echo esc_url( $jetpack_admin_url . '#/privacy' ); ?>" rel="noopener noreferrer" title="<?php esc_html_e( "Automattic's Privacy Policy", 'jetpack' ); ?>" class="jp-footer__link"><?php echo esc_html_x( 'Privacy', 'Navigation item', 'jetpack' ); ?></a> + </li> + <?php if ( is_multisite() && current_user_can( 'jetpack_network_sites_page' ) ) { ?> + <li class="jp-footer__link-item"> + <a href="<?php echo esc_url( network_admin_url( 'admin.php?page=jetpack' ) ); ?>" title="<?php esc_html_e( "Manage your network's Jetpack Sites.", 'jetpack' ); ?>" class="jp-footer__link"><?php echo esc_html_x( 'Network Sites', 'Navigation item', 'jetpack' ); ?></a> + </li> + <?php } ?> + <?php if ( is_multisite() && current_user_can( 'jetpack_network_settings_page' ) ) { ?> + <li class="jp-footer__link-item"> + <a href="<?php echo esc_url( network_admin_url( 'admin.php?page=jetpack-settings' ) ); ?>" title="<?php esc_html_e( "Manage your network's Jetpack Sites.", 'jetpack' ); ?>" class="jp-footer__link"><?php echo esc_html_x( 'Network Settings', 'Navigation item', 'jetpack' ); ?></a> + </li> + <?php } ?> + <?php if ( current_user_can( 'manage_options' ) ) { ?> + <li class="jp-footer__link-item"> + <a href="<?php echo esc_url( admin_url() . 'admin.php?page=jetpack_modules' ); ?>" title="<?php esc_html_e( "Access the full list of Jetpack modules available on your site.", 'jetpack' ); ?>" class="jp-footer__link"><?php echo esc_html_x( 'Modules', 'Navigation item', 'jetpack' ); ?></a> + </li> + <li class="jp-footer__link-item"> + <a href="<?php echo esc_url( admin_url() . 'admin.php?page=jetpack-debugger' ); ?>" title="<?php esc_html_e( "Test your site's compatibility with Jetpack.", 'jetpack' ); ?>" class="jp-footer__link"><?php echo esc_html_x( 'Debug', 'Navigation item', 'jetpack' ); ?></a> + </li> + <?php } ?> + </ul> + </div> + </div> + <?php + return; + } +} diff --git a/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-landing-page.php b/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-landing-page.php new file mode 100644 index 00000000..5c06c284 --- /dev/null +++ b/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-landing-page.php @@ -0,0 +1,3 @@ +<?php +// This is intentionally left empty as a stub because some sites were caching the require() +// @see https://github.com/Automattic/jetpack/issues/5091 diff --git a/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-react-page.php b/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-react-page.php new file mode 100644 index 00000000..e5a423f0 --- /dev/null +++ b/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-react-page.php @@ -0,0 +1,399 @@ +<?php +include_once( 'class.jetpack-admin-page.php' ); + +// Builds the landing page and its menu +class Jetpack_React_Page extends Jetpack_Admin_Page { + + protected $dont_show_if_not_active = false; + + protected $is_redirecting = false; + + function get_page_hook() { + // Add the main admin Jetpack menu + return add_menu_page( 'Jetpack', 'Jetpack', 'jetpack_admin_page', 'jetpack', array( $this, 'render' ), 'div' ); + } + + function add_page_actions( $hook ) { + /** This action is documented in class.jetpack.php */ + do_action( 'jetpack_admin_menu', $hook ); + + // Place the Jetpack menu item on top and others in the order they appear + add_filter( 'custom_menu_order', '__return_true' ); + add_filter( 'menu_order', array( $this, 'jetpack_menu_order' ) ); + + if ( ! isset( $_GET['page'] ) || 'jetpack' !== $_GET['page'] ) { + return; // No need to handle the fallback redirection if we are not on the Jetpack page + } + + // Adding a redirect meta tag if the REST API is disabled + if ( ! $this->is_rest_api_enabled() ) { + $this->is_redirecting = true; + add_action( 'admin_head', array( $this, 'add_fallback_head_meta' ) ); + } + + // Adding a redirect meta tag wrapped in noscript tags for all browsers in case they have JavaScript disabled + add_action( 'admin_head', array( $this, 'add_noscript_head_meta' ) ); + + // If this is the first time the user is viewing the admin, don't show JITMs. + // This filter is added just in time because this function is called on admin_menu + // and JITMs are initialized on admin_init + if ( Jetpack::is_active() && ! Jetpack_Options::get_option( 'first_admin_view', false ) ) { + Jetpack_Options::update_option( 'first_admin_view', true ); + add_filter( 'jetpack_just_in_time_msgs', '__return_false' ); + } + } + + /** + * Add Jetpack Dashboard sub-link and point it to AAG if the user can view stats, manage modules or if Protect is active. + * + * Works in Dev Mode or when user is connected. + * + * @since 4.3.0 + */ + function jetpack_add_dashboard_sub_nav_item() { + if ( Jetpack::is_development_mode() || Jetpack::is_active() ) { + global $submenu; + if ( current_user_can( 'jetpack_admin_page' ) ) { + $submenu['jetpack'][] = array( __( 'Dashboard', 'jetpack' ), 'jetpack_admin_page', 'admin.php?page=jetpack#/dashboard' ); + } + } + } + + /** + * If user is allowed to see the Jetpack Admin, add Settings sub-link. + * + * @since 4.3.0 + */ + function jetpack_add_settings_sub_nav_item() { + if ( ( Jetpack::is_development_mode() || Jetpack::is_active() ) && current_user_can( 'jetpack_admin_page' ) && current_user_can( 'edit_posts' ) ) { + global $submenu; + $submenu['jetpack'][] = array( __( 'Settings', 'jetpack' ), 'jetpack_admin_page', 'admin.php?page=jetpack#/settings' ); + } + } + + function add_fallback_head_meta() { + echo '<meta http-equiv="refresh" content="0; url=?page=jetpack_modules">'; + } + + function add_noscript_head_meta() { + echo '<noscript>'; + $this->add_fallback_head_meta(); + echo '</noscript>'; + } + + function jetpack_menu_order( $menu_order ) { + $jp_menu_order = array(); + + foreach ( $menu_order as $index => $item ) { + if ( $item != 'jetpack' ) + $jp_menu_order[] = $item; + + if ( $index == 0 ) + $jp_menu_order[] = 'jetpack'; + } + + return $jp_menu_order; + } + + function page_render() { + /** This action is already documented in views/admin/admin-page.php */ + do_action( 'jetpack_notices' ); + + // Try fetching by patch + $static_html = @file_get_contents( JETPACK__PLUGIN_DIR . '_inc/build/static.html' ); + + if ( false === $static_html ) { + + // If we still have nothing, display an error + echo '<p>'; + esc_html_e( 'Error fetching static.html. Try running: ', 'jetpack' ); + echo '<code>yarn distclean && yarn build</code>'; + echo '</p>'; + } else { + + // We got the static.html so let's display it + echo $static_html; + } + } + + /** + * Gets array of any Jetpack notices that have been dismissed. + * + * @since 4.0.1 + * @return mixed|void + */ + function get_dismissed_jetpack_notices() { + $jetpack_dismissed_notices = get_option( 'jetpack_dismissed_notices', array() ); + /** + * Array of notices that have been dismissed. + * + * @since 4.0.1 + * + * @param array $jetpack_dismissed_notices If empty, will not show any Jetpack notices. + */ + $dismissed_notices = apply_filters( 'jetpack_dismissed_notices', $jetpack_dismissed_notices ); + return $dismissed_notices; + } + + function additional_styles() { + Jetpack_Admin_Page::load_wrapper_styles(); + } + + function page_admin_scripts() { + if ( $this->is_redirecting ) { + return; // No need for scripts on a fallback page + } + + $script_deps_path = JETPACK__PLUGIN_DIR . '_inc/build/admin.deps.json'; + $script_dependencies = file_exists( $script_deps_path ) + ? json_decode( file_get_contents( $script_deps_path ) ) + : array(); + $script_dependencies[] = 'wp-polyfill'; + + wp_enqueue_script( + 'react-plugin', + plugins_url( '_inc/build/admin.js', JETPACK__PLUGIN_FILE ), + $script_dependencies, + JETPACK__VERSION, + true + ); + + if ( ! Jetpack::is_development_mode() && Jetpack::is_active() ) { + // Required for Analytics. + wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true ); + } + + // Add objects to be passed to the initial state of the app. + wp_localize_script( 'react-plugin', 'Initial_State', $this->get_initial_state() ); + } + + function get_initial_state() { + // Load API endpoint base classes and endpoints for getting the module list fed into the JS Admin Page + require_once JETPACK__PLUGIN_DIR . '_inc/lib/core-api/class.jetpack-core-api-xmlrpc-consumer-endpoint.php'; + require_once JETPACK__PLUGIN_DIR . '_inc/lib/core-api/class.jetpack-core-api-module-endpoints.php'; + $moduleListEndpoint = new Jetpack_Core_API_Module_List_Endpoint(); + $modules = $moduleListEndpoint->get_modules(); + + // Preparing translated fields for JSON encoding by transforming all HTML entities to + // respective characters. + foreach( $modules as $slug => $data ) { + $modules[ $slug ]['name'] = html_entity_decode( $data['name'] ); + $modules[ $slug ]['description'] = html_entity_decode( $data['description'] ); + $modules[ $slug ]['short_description'] = html_entity_decode( $data['short_description'] ); + $modules[ $slug ]['long_description'] = html_entity_decode( $data['long_description'] ); + } + + // Collecting roles that can view site stats. + $stats_roles = array(); + $enabled_roles = function_exists( 'stats_get_option' ) ? stats_get_option( 'roles' ) : array( 'administrator' ); + + if ( ! function_exists( 'get_editable_roles' ) ) { + require_once ABSPATH . 'wp-admin/includes/user.php'; + } + foreach ( get_editable_roles() as $slug => $role ) { + $stats_roles[ $slug ] = array( + 'name' => translate_user_role( $role['name'] ), + 'canView' => is_array( $enabled_roles ) ? in_array( $slug, $enabled_roles, true ) : false, + ); + } + + // Get information about current theme. + $current_theme = wp_get_theme(); + + // Get all themes that Infinite Scroll provides support for natively. + $inf_scr_support_themes = array(); + foreach ( Jetpack::glob_php( JETPACK__PLUGIN_DIR . 'modules/infinite-scroll/themes' ) as $path ) { + if ( is_readable( $path ) ) { + $inf_scr_support_themes[] = basename( $path, '.php' ); + } + } + + // Get last post, to build the link to Customizer in the Related Posts module. + $last_post = get_posts( array( 'posts_per_page' => 1 ) ); + $last_post = isset( $last_post[0] ) && $last_post[0] instanceof WP_Post + ? get_permalink( $last_post[0]->ID ) + : get_home_url(); + + // Ensure that class to get the affiliate code is loaded + if ( ! class_exists( 'Jetpack_Affiliate' ) ) { + require_once JETPACK__PLUGIN_DIR . 'class.jetpack-affiliate.php'; + } + + return array( + 'WP_API_root' => esc_url_raw( rest_url() ), + 'WP_API_nonce' => wp_create_nonce( 'wp_rest' ), + 'pluginBaseUrl' => plugins_url( '', JETPACK__PLUGIN_FILE ), + 'connectionStatus' => array( + 'isActive' => Jetpack::is_active(), + 'isStaging' => Jetpack::is_staging_site(), + 'devMode' => array( + 'isActive' => Jetpack::is_development_mode(), + 'constant' => defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG, + 'url' => site_url() && false === strpos( site_url(), '.' ), + 'filter' => apply_filters( 'jetpack_development_mode', false ), + ), + 'isPublic' => '1' == get_option( 'blog_public' ), + 'isInIdentityCrisis' => Jetpack::validate_sync_error_idc_option(), + 'sandboxDomain' => JETPACK__SANDBOX_DOMAIN, + ), + 'connectUrl' => Jetpack::init()->build_connect_url( true, false, false ), + 'dismissedNotices' => $this->get_dismissed_jetpack_notices(), + 'isDevVersion' => Jetpack::is_development_version(), + 'currentVersion' => JETPACK__VERSION, + 'is_gutenberg_available' => true, + 'getModules' => $modules, + 'showJumpstart' => jetpack_show_jumpstart(), + 'rawUrl' => Jetpack::build_raw_urls( get_home_url() ), + 'adminUrl' => esc_url( admin_url() ), + 'stats' => array( + // data is populated asynchronously on page load + 'data' => array( + 'general' => false, + 'day' => false, + 'week' => false, + 'month' => false, + ), + 'roles' => $stats_roles, + ), + 'aff' => Jetpack_Affiliate::init()->get_affiliate_code(), + 'settings' => $this->get_flattened_settings( $modules ), + 'userData' => array( +// 'othersLinked' => Jetpack::get_other_linked_admins(), + 'currentUser' => jetpack_current_user_data(), + ), + 'siteData' => array( + 'icon' => has_site_icon() + ? apply_filters( 'jetpack_photon_url', get_site_icon_url(), array( 'w' => 64 ) ) + : '', + 'siteVisibleToSearchEngines' => '1' == get_option( 'blog_public' ), + /** + * Whether promotions are visible or not. + * + * @since 4.8.0 + * + * @param bool $are_promotions_active Status of promotions visibility. True by default. + */ + 'showPromotions' => apply_filters( 'jetpack_show_promotions', true ), + 'isAtomicSite' => jetpack_is_atomic_site(), + 'plan' => Jetpack_Plan::get(), + 'showBackups' => Jetpack::show_backups_ui(), + ), + 'themeData' => array( + 'name' => $current_theme->get( 'Name' ), + 'hasUpdate' => (bool) get_theme_update_available( $current_theme ), + 'support' => array( + 'infinite-scroll' => current_theme_supports( 'infinite-scroll' ) || in_array( $current_theme->get_stylesheet(), $inf_scr_support_themes ), + ), + ), + 'locale' => Jetpack::get_i18n_data_json(), + 'localeSlug' => join( '-', explode( '_', get_user_locale() ) ), + 'jetpackStateNotices' => array( + 'messageCode' => Jetpack::state( 'message' ), + 'errorCode' => Jetpack::state( 'error' ), + 'errorDescription' => Jetpack::state( 'error_description' ), + ), + 'tracksUserData' => Jetpack_Tracks_Client::get_connected_user_tracks_identity(), + 'currentIp' => function_exists( 'jetpack_protect_get_ip' ) ? jetpack_protect_get_ip() : false, + 'lastPostUrl' => esc_url( $last_post ), + 'externalServicesConnectUrls' => $this->get_external_services_connect_urls() + ); + } + + function get_external_services_connect_urls() { + $connect_urls = array(); + jetpack_require_lib( 'class.jetpack-keyring-service-helper' ); + foreach ( Jetpack_Keyring_Service_Helper::$SERVICES as $service_name => $service_info ) { + $connect_urls[ $service_name ] = Jetpack_Keyring_Service_Helper::connect_url( $service_name, $service_info[ 'for' ] ); + } + return $connect_urls; + } + + /** + * Returns an array of modules and settings both as first class members of the object. + * + * @param array $modules the result of an API request to get all modules. + * + * @return array flattened settings with modules. + */ + function get_flattened_settings( $modules ) { + $core_api_endpoint = new Jetpack_Core_API_Data(); + $settings = $core_api_endpoint->get_all_options(); + return $settings->data; + } +} + +/* + * Only show Jump Start on first activation. + * Any option 'jumpstart' other than 'new connection' will hide it. + * + * The option can be of 4 things, and will be stored as such: + * new_connection : Brand new connection - Show + * jumpstart_activated : Jump Start has been activated - dismiss + * jumpstart_dismissed : Manual dismissal of Jump Start - dismiss + * jetpack_action_taken: Deprecated since 7.3 But still listed here to respect behaviour for old versions. + * Manual activation of a module already happened - dismiss. + * + * @todo move to functions.global.php when available + * @since 3.6 + * @return bool | show or hide + */ +function jetpack_show_jumpstart() { + if ( ! Jetpack::is_active() ) { + return false; + } + $jumpstart_option = Jetpack_Options::get_option( 'jumpstart' ); + + $hide_options = array( + 'jumpstart_activated', + 'jetpack_action_taken', + 'jumpstart_dismissed' + ); + + if ( ! $jumpstart_option || in_array( $jumpstart_option, $hide_options ) ) { + return false; + } + + return true; +} + +/** + * Gather data about the current user. + * + * @since 4.1.0 + * + * @return array + */ +function jetpack_current_user_data() { + $current_user = wp_get_current_user(); + $is_master_user = $current_user->ID == Jetpack_Options::get_option( 'master_user' ); + $dotcom_data = Jetpack::get_connected_user_data(); + // Add connected user gravatar to the returned dotcom_data. + $dotcom_data['avatar'] = get_avatar_url( $dotcom_data['email'], array( 'size' => 64, 'default' => 'mysteryman' ) ); + + $current_user_data = array( + 'isConnected' => Jetpack::is_user_connected( $current_user->ID ), + 'isMaster' => $is_master_user, + 'username' => $current_user->user_login, + 'id' => $current_user->ID, + 'wpcomUser' => $dotcom_data, + 'gravatar' => get_avatar( $current_user->ID, 40, 'mm', '', array( 'force_display' => true ) ), + 'permissions' => array( + 'admin_page' => current_user_can( 'jetpack_admin_page' ), + 'connect' => current_user_can( 'jetpack_connect' ), + 'disconnect' => current_user_can( 'jetpack_disconnect' ), + 'manage_modules' => current_user_can( 'jetpack_manage_modules' ), + 'network_admin' => current_user_can( 'jetpack_network_admin_page' ), + 'network_sites_page' => current_user_can( 'jetpack_network_sites_page' ), + 'edit_posts' => current_user_can( 'edit_posts' ), + 'publish_posts' => current_user_can( 'publish_posts' ), + 'manage_options' => current_user_can( 'manage_options' ), + 'view_stats' => current_user_can( 'view_stats' ), + 'manage_plugins' => current_user_can( 'install_plugins' ) + && current_user_can( 'activate_plugins' ) + && current_user_can( 'update_plugins' ) + && current_user_can( 'delete_plugins' ), + ), + ); + + return $current_user_data; +} diff --git a/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php b/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php new file mode 100644 index 00000000..69991c70 --- /dev/null +++ b/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php @@ -0,0 +1,141 @@ +<?php +include_once( 'class.jetpack-admin-page.php' ); +include_once( JETPACK__PLUGIN_DIR . 'class.jetpack-modules-list-table.php' ); + +// Builds the settings page and its menu +class Jetpack_Settings_Page extends Jetpack_Admin_Page { + + // Show the settings page only when Jetpack is connected or in dev mode + protected $dont_show_if_not_active = true; + + function add_page_actions( $hook ) {} + + // Adds the Settings sub menu + function get_page_hook() { + return add_submenu_page( + null, + __( 'Jetpack Settings', 'jetpack' ), + __( 'Settings', 'jetpack' ), + 'jetpack_manage_modules', + 'jetpack_modules', + array( $this, 'render' ) + ); + } + + // Renders the module list table where you can use bulk action or row + // actions to activate/deactivate and configure modules + function page_render() { + $list_table = new Jetpack_Modules_List_Table; + + // We have static.html so let's continue trying to fetch the others + $noscript_notice = @file_get_contents( JETPACK__PLUGIN_DIR . '_inc/build/static-noscript-notice.html' ); + $rest_api_notice = @file_get_contents( JETPACK__PLUGIN_DIR . '_inc/build/static-version-notice.html' ); + + $noscript_notice = str_replace( + '#HEADER_TEXT#', + esc_html__( 'You have JavaScript disabled', 'jetpack' ), + $noscript_notice + ); + $noscript_notice = str_replace( + '#TEXT#', + esc_html__( "Turn on JavaScript to unlock Jetpack's full potential!", 'jetpack' ), + $noscript_notice + ); + + $rest_api_notice = str_replace( + '#HEADER_TEXT#', + esc_html( __( 'WordPress REST API is disabled', 'jetpack' ) ), + $rest_api_notice + ); + $rest_api_notice = str_replace( + '#TEXT#', + esc_html( __( "Enable WordPress REST API to unlock Jetpack's full potential!", 'jetpack' ) ), + $rest_api_notice + ); + + if ( ! $this->is_rest_api_enabled() ) { + echo $rest_api_notice; + } + echo $noscript_notice; + ?> + + <div class="page-content configure"> + <div class="frame top hide-if-no-js"> + <div class="wrap"> + <div class="manage-left"> + <table class="table table-bordered fixed-top"> + <thead> + <tr> + <th class="check-column"><input type="checkbox" class="checkall"></th> + <th colspan="2"> + <?php $list_table->unprotected_display_tablenav( 'top' ); ?> + <span class="filter-search"> + <button type="button" class="button">Filter</button> + </span> + </th> + </tr> + </thead> + </table> + </div> + </div><!-- /.wrap --> + </div><!-- /.frame --> + <div class="frame bottom"> + <div class="wrap"> + <div class="manage-right" style="display: none;"> + <div class="bumper"> + <form class="navbar-form" role="search"> + <input type="hidden" name="page" value="jetpack_modules" /> + <?php $list_table->search_box( __( 'Search', 'jetpack' ), 'srch-term' ); ?> + <p><?php esc_html_e( 'View:', 'jetpack' ); ?></p> + <div class="button-group filter-active"> + <button type="button" class="button <?php if ( empty( $_GET['activated'] ) ) echo 'active'; ?>"><?php esc_html_e( 'All', 'jetpack' ); ?></button> + <button type="button" class="button <?php if ( ! empty( $_GET['activated'] ) && 'true' == $_GET['activated'] ) echo 'active'; ?>" data-filter-by="activated" data-filter-value="true"><?php esc_html_e( 'Active', 'jetpack' ); ?></button> + <button type="button" class="button <?php if ( ! empty( $_GET['activated'] ) && 'false' == $_GET['activated'] ) echo 'active'; ?>" data-filter-by="activated" data-filter-value="false"><?php esc_html_e( 'Inactive', 'jetpack' ); ?></button> + </div> + <p><?php esc_html_e( 'Sort by:', 'jetpack' ); ?></p> + <div class="button-group sort"> + <button type="button" class="button <?php if ( empty( $_GET['sort_by'] ) ) echo 'active'; ?>" data-sort-by="name"><?php esc_html_e( 'Alphabetical', 'jetpack' ); ?></button> + <button type="button" class="button <?php if ( ! empty( $_GET['sort_by'] ) && 'introduced' == $_GET['sort_by'] ) echo 'active'; ?>" data-sort-by="introduced" data-sort-order="reverse"><?php esc_html_e( 'Newest', 'jetpack' ); ?></button> + <button type="button" class="button <?php if ( ! empty( $_GET['sort_by'] ) && 'sort' == $_GET['sort_by'] ) echo 'active'; ?>" data-sort-by="sort"><?php esc_html_e( 'Popular', 'jetpack' ); ?></button> + </div> + <p><?php esc_html_e( 'Show:', 'jetpack' ); ?></p> + <?php $list_table->views(); ?> + </form> + </div> + </div> + <div class="manage-left" style="width: 100%;"> + <form class="jetpack-modules-list-table-form" onsubmit="return false;"> + <table class="<?php echo implode( ' ', $list_table->get_table_classes() ); ?>"> + <tbody id="the-list"> + <?php $list_table->display_rows_or_placeholder(); ?> + </tbody> + </table> + </form> + </div> + </div><!-- /.wrap --> + </div><!-- /.frame --> + </div><!-- /.content --> + <?php + + JetpackTracking::record_user_event( 'wpa_page_view', array( 'path' => 'old_settings' ) ); + } + + /** + * Load styles for static page. + * + * @since 4.3.0 + */ + function additional_styles() { + Jetpack_Admin_Page::load_wrapper_styles(); + } + + // Javascript logic specific to the list table + function page_admin_scripts() { + wp_enqueue_script( + 'jetpack-admin-js', + Jetpack::get_file_url_for_environment( '_inc/build/jetpack-admin.min.js', '_inc/jetpack-admin.js' ), + array( 'jquery' ), + JETPACK__VERSION + ); + } +} diff --git a/plugins/jetpack/_inc/lib/class.color.php b/plugins/jetpack/_inc/lib/class.color.php new file mode 100644 index 00000000..a57f2009 --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.color.php @@ -0,0 +1,755 @@ +<?php +/** + * Color utility and conversion + * + * Represents a color value, and converts between RGB/HSV/XYZ/Lab/HSL + * + * Example: + * $color = new Jetpack_Color(0xFFFFFF); + * + * @author Harold Asbridge <hasbridge@gmail.com> + * @author Matt Wiebe <wiebe@automattic.com> + * @license http://www.opensource.org/licenses/MIT + */ + +class Jetpack_Color { + /** + * @var int + */ + protected $color = 0; + + /** + * Initialize object + * + * @param string|array $color A color of the type $type + * @param string $type The type of color we will construct from. + * One of hex (default), rgb, hsl, int + */ + public function __construct( $color = null, $type = 'hex' ) { + if ( $color ) { + switch ( $type ) { + case 'hex': + $this->fromHex( $color ); + break; + case 'rgb': + if ( is_array( $color ) && count( $color ) == 3 ) { + list( $r, $g, $b ) = array_values( $color ); + $this->fromRgbInt( $r, $g, $b ); + } + break; + case 'hsl': + if ( is_array( $color ) && count( $color ) == 3 ) { + list( $h, $s, $l ) = array_values( $color ); + $this->fromHsl( $h, $s, $l ); + } + break; + case 'int': + $this->fromInt( $color ); + break; + default: + // there is no default. + break; + } + } + } + + /** + * Init color from hex value + * + * @param string $hexValue + * + * @return Jetpack_Color + */ + public function fromHex($hexValue) { + $hexValue = str_replace( '#', '', $hexValue ); + // handle short hex codes like #fff + if ( 3 === strlen( $hexValue ) ) { + $short = $hexValue; + $i = 0; + $hexValue = ''; + while ( $i < 3 ) { + $chunk = substr($short, $i, 1 ); + $hexValue .= $chunk . $chunk; + $i++; + } + } + $intValue = hexdec( $hexValue ); + + if ( $intValue < 0 || $intValue > 16777215 ) { + throw new RangeException( $hexValue . " out of valid color code range" ); + } + + $this->color = $intValue; + + return $this; + } + + /** + * Init color from integer RGB values + * + * @param int $red + * @param int $green + * @param int $blue + * + * @return Jetpack_Color + */ + public function fromRgbInt($red, $green, $blue) + { + if ( $red < 0 || $red > 255 ) + throw new RangeException( "Red value " . $red . " out of valid color code range" ); + + if ( $green < 0 || $green > 255 ) + throw new RangeException( "Green value " . $green . " out of valid color code range" ); + + if ( $blue < 0 || $blue > 255 ) + throw new RangeException( "Blue value " . $blue . " out of valid color code range" ); + + $this->color = (int)(($red << 16) + ($green << 8) + $blue); + + return $this; + } + + /** + * Init color from hex RGB values + * + * @param string $red + * @param string $green + * @param string $blue + * + * @return Jetpack_Color + */ + public function fromRgbHex($red, $green, $blue) + { + return $this->fromRgbInt(hexdec($red), hexdec($green), hexdec($blue)); + } + + /** + * Converts an HSL color value to RGB. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * @param int $h Hue. [0-360] + * @param in $s Saturation [0, 100] + * @param int $l Lightness [0, 100] + */ + public function fromHsl( $h, $s, $l ) { + $h /= 360; $s /= 100; $l /= 100; + + if ( $s == 0 ) { + $r = $g = $b = $l; // achromatic + } + else { + $q = $l < 0.5 ? $l * ( 1 + $s ) : $l + $s - $l * $s; + $p = 2 * $l - $q; + $r = $this->hue2rgb( $p, $q, $h + 1/3 ); + $g = $this->hue2rgb( $p, $q, $h ); + $b = $this->hue2rgb( $p, $q, $h - 1/3 ); + } + + return $this->fromRgbInt( $r * 255, $g * 255, $b * 255 ); + } + + /** + * Helper function for Jetpack_Color::fromHsl() + */ + private function hue2rgb( $p, $q, $t ) { + if ( $t < 0 ) $t += 1; + if ( $t > 1 ) $t -= 1; + if ( $t < 1/6 ) return $p + ( $q - $p ) * 6 * $t; + if ( $t < 1/2 ) return $q; + if ( $t < 2/3 ) return $p + ( $q - $p ) * ( 2/3 - $t ) * 6; + return $p; + } + + /** + * Init color from integer value + * + * @param int $intValue + * + * @return Jetpack_Color + */ + public function fromInt($intValue) + { + if ( $intValue < 0 || $intValue > 16777215 ) + throw new RangeException( $intValue . " out of valid color code range" ); + + $this->color = $intValue; + + return $this; + } + + /** + * Convert color to hex + * + * @return string + */ + public function toHex() + { + return str_pad(dechex($this->color), 6, '0', STR_PAD_LEFT); + } + + /** + * Convert color to RGB array (integer values) + * + * @return array + */ + public function toRgbInt() + { + return array( + 'red' => (int)(255 & ($this->color >> 16)), + 'green' => (int)(255 & ($this->color >> 8)), + 'blue' => (int)(255 & ($this->color)) + ); + } + + /** + * Convert color to RGB array (hex values) + * + * @return array + */ + public function toRgbHex() + { + $r = array(); + foreach ($this->toRgbInt() as $item) { + $r[] = dechex($item); + } + return $r; + } + + /** + * Get Hue/Saturation/Value for the current color + * (float values, slow but accurate) + * + * @return array + */ + public function toHsvFloat() + { + $rgb = $this->toRgbInt(); + + $rgbMin = min($rgb); + $rgbMax = max($rgb); + + $hsv = array( + 'hue' => 0, + 'sat' => 0, + 'val' => $rgbMax + ); + + // If v is 0, color is black + if ($hsv['val'] == 0) { + return $hsv; + } + + // Normalize RGB values to 1 + $rgb['red'] /= $hsv['val']; + $rgb['green'] /= $hsv['val']; + $rgb['blue'] /= $hsv['val']; + $rgbMin = min($rgb); + $rgbMax = max($rgb); + + // Calculate saturation + $hsv['sat'] = $rgbMax - $rgbMin; + if ($hsv['sat'] == 0) { + $hsv['hue'] = 0; + return $hsv; + } + + // Normalize saturation to 1 + $rgb['red'] = ($rgb['red'] - $rgbMin) / ($rgbMax - $rgbMin); + $rgb['green'] = ($rgb['green'] - $rgbMin) / ($rgbMax - $rgbMin); + $rgb['blue'] = ($rgb['blue'] - $rgbMin) / ($rgbMax - $rgbMin); + $rgbMin = min($rgb); + $rgbMax = max($rgb); + + // Calculate hue + if ($rgbMax == $rgb['red']) { + $hsv['hue'] = 0.0 + 60 * ($rgb['green'] - $rgb['blue']); + if ($hsv['hue'] < 0) { + $hsv['hue'] += 360; + } + } else if ($rgbMax == $rgb['green']) { + $hsv['hue'] = 120 + (60 * ($rgb['blue'] - $rgb['red'])); + } else { + $hsv['hue'] = 240 + (60 * ($rgb['red'] - $rgb['green'])); + } + + return $hsv; + } + + /** + * Get HSV values for color + * (integer values from 0-255, fast but less accurate) + * + * @return int + */ + public function toHsvInt() + { + $rgb = $this->toRgbInt(); + + $rgbMin = min($rgb); + $rgbMax = max($rgb); + + $hsv = array( + 'hue' => 0, + 'sat' => 0, + 'val' => $rgbMax + ); + + // If value is 0, color is black + if ($hsv['val'] == 0) { + return $hsv; + } + + // Calculate saturation + $hsv['sat'] = round(255 * ($rgbMax - $rgbMin) / $hsv['val']); + if ($hsv['sat'] == 0) { + $hsv['hue'] = 0; + return $hsv; + } + + // Calculate hue + if ($rgbMax == $rgb['red']) { + $hsv['hue'] = round(0 + 43 * ($rgb['green'] - $rgb['blue']) / ($rgbMax - $rgbMin)); + } else if ($rgbMax == $rgb['green']) { + $hsv['hue'] = round(85 + 43 * ($rgb['blue'] - $rgb['red']) / ($rgbMax - $rgbMin)); + } else { + $hsv['hue'] = round(171 + 43 * ($rgb['red'] - $rgb['green']) / ($rgbMax - $rgbMin)); + } + if ($hsv['hue'] < 0) { + $hsv['hue'] += 255; + } + + return $hsv; + } + + /** + * Converts an RGB color value to HSL. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes r, g, and b are contained in the set [0, 255] and + * returns h in [0, 360], s in [0, 100], l in [0, 100] + * + * @return Array The HSL representation + */ + public function toHsl() { + list( $r, $g, $b ) = array_values( $this->toRgbInt() ); + $r /= 255; $g /= 255; $b /= 255; + $max = max( $r, $g, $b ); + $min = min( $r, $g, $b ); + $h = $s = $l = ( $max + $min ) / 2; + #var_dump( array( compact('max', 'min', 'r', 'g', 'b')) ); + if ( $max == $min ) { + $h = $s = 0; // achromatic + } + else { + $d = $max - $min; + $s = $l > 0.5 ? $d / ( 2 - $max - $min ) : $d / ( $max + $min ); + switch ( $max ) { + case $r: + $h = ( $g - $b ) / $d + ( $g < $b ? 6 : 0 ); + break; + case $g: + $h = ( $b - $r ) / $d + 2; + break; + case $b: + $h = ( $r - $g ) / $d + 4; + break; + } + $h /= 6; + } + $h = (int) round( $h * 360 ); + $s = (int) round( $s * 100 ); + $l = (int) round( $l * 100 ); + return compact( 'h', 's', 'l' ); + } + + public function toCSS( $type = 'hex', $alpha = 1 ) { + switch ( $type ) { + case 'hex': + return $this->toString(); + break; + case 'rgb': + case 'rgba': + list( $r, $g, $b ) = array_values( $this->toRgbInt() ); + if ( is_numeric( $alpha ) && $alpha < 1 ) { + return "rgba( {$r}, {$g}, {$b}, $alpha )"; + } + else { + return "rgb( {$r}, {$g}, {$b} )"; + } + break; + case 'hsl': + case 'hsla': + list( $h, $s, $l ) = array_values( $this->toHsl() ); + if ( is_numeric( $alpha ) && $alpha < 1 ) { + return "hsla( {$h}, {$s}, {$l}, $alpha )"; + } + else { + return "hsl( {$h}, {$s}, {$l} )"; + } + break; + default: + return $this->toString(); + break; + } + } + + /** + * Get current color in XYZ format + * + * @return array + */ + public function toXyz() + { + $rgb = $this->toRgbInt(); + + // Normalize RGB values to 1 + + $rgb_new = array(); + foreach ($rgb as $item) { + $rgb_new[] = $item / 255; + } + $rgb = $rgb_new; + + $rgb_new = array(); + foreach ($rgb as $item) { + if ($item > 0.04045) { + $item = pow((($item + 0.055) / 1.055), 2.4); + } else { + $item = $item / 12.92; + } + $rgb_new[] = $item * 100; + } + $rgb = $rgb_new; + + // Observer. = 2°, Illuminant = D65 + $xyz = array( + 'x' => ($rgb['red'] * 0.4124) + ($rgb['green'] * 0.3576) + ($rgb['blue'] * 0.1805), + 'y' => ($rgb['red'] * 0.2126) + ($rgb['green'] * 0.7152) + ($rgb['blue'] * 0.0722), + 'z' => ($rgb['red'] * 0.0193) + ($rgb['green'] * 0.1192) + ($rgb['blue'] * 0.9505) + ); + + return $xyz; + } + + /** + * Get color CIE-Lab values + * + * @return array + */ + public function toLabCie() + { + $xyz = $this->toXyz(); + + //Ovserver = 2*, Iluminant=D65 + $xyz['x'] /= 95.047; + $xyz['y'] /= 100; + $xyz['z'] /= 108.883; + + $xyz_new = array(); + foreach ($xyz as $item) { + if ($item > 0.008856) { + $xyz_new[] = pow($item, 1/3); + } else { + $xyz_new[] = (7.787 * $item) + (16 / 116); + } + } + $xyz = $xyz_new; + + $lab = array( + 'l' => (116 * $xyz['y']) - 16, + 'a' => 500 * ($xyz['x'] - $xyz['y']), + 'b' => 200 * ($xyz['y'] - $xyz['z']) + ); + + return $lab; + } + + /** + * Convert color to integer + * + * @return int + */ + public function toInt() + { + return $this->color; + } + + /** + * Alias of toString() + * + * @return string + */ + public function __toString() + { + return $this->toString(); + } + + /** + * Get color as string + * + * @return string + */ + public function toString() + { + $str = $this->toHex(); + return strtoupper("#{$str}"); + } + + /** + * Get the distance between this color and the given color + * + * @param Jetpack_Color $color + * + * @return int + */ + public function getDistanceRgbFrom(Jetpack_Color $color) + { + $rgb1 = $this->toRgbInt(); + $rgb2 = $color->toRgbInt(); + + $rDiff = abs($rgb1['red'] - $rgb2['red']); + $gDiff = abs($rgb1['green'] - $rgb2['green']); + $bDiff = abs($rgb1['blue'] - $rgb2['blue']); + + // Sum of RGB differences + $diff = $rDiff + $gDiff + $bDiff; + return $diff; + } + + /** + * Get distance from the given color using the Delta E method + * + * @param Jetpack_Color $color + * + * @return float + */ + public function getDistanceLabFrom(Jetpack_Color $color) + { + $lab1 = $this->toLabCie(); + $lab2 = $color->toLabCie(); + + $lDiff = abs($lab2['l'] - $lab1['l']); + $aDiff = abs($lab2['a'] - $lab1['a']); + $bDiff = abs($lab2['b'] - $lab1['b']); + + $delta = sqrt($lDiff + $aDiff + $bDiff); + + return $delta; + } + + public function toLuminosity() { + $lum = array(); + foreach( $this->toRgbInt() as $slot => $value ) { + $chan = $value / 255; + $lum[ $slot ] = ( $chan <= 0.03928 ) ? $chan / 12.92 : pow( ( ( $chan + 0.055 ) / 1.055 ), 2.4 ); + } + return 0.2126 * $lum['red'] + 0.7152 * $lum['green'] + 0.0722 * $lum['blue']; + } + + /** + * Get distance between colors using luminance. + * Should be more than 5 for readable contrast + * + * @param Jetpack_Color $color Another color + * @return float + */ + public function getDistanceLuminosityFrom( Jetpack_Color $color ) { + $L1 = $this->toLuminosity(); + $L2 = $color->toLuminosity(); + if ( $L1 > $L2 ) { + return ( $L1 + 0.05 ) / ( $L2 + 0.05 ); + } + else{ + return ( $L2 + 0.05 ) / ( $L1 + 0.05 ); + } + } + + public function getMaxContrastColor() { + $withBlack = $this->getDistanceLuminosityFrom( new Jetpack_Color( '#000') ); + $withWhite = $this->getDistanceLuminosityFrom( new Jetpack_Color( '#fff') ); + $color = new Jetpack_Color; + $hex = ( $withBlack >= $withWhite ) ? '#000000' : '#ffffff'; + return $color->fromHex( $hex ); + } + + public function getGrayscaleContrastingColor( $contrast = false ) { + if ( ! $contrast ) { + return $this->getMaxContrastColor(); + } + // don't allow less than 5 + $target_contrast = ( $contrast < 5 ) ? 5 : $contrast; + $color = $this->getMaxContrastColor(); + $contrast = $color->getDistanceLuminosityFrom( $this ); + + // if current max contrast is less than the target contrast, we had wishful thinking. + if ( $contrast <= $target_contrast ) { + return $color; + } + + $incr = ( '#000000' === $color->toString() ) ? 1 : -1; + while ( $contrast > $target_contrast ) { + $color = $color->incrementLightness( $incr ); + $contrast = $color->getDistanceLuminosityFrom( $this ); + } + + return $color; + } + + /** + * Gets a readable contrasting color. $this is assumed to be the text and $color the background color. + * @param object $bg_color A Color object that will be compared against $this + * @param integer $min_contrast The minimum contrast to achieve, if possible. + * @return object A Color object, an increased contrast $this compared against $bg_color + */ + public function getReadableContrastingColor( $bg_color = false, $min_contrast = 5 ) { + if ( ! $bg_color || ! is_a( $bg_color, 'Jetpack_Color' ) ) { + return $this; + } + // you shouldn't use less than 5, but you might want to. + $target_contrast = $min_contrast; + // working things + $contrast = $bg_color->getDistanceLuminosityFrom( $this ); + $max_contrast_color = $bg_color->getMaxContrastColor(); + $max_contrast = $max_contrast_color->getDistanceLuminosityFrom( $bg_color ); + + // if current max contrast is less than the target contrast, we had wishful thinking. + // still, go max + if ( $max_contrast <= $target_contrast ) { + return $max_contrast_color; + } + // or, we might already have sufficient contrast + if ( $contrast >= $target_contrast ) { + return $this; + } + + $incr = ( 0 === $max_contrast_color->toInt() ) ? -1 : 1; + while ( $contrast < $target_contrast ) { + $this->incrementLightness( $incr ); + $contrast = $bg_color->getDistanceLuminosityFrom( $this ); + // infininite loop prevention: you never know. + if ( $this->color === 0 || $this->color === 16777215 ) { + break; + } + } + + return $this; + } + + /** + * Detect if color is grayscale + * + * @param int @threshold + * + * @return bool + */ + public function isGrayscale($threshold = 16) + { + $rgb = $this->toRgbInt(); + + // Get min and max rgb values, then difference between them + $rgbMin = min($rgb); + $rgbMax = max($rgb); + $diff = $rgbMax - $rgbMin; + + return $diff < $threshold; + } + + /** + * Get the closest matching color from the given array of colors + * + * @param array $colors array of integers or Jetpack_Color objects + * + * @return mixed the array key of the matched color + */ + public function getClosestMatch(array $colors) + { + $matchDist = 10000; + $matchKey = null; + foreach($colors as $key => $color) { + if (false === ($color instanceof Jetpack_Color)) { + $c = new Jetpack_Color($color); + } + $dist = $this->getDistanceLabFrom($c); + if ($dist < $matchDist) { + $matchDist = $dist; + $matchKey = $key; + } + } + + return $matchKey; + } + + /* TRANSFORMS */ + + public function darken( $amount = 5 ) { + return $this->incrementLightness( - $amount ); + } + + public function lighten( $amount = 5 ) { + return $this->incrementLightness( $amount ); + } + + public function incrementLightness( $amount ) { + $hsl = $this->toHsl(); + extract( $hsl ); + $l += $amount; + if ( $l < 0 ) $l = 0; + if ( $l > 100 ) $l = 100; + return $this->fromHsl( $h, $s, $l ); + } + + public function saturate( $amount = 15 ) { + return $this->incrementSaturation( $amount ); + } + + public function desaturate( $amount = 15 ) { + return $this->incrementSaturation( - $amount ); + } + + public function incrementSaturation( $amount ) { + $hsl = $this->toHsl(); + extract( $hsl ); + $s += $amount; + if ( $s < 0 ) $s = 0; + if ( $s > 100 ) $s = 100; + return $this->fromHsl( $h, $s, $l ); + } + + public function toGrayscale() { + $hsl = $this->toHsl(); + extract( $hsl ); + $s = 0; + return $this->fromHsl( $h, $s, $l ); + } + + public function getComplement() { + return $this->incrementHue( 180 ); + } + + public function getSplitComplement( $step = 1 ) { + $incr = 180 + ( $step * 30 ); + return $this->incrementHue( $incr ); + } + + public function getAnalog( $step = 1 ) { + $incr = $step * 30; + return $this->incrementHue( $incr ); + } + + public function getTetrad( $step = 1 ) { + $incr = $step * 60; + return $this->incrementHue( $incr ); + } + + public function getTriad( $step = 1 ) { + $incr = $step * 120; + return $this->incrementHue( $incr ); + } + + public function incrementHue( $amount ) { + $hsl = $this->toHsl(); + extract( $hsl ); + $h = ( $h + $amount ) % 360; + if ( $h < 0 ) $h = 360 - $h; + return $this->fromHsl( $h, $s, $l ); + } + +} // class Jetpack_Color diff --git a/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php b/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php new file mode 100644 index 00000000..a0f0bf44 --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php @@ -0,0 +1,3196 @@ +<?php +/** + * Register WP REST API endpoints for Jetpack. + * + * @author Automattic + */ + +/** + * Disable direct access. + */ +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +// Load WP_Error for error messages. +require_once ABSPATH . '/wp-includes/class-wp-error.php'; + +// Register endpoints when WP REST API is initialized. +add_action( 'rest_api_init', array( 'Jetpack_Core_Json_Api_Endpoints', 'register_endpoints' ) ); +// Load API endpoints that are synced with WP.com +// Each of these is a class that will register its own routes on 'rest_api_init'. +require_once JETPACK__PLUGIN_DIR . '_inc/lib/core-api/load-wpcom-endpoints.php'; + +/** + * Class Jetpack_Core_Json_Api_Endpoints + * + * @since 4.3.0 + */ +class Jetpack_Core_Json_Api_Endpoints { + + /** + * @var string Generic error message when user is not allowed to perform an action. + */ + public static $user_permissions_error_msg; + + /** + * @var array Roles that can access Stats once they're granted access. + */ + public static $stats_roles; + + /** + * Declare the Jetpack REST API endpoints. + * + * @since 4.3.0 + */ + public static function register_endpoints() { + + // Load API endpoint base classes + require_once JETPACK__PLUGIN_DIR . '_inc/lib/core-api/class.jetpack-core-api-xmlrpc-consumer-endpoint.php'; + + // Load API endpoints + require_once JETPACK__PLUGIN_DIR . '_inc/lib/core-api/class.jetpack-core-api-module-endpoints.php'; + require_once JETPACK__PLUGIN_DIR . '_inc/lib/core-api/class.jetpack-core-api-site-endpoints.php'; + require_once JETPACK__PLUGIN_DIR . '_inc/lib/core-api/class.jetpack-core-api-widgets-endpoints.php'; + + self::$user_permissions_error_msg = esc_html__( + 'You do not have the correct user permissions to perform this action. + Please contact your site admin if you think this is a mistake.', + 'jetpack' + ); + + self::$stats_roles = array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ); + + Jetpack::load_xml_rpc_client(); + $ixr_client = new Jetpack_IXR_Client( array( 'user_id' => get_current_user_id() ) ); + $core_api_endpoint = new Jetpack_Core_API_Data( $ixr_client ); + $module_list_endpoint = new Jetpack_Core_API_Module_List_Endpoint(); + $module_data_endpoint = new Jetpack_Core_API_Module_Data_Endpoint(); + $module_toggle_endpoint = new Jetpack_Core_API_Module_Toggle_Endpoint( new Jetpack_IXR_Client() ); + $site_endpoint = new Jetpack_Core_API_Site_Endpoint(); + $widget_endpoint = new Jetpack_Core_API_Widget_Endpoint(); + + register_rest_route( 'jetpack/v4', 'plans', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::get_plans', + 'permission_callback' => __CLASS__ . '::connect_url_permission_callback', + + ) ); + + register_rest_route( 'jetpack/v4', '/jitm', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::get_jitm_message', + ) ); + + register_rest_route( 'jetpack/v4', '/jitm', array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => __CLASS__ . '::delete_jitm_message' + ) ); + + // Register a site + register_rest_route( 'jetpack/v4', '/verify_registration', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::verify_registration', + ) ); + + // Authorize a remote user + register_rest_route( 'jetpack/v4', '/remote_authorize', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::remote_authorize', + ) ); + + // Get current connection status of Jetpack + register_rest_route( 'jetpack/v4', '/connection', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::jetpack_connection_status', + ) ); + + // Test current connection status of Jetpack + register_rest_route( 'jetpack/v4', '/connection/test', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::jetpack_connection_test', + 'permission_callback' => __CLASS__ . '::manage_modules_permission_check', + ) ); + + // Endpoint specific for privileged servers to request detailed debug information. + register_rest_route( 'jetpack/v4', '/connection/test-wpcom/', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::jetpack_connection_test_for_external', + 'permission_callback' => __CLASS__ . '::view_jetpack_connection_test_check', + ) ); + + register_rest_route( 'jetpack/v4', '/rewind', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::get_rewind_data', + 'permission_callback' => __CLASS__ . '::view_admin_page_permission_check', + ) ); + + // Fetches a fresh connect URL + register_rest_route( 'jetpack/v4', '/connection/url', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::build_connect_url', + 'permission_callback' => __CLASS__ . '::connect_url_permission_callback', + ) ); + + // Get current user connection data + register_rest_route( 'jetpack/v4', '/connection/data', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::get_user_connection_data', + 'permission_callback' => __CLASS__ . '::get_user_connection_data_permission_callback', + ) ); + + // Set the connection owner + register_rest_route( 'jetpack/v4', '/connection/owner', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::set_connection_owner', + 'permission_callback' => __CLASS__ . '::set_connection_owner_permission_callback', + ) ); + + // Current user: get or set tracking settings. + register_rest_route( 'jetpack/v4', '/tracking/settings', array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::get_user_tracking_settings', + 'permission_callback' => __CLASS__ . '::view_admin_page_permission_check', + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::update_user_tracking_settings', + 'permission_callback' => __CLASS__ . '::view_admin_page_permission_check', + 'args' => array( + 'tracks_opt_out' => array( 'type' => 'boolean' ), + ), + ), + ) ); + + // Disconnect site from WordPress.com servers + register_rest_route( 'jetpack/v4', '/connection', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::disconnect_site', + 'permission_callback' => __CLASS__ . '::disconnect_site_permission_callback', + ) ); + + // Disconnect/unlink user from WordPress.com servers + register_rest_route( 'jetpack/v4', '/connection/user', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::unlink_user', + 'permission_callback' => __CLASS__ . '::unlink_user_permission_callback', + ) ); + + // Get current site data + register_rest_route( 'jetpack/v4', '/site', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::get_site_data', + 'permission_callback' => __CLASS__ . '::view_admin_page_permission_check', + ) ); + + // Get current site data + register_rest_route( 'jetpack/v4', '/site/features', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $site_endpoint, 'get_features' ), + 'permission_callback' => array( $site_endpoint , 'can_request' ), + ) ); + + // Confirm that a site in identity crisis should be in staging mode + register_rest_route( 'jetpack/v4', '/identity-crisis/confirm-safe-mode', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::confirm_safe_mode', + 'permission_callback' => __CLASS__ . '::identity_crisis_mitigation_permission_check', + ) ); + + // IDC resolve: create an entirely new shadow site for this URL. + register_rest_route( 'jetpack/v4', '/identity-crisis/start-fresh', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::start_fresh_connection', + 'permission_callback' => __CLASS__ . '::identity_crisis_mitigation_permission_check', + ) ); + + // Handles the request to migrate stats and subscribers during an identity crisis. + register_rest_route( 'jetpack/v4', 'identity-crisis/migrate', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::migrate_stats_and_subscribers', + 'permissison_callback' => __CLASS__ . '::identity_crisis_mitigation_permission_check', + ) ); + + // Return all modules + register_rest_route( 'jetpack/v4', '/module/all', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $module_list_endpoint, 'process' ), + 'permission_callback' => array( $module_list_endpoint, 'can_request' ), + ) ); + + // Activate many modules + register_rest_route( 'jetpack/v4', '/module/all/active', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $module_list_endpoint, 'process' ), + 'permission_callback' => array( $module_list_endpoint, 'can_request' ), + 'args' => array( + 'modules' => array( + 'default' => '', + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + 'required' => true, + 'validate_callback' => __CLASS__ . '::validate_module_list', + ), + 'active' => array( + 'default' => true, + 'type' => 'boolean', + 'required' => false, + 'validate_callback' => __CLASS__ . '::validate_boolean', + ), + ) + ) ); + + // Return a single module and update it when needed + register_rest_route( 'jetpack/v4', '/module/(?P<slug>[a-z\-]+)', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $core_api_endpoint, 'process' ), + 'permission_callback' => array( $core_api_endpoint, 'can_request' ), + ) ); + + // Activate and deactivate a module + register_rest_route( 'jetpack/v4', '/module/(?P<slug>[a-z\-]+)/active', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $module_toggle_endpoint, 'process' ), + 'permission_callback' => array( $module_toggle_endpoint, 'can_request' ), + 'args' => array( + 'active' => array( + 'default' => true, + 'type' => 'boolean', + 'required' => true, + 'validate_callback' => __CLASS__ . '::validate_boolean', + ), + ) + ) ); + + // Update a module + register_rest_route( 'jetpack/v4', '/module/(?P<slug>[a-z\-]+)', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $core_api_endpoint, 'process' ), + 'permission_callback' => array( $core_api_endpoint, 'can_request' ), + 'args' => self::get_updateable_parameters( 'any' ) + ) ); + + // Get data for a specific module, i.e. Protect block count, WPCOM stats, + // Akismet spam count, etc. + register_rest_route( 'jetpack/v4', '/module/(?P<slug>[a-z\-]+)/data', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $module_data_endpoint, 'process' ), + 'permission_callback' => array( $module_data_endpoint, 'can_request' ), + 'args' => array( + 'range' => array( + 'default' => 'day', + 'type' => 'string', + 'required' => false, + 'validate_callback' => __CLASS__ . '::validate_string', + ), + ) + ) ); + + // Check if the API key for a specific service is valid or not + register_rest_route( 'jetpack/v4', '/module/(?P<service>[a-z\-]+)/key/check', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $module_data_endpoint, 'key_check' ), + 'permission_callback' => __CLASS__ . '::update_settings_permission_check', + 'sanitize_callback' => 'sanitize_text_field', + ) ); + + register_rest_route( 'jetpack/v4', '/module/(?P<service>[a-z\-]+)/key/check', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $module_data_endpoint, 'key_check' ), + 'permission_callback' => __CLASS__ . '::update_settings_permission_check', + 'sanitize_callback' => 'sanitize_text_field', + 'args' => array( + 'api_key' => array( + 'default' => '', + 'type' => 'string', + 'validate_callback' => __CLASS__ . '::validate_alphanum', + ), + ) + ) ); + + // Update any Jetpack module option or setting + register_rest_route( 'jetpack/v4', '/settings', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $core_api_endpoint, 'process' ), + 'permission_callback' => array( $core_api_endpoint, 'can_request' ), + 'args' => self::get_updateable_parameters( 'any' ) + ) ); + + // Update a module + register_rest_route( 'jetpack/v4', '/settings/(?P<slug>[a-z\-]+)', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $core_api_endpoint, 'process' ), + 'permission_callback' => array( $core_api_endpoint, 'can_request' ), + 'args' => self::get_updateable_parameters() + ) ); + + // Return all module settings + register_rest_route( 'jetpack/v4', '/settings/', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $core_api_endpoint, 'process' ), + 'permission_callback' => array( $core_api_endpoint, 'can_request' ), + ) ); + + // Reset all Jetpack options + register_rest_route( 'jetpack/v4', '/options/(?P<options>[a-z\-]+)', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::reset_jetpack_options', + 'permission_callback' => __CLASS__ . '::manage_modules_permission_check', + ) ); + + // Return current Jumpstart status + register_rest_route( 'jetpack/v4', '/jumpstart', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::jumpstart_status', + 'permission_callback' => __CLASS__ . '::update_settings_permission_check', + ) ); + + // Update Jumpstart + register_rest_route( 'jetpack/v4', '/jumpstart', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::jumpstart_toggle', + 'permission_callback' => __CLASS__ . '::manage_modules_permission_check', + 'args' => array( + 'active' => array( + 'required' => true, + 'validate_callback' => __CLASS__ . '::validate_boolean', + ), + ), + ) ); + + // Updates: get number of plugin updates available + register_rest_route( 'jetpack/v4', '/updates/plugins', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::get_plugin_update_count', + 'permission_callback' => __CLASS__ . '::view_admin_page_permission_check', + ) ); + + // Dismiss Jetpack Notices + register_rest_route( 'jetpack/v4', '/notice/(?P<notice>[a-z\-_]+)', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::dismiss_notice', + 'permission_callback' => __CLASS__ . '::view_admin_page_permission_check', + ) ); + + // Plugins: get list of all plugins. + register_rest_route( 'jetpack/v4', '/plugins', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::get_plugins', + 'permission_callback' => __CLASS__ . '::activate_plugins_permission_check', + ) ); + + // Plugins: check if the plugin is active. + register_rest_route( 'jetpack/v4', '/plugin/(?P<plugin>[a-z\/\.\-_]+)', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::get_plugin', + 'permission_callback' => __CLASS__ . '::activate_plugins_permission_check', + ) ); + + // Widgets: get information about a widget that supports it. + register_rest_route( 'jetpack/v4', '/widgets/(?P<id>[0-9a-z\-_]+)', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $widget_endpoint, 'process' ), + 'permission_callback' => array( $widget_endpoint, 'can_request' ), + ) ); + + // Site Verify: check if the site is verified, and a get verification token if not + register_rest_route( 'jetpack/v4', '/verify-site/(?P<service>[a-z\-_]+)', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::is_site_verified_and_token', + 'permission_callback' => __CLASS__ . '::update_settings_permission_check', + ) ); + + register_rest_route( 'jetpack/v4', '/verify-site/(?P<service>[a-z\-_]+)/(?<keyring_id>[0-9]+)', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::is_site_verified_and_token', + 'permission_callback' => __CLASS__ . '::update_settings_permission_check', + ) ); + + // Site Verify: tell a service to verify the site + register_rest_route( 'jetpack/v4', '/verify-site/(?P<service>[a-z\-_]+)', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::verify_site', + 'permission_callback' => __CLASS__ . '::update_settings_permission_check', + 'args' => array( + 'keyring_id' => array( + 'required' => true, + 'type' => 'integer', + 'validate_callback' => __CLASS__ . '::validate_posint', + ), + ) + ) ); + + // Get and set API keys. + // Note: permission_callback intentionally omitted from the GET method. + // Map block requires open access to API keys on the front end. + register_rest_route( + 'jetpack/v4', + '/service-api-keys/(?P<service>[a-z\-_]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::get_service_api_key', + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::update_service_api_key', + 'permission_callback' => array( 'WPCOM_REST_API_V2_Endpoint_Service_API_Keys','edit_others_posts_check' ), + 'args' => array( + 'service_api_key' => array( + 'required' => true, + 'type' => 'text', + ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => __CLASS__ . '::delete_service_api_key', + 'permission_callback' => array( 'WPCOM_REST_API_V2_Endpoint_Service_API_Keys','edit_others_posts_check' ), + ), + ) + ); + } + + public static function get_plans( $request ) { + $request = Jetpack_Client::wpcom_json_api_request_as_user( + '/plans?_locale=' . get_user_locale(), + '2', + array( + 'method' => 'GET', + 'headers' => array( + 'X-Forwarded-For' => Jetpack::current_user_ip( true ), + ), + ) + ); + + $body = wp_remote_retrieve_body( $request ); + if ( 200 === wp_remote_retrieve_response_code( $request ) ) { + $data = $body; + } else { + // something went wrong so we'll just return the response without caching + return $body; + } + + return $data; + } + + /** + * Asks for a jitm, unless they've been disabled, in which case it returns an empty array + * + * @param $request WP_REST_Request + * + * @return array An array of jitms + */ + public static function get_jitm_message( $request ) { + require_once( JETPACK__PLUGIN_DIR . 'class.jetpack-jitm.php' ); + + $jitm = Jetpack_JITM::init(); + + if ( ! $jitm ) { + return array(); + } + + return $jitm->get_messages( $request['message_path'], urldecode_deep( $request['query'] ) ); + } + + /** + * Dismisses a jitm + * @param $request WP_REST_Request The request + * + * @return bool Always True + */ + public static function delete_jitm_message( $request ) { + require_once( JETPACK__PLUGIN_DIR . 'class.jetpack-jitm.php' ); + + $jitm = Jetpack_JITM::init(); + + if ( ! $jitm ) { + return true; + } + + return $jitm->dismiss( $request['id'], $request['feature_class'] ); + } + + /** + * Handles verification that a site is registered + * + * @since 5.4.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return array|wp-error + */ + public static function verify_registration( $request ) { + require_once JETPACK__PLUGIN_DIR . 'class.jetpack-xmlrpc-server.php'; + $xmlrpc_server = new Jetpack_XMLRPC_Server(); + $result = $xmlrpc_server->verify_registration( array( $request['secret_1'], $request['state'] ) ); + + if ( is_a( $result, 'IXR_Error' ) ) { + $result = new WP_Error( $result->code, $result->message ); + } + + return $result; + } + + + /** + * Checks if this site has been verified using a service - only 'google' supported at present - and a specfic + * keyring to use to get the token if it is not + * + * Returns 'verified' = true/false, and a token if 'verified' is false and site is ready for verification + * + * @since 6.6.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return array|wp-error + */ + public static function is_site_verified_and_token( $request ) { + /** + * Return an error if the site uses a Maintenance / Coming Soon plugin + * and if the plugin is configured to make the site private. + * + * We currently handle the following plugins: + * - https://github.com/mojoness/mojo-marketplace-wp-plugin (used by bluehost) + * - https://wordpress.org/plugins/mojo-under-construction + * - https://wordpress.org/plugins/under-construction-page + * - https://wordpress.org/plugins/ultimate-under-construction + * - https://wordpress.org/plugins/coming-soon + * + * You can handle this in your own plugin thanks to the `jetpack_is_under_construction_plugin` filter. + * If the filter returns true, we will consider the site as under construction. + */ + $mm_coming_soon = get_option( 'mm_coming_soon', null ); + $under_construction_activation_status = get_option( 'underConstructionActivationStatus', null ); + $ucp_options = get_option( 'ucp_options', array() ); + $uuc_settings = get_option( 'uuc_settings', array() ); + $csp4 = get_option( 'seed_csp4_settings_content', array() ); + if ( + ( Jetpack::is_plugin_active( 'mojo-marketplace-wp-plugin/mojo-marketplace.php' ) && 'true' === $mm_coming_soon ) + || Jetpack::is_plugin_active( 'mojo-under-construction/mojo-contruction.php' ) && 1 == $under_construction_activation_status // WPCS: loose comparison ok. + || ( Jetpack::is_plugin_active( 'under-construction-page/under-construction.php' ) && isset( $ucp_options['status'] ) && 1 == $ucp_options['status'] ) // WPCS: loose comparison ok. + || ( Jetpack::is_plugin_active( 'ultimate-under-construction/ultimate-under-construction.php' ) && isset( $uuc_settings['enable'] ) && 1 == $uuc_settings['enable'] ) // WPCS: loose comparison ok. + || ( Jetpack::is_plugin_active( 'coming-soon/coming-soon.php' ) && isset( $csp4['status'] ) && ( 1 == $csp4['status'] || 2 == $csp4['status'] ) ) // WPCS: loose comparison ok. + /** + * Allow plugins to mark a site as "under construction". + * + * @since 6.7.0 + * + * @param false bool Is the site under construction? Default to false. + */ + || true === apply_filters( 'jetpack_is_under_construction_plugin', false ) + ) { + return new WP_Error( 'forbidden', __( 'Site is under construction and cannot be verified', 'jetpack' ) ); + } + + Jetpack::load_xml_rpc_client(); + $xml = new Jetpack_IXR_Client( array( + 'user_id' => get_current_user_id(), + ) ); + + $args = array( + 'user_id' => get_current_user_id(), + 'service' => $request[ 'service' ], + ); + + if ( isset( $request[ 'keyring_id' ] ) ) { + $args[ 'keyring_id' ] = $request[ 'keyring_id' ]; + } + + $xml->query( 'jetpack.isSiteVerified', $args ); + + if ( $xml->isError() ) { + return new WP_Error( 'error_checking_if_site_verified_google', sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() ) ); + } else { + return $xml->getResponse(); + } + } + + + + public static function verify_site( $request ) { + Jetpack::load_xml_rpc_client(); + $xml = new Jetpack_IXR_Client( array( + 'user_id' => get_current_user_id(), + ) ); + + $params = $request->get_json_params(); + + $xml->query( 'jetpack.verifySite', array( + 'user_id' => get_current_user_id(), + 'service' => $request[ 'service' ], + 'keyring_id' => $params[ 'keyring_id' ], + ) + ); + + if ( $xml->isError() ) { + return new WP_Error( 'error_verifying_site_google', sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() ) ); + } else { + $response = $xml->getResponse(); + + if ( ! empty( $response['errors'] ) ) { + $error = new WP_Error; + $error->errors = $response['errors']; + return $error; + } + + return $response; + } + } + + /** + * Handles verification that a site is registered + * + * @since 5.4.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return array|wp-error + */ + public static function remote_authorize( $request ) { + require_once JETPACK__PLUGIN_DIR . 'class.jetpack-xmlrpc-server.php'; + $xmlrpc_server = new Jetpack_XMLRPC_Server(); + $result = $xmlrpc_server->remote_authorize( $request ); + + if ( is_a( $result, 'IXR_Error' ) ) { + $result = new WP_Error( $result->code, $result->message ); + } + + return $result; + } + + /** + * Handles dismissing of Jetpack Notices + * + * @since 4.3.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return array|wp-error + */ + public static function dismiss_notice( $request ) { + $notice = $request['notice']; + + if ( ! isset( $request['dismissed'] ) || $request['dismissed'] !== true ) { + return new WP_Error( 'invalid_param', esc_html__( 'Invalid parameter "dismissed".', 'jetpack' ), array( 'status' => 404 ) ); + } + + if ( isset( $notice ) && ! empty( $notice ) ) { + switch( $notice ) { + case 'feedback_dash_request': + case 'welcome': + $notices = get_option( 'jetpack_dismissed_notices', array() ); + $notices[ $notice ] = true; + update_option( 'jetpack_dismissed_notices', $notices ); + return rest_ensure_response( get_option( 'jetpack_dismissed_notices', array() ) ); + + default: + return new WP_Error( 'invalid_param', esc_html__( 'Invalid parameter "notice".', 'jetpack' ), array( 'status' => 404 ) ); + } + } + + return new WP_Error( 'required_param', esc_html__( 'Missing parameter "notice".', 'jetpack' ), array( 'status' => 404 ) ); + } + + /** + * Verify that the user can disconnect the site. + * + * @since 4.3.0 + * + * @return bool|WP_Error True if user is able to disconnect the site. + */ + public static function disconnect_site_permission_callback() { + if ( current_user_can( 'jetpack_disconnect' ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_jetpack_disconnect', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + + } + + /** + * Verify that the user can get a connect/link URL + * + * @since 4.3.0 + * + * @return bool|WP_Error True if user is able to disconnect the site. + */ + public static function connect_url_permission_callback() { + if ( current_user_can( 'jetpack_connect_user' ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_jetpack_disconnect', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + + } + + /** + * Verify that a user can get the data about the current user. + * Only those who can connect. + * + * @since 4.3.0 + * + * @uses Jetpack::is_user_connected(); + * + * @return bool|WP_Error True if user is able to unlink. + */ + public static function get_user_connection_data_permission_callback() { + if ( current_user_can( 'jetpack_connect_user' ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_user_connection_data', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + } + + /** + * Check that user has permission to change the master user. + * + * @since 6.2.0 + * + * @return bool|WP_Error True if user is able to change master user. + */ + public static function set_connection_owner_permission_callback() { + if ( get_current_user_id() === Jetpack_Options::get_option( 'master_user' ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_set_connection_owner', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + } + + /** + * Verify that a user can use the /connection/user endpoint. Has to be a registered user and be currently linked. + * + * @since 4.3.0 + * + * @uses Jetpack::is_user_connected(); + * + * @return bool|WP_Error True if user is able to unlink. + */ + public static function unlink_user_permission_callback() { + if ( current_user_can( 'jetpack_connect_user' ) && Jetpack::is_user_connected( get_current_user_id() ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_unlink_user', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + } + + /** + * Verify that user can manage Jetpack modules. + * + * @since 4.3.0 + * + * @return bool Whether user has the capability 'jetpack_manage_modules'. + */ + public static function manage_modules_permission_check() { + if ( current_user_can( 'jetpack_manage_modules' ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_manage_modules', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + } + + /** + * Verify that user can update Jetpack modules. + * + * @since 4.3.0 + * + * @return bool Whether user has the capability 'jetpack_configure_modules'. + */ + public static function configure_modules_permission_check() { + if ( current_user_can( 'jetpack_configure_modules' ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_configure_modules', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + } + + /** + * Verify that user can view Jetpack admin page. + * + * @since 4.3.0 + * + * @return bool Whether user has the capability 'jetpack_admin_page'. + */ + public static function view_admin_page_permission_check() { + if ( current_user_can( 'jetpack_admin_page' ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_view_admin', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + } + + /** + * Verify that user can mitigate an identity crisis. + * + * @since 4.4.0 + * + * @return bool Whether user has capability 'jetpack_disconnect'. + */ + public static function identity_crisis_mitigation_permission_check() { + if ( current_user_can( 'jetpack_disconnect' ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_identity_crisis', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + } + + /** + * Verify that user can update Jetpack general settings. + * + * @since 4.3.0 + * + * @return bool Whether user has the capability 'update_settings_permission_check'. + */ + public static function update_settings_permission_check() { + if ( current_user_can( 'jetpack_configure_modules' ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_manage_settings', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + } + + /** + * Verify that user can view Jetpack admin page and can activate plugins. + * + * @since 4.3.0 + * + * @return bool Whether user has the capability 'jetpack_admin_page' and 'activate_plugins'. + */ + public static function activate_plugins_permission_check() { + if ( current_user_can( 'jetpack_admin_page' ) && current_user_can( 'activate_plugins' ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_activate_plugins', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + } + + /** + * Verify that user can edit other's posts (Editors and Administrators). + * + * @return bool Whether user has the capability 'edit_others_posts'. + */ + public static function edit_others_posts_check() { + if ( current_user_can( 'edit_others_posts' ) ) { + return true; + } + + return new WP_Error( 'invalid_user_permission_edit_others_posts', self::$user_permissions_error_msg, array( 'status' => self::rest_authorization_required_code() ) ); + } + + /** + * Contextual HTTP error code for authorization failure. + * + * Taken from rest_authorization_required_code() in WP-API plugin until is added to core. + * @see https://github.com/WP-API/WP-API/commit/7ba0ae6fe4f605d5ffe4ee85b1cd5f9fb46900a6 + * + * @since 4.3.0 + * + * @return int + */ + public static function rest_authorization_required_code() { + return is_user_logged_in() ? 403 : 401; + } + + /** + * Get connection status for this Jetpack site. + * + * @since 4.3.0 + * + * @return bool True if site is connected + */ + public static function jetpack_connection_status() { + return rest_ensure_response( array( + 'isActive' => Jetpack::is_active(), + 'isStaging' => Jetpack::is_staging_site(), + 'devMode' => array( + 'isActive' => Jetpack::is_development_mode(), + 'constant' => defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG, + 'url' => site_url() && false === strpos( site_url(), '.' ), + 'filter' => apply_filters( 'jetpack_development_mode', false ), + ), + ) + ); + } + + /** + * Test connection status for this Jetpack site. + * + * @since 6.8.0 + * + * @return array|WP_Error WP_Error returned if connection test does not succeed. + */ + public static function jetpack_connection_test() { + jetpack_require_lib( 'debugger' ); + $cxntests = new Jetpack_Cxn_Tests(); + + if ( $cxntests->pass() ) { + return rest_ensure_response( + array( + 'code' => 'success', + 'message' => __( 'All connection tests passed.', 'jetpack' ), + ) + ); + } else { + return $cxntests->output_fails_as_wp_error(); + } + } + + /** + * Test connection permission check method. + * + * @since 7.1.0 + * + * @return bool + */ + public static function view_jetpack_connection_test_check() { + if ( ! isset( $_GET['signature'], $_GET['timestamp'], $_GET['url'] ) ) { + return false; + } + $signature = base64_decode( $_GET['signature'] ); + + $signature_data = wp_json_encode( + array( + 'rest_route' => $_GET['rest_route'], + 'timestamp' => intval( $_GET['timestamp'] ), + 'url' => wp_unslash( $_GET['url'] ), + ) + ); + + if ( + ! function_exists( 'openssl_verify' ) + || ! openssl_verify( + $signature_data, + $signature, + JETPACK__DEBUGGER_PUBLIC_KEY + ) + ) { + return false; + } + + // signature timestamp must be within 5min of current time + if ( abs( time() - intval( $_GET['timestamp'] ) ) > 300 ) { + return false; + } + + return true; + } + + /** + * Test connection status for this Jetpack site, encrypt the results for decryption by a third-party. + * + * @since 7.1.0 + * + * @return array|mixed|object|WP_Error + */ + public static function jetpack_connection_test_for_external() { + // Since we are running this test for inclusion in the WP.com testing suite, let's not try to run them as part of these results. + add_filter( 'jetpack_debugger_run_self_test', '__return_false' ); + jetpack_require_lib( 'debugger' ); + $cxntests = new Jetpack_Cxn_Tests(); + + if ( $cxntests->pass() ) { + $result = array( + 'code' => 'success', + 'message' => __( 'All connection tests passed.', 'jetpack' ), + ); + } else { + $error = $cxntests->output_fails_as_wp_error(); // Using this so the output is similar both ways. + $errors = array(); + + // Borrowed from WP_REST_Server::error_to_response(). + foreach ( (array) $error->errors as $code => $messages ) { + foreach ( (array) $messages as $message ) { + $errors[] = array( + 'code' => $code, + 'message' => $message, + 'data' => $error->get_error_data( $code ), + ); + } + } + + $result = $errors[0]; + if ( count( $errors ) > 1 ) { + // Remove the primary error. + array_shift( $errors ); + $result['additional_errors'] = $errors; + } + } + + $result = wp_json_encode( $result ); + + $encrypted = $cxntests->encrypt_string_for_wpcom( $result ); + + if ( ! $encrypted || ! is_array( $encrypted ) ) { + return rest_ensure_response( + array( + 'code' => 'action_required', + 'message' => 'Please request results from the in-plugin debugger', + ) + ); + } + + return rest_ensure_response( + array( + 'code' => 'response', + 'debug' => array( + 'data' => $encrypted['data'], + 'key' => $encrypted['key'], + ), + ) + ); + } + + public static function rewind_data() { + $site_id = Jetpack_Options::get_option( 'id' ); + + if ( ! $site_id ) { + return new WP_Error( 'site_id_missing' ); + } + + $response = Jetpack_Client::wpcom_json_api_request_as_blog( sprintf( '/sites/%d/rewind', $site_id ) .'?force=wpcom', '2', array(), null, 'wpcom' ); + + if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { + return new WP_Error( 'rewind_data_fetch_failed' ); + } + + $body = wp_remote_retrieve_body( $response ); + + return json_decode( $body ); + } + + /** + * Get rewind data + * + * @since 5.7.0 + * + * @return array Array of rewind properties. + */ + public static function get_rewind_data() { + $rewind_data = self::rewind_data(); + + if ( ! is_wp_error( $rewind_data ) ) { + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'Backup & Scan data correctly received.', 'jetpack' ), + 'data' => wp_json_encode( $rewind_data ), + ) + ); + } + + if ( $rewind_data->get_error_code() === 'rewind_data_fetch_failed' ) { + return new WP_Error( 'rewind_data_fetch_failed', esc_html__( 'Failed fetching rewind data. Try again later.', 'jetpack' ), array( 'status' => 400 ) ); + } + + if ( $rewind_data->get_error_code() === 'site_id_missing' ) { + return new WP_Error( 'site_id_missing', esc_html__( 'The ID of this site does not exist.', 'jetpack' ), array( 'status' => 404 ) ); + } + + return new WP_Error( + 'error_get_rewind_data', + esc_html__( 'Could not retrieve Backup & Scan data.', 'jetpack' ), + array( 'status' => 500 ) + ); + } + + /** + * Disconnects Jetpack from the WordPress.com Servers + * + * @uses Jetpack::disconnect(); + * @since 4.3.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return bool|WP_Error True if Jetpack successfully disconnected. + */ + public static function disconnect_site( $request ) { + + if ( ! isset( $request['isActive'] ) || $request['isActive'] !== false ) { + return new WP_Error( 'invalid_param', esc_html__( 'Invalid Parameter', 'jetpack' ), array( 'status' => 404 ) ); + } + + if ( Jetpack::is_active() ) { + Jetpack::disconnect(); + return rest_ensure_response( array( 'code' => 'success' ) ); + } + + return new WP_Error( 'disconnect_failed', esc_html__( 'Was not able to disconnect the site. Please try again.', 'jetpack' ), array( 'status' => 400 ) ); + } + + /** + * Gets a new connect raw URL with fresh nonce. + * + * @uses Jetpack::disconnect(); + * @since 4.3.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return string|WP_Error A raw URL if the connection URL could be built; error message otherwise. + */ + public static function build_connect_url() { + $url = Jetpack::init()->build_connect_url( true, false, false ); + if ( $url ) { + return rest_ensure_response( $url ); + } + + return new WP_Error( 'build_connect_url_failed', esc_html__( 'Unable to build the connect URL. Please reload the page and try again.', 'jetpack' ), array( 'status' => 400 ) ); + } + + /** + * Get miscellaneous user data related to the connection. Similar data available in old "My Jetpack". + * Information about the master/primary user. + * Information about the current user. + * + * @since 4.3.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return object + */ + public static function get_user_connection_data() { + require_once( JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class.jetpack-react-page.php' ); + + $response = array( +// 'othersLinked' => Jetpack::get_other_linked_admins(), + 'currentUser' => jetpack_current_user_data(), + ); + return rest_ensure_response( $response ); + } + + /** + * Change the master user. + * + * @since 6.2.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return bool|WP_Error True if owner successfully changed. + */ + public static function set_connection_owner( $request ) { + if ( ! isset( $request['owner'] ) ) { + return new WP_Error( + 'invalid_param', + esc_html__( 'Invalid Parameter', 'jetpack' ), + array( 'status' => 400 ) + ); + } + + $new_owner_id = $request['owner']; + if ( ! user_can( $new_owner_id, 'administrator' ) ) { + return new WP_Error( + 'new_owner_not_admin', + esc_html__( 'New owner is not admin', 'jetpack' ), + array( 'status' => 400 ) + ); + } + + if ( $new_owner_id === get_current_user_id() ) { + return new WP_Error( + 'new_owner_is_current_user', + esc_html__( 'New owner is same as current user', 'jetpack' ), + array( 'status' => 400 ) + ); + } + + if ( ! Jetpack::is_user_connected( $new_owner_id ) ) { + return new WP_Error( + 'new_owner_not_connected', + esc_html__( 'New owner is not connected', 'jetpack' ), + array( 'status' => 400 ) + ); + } + + // Update the master user in Jetpack + $updated = Jetpack_Options::update_option( 'master_user', $new_owner_id ); + + // Notify WPCOM about the master user change + Jetpack::load_xml_rpc_client(); + $xml = new Jetpack_IXR_Client( array( + 'user_id' => get_current_user_id(), + ) ); + $xml->query( 'jetpack.switchBlogOwner', array( + 'new_blog_owner' => $new_owner_id, + ) ); + + if ( $updated && ! $xml->isError() ) { + return rest_ensure_response( + array( + 'code' => 'success', + ) + ); + } + return new WP_Error( + 'error_setting_new_owner', + esc_html__( 'Could not confirm new owner.', 'jetpack' ), + array( 'status' => 500 ) + ); + } + + /** + * Unlinks current user from the WordPress.com Servers. + * + * @since 4.3.0 + * @uses Jetpack::unlink_user + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return bool|WP_Error True if user successfully unlinked. + */ + public static function unlink_user( $request ) { + + if ( ! isset( $request['linked'] ) || $request['linked'] !== false ) { + return new WP_Error( 'invalid_param', esc_html__( 'Invalid Parameter', 'jetpack' ), array( 'status' => 404 ) ); + } + + if ( Jetpack::unlink_user() ) { + return rest_ensure_response( + array( + 'code' => 'success' + ) + ); + } + + return new WP_Error( 'unlink_user_failed', esc_html__( 'Was not able to unlink the user. Please try again.', 'jetpack' ), array( 'status' => 400 ) ); + } + + /** + * Gets current user's tracking settings. + * + * @since 6.0.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return WP_REST_Response|WP_Error Response, else error. + */ + public static function get_user_tracking_settings( $request ) { + if ( ! Jetpack::is_user_connected() ) { + $response = array( + 'tracks_opt_out' => true, // Default to opt-out if not connected to wp.com. + ); + } else { + $response = Jetpack_Client::wpcom_json_api_request_as_user( + '/jetpack-user-tracking', + 'v2', + array( + 'method' => 'GET', + 'headers' => array( + 'X-Forwarded-For' => Jetpack::current_user_ip( true ), + ), + ) + ); + if ( ! is_wp_error( $response ) ) { + $response = json_decode( wp_remote_retrieve_body( $response ), true ); + } + } + + return rest_ensure_response( $response ); + } + + /** + * Updates current user's tracking settings. + * + * @since 6.0.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return WP_REST_Response|WP_Error Response, else error. + */ + public static function update_user_tracking_settings( $request ) { + if ( ! Jetpack::is_user_connected() ) { + $response = array( + 'tracks_opt_out' => true, // Default to opt-out if not connected to wp.com. + ); + } else { + $response = Jetpack_Client::wpcom_json_api_request_as_user( + '/jetpack-user-tracking', + 'v2', + array( + 'method' => 'PUT', + 'headers' => array( + 'Content-Type' => 'application/json', + 'X-Forwarded-For' => Jetpack::current_user_ip( true ), + ), + ), + wp_json_encode( $request->get_params() ) + ); + if ( ! is_wp_error( $response ) ) { + $response = json_decode( wp_remote_retrieve_body( $response ), true ); + } + } + + return rest_ensure_response( $response ); + } + + /** + * Fetch site data from .com including the site's current plan. + * + * @since 5.5.0 + * + * @return array Array of site properties. + */ + public static function site_data() { + $site_id = Jetpack_Options::get_option( 'id' ); + + if ( ! $site_id ) { + new WP_Error( 'site_id_missing' ); + } + + $response = Jetpack_Client::wpcom_json_api_request_as_blog( sprintf( '/sites/%d', $site_id ) .'?force=wpcom', '1.1' ); + + if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { + return new WP_Error( 'site_data_fetch_failed' ); + } + + Jetpack_Plan::update_from_sites_response( $response ); + + $body = wp_remote_retrieve_body( $response ); + + return json_decode( $body ); + } + /** + * Get site data, including for example, the site's current plan. + * + * @since 4.3.0 + * + * @return array Array of site properties. + */ + public static function get_site_data() { + $site_data = self::site_data(); + + if ( ! is_wp_error( $site_data ) ) { + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'Site data correctly received.', 'jetpack' ), + 'data' => json_encode( $site_data ), + ) + ); + } + if ( $site_data->get_error_code() === 'site_data_fetch_failed' ) { + return new WP_Error( 'site_data_fetch_failed', esc_html__( 'Failed fetching site data. Try again later.', 'jetpack' ), array( 'status' => 400 ) ); + } + + if ( $site_data->get_error_code() === 'site_id_missing' ) { + return new WP_Error( 'site_id_missing', esc_html__( 'The ID of this site does not exist.', 'jetpack' ), array( 'status' => 404 ) ); + } + } + + /** + * Handles identity crisis mitigation, confirming safe mode for this site. + * + * @since 4.4.0 + * + * @return bool | WP_Error True if option is properly set. + */ + public static function confirm_safe_mode() { + $updated = Jetpack_Options::update_option( 'safe_mode_confirmed', true ); + if ( $updated ) { + return rest_ensure_response( + array( + 'code' => 'success' + ) + ); + } + return new WP_Error( + 'error_setting_jetpack_safe_mode', + esc_html__( 'Could not confirm safe mode.', 'jetpack' ), + array( 'status' => 500 ) + ); + } + + /** + * Handles identity crisis mitigation, migrating stats and subscribers from old url to this, new url. + * + * @since 4.4.0 + * + * @return bool | WP_Error True if option is properly set. + */ + public static function migrate_stats_and_subscribers() { + if ( Jetpack_Options::get_option( 'sync_error_idc' ) && ! Jetpack_Options::delete_option( 'sync_error_idc' ) ) { + return new WP_Error( + 'error_deleting_sync_error_idc', + esc_html__( 'Could not delete sync error option.', 'jetpack' ), + array( 'status' => 500 ) + ); + } + + if ( Jetpack_Options::get_option( 'migrate_for_idc' ) || Jetpack_Options::update_option( 'migrate_for_idc', true ) ) { + return rest_ensure_response( + array( + 'code' => 'success' + ) + ); + } + return new WP_Error( + 'error_setting_jetpack_migrate', + esc_html__( 'Could not confirm migration.', 'jetpack' ), + array( 'status' => 500 ) + ); + } + + /** + * This IDC resolution will disconnect the site and re-connect to a completely new + * and separate shadow site than the original. + * + * It will first will disconnect the site without phoning home as to not disturb the production site. + * It then builds a fresh connection URL and sends it back along with the response. + * + * @since 4.4.0 + * @return bool|WP_Error + */ + public static function start_fresh_connection() { + // First clear the options / disconnect. + Jetpack::disconnect(); + return self::build_connect_url(); + } + + /** + * Reset Jetpack options + * + * @since 4.3.0 + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $options Available options to reset are options|modules + * } + * + * @return bool|WP_Error True if options were reset. Otherwise, a WP_Error instance with the corresponding error. + */ + public static function reset_jetpack_options( $request ) { + + if ( ! isset( $request['reset'] ) || $request['reset'] !== true ) { + return new WP_Error( 'invalid_param', esc_html__( 'Invalid Parameter', 'jetpack' ), array( 'status' => 404 ) ); + } + + if ( isset( $request['options'] ) ) { + $data = $request['options']; + + switch( $data ) { + case ( 'options' ) : + $options_to_reset = Jetpack::get_jetpack_options_for_reset(); + + // Reset the Jetpack options + foreach ( $options_to_reset['jp_options'] as $option_to_reset ) { + Jetpack_Options::delete_option( $option_to_reset ); + } + + foreach ( $options_to_reset['wp_options'] as $option_to_reset ) { + delete_option( $option_to_reset ); + } + + // Reset to default modules + $default_modules = Jetpack::get_default_modules(); + Jetpack::update_active_modules( $default_modules ); + + // Jumpstart option is special + Jetpack_Options::update_option( 'jumpstart', 'new_connection' ); + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'Jetpack options reset.', 'jetpack' ), + ) ); + break; + + case 'modules': + $default_modules = Jetpack::get_default_modules(); + Jetpack::update_active_modules( $default_modules ); + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'Modules reset to default.', 'jetpack' ), + ) ); + break; + + default: + return new WP_Error( 'invalid_param', esc_html__( 'Invalid Parameter', 'jetpack' ), array( 'status' => 404 ) ); + } + } + + return new WP_Error( 'required_param', esc_html__( 'Missing parameter "type".', 'jetpack' ), array( 'status' => 404 ) ); + } + + /** + * Retrieves the current status of Jumpstart. + * + * @since 4.5.0 + * + * @return bool + */ + public static function jumpstart_status() { + return array( + 'status' => Jetpack_Options::get_option( 'jumpstart' ) + ); + } + + /** + * Toggles activation or deactivation of the JumpStart + * + * @since 4.3.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return bool|WP_Error True if toggling Jumpstart succeeded. Otherwise, a WP_Error instance with the corresponding error. + */ + public static function jumpstart_toggle( $request ) { + + if ( $request[ 'active' ] ) { + return self::jumpstart_activate( $request ); + } else { + return self::jumpstart_deactivate( $request ); + } + } + + /** + * Activates a series of valid Jetpack modules and initializes some options. + * + * @since 4.3.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return bool|WP_Error True if Jumpstart succeeded. Otherwise, a WP_Error instance with the corresponding error. + */ + public static function jumpstart_activate( $request ) { + $modules = Jetpack::get_available_modules(); + $activate_modules = array(); + foreach ( $modules as $module ) { + $module_info = Jetpack::get_module( $module ); + if ( isset( $module_info['feature'] ) && is_array( $module_info['feature'] ) && in_array( 'Jumpstart', $module_info['feature'] ) ) { + $activate_modules[] = $module; + } + } + + // Collect success/error messages like modules that are properly activated. + $result = array( + 'activated_modules' => array(), + 'failed_modules' => array(), + ); + + // Update the jumpstart option + if ( 'new_connection' === Jetpack_Options::get_option( 'jumpstart' ) ) { + $result['jumpstart_activated'] = Jetpack_Options::update_option( 'jumpstart', 'jumpstart_activated' ); + } + + // Check for possible conflicting plugins + $module_slugs_filtered = Jetpack::init()->filter_default_modules( $activate_modules ); + + foreach ( $module_slugs_filtered as $module_slug ) { + Jetpack::log( 'activate', $module_slug ); + if ( Jetpack::activate_module( $module_slug, false, false ) ) { + $result['activated_modules'][] = $module_slug; + } else { + $result['failed_modules'][] = $module_slug; + } + } + + // Set the default sharing buttons and set to display on posts if none have been set. + $sharing_services = get_option( 'sharing-services' ); + $sharing_options = get_option( 'sharing-options' ); + if ( empty( $sharing_services['visible'] ) ) { + // Default buttons to set + $visible = array( + 'twitter', + 'facebook', + ); + $hidden = array(); + + // Set some sharing settings + if ( class_exists( 'Sharing_Service' ) ) { + $sharing = new Sharing_Service(); + $sharing_options['global'] = array( + 'button_style' => 'icon', + 'sharing_label' => $sharing->default_sharing_label, + 'open_links' => 'same', + 'show' => array( 'post' ), + 'custom' => isset( $sharing_options['global']['custom'] ) ? $sharing_options['global']['custom'] : array() + ); + + $result['sharing_options'] = update_option( 'sharing-options', $sharing_options ); + $result['sharing_services'] = update_option( 'sharing-services', array( 'visible' => $visible, 'hidden' => $hidden ) ); + } + } + + // If all Jumpstart modules were activated + if ( empty( $result['failed_modules'] ) ) { + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'Jumpstart done.', 'jetpack' ), + 'data' => $result, + ) ); + } + + return new WP_Error( 'jumpstart_failed', esc_html( sprintf( _n( 'Jumpstart failed activating this module: %s.', 'Jumpstart failed activating these modules: %s.', count( $result['failed_modules'] ), 'jetpack' ), join( ', ', $result['failed_modules'] ) ) ), array( 'status' => 400 ) ); + } + + /** + * Dismisses Jumpstart so user is not prompted to go through it again. + * + * @since 4.3.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return bool|WP_Error True if Jumpstart was disabled or was nothing to dismiss. Otherwise, a WP_Error instance with a message. + */ + public static function jumpstart_deactivate( $request ) { + + // If dismissed, flag the jumpstart option as such. + if ( 'new_connection' === Jetpack_Options::get_option( 'jumpstart' ) ) { + if ( Jetpack_Options::update_option( 'jumpstart', 'jumpstart_dismissed' ) ) { + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'Jumpstart dismissed.', 'jetpack' ), + ) ); + } else { + return new WP_Error( 'jumpstart_failed_dismiss', esc_html__( 'Jumpstart could not be dismissed.', 'jetpack' ), array( 'status' => 400 ) ); + } + } + + // If this was not a new connection and there was nothing to dismiss, don't fail. + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'Nothing to dismiss. This was not a new connection.', 'jetpack' ), + ) ); + } + + /** + * Get the query parameters to update module options or general settings. + * + * @since 4.3.0 + * @since 4.4.0 Accepts a $selector parameter. + * + * @param string $selector Selects a set of options to update, Can be empty, a module slug or 'any'. + * + * @return array + */ + public static function get_updateable_parameters( $selector = '' ) { + $parameters = array( + 'context' => array( + 'default' => 'edit', + ), + ); + + return array_merge( $parameters, self::get_updateable_data_list( $selector ) ); + } + + /** + * Returns a list of module options or general settings that can be updated. + * + * @since 4.3.0 + * @since 4.4.0 Accepts 'any' as a parameter which will make it return the entire list. + * + * @param string|array $selector Module slug, 'any', or an array of parameters. + * If empty, it's assumed we're updating a module and we'll try to get its slug. + * If 'any' the full list is returned. + * If it's an array of parameters, includes the elements by matching keys. + * + * @return array + */ + public static function get_updateable_data_list( $selector = '' ) { + + $options = array( + + // Carousel + 'carousel_background_color' => array( + 'description' => esc_html__( 'Color scheme.', 'jetpack' ), + 'type' => 'string', + 'default' => 'black', + 'enum' => array( + 'black', + 'white', + ), + 'enum_labels' => array( + 'black' => esc_html__( 'Black', 'jetpack' ), + 'white' => esc_html__( 'White', 'jetpack' ), + ), + 'validate_callback' => __CLASS__ . '::validate_list_item', + 'jp_group' => 'carousel', + ), + 'carousel_display_exif' => array( + 'description' => wp_kses( sprintf( __( 'Show photo metadata (<a href="http://en.wikipedia.org/wiki/Exchangeable_image_file_format" target="_blank">Exif</a>) in carousel, when available.', 'jetpack' ) ), array( 'a' => array( 'href' => true, 'target' => true ) ) ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'carousel', + ), + + // Comments + 'highlander_comment_form_prompt' => array( + 'description' => esc_html__( 'Greeting Text', 'jetpack' ), + 'type' => 'string', + 'default' => esc_html__( 'Leave a Reply', 'jetpack' ), + 'sanitize_callback' => 'sanitize_text_field', + 'jp_group' => 'comments', + ), + 'jetpack_comment_form_color_scheme' => array( + 'description' => esc_html__( "Color scheme", 'jetpack' ), + 'type' => 'string', + 'default' => 'light', + 'enum' => array( + 'light', + 'dark', + 'transparent', + ), + 'enum_labels' => array( + 'light' => esc_html__( 'Light', 'jetpack' ), + 'dark' => esc_html__( 'Dark', 'jetpack' ), + 'transparent' => esc_html__( 'Transparent', 'jetpack' ), + ), + 'validate_callback' => __CLASS__ . '::validate_list_item', + 'jp_group' => 'comments', + ), + + // Custom Content Types + 'jetpack_portfolio' => array( + 'description' => esc_html__( 'Enable or disable Jetpack portfolio post type.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'custom-content-types', + ), + 'jetpack_portfolio_posts_per_page' => array( + 'description' => esc_html__( 'Number of entries to show at most in Portfolio pages.', 'jetpack' ), + 'type' => 'integer', + 'default' => 10, + 'validate_callback' => __CLASS__ . '::validate_posint', + 'jp_group' => 'custom-content-types', + ), + 'jetpack_testimonial' => array( + 'description' => esc_html__( 'Enable or disable Jetpack testimonial post type.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'custom-content-types', + ), + 'jetpack_testimonial_posts_per_page' => array( + 'description' => esc_html__( 'Number of entries to show at most in Testimonial pages.', 'jetpack' ), + 'type' => 'integer', + 'default' => 10, + 'validate_callback' => __CLASS__ . '::validate_posint', + 'jp_group' => 'custom-content-types', + ), + + // Galleries + 'tiled_galleries' => array( + 'description' => esc_html__( 'Display all your gallery pictures in a cool mosaic.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'tiled-gallery', + ), + + 'gravatar_disable_hovercards' => array( + 'description' => esc_html__( "View people's profiles when you mouse over their Gravatars", 'jetpack' ), + 'type' => 'string', + 'default' => 'enabled', + // Not visible. This is used as the checkbox value. + 'enum' => array( + 'enabled', + 'disabled', + ), + 'enum_labels' => array( + 'enabled' => esc_html__( 'Enabled', 'jetpack' ), + 'disabled' => esc_html__( 'Disabled', 'jetpack' ), + ), + 'validate_callback' => __CLASS__ . '::validate_list_item', + 'jp_group' => 'gravatar-hovercards', + ), + + // Infinite Scroll + 'infinite_scroll' => array( + 'description' => esc_html__( 'To infinity and beyond', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'infinite-scroll', + ), + 'infinite_scroll_google_analytics' => array( + 'description' => esc_html__( 'Use Google Analytics with Infinite Scroll', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'infinite-scroll', + ), + + // Likes + 'wpl_default' => array( + 'description' => esc_html__( 'WordPress.com Likes are', 'jetpack' ), + 'type' => 'string', + 'default' => 'on', + 'enum' => array( + 'on', + 'off', + ), + 'enum_labels' => array( + 'on' => esc_html__( 'On for all posts', 'jetpack' ), + 'off' => esc_html__( 'Turned on per post', 'jetpack' ), + ), + 'validate_callback' => __CLASS__ . '::validate_list_item', + 'jp_group' => 'likes', + ), + 'social_notifications_like' => array( + 'description' => esc_html__( 'Send email notification when someone likes a post', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'likes', + ), + + // Markdown + 'wpcom_publish_comments_with_markdown' => array( + 'description' => esc_html__( 'Use Markdown for comments.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'markdown', + ), + 'wpcom_publish_posts_with_markdown' => array( + 'description' => esc_html__( 'Use Markdown for posts.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'markdown', + ), + + // Mobile Theme + 'wp_mobile_excerpt' => array( + 'description' => esc_html__( 'Excerpts', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'minileven', + ), + 'wp_mobile_featured_images' => array( + 'description' => esc_html__( 'Featured Images', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'minileven', + ), + 'wp_mobile_app_promos' => array( + 'description' => esc_html__( 'Show a promo for the WordPress mobile apps in the footer of the mobile theme.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'minileven', + ), + + // Monitor + 'monitor_receive_notifications' => array( + 'description' => esc_html__( 'Receive Monitor Email Notifications.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'monitor', + ), + + // Post by Email + 'post_by_email_address' => array( + 'description' => esc_html__( 'Email Address', 'jetpack' ), + 'type' => 'string', + 'default' => 'noop', + 'enum' => array( + 'noop', + 'create', + 'regenerate', + 'delete', + ), + 'enum_labels' => array( + 'noop' => '', + 'create' => esc_html__( 'Create Post by Email address', 'jetpack' ), + 'regenerate' => esc_html__( 'Regenerate Post by Email address', 'jetpack' ), + 'delete' => esc_html__( 'Delete Post by Email address', 'jetpack' ), + ), + 'validate_callback' => __CLASS__ . '::validate_list_item', + 'jp_group' => 'post-by-email', + ), + + // Protect + 'jetpack_protect_key' => array( + 'description' => esc_html__( 'Protect API key', 'jetpack' ), + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_alphanum', + 'jp_group' => 'protect', + ), + 'jetpack_protect_global_whitelist' => array( + 'description' => esc_html__( 'Protect global whitelist', 'jetpack' ), + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_string', + 'sanitize_callback' => 'esc_textarea', + 'jp_group' => 'protect', + ), + + // Sharing + 'sharing_services' => array( + 'description' => esc_html__( 'Enabled Services and those hidden behind a button', 'jetpack' ), + 'type' => 'object', + 'default' => array( + 'visible' => array( 'twitter', 'facebook', 'google-plus-1' ), + 'hidden' => array(), + ), + 'validate_callback' => __CLASS__ . '::validate_services', + 'jp_group' => 'sharedaddy', + ), + 'button_style' => array( + 'description' => esc_html__( 'Button Style', 'jetpack' ), + 'type' => 'string', + 'default' => 'icon', + 'enum' => array( + 'icon-text', + 'icon', + 'text', + 'official', + ), + 'enum_labels' => array( + 'icon-text' => esc_html__( 'Icon + text', 'jetpack' ), + 'icon' => esc_html__( 'Icon only', 'jetpack' ), + 'text' => esc_html__( 'Text only', 'jetpack' ), + 'official' => esc_html__( 'Official buttons', 'jetpack' ), + ), + 'validate_callback' => __CLASS__ . '::validate_list_item', + 'jp_group' => 'sharedaddy', + ), + 'sharing_label' => array( + 'description' => esc_html__( 'Sharing Label', 'jetpack' ), + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_string', + 'sanitize_callback' => 'esc_html', + 'jp_group' => 'sharedaddy', + ), + 'show' => array( + 'description' => esc_html__( 'Views where buttons are shown', 'jetpack' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string' + ), + 'default' => array( 'post' ), + 'validate_callback' => __CLASS__ . '::validate_sharing_show', + 'jp_group' => 'sharedaddy', + ), + 'jetpack-twitter-cards-site-tag' => array( + 'description' => esc_html__( "The Twitter username of the owner of this site's domain.", 'jetpack' ), + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_twitter_username', + 'sanitize_callback' => 'esc_html', + 'jp_group' => 'sharedaddy', + ), + 'sharedaddy_disable_resources' => array( + 'description' => esc_html__( 'Disable CSS and JS', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'sharedaddy', + ), + 'custom' => array( + 'description' => esc_html__( 'Custom sharing services added by user.', 'jetpack' ), + 'type' => 'object', + 'default' => array( + 'sharing_name' => '', + 'sharing_url' => '', + 'sharing_icon' => '', + ), + 'validate_callback' => __CLASS__ . '::validate_custom_service', + 'jp_group' => 'sharedaddy', + ), + // Not an option, but an action that can be perfomed on the list of custom services passing the service ID. + 'sharing_delete_service' => array( + 'description' => esc_html__( 'Delete custom sharing service.', 'jetpack' ), + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_custom_service_id', + 'jp_group' => 'sharedaddy', + ), + + // SSO + 'jetpack_sso_require_two_step' => array( + 'description' => esc_html__( 'Require Two-Step Authentication', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'sso', + ), + 'jetpack_sso_match_by_email' => array( + 'description' => esc_html__( 'Match by Email', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'sso', + ), + + // Subscriptions + 'stb_enabled' => array( + 'description' => esc_html__( "Show a <em>'follow blog'</em> option in the comment form", 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'subscriptions', + ), + 'stc_enabled' => array( + 'description' => esc_html__( "Show a <em>'follow comments'</em> option in the comment form", 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'subscriptions', + ), + + // Related Posts + 'show_headline' => array( + 'description' => esc_html__( 'Highlight related content with a heading', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'related-posts', + ), + 'show_thumbnails' => array( + 'description' => esc_html__( 'Show a thumbnail image where available', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'related-posts', + ), + + // Verification Tools + 'google' => array( + 'description' => esc_html__( 'Google Search Console', 'jetpack' ), + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_verification_service', + 'jp_group' => 'verification-tools', + ), + 'bing' => array( + 'description' => esc_html__( 'Bing Webmaster Center', 'jetpack' ), + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_verification_service', + 'jp_group' => 'verification-tools', + ), + 'pinterest' => array( + 'description' => esc_html__( 'Pinterest Site Verification', 'jetpack' ), + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_verification_service', + 'jp_group' => 'verification-tools', + ), + 'yandex' => array( + 'description' => esc_html__( 'Yandex Site Verification', 'jetpack' ), + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_verification_service', + 'jp_group' => 'verification-tools', + ), + 'enable_header_ad' => array( + 'description' => esc_html__( 'Display an ad unit at the top of each page.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'wordads', + ), + 'wordads_approved' => array( + 'description' => esc_html__( 'Is site approved for WordAds?', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'wordads', + ), + 'wordads_second_belowpost' => array( + 'description' => esc_html__( 'Display second ad below post?', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'wordads', + ), + 'wordads_display_front_page' => array( + 'description' => esc_html__( 'Display ads on the front page?', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'wordads', + ), + 'wordads_display_post' => array( + 'description' => esc_html__( 'Display ads on posts?', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'wordads', + ), + 'wordads_display_page' => array( + 'description' => esc_html__( 'Display ads on pages?', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'wordads', + ), + 'wordads_display_archive' => array( + 'description' => esc_html__( 'Display ads on archive pages?', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'wordads', + ), + 'wordads_custom_adstxt' => array( + 'description' => esc_html__( 'Custom ads.txt entries', 'jetpack' ), + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_string', + 'sanitize_callback' => 'sanitize_textarea_field', + 'jp_group' => 'wordads', + ), + + // Google Analytics + 'google_analytics_tracking_id' => array( + 'description' => esc_html__( 'Google Analytics', 'jetpack' ), + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_alphanum', + 'jp_group' => 'google-analytics', + ), + + // Stats + 'admin_bar' => array( + 'description' => esc_html__( 'Put a chart showing 48 hours of views in the admin bar.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'stats', + ), + 'roles' => array( + 'description' => esc_html__( 'Select the roles that will be able to view stats reports.', 'jetpack' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string' + ), + 'default' => array( 'administrator' ), + 'validate_callback' => __CLASS__ . '::validate_stats_roles', + 'sanitize_callback' => __CLASS__ . '::sanitize_stats_allowed_roles', + 'jp_group' => 'stats', + ), + 'count_roles' => array( + 'description' => esc_html__( 'Count the page views of registered users who are logged in.', 'jetpack' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string' + ), + 'default' => array( 'administrator' ), + 'validate_callback' => __CLASS__ . '::validate_stats_roles', + 'jp_group' => 'stats', + ), + 'blog_id' => array( + 'description' => esc_html__( 'Blog ID.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'stats', + ), + 'do_not_track' => array( + 'description' => esc_html__( 'Do not track.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'stats', + ), + 'hide_smile' => array( + 'description' => esc_html__( 'Hide the stats smiley face image.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 1, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'stats', + ), + 'version' => array( + 'description' => esc_html__( 'Version.', 'jetpack' ), + 'type' => 'integer', + 'default' => 9, + 'validate_callback' => __CLASS__ . '::validate_posint', + 'jp_group' => 'stats', + ), + + // Akismet - Not a module, but a plugin. The options can be passed and handled differently. + 'akismet_show_user_comments_approved' => array( + 'description' => '', + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'settings', + ), + + 'wordpress_api_key' => array( + 'description' => '', + 'type' => 'string', + 'default' => '', + 'validate_callback' => __CLASS__ . '::validate_alphanum', + 'jp_group' => 'settings', + ), + + // Apps card on dashboard + 'dismiss_dash_app_card' => array( + 'description' => '', + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'settings', + ), + + // Empty stats card dismiss + 'dismiss_empty_stats_card' => array( + 'description' => '', + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'settings', + ), + + 'lang_id' => array( + 'description' => esc_html__( 'Primary language for the site.', 'jetpack' ), + 'type' => 'string', + 'default' => 'en_US', + 'jp_group' => 'settings', + ), + + 'onboarding' => array( + 'description' => '', + 'type' => 'object', + 'default' => array( + 'siteTitle' => '', + 'siteDescription' => '', + 'siteType' => 'personal', + 'homepageFormat' => 'posts', + 'addContactForm' => 0, + 'businessAddress' => array( + 'name' => '', + 'street' => '', + 'city' => '', + 'state' => '', + 'zip' => '', + ), + 'installWooCommerce' => false, + ), + 'validate_callback' => __CLASS__ . '::validate_onboarding', + 'jp_group' => 'settings', + ), + + ); + + // Add modules to list so they can be toggled + $modules = Jetpack::get_available_modules(); + if ( is_array( $modules ) && ! empty( $modules ) ) { + $module_args = array( + 'description' => '', + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'modules', + ); + foreach( $modules as $module ) { + $options[ $module ] = $module_args; + } + } + + if ( is_array( $selector ) ) { + + // Return only those options whose keys match $selector keys + return array_intersect_key( $options, $selector ); + } + + if ( 'any' === $selector ) { + + // Toggle module or update any module option or any general setting + return $options; + } + + // We're updating the options for a single module. + if ( empty( $selector ) ) { + $selector = self::get_module_requested(); + } + $selected = array(); + foreach ( $options as $option => $attributes ) { + + // Not adding an isset( $attributes['jp_group'] ) because if it's not set, it must be fixed, otherwise options will fail. + if ( $selector === $attributes['jp_group'] ) { + $selected[ $option ] = $attributes; + } + } + return $selected; + } + + /** + * Validates that the parameters are proper values that can be set during Jetpack onboarding. + * + * @since 5.4.0 + * + * @param array $onboarding_data Values to check. + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_onboarding( $onboarding_data, $request, $param ) { + if ( ! is_array( $onboarding_data ) ) { + return new WP_Error( 'invalid_param', esc_html__( 'Not valid onboarding data.', 'jetpack' ) ); + } + foreach ( $onboarding_data as $value ) { + if ( is_string( $value ) ) { + $onboarding_choice = self::validate_string( $value, $request, $param ); + } elseif ( is_array( $value ) ) { + $onboarding_choice = self::validate_onboarding( $value, $request, $param ); + } else { + $onboarding_choice = self::validate_boolean( $value, $request, $param ); + } + if ( is_wp_error( $onboarding_choice ) ) { + return $onboarding_choice; + } + } + return true; + } + + /** + * Validates that the parameter is either a pure boolean or a numeric string that can be mapped to a boolean. + * + * @since 4.3.0 + * + * @param string|bool $value Value to check. + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_boolean( $value, $request, $param ) { + if ( ! is_bool( $value ) && ! ( ( ctype_digit( $value ) || is_numeric( $value ) ) && in_array( $value, array( 0, 1 ) ) ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be true, false, 0 or 1.', 'jetpack' ), $param ) ); + } + return true; + } + + /** + * Validates that the parameter is a positive integer. + * + * @since 4.3.0 + * + * @param int $value Value to check. + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_posint( $value = 0, $request, $param ) { + if ( ! is_numeric( $value ) || $value <= 0 ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be a positive integer.', 'jetpack' ), $param ) ); + } + return true; + } + + /** + * Validates that the parameter belongs to a list of admitted values. + * + * @since 4.3.0 + * + * @param string $value Value to check. + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_list_item( $value = '', $request, $param ) { + $attributes = $request->get_attributes(); + if ( ! isset( $attributes['args'][ $param ] ) || ! is_array( $attributes['args'][ $param ] ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s not recognized', 'jetpack' ), $param ) ); + } + $args = $attributes['args'][ $param ]; + if ( ! empty( $args['enum'] ) ) { + + // If it's an associative array, use the keys to check that the value is among those admitted. + $enum = ( count( array_filter( array_keys( $args['enum'] ), 'is_string' ) ) > 0 ) ? array_keys( $args['enum'] ) : $args['enum']; + if ( ! in_array( $value, $enum ) ) { + return new WP_Error( 'invalid_param_value', sprintf( + /* Translators: first variable is the parameter passed to endpoint that holds the list item, the second is a list of admitted values. */ + esc_html__( '%1$s must be one of %2$s', 'jetpack' ), $param, implode( ', ', $enum ) + ) ); + } + } + return true; + } + + /** + * Validates that the parameter belongs to a list of admitted values. + * + * @since 4.3.0 + * + * @param string $value Value to check. + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_module_list( $value = '', $request, $param ) { + if ( ! is_array( $value ) ) { + return new WP_Error( 'invalid_param_value', sprintf( esc_html__( '%s must be an array', 'jetpack' ), $param ) ); + } + + $modules = Jetpack::get_available_modules(); + + if ( count( array_intersect( $value, $modules ) ) != count( $value ) ) { + return new WP_Error( 'invalid_param_value', sprintf( esc_html__( '%s must be a list of valid modules', 'jetpack' ), $param ) ); + } + + return true; + } + + /** + * Validates that the parameter is an alphanumeric or empty string (to be able to clear the field). + * + * @since 4.3.0 + * + * @param string $value Value to check. + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_alphanum( $value = '', $request, $param ) { + if ( ! empty( $value ) && ( ! is_string( $value ) || ! preg_match( '/^[a-z0-9]+$/i', $value ) ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be an alphanumeric string.', 'jetpack' ), $param ) ); + } + return true; + } + + /** + * Validates that the parameter is a tag or id for a verification service, or an empty string (to be able to clear the field). + * + * @since 4.6.0 + * + * @param string $value Value to check. + * @param WP_REST_Request $request + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_verification_service( $value = '', $request, $param ) { + if ( ! empty( $value ) && ! ( is_string( $value ) && ( preg_match( '/^[a-z0-9_-]+$/i', $value ) || jetpack_verification_get_code( $value ) !== false ) ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be an alphanumeric string or a verification tag.', 'jetpack' ), $param ) ); + } + return true; + } + + /** + * Validates that the parameter is among the roles allowed for Stats. + * + * @since 4.3.0 + * + * @param string|bool $value Value to check. + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_stats_roles( $value, $request, $param ) { + if ( ! empty( $value ) && ! array_intersect( self::$stats_roles, $value ) ) { + return new WP_Error( 'invalid_param', sprintf( + /* Translators: first variable is the name of a parameter passed to endpoint holding the role that will be checked, the second is a list of roles allowed to see stats. The parameter is checked against this list. */ + esc_html__( '%1$s must be %2$s.', 'jetpack' ), $param, join( ', ', self::$stats_roles ) + ) ); + } + return true; + } + + /** + * Validates that the parameter is among the views where the Sharing can be displayed. + * + * @since 4.3.0 + * + * @param string|bool $value Value to check. + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_sharing_show( $value, $request, $param ) { + $views = array( 'index', 'post', 'page', 'attachment', 'jetpack-portfolio' ); + if ( ! is_array( $value ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be an array of post types.', 'jetpack' ), $param ) ); + } + if ( ! array_intersect( $views, $value ) ) { + return new WP_Error( 'invalid_param', sprintf( + /* Translators: first variable is the name of a parameter passed to endpoint holding the post type where Sharing will be displayed, the second is a list of post types where Sharing can be displayed */ + esc_html__( '%1$s must be %2$s.', 'jetpack' ), $param, join( ', ', $views ) + ) ); + } + return true; + } + + /** + * Validates that the parameter is among the views where the Sharing can be displayed. + * + * @since 4.3.0 + * + * @param string|bool $value { + * Value to check received by request. + * + * @type array $visible List of slug of services to share to that are displayed directly in the page. + * @type array $hidden List of slug of services to share to that are concealed in a folding menu. + * } + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_services( $value, $request, $param ) { + if ( ! is_array( $value ) || ! isset( $value['visible'] ) || ! isset( $value['hidden'] ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be an array with visible and hidden items.', 'jetpack' ), $param ) ); + } + + // Allow to clear everything. + if ( empty( $value['visible'] ) && empty( $value['hidden'] ) ) { + return true; + } + + if ( ! class_exists( 'Sharing_Service' ) && ! include_once( JETPACK__PLUGIN_DIR . 'modules/sharedaddy/sharing-service.php' ) ) { + return new WP_Error( 'invalid_param', esc_html__( 'Failed loading required dependency Sharing_Service.', 'jetpack' ) ); + } + $sharer = new Sharing_Service(); + $services = array_keys( $sharer->get_all_services() ); + + if ( + ( ! empty( $value['visible'] ) && ! array_intersect( $value['visible'], $services ) ) + || + ( ! empty( $value['hidden'] ) && ! array_intersect( $value['hidden'], $services ) ) ) + { + return new WP_Error( 'invalid_param', sprintf( + /* Translators: placeholder 1 is a parameter holding the services passed to endpoint, placeholder 2 is a list of all Jetpack Sharing services */ + esc_html__( '%1$s visible and hidden items must be a list of %2$s.', 'jetpack' ), $param, join( ', ', $services ) + ) ); + } + return true; + } + + /** + * Validates that the parameter has enough information to build a custom sharing button. + * + * @since 4.3.0 + * + * @param string|bool $value Value to check. + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_custom_service( $value, $request, $param ) { + if ( ! is_array( $value ) || ! isset( $value['sharing_name'] ) || ! isset( $value['sharing_url'] ) || ! isset( $value['sharing_icon'] ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be an array with sharing name, url and icon.', 'jetpack' ), $param ) ); + } + + // Allow to clear everything. + if ( empty( $value['sharing_name'] ) && empty( $value['sharing_url'] ) && empty( $value['sharing_icon'] ) ) { + return true; + } + + if ( ! class_exists( 'Sharing_Service' ) && ! include_once( JETPACK__PLUGIN_DIR . 'modules/sharedaddy/sharing-service.php' ) ) { + return new WP_Error( 'invalid_param', esc_html__( 'Failed loading required dependency Sharing_Service.', 'jetpack' ) ); + } + + if ( ( ! empty( $value['sharing_name'] ) && ! is_string( $value['sharing_name'] ) ) + || ( ! empty( $value['sharing_url'] ) && ! is_string( $value['sharing_url'] ) ) + || ( ! empty( $value['sharing_icon'] ) && ! is_string( $value['sharing_icon'] ) ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s needs sharing name, url and icon.', 'jetpack' ), $param ) ); + } + return true; + } + + /** + * Validates that the parameter is a custom sharing service ID like 'custom-1461976264'. + * + * @since 4.3.0 + * + * @param string $value Value to check. + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_custom_service_id( $value = '', $request, $param ) { + if ( ! empty( $value ) && ( ! is_string( $value ) || ! preg_match( '/custom\-[0-1]+/i', $value ) ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( "%s must be a string prefixed with 'custom-' and followed by a numeric ID.", 'jetpack' ), $param ) ); + } + + if ( ! class_exists( 'Sharing_Service' ) && ! include_once( JETPACK__PLUGIN_DIR . 'modules/sharedaddy/sharing-service.php' ) ) { + return new WP_Error( 'invalid_param', esc_html__( 'Failed loading required dependency Sharing_Service.', 'jetpack' ) ); + } + $sharer = new Sharing_Service(); + $services = array_keys( $sharer->get_all_services() ); + + if ( ! empty( $value ) && ! in_array( $value, $services ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s is not a registered custom sharing service.', 'jetpack' ), $param ) ); + } + + return true; + } + + /** + * Validates that the parameter is a Twitter username or empty string (to be able to clear the field). + * + * @since 4.3.0 + * + * @param string $value Value to check. + * @param WP_REST_Request $request + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_twitter_username( $value = '', $request, $param ) { + if ( ! empty( $value ) && ( ! is_string( $value ) || ! preg_match( '/^@?\w{1,15}$/i', $value ) ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be a Twitter username.', 'jetpack' ), $param ) ); + } + return true; + } + + /** + * Validates that the parameter is a string. + * + * @since 4.3.0 + * + * @param string $value Value to check. + * @param WP_REST_Request $request The request sent to the WP REST API. + * @param string $param Name of the parameter passed to endpoint holding $value. + * + * @return bool|WP_Error + */ + public static function validate_string( $value = '', $request, $param ) { + if ( ! is_string( $value ) ) { + return new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be a string.', 'jetpack' ), $param ) ); + } + return true; + } + + /** + * If for some reason the roles allowed to see Stats are empty (for example, user tampering with checkboxes), + * return an array with only 'administrator' as the allowed role and save it for 'roles' option. + * + * @since 4.3.0 + * + * @param string|bool $value Value to check. + * + * @return bool|array + */ + public static function sanitize_stats_allowed_roles( $value ) { + if ( empty( $value ) ) { + return array( 'administrator' ); + } + return $value; + } + + /** + * Get the currently accessed route and return the module slug in it. + * + * @since 4.3.0 + * + * @param string $route Regular expression for the endpoint with the module slug to return. + * + * @return array|string + */ + public static function get_module_requested( $route = '/module/(?P<slug>[a-z\-]+)' ) { + + if ( empty( $GLOBALS['wp']->query_vars['rest_route'] ) ) { + return ''; + } + + preg_match( "#$route#", $GLOBALS['wp']->query_vars['rest_route'], $module ); + + if ( empty( $module['slug'] ) ) { + return ''; + } + + return $module['slug']; + } + + /** + * Adds extra information for modules. + * + * @since 4.3.0 + * + * @param string|array $modules Can be a single module or a list of modules. + * @param null|string $slug Slug of the module in the first parameter. + * + * @return array|string + */ + public static function prepare_modules_for_response( $modules = '', $slug = null ) { + global $wp_rewrite; + + /** This filter is documented in modules/sitemaps/sitemaps.php */ + $location = apply_filters( 'jetpack_sitemap_location', '' ); + + if ( $wp_rewrite->using_index_permalinks() ) { + $sitemap_url = home_url( '/index.php' . $location . '/sitemap.xml' ); + $news_sitemap_url = home_url( '/index.php' . $location . '/news-sitemap.xml' ); + } else if ( $wp_rewrite->using_permalinks() ) { + $sitemap_url = home_url( $location . '/sitemap.xml' ); + $news_sitemap_url = home_url( $location . '/news-sitemap.xml' ); + } else { + $sitemap_url = home_url( $location . '/?jetpack-sitemap=sitemap.xml' ); + $news_sitemap_url = home_url( $location . '/?jetpack-sitemap=news-sitemap.xml' ); + } + + if ( is_null( $slug ) && isset( $modules['sitemaps'] ) ) { + // Is a list of modules + $modules['sitemaps']['extra']['sitemap_url'] = $sitemap_url; + $modules['sitemaps']['extra']['news_sitemap_url'] = $news_sitemap_url; + } elseif ( 'sitemaps' == $slug ) { + // It's a single module + $modules['extra']['sitemap_url'] = $sitemap_url; + $modules['extra']['news_sitemap_url'] = $news_sitemap_url; + } + return $modules; + } + + /** + * Remove 'validate_callback' item from options available for module. + * Fetch current option value and add to array of module options. + * Prepare values of module options that need special handling, like those saved in wpcom. + * + * @since 4.3.0 + * + * @param string $module Module slug. + * @return array + */ + public static function prepare_options_for_response( $module = '' ) { + $options = self::get_updateable_data_list( $module ); + + if ( ! is_array( $options ) || empty( $options ) ) { + return $options; + } + + // Some modules need special treatment. + switch ( $module ) { + + case 'monitor': + // Status of user notifications + $options['monitor_receive_notifications']['current_value'] = self::cast_value( self::get_remote_value( 'monitor', 'monitor_receive_notifications' ), $options['monitor_receive_notifications'] ); + break; + + case 'post-by-email': + // Email address + $options['post_by_email_address']['current_value'] = self::cast_value( self::get_remote_value( 'post-by-email', 'post_by_email_address' ), $options['post_by_email_address'] ); + break; + + case 'protect': + // Protect + $options['jetpack_protect_key']['current_value'] = get_site_option( 'jetpack_protect_key', false ); + if ( ! function_exists( 'jetpack_protect_format_whitelist' ) ) { + include_once( JETPACK__PLUGIN_DIR . 'modules/protect/shared-functions.php' ); + } + $options['jetpack_protect_global_whitelist']['current_value'] = jetpack_protect_format_whitelist(); + break; + + case 'related-posts': + // It's local, but it must be broken apart since it's saved as an array. + $options = self::split_options( $options, Jetpack_Options::get_option( 'relatedposts' ) ); + break; + + case 'verification-tools': + // It's local, but it must be broken apart since it's saved as an array. + $options = self::split_options( $options, get_option( 'verification_services_codes' ) ); + break; + + case 'google-analytics': + $wga = get_option( 'jetpack_wga' ); + $code = ''; + if ( is_array( $wga ) && array_key_exists( 'code', $wga ) ) { + $code = $wga[ 'code' ]; + } + $options[ 'google_analytics_tracking_id' ][ 'current_value' ] = $code; + break; + + case 'sharedaddy': + // It's local, but it must be broken apart since it's saved as an array. + if ( ! class_exists( 'Sharing_Service' ) && ! include_once( JETPACK__PLUGIN_DIR . 'modules/sharedaddy/sharing-service.php' ) ) { + break; + } + $sharer = new Sharing_Service(); + $options = self::split_options( $options, $sharer->get_global_options() ); + $options['sharing_services']['current_value'] = $sharer->get_blog_services(); + $other_sharedaddy_options = array( 'jetpack-twitter-cards-site-tag', 'sharedaddy_disable_resources', 'sharing_delete_service' ); + foreach ( $other_sharedaddy_options as $key ) { + $default_value = isset( $options[ $key ]['default'] ) ? $options[ $key ]['default'] : ''; + $current_value = get_option( $key, $default_value ); + $options[ $key ]['current_value'] = self::cast_value( $current_value, $options[ $key ] ); + } + break; + + case 'stats': + // It's local, but it must be broken apart since it's saved as an array. + if ( ! function_exists( 'stats_get_options' ) ) { + include_once( JETPACK__PLUGIN_DIR . 'modules/stats.php' ); + } + $options = self::split_options( $options, stats_get_options() ); + break; + default: + // These option are just stored as plain WordPress options. + foreach ( $options as $key => $value ) { + $default_value = isset( $options[ $key ]['default'] ) ? $options[ $key ]['default'] : ''; + $current_value = get_option( $key, $default_value ); + $options[ $key ]['current_value'] = self::cast_value( $current_value, $options[ $key ] ); + } + } + // At this point some options have current_value not set because they're options + // that only get written on update, so we set current_value to the default one. + foreach ( $options as $key => $value ) { + // We don't need validate_callback in the response + if ( isset( $options[ $key ]['validate_callback'] ) ) { + unset( $options[ $key ]['validate_callback'] ); + } + $default_value = isset( $options[ $key ]['default'] ) ? $options[ $key ]['default'] : ''; + if ( ! array_key_exists( 'current_value', $options[ $key ] ) ) { + $options[ $key ]['current_value'] = self::cast_value( $default_value, $options[ $key ] ); + } + } + return $options; + } + + /** + * Splits module options saved as arrays like relatedposts or verification_services_codes into separate options to be returned in the response. + * + * @since 4.3.0 + * + * @param array $separate_options Array of options admitted by the module. + * @param array $grouped_options Option saved as array to be splitted. + * @param string $prefix Optional prefix for the separate option keys. + * + * @return array + */ + public static function split_options( $separate_options, $grouped_options, $prefix = '' ) { + if ( is_array( $grouped_options ) ) { + foreach ( $grouped_options as $key => $value ) { + $option_key = $prefix . $key; + if ( isset( $separate_options[ $option_key ] ) ) { + $separate_options[ $option_key ]['current_value'] = self::cast_value( $grouped_options[ $key ], $separate_options[ $option_key ] ); + } + } + } + return $separate_options; + } + + /** + * Perform a casting to the value specified in the option definition. + * + * @since 4.3.0 + * + * @param mixed $value Value to cast to the proper type. + * @param array $definition Type to cast the value to. + * + * @return bool|float|int|string + */ + public static function cast_value( $value, $definition ) { + if ( $value === 'NULL' ) { + return null; + } + + if ( isset( $definition['type'] ) ) { + switch ( $definition['type'] ) { + case 'boolean': + if ( 'true' === $value ) { + return true; + } elseif ( 'false' === $value ) { + return false; + } + return (bool) $value; + break; + + case 'integer': + return (int) $value; + break; + + case 'float': + return (float) $value; + break; + + case 'string': + return (string) $value; + break; + } + } + return $value; + } + + /** + * Get a value not saved locally. + * + * @since 4.3.0 + * + * @param string $module Module slug. + * @param string $option Option name. + * + * @return bool Whether user is receiving notifications or not. + */ + public static function get_remote_value( $module, $option ) { + + if ( in_array( $module, array( 'post-by-email' ), true ) ) { + $option .= get_current_user_id(); + } + + // If option doesn't exist, 'does_not_exist' will be returned. + $value = get_option( $option, 'does_not_exist' ); + + // If option exists, just return it. + if ( 'does_not_exist' !== $value ) { + return $value; + } + + // Only check a remote option if Jetpack is connected. + if ( ! Jetpack::is_active() ) { + return false; + } + + // Do what is necessary for each module. + switch ( $module ) { + case 'monitor': + // Load the class to use the method. If class can't be found, do nothing. + if ( ! class_exists( 'Jetpack_Monitor' ) && ! include_once( Jetpack::get_module_path( $module ) ) ) { + return false; + } + $value = Jetpack_Monitor::user_receives_notifications( false ); + break; + + case 'post-by-email': + // Load the class to use the method. If class can't be found, do nothing. + if ( ! class_exists( 'Jetpack_Post_By_Email' ) && ! include_once( Jetpack::get_module_path( $module ) ) ) { + return false; + } + $post_by_email = new Jetpack_Post_By_Email(); + $value = $post_by_email->get_post_by_email_address(); + if ( $value === null ) { + $value = 'NULL'; // sentinel value so it actually gets set + } + break; + } + + // Normalize value to boolean. + if ( is_wp_error( $value ) || is_null( $value ) ) { + $value = false; + } + + // Save option to use it next time. + update_option( $option, $value ); + + return $value; + } + + /** + * Get number of plugin updates available. + * + * @since 4.3.0 + * + * @return mixed|WP_Error Number of plugin updates available. Otherwise, a WP_Error instance with the corresponding error. + */ + public static function get_plugin_update_count() { + $updates = wp_get_update_data(); + if ( isset( $updates['counts'] ) && isset( $updates['counts']['plugins'] ) ) { + $count = $updates['counts']['plugins']; + if ( 0 == $count ) { + $response = array( + 'code' => 'success', + 'message' => esc_html__( 'All plugins are up-to-date. Keep up the good work!', 'jetpack' ), + 'count' => 0, + ); + } else { + $response = array( + 'code' => 'updates-available', + 'message' => esc_html( sprintf( _n( '%s plugin need updating.', '%s plugins need updating.', $count, 'jetpack' ), $count ) ), + 'count' => $count, + ); + } + return rest_ensure_response( $response ); + } + + return new WP_Error( 'not_found', esc_html__( 'Could not check updates for plugins on this site.', 'jetpack' ), array( 'status' => 404 ) ); + } + + + /** + * Returns a list of all plugins in the site. + * + * @since 4.2.0 + * @uses get_plugins() + * + * @return array + */ + private static function core_get_plugins() { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + /** This filter is documented in wp-admin/includes/class-wp-plugins-list-table.php */ + $plugins = apply_filters( 'all_plugins', get_plugins() ); + + if ( is_array( $plugins ) && ! empty( $plugins ) ) { + foreach ( $plugins as $plugin_slug => $plugin_data ) { + $plugins[ $plugin_slug ]['active'] = self::core_is_plugin_active( $plugin_slug ); + } + return $plugins; + } + + return array(); + } + + /** + * Deprecated - Get third party plugin API keys. + * @deprecated + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $slug Plugin slug with the syntax 'plugin-directory/plugin-main-file.php'. + * } + */ + public static function get_service_api_key( $request ) { + _deprecated_function( __METHOD__, 'jetpack-6.9.0', 'WPCOM_REST_API_V2_Endpoint_Service_API_Keys::get_service_api_key' ); + return WPCOM_REST_API_V2_Endpoint_Service_API_Keys::get_service_api_key( $request ); + } + + /** + * Deprecated - Update third party plugin API keys. + * @deprecated + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $slug Plugin slug with the syntax 'plugin-directory/plugin-main-file.php'. + * } + */ + public static function update_service_api_key( $request ) { + _deprecated_function( __METHOD__, 'jetpack-6.9.0', 'WPCOM_REST_API_V2_Endpoint_Service_API_Keys::update_service_api_key' ); + return WPCOM_REST_API_V2_Endpoint_Service_API_Keys::update_service_api_key( $request ) ; + } + + /** + * Deprecated - Delete a third party plugin API key. + * @deprecated + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $slug Plugin slug with the syntax 'plugin-directory/plugin-main-file.php'. + * } + */ + public static function delete_service_api_key( $request ) { + _deprecated_function( __METHOD__, 'jetpack-6.9.0', 'WPCOM_REST_API_V2_Endpoint_Service_API_Keys::delete_service_api_key' ); + return WPCOM_REST_API_V2_Endpoint_Service_API_Keys::delete_service_api_key( $request ); + } + + /** + * Deprecated - Validate the service provided in /service-api-keys/ endpoints. + * To add a service to these endpoints, add the service name to $valid_services + * and add '{service name}_api_key' to the non-compact return array in get_option_names(), + * in class-jetpack-options.php + * @deprecated + * + * @param string $service The service the API key is for. + * @return string Returns the service name if valid, null if invalid. + */ + public static function validate_service_api_service( $service = null ) { + _deprecated_function( __METHOD__, 'jetpack-6.9.0', 'WPCOM_REST_API_V2_Endpoint_Service_API_Keys::validate_service_api_service' ); + return WPCOM_REST_API_V2_Endpoint_Service_API_Keys::validate_service_api_service( $service ); + } + + /** + * Error response for invalid service API key requests with an invalid service. + */ + public static function service_api_invalid_service_response() { + _deprecated_function( __METHOD__, 'jetpack-6.9.0', 'WPCOM_REST_API_V2_Endpoint_Service_API_Keys::service_api_invalid_service_response' ); + return WPCOM_REST_API_V2_Endpoint_Service_API_Keys::service_api_invalid_service_response(); + } + + /** + * Deprecated - Validate API Key + * @deprecated + * + * @param string $key The API key to be validated. + * @param string $service The service the API key is for. + * + */ + public static function validate_service_api_key( $key = null, $service = null ) { + _deprecated_function( __METHOD__, 'jetpack-6.9.0', 'WPCOM_REST_API_V2_Endpoint_Service_API_Keys::validate_service_api_key' ); + return WPCOM_REST_API_V2_Endpoint_Service_API_Keys::validate_service_api_key( $key , $service ); + } + + /** + * Deprecated - Validate Mapbox API key + * Based loosely on https://github.com/mapbox/geocoding-example/blob/master/php/MapboxTest.php + * @deprecated + * + * @param string $key The API key to be validated. + */ + public static function validate_service_api_key_mapbox( $key ) { + _deprecated_function( __METHOD__, 'jetpack-6.9.0', 'WPCOM_REST_API_V2_Endpoint_Service_API_Keys::validate_service_api_key' ); + return WPCOM_REST_API_V2_Endpoint_Service_API_Keys::validate_service_api_key_mapbox( $key ); + + } + + /** + * Checks if the queried plugin is active. + * + * @since 4.2.0 + * @uses is_plugin_active() + * + * @return bool + */ + private static function core_is_plugin_active( $plugin ) { + if ( ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + return is_plugin_active( $plugin ); + } + + /** + * Get plugins data in site. + * + * @since 4.2.0 + * + * @return WP_REST_Response|WP_Error List of plugins in the site. Otherwise, a WP_Error instance with the corresponding error. + */ + public static function get_plugins() { + $plugins = self::core_get_plugins(); + + if ( ! empty( $plugins ) ) { + return rest_ensure_response( $plugins ); + } + + return new WP_Error( 'not_found', esc_html__( 'Unable to list plugins.', 'jetpack' ), array( 'status' => 404 ) ); + } + + /** + * Get data about the queried plugin. Currently it only returns whether the plugin is active or not. + * + * @since 4.2.0 + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $slug Plugin slug with the syntax 'plugin-directory/plugin-main-file.php'. + * } + * + * @return bool|WP_Error True if module was activated. Otherwise, a WP_Error instance with the corresponding error. + */ + public static function get_plugin( $request ) { + + $plugins = self::core_get_plugins(); + + if ( empty( $plugins ) ) { + return new WP_Error( 'no_plugins_found', esc_html__( 'This site has no plugins.', 'jetpack' ), array( 'status' => 404 ) ); + } + + $plugin = stripslashes( $request['plugin'] ); + + if ( ! in_array( $plugin, array_keys( $plugins ) ) ) { + return new WP_Error( 'plugin_not_found', esc_html( sprintf( __( 'Plugin %s is not installed.', 'jetpack' ), $plugin ) ), array( 'status' => 404 ) ); + } + + $plugin_data = $plugins[ $plugin ]; + + $plugin_data['active'] = self::core_is_plugin_active( $plugin ); + + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'Plugin found.', 'jetpack' ), + 'data' => $plugin_data + ) ); + } + +} // class end diff --git a/plugins/jetpack/_inc/lib/class.jetpack-automatic-install-skin.php b/plugins/jetpack/_inc/lib/class.jetpack-automatic-install-skin.php new file mode 100644 index 00000000..228c6b2c --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.jetpack-automatic-install-skin.php @@ -0,0 +1,111 @@ +<?php + +include_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; +include_once ABSPATH . 'wp-admin/includes/file.php'; + +/** + * Allows us to capture that the site doesn't have proper file system access. + * In order to update the plugin. + */ +class Jetpack_Automatic_Install_Skin extends Automatic_Upgrader_Skin { + /** + * Stores the last error key; + **/ + protected $main_error_code = 'install_error'; + + /** + * Stores the last error message. + **/ + protected $main_error_message = 'An unknown error occurred during installation'; + + /** + * Overwrites the set_upgrader to be able to tell if we e ven have the ability to write to the files. + * + * @param WP_Upgrader $upgrader + * + */ + public function set_upgrader( &$upgrader ) { + parent::set_upgrader( $upgrader ); + + // Check if we even have permission to. + $result = $upgrader->fs_connect( array( WP_CONTENT_DIR, WP_PLUGIN_DIR ) ); + if ( ! $result ) { + // set the string here since they are not available just yet + $upgrader->generic_strings(); + $this->feedback( 'fs_unavailable' ); + } + } + + /** + * Overwrites the error function + */ + public function error( $error ) { + if ( is_wp_error( $error ) ) { + $this->feedback( $error ); + } + } + + private function set_main_error_code( $code ) { + // Don't set the process_failed as code since it is not that helpful unless we don't have one already set. + $this->main_error_code = ( $code === 'process_failed' && $this->main_error_code ? $this->main_error_code : $code ); + } + + private function set_main_error_message( $message, $code ) { + // Don't set the process_failed as message since it is not that helpful unless we don't have one already set. + $this->main_error_message = ( $code === 'process_failed' && $this->main_error_code ? $this->main_error_code : $message ); + } + + public function get_main_error_code() { + return $this->main_error_code; + } + + public function get_main_error_message() { + return $this->main_error_message; + } + + /** + * Overwrites the feedback function + */ + public function feedback( $data ) { + + $current_error = null; + if ( is_wp_error( $data ) ) { + $this->set_main_error_code( $data->get_error_code() ); + $string = $data->get_error_message(); + } elseif ( is_array( $data ) ) { + return; + } else { + $string = $data; + } + + if ( ! empty( $this->upgrader->strings[$string] ) ) { + $this->set_main_error_code( $string ); + + $current_error = $string; + $string = $this->upgrader->strings[$string]; + } + + if ( strpos( $string, '%' ) !== false ) { + $args = func_get_args(); + $args = array_splice( $args, 1 ); + if ( ! empty( $args ) ) { + $string = vsprintf( $string, $args ); + } + } + + $string = trim( $string ); + $string = wp_kses( + $string, array( + 'a' => array( + 'href' => true + ), + 'br' => true, + 'em' => true, + 'strong' => true, + ) + ); + + $this->set_main_error_message( $string, $current_error ); + $this->messages[] = $string; + } +} diff --git a/plugins/jetpack/_inc/lib/class.jetpack-iframe-embed.php b/plugins/jetpack/_inc/lib/class.jetpack-iframe-embed.php new file mode 100644 index 00000000..4445cb65 --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.jetpack-iframe-embed.php @@ -0,0 +1,84 @@ +<?php +/** + * Tweak the preview when rendered in an iframe + */ + +class Jetpack_Iframe_Embed { + static function init() { + if ( ! self::is_embedding_in_iframe() ) { + return; + } + + // Disable the admin bar + if ( ! defined( 'IFRAME_REQUEST' ) ) { + define( 'IFRAME_REQUEST', true ); + } + + // Prevent canonical redirects + remove_filter( 'template_redirect', 'redirect_canonical' ); + + add_action( 'wp_head', array( 'Jetpack_Iframe_Embed', 'noindex' ), 1 ); + add_action( 'wp_head', array( 'Jetpack_Iframe_Embed', 'base_target_blank' ), 1 ); + + add_filter( 'shortcode_atts_video', array( 'Jetpack_Iframe_Embed', 'disable_autoplay' ) ); + add_filter( 'shortcode_atts_audio', array( 'Jetpack_Iframe_Embed', 'disable_autoplay' ) ); + + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { + wp_enqueue_script( 'jetpack-iframe-embed', WPMU_PLUGIN_URL . '/jetpack-iframe-embed/jetpack-iframe-embed.js', array( 'jquery' ) ); + } else { + $ver = sprintf( '%s-%s', gmdate( 'oW' ), defined( 'JETPACK__VERSION' ) ? JETPACK__VERSION : '' ); + wp_enqueue_script( 'jetpack-iframe-embed', '//s0.wp.com/wp-content/mu-plugins/jetpack-iframe-embed/jetpack-iframe-embed.js', array( 'jquery' ), $ver ); + } + wp_localize_script( 'jetpack-iframe-embed', '_previewSite', array( 'siteURL' => get_site_url() ) ); + } + + static function is_embedding_in_iframe() { + return ( + self::has_iframe_get_param() && ( + self::has_preview_get_param() || + self::has_preview_theme_preview_param() + ) + ); + } + + private static function has_iframe_get_param() { + return isset( $_GET['iframe'] ) && $_GET['iframe'] === 'true'; + } + + private static function has_preview_get_param() { + return isset( $_GET['preview'] ) && $_GET['preview'] === 'true'; + } + + private static function has_preview_theme_preview_param() { + return isset( $_GET['theme_preview'] ) && $_GET['theme_preview'] === 'true'; + } + + /** + * Disable `autoplay` shortcode attribute in context of an iframe + * Added via `shortcode_atts_video` & `shortcode_atts_audio` in `init` + * + * @param array $atts The output array of shortcode attributes. + * + * @return array The output array of shortcode attributes. + */ + static function disable_autoplay( $atts ) { + return array_merge( $atts, array( 'autoplay' => false ) ); + } + + /** + * We don't want search engines to index iframe previews + * Added via `wp_head` action in `init` + */ + static function noindex() { + echo '<meta name="robots" content="noindex,nofollow" />'; + } + + /** + * Make sure all links and forms open in a new window by default + * (unless overridden on client-side by JS) + * Added via `wp_head` action in `init` + */ + static function base_target_blank() { + echo '<base target="_blank" />'; + } +} diff --git a/plugins/jetpack/_inc/lib/class.jetpack-keyring-service-helper.php b/plugins/jetpack/_inc/lib/class.jetpack-keyring-service-helper.php new file mode 100644 index 00000000..c8005ea1 --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.jetpack-keyring-service-helper.php @@ -0,0 +1,204 @@ +<?php + +class Jetpack_Keyring_Service_Helper { + /** + * @var Jetpack_Keyring_Service_Helper + **/ + private static $instance = null; + + static function init() { + if ( is_null( self::$instance ) ) { + self::$instance = new Jetpack_Keyring_Service_Helper; + } + + return self::$instance; + } + + public static $SERVICES = array( + 'facebook' => array( + 'for' => 'publicize' + ), + 'twitter' => array( + 'for' => 'publicize' + ), + 'linkedin' => array( + 'for' => 'publicize' + ), + 'tumblr' => array( + 'for' => 'publicize' + ), + 'path' => array( + 'for' => 'publicize' + ), + 'google_plus' => array( + 'for' => 'publicize' + ), + 'google_site_verification' => array( + 'for' => 'other' + ) + ); + + private function __construct() { + add_action( 'load-settings_page_sharing', array( __CLASS__, 'admin_page_load' ), 9 ); + } + + function get_services( $filter = 'all' ) { + $services = array( + + ); + + if ( 'all' == $filter ) { + return $services; + } else { + $connected_services = array(); + foreach ( $services as $service => $empty ) { + $connections = $this->get_connections( $service ); + if ( $connections ) { + $connected_services[ $service ] = $connections; + } + } + return $connected_services; + } + } + + /** + * Gets a URL to the public-api actions. Works like WP's admin_url + * + * @param string $service Shortname of a specific service. + * + * @return URL to specific public-api process + */ + // on WordPress.com this is/calls Keyring::admin_url + static function api_url( $service = false, $params = array() ) { + /** + * Filters the API URL used to interact with WordPress.com. + * + * @since 2.0.0 + * + * @param string https://public-api.wordpress.com/connect/?jetpack=publicize Default Publicize API URL. + */ + $url = apply_filters( 'publicize_api_url', 'https://public-api.wordpress.com/connect/?jetpack=publicize' ); + + if ( $service ) { + $url = add_query_arg( array( 'service' => $service ), $url ); + } + + if ( count( $params ) ) { + $url = add_query_arg( $params, $url ); + } + + return $url; + } + + static function connect_url( $service_name, $for ) { + return add_query_arg( array( + 'action' => 'request', + 'service' => $service_name, + 'kr_nonce' => wp_create_nonce( 'keyring-request' ), + 'nonce' => wp_create_nonce( "keyring-request-$service_name" ), + 'for' => $for, + ), menu_page_url( 'sharing', false ) ); + } + + static function refresh_url( $service_name, $for ) { + return add_query_arg( array( + 'action' => 'request', + 'service' => $service_name, + 'kr_nonce' => wp_create_nonce( 'keyring-request' ), + 'refresh' => 1, + 'for' => $for, + 'nonce' => wp_create_nonce( "keyring-request-$service_name" ), + ), admin_url( 'options-general.php?page=sharing' ) ); + } + + static function disconnect_url( $service_name, $id ) { + return add_query_arg( array( + 'action' => 'delete', + 'service' => $service_name, + 'id' => $id, + 'kr_nonce' => wp_create_nonce( 'keyring-request' ), + 'nonce' => wp_create_nonce( "keyring-request-$service_name" ), + ), menu_page_url( 'sharing', false ) ); + } + + static function admin_page_load() { + if ( isset( $_GET['action'] ) ) { + if ( isset( $_GET['service'] ) ) { + $service_name = $_GET['service']; + } + + switch ( $_GET['action'] ) { + + case 'request': + check_admin_referer( 'keyring-request', 'kr_nonce' ); + check_admin_referer( "keyring-request-$service_name", 'nonce' ); + + $verification = Jetpack::generate_secrets( 'publicize' ); + if ( ! $verification ) { + $url = Jetpack::admin_url( 'jetpack#/settings' ); + wp_die( sprintf( __( "Jetpack is not connected. Please connect Jetpack by visiting <a href='%s'>Settings</a>.", 'jetpack' ), $url ) ); + + } + $stats_options = get_option( 'stats_options' ); + $wpcom_blog_id = Jetpack_Options::get_option( 'id' ); + $wpcom_blog_id = ! empty( $wpcom_blog_id ) ? $wpcom_blog_id : $stats_options['blog_id']; + + $user = wp_get_current_user(); + $redirect = Jetpack_Keyring_Service_Helper::api_url( $service_name, urlencode_deep( array( + 'action' => 'request', + 'redirect_uri' => add_query_arg( array( 'action' => 'done' ), menu_page_url( 'sharing', false ) ), + 'for' => 'publicize', + // required flag that says this connection is intended for publicize + 'siteurl' => site_url(), + 'state' => $user->ID, + 'blog_id' => $wpcom_blog_id, + 'secret_1' => $verification['secret_1'], + 'secret_2' => $verification['secret_2'], + 'eol' => $verification['exp'], + ) ) ); + wp_redirect( $redirect ); + exit; + break; + + case 'completed': + Jetpack::load_xml_rpc_client(); + $xml = new Jetpack_IXR_Client(); + $xml->query( 'jetpack.fetchPublicizeConnections' ); + + if ( ! $xml->isError() ) { + $response = $xml->getResponse(); + Jetpack_Options::update_option( 'publicize_connections', $response ); + } + + break; + + case 'delete': + $id = $_GET['id']; + + check_admin_referer( 'keyring-request', 'kr_nonce' ); + check_admin_referer( "keyring-request-$service_name", 'nonce' ); + + Jetpack_Keyring_Service_Helper::disconnect( $service_name, $id ); + + do_action( 'connection_disconnected', $service_name ); + break; + } + } + } + + /** + * Remove a Publicize connection + */ + static function disconnect( $service_name, $connection_id, $_blog_id = false, $_user_id = false, $force_delete = false ) { + Jetpack::load_xml_rpc_client(); + $xml = new Jetpack_IXR_Client(); + $xml->query( 'jetpack.deletePublicizeConnection', $connection_id ); + + if ( ! $xml->isError() ) { + Jetpack_Options::update_option( 'publicize_connections', $xml->getResponse() ); + } else { + return false; + } + } + +} diff --git a/plugins/jetpack/_inc/lib/class.jetpack-password-checker.php b/plugins/jetpack/_inc/lib/class.jetpack-password-checker.php new file mode 100644 index 00000000..14de6053 --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.jetpack-password-checker.php @@ -0,0 +1,1288 @@ +<?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName +/** + * The password strength checker. + * + * @package jetpack + */ + +/** + * Checks passwords strength. + */ +class Jetpack_Password_Checker { + + /** + * Minimum entropy bits a password should contain. 36 bits of entropy is considered + * to be a reasonable password, 28 stands for a weak one. + * + * @const Integer + */ + const MINIMUM_BITS = 28; + + /** + * Currently tested password. + * + * @var String + */ + public $password = ''; + + /** + * Test results array. + * + * @var Array + */ + public $test_results = ''; + + /** + * Current password score. + * + * @var Integer + */ + public $score = 0; + + /** + * Current multiplier affecting the score. + * + * @var Integer + */ + public $multiplier = 4; + + /** + * A common password blacklist, which on match will immediately disqualify the password. + * + * @var Array + */ + public $common_passwords = array(); + + /** + * Minimum password length setting. + * + * @var Integer + */ + public $min_password_length = 6; + + /** + * User defined strings that passwords need to be tested for a match against. + * + * @var Array + */ + private $user_strings_to_test = array(); + + /** + * The user object for whom the password is being tested. + * + * @var WP_User + */ + protected $user; + + /** + * The user identifier for whom the password is being tested, used if there's no user object. + * + * @var WP_User + */ + protected $user_id; + + /** + * Creates an instance of the password checker class for the specified user, or + * defaults to the currently logged in user. + * + * @param Mixed $user can be an integer ID, or a WP_User object. + */ + public function __construct( $user = null ) { + + /** + * Filters Jetpack's password strength enforcement settings. You can supply your own passwords + * that should not be used for authenticating in addition to weak and easy to guess strings for + * each user. For example, you can add passwords from known password databases to avoid compromised + * password usage. + * + * @since 7.2.0 + * + * @param Array $restricted_passwords strings that are forbidden for use as passwords. + */ + $this->common_passwords = apply_filters( 'jetpack_password_checker_restricted_strings', array() ); + + if ( is_null( $user ) ) { + $this->user_id = get_current_user_id(); + } elseif ( is_object( $user ) && isset( $user->ID ) ) { + + // Existing user, using their ID. + $this->user_id = $user->ID; + + } elseif ( is_object( $user ) ) { + + // Newly created user, using existing data. + $this->user = $user; + $this->user_id = 'new_user'; + + } else { + $this->user_id = $user; + } + $this->min_password_length = apply_filters( 'better_password_min_length', $this->min_password_length ); + } + + /** + * Run tests against a password. + * + * @param String $password the tested string. + * @param Boolean $required_only only test against required conditions, defaults to false. + * @return Array $results an array containing failed and passed test results. + */ + public function test( $password, $required_only = false ) { + + $this->password = $password; + $results = $this->run_tests( $this->list_tests(), $required_only ); + + // If we've failed on the required tests, return now. + if ( ! empty( $results['failed'] ) ) { + return array( + 'passed' => false, + 'test_results' => $results, + ); + } + + /** + * Filters Jetpack's password strength enforcement settings. You can modify the minimum + * entropy bits requirement using this filter. + * + * @since 7.2.0 + * + * @param Array $minimum_entropy_bits minimum entropy bits requirement. + */ + $bits = apply_filters( 'jetpack_password_checker_minimum_entropy_bits', self::MINIMUM_BITS ); + $entropy_bits = $this->calculate_entropy_bits( $this->password ); + + // If we have failed the entropy bits test, run the regex tests so we can suggest improvements. + if ( $entropy_bits < $bits ) { + $results['failed']['entropy_bits'] = $entropy_bits; + $results = array_merge( + $results, + $this->run_tests( $this->list_tests( 'preg_match' ), false ) + ); + } + + return( array( + 'passed' => empty( $results['failed'] ), + 'test_results' => $results, + ) ); + } + + /** + * Run the tests using the currently set up object values. + * + * @param Array $tests tests to run. + * @param Boolean $required_only whether to run only required tests. + * @return Array test results. + */ + protected function run_tests( $tests, $required_only = false ) { + + $results = array( + 'passed' => array(), + 'failed' => array(), + ); + + foreach ( $tests as $test_type => $section_tests ) { + foreach ( $section_tests as $test_name => $test_data ) { + + // Skip non-required tests if required_only param is set. + if ( $required_only && ! $test_data['required'] ) { + continue; + } + + $test_function = 'test_' . $test_type; + + $result = call_user_func( array( $this, $test_function ), $test_data ); + + if ( $result ) { + $results['passed'][] = array( 'test_name' => $test_name ); + } else { + $results['failed'][] = array( + 'test_name' => $test_name, + 'explanation' => $test_data['error'], + ); + + if ( isset( $test_data['fail_immediately'] ) ) { + return $results; + } + } + } + } + + return $results; + } + + /** + * Returns a list of tests that need to be run on password strings. + * + * @param Array $sections only return specific sections with the passed keys, defaults to all. + * @return Array test descriptions. + */ + protected function list_tests( $sections = false ) { + // Note: these should be in order of priority. + $tests = array( + 'preg_match' => array( + 'no_backslashes' => array( + 'pattern' => '^[^\\\\]*$', + 'error' => __( 'Passwords may not contain the character "\".', 'jetpack' ), + 'required' => true, + 'fail_immediately' => true, + ), + 'minimum_length' => array( + 'pattern' => '^.{' . $this->min_password_length . ',}', + /* translators: %d is a number of characters in the password. */ + 'error' => sprintf( __( 'Password must be at least %d characters.', 'jetpack' ), $this->min_password_length ), + 'required' => true, + 'fail_immediately' => true, + ), + 'has_mixed_case' => array( + 'pattern' => '([a-z].*?[A-Z]|[A-Z].*?[a-z])', + 'error' => __( 'This password is too easy to guess: you can improve it by adding additional uppercase letters, lowercase letters, or numbers.', 'jetpack' ), + 'trim' => true, + 'required' => false, + ), + 'has_digit' => array( + 'pattern' => '\d', + 'error' => __( 'This password is too easy to guess: you can improve it by mixing both letters and numbers.', 'jetpack' ), + 'trim' => false, + 'required' => false, + ), + 'has_special_char' => array( + 'pattern' => '[^a-zA-Z\d]', + 'error' => __( 'This password is too easy to guess: you can improve it by including special characters such as !#=?*&.', 'jetpack' ), + 'required' => false, + ), + ), + 'compare_to_list' => array( + 'not_a_common_password' => array( + 'list_callback' => 'get_common_passwords', + 'compare_callback' => 'negative_in_array', + 'error' => __( 'This is a very common password. Choose something that will be harder for others to guess.', 'jetpack' ), + 'required' => true, + ), + 'not_same_as_other_user_data' => array( + 'list_callback' => 'get_other_user_data', + 'compare_callback' => 'test_not_same_as_other_user_data', + 'error' => __( 'Your password is too weak: Looks like you\'re including easy to guess information about yourself. Try something a little more unique.', 'jetpack' ), + 'required' => true, + ), + ), + ); + + /** + * Filters Jetpack's password strength enforcement settings. You can determine the tests run + * and their order based on whatever criteria you wish to specify. + * + * @since 7.2.0 + * + * @param Array $minimum_entropy_bits minimum entropy bits requirement. + */ + $tests = apply_filters( 'jetpack_password_checker_tests', $tests ); + + if ( ! $sections ) { + return $tests; + } + + $sections = (array) $sections; + return array_intersect_key( $tests, array_flip( $sections ) ); + } + + /** + * Provides the regular expression tester functionality. + * + * @param Array $test_data the current test data. + * @return Boolean does the test pass? + */ + protected function test_preg_match( $test_data ) { + $password = stripslashes( $this->password ); + + if ( isset( $test_data['trim'] ) ) { + $password = substr( $password, 1, -1 ); + } + + if ( ! preg_match( '/' . $test_data['pattern'] . '/u', $password ) ) { + return false; + } + + return true; + } + + /** + * Provides the comparison tester functionality. + * + * @param Array $test_data the current test data. + * @return Boolean does the test pass? + */ + protected function test_compare_to_list( $test_data ) { + $list_callback = $test_data['list_callback']; + $compare_callback = $test_data['compare_callback']; + + if ( + ! is_callable( array( $this, $list_callback ) ) + || ! is_callable( array( $this, $compare_callback ) ) + ) { + return false; + } + + $list = call_user_func( array( $this, $list_callback ) ); + if ( empty( $list ) ) { + return true; + } + + return call_user_func( array( $this, $compare_callback ), $this->password, $list ); + } + + /** + * Getter for the common password list. + * + * @return Array common passwords. + */ + protected function get_common_passwords() { + return $this->common_passwords; + } + + /** + * Returns the widely known user data that can not be used in the password to avoid + * predictable strings. + * + * @return Array user data. + */ + protected function get_other_user_data() { + + if ( ! isset( $this->user ) ) { + $user_data = get_userdata( $this->user_id ); + + $first_name = get_user_meta( $user_data->ID, 'first_name', true ); + $last_name = get_user_meta( $user_data->ID, 'last_name', true ); + $nickname = get_user_meta( $user_data->ID, 'nickname', true ); + + $this->add_user_strings_to_test( $nickname ); + $this->add_user_strings_to_test( $user_data->user_nicename ); + $this->add_user_strings_to_test( $user_data->display_name ); + } else { + $user_data = $this->user; + + $first_name = $user_data->first_name; + $last_name = $user_data->last_name; + } + $email_username = substr( $user_data->user_email, 0, strpos( $user_data->user_email, '@' ) ); + + $this->add_user_strings_to_test( $user_data->user_email ); + $this->add_user_strings_to_test( $email_username, '.' ); + $this->add_user_strings_to_test( $first_name ); + $this->add_user_strings_to_test( $last_name ); + + return $this->user_strings_to_test; + } + + /** + * Compare the password for matches with known user data. + * + * @param String $password the string to be tested. + * @param Array $strings_to_test known user data. + * @return Boolean does the test pass? + */ + protected function test_not_same_as_other_user_data( $password, $strings_to_test ) { + $password_lowercase = strtolower( $password ); + foreach ( array_unique( $strings_to_test ) as $string ) { + if ( empty( $string ) ) { + continue; + } + + $string = strtolower( $string ); + $string_reversed = strrev( $string ); + + if ( $password_lowercase === $string || $password_lowercase === $string_reversed ) { + return false; + } + + // Also check for the string or reversed string with any numbers just stuck to the end to catch things like bob123 as passwords. + if ( + preg_match( '/^' . preg_quote( $string, '/' ) . '\d+$/', $password_lowercase ) + || preg_match( '/^' . preg_quote( $string_reversed, '/' ) . '\d+$/', $password_lowercase ) + ) { + return false; + } + } + return true; + } + + /** + * A shorthand for the not in array construct. + * + * @param Mixed $needle the needle. + * @param Array $haystack the haystack. + * @return is the needle not in the haystack? + */ + protected function negative_in_array( $needle, $haystack ) { + if ( in_array( $needle, $haystack, true ) ) { + return false; + } + + return true; + } + + /** + * A helper function used to break a single string into its constituents so + * that both the full string and its constituents and any variants thereof + * can be tested against the password. + * + * @param String $string the string to be broken down. + * @param String $explode_delimiter delimiter. + * @return NULL|Array array of fragments, or NULL on empty string. + */ + protected function add_user_strings_to_test( $string, $explode_delimiter = ' ' ) { + + // Don't check against empty strings. + if ( empty( $string ) ) { + return; + } + + $strings = explode( $explode_delimiter, $string ); + + // Remove any non alpha numeric characters from the strings to check against. + foreach ( $strings as $key => $_string ) { + $strings[ $key ] = preg_replace( '/[^a-zA-Z0-9]/', '', $_string ); + } + + // Check the original too. + $strings[] = $string; + + // Check the original minus non alpha numeric characters. + $strings[] = preg_replace( '/[^a-zA-Z0-9]/', '', $string ); + + // Remove any empty strings. + $strings = array_filter( $strings ); + $this->user_strings_to_test = array_merge( $this->user_strings_to_test, $strings ); + } + + /** + * Return a character set size that is used in the string. + * + * @param String $password the password. + * @return Integer number of different character sets in use. + */ + protected function get_charset_size( $password ) { + $size = 0; + + // Lowercase a-z. + if ( preg_match( '/[a-z]/', $password ) ) { + $size += 26; + } + + // Uppercase A-Z. + if ( preg_match( '/[A-Z]/', substr( $password, 1, -1 ) ) ) { + $size += 26; + } + + // Digits. + if ( preg_match( '/\d/', substr( $password, 1, -1 ) ) ) { + $size += 10; + } + + // Over digits symbols. + if ( preg_match( '/[!|@|#|$|%|^|&|*|(|)]/', $password ) ) { + $size += 10; + } + + // Other symbols. + if ( preg_match( '#[`|~|-|_|=|+|\[|{|\]|}|\\|\|;:\'",<\.>/\?]#', $password ) ) { + $size += 20; + } + + // Spaces. + if ( strpos( $password, ' ' ) ) { + $size++; + } + + return $size; + } + + /** + * Shorthand for getting a character index. + * + * @param String $char character. + * @return Integer the character code. + */ + protected function get_char_index( $char ) { + $char = strtolower( $char[0] ); + if ( $char < 'a' || $char > 'z' ) { + return 0; + } else { + return ord( $char[0] ) - ord( 'a' ) + 1; + } + } + + /** + * This is the password strength calculation algorithm, based on the formula H = L(logN/log2). + * + * H = Entropy + * L = String length (the for iterator) + * N = Our charset size, via get_charset_size() + * + * @see http://en.wikipedia.org/wiki/Password_strength#Random_passwords + * + * On top of the base formula, we're also multiplying the bits of entropy for every char + * by 1 - (the probabily of it following the previous char) + * i.e.: the probablity of U following Q is ~0.84. If our password contains this pair of characters, + * the u char will only add ( 0.16^2 * charset_score ) to our total of entropy bits. + * + * @param String $password the password. + */ + protected function calculate_entropy_bits( $password ) { + $bits = 0; + $charset_score = log( $this->get_charset_size( $password ) ) / log( 2 ); + + $aidx = $this->get_char_index( $password[0] ); + $length = strlen( $password ); + + for ( $b = 1; $b < $length; $b++ ) { + $bidx = $this->get_char_index( $password[ $b ] ); + + // 27 = number of chars in the index (a-z,' '). + $c = 1.0 - $this->frequency_table[ $aidx * 27 + $bidx ]; + $bits += $charset_score * $c * $c; + + // Move on to next pair. + $aidx = $bidx; + } + + return $bits; + } + + /** + * A frequency table of character pairs, starting with ' ' then ' a', ' b' [...] , 'a ', 'aa' etc. + * + * @see http://rumkin.com/tools/password/passchk.php + * @var Array + */ + public $frequency_table = array( + 0.23653710453418866, + 0.04577693541332556, + 0.03449832337075375, + 0.042918209651552706, + 0.037390873305146524, + 0.028509112115468728, + 0.02350896632162123, + 0.022188657238664526, + 0.028429800262428927, + 0.04357019973757107, + 0.00913602565971716, + 0.03223093745443942, + 0.02235311269864412, + 0.04438081352966905, + 0.04512377897652719, + 0.020055401662049863, + 0.055903192885260244, + 0.0024388394809739026, + 0.035207464644991984, + 0.07355941099285611, + 0.036905671380667734, + 0.026134421927394666, + 0.023787724158040528, + 0.011352092141711621, + 0.0032354570637119114, + 0.005986878553725033, + 0.008861933226417843, + 0.11511532293337222, + 0.027556203528211108, + 0.024331243621519172, + 0.039266365359381834, + 0.031599941682461, + 0.014403265782183991, + 0.015480973902901297, + 0.027770812071730572, + 0.00942761335471643, + 0.039872867764980315, + 0.0078122175244204695, + 0.02808456043154979, + 0.08429100451960927, + 0.04688963405744277, + 0.13831170724595424, + 0.002540311998833649, + 0.025211838460416972, + 0.001543082081936142, + 0.09519638431258201, + 0.061845750109345385, + 0.08907071001603732, + 0.02137571074500656, + 0.027093162268552268, + 0.005521504592506197, + 0.003023181221752442, + 0.007086747339262283, + 0.010262720513194342, + 0.08785070710016038, + 0.14617757690625455, + 0.03417291150313457, + 0.0059635515381250915, + 0.006146668610584633, + 0.195202799241872, + 0.002774748505613063, + 0.004715556203528212, + 0.0044776206444088066, + 0.11205481848665985, + 0.005654468581425864, + 0.0028820527773727946, + 0.07383000437381543, + 0.005516839189386207, + 0.006496573844583759, + 0.09843067502551392, + 0.0027140982650532145, + 0.0006893133109782768, + 0.08425368129464937, + 0.021325557661466685, + 0.006493074792243767, + 0.07023414491908442, + 0.002077270739174807, + 0.0024633328473538415, + 0.0007744569179180639, + 0.015413325557661468, + 0.0011990086018370024, + 0.13162851727657093, + 0.10115993585070711, + 0.0026989357049132527, + 0.03319317684793702, + 0.002946202070272634, + 0.0783216212275842, + 0.0018358361277154103, + 0.00258813238081353, + 0.2141688292754046, + 0.09853681294649366, + 0.0032482869222918796, + 0.04359352675317102, + 0.01993526753171016, + 0.0036880011663507797, + 0.008011663507799971, + 0.12014696019827964, + 0.0029846916460125384, + 0.0017553579238956116, + 0.029470185158186325, + 0.010413179763813967, + 0.030699518880303252, + 0.03508499781309229, + 0.002021285901734947, + 0.0010613792097973467, + 0.0005295232541186761, + 0.009677212421635807, + 0.010585799679253535, + 0.17101734946785244, + 0.07968625164018078, + 0.007839043592360402, + 0.005438693687126403, + 0.0183606939787141, + 0.2732701559994168, + 0.004953491762647616, + 0.007259367254701851, + 0.008104971570199739, + 0.13274588132380813, + 0.004210526315789474, + 0.004997813092287506, + 0.017006560723137484, + 0.007442484327161393, + 0.016789619478058026, + 0.08477737279486806, + 0.005106283714827234, + 0.0005026971861787433, + 0.04040355736987899, + 0.037535500801866156, + 0.00885960052485785, + 0.0336410555474559, + 0.007066919376002332, + 0.005344219273946639, + 0.0006333284735384167, + 0.010684939495553289, + 0.0063064586674442345, + 0.15386849394955532, + 0.015049424114302375, + 0.012162705933809595, + 0.020425134859308938, + 0.037366379938766583, + 0.02157165767604607, + 0.009373961218836564, + 0.0173214754337367, + 0.009616562181075958, + 0.029522670943286193, + 0.010154249890654615, + 0.018600962239393497, + 0.06362210234728094, + 0.03157078291296107, + 0.151603440734801, + 0.0062329785683044175, + 0.014775331681003062, + 0.0020854351946347867, + 0.1826342032366234, + 0.0878017203674005, + 0.054190989940224525, + 0.010329202507654177, + 0.012763376585508092, + 0.0064872430383437815, + 0.006381105117364048, + 0.005388540603586529, + 0.0090800408222773, + 0.09611196967487973, + 0.09940691062837148, + 0.01033969966467415, + 0.004034407348009914, + 0.008826942703017933, + 0.11474675608689314, + 0.07132584924916169, + 0.012388977985129028, + 0.005435194634786413, + 0.1417174515235457, + 0.0037066627788307337, + 0.0045802595130485495, + 0.060800699810468, + 0.005341886572386646, + 0.005683627350925791, + 0.12434932205860913, + 0.004596588423968508, + 0.0007534626038781163, + 0.07107041842834232, + 0.022361277154104096, + 0.04784720804782038, + 0.06277533168100306, + 0.003441901151771395, + 0.005828254847645429, + 0.0009669047966175828, + 0.009470768333576322, + 0.002077270739174807, + 0.12797667298440007, + 0.08797783933518005, + 0.005388540603586529, + 0.0024913252660737715, + 0.007550954949701123, + 0.2786866890217233, + 0.002509986878553725, + 0.029002478495407494, + 0.0303204548768042, + 0.07576614666861058, + 0.00246799825047383, + 0.00592389561160519, + 0.039574281965301064, + 0.00706808572678233, + 0.03304505029887739, + 0.05474150750838315, + 0.0028633911648928414, + 0.0005073625892987316, + 0.07293541332555767, + 0.053528502697186175, + 0.022566554891383584, + 0.038151334013704616, + 0.002716430966613209, + 0.005049132526607377, + 0.0009902318122175246, + 0.008997229916897508, + 0.0011861787432570347, + 0.1666377022889634, + 0.14414462749671964, + 0.003374252806531564, + 0.005169266656947077, + 0.008468873013558828, + 0.16337541915731155, + 0.002873888321912815, + 0.004305000728969237, + 0.0031141565825922144, + 0.1241172182533897, + 0.0052800699810468, + 0.008969237498177577, + 0.024094474413179766, + 0.017029887738737422, + 0.01722700102055693, + 0.10618457501093455, + 0.006147834961364631, + 0.0008269427030179326, + 0.03303571949263741, + 0.024188948826359528, + 0.05213937891820965, + 0.04505846333284735, + 0.0035270447587111824, + 0.006799825047383001, + 0.0008199445983379502, + 0.02206735675754483, + 0.001010059775477475, + 0.11971191135734072, + 0.04656538854060359, + 0.011243621519171892, + 0.06513019390581717, + 0.032375564951159064, + 0.06347047674588133, + 0.013678961947805804, + 0.03309870243475726, + 0.006982942119842543, + 0.009726199154395685, + 0.010121592068814697, + 0.032514360693978714, + 0.04986032949409535, + 0.039734072022160664, + 0.15690683773144773, + 0.03949963551538125, + 0.014790494241143023, + 0.002722262720513194, + 0.02614375273363464, + 0.10753637556495116, + 0.06764834523983088, + 0.006221315060504448, + 0.021317393206006705, + 0.0030826651115322934, + 0.002399183554454002, + 0.0019069835252952323, + 0.015595276279341012, + 0.0925126111678087, + 0.18437906400349907, + 0.006538562472663654, + 0.008719638431258201, + 0.02116693395538708, + 0.18241376293920394, + 0.007290858725761773, + 0.005976381396705059, + 0.005629975215045925, + 0.09721300481119698, + 0.004810030616707975, + 0.024303251202799244, + 0.012954658113427612, + 0.011057005394372358, + 0.02733459688001166, + 0.10135121737862662, + 0.012016912086309959, + 0.001055547455897361, + 0.009027555037177431, + 0.07162326869806095, + 0.01007143898527482, + 0.07297623560285756, + 0.006741507508383147, + 0.0036891675171307776, + 0.0008409389123778977, + 0.011272780288671819, + 0.007020265344802449, + 0.1030389269572824, + 0.15350809155853623, + 0.004232686980609419, + 0.004353987461729115, + 0.0023385333138941536, + 0.14450386353695874, + 0.002546143752733635, + 0.0024470039364338824, + 0.01200758128006998, + 0.0981227584195947, + 0.003161976964572095, + 0.040695145064878264, + 0.03460446129173349, + 0.003908441463770229, + 0.01598483743986004, + 0.13107216795451232, + 0.003129319142732177, + 0.00032307916605919226, + 0.04050386353695874, + 0.05452689896486368, + 0.03589677795597026, + 0.07087097244496282, + 0.006143169558244642, + 0.008684647907858289, + 0.0004607085580988482, + 0.022010205569324977, + 0.0009097536083977258, + 0.07328765126111678, + 0.14751421490013122, + 0.008015162560139961, + 0.006601545414783497, + 0.025279486805656802, + 0.1682449336637994, + 0.008313748359819215, + 0.007010934538562473, + 0.005886572386645284, + 0.16889575739903775, + 0.004123050007289692, + 0.011925936725470185, + 0.10007289692374982, + 0.013380376148126549, + 0.009021723283277445, + 0.08650823735238372, + 0.007756232686980609, + 0.0007243038343781893, + 0.0026791077416533026, + 0.02797492345823006, + 0.032384895757399036, + 0.04187432570345531, + 0.00882461000145794, + 0.0032401224668318998, + 0.00033357632307916605, + 0.027878116343490307, + 0.0022277299897944304, + 0.14333518005540166, + 0.1725534334451086, + 0.02781629975215046, + 0.006909462020702727, + 0.005264907420906838, + 0.16661437527336345, + 0.004325995043009185, + 0.003334596880011664, + 0.005312727802886718, + 0.14024668318996938, + 0.0013261408368566844, + 0.003504884093891238, + 0.006375273363464061, + 0.04964922000291588, + 0.008290421344219274, + 0.09536783787724158, + 0.05394372357486515, + 0.005505175681586237, + 0.005339553870826651, + 0.01782067356757545, + 0.006710016037323225, + 0.05105933809593235, + 0.002983525295232541, + 0.002940370316372649, + 0.0004548768041988629, + 0.01208456043154979, + 0.000915585362297711, + 0.20146260387811635, + 0.067196967487972, + 0.006158332118384605, + 0.025438110511736407, + 0.07753783350342616, + 0.1273876658405015, + 0.009337804344656656, + 0.07683452398308792, + 0.0070412596588423975, + 0.08747164309666132, + 0.0038827817466102928, + 0.018116926665694706, + 0.005017641055547455, + 0.004567429654468581, + 0.028277008310249308, + 0.05271555620352821, + 0.004394809739029013, + 0.0013343052923166642, + 0.00411605190260971, + 0.059621519171890944, + 0.09073859163143316, + 0.01446858142586383, + 0.006770666277883074, + 0.003425572240851436, + 0.0004455459979588861, + 0.010401516256013998, + 0.005825922146085436, + 0.10833882490158916, + 0.007584779122321038, + 0.016903921854497742, + 0.02719580113719201, + 0.0304814112844438, + 0.02206385770520484, + 0.013064295086747339, + 0.02696369733197259, + 0.009581571657676046, + 0.026761918647033093, + 0.006510570053943724, + 0.021941390873305145, + 0.07042659279778393, + 0.05437410701268406, + 0.1425175681586237, + 0.027802303542790494, + 0.037690625455605774, + 0.0019606356611750987, + 0.1095623268698061, + 0.06157748942994606, + 0.044618749088788455, + 0.04955124653739612, + 0.03608689313310978, + 0.018381688292754043, + 0.003404577926811489, + 0.015036594255722409, + 0.009600233270156, + 0.10794693103951014, + 0.12447528794284882, + 0.0031981338387520046, + 0.0074716430966613205, + 0.003202799241871993, + 0.13437643971424407, + 0.006655197550663361, + 0.0036693395538708266, + 0.049338970695436656, + 0.09486863974340283, + 0.0015990669193760023, + 0.0026604461291733486, + 0.051775477474850555, + 0.0041347135150896636, + 0.005450357194926374, + 0.12030325120279925, + 0.04581309228750547, + 0.0004537104534188657, + 0.12425601399620935, + 0.025981629975215047, + 0.023926519900860182, + 0.04423385333138941, + 0.0017950138504155123, + 0.002661612479953346, + 0.0006333284735384167, + 0.008449045050298877, + 0.000653156436798367, + 0.04816678816153958, + 0.008625164018078437, + 0.0039037760606502403, + 0.005228750546726928, + 0.004531272780288672, + 0.0056672984400058316, + 0.00359585945473101, + 0.0032179618020119548, + 0.0038093016474704767, + 0.011452398308791368, + 0.002519317684793702, + 0.00280390727511299, + 0.005572824026826068, + 0.004554599795888614, + 0.004531272780288672, + 0.0035841959469310393, + 0.004400641492928998, + 0.0036670068523108326, + 0.004839189386207902, + 0.006258638285464354, + 0.004897506925207757, + 0.840776789619478, + 0.004968654322787578, + 0.002886718180492783, + 0.0019757982213150604, + 0.0018568304417553576, + 0.001691208630995772, + 0.09009243329931477, + 0.14030150167662925, + 0.013242746756086894, + 0.013746610293045632, + 0.027342761335471644, + 0.16938912377897652, + 0.006607377168683481, + 0.01661933226417845, + 0.008173786266219566, + 0.13297448607668758, + 0.0034675608689313307, + 0.016641492928998396, + 0.011722991689750693, + 0.021493512173786266, + 0.03430820819361423, + 0.10099548039072752, + 0.00873596734217816, + 0.0018323370753754193, + 0.020103222044029742, + 0.047197550663362, + 0.040833940807697915, + 0.03361189677795597, + 0.010844729552412887, + 0.005544831608106138, + 0.0007522962530981193, + 0.01525120279924187, + 0.00815512465373961, + 0.2109648636827526, + 0.058258055110074355, + 0.007181221752442048, + 0.043560868931331105, + 0.004058900714389853, + 0.10618107595859454, + 0.0062399766729844, + 0.004835690333867911, + 0.02679224376731302, + 0.08414637702288964, + 0.0030698352529523252, + 0.03637498177576906, + 0.01592885260242018, + 0.017413617145356466, + 0.008430383437818923, + 0.037231083248286924, + 0.03290275550371775, + 0.007538125091121154, + 0.004500947660008748, + 0.05932409972299169, + 0.16006764834523984, + 0.03309636973319726, + 0.007766729844000583, + 0.005225251494386936, + 0.0006321621227584196, + 0.012989648636827526, + 0.005274238227146815, + 0.1254503571949264, + 0.12852719055255868, + 0.0035433736696311416, + 0.005203090829566993, + 0.0019314768916751715, + 0.20520775623268697, + 0.002509986878553725, + 0.00343606939787141, + 0.027138649948972155, + 0.13926578218399185, + 0.004565096952908587, + 0.005614812654905963, + 0.00874413179763814, + 0.004109053797929727, + 0.008300918501239247, + 0.08270943286193323, + 0.002912377897652719, + 0.0037066627788307337, + 0.06909578655780726, + 0.03242805073625893, + 0.05237614812654906, + 0.04723487388832191, + 0.0038991106575302524, + 0.006299460562764251, + 0.00043388249015891526, + 0.020029741944889927, + 0.005311561452106721, + 0.09334072022160665, + 0.022940953491762648, + 0.024658988190698353, + 0.02901297565242747, + 0.03531593526753171, + 0.0758023035427905, + 0.013711619769645722, + 0.021597317393206007, + 0.009670214316955824, + 0.044728386062108175, + 0.010596296836273509, + 0.03264382563055839, + 0.0604822860475288, + 0.05489546581134276, + 0.11501851581863246, + 0.01837585653885406, + 0.026237060796034405, + 0.0011255285026971862, + 0.08704125965884241, + 0.10156349322058608, + 0.06660562764251349, + 0.023434319871701415, + 0.010777081207173057, + 0.005409534917626476, + 0.003123487388832191, + 0.0028762210234728096, + 0.0089995626184575, + 0.07518297127861205, + 0.2314868056568013, + 0.002226563639014434, + 0.003285610147251786, + 0.0027455897361131363, + 0.2724537104534189, + 0.0016655489138358362, + 0.0019209797346551977, + 0.0022137337804344656, + 0.17690392185449774, + 0.0014532730718763668, + 0.0024994897215337513, + 0.015302522233561744, + 0.003441901151771395, + 0.015303688584341741, + 0.09314593964134713, + 0.0017833503426155418, + 0.0005108616416387229, + 0.017828838023035427, + 0.010385187345094037, + 0.003168975069252078, + 0.01902901297565243, + 0.005525003644846187, + 0.0010088934246974776, + 0.0009272488700976819, + 0.036282840064149294, + 0.0022977110365942554, + 0.0766805656801283, + 0.22270418428342326, + 0.005283569033386791, + 0.007155562035282111, + 0.01173582154833066, + 0.1715620352821111, + 0.003925936725470185, + 0.004425134859308937, + 0.020040239101909902, + 0.14243242455168392, + 0.0016737133692958156, + 0.0066808572678232975, + 0.011980755212130047, + 0.012638577052048404, + 0.07206065024055984, + 0.08115701997375711, + 0.00710424260096224, + 0.0007278028867181805, + 0.02347630849978131, + 0.04595538708266512, + 0.01481965301064295, + 0.013925061962385188, + 0.0018125091121154687, + 0.00529173348884677, + 0.0016340574427759146, + 0.03072401224668319, + 0.0023746901880740633, + 0.25174165330223064, + 0.06673392622831317, + 0.00878378772415804, + 0.03956261845750109, + 0.010077270739174807, + 0.0844787869951888, + 0.00985216503863537, + 0.004973319725907567, + 0.01893220586091267, + 0.11200583175389998, + 0.0028715556203528212, + 0.004095057588569762, + 0.01202391019098994, + 0.01756757544831608, + 0.014825484764542934, + 0.05312961073042717, + 0.06746872721971132, + 0.003845458521650386, + 0.0210806239976673, + 0.019443067502551394, + 0.08017028721387957, + 0.01825572240851436, + 0.005365213587986587, + 0.01959702580551101, + 0.026184575010934536, + 0.02474879720075813, + 0.002171745152354571, + 0.25827321767021433, + 0.048050153083539875, + 0.01043184137629392, + 0.03930485493512174, + 0.027640180784370902, + 0.03294007872867765, + 0.006474413179763814, + 0.018314039947514214, + 0.015119405161102202, + 0.014706516984983233, + 0.005494678524566263, + 0.03309870243475726, + 0.043864120134130345, + 0.058996355153812505, + 0.06265986295378335, + 0.04633328473538417, + 0.03790756670068523, + 0.0004642076104388394, + 0.037849249161685375, + 0.08369966467415076, + 0.04999679253535501, + 0.02392768625164018, + 0.010998687855372504, + 0.009881323808135296, + 0.003867619186470331, + 0.012434465665548913, + 0.007253535500801866, + 0.11106225397288234, + 0.17624726636535937, + 0.008209943140399476, + 0.008390727511299025, + 0.012682898381688294, + 0.1825653885406036, + 0.001538416678816154, + 0.004590756670068524, + 0.008710307625018223, + 0.1299513048549351, + 0.002677941390873305, + 0.012309666132089225, + 0.014087184720804781, + 0.01199941682461, + 0.031246537396121883, + 0.07206648199445984, + 0.008254264470039366, + 0.0007033095203382417, + 0.007034261554162415, + 0.006599212713223502, + 0.013906400349905234, + 0.050098265053214755, + 0.007133401370462167, + 0.017750692520775622, + 0.0008257763522379356, + 0.03918821985712203, + 0.06015454147834961, + ); +} diff --git a/plugins/jetpack/_inc/lib/class.jetpack-photon-image-sizes.php b/plugins/jetpack/_inc/lib/class.jetpack-photon-image-sizes.php new file mode 100644 index 00000000..8dc22d19 --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.jetpack-photon-image-sizes.php @@ -0,0 +1,182 @@ +<?php +/** + * The Image Sizes library. + * + * @package jetpack + */ + +jetpack_require_lib( 'class.jetpack-photon-image' ); + +/** + * Class Jetpack_Photon_ImageSizes + * + * Manages image resizing via Jetpack CDN Service. + */ +class Jetpack_Photon_ImageSizes { + + /** + * @var array $data Attachment metadata. + */ + public $data; + + /** + * @var Image Image to be resized. + */ + public $image; + + /** + * @var null|array $sizes Intermediate sizes. + */ + public static $sizes = null; + + /** + * Construct new sizes meta + * + * @param int $attachment_id Attachment ID. + * @param array $data Attachment metadata. + */ + public function __construct( $attachment_id, $data ) { + $this->data = $data; + $this->image = new Jetpack_Photon_Image( $data, get_post_mime_type( $attachment_id ) ); + $this->generate_sizes(); + } + + /** + * Generate sizes for attachment. + * + * @return array Array of sizes; empty array as failure fallback. + */ + protected function generate_sizes() { + + // There is no need to generate the sizes a new for every single image. + if ( null !== self::$sizes ) { + return self::$sizes; + } + + /* + * The following logic is copied over from wp_generate_attachment_metadata + */ + $_wp_additional_image_sizes = wp_get_additional_image_sizes(); + + $sizes = array(); + + $intermediate_image_sizes = get_intermediate_image_sizes(); + + foreach ( $intermediate_image_sizes as $s ) { + $sizes[ $s ] = array( + 'width' => '', + 'height' => '', + 'crop' => false, + ); + if ( isset( $_wp_additional_image_sizes[ $s ]['width'] ) ) { + // For theme-added sizes. + $sizes[ $s ]['width'] = intval( $_wp_additional_image_sizes[ $s ]['width'] ); + } else { + // For default sizes set in options. + $sizes[ $s ]['width'] = get_option( "{$s}_size_w" ); + } + + if ( isset( $_wp_additional_image_sizes[ $s ]['height'] ) ) { + // For theme-added sizes. + $sizes[ $s ]['height'] = intval( $_wp_additional_image_sizes[ $s ]['height'] ); + } else { + // For default sizes set in options. + $sizes[ $s ]['height'] = get_option( "{$s}_size_h" ); + } + + if ( isset( $_wp_additional_image_sizes[ $s ]['crop'] ) ) { + // For theme-added sizes. + $sizes[ $s ]['crop'] = $_wp_additional_image_sizes[ $s ]['crop']; + } else { + // For default sizes set in options. + $sizes[ $s ]['crop'] = get_option( "{$s}_crop" ); + } + } + + self::$sizes = $sizes; + + return $sizes; + } + + /** + * @return array + */ + public function filtered_sizes() { + // Remove filter preventing the creation of advanced sizes. + remove_filter( + 'intermediate_image_sizes_advanced', + array( 'Jetpack_Photon', 'filter_photon_noresize_intermediate_sizes' ) + ); + + /** This filter is documented in wp-admin/includes/image.php */ + $sizes = apply_filters( 'intermediate_image_sizes_advanced', self::$sizes, $this->data ); + + // Re-add the filter removed above. + add_filter( + 'intermediate_image_sizes_advanced', + array( 'Jetpack_Photon', 'filter_photon_noresize_intermediate_sizes' ) + ); + + return (array) $sizes; + } + + /** + * Standardises and validates the size_data array. + * + * @param array $size_data Size data array - at least containing height or width key. Can contain crop as well. + * + * @return array Array with populated width, height and crop keys; empty array if no width and height are provided. + */ + public function standardize_size_data( $size_data ) { + $has_at_least_width_or_height = ( isset( $size_data['width'] ) || isset( $size_data['height'] ) ); + if ( ! $has_at_least_width_or_height ) { + return array(); + } + + $defaults = array( + 'width' => null, + 'height' => null, + 'crop' => false, + ); + + return array_merge( $defaults, $size_data ); + } + + /** + * Get sizes for attachment post meta. + * + * @return array ImageSizes for attachment postmeta. + */ + public function generate_sizes_meta() { + + $metadata = array(); + + foreach ( $this->filtered_sizes() as $size => $size_data ) { + + $size_data = $this->standardize_size_data( $size_data ); + + if ( true === empty( $size_data ) ) { + continue; + } + + $resized_image = $this->resize( $size_data ); + + if ( true === is_array( $resized_image ) ) { + $metadata[ $size ] = $resized_image; + } + } + + return $metadata; + } + + /** + * @param array $size_data + * + * @return array|\WP_Error Array for usage in $metadata['sizes']; WP_Error on failure. + */ + protected function resize( $size_data ) { + + return $this->image->get_size( $size_data ); + + } +} diff --git a/plugins/jetpack/_inc/lib/class.jetpack-photon-image.php b/plugins/jetpack/_inc/lib/class.jetpack-photon-image.php new file mode 100644 index 00000000..d364f2a3 --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.jetpack-photon-image.php @@ -0,0 +1,243 @@ +<?php +/** + * The Image Class. + * + * @package Jetpack + */ + +/** + * Represents a resizable image, exposing properties necessary for properly generating srcset. + */ +class Jetpack_Photon_Image { + + /** + * @var string $filename Attachment's Filename. + */ + public $filename; + + /** + * @var string/WP_Erorr $mime_type Attachment's mime-type, WP_Error on failure when recalculating the dimensions. + */ + private $mime_type; + + /** + * @var int $original_width Image original width. + */ + private $original_width; + + /** + * @var int $original_width Image original height. + */ + private $original_height; + + /** + * @var int $width Current attachment's width. + */ + private $width; + + /** + * @var int $height Current attachment's height. + */ + private $height; + + /** + * @var bool $is_resized Whether the attachment has been resized yet, or not. + */ + private $is_resized = false; + + /** + * Constructs the image object. + * + * The $data array should provide at least + * file : string Image file path + * width : int Image width + * height : int Image height + * + * @param array $data Array of attachment metadata, typically value of _wp_attachment_metadata postmeta + * @param string|\WP_Error $mime_type Typically value returned from get_post_mime_type function. + */ + public function __construct( $data, $mime_type ) { + $this->filename = $data['file']; + $this->width = $this->original_width = $data['width']; + $this->height = $this->original_height = $data['height']; + $this->mime_type = $mime_type; + } + + /** + * Resizes the image to given size. + * + * @param array $size_data Array of width, height, and crop properties of a size. + * + * @return bool|\WP_Error True if resize was successful, WP_Error on failure. + */ + public function resize( $size_data ) { + + $dimensions = $this->image_resize_dimensions( $size_data['width'], $size_data['height'], $size_data['crop'] ); + + if ( true === is_wp_error( $dimensions ) ) { + return $dimensions; // Returns \WP_Error. + } + + if ( true === is_wp_error( $this->mime_type ) ) { + return $this->mime_type; // Returns \WP_Error. + } + + $this->set_width_height( $dimensions ); + + return $this->is_resized = true; + } + + /** + * Generates size data for usage in $metadata['sizes'];. + * + * @param array $size_data Array of width, height, and crop properties of a size. + * + * @return array|\WP_Error An array containing file, width, height, and mime-type keys and it's values. WP_Error on failure. + */ + public function get_size( $size_data ) { + + $is_resized = $this->resize( $size_data ); + + if ( true === is_wp_error( $is_resized ) ) { + return $is_resized; + } + + return array( + 'file' => $this->get_filename(), + 'width' => $this->get_width(), + 'height' => $this->get_height(), + 'mime-type' => $this->get_mime_type(), + ); + } + + /** + * Resets the image to it's original dimensions. + * + * @return bool True on successful reset to original dimensions. + */ + public function reset_to_original() { + $this->width = $this->original_width; + $this->height = $this->original_height; + $this->is_resized = false; + + return true; + } + + /** + * Return the basename filename. If the image has been resized, including + * the resizing params for Jetpack CDN. + * + * @return string Basename of the filename. + */ + public function get_filename() { + + if ( true === $this->is_resized() ) { + $filename = $this->get_resized_filename(); + } else { + $filename = $this->filename; + } + + return wp_basename( $filename ); + } + + /** + * Returns current image width. Either original, or after resize. + * + * @return int + */ + public function get_width() { + return (int) $this->width; + } + + /** + * Returns current image height. Either original, or after resize. + * + * @return int + */ + public function get_height() { + return (int) $this->height; + } + + /** + * Returns image mime type. + * + * @return string|WP_Error Image's mime type or WP_Error if it was not determined. + */ + public function get_mime_type() { + return $this->mime_type; + } + + /** + * Checks the resize status of the image. + * + * @return bool If the image has been resized. + */ + public function is_resized() { + return ( true === $this->is_resized ); + } + + /** + * Get filename with proper args for the Photon service. + * + * @return string Filename with query args for Photon service + */ + protected function get_resized_filename() { + $query_args = array( + 'resize' => join( + ',', + array( + $this->get_width(), + $this->get_height(), + ) + ), + ); + + return add_query_arg( $query_args, $this->filename ); + } + + /** + * Get resize dimensions used for the Jetpack CDN service. + * + * Converts the list of values returned from `image_resize_dimensions()` to + * associative array for the sake of more readable code no relying on index + * nor `list`. + * + * @param int $max_width + * @param int $max_height + * @param bool|array $crop + * + * @return array|\WP_Error Array of dimensions matching the parameters to imagecopyresampled. WP_Error on failure. + */ + protected function image_resize_dimensions( $max_width, $max_height, $crop ) { + $dimensions = image_resize_dimensions( $this->original_width, $this->original_height, $max_width, $max_height, $crop ); + if ( ! $dimensions ) { + return new WP_Error( 'error_getting_dimensions', __( 'Could not calculate resized image dimensions' ), $this->filename ); + } + + return array_combine( + array( + 'dst_x', + 'dst_y', + 'src_x', + 'src_y', + 'dst_w', + 'dst_h', + 'src_w', + 'src_h', + ), + $dimensions + ); + } + + /** + * Sets proper width and height from dimensions. + * + * @param Array $dimensions an array of image dimensions. + * @return void + */ + protected function set_width_height( $dimensions ) { + $this->width = (int) $dimensions['dst_w']; + $this->height = (int) $dimensions['dst_h']; + } + +} diff --git a/plugins/jetpack/_inc/lib/class.jetpack-search-performance-logger.php b/plugins/jetpack/_inc/lib/class.jetpack-search-performance-logger.php new file mode 100644 index 00000000..f8de70ee --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.jetpack-search-performance-logger.php @@ -0,0 +1,83 @@ +<?php + +class Jetpack_Search_Performance_Logger { + /** + * @var Jetpack_Search_Performance_Logger + **/ + private static $instance = null; + + private $current_query = null; + private $query_started = null; + private $stats = null; + + static function init() { + if ( is_null( self::$instance ) ) { + self::$instance = new Jetpack_Search_Performance_Logger; + } + + return self::$instance; + } + + private function __construct() { + $this->stats = array(); + add_action( 'pre_get_posts', array( $this, 'begin_log_query' ), 10, 1 ); + add_action( 'did_jetpack_search_query', array( $this, 'log_jetpack_search_query' ) ); + add_filter( 'found_posts', array( $this, 'log_mysql_query' ), 10, 2 ); + add_action( 'wp_footer', array( $this, 'print_stats' ) ); + } + + public function begin_log_query( $query ) { + if ( $this->should_log_query( $query ) ) { + $this->query_started = microtime( true ); + $this->current_query = $query; + } + } + + public function log_mysql_query( $found_posts, $query ) { + if ( $this->current_query === $query ) { + $duration = microtime( true ) - $this->query_started; + if ( $duration < 60 ) { // eliminate outliers, likely tracking errors + $this->record_query_time( $duration, false ); + } + $this->reset_query_state(); + } + + return $found_posts; + } + + public function log_jetpack_search_query() { + $duration = microtime( true ) - $this->query_started; + if ( $duration < 60 ) { // eliminate outliers, likely tracking errors + $this->record_query_time( $duration, true ); + } + $this->reset_query_state(); + } + + private function reset_query_state() { + $this->query_started = null; + $this->current_query = null; + } + + private function should_log_query( $query ) { + return $query->is_main_query() && $query->is_search(); + } + + private function record_query_time( $duration, $was_jetpack_search ) { + $this->stats[] = array( $was_jetpack_search, intval( $duration * 1000 ) ); + } + + public function print_stats() { + $beacons = array(); + if ( ! empty( $this->stats ) ) { + foreach( $this->stats as $stat ) { + $search_type = $stat[0] ? 'es' : 'mysql'; + $beacons[] = "%22jetpack.search.{$search_type}.duration:{$stat[1]}|ms%22"; + } + + $encoded_json = '{%22beacons%22:[' . implode(',', $beacons ) . ']}'; + $encoded_site_url = urlencode( site_url() ); + $url = "https://pixel.wp.com/boom.gif?v=0.9&u={$encoded_site_url}&json={$encoded_json}"; + echo '<img src="' . $url . '" width="1" height="1" style="display:none;" alt=":)"/>'; + } + } +} diff --git a/plugins/jetpack/_inc/lib/class.media-extractor.php b/plugins/jetpack/_inc/lib/class.media-extractor.php new file mode 100644 index 00000000..6acf34db --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.media-extractor.php @@ -0,0 +1,436 @@ +<?php +/** + * Class with methods to extract metadata from a post/page about videos, images, links, mentions embedded + * in or attached to the post/page. + * + * @todo Additionally, have some filters on number of items in each field + */ +class Jetpack_Media_Meta_Extractor { + + // Some consts for what to extract + const ALL = 255; + const LINKS = 1; + const MENTIONS = 2; + const IMAGES = 4; + const SHORTCODES = 8; // Only the keeper shortcodes below + const EMBEDS = 16; + const HASHTAGS = 32; + + // For these, we try to extract some data from the shortcode, rather than just recording its presence (which we do for all) + // There should be a function get_{shortcode}_id( $atts ) or static method SomethingShortcode::get_{shortcode}_id( $atts ) for these. + private static $KEEPER_SHORTCODES = array( + 'youtube', + 'vimeo', + 'hulu', + 'ted', + 'wpvideo', + 'videopress', + ); + + /** + * Gets the specified media and meta info from the given post. + * NOTE: If you have the post's HTML content already and don't need image data, use extract_from_content() instead. + * + * @param $blog_id The ID of the blog + * @param $post_id The ID of the post + * @param $what_to_extract (int) A mask of things to extract, e.g. Jetpack_Media_Meta_Extractor::IMAGES | Jetpack_Media_Meta_Extractor::MENTIONS + * @returns a structure containing metadata about the embedded things, or empty array if nothing found, or WP_Error on error + */ + static public function extract( $blog_id, $post_id, $what_to_extract = self::ALL ) { + + // multisite? + if ( function_exists( 'switch_to_blog') ) + switch_to_blog( $blog_id ); + + $post = get_post( $post_id ); + $content = $post->post_title . "\n\n" . $post->post_content; + $char_cnt = strlen( $content ); + + //prevent running extraction on really huge amounts of content + if ( $char_cnt > 100000 ) //about 20k English words + $content = substr( $content, 0, 100000 ); + + $extracted = array(); + + // Get images first, we need the full post for that + if ( self::IMAGES & $what_to_extract ) { + $extracted = self::get_image_fields( $post ); + + // Turn off images so we can safely call extract_from_content() below + $what_to_extract = $what_to_extract - self::IMAGES; + } + + if ( function_exists( 'switch_to_blog') ) + restore_current_blog(); + + // All of the other things besides images can be extracted from just the content + $extracted = self::extract_from_content( $content, $what_to_extract, $extracted ); + + return $extracted; + } + + /** + * Gets the specified meta info from the given post content. + * NOTE: If you want IMAGES, call extract( $blog_id, $post_id, ...) which will give you more/better image extraction + * This method will give you an error if you ask for IMAGES. + * + * @param $content The HTML post_content of a post + * @param $what_to_extract (int) A mask of things to extract, e.g. Jetpack_Media_Meta_Extractor::IMAGES | Jetpack_Media_Meta_Extractor::MENTIONS + * @param $already_extracted (array) Previously extracted things, e.g. images from extract(), which can be used for x-referencing here + * @returns a structure containing metadata about the embedded things, or empty array if nothing found, or WP_Error on error + */ + static public function extract_from_content( $content, $what_to_extract = self::ALL, $already_extracted = array() ) { + $stripped_content = self::get_stripped_content( $content ); + + // Maybe start with some previously extracted things (e.g. images from extract() + $extracted = $already_extracted; + + // Embedded media objects will have already been converted to shortcodes by pre_kses hooks on save. + + if ( self::IMAGES & $what_to_extract ) { + $images = Jetpack_Media_Meta_Extractor::extract_images_from_content( $stripped_content, array() ); + $extracted = array_merge( $extracted, $images ); + } + + // ----------------------------------- MENTIONS ------------------------------ + + if ( self::MENTIONS & $what_to_extract ) { + if ( preg_match_all( '/(^|\s)@(\w+)/u', $stripped_content, $matches ) ) { + $mentions = array_values( array_unique( $matches[2] ) ); //array_unique() retains the keys! + $mentions = array_map( 'strtolower', $mentions ); + $extracted['mention'] = array( 'name' => $mentions ); + if ( !isset( $extracted['has'] ) ) + $extracted['has'] = array(); + $extracted['has']['mention'] = count( $mentions ); + } + } + + // ----------------------------------- HASHTAGS ------------------------------ + /** Some hosts may not compile with --enable-unicode-properties and kick a warning: + * Warning: preg_match_all() [function.preg-match-all]: Compilation failed: support for \P, \p, and \X has not been compiled + * Therefore, we only run this code block on wpcom, not in Jetpack. + */ + if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) && ( self::HASHTAGS & $what_to_extract ) ) { + //This regex does not exactly match Twitter's + // if there are problems/complaints we should implement this: + // https://github.com/twitter/twitter-text/blob/master/java/src/com/twitter/Regex.java + if ( preg_match_all( '/(?:^|\s)#(\w*\p{L}+\w*)/u', $stripped_content, $matches ) ) { + $hashtags = array_values( array_unique( $matches[1] ) ); //array_unique() retains the keys! + $hashtags = array_map( 'strtolower', $hashtags ); + $extracted['hashtag'] = array( 'name' => $hashtags ); + if ( !isset( $extracted['has'] ) ) + $extracted['has'] = array(); + $extracted['has']['hashtag'] = count( $hashtags ); + } + } + + // ----------------------------------- SHORTCODES ------------------------------ + + // Always look for shortcodes. + // If we don't want them, we'll just remove them, so we don't grab them as links below + $shortcode_pattern = '/' . get_shortcode_regex() . '/s'; + if ( preg_match_all( $shortcode_pattern, $content, $matches ) ) { + + $shortcode_total_count = 0; + $shortcode_type_counts = array(); + $shortcode_types = array(); + $shortcode_details = array(); + + if ( self::SHORTCODES & $what_to_extract ) { + + foreach( $matches[2] as $key => $shortcode ) { + //Elasticsearch (and probably other things) doesn't deal well with some chars as key names + $shortcode_name = preg_replace( '/[.,*"\'\/\\\\#+ ]/', '_', $shortcode ); + + $attr = shortcode_parse_atts( $matches[3][ $key ] ); + + $shortcode_total_count++; + if ( ! isset( $shortcode_type_counts[$shortcode_name] ) ) + $shortcode_type_counts[$shortcode_name] = 0; + $shortcode_type_counts[$shortcode_name]++; + + // Store (uniquely) presence of all shortcode regardless of whether it's a keeper (for those, get ID below) + // @todo Store number of occurrences? + if ( ! in_array( $shortcode_name, $shortcode_types ) ) + $shortcode_types[] = $shortcode_name; + + // For keeper shortcodes, also store the id/url of the object (e.g. youtube video, TED talk, etc.) + if ( in_array( $shortcode, self::$KEEPER_SHORTCODES ) ) { + unset( $id ); // Clear shortcode ID data left from the last shortcode + // We'll try to get the salient ID from the function jetpack_shortcode_get_xyz_id() + // If the shortcode is a class, we'll call XyzShortcode::get_xyz_id() + $shortcode_get_id_func = "jetpack_shortcode_get_{$shortcode}_id"; + $shortcode_class_name = ucfirst( $shortcode ) . 'Shortcode'; + $shortcode_get_id_method = "get_{$shortcode}_id"; + if ( function_exists( $shortcode_get_id_func ) ) { + $id = call_user_func( $shortcode_get_id_func, $attr ); + } else if ( method_exists( $shortcode_class_name, $shortcode_get_id_method ) ) { + $id = call_user_func( array( $shortcode_class_name, $shortcode_get_id_method ), $attr ); + } + if ( ! empty( $id ) + && ( ! isset( $shortcode_details[$shortcode_name] ) || ! in_array( $id, $shortcode_details[$shortcode_name] ) ) ) + $shortcode_details[$shortcode_name][] = $id; + } + } + + if ( $shortcode_total_count > 0 ) { + // Add the shortcode info to the $extracted array + if ( !isset( $extracted['has'] ) ) + $extracted['has'] = array(); + $extracted['has']['shortcode'] = $shortcode_total_count; + $extracted['shortcode'] = array(); + foreach ( $shortcode_type_counts as $type => $count ) + $extracted['shortcode'][$type] = array( 'count' => $count ); + if ( ! empty( $shortcode_types ) ) + $extracted['shortcode_types'] = $shortcode_types; + foreach ( $shortcode_details as $type => $id ) + $extracted['shortcode'][$type]['id'] = $id; + } + } + + // Remove the shortcodes form our copy of $content, so we don't count links in them as links below. + $content = preg_replace( $shortcode_pattern, ' ', $content ); + } + + // ----------------------------------- LINKS ------------------------------ + + if ( self::LINKS & $what_to_extract ) { + + // To hold the extracted stuff we find + $links = array(); + + // @todo Get the text inside the links? + + // Grab any links, whether in <a href="..." or not, but subtract those from shortcodes and images + // (we treat embed links as just another link) + if ( preg_match_all( '#(?:^|\s|"|\')(https?://([^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/))))#', $content, $matches ) ) { + + foreach ( $matches[1] as $link_raw ) { + $url = parse_url( $link_raw ); + + // Data URI links + if ( isset( $url['scheme'] ) && 'data' === $url['scheme'] ) + continue; + + // Remove large (and likely invalid) links + if ( 4096 < strlen( $link_raw ) ) + continue; + + // Build a simple form of the URL so we can compare it to ones we found in IMAGES or SHORTCODES and exclude those + $simple_url = $url['scheme'] . '://' . $url['host'] . ( ! empty( $url['path'] ) ? $url['path'] : '' ); + if ( isset( $extracted['image']['url'] ) ) { + if ( in_array( $simple_url, (array) $extracted['image']['url'] ) ) + continue; + } + + list( $proto, $link_all_but_proto ) = explode( '://', $link_raw ); + + // Build a reversed hostname + $host_parts = array_reverse( explode( '.', $url['host'] ) ); + $host_reversed = ''; + foreach ( $host_parts as $part ) { + $host_reversed .= ( ! empty( $host_reversed ) ? '.' : '' ) . $part; + } + + $link_analyzed = ''; + if ( !empty( $url['path'] ) ) { + // The whole path (no query args or fragments) + $path = substr( $url['path'], 1 ); // strip the leading '/' + $link_analyzed .= ( ! empty( $link_analyzed ) ? ' ' : '' ) . $path; + + // The path split by / + $path_split = explode( '/', $path ); + if ( count( $path_split ) > 1 ) { + $link_analyzed .= ' ' . implode( ' ', $path_split ); + } + + // The fragment + if ( ! empty( $url['fragment'] ) ) + $link_analyzed .= ( ! empty( $link_analyzed ) ? ' ' : '' ) . $url['fragment']; + } + + // @todo Check unique before adding + $links[] = array( + 'url' => $link_all_but_proto, + 'host_reversed' => $host_reversed, + 'host' => $url['host'], + ); + } + + } + + $link_count = count( $links ); + if ( $link_count ) { + $extracted[ 'link' ] = $links; + if ( !isset( $extracted['has'] ) ) + $extracted['has'] = array(); + $extracted['has']['link'] = $link_count; + } + } + + // ----------------------------------- EMBEDS ------------------------------ + + //Embeds are just individual links on their own line + if ( self::EMBEDS & $what_to_extract ) { + + if ( !function_exists( '_wp_oembed_get_object' ) ) + include( ABSPATH . WPINC . '/class-oembed.php' ); + + // get an oembed object + $oembed = _wp_oembed_get_object(); + + // Grab any links on their own lines that may be embeds + if ( preg_match_all( '|^\s*(https?://[^\s"]+)\s*$|im', $content, $matches ) ) { + + // To hold the extracted stuff we find + $embeds = array(); + + foreach ( $matches[1] as $link_raw ) { + $url = parse_url( $link_raw ); + + list( $proto, $link_all_but_proto ) = explode( '://', $link_raw ); + + // Check whether this "link" is really an embed. + foreach ( $oembed->providers as $matchmask => $data ) { + list( $providerurl, $regex ) = $data; + + // Turn the asterisk-type provider URLs into regex + if ( !$regex ) { + $matchmask = '#' . str_replace( '___wildcard___', '(.+)', preg_quote( str_replace( '*', '___wildcard___', $matchmask ), '#' ) ) . '#i'; + $matchmask = preg_replace( '|^#http\\\://|', '#https?\://', $matchmask ); + } + + if ( preg_match( $matchmask, $link_raw ) ) { + $provider = str_replace( '{format}', 'json', $providerurl ); // JSON is easier to deal with than XML + $embeds[] = $link_all_but_proto; // @todo Check unique before adding + + // @todo Try to get ID's for the ones we care about (shortcode_keepers) + break; + } + } + } + + if ( ! empty( $embeds ) ) { + if ( !isset( $extracted['has'] ) ) + $extracted['has'] = array(); + $extracted['has']['embed'] = count( $embeds ); + $extracted['embed'] = array( 'url' => array() ); + foreach ( $embeds as $e ) + $extracted['embed']['url'][] = $e; + } + } + } + + return $extracted; + } + + /** + * @param $post A post object + * @param $args (array) Optional args, see defaults list for details + * @returns array Returns an array of all images meeting the specified criteria in $args + * + * Uses Jetpack Post Images + */ + private static function get_image_fields( $post, $args = array() ) { + + $defaults = array( + 'width' => 200, // Required minimum width (if possible to determine) + 'height' => 200, // Required minimum height (if possible to determine) + ); + + $args = wp_parse_args( $args, $defaults ); + + $image_list = array(); + $image_booleans = array(); + $image_booleans['gallery'] = 0; + + $from_featured_image = Jetpack_PostImages::from_thumbnail( $post->ID, $args['width'], $args['height'] ); + if ( !empty( $from_featured_image ) ) { + $srcs = wp_list_pluck( $from_featured_image, 'src' ); + $image_list = array_merge( $image_list, $srcs ); + } + + $from_slideshow = Jetpack_PostImages::from_slideshow( $post->ID, $args['width'], $args['height'] ); + if ( !empty( $from_slideshow ) ) { + $srcs = wp_list_pluck( $from_slideshow, 'src' ); + $image_list = array_merge( $image_list, $srcs ); + } + + $from_gallery = Jetpack_PostImages::from_gallery( $post->ID ); + if ( !empty( $from_gallery ) ) { + $srcs = wp_list_pluck( $from_gallery, 'src' ); + $image_list = array_merge( $image_list, $srcs ); + $image_booleans['gallery']++; // @todo This count isn't correct, will only every count 1 + } + + // @todo Can we check width/height of these efficiently? Could maybe use query args at least, before we strip them out + $image_list = Jetpack_Media_Meta_Extractor::get_images_from_html( $post->post_content, $image_list ); + + return Jetpack_Media_Meta_Extractor::build_image_struct( $image_list, $image_booleans ); + } + + public static function extract_images_from_content( $content, $image_list ) { + $image_list = Jetpack_Media_Meta_Extractor::get_images_from_html( $content, $image_list ); + return Jetpack_Media_Meta_Extractor::build_image_struct( $image_list, array() ); + } + + public static function build_image_struct( $image_list, $image_booleans ) { + if ( ! empty( $image_list ) ) { + $retval = array( 'image' => array() ); + $image_list = array_unique( $image_list ); + foreach ( $image_list as $img ) { + $retval['image'][] = array( 'url' => $img ); + } + $image_booleans['image'] = count( $retval['image'] ); + if ( ! empty( $image_booleans ) ) + $retval['has'] = $image_booleans; + return $retval; + } else { + return array(); + } + } + + /** + * + * @param string $html Some markup, possibly containing image tags + * @param array $images_already_extracted (just an array of image URLs without query strings, no special structure), used for de-duplication + * @return array Image URLs extracted from the HTML, stripped of query params and de-duped + */ + public static function get_images_from_html( $html, $images_already_extracted ) { + $image_list = $images_already_extracted; + $from_html = Jetpack_PostImages::from_html( $html ); + if ( !empty( $from_html ) ) { + $srcs = wp_list_pluck( $from_html, 'src' ); + foreach( $srcs as $image_url ) { + if ( ( $src = parse_url( $image_url ) ) && isset( $src['scheme'], $src['host'], $src['path'] ) ) { + // Rebuild the URL without the query string + $queryless = $src['scheme'] . '://' . $src['host'] . $src['path']; + } elseif ( $length = strpos( $image_url, '?' ) ) { + // If parse_url() didn't work, strip off the query string the old fashioned way + $queryless = substr( $image_url, 0, $length ); + } else { + // Failing that, there was no spoon! Err ... query string! + $queryless = $image_url; + } + + // Discard URLs that are longer then 4KB, these are likely data URIs or malformed HTML. + if ( 4096 < strlen( $queryless ) ) { + continue; + } + + if ( ! in_array( $queryless, $image_list ) ) { + $image_list[] = $queryless; + } + } + } + return $image_list; + } + + private static function get_stripped_content( $content ) { + $clean_content = strip_tags( $content ); + $clean_content = html_entity_decode( $clean_content ); + //completely strip shortcodes and any content they enclose + $clean_content = strip_shortcodes( $clean_content ); + return $clean_content; + } +} diff --git a/plugins/jetpack/_inc/lib/class.media-summary.php b/plugins/jetpack/_inc/lib/class.media-summary.php new file mode 100644 index 00000000..732706d4 --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.media-summary.php @@ -0,0 +1,369 @@ +<?php +/** + * Class Jetpack_Media_Summary + * + * embed [video] > gallery > image > text + */ +class Jetpack_Media_Summary { + + private static $cache = array(); + + static function get( $post_id, $blog_id = 0, $args = array() ) { + + $defaults = array( + 'max_words' => 16, + 'max_chars' => 256, + ); + $args = wp_parse_args( $args, $defaults ); + + $switched = false; + if ( !empty( $blog_id ) && $blog_id != get_current_blog_id() && function_exists( 'switch_to_blog' ) ) { + switch_to_blog( $blog_id ); + $switched = true; + } else { + $blog_id = get_current_blog_id(); + } + + $cache_key = "{$blog_id}_{$post_id}_{$args['max_words']}_{$args['max_chars']}"; + if ( isset( self::$cache[ $cache_key ] ) ) { + return self::$cache[ $cache_key ]; + } + + if ( ! class_exists( 'Jetpack_Media_Meta_Extractor' ) ) { + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { + jetpack_require_lib( 'class.wpcom-media-meta-extractor' ); + } else { + jetpack_require_lib( 'class.media-extractor' ); + } + } + + $post = get_post( $post_id ); + $permalink = get_permalink( $post_id ); + + $return = array( + 'type' => 'standard', + 'permalink' => $permalink, + 'image' => '', + 'excerpt' => '', + 'word_count' => 0, + 'secure' => array( + 'image' => '', + ), + 'count' => array( + 'image' => 0, + 'video' => 0, + 'word' => 0, + 'link' => 0, + ), + ); + + if ( empty( $post->post_password ) ) { + $return['excerpt'] = self::get_excerpt( $post->post_content, $post->post_excerpt, $args['max_words'], $args['max_chars'] , $post); + $return['count']['word'] = self::get_word_count( $post->post_content ); + $return['count']['word_remaining'] = self::get_word_remaining_count( $post->post_content, $return['excerpt'] ); + $return['count']['link'] = self::get_link_count( $post->post_content ); + } + + $extract = Jetpack_Media_Meta_Extractor::extract( $blog_id, $post_id, Jetpack_Media_Meta_Extractor::ALL ); + + if ( empty( $extract['has'] ) ) + return $return; + + // Prioritize [some] video embeds + if ( !empty( $extract['has']['shortcode'] ) ) { + foreach ( $extract['shortcode'] as $type => $data ) { + switch ( $type ) { + case 'videopress': + case 'wpvideo': + if ( 0 == $return['count']['video'] ) { + // If there is no id on the video, then let's just skip this + if ( ! isset ( $data['id'][0] ) ) { + break; + } + + $guid = $data['id'][0]; + $video_info = videopress_get_video_details( $guid ); + + // Only add the video tags if the guid returns a valid videopress object. + if ( $video_info instanceof stdClass ) { + // Continue early if we can't find a Video slug. + if ( empty( $video_info->files->std->mp4 ) ) { + break; + } + + $url = sprintf( + 'https://videos.files.wordpress.com/%1$s/%2$s', + $guid, + $video_info->files->std->mp4 + ); + + $thumbnail = $video_info->poster; + if ( ! empty( $thumbnail ) ) { + $return['image'] = $thumbnail; + $return['secure']['image'] = $thumbnail; + } + + $return['type'] = 'video'; + $return['video'] = esc_url_raw( $url ); + $return['video_type'] = 'video/mp4'; + $return['secure']['video'] = $return['video']; + } + + } + $return['count']['video']++; + break; + case 'youtube': + if ( 0 == $return['count']['video'] ) { + $return['type'] = 'video'; + $return['video'] = esc_url_raw( 'http://www.youtube.com/watch?feature=player_embedded&v=' . $extract['shortcode']['youtube']['id'][0] ); + $return['image'] = self::get_video_poster( 'youtube', $extract['shortcode']['youtube']['id'][0] ); + $return['secure']['video'] = self::https( $return['video'] ); + $return['secure']['image'] = self::https( $return['image'] ); + } + $return['count']['video']++; + break; + case 'vimeo': + if ( 0 == $return['count']['video'] ) { + $return['type'] = 'video'; + $return['video'] = esc_url_raw( 'http://vimeo.com/' . $extract['shortcode']['vimeo']['id'][0] ); + $return['secure']['video'] = self::https( $return['video'] ); + + $poster_image = get_post_meta( $post_id, 'vimeo_poster_image', true ); + if ( !empty( $poster_image ) ) { + $return['image'] = $poster_image; + $poster_url_parts = parse_url( $poster_image ); + $return['secure']['image'] = 'https://secure-a.vimeocdn.com' . $poster_url_parts['path']; + } + } + $return['count']['video']++; + break; + } + } + + } + + if ( !empty( $extract['has']['embed'] ) ) { + foreach( $extract['embed']['url'] as $embed ) { + if ( preg_match( '/((youtube|vimeo|dailymotion)\.com|youtu.be)/', $embed ) ) { + if ( 0 == $return['count']['video'] ) { + $return['type'] = 'video'; + $return['video'] = 'http://' . $embed; + $return['secure']['video'] = self::https( $return['video'] ); + if ( false !== strpos( $embed, 'youtube' ) ) { + $return['image'] = self::get_video_poster( 'youtube', jetpack_get_youtube_id( $return['video'] ) ); + $return['secure']['image'] = self::https( $return['image'] ); + } else if ( false !== strpos( $embed, 'youtu.be' ) ) { + $youtube_id = jetpack_get_youtube_id( $return['video'] ); + $return['video'] = 'http://youtube.com/watch?v=' . $youtube_id . '&feature=youtu.be'; + $return['secure']['video'] = self::https( $return['video'] ); + $return['image'] = self::get_video_poster( 'youtube', jetpack_get_youtube_id( $return['video'] ) ); + $return['secure']['image'] = self::https( $return['image'] ); + } else if ( false !== strpos( $embed, 'vimeo' ) ) { + $poster_image = get_post_meta( $post_id, 'vimeo_poster_image', true ); + if ( !empty( $poster_image ) ) { + $return['image'] = $poster_image; + $poster_url_parts = parse_url( $poster_image ); + $return['secure']['image'] = 'https://secure-a.vimeocdn.com' . $poster_url_parts['path']; + } + } else if ( false !== strpos( $embed, 'dailymotion' ) ) { + $return['image'] = str_replace( 'dailymotion.com/video/','dailymotion.com/thumbnail/video/', $embed ); + $return['image'] = parse_url( $return['image'], PHP_URL_SCHEME ) === null ? 'http://' . $return['image'] : $return['image']; + $return['secure']['image'] = self::https( $return['image'] ); + } + + } + $return['count']['video']++; + } + } + } + + // Do we really want to make the video the primary focus of the post? + if ( 'video' == $return['type'] ) { + $content = wpautop( strip_tags( $post->post_content ) ); + $paragraphs = explode( '</p>', $content ); + $number_of_paragraphs = 0; + + foreach ( $paragraphs as $i => $paragraph ) { + // Don't include blank lines as a paragraph + if ( '' == trim( $paragraph ) ) { + unset( $paragraphs[$i] ); + continue; + } + $number_of_paragraphs++; + } + + $number_of_paragraphs = $number_of_paragraphs - $return['count']['video']; // subtract amount for videos.. + + // More than 2 paragraph? The video is not the primary focus so we can do some more analysis + if ( $number_of_paragraphs > 2 ) + $return['type'] = 'standard'; + } + + // If we don't have any prioritized embed... + if ( 'standard' == $return['type'] ) { + if ( ( ! empty( $extract['has']['gallery'] ) || ! empty( $extract['shortcode']['gallery']['count'] ) ) && ! empty( $extract['image'] ) ) { + //... Then we prioritize galleries first (multiple images returned) + $return['type'] = 'gallery'; + $return['images'] = $extract['image']; + foreach ( $return['images'] as $image ) { + $return['secure']['images'][] = array( 'url' => self::ssl_img( $image['url'] ) ); + $return['count']['image']++; + } + } else if ( ! empty( $extract['has']['image'] ) ) { + // ... Or we try and select a single image that would make sense + $content = wpautop( strip_tags( $post->post_content ) ); + $paragraphs = explode( '</p>', $content ); + $number_of_paragraphs = 0; + + foreach ( $paragraphs as $i => $paragraph ) { + // Don't include 'actual' captions as a paragraph + if ( false !== strpos( $paragraph, '[caption' ) ) { + unset( $paragraphs[$i] ); + continue; + } + // Don't include blank lines as a paragraph + if ( '' == trim( $paragraph ) ) { + unset( $paragraphs[$i] ); + continue; + } + $number_of_paragraphs++; + } + + $return['image'] = $extract['image'][0]['url']; + $return['secure']['image'] = self::ssl_img( $return['image'] ); + $return['count']['image']++; + + if ( $number_of_paragraphs <= 2 && 1 == count( $extract['image'] ) ) { + // If we have lots of text or images, let's not treat it as an image post, but return its first image + $return['type'] = 'image'; + } + } + } + + if ( $switched ) { + restore_current_blog(); + } + + /** + * Allow a theme or plugin to inspect and ultimately change the media summary. + * + * @since 4.4.0 + * + * @param array $data The calculated media summary data. + * @param int $post_id The id of the post this data applies to. + */ + $return = apply_filters( 'jetpack_media_summary_output', $return, $post_id ); + + self::$cache[ $cache_key ] = $return; + + return $return; + } + + static function https( $str ) { + return str_replace( 'http://', 'https://', $str ); + } + + static function ssl_img( $url ) { + if ( false !== strpos( $url, 'files.wordpress.com' ) ) { + return self::https( $url ); + } else { + return self::https( jetpack_photon_url( $url ) ); + } + } + + static function get_video_poster( $type, $id ) { + if ( 'videopress' == $type ) { + if ( function_exists( 'video_get_highest_resolution_image_url' ) ) { + return video_get_highest_resolution_image_url( $id ); + } else if ( class_exists( 'VideoPress_Video' ) ) { + $video = new VideoPress_Video( $id ); + return $video->poster_frame_uri; + } + } else if ( 'youtube' == $type ) { + return 'http://img.youtube.com/vi/'.$id.'/0.jpg'; + } + } + + static function clean_text( $text ) { + return trim( + preg_replace( + '/[\s]+/', + ' ', + preg_replace( + '@https?://[\S]+@', + '', + strip_shortcodes( + strip_tags( + $text + ) + ) + ) + ) + ); + } + + /** + * Retrieve an excerpt for the post summary. + * + * This function works around a suspected problem with Core. If resolved, this function should be simplified. + * @link https://github.com/Automattic/jetpack/pull/8510 + * @link https://core.trac.wordpress.org/ticket/42814 + * + * @param string $post_content The post's content. + * @param string $post_excerpt The post's excerpt. Empty if none was explicitly set. + * @param int $max_words Maximum number of words for the excerpt. Used on wp.com. Default 16. + * @param int $max_chars Maximum characters in the excerpt. Used on wp.com. Default 256. + * @param WP_Post $requested_post The post object. + * @return string Post excerpt. + **/ + static function get_excerpt( $post_content, $post_excerpt, $max_words = 16, $max_chars = 256, $requested_post = null ) { + global $post; + $original_post = $post; // Saving the global for later use. + if ( function_exists( 'wpcom_enhanced_excerpt_extract_excerpt' ) ) { + return self::clean_text( wpcom_enhanced_excerpt_extract_excerpt( array( + 'text' => $post_content, + 'excerpt_only' => true, + 'show_read_more' => false, + 'max_words' => $max_words, + 'max_chars' => $max_chars, + 'read_more_threshold' => 25, + ) ) ); + } elseif ( $requested_post instanceof WP_Post ) { + $post = $requested_post; // setup_postdata does not set the global. + setup_postdata( $post ); + /** This filter is documented in core/src/wp-includes/post-template.php */ + $post_excerpt = apply_filters( 'get_the_excerpt', $post_excerpt, $post ); + $post = $original_post; // wp_reset_postdata uses the $post global. + wp_reset_postdata(); + return self::clean_text( $post_excerpt ); + } + return ''; + } + + /** + * Split a string into an array of words. + * + * @param string $text Post content or excerpt. + */ + static function split_content_in_words( $text ) { + $words = preg_split( '/[\s!?;,.]+/', $text, null, PREG_SPLIT_NO_EMPTY ); + + // Return an empty array if the split above fails. + return $words ? $words : array(); + } + + static function get_word_count( $post_content ) { + return (int) count( self::split_content_in_words( self::clean_text( $post_content ) ) ); + } + + static function get_word_remaining_count( $post_content, $excerpt_content ) { + $content_word_count = count( self::split_content_in_words( self::clean_text( $post_content ) ) ); + $excerpt_word_count = count( self::split_content_in_words( self::clean_text( $excerpt_content ) ) ); + + return (int) $content_word_count - $excerpt_word_count; + } + + static function get_link_count( $post_content ) { + return preg_match_all( '/\<a[\> ]/', $post_content, $matches ); + } +} diff --git a/plugins/jetpack/_inc/lib/class.media.php b/plugins/jetpack/_inc/lib/class.media.php new file mode 100644 index 00000000..e48c4aad --- /dev/null +++ b/plugins/jetpack/_inc/lib/class.media.php @@ -0,0 +1,505 @@ +<?php + +require_once( JETPACK__PLUGIN_DIR . 'sal/class.json-api-date.php' ); + +/** + * Class to handle different actions related to media. + */ +class Jetpack_Media { + public static $WP_ORIGINAL_MEDIA = '_wp_original_post_media'; + public static $WP_REVISION_HISTORY = '_wp_revision_history'; + public static $REVISION_HISTORY_MAXIMUM_AMOUNT = 0; + public static $WP_ATTACHMENT_IMAGE_ALT = '_wp_attachment_image_alt'; + + /** + * Generate a filename in function of the original filename of the media. + * The returned name has the `{basename}-{hash}-{random-number}.{ext}` shape. + * The hash is built according to the filename trying to avoid name collisions + * with other media files. + * + * @param number $media_id - media post ID + * @param string $new_filename - the new filename + * @return string A random filename. + */ + public static function generate_new_filename( $media_id, $new_filename ) { + // get the right filename extension + $new_filename_paths = pathinfo( $new_filename ); + $new_file_ext = $new_filename_paths['extension']; + + // take out filename from the original file or from the current attachment + $original_media = (array) self::get_original_media( $media_id ); + + if ( ! empty( $original_media ) ) { + $original_file_parts = pathinfo( $original_media['file'] ); + $filename_base = $original_file_parts['filename']; + } else { + $current_file = get_attached_file( $media_id ); + $current_file_parts = pathinfo( $current_file ); + $current_file_ext = $current_file_parts['filename']; + $filename_base = $current_file_parts['filename']; + } + + // add unique seed based on the filename + $filename_base .= '-' . crc32( $filename_base ) . '-'; + + $number_suffix = time() . rand( 100, 999 ); + + do { + $filename = $filename_base; + $filename .= $number_suffix; + $file_ext = $new_file_ext ? $new_file_ext : $current_file_ext; + + $new_filename = "{$filename}.{$file_ext}"; + $new_path = "{$current_file_parts['dirname']}/$new_filename"; + $number_suffix++; + } while( file_exists( $new_path ) ); + + return $new_filename; + } + + /** + * File urls use the post (image item) date to generate a folder path. + * Post dates can change, so we use the original date used in the `guid` + * url so edits can remain in the same folder. In the following function + * we capture a string in the format of `YYYY/MM` from the guid. + * + * For example with a guid of + * "http://test.files.wordpress.com/2016/10/test.png" the resulting string + * would be: "2016/10" + * + * @param number $media_id + * @return string + */ + private function get_time_string_from_guid( $media_id ) { + $time = date( "Y/m", strtotime( current_time( 'mysql' ) ) ); + + if ( $media = get_post( $media_id ) ) { + $pattern = '/\/(\d{4}\/\d{2})\//'; + preg_match( $pattern, $media->guid, $matches ); + if ( count( $matches ) > 1 ) { + $time = $matches[1]; + } + } + return $time; + } + + /** + * Return an array of allowed mime_type items used to upload a media file. + * + * @return array mime_type array + */ + static function get_allowed_mime_types( $default_mime_types ) { + return array_unique( array_merge( $default_mime_types, array( + 'application/msword', // .doc + 'application/vnd.ms-powerpoint', // .ppt, .pps + 'application/vnd.ms-excel', // .xls + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx + 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', // .ppsx + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx + 'application/vnd.oasis.opendocument.text', // .odt + 'application/pdf', // .pdf + ) ) ); + } + + /** + * Checks that the mime type of the file + * is among those in a filterable list of mime types. + * + * @param string $file Path to file to get its mime type. + * @return bool + */ + protected static function is_file_supported_for_sideloading( $file ) { + if ( class_exists( 'finfo' ) ) { // php 5.3+ + // phpcs:ignore PHPCompatibility.PHP.NewClasses.finfoFound + $finfo = new finfo( FILEINFO_MIME ); + $mime = explode( '; ', $finfo->file( $file ) ); + $type = $mime[0]; + + } elseif ( function_exists( 'mime_content_type' ) ) { // PHP 5.2 + $type = mime_content_type( $file ); + + } else { + return false; + } + + /** + * Filter the list of supported mime types for media sideloading. + * + * @since 4.0 + * + * @module json-api + * + * @param array $supported_mime_types Array of the supported mime types for media sideloading. + */ + $supported_mime_types = apply_filters( 'jetpack_supported_media_sideload_types', array( + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/bmp', + 'video/quicktime', + 'video/mp4', + 'video/mpeg', + 'video/ogg', + 'video/3gpp', + 'video/3gpp2', + 'video/h261', + 'video/h262', + 'video/h264', + 'video/x-msvideo', + 'video/x-ms-wmv', + 'video/x-ms-asf', + ) ); + + // If the type returned was not an array as expected, then we know we don't have a match. + if ( ! is_array( $supported_mime_types ) ) { + return false; + } + + return in_array( $type, $supported_mime_types ); + } + + /** + * Try to remove the temporal file from the given file array. + * + * @param array $file_array Array with data about the temporal file + * @return bool `true` if the file has been removed. `false` either the file doesn't exist or it couldn't be removed. + */ + private static function remove_tmp_file( $file_array ) { + if ( ! file_exists ( $file_array['tmp_name'] ) ) { + return false; + } + return @unlink( $file_array['tmp_name'] ); + } + + /** + * Save the given temporal file considering file type, + * correct location according to the original file path, etc. + * The file type control is done through of `jetpack_supported_media_sideload_types` filter, + * which allows define to the users their own file types list. + * + * @param array $file_array file to save + * @param number $media_id + * @return array|WP_Error an array with information about the new file saved or a WP_Error is something went wrong. + */ + public static function save_temporary_file( $file_array, $media_id ) { + $tmp_filename = $file_array['tmp_name']; + + if ( ! file_exists( $tmp_filename ) ) { + return new WP_Error( 'invalid_input', 'No media provided in input.' ); + } + + // add additional mime_types through of the `jetpack_supported_media_sideload_types` filter + $mime_type_static_filter = array( + 'Jetpack_Media', + 'get_allowed_mime_types' + ); + + add_filter( 'jetpack_supported_media_sideload_types', $mime_type_static_filter ); + if ( + ! self::is_file_supported_for_sideloading( $tmp_filename ) && + ! file_is_displayable_image( $tmp_filename ) + ) { + @unlink( $tmp_filename ); + return new WP_Error( 'invalid_input', 'Invalid file type.', 403 ); + } + remove_filter( 'jetpack_supported_media_sideload_types', $mime_type_static_filter ); + + // generate a new file name + $tmp_new_filename = self::generate_new_filename( $media_id, $file_array[ 'name' ] ); + + // start to create the parameters to move the temporal file + $overrides = array( 'test_form' => false ); + + // get time according to the original filaname + $time = self::get_time_string_from_guid( $media_id ); + + $file_array['name'] = $tmp_new_filename; + $file = wp_handle_sideload( $file_array, $overrides, $time ); + + self::remove_tmp_file( $file_array ); + + if ( isset( $file['error'] ) ) { + return new WP_Error( 'upload_error', $file['error'] ); + } + + return $file; + } + + /** + * Return an object with an snapshot of a revision item. + * + * @param object $media_item - media post object + * @return object a revision item + */ + public static function get_snapshot( $media_item ) { + $current_file = get_attached_file( $media_item->ID ); + $file_paths = pathinfo( $current_file ); + + $snapshot = array( + 'date' => (string) WPCOM_JSON_API_Date::format_date( $media_item->post_modified_gmt, $media_item->post_modified ), + 'URL' => (string) wp_get_attachment_url( $media_item->ID ), + 'file' => (string) $file_paths['basename'], + 'extension' => (string) $file_paths['extension'], + 'mime_type' => (string) $media_item->post_mime_type, + 'size' => (int) filesize( $current_file ) + ); + + return (object) $snapshot; + } + + /** + * Add a new item into revision_history array. + * + * @param object $media_item - media post object + * @param file $file - file recently added + * @param bool $has_original_media - condition is the original media has been already added + * @return bool `true` if the item has been added. Otherwise `false`. + */ + public static function register_revision( $media_item, $file, $has_original_media ) { + if ( is_wp_error( $file ) || ! $has_original_media ) { + return false; + } + + add_post_meta( $media_item->ID, self::$WP_REVISION_HISTORY, self::get_snapshot( $media_item ) ); + } + /** + * Return the `revision_history` of the given media. + * + * @param number $media_id - media post ID + * @return array `revision_history` array + */ + public static function get_revision_history( $media_id ) { + return array_reverse( get_post_meta( $media_id, self::$WP_REVISION_HISTORY ) ); + } + + /** + * Return the original media data + */ + public static function get_original_media( $media_id ) { + $original = get_post_meta( $media_id, self::$WP_ORIGINAL_MEDIA, true ); + $original = $original ? $original : array(); + return $original; + } + + public static function delete_file( $pathname ) { + if ( ! file_exists( $pathname ) || ! is_file( $pathname ) ) { + // let's touch a fake file to try to `really` remove the media file + touch( $pathname ); + } + + return wp_delete_file( $pathname ); + } + + /** + * Try to delete a file according to the dirname of + * the media attached file and the filename. + * + * @param number $media_id - media post ID + * @param string $filename - basename of the file ( name-of-file.ext ) + * @return bool `true` is the file has been removed, `false` if not. + */ + private static function delete_media_history_file( $media_id, $filename ) { + $attached_path = get_attached_file( $media_id ); + $attached_parts = pathinfo( $attached_path ); + $dirname = $attached_parts['dirname']; + + $pathname = $dirname . '/' . $filename; + + // remove thumbnails + $metadata = wp_generate_attachment_metadata( $media_id, $pathname ); + + if ( isset( $metadata ) && isset( $metadata['sizes'] ) ) { + foreach ( $metadata['sizes'] as $size => $properties ) { + self::delete_file( $dirname . '/' . $properties['file'] ); + } + } + + // remove primary file + self::delete_file( $pathname ); + } + + /** + * Remove specific items from the `revision history` array + * depending on the given criteria: array( + * 'from' => (int) <from>, + * 'to' => (int) <to>, + * ) + * + * Also, it removes the file defined in each item. + * + * @param number $media_id - media post ID + * @param object $criteria - criteria to remove the items + * @param array [$revision_history] - revision history array + * @return array `revision_history` array updated. + */ + public static function remove_items_from_revision_history( $media_id, $criteria = array(), $revision_history ) { + if ( ! isset ( $revision_history ) ) { + $revision_history = self::get_revision_history( $media_id ); + } + + $from = $criteria['from']; + $to = $criteria['to'] ? $criteria['to'] : ( $from + 1 ); + + for ( $i = $from; $i < $to; $i++ ) { + $removed_item = array_slice( $revision_history, $from, 1 ); + if ( ! $removed_item ) { + break; + } + + array_splice( $revision_history, $from, 1 ); + self::delete_media_history_file( $media_id, $removed_item[0]->file ); + } + + // override all history items + delete_post_meta( $media_id, self::$WP_REVISION_HISTORY ); + $revision_history = array_reverse( $revision_history ); + foreach ( $revision_history as &$item ) { + add_post_meta( $media_id, self::$WP_REVISION_HISTORY, $item ); + } + + return $revision_history; + } + + /** + * Limit the number of items of the `revision_history` array. + * When the stack is overflowing the oldest item is remove from there (FIFO). + * + * @param number $media_id - media post ID + * @param number [$limit] - maximun amount of items. 20 as default. + * @return array items removed from `revision_history` + */ + public static function limit_revision_history( $media_id, $limit = null) { + if ( is_null( $limit ) ) { + $limit = self::$REVISION_HISTORY_MAXIMUM_AMOUNT; + } + + $revision_history = self::get_revision_history( $media_id ); + + $total = count( $revision_history ); + + if ( $total < $limit ) { + return array(); + } + + self::remove_items_from_revision_history( + $media_id, + array( 'from' => $limit, 'to' => $total ), + $revision_history + ); + + return self::get_revision_history( $media_id ); + } + + /** + * Remove the original file and clean the post metadata. + * + * @param number $media_id - media post ID + */ + public static function clean_original_media( $media_id ) { + $original_file = self::get_original_media( $media_id ); + + if ( ! $original_file ) { + return null; + } + + self::delete_media_history_file( $media_id, $original_file->file ); + return delete_post_meta( $media_id, self::$WP_ORIGINAL_MEDIA ); + } + + /** + * Clean `revision_history` of the given $media_id. it means: + * - remove all media files tied to the `revision_history` items. + * - clean `revision_history` meta data. + * - remove and clean the `original_media` + * + * @param number $media_id - media post ID + * @return array results of removing these files + */ + public static function clean_revision_history( $media_id ) { + self::clean_original_media( $media_id ); + + $revision_history = self::get_revision_history( $media_id ); + $total = count( $revision_history ); + $updated_history = array(); + + if ( $total < 1 ) { + return $updated_history; + } + + $updated_history = self::remove_items_from_revision_history( + $media_id, + array( 'from' => 0, 'to' => $total ), + $revision_history + ); + + return $updated_history; + } + + /** + * Edit media item process: + * + * - update attachment file + * - preserve original media file + * - trace revision history + * + * @param number $media_id - media post ID + * @param array $file_array - temporal file + * @return {Post|WP_Error} Updated media item or a WP_Error is something went wrong. + */ + public static function edit_media_file( $media_id, $file_array ) { + $media_item = get_post( $media_id ); + $has_original_media = self::get_original_media( $media_id ); + + if ( ! $has_original_media ) { + // The first time that the media is updated + // the original media is stored into the revision_history + $snapshot = self::get_snapshot( $media_item ); + add_post_meta( $media_id, self::$WP_ORIGINAL_MEDIA, $snapshot, true ); + } + + // save temporary file in the correct location + $uploaded_file = self::save_temporary_file( $file_array, $media_id ); + + if ( is_wp_error( $uploaded_file ) ) { + self::remove_tmp_file( $file_array ); + return $uploaded_file; + } + + // revision_history control + self::register_revision( $media_item, $uploaded_file, $has_original_media ); + + $uploaded_path = $uploaded_file['file']; + $udpated_mime_type = $uploaded_file['type']; + $was_updated = update_attached_file( $media_id, $uploaded_path ); + + if ( ! $was_updated ) { + return WP_Error( 'update_error', 'Media update error' ); + } + + $new_metadata = wp_generate_attachment_metadata( $media_id, $uploaded_path ); + wp_update_attachment_metadata( $media_id, $new_metadata ); + + // check maximum amount of revision_history + self::limit_revision_history( $media_id ); + + $edited_action = wp_update_post( (object) array( + 'ID' => $media_id, + 'post_mime_type' => $udpated_mime_type + ), true ); + + if ( is_wp_error( $edited_action ) ) { + return $edited_action; + } + + return $media_item; + } +} + +// hook: clean revision history when the media item is deleted +function clean_revision_history( $media_id ) { + Jetpack_Media::clean_revision_history( $media_id ); +}; + +add_action( 'delete_attachment', 'clean_revision_history' ); + diff --git a/plugins/jetpack/_inc/lib/core-api/class-wpcom-rest-field-controller.php b/plugins/jetpack/_inc/lib/core-api/class-wpcom-rest-field-controller.php new file mode 100644 index 00000000..2a2245e4 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/class-wpcom-rest-field-controller.php @@ -0,0 +1,333 @@ +<?php + +// @todo - nicer API for array values? + +/** + * `WP_REST_Controller` is basically a wrapper for `register_rest_route()` + * `WPCOM_REST_API_V2_Field_Controller` is a mostly-analogous wrapper for `register_rest_field()` + */ +abstract class WPCOM_REST_API_V2_Field_Controller { + /** + * @var string|string[] $object_type The REST Object Type(s) to which the field should be added. + */ + protected $object_type; + + /** + * @var string $field_name The name of the REST API field to add. + */ + protected $field_name; + + public function __construct() { + if ( ! $this->object_type ) { + /* translators: %s: object_type */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::$object_type', sprintf( __( "Property '%s' must be overridden.", 'jetpack' ), 'object_type' ), 'Jetpack 6.8' ); + return; + } + + if ( ! $this->field_name ) { + /* translators: %s: field_name */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::$field_name', sprintf( __( "Property '%s' must be overridden.", 'jetpack' ), 'field_name' ), 'Jetpack 6.8' ); + return; + } + + add_action( 'rest_api_init', array( $this, 'register_fields' ) ); + + // do this again later to collect any CPTs that get registered later + add_action( 'restapi_theme_init', array( $this, 'register_fields' ), 20 ); + } + + /** + * Registers the field with the appropriate schema and callbacks. + */ + public function register_fields() { + foreach ( (array) $this->object_type as $object_type ) { + register_rest_field( + $object_type, + $this->field_name, + array( + 'get_callback' => array( $this, 'get_for_response' ), + 'update_callback' => array( $this, 'update_from_request' ), + 'schema' => $this->get_schema(), + ) + ); + } + } + + /** + * Ensures the response matches the schema and request context. + * + * @param mixed $value + * @param WP_REST_Request $request + * @return mixed + */ + private function prepare_for_response( $value, $request ) { + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $schema = $this->get_schema(); + + $is_valid = rest_validate_value_from_schema( $value, $schema, $this->field_name ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + + return $this->filter_response_by_context( $value, $schema, $context ); + } + + /** + * Returns the schema's default value + * + * If there is no default, returns the type's falsey value. + * + * @param array $schema + * @return mixed + */ + final public function get_default_value( $schema ) { + if ( isset( $schema['default'] ) ) { + return $schema['default']; + } + + // If you have something more complicated, use $schema['default']; + switch ( isset( $schema['type'] ) ? $schema['type'] : 'null' ) { + case 'string': + return ''; + case 'integer': + case 'number': + return 0; + case 'object': + return (object) array(); + case 'array': + return array(); + case 'boolean': + return false; + case 'null': + default: + return null; + } + } + + /** + * The field's wrapped getter. Does permission checks and output preparation. + * + * This cannot be extended: implement `->get()` instead. + * + * @param mixed $object_data Probably an array. Whatever the endpoint returns. + * @param string $field_name Should always match `->field_name` + * @param WP_REST_Request $request + * @param string $object_type Should always match `->object_type` + * @return mixed + */ + final public function get_for_response( $object_data, $field_name, $request, $object_type ) { + $permission_check = $this->get_permission_check( $object_data, $request ); + + if ( ! $permission_check ) { + /* translators: %s: get_permission_check() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::get_permission_check', sprintf( __( "Method '%s' must return either true or WP_Error.", 'jetpack' ), 'get_permission_check' ), 'Jetpack 6.8' ); + return $this->get_default_value( $this->get_schema() ); + } + + if ( is_wp_error( $permission_check ) ) { + return $this->get_default_value( $this->get_schema() ); + } + + $value = $this->get( $object_data, $request ); + + return $this->prepare_for_response( $value, $request ); + } + + /** + * The field's wrapped setter. Does permission checks. + * + * This cannot be extended: implement `->update()` instead. + * + * @param mixed $value The new value for the field. + * @param mixed $object_data Probably a WordPress object (e.g., WP_Post) + * @param string $field_name Should always match `->field_name` + * @param WP_REST_Request $request + * @param string $object_type Should always match `->object_type` + * @return void|WP_Error + */ + final public function update_from_request( $value, $object_data, $field_name, $request, $object_type ) { + $permission_check = $this->update_permission_check( $value, $object_data, $request ); + + if ( ! $permission_check ) { + /* translators: %s: update_permission_check() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::update_permission_check', sprintf( __( "Method '%s' must return either true or WP_Error.", 'jetpack' ), 'update_permission_check' ), 'Jetpack 6.8' ); + /* translators: %s: the name of an API response field */ + return new WP_Error( 'invalid_user_permission', sprintf( __( "You are not allowed to access the '%s' field.", 'jetpack' ), $this->field_name ) ); + } + + if ( is_wp_error( $permission_check ) ) { + return $permission_check; + } + + $updated = $this->update( $value, $object_data, $request ); + + if ( is_wp_error( $updated ) ) { + return $updated; + } + } + + /** + * Permission Check for the field's getter. Must be implemented in the inheriting class. + * + * @param mixed $object_data Whatever the endpoint would return for its response. + * @param WP_REST_Request $request + * @return true|WP_Error + */ + public function get_permission_check( $object_data, $request ) { + /* translators: %s: get_permission_check() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::get_permission_check', sprintf( __( "Method '%s' must be overridden.", 'jetpack' ), __METHOD__ ), 'Jetpack 6.8' ); + } + + /** + * The field's "raw" getter. Must be implemented in the inheriting class. + * + * @param mixed $object_data Whatever the endpoint would return for its response. + * @param WP_REST_Request $request + * @return mixed + */ + public function get( $object_data, $request ) { + /* translators: %s: get() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::get', sprintf( __( "Method '%s' must be overridden.", 'jetpack' ), __METHOD__ ), 'Jetpack 6.8' ); + } + + /** + * Permission Check for the field's setter. Must be implemented in the inheriting class. + * + * @param mixed $value The new value for the field. + * @param mixed $object_data Probably a WordPress object (e.g., WP_Post) + * @param WP_REST_Request $request + * @return true|WP_Error + */ + public function update_permission_check( $value, $object_data, $request ) { + /* translators: %s: update_permission_check() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::update_permission_check', sprintf( __( "Method '%s' must be overridden.", 'jetpack' ), __METHOD__ ), 'Jetpack 6.8' ); + } + + /** + * The field's "raw" setter. Must be implemented in the inheriting class. + * + * @param mixed $value The new value for the field. + * @param mixed $object_data Probably a WordPress object (e.g., WP_Post) + * @param WP_REST_Request $request + * @return mixed + */ + public function update( $value, $object_data, $request ) { + /* translators: %s: update() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::update', sprintf( __( "Method '%s' must be overridden.", 'jetpack' ), __METHOD__ ), 'Jetpack 6.8' ); + } + + /** + * The JSON Schema for the field + * + * @link https://json-schema.org/understanding-json-schema/ + * As of WordPress 5.0, Core currently understands: + * * type + * * string - not minLength, not maxLength, not pattern + * * integer - minimum, maximum, exclusiveMinimum, exclusiveMaximum, not multipleOf + * * number - minimum, maximum, exclusiveMinimum, exclusiveMaximum, not multipleOf + * * boolean + * * null + * * object - properties, additionalProperties, not propertyNames, not dependencies, not patternProperties, not required + * * array: only lists, not tuples - items, not minItems, not maxItems, not uniqueItems, not contains + * * enum + * * format + * * date-time + * * email + * * ip + * * uri + * As of WordPress 5.0, Core does not support: + * * Multiple type: `type: [ 'string', 'integer' ]` + * * $ref, allOf, anyOf, oneOf, not, const + * + * @return array + */ + public function get_schema() { + /* translators: %s: get_schema() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::get_schema', sprintf( __( "Method '%s' must be overridden.", 'jetpack' ), __METHOD__ ), 'Jetpack 6.8' ); + } + + /** + * @param array $schema + * @param string $context REST API Request context + * @return bool + */ + private function is_valid_for_context( $schema, $context ) { + return empty( $schema['context'] ) || in_array( $context, $schema['context'], true ); + } + + /** + * Removes properties that should not appear in the current + * request's context + * + * $context is a Core REST API Framework request attribute that is + * always one of: + * * view (what you see on the blog) + * * edit (what you see in an editor) + * * embed (what you see in, e.g., an oembed) + * + * Fields (and sub-fields, and sub-sub-...) can be flagged for a + * set of specific contexts via the field's schema. + * + * The Core API will filter out top-level fields with the wrong + * context, but will not recurse deeply enough into arrays/objects + * to remove all levels of sub-fields with the wrong context. + * + * This function handles that recursion. + * + * @param mixed $value + * @param array $schema + * @param string $context REST API Request context + * @return mixed Filtered $value + */ + final public function filter_response_by_context( $value, $schema, $context ) { + if ( ! $this->is_valid_for_context( $schema, $context ) ) { + // We use this intentionally odd looking WP_Error object + // internally only in this recursive function (see below + // in the `object` case). It will never be output by the REST API. + // If we return this for the top level object, Core + // correctly remove the top level object from the response + // for us. + return new WP_Error( '__wrong-context__' ); + } + + switch ( $schema['type'] ) { + case 'array': + if ( ! isset( $schema['items'] ) ) { + return $value; + } + + // Shortcircuit if we know none of the items are valid for this context. + // This would only happen in a strangely written schema. + if ( ! $this->is_valid_for_context( $schema['items'], $context ) ) { + return array(); + } + + // Recurse to prune sub-properties of each item. + foreach ( $value as $key => $item ) { + $value[ $key ] = $this->filter_response_by_context( $item, $schema['items'], $context ); + } + + return $value; + case 'object': + if ( ! isset( $schema['properties'] ) ) { + return $value; + } + + foreach ( $value as $field_name => $field_value ) { + if ( isset( $schema['properties'][ $field_name ] ) ) { + $field_value = $this->filter_response_by_context( $field_value, $schema['properties'][ $field_name ], $context ); + if ( is_wp_error( $field_value ) && '__wrong-context__' === $field_value->get_error_code() ) { + unset( $value[ $field_name ] ); + } else { + // Respect recursion that pruned sub-properties of each property. + $value[ $field_name ] = $field_value; + } + } + } + + return (object) $value; + } + + return $value; + } +} diff --git a/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-module-endpoints.php b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-module-endpoints.php new file mode 100644 index 00000000..96a47a08 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-module-endpoints.php @@ -0,0 +1,1708 @@ +<?php +/** + * This is the base class for every Core API endpoint Jetpack uses. + * + */ +class Jetpack_Core_API_Module_Toggle_Endpoint + extends Jetpack_Core_API_XMLRPC_Consumer_Endpoint { + + /** + * Check if the module requires the site to be publicly accessible from WPCOM. + * If the site meets this requirement, the module is activated. Otherwise an error is returned. + * + * @since 4.3.0 + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $slug Module slug. + * @type bool $active should module be activated. + * } + * + * @return WP_REST_Response|WP_Error A REST response if the request was served successfully, otherwise an error. + */ + public function process( $request ) { + if ( $request['active'] ) { + return $this->activate_module( $request ); + } else { + return $this->deactivate_module( $request ); + } + } + + /** + * If it's a valid Jetpack module, activate it. + * + * @since 4.3.0 + * + * @param string|WP_REST_Request $request It's a WP_REST_Request when called from endpoint /module/<slug>/* + * and a string when called from Jetpack_Core_API_Data->update_data. + * { + * Array of parameters received by request. + * + * @type string $slug Module slug. + * } + * + * @return bool|WP_Error True if module was activated. Otherwise, a WP_Error instance with the corresponding error. + */ + public function activate_module( $request ) { + $module_slug = ''; + + if ( + ( + is_array( $request ) + || is_object( $request ) + ) + && isset( $request['slug'] ) + ) { + $module_slug = $request['slug']; + } else { + $module_slug = $request; + } + + if ( ! Jetpack::is_module( $module_slug ) ) { + return new WP_Error( + 'not_found', + esc_html__( 'The requested Jetpack module was not found.', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + if ( ! Jetpack_Plan::supports( $module_slug ) ) { + return new WP_Error( + 'not_supported', + esc_html__( 'The requested Jetpack module is not supported by your plan.', 'jetpack' ), + array( 'status' => 424 ) + ); + } + + if ( Jetpack::activate_module( $module_slug, false, false ) ) { + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'The requested Jetpack module was activated.', 'jetpack' ), + ) ); + } + + return new WP_Error( + 'activation_failed', + esc_html__( 'The requested Jetpack module could not be activated.', 'jetpack' ), + array( 'status' => 424 ) + ); + } + + /** + * If it's a valid Jetpack module, deactivate it. + * + * @since 4.3.0 + * + * @param string|WP_REST_Request $request It's a WP_REST_Request when called from endpoint /module/<slug>/* + * and a string when called from Jetpack_Core_API_Data->update_data. + * { + * Array of parameters received by request. + * + * @type string $slug Module slug. + * } + * + * @return bool|WP_Error True if module was activated. Otherwise, a WP_Error instance with the corresponding error. + */ + public function deactivate_module( $request ) { + $module_slug = ''; + + if ( + ( + is_array( $request ) + || is_object( $request ) + ) + && isset( $request['slug'] ) + ) { + $module_slug = $request['slug']; + } else { + $module_slug = $request; + } + + if ( ! Jetpack::is_module( $module_slug ) ) { + return new WP_Error( + 'not_found', + esc_html__( 'The requested Jetpack module was not found.', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + if ( ! Jetpack::is_module_active( $module_slug ) ) { + return new WP_Error( + 'already_inactive', + esc_html__( 'The requested Jetpack module was already inactive.', 'jetpack' ), + array( 'status' => 409 ) + ); + } + + if ( Jetpack::deactivate_module( $module_slug ) ) { + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'The requested Jetpack module was deactivated.', 'jetpack' ), + ) ); + } + return new WP_Error( + 'deactivation_failed', + esc_html__( 'The requested Jetpack module could not be deactivated.', 'jetpack' ), + array( 'status' => 400 ) + ); + } + + /** + * Check that the current user has permissions to manage Jetpack modules. + * + * @since 4.3.0 + * + * @return bool + */ + public function can_request() { + return current_user_can( 'jetpack_manage_modules' ); + } +} + +class Jetpack_Core_API_Module_List_Endpoint { + + /** + * A WordPress REST API callback method that accepts a request object and decides what to do with it. + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @since 4.3.0 + * + * @return bool|Array|WP_Error a resulting value or object, or an error. + */ + public function process( $request ) { + if ( 'GET' === $request->get_method() ) { + return $this->get_modules( $request ); + } else { + return $this->activate_modules( $request ); + } + } + + /** + * Get a list of all Jetpack modules and their information. + * + * @since 4.3.0 + * + * @return array Array of Jetpack modules. + */ + public function get_modules() { + require_once( JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php' ); + + $modules = Jetpack_Admin::init()->get_modules(); + foreach ( $modules as $slug => $properties ) { + $modules[ $slug ]['options'] = + Jetpack_Core_Json_Api_Endpoints::prepare_options_for_response( $slug ); + if ( + isset( $modules[ $slug ]['requires_connection'] ) + && $modules[ $slug ]['requires_connection'] + && Jetpack::is_development_mode() + ) { + $modules[ $slug ]['activated'] = false; + } + } + + $modules = Jetpack::get_translated_modules( $modules ); + + return Jetpack_Core_Json_Api_Endpoints::prepare_modules_for_response( $modules ); + } + + /** + * Activate a list of valid Jetpack modules. + * + * @since 4.3.0 + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $slug Module slug. + * } + * + * @return bool|WP_Error True if modules were activated. Otherwise, a WP_Error instance with the corresponding error. + */ + public static function activate_modules( $request ) { + + if ( + ! isset( $request['modules'] ) + || ! is_array( $request['modules'] ) + ) { + return new WP_Error( + 'not_found', + esc_html__( 'The requested Jetpack module was not found.', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + $activated = array(); + $failed = array(); + + foreach ( $request['modules'] as $module ) { + if ( Jetpack::activate_module( $module, false, false ) ) { + $activated[] = $module; + } else { + $failed[] = $module; + } + } + + if ( empty( $failed ) ) { + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'All modules activated.', 'jetpack' ), + ) ); + } + + $error = ''; + + $activated_count = count( $activated ); + if ( $activated_count > 0 ) { + $activated_last = array_pop( $activated ); + $activated_text = $activated_count > 1 ? sprintf( + /* Translators: first variable is a list followed by the last item, which is the second variable. Example: dog, cat and bird. */ + __( '%1$s and %2$s', 'jetpack' ), + join( ', ', $activated ), $activated_last ) : $activated_last; + + $error = sprintf( + /* Translators: the variable is a module name. */ + _n( 'The module %s was activated.', 'The modules %s were activated.', $activated_count, 'jetpack' ), + $activated_text ) . ' '; + } + + $failed_count = count( $failed ); + if ( count( $failed ) > 0 ) { + $failed_last = array_pop( $failed ); + $failed_text = $failed_count > 1 ? sprintf( + /* Translators: first variable is a list followed by the last item, which is the second variable. Example: dog, cat and bird. */ + __( '%1$s and %2$s', 'jetpack' ), + join( ', ', $failed ), $failed_last ) : $failed_last; + + $error = sprintf( + /* Translators: the variable is a module name. */ + _n( 'The module %s failed to be activated.', 'The modules %s failed to be activated.', $failed_count, 'jetpack' ), + $failed_text ) . ' '; + } + + return new WP_Error( + 'activation_failed', + esc_html( $error ), + array( 'status' => 424 ) + ); + } + + /** + * A WordPress REST API permission callback method that accepts a request object and decides + * if the current user has enough privileges to act. + * + * @since 4.3.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return bool does the current user have enough privilege. + */ + public function can_request( $request ) { + if ( 'GET' === $request->get_method() ) { + return current_user_can( 'jetpack_admin_page' ); + } else { + return current_user_can( 'jetpack_manage_modules' ); + } + } +} + +/** + * Class that manages updating of Jetpack module options and general Jetpack settings or retrieving module data. + * If no module is specified, all module settings are retrieved/updated. + * + * @since 4.3.0 + * @since 4.4.0 Renamed Jetpack_Core_API_Module_Endpoint from to Jetpack_Core_API_Data. + * + * @author Automattic + */ +class Jetpack_Core_API_Data extends Jetpack_Core_API_XMLRPC_Consumer_Endpoint { + + /** + * Process request by returning the module or updating it. + * If no module is specified, settings for all modules are assumed. + * + * @since 4.3.0 + * + * @param WP_REST_Request $request + * + * @return bool|mixed|void|WP_Error + */ + public function process( $request ) { + if ( 'GET' === $request->get_method() ) { + if ( isset( $request['slug'] ) ) { + return $this->get_module( $request ); + } + + return $this->get_all_options(); + } else { + return $this->update_data( $request ); + } + } + + /** + * Get information about a specific and valid Jetpack module. + * + * @since 4.3.0 + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $slug Module slug. + * } + * + * @return mixed|void|WP_Error + */ + public function get_module( $request ) { + if ( Jetpack::is_module( $request['slug'] ) ) { + + $module = Jetpack::get_module( $request['slug'] ); + + $module['options'] = Jetpack_Core_Json_Api_Endpoints::prepare_options_for_response( $request['slug'] ); + + if ( + isset( $module['requires_connection'] ) + && $module['requires_connection'] + && Jetpack::is_development_mode() + ) { + $module['activated'] = false; + } + + $i18n = jetpack_get_module_i18n( $request['slug'] ); + if ( isset( $module['name'] ) ) { + $module['name'] = $i18n['name']; + } + if ( isset( $module['description'] ) ) { + $module['description'] = $i18n['description']; + $module['short_description'] = $i18n['description']; + } + + return Jetpack_Core_Json_Api_Endpoints::prepare_modules_for_response( $module ); + } + + return new WP_Error( + 'not_found', + esc_html__( 'The requested Jetpack module was not found.', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + /** + * Get information about all Jetpack module options and settings. + * + * @since 4.6.0 + * + * @return WP_REST_Response $response + */ + public function get_all_options() { + $response = array(); + + $modules = Jetpack::get_available_modules(); + if ( is_array( $modules ) && ! empty( $modules ) ) { + foreach ( $modules as $module ) { + // Add all module options + $options = Jetpack_Core_Json_Api_Endpoints::prepare_options_for_response( $module ); + foreach ( $options as $option_name => $option ) { + $response[ $option_name ] = $option['current_value']; + } + + // Add the module activation state + $response[ $module ] = Jetpack::is_module_active( $module ); + } + } + + $settings = Jetpack_Core_Json_Api_Endpoints::get_updateable_data_list( 'settings' ); + + if ( ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + foreach ( $settings as $setting => $properties ) { + switch ( $setting ) { + case 'lang_id': + if ( defined( 'WPLANG' ) ) { + // We can't affect this setting, so warn the client + $response[ $setting ] = 'error_const'; + break; + } + + if ( ! current_user_can( 'install_languages' ) ) { + // The user doesn't have caps to install language packs, so warn the client + $response[ $setting ] = 'error_cap'; + break; + } + + $value = get_option( 'WPLANG' ); + $response[ $setting ] = empty( $value ) ? 'en_US' : $value; + break; + + case 'wordpress_api_key': + // When field is clear, return empty. Otherwise it would return "false". + if ( '' === get_option( 'wordpress_api_key', '' ) ) { + $response[ $setting ] = ''; + } else { + if ( ! class_exists( 'Akismet' ) ) { + if ( is_readable( WP_PLUGIN_DIR . '/akismet/class.akismet.php' ) ) { + require_once WP_PLUGIN_DIR . '/akismet/class.akismet.php'; + } + } + $response[ $setting ] = class_exists( 'Akismet' ) ? Akismet::get_api_key() : ''; + } + break; + + case 'onboarding': + $business_address = get_option( 'jpo_business_address' ); + $business_address = is_array( $business_address ) ? array_map( array( $this, 'decode_special_characters' ), $business_address ) : $business_address; + + $response[ $setting ] = array( + 'siteTitle' => $this->decode_special_characters( get_option( 'blogname' ) ), + 'siteDescription' => $this->decode_special_characters( get_option( 'blogdescription' ) ), + 'siteType' => get_option( 'jpo_site_type' ), + 'homepageFormat' => get_option( 'jpo_homepage_format' ), + 'addContactForm' => intval( get_option( 'jpo_contact_page' ) ), + 'businessAddress' => $business_address, + 'installWooCommerce' => is_plugin_active( 'woocommerce/woocommerce.php' ), + 'stats' => Jetpack::is_active() && Jetpack::is_module_active( 'stats' ), + ); + break; + + default: + $response[ $setting ] = Jetpack_Core_Json_Api_Endpoints::cast_value( get_option( $setting ), $settings[ $setting ] ); + break; + } + } + + $response['akismet'] = is_plugin_active( 'akismet/akismet.php' ); + + return rest_ensure_response( $response ); + } + + /** + * Decode the special HTML characters in a certain value. + * + * @since 5.8 + * + * @param string $value Value to decode. + * + * @return string Value with decoded HTML characters. + */ + private function decode_special_characters( $value ) { + return (string) htmlspecialchars_decode( $value, ENT_QUOTES ); + } + + /** + * If it's a valid Jetpack module and configuration parameters have been sent, update it. + * + * @since 4.3.0 + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $slug Module slug. + * } + * + * @return bool|WP_Error True if module was updated. Otherwise, a WP_Error instance with the corresponding error. + */ + public function update_data( $request ) { + + // If it's null, we're trying to update many module options from different modules. + if ( is_null( $request['slug'] ) ) { + + // Value admitted by Jetpack_Core_Json_Api_Endpoints::get_updateable_data_list that will make it return all module options. + // It will not be passed. It's just checked in this method to pass that method a string or array. + $request['slug'] = 'any'; + } else { + if ( ! Jetpack::is_module( $request['slug'] ) ) { + return new WP_Error( 'not_found', esc_html__( 'The requested Jetpack module was not found.', 'jetpack' ), array( 'status' => 404 ) ); + } + + if ( ! Jetpack::is_module_active( $request['slug'] ) ) { + return new WP_Error( 'inactive', esc_html__( 'The requested Jetpack module is inactive.', 'jetpack' ), array( 'status' => 409 ) ); + } + } + + // Get parameters to update the module. We can not simply use $request->get_params() because when we registered + // this route, we are adding the entire output of Jetpack_Core_Json_Api_Endpoints::get_updateable_data_list() to + // the current request object's params. We are interested in body of the actual request. + // This may be JSON: + $params = $request->get_json_params(); + if ( ! is_array( $params ) ) { + // Or it may be standard POST key-value pairs: + $params = $request->get_body_params(); + } + + // Exit if no parameters were passed. + if ( ! is_array( $params ) ) { + return new WP_Error( 'missing_options', esc_html__( 'Missing options.', 'jetpack' ), array( 'status' => 404 ) ); + } + + // If $params was set via `get_body_params()` there may be some additional variables in the request that can + // cause validation to fail. This method verifies that each param was in fact updated and will throw a `some_updated` + // error if unused variables are included in the request. + foreach ( array_keys( $params ) as $key ) { + if ( is_int( $key ) || 'slug' === $key || 'context' === $key ) { + unset( $params[ $key ] ); + } + } + + // Get available module options. + $options = Jetpack_Core_Json_Api_Endpoints::get_updateable_data_list( 'any' === $request['slug'] + ? $params + : $request['slug'] + ); + + // Prepare to toggle module if needed + $toggle_module = new Jetpack_Core_API_Module_Toggle_Endpoint( new Jetpack_IXR_Client() ); + + // Options that are invalid or failed to update. + $invalid = array_keys( array_diff_key( $params, $options ) ); + $not_updated = array(); + + // Remove invalid options + $params = array_intersect_key( $params, $options ); + + // Used if response is successful. The message can be overwritten and additional data can be added here. + $response = array( + 'code' => 'success', + 'message' => esc_html__( 'The requested Jetpack data updates were successful.', 'jetpack' ), + ); + + // If there are modules to activate, activate them first so they're ready when their options are set. + foreach ( $params as $option => $value ) { + if ( 'modules' === $options[ $option ]['jp_group'] ) { + + // Used if there was an error. Can be overwritten with specific error messages. + $error = ''; + + // Set to true if the module toggling was successful. + $updated = false; + + // Check if user can toggle the module. + if ( $toggle_module->can_request() ) { + + // Activate or deactivate the module according to the value passed. + $toggle_result = $value + ? $toggle_module->activate_module( $option ) + : $toggle_module->deactivate_module( $option ); + + if ( + is_wp_error( $toggle_result ) + && 'already_inactive' === $toggle_result->get_error_code() + ) { + + // If the module is already inactive, we don't fail + $updated = true; + } elseif ( is_wp_error( $toggle_result ) ) { + $error = $toggle_result->get_error_message(); + } else { + $updated = true; + } + } else { + $error = Jetpack_Core_Json_Api_Endpoints::$user_permissions_error_msg; + } + + // The module was not toggled. + if ( ! $updated ) { + $not_updated[ $option ] = $error; + } + + // Remove module from list so we don't go through it again. + unset( $params[ $option ] ); + } + } + + foreach ( $params as $option => $value ) { + + // Used if there was an error. Can be overwritten with specific error messages. + $error = ''; + + // Set to true if the option update was successful. + $updated = false; + + // Get option attributes, including the group it belongs to. + $option_attrs = $options[ $option ]; + + // If this is a module option and the related module isn't active for any reason, continue with the next one. + if ( 'settings' !== $option_attrs['jp_group'] ) { + if ( ! Jetpack::is_module( $option_attrs['jp_group'] ) ) { + $not_updated[ $option ] = esc_html__( 'The requested Jetpack module was not found.', 'jetpack' ); + continue; + } + + if ( + 'any' !== $request['slug'] + && ! Jetpack::is_module_active( $option_attrs['jp_group'] ) + ) { + + // We only take note of skipped options when updating one module + $not_updated[ $option ] = esc_html__( 'The requested Jetpack module is inactive.', 'jetpack' ); + continue; + } + } + + // Properly cast value based on its type defined in endpoint accepted args. + $value = Jetpack_Core_Json_Api_Endpoints::cast_value( $value, $option_attrs ); + + switch ( $option ) { + case 'lang_id': + if ( defined( 'WPLANG' ) || ! current_user_can( 'install_languages' ) ) { + // We can't affect this setting + $updated = false; + break; + } + + if ( $value === 'en_US' || empty( $value ) ) { + return delete_option( 'WPLANG' ); + } + + if ( ! function_exists( 'request_filesystem_credentials' ) ) { + require_once( ABSPATH . 'wp-admin/includes/file.php' ); + } + + if ( ! function_exists( 'wp_download_language_pack' ) ) { + require_once ABSPATH . 'wp-admin/includes/translation-install.php'; + } + + // `wp_download_language_pack` only tries to download packs if they're not already available + $language = wp_download_language_pack( $value ); + if ( $language === false ) { + // The language pack download failed. + $updated = false; + break; + } + $updated = get_option( 'WPLANG' ) === $language ? true : update_option( 'WPLANG', $language ); + break; + + case 'monitor_receive_notifications': + $monitor = new Jetpack_Monitor(); + + // If we got true as response, consider it done. + $updated = true === $monitor->update_option_receive_jetpack_monitor_notification( $value ); + break; + + case 'post_by_email_address': + if ( 'create' == $value ) { + $result = $this->_process_post_by_email( + 'jetpack.createPostByEmailAddress', + esc_html__( 'Unable to create the Post by Email address. Please try again later.', 'jetpack' ) + ); + } elseif ( 'regenerate' == $value ) { + $result = $this->_process_post_by_email( + 'jetpack.regeneratePostByEmailAddress', + esc_html__( 'Unable to regenerate the Post by Email address. Please try again later.', 'jetpack' ) + ); + } elseif ( 'delete' == $value ) { + $result = $this->_process_post_by_email( + 'jetpack.deletePostByEmailAddress', + esc_html__( 'Unable to delete the Post by Email address. Please try again later.', 'jetpack' ) + ); + } else { + $result = false; + } + + // If we got an email address (create or regenerate) or 1 (delete), consider it done. + if ( is_string( $result ) && preg_match( '/[a-z0-9]+@post.wordpress.com/', $result ) ) { + $response[$option] = $result; + $updated = true; + } elseif ( 1 == $result ) { + $updated = true; + } elseif ( is_array( $result ) && isset( $result['message'] ) ) { + $error = $result['message']; + } + break; + + case 'jetpack_protect_key': + $protect = Jetpack_Protect_Module::instance(); + if ( 'create' == $value ) { + $result = $protect->get_protect_key(); + } else { + $result = false; + } + + // If we got one of Protect keys, consider it done. + if ( preg_match( '/[a-z0-9]{40,}/i', $result ) ) { + $response[$option] = $result; + $updated = true; + } + break; + + case 'jetpack_protect_global_whitelist': + $updated = jetpack_protect_save_whitelist( explode( PHP_EOL, str_replace( array( ' ', ',' ), array( '', "\n" ), $value ) ) ); + if ( is_wp_error( $updated ) ) { + $error = $updated->get_error_message(); + } + break; + + case 'show_headline': + case 'show_thumbnails': + $grouped_options = $grouped_options_current = (array) Jetpack_Options::get_option( 'relatedposts' ); + $grouped_options[$option] = $value; + + // If option value was the same, consider it done. + $updated = $grouped_options_current != $grouped_options ? Jetpack_Options::update_option( 'relatedposts', $grouped_options ) : true; + break; + + case 'google': + case 'bing': + case 'pinterest': + case 'yandex': + $grouped_options = $grouped_options_current = (array) get_option( 'verification_services_codes' ); + + // Extracts the content attribute from the HTML meta tag if needed + if ( preg_match( '#.*<meta name="(?:[^"]+)" content="([^"]+)" />.*#i', $value, $matches ) ) { + $grouped_options[ $option ] = $matches[1]; + } else { + $grouped_options[ $option ] = $value; + } + + // If option value was the same, consider it done. + $updated = $grouped_options_current != $grouped_options ? update_option( 'verification_services_codes', $grouped_options ) : true; + break; + + case 'sharing_services': + if ( ! class_exists( 'Sharing_Service' ) && ! include_once( JETPACK__PLUGIN_DIR . 'modules/sharedaddy/sharing-service.php' ) ) { + break; + } + + $sharer = new Sharing_Service(); + + // If option value was the same, consider it done. + $updated = $value != $sharer->get_blog_services() ? $sharer->set_blog_services( $value['visible'], $value['hidden'] ) : true; + break; + + case 'button_style': + case 'sharing_label': + case 'show': + if ( ! class_exists( 'Sharing_Service' ) && ! include_once( JETPACK__PLUGIN_DIR . 'modules/sharedaddy/sharing-service.php' ) ) { + break; + } + + $sharer = new Sharing_Service(); + $grouped_options = $sharer->get_global_options(); + $grouped_options[ $option ] = $value; + $updated = $sharer->set_global_options( $grouped_options ); + break; + + case 'custom': + if ( ! class_exists( 'Sharing_Service' ) && ! include_once( JETPACK__PLUGIN_DIR . 'modules/sharedaddy/sharing-service.php' ) ) { + break; + } + + $sharer = new Sharing_Service(); + $updated = $sharer->new_service( stripslashes( $value['sharing_name'] ), stripslashes( $value['sharing_url'] ), stripslashes( $value['sharing_icon'] ) ); + + // Return new custom service + $response[$option] = $updated; + break; + + case 'sharing_delete_service': + if ( ! class_exists( 'Sharing_Service' ) && ! include_once( JETPACK__PLUGIN_DIR . 'modules/sharedaddy/sharing-service.php' ) ) { + break; + } + + $sharer = new Sharing_Service(); + $updated = $sharer->delete_service( $value ); + break; + + case 'jetpack-twitter-cards-site-tag': + $value = trim( ltrim( strip_tags( $value ), '@' ) ); + $updated = get_option( $option ) !== $value ? update_option( $option, $value ) : true; + break; + + case 'admin_bar': + case 'roles': + case 'count_roles': + case 'blog_id': + case 'do_not_track': + case 'hide_smile': + case 'version': + $grouped_options = $grouped_options_current = (array) get_option( 'stats_options' ); + $grouped_options[$option] = $value; + + // If option value was the same, consider it done. + $updated = $grouped_options_current != $grouped_options ? update_option( 'stats_options', $grouped_options ) : true; + break; + + case 'akismet_show_user_comments_approved': + + // Save Akismet option '1' or '0' like it's done in akismet/class.akismet-admin.php + $updated = get_option( $option ) != $value ? update_option( $option, (bool) $value ? '1' : '0' ) : true; + break; + + case 'wordpress_api_key': + + if ( ! file_exists( WP_PLUGIN_DIR . '/akismet/class.akismet.php' ) ) { + $error = esc_html__( 'Please install Akismet.', 'jetpack' ); + $updated = false; + break; + } + + if ( ! defined( 'AKISMET_VERSION' ) ) { + $error = esc_html__( 'Please activate Akismet.', 'jetpack' ); + $updated = false; + break; + } + + // Allow to clear the API key field + if ( '' === $value ) { + $updated = get_option( $option ) != $value ? update_option( $option, $value ) : true; + break; + } + + require_once WP_PLUGIN_DIR . '/akismet/class.akismet.php'; + require_once WP_PLUGIN_DIR . '/akismet/class.akismet-admin.php'; + + if ( class_exists( 'Akismet_Admin' ) && method_exists( 'Akismet_Admin', 'save_key' ) ) { + if ( Akismet::verify_key( $value ) === 'valid' ) { + $akismet_user = Akismet_Admin::get_akismet_user( $value ); + if ( $akismet_user ) { + if ( in_array( $akismet_user->status, array( 'active', 'active-dunning', 'no-sub' ) ) ) { + $updated = get_option( $option ) != $value ? update_option( $option, $value ) : true; + break; + } else { + $error = esc_html__( "Akismet user status doesn't allow to update the key", 'jetpack' ); + } + } else { + $error = esc_html__( 'Invalid Akismet user', 'jetpack' ); + } + } else { + $error = esc_html__( 'Invalid Akismet key', 'jetpack' ); + } + } else { + $error = esc_html__( 'Akismet is not installed or active', 'jetpack' ); + } + $updated = false; + break; + + case 'google_analytics_tracking_id': + $grouped_options = $grouped_options_current = (array) get_option( 'jetpack_wga' ); + $grouped_options[ 'code' ] = $value; + + // If option value was the same, consider it done. + $updated = $grouped_options_current != $grouped_options ? update_option( 'jetpack_wga', $grouped_options ) : true; + break; + + case 'dismiss_dash_app_card': + case 'dismiss_empty_stats_card': + // If option value was the same, consider it done. + $updated = get_option( $option ) != $value ? update_option( $option, (bool) $value ) : true; + break; + + case 'onboarding': + jetpack_require_lib( 'widgets' ); + // Break apart and set Jetpack onboarding options. + $result = $this->_process_onboarding( (array) $value ); + if ( empty( $result ) ) { + $updated = true; + } else { + $error = sprintf( esc_html__( 'Onboarding failed to process: %s', 'jetpack' ), $result ); + $updated = false; + } + break; + + default: + // If option value was the same, consider it done. + $updated = get_option( $option ) != $value ? update_option( $option, $value ) : true; + break; + } + + // The option was not updated. + if ( ! $updated ) { + $not_updated[ $option ] = $error; + } + } + + if ( empty( $invalid ) && empty( $not_updated ) ) { + // The option was updated. + return rest_ensure_response( $response ); + } else { + $invalid_count = count( $invalid ); + $not_updated_count = count( $not_updated ); + $error = ''; + if ( $invalid_count > 0 ) { + $error = sprintf( + /* Translators: the plural variable is a comma-separated list. Example: dog, cat, bird. */ + _n( 'Invalid option: %s.', 'Invalid options: %s.', $invalid_count, 'jetpack' ), + join( ', ', $invalid ) + ); + } + if ( $not_updated_count > 0 ) { + $not_updated_messages = array(); + foreach ( $not_updated as $not_updated_option => $not_updated_message ) { + if ( ! empty( $not_updated_message ) ) { + $not_updated_messages[] = sprintf( + /* Translators: the first variable is a module option or slug, or setting. The second is the error message . */ + __( '%1$s: %2$s', 'jetpack' ), + $not_updated_option, $not_updated_message ); + } + } + if ( ! empty( $error ) ) { + $error .= ' '; + } + if ( ! empty( $not_updated_messages ) ) { + $error .= ' ' . join( '. ', $not_updated_messages ); + } + + } + // There was an error because some options were updated but others were invalid or failed to update. + return new WP_Error( 'some_updated', esc_html( $error ), array( 'status' => 400 ) ); + } + + } + + /** + * Perform tasks in the site based on onboarding choices. + * + * @since 5.4.0 + * + * @param array $data Onboarding choices made by user. + * + * @return string Result of onboarding processing and, if there is one, an error message. + */ + private function _process_onboarding( $data ) { + if ( isset( $data['end'] ) && $data['end'] ) { + return Jetpack::invalidate_onboarding_token() + ? '' + : esc_html__( "The onboarding token couldn't be deleted.", 'jetpack' ); + } + + $error = array(); + + if ( ! empty( $data['siteTitle'] ) ) { + // If option value was the same, consider it done. + if ( ! ( update_option( 'blogname', $data['siteTitle'] ) || get_option( 'blogname' ) == $data['siteTitle'] ) ) { + $error[] = 'siteTitle'; + } + } + + if ( isset( $data['siteDescription'] ) ) { + // If option value was the same, consider it done. + if ( ! ( update_option( 'blogdescription', $data['siteDescription'] ) || get_option( 'blogdescription' ) == $data['siteDescription'] ) ) { + $error[] = 'siteDescription'; + } + } + + $site_title = get_option( 'blogname' ); + $author = get_current_user_id() || 1; + + if ( ! empty( $data['siteType'] ) ) { + if ( ! ( update_option( 'jpo_site_type', $data['siteType'] ) || get_option( 'jpo_site_type' ) == $data['siteType'] ) ) { + $error[] = 'siteType'; + } + } + + if ( isset( $data['homepageFormat'] ) ) { + // If $data['homepageFormat'] is 'posts', we have nothing to do since it's WordPress' default + // if it exists, just update + $homepage_format = get_option( 'jpo_homepage_format' ); + if ( ! $homepage_format || $homepage_format !== $data['homepageFormat'] ) { + if ( 'page' === $data['homepageFormat'] ) { + if ( ! ( update_option( 'show_on_front', 'page' ) || get_option( 'show_on_front' ) == 'page' ) ) { + $error[] = 'homepageFormat'; + } + + $home = wp_insert_post( array( + 'post_type' => 'page', + /* translators: this references the home page of a site, also called front page. */ + 'post_title' => esc_html_x( 'Home Page', 'The home page of a website.', 'jetpack' ), + 'post_content' => sprintf( esc_html__( 'Welcome to %s.', 'jetpack' ), $site_title ), + 'post_status' => 'publish', + 'post_author' => $author, + ) ); + if ( 0 == $home ) { + $error[] = 'home insert: 0'; + } elseif ( is_wp_error( $home ) ) { + $error[] = 'home creation: '. $home->get_error_message(); + } + if ( ! ( update_option( 'page_on_front', $home ) || get_option( 'page_on_front' ) == $home ) ) { + + $error[] = 'home set'; + } + + $blog = wp_insert_post( array( + 'post_type' => 'page', + /* translators: this references the page where blog posts are listed. */ + 'post_title' => esc_html_x( 'Blog', 'The blog of a website.', 'jetpack' ), + 'post_content' => sprintf( esc_html__( 'These are the latest posts in %s.', 'jetpack' ), $site_title ), + 'post_status' => 'publish', + 'post_author' => $author, + ) ); + if ( 0 == $blog ) { + $error[] = 'blog insert: 0'; + } elseif ( is_wp_error( $blog ) ) { + $error[] = 'blog creation: '. $blog->get_error_message(); + } + if ( ! ( update_option( 'page_for_posts', $blog ) || get_option( 'page_for_posts' ) == $blog ) ) { + $error[] = 'blog set'; + } + } else { + $front_page = get_option( 'page_on_front' ); + $posts_page = get_option( 'page_for_posts' ); + if ( $posts_page && get_post( $posts_page ) ) { + wp_delete_post( $posts_page ); + } + if ( $front_page && get_post( $front_page ) ) { + wp_delete_post( $front_page ); + } + update_option( 'show_on_front', 'posts' ); + } + } + update_option( 'jpo_homepage_format', $data['homepageFormat'] ); + } + + // Setup contact page and add a form and/or business info + $contact_page = ''; + if ( ! empty( $data['addContactForm'] ) && ! get_option( 'jpo_contact_page' ) ) { + $contact_form_module_active = Jetpack::is_module_active( 'contact-form' ); + if ( ! $contact_form_module_active ) { + $contact_form_module_active = Jetpack::activate_module( 'contact-form', false, false ); + } + + if ( $contact_form_module_active ) { + $contact_page = '[contact-form][contact-field label="' . esc_html__( 'Name', 'jetpack' ) . '" type="name" required="true" /][contact-field label="' . esc_html__( 'Email', 'jetpack' ) . '" type="email" required="true" /][contact-field label="' . esc_html__( 'Website', 'jetpack' ) . '" type="url" /][contact-field label="' . esc_html__( 'Message', 'jetpack' ) . '" type="textarea" /][/contact-form]'; + } else { + $error[] = 'contact-form activate'; + } + } + + if ( isset( $data['businessPersonal'] ) && 'business' === $data['businessPersonal'] ) { + $contact_page .= "\n" . join( "\n", $data['businessInfo'] ); + } + + if ( ! empty( $contact_page ) ) { + $form = wp_insert_post( array( + 'post_type' => 'page', + /* translators: this references a page with contact details and possibly a form. */ + 'post_title' => esc_html_x( 'Contact us', 'Contact page for your website.', 'jetpack' ), + 'post_content' => esc_html__( 'Send us a message!', 'jetpack' ) . "\n" . $contact_page, + 'post_status' => 'publish', + 'post_author' => $author, + ) ); + if ( 0 == $form ) { + $error[] = 'form insert: 0'; + } elseif ( is_wp_error( $form ) ) { + $error[] = 'form creation: '. $form->get_error_message(); + } else { + update_option( 'jpo_contact_page', $form ); + } + } + + if ( isset( $data['businessAddress'] ) ) { + $handled_business_address = self::handle_business_address( $data['businessAddress'] ); + if ( is_wp_error( $handled_business_address ) ) { + $error[] = 'BusinessAddress'; + } + } + + if ( ! empty( $data['installWooCommerce'] ) ) { + jetpack_require_lib( 'plugins' ); + $wc_install_result = Jetpack_Plugins::install_and_activate_plugin( 'woocommerce' ); + delete_transient( '_wc_activation_redirect' ); // Redirecting to WC setup would kill our users' flow + if ( is_wp_error( $wc_install_result ) ) { + $error[] = 'woocommerce installation'; + } + } + + if ( ! empty( $data['stats'] ) ) { + if ( Jetpack::is_active() ) { + $stats_module_active = Jetpack::is_module_active( 'stats' ); + if ( ! $stats_module_active ) { + $stats_module_active = Jetpack::activate_module( 'stats', false, false ); + } + + if ( ! $stats_module_active ) { + $error[] = 'stats activate'; + } + } else { + $error[] = 'stats not connected'; + } + } + + return empty( $error ) + ? '' + : join( ', ', $error ); + } + + /** + * Add or update Business Address widget. + * + * @param array $address Array of business address fields. + * + * @return WP_Error|true True if the data was saved correctly. + */ + static function handle_business_address( $address ) { + $first_sidebar = Jetpack_Widgets::get_first_sidebar(); + + $widgets_module_active = Jetpack::is_module_active( 'widgets' ); + if ( ! $widgets_module_active ) { + $widgets_module_active = Jetpack::activate_module( 'widgets', false, false ); + } + if ( ! $widgets_module_active ) { + return new WP_Error( 'module_activation_failed', 'Failed to activate the widgets module.', 400 ); + } + + if ( $first_sidebar ) { + $title = isset( $address['name'] ) ? sanitize_text_field( $address['name'] ) : ''; + $street = isset( $address['street'] ) ? sanitize_text_field( $address['street'] ) : ''; + $city = isset( $address['city'] ) ? sanitize_text_field( $address['city'] ) : ''; + $state = isset( $address['state'] ) ? sanitize_text_field( $address['state'] ) : ''; + $zip = isset( $address['zip'] ) ? sanitize_text_field( $address['zip'] ) : ''; + $country = isset( $address['country'] ) ? sanitize_text_field( $address['country'] ) : ''; + + $full_address = implode( ' ', array_filter( array( $street, $city, $state, $zip, $country ) ) ); + + $widget_options = array( + 'title' => $title, + 'address' => $full_address, + 'phone' => '', + 'hours' => '', + 'showmap' => false, + 'email' => '' + ); + + $widget_updated = ''; + if ( ! self::has_business_address_widget( $first_sidebar ) ) { + $widget_updated = Jetpack_Widgets::insert_widget_in_sidebar( 'widget_contact_info', $widget_options, $first_sidebar ); + } else { + $widget_updated = Jetpack_Widgets::update_widget_in_sidebar( 'widget_contact_info', $widget_options, $first_sidebar ); + } + if ( is_wp_error( $widget_updated ) ) { + return new WP_Error( 'widget_update_failed', 'Widget could not be updated.', 400 ); + } + + $address_save = array( + 'name' => $title, + 'street' => $street, + 'city' => $city, + 'state' => $state, + 'zip' => $zip, + 'country' => $country + ); + update_option( 'jpo_business_address', $address_save ); + return true; + } + + // No sidebar to place the widget + return new WP_Error( 'sidebar_not_found', 'No sidebar.', 400 ); + } + + /** + * Check whether "Contact Info & Map" widget is present in a given sidebar. + * + * @param string $sidebar ID of the sidebar to which the widget will be added. + * + * @return bool Whether the widget is present in a given sidebar. + */ + static function has_business_address_widget( $sidebar ) { + $sidebars_widgets = get_option( 'sidebars_widgets', array() ); + if ( ! isset( $sidebars_widgets[ $sidebar ] ) ) { + return false; + } + foreach ( $sidebars_widgets[ $sidebar ] as $widget ) { + if ( strpos( $widget, 'widget_contact_info' ) !== false ) { + return true; + } + } + return false; + } + + /** + * Calls WPCOM through authenticated request to create, regenerate or delete the Post by Email address. + * @todo: When all settings are updated to use endpoints, move this to the Post by Email module and replace __process_ajax_proxy_request. + * + * @since 4.3.0 + * + * @param string $endpoint Process to call on WPCOM to create, regenerate or delete the Post by Email address. + * @param string $error Error message to return. + * + * @return array + */ + private function _process_post_by_email( $endpoint, $error ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return array( 'message' => $error ); + } + + $this->xmlrpc->query( $endpoint ); + + if ( $this->xmlrpc->isError() ) { + return array( 'message' => $error ); + } + + $response = $this->xmlrpc->getResponse(); + if ( empty( $response ) ) { + return array( 'message' => $error ); + } + + // Used only in Jetpack_Core_Json_Api_Endpoints::get_remote_value. + update_option( 'post_by_email_address' . get_current_user_id(), $response ); + + return $response; + } + + /** + * Check if user is allowed to perform the update. + * + * @since 4.3.0 + * + * @param WP_REST_Request $request The request sent to the WP REST API. + * + * @return bool + */ + public function can_request( $request ) { + $req_params = $request->get_params(); + if ( ! empty( $req_params['onboarding']['token'] ) && isset( $req_params['rest_route'] ) ) { + return Jetpack::validate_onboarding_token_action( $req_params['onboarding']['token'], $req_params['rest_route'] ); + } + + if ( 'GET' === $request->get_method() ) { + return current_user_can( 'jetpack_admin_page' ); + } else { + $module = Jetpack_Core_Json_Api_Endpoints::get_module_requested(); + if ( empty( $module ) ) { + $params = $request->get_json_params(); + if ( ! is_array( $params ) ) { + $params = $request->get_body_params(); + } + $options = Jetpack_Core_Json_Api_Endpoints::get_updateable_data_list( $params ); + foreach ( $options as $option => $definition ) { + if ( in_array( $options[ $option ]['jp_group'], array( 'post-by-email' ) ) ) { + $module = $options[ $option ]['jp_group']; + break; + } + } + } + // User is trying to create, regenerate or delete its PbE. + if ( 'post-by-email' === $module ) { + return current_user_can( 'edit_posts' ) && current_user_can( 'jetpack_admin_page' ); + } + return current_user_can( 'jetpack_configure_modules' ); + } + } +} + +class Jetpack_Core_API_Module_Data_Endpoint { + + public function process( $request ) { + switch( $request['slug'] ) { + case 'protect': + return $this->get_protect_data(); + case 'stats': + return $this->get_stats_data( $request ); + case 'akismet': + return $this->get_akismet_data(); + case 'monitor': + return $this->get_monitor_data(); + case 'verification-tools': + return $this->get_verification_tools_data(); + case 'vaultpress': + return $this->get_vaultpress_data(); + } + } + + /** + * Decide against which service to check the key. + * + * @since 4.8.0 + * + * @param WP_REST_Request $request + * + * @return bool + */ + public function key_check( $request ) { + switch( $request['service'] ) { + case 'akismet': + $params = $request->get_json_params(); + if ( isset( $params['api_key'] ) && ! empty( $params['api_key'] ) ) { + return $this->check_akismet_key( $params['api_key'] ); + } + return $this->check_akismet_key(); + } + return false; + } + + /** + * Get number of blocked intrusion attempts. + * + * @since 4.3.0 + * + * @return mixed|WP_Error Number of blocked attempts if protection is enabled. Otherwise, a WP_Error instance with the corresponding error. + */ + public function get_protect_data() { + if ( Jetpack::is_module_active( 'protect' ) ) { + return get_site_option( 'jetpack_protect_blocked_attempts' ); + } + + return new WP_Error( + 'not_active', + esc_html__( 'The requested Jetpack module is not active.', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + /** + * Get number of spam messages blocked by Akismet. + * + * @since 4.3.0 + * + * @return int|string Number of spam blocked by Akismet. Otherwise, an error message. + */ + public function get_akismet_data() { + if ( ! is_wp_error( $status = $this->akismet_is_active_and_registered() ) ) { + return rest_ensure_response( Akismet_Admin::get_stats( Akismet::get_api_key() ) ); + } else { + return $status->get_error_code(); + } + } + + /** + * Verify the Akismet API key. + * + * @since 4.8.0 + * + * @param string $api_key Optional API key to check. + * + * @return array Information about the key. 'validKey' is true if key is valid, false otherwise. + */ + public function check_akismet_key( $api_key = '' ) { + $akismet_status = $this->akismet_class_exists(); + if ( is_wp_error( $akismet_status ) ) { + return rest_ensure_response( array( + 'validKey' => false, + 'invalidKeyCode' => $akismet_status->get_error_code(), + 'invalidKeyMessage' => $akismet_status->get_error_message(), + ) ); + } + + $key_status = Akismet::check_key_status( empty( $api_key ) ? Akismet::get_api_key() : $api_key ); + + if ( ! $key_status || 'invalid' === $key_status || 'failed' === $key_status ) { + return rest_ensure_response( array( + 'validKey' => false, + 'invalidKeyCode' => 'invalid_key', + 'invalidKeyMessage' => esc_html__( 'Invalid Akismet key. Please contact support.', 'jetpack' ), + ) ); + } + + return rest_ensure_response( array( + 'validKey' => isset( $key_status[1] ) && 'valid' === $key_status[1] + ) ); + } + + /** + * Check if Akismet class file exists and if class is loaded. + * + * @since 4.8.0 + * + * @return bool|WP_Error Returns true if class file exists and class is loaded, WP_Error otherwise. + */ + private function akismet_class_exists() { + if ( ! file_exists( WP_PLUGIN_DIR . '/akismet/class.akismet.php' ) ) { + return new WP_Error( 'not_installed', esc_html__( 'Please install Akismet.', 'jetpack' ), array( 'status' => 400 ) ); + } + + if ( ! class_exists( 'Akismet' ) ) { + return new WP_Error( 'not_active', esc_html__( 'Please activate Akismet.', 'jetpack' ), array( 'status' => 400 ) ); + } + + return true; + } + + /** + * Is Akismet registered and active? + * + * @since 4.3.0 + * + * @return bool|WP_Error True if Akismet is active and registered. Otherwise, a WP_Error instance with the corresponding error. + */ + private function akismet_is_active_and_registered() { + if ( is_wp_error( $akismet_exists = $this->akismet_class_exists() ) ) { + return $akismet_exists; + } + + // What about if Akismet is put in a sub-directory or maybe in mu-plugins? + require_once WP_PLUGIN_DIR . '/akismet/class.akismet.php'; + require_once WP_PLUGIN_DIR . '/akismet/class.akismet-admin.php'; + $akismet_key = Akismet::verify_key( Akismet::get_api_key() ); + + if ( ! $akismet_key || 'invalid' === $akismet_key || 'failed' === $akismet_key ) { + return new WP_Error( 'invalid_key', esc_html__( 'Invalid Akismet key. Please contact support.', 'jetpack' ), array( 'status' => 400 ) ); + } + + return true; + } + + /** + * Get stats data for this site + * + * @since 4.1.0 + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $date Date range to restrict results to. + * } + * + * @return WP_Error|WP_HTTP_Response|WP_REST_Response Stats information relayed from WordPress.com. + */ + public function get_stats_data( WP_REST_Request $request ) { + // Get parameters to fetch Stats data. + $range = $request->get_param( 'range' ); + + // If no parameters were passed. + if ( + empty ( $range ) + || ! in_array( $range, array( 'day', 'week', 'month' ), true ) + ) { + $range = 'day'; + } + + if ( ! function_exists( 'stats_get_from_restapi' ) ) { + require_once( JETPACK__PLUGIN_DIR . 'modules/stats.php' ); + } + + switch ( $range ) { + + // This is always called first on page load + case 'day': + $initial_stats = stats_get_from_restapi(); + return rest_ensure_response( array( + 'general' => $initial_stats, + + // Build data for 'day' as if it was stats_get_from_restapi( array(), 'visits?unit=day&quantity=30' ); + 'day' => isset( $initial_stats->visits ) + ? $initial_stats->visits + : array(), + ) ); + case 'week': + return rest_ensure_response( array( + 'week' => stats_get_from_restapi( array(), 'visits?unit=week&quantity=14' ), + ) ); + case 'month': + return rest_ensure_response( array( + 'month' => stats_get_from_restapi( array(), 'visits?unit=month&quantity=12&' ), + ) ); + } + } + + /** + * Get date of last downtime. + * + * @since 4.3.0 + * + * @return mixed|WP_Error Number of days since last downtime. Otherwise, a WP_Error instance with the corresponding error. + */ + public function get_monitor_data() { + if ( ! Jetpack::is_module_active( 'monitor' ) ) { + return new WP_Error( + 'not_active', + esc_html__( 'The requested Jetpack module is not active.', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + $monitor = new Jetpack_Monitor(); + $last_downtime = $monitor->monitor_get_last_downtime(); + if ( is_wp_error( $last_downtime ) ) { + return $last_downtime; + } else if ( false === strtotime( $last_downtime ) ) { + return rest_ensure_response( array( + 'code' => 'success', + 'date' => null, + ) ); + } else { + return rest_ensure_response( array( + 'code' => 'success', + 'date' => human_time_diff( strtotime( $last_downtime ), strtotime( 'now' ) ), + ) ); + } + } + + /** + * Get services that this site is verified with. + * + * @since 4.3.0 + * + * @return mixed|WP_Error List of services that verified this site. Otherwise, a WP_Error instance with the corresponding error. + */ + public function get_verification_tools_data() { + if ( ! Jetpack::is_module_active( 'verification-tools' ) ) { + return new WP_Error( + 'not_active', + esc_html__( 'The requested Jetpack module is not active.', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + $verification_services_codes = get_option( 'verification_services_codes' ); + if ( + ! is_array( $verification_services_codes ) + || empty( $verification_services_codes ) + ) { + return new WP_Error( + 'empty', + esc_html__( 'Site not verified with any service.', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + $services = array(); + foreach ( jetpack_verification_services() as $name => $service ) { + if ( is_array( $service ) && ! empty( $verification_services_codes[ $name ] ) ) { + switch ( $name ) { + case 'google': + $services[] = 'Google'; + break; + case 'bing': + $services[] = 'Bing'; + break; + case 'pinterest': + $services[] = 'Pinterest'; + break; + case 'yandex': + $services[] = 'Yandex'; + break; + } + } + } + + if ( empty( $services ) ) { + return new WP_Error( + 'empty', + esc_html__( 'Site not verified with any service.', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + if ( 2 > count( $services ) ) { + $message = esc_html( + sprintf( + /* translators: %s is a service name like Google, Bing, Pinterest, etc. */ + __( 'Your site is verified with %s.', 'jetpack' ), + $services[0] + ) + ); + } else { + $copy_services = $services; + $last = count( $copy_services ) - 1; + $last_service = $copy_services[ $last ]; + unset( $copy_services[ $last ] ); + $message = esc_html( + sprintf( + /* translators: %1$s is a comma separated list of services, and %2$s is a single service name like Google, Bing, Pinterest, etc. */ + __( 'Your site is verified with %1$s and %2$s.', 'jetpack' ), + join( ', ', $copy_services ), + $last_service + ) + ); + } + + return rest_ensure_response( array( + 'code' => 'success', + 'message' => $message, + 'services' => $services, + ) ); + } + + /** + * Get VaultPress site data including, among other things, the date of the last backup if it was completed. + * + * @since 4.3.0 + * + * @return mixed|WP_Error VaultPress site data. Otherwise, a WP_Error instance with the corresponding error. + */ + public function get_vaultpress_data() { + if ( ! class_exists( 'VaultPress' ) ) { + return new WP_Error( + 'not_active', + esc_html__( 'The requested Jetpack module is not active.', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + $vaultpress = new VaultPress(); + if ( ! $vaultpress->is_registered() ) { + return rest_ensure_response( array( + 'code' => 'not_registered', + 'message' => esc_html__( 'You need to register for VaultPress.', 'jetpack' ) + ) ); + } + + $data = json_decode( base64_decode( $vaultpress->contact_service( 'plugin_data' ) ) ); + if ( false == $data ) { + return rest_ensure_response( array( + 'code' => 'not_registered', + 'message' => esc_html__( 'Could not connect to VaultPress.', 'jetpack' ) + ) ); + } else if ( is_wp_error( $data ) || ! isset( $data->backups->last_backup ) ) { + return $data; + } else if ( empty( $data->backups->last_backup ) ) { + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'VaultPress is active and will back up your site soon.', 'jetpack' ), + 'data' => $data, + ) ); + } else { + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html( + sprintf( + __( 'Your site was successfully backed-up %s ago.', 'jetpack' ), + human_time_diff( + $data->backups->last_backup, + current_time( 'timestamp' ) + ) + ) + ), + 'data' => $data, + ) ); + } + } + + /** + * A WordPress REST API permission callback method that accepts a request object and + * decides if the current user has enough privileges to act. + * + * @since 4.3.0 + * + * @return bool does a current user have enough privileges. + */ + public function can_request() { + return current_user_can( 'jetpack_admin_page' ); + } +} + +/** + * Actions performed only when Gravatar Hovercards is activated through the endpoint call. + * + * @since 4.3.1 + */ +function jetpack_do_after_gravatar_hovercards_activation() { + + // When Gravatar Hovercards is activated, enable them automatically. + update_option( 'gravatar_disable_hovercards', 'enabled' ); +} +add_action( 'jetpack_activate_module_gravatar-hovercards', 'jetpack_do_after_gravatar_hovercards_activation' ); + +/** + * Actions performed only when Gravatar Hovercards is activated through the endpoint call. + * + * @since 4.3.1 + */ +function jetpack_do_after_gravatar_hovercards_deactivation() { + + // When Gravatar Hovercards is deactivated, disable them automatically. + update_option( 'gravatar_disable_hovercards', 'disabled' ); +} +add_action( 'jetpack_deactivate_module_gravatar-hovercards', 'jetpack_do_after_gravatar_hovercards_deactivation' ); + +/** + * Actions performed only when Markdown is activated through the endpoint call. + * + * @since 4.7.0 + */ +function jetpack_do_after_markdown_activation() { + + // When Markdown is activated, enable support for post editing automatically. + update_option( 'wpcom_publish_posts_with_markdown', true ); +} +add_action( 'jetpack_activate_module_markdown', 'jetpack_do_after_markdown_activation' ); diff --git a/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-site-endpoints.php b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-site-endpoints.php new file mode 100644 index 00000000..68327f51 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-site-endpoints.php @@ -0,0 +1,60 @@ +<?php +/** + * This is the endpoint class for `/site` endpoints. + * + */ +class Jetpack_Core_API_Site_Endpoint { + + /** + * Returns the result of `/sites/%s/features` endpoint call. + * @return object $features has 'active' and 'available' properties each of which contain feature slugs. + * 'active' is a simple array of slugs that are active on the current plan. + * 'available' is an object with keys that represent feature slugs and values are arrays + * of plan slugs that enable these features + */ + public static function get_features() { + + // Make the API request + $request = sprintf( '/sites/%d/features', Jetpack_Options::get_option( 'id' ) ); + $response = Jetpack_Client::wpcom_json_api_request_as_blog( $request, '1.1' ); + + // Bail if there was an error or malformed response + if ( is_wp_error( $response ) || ! is_array( $response ) || ! isset( $response['body'] ) ) { + return new WP_Error( + 'failed_to_fetch_data', + esc_html__( 'Unable to fetch the requested data.', 'jetpack' ), + array( 'status' => 500 ) + ); + } + + // Decode the results + $results = json_decode( $response['body'], true ); + + // Bail if there were no results or plan details returned + if ( ! is_array( $results ) ) { + return new WP_Error( + 'failed_to_fetch_data', + esc_html__( 'Unable to fetch the requested data.', 'jetpack' ), + array( 'status' => 500 ) + ); + } + + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'Site features correctly received.', 'jetpack' ), + 'data' => wp_remote_retrieve_body( $response ), + ) + ); + } + + /** + * Check that the current user has permissions to request information about this site. + * + * @since 5.1.0 + * + * @return bool + */ + public static function can_request() { + return current_user_can( 'jetpack_manage_modules' ); + } +} diff --git a/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-widgets-endpoints.php b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-widgets-endpoints.php new file mode 100644 index 00000000..ffd62bb3 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-widgets-endpoints.php @@ -0,0 +1,56 @@ +<?php +/** + * Widget information getter endpoint. + * + */ +class Jetpack_Core_API_Widget_Endpoint { + + /** + * @since 5.5.0 + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $id Widget id. + * } + * + * @return WP_REST_Response|WP_Error A REST response if the request was served successfully, otherwise an error. + */ + public function process( $request ) { + $widget_base = _get_widget_id_base( $request['id'] ); + $widget_id = (int) substr( $request['id'], strlen( $widget_base ) + 1 ); + + switch( $widget_base ) { + case 'milestone_widget': + $instances = get_option( 'widget_milestone_widget', array() ); + + if ( + class_exists( 'Milestone_Widget' ) + && is_active_widget( false, $widget_base . '-' . $widget_id, $widget_base ) + && isset( $instances[ $widget_id ] ) + ) { + $instance = $instances[ $widget_id ]; + $widget = new Milestone_Widget(); + return $widget->get_widget_data( $instance ); + } + } + + return new WP_Error( + 'not_found', + esc_html__( 'The requested widget was not found.', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + /** + * Check that the current user has permissions to view widget information. + * For the currently supported widget there are no permissions required. + * + * @since 5.5.0 + * + * @return bool + */ + public function can_request() { + return true; + } +} diff --git a/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-xmlrpc-consumer-endpoint.php b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-xmlrpc-consumer-endpoint.php new file mode 100644 index 00000000..abfc8627 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-xmlrpc-consumer-endpoint.php @@ -0,0 +1,39 @@ +<?php +/** + * This is the base class for every Core API endpoint that needs an XMLRPC client. + * + */ +abstract class Jetpack_Core_API_XMLRPC_Consumer_Endpoint { + + /** + * An instance of the Jetpack XMLRPC client to make WordPress.com requests + * + * @private + * @var Jetpack_IXR_Client + */ + protected $xmlrpc; + + /** + * + * @since 4.3.0 + * + * @param Jetpack_IXR_Client $xmlrpc + */ + public function __construct( $xmlrpc = null ) { + $this->xmlrpc = $xmlrpc; + } + + /** + * Checks if the site is public and returns the result. + * + * @since 4.3.0 + * + * @return Boolean $is_public + */ + protected function is_site_public() { + if ( $this->xmlrpc->query( 'jetpack.isSitePubliclyAccessible', home_url() ) ) { + return $this->xmlrpc->getResponse(); + } + return false; + } +}
\ No newline at end of file diff --git a/plugins/jetpack/_inc/lib/core-api/load-wpcom-endpoints.php b/plugins/jetpack/_inc/lib/core-api/load-wpcom-endpoints.php new file mode 100644 index 00000000..2b26f78c --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/load-wpcom-endpoints.php @@ -0,0 +1,40 @@ +<?php + +/* + * Loader for WP REST API endpoints that are synced with WP.com. + * + * On WP.com see: + * - wp-content/mu-plugins/rest-api.php + * - wp-content/rest-api-plugins/jetpack-endpoints/ + */ + +function wpcom_rest_api_v2_load_plugin_files( $file_pattern ) { + $plugins = glob( dirname( __FILE__ ) . '/' . $file_pattern ); + + if ( ! is_array( $plugins ) ) { + return; + } + + foreach ( array_filter( $plugins, 'is_file' ) as $plugin ) { + require_once $plugin; + } +} + +// API v2 plugins: define a class, then call this function. +function wpcom_rest_api_v2_load_plugin( $class_name ) { + global $wpcom_rest_api_v2_plugins; + + if ( ! isset( $wpcom_rest_api_v2_plugins ) ) { + $_GLOBALS['wpcom_rest_api_v2_plugins'] = $wpcom_rest_api_v2_plugins = array(); + } + + if ( ! isset( $wpcom_rest_api_v2_plugins[ $class_name ] ) ) { + $wpcom_rest_api_v2_plugins[ $class_name ] = new $class_name; + } +} + +require dirname( __FILE__ ) . '/class-wpcom-rest-field-controller.php'; + +// Now load the endpoint files. +wpcom_rest_api_v2_load_plugin_files( 'wpcom-endpoints/*.php' ); +wpcom_rest_api_v2_load_plugin_files( 'wpcom-fields/*.php' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/business-hours.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/business-hours.php new file mode 100644 index 00000000..2bf80939 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/business-hours.php @@ -0,0 +1,49 @@ +<?php + +/** + * Business Hours: Localized week + * + * @since 7.1 + */ +class WPCOM_REST_API_V2_Endpoint_Business_Hours extends WP_REST_Controller { + function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'business-hours'; + // This endpoint *does not* need to connect directly to Jetpack sites. + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + public function register_routes() { + // GET /sites/<blog_id>/business-hours/localized-week - Return the localized + register_rest_route( $this->namespace, '/' . $this->rest_base . '/localized-week', array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_localized_week' ), + ) + ) ); + } + + /** + * Retreives localized business hours + * + * @return array data object containing information about business hours + */ + public function get_localized_week() { + global $wp_locale; + + return array( + 'days' => array( + 'Sun' => $wp_locale->get_weekday( 0 ), + 'Mon' => $wp_locale->get_weekday( 1 ), + 'Tue' => $wp_locale->get_weekday( 2 ), + 'Wed' => $wp_locale->get_weekday( 3 ), + 'Thu' => $wp_locale->get_weekday( 4 ), + 'Fri' => $wp_locale->get_weekday( 5 ), + 'Sat' => $wp_locale->get_weekday( 6 ), + ), + 'startOfWeek' => (int) get_option( 'start_of_week', 0 ), + ); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Business_Hours' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mailchimp.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mailchimp.php new file mode 100644 index 00000000..354880ed --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-mailchimp.php @@ -0,0 +1,79 @@ +<?php + +/** + * Mailchimp: Get Mailchimp Status. + * API to determine if current site has linked Mailchimp account and mailing list selected. + * This API is meant to be used in Jetpack and on WPCOM. + * + * @since 7.1 + */ +class WPCOM_REST_API_V2_Endpoint_Mailchimp extends WP_REST_Controller { + public function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'mailchimp'; + $this->wpcom_is_wpcom_only_endpoint = true; + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Called automatically on `rest_api_init()`. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_mailchimp_status' ), + ), + ) + ); + } + + /** + * Check if MailChimp is set up properly. + * + * @return bool + */ + private function is_connected() { + $option = get_option( 'jetpack_mailchimp' ); + if ( ! $option ) { + return false; + } + $data = json_decode( $option, true ); + if ( ! $data ) { + return false; + } + return isset( $data['follower_list_id'], $data['keyring_id'] ); + } + + /** + * Get the status of current blog's Mailchimp connection + * + * @return mixed + * code:string (connected|unconnected), + * connect_url:string + * site_id:int + */ + public function get_mailchimp_status() { + $is_wpcom = ( defined( 'IS_WPCOM' ) && IS_WPCOM ); + $site_id = $is_wpcom ? get_current_blog_id() : Jetpack_Options::get_option( 'id' ); + if ( ! $site_id ) { + return new WP_Error( + 'unavailable_site_id', + __( 'Sorry, something is wrong with your Jetpack connection.', 'jetpack' ), + 403 + ); + } + $connect_url = sprintf( 'https://wordpress.com/marketing/connections/%s', rawurlencode( $site_id ) ); + return array( + 'code' => $this->is_connected() ? 'connected' : 'not_connected', + 'connect_url' => $connect_url, + 'site_id' => $site_id, + ); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Mailchimp' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/gutenberg-available-extensions.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/gutenberg-available-extensions.php new file mode 100644 index 00000000..a10a4056 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/gutenberg-available-extensions.php @@ -0,0 +1,71 @@ +<?php + +/* + * Gutenberg: List Available Gutenberg Extensions (Blocks and Plugins) + * + * [ + * { # Availabilty Object. See schema for more detail. + * available: (boolean) Whether the extension is available + * unavailable_reason: (string) Reason for the extension not being available + * }, + * ... + * ] + * + * @since 6.9 + */ +class WPCOM_REST_API_V2_Endpoint_Gutenberg_Available_Extensions extends WP_REST_Controller { + function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'gutenberg'; + $this->wpcom_is_site_specific_endpoint = true; + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + public function register_routes() { + register_rest_route( $this->namespace, $this->rest_base . '/available-extensions', array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( 'Jetpack_Gutenberg', 'get_availability' ), + 'permission_callback' => array( $this, 'get_items_permission_check' ), + ), + 'schema' => array( $this, 'get_item_schema' ), + ) ); + } + + /** + * Return the available Gutenberg extensions schema + * + * @return array Available Gutenberg extensions schema + */ + public function get_public_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'gutenberg-available-extensions', + 'type' => 'object', + 'properties' => array( + 'available' => array( + 'description' => __( 'Whether the extension is available', 'jetpack' ), + 'type' => 'boolean', + ), + 'unavailable_reason' => array( + 'description' => __( 'Reason for the extension not being available', 'jetpack' ), + 'type' => 'string', + ), + ), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Ensure the user has proper permissions + * + * @return boolean + */ + public function get_items_permission_check() { + return current_user_can( 'edit_posts' ); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Gutenberg_Available_Extensions' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/hello.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/hello.php new file mode 100644 index 00000000..a05769b2 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/hello.php @@ -0,0 +1,22 @@ +<?php + +class WPCOM_REST_API_V2_Endpoint_Hello { + public function __construct() { + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + public function register_routes() { + register_rest_route( 'wpcom/v2', '/hello', array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_data' ), + ), + ) ); + } + + public function get_data( $request ) { + return array( 'hello' => 'world' ); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Hello' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/memberships.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/memberships.php new file mode 100644 index 00000000..ec997739 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/memberships.php @@ -0,0 +1,187 @@ +<?php // phpcs:disable WordPress.Files.FileName.InvalidClassFileName +/** + * Memberships: API to communicate with "product" database. + * + * @package Jetpack + * @since 7.3.0 + */ + +/** + * Class WPCOM_REST_API_V2_Endpoint_Memberships + * This introduces V2 endpoints. + */ +class WPCOM_REST_API_V2_Endpoint_Memberships extends WP_REST_Controller { + + /** + * WPCOM_REST_API_V2_Endpoint_Memberships constructor. + */ + public function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'memberships'; + $this->wpcom_is_wpcom_only_endpoint = true; + $this->wpcom_is_site_specific_endpoint = true; + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Called automatically on `rest_api_init()`. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + $this->rest_base . '/status', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_status' ), + 'permission_callback' => array( $this, 'get_status_permission_check' ), + ), + ) + ); + register_rest_route( + $this->namespace, + $this->rest_base . '/product', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_product' ), + 'permission_callback' => array( $this, 'get_status_permission_check' ), + 'args' => array( + 'title' => array( + 'type' => 'string', + 'required' => true, + ), + 'price' => array( + 'type' => 'float', + 'required' => true, + ), + 'currency' => array( + 'type' => 'string', + 'required' => true, + ), + 'interval' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ), + ) + ); + } + + /** + * Ensure the user has proper permissions + * + * @return boolean + */ + public function get_status_permission_check() { + return current_user_can( 'edit_posts' ); + } + + /** + * Do create a product based on data, or pass request to wpcom. + * + * @param object $request - request passed from WP. + * + * @return array|WP_Error + */ + public function create_product( $request ) { + if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) { + require_lib( 'memberships' ); + $connected_destination_account_id = Jetpack_Memberships::get_connected_account_id(); + if ( ! $connected_destination_account_id ) { + return new WP_Error( 'no-destination-account', __( 'Please set up a Stripe account for this site first', 'jetpack' ) ); + } + $product = Memberships_Product::create( + get_current_blog_id(), + array( + 'title' => $request['title'], + 'price' => $request['price'], + 'currency' => $request['currency'], + 'interval' => $request['interval'], + 'connected_destination_account_id' => $connected_destination_account_id, + ) + ); + if ( is_wp_error( $product ) ) { + return new WP_Error( $product->get_error_code(), __( 'Creating product has failed.', 'jetpack' ) ); + } + return $product->to_array(); + } else { + $blog_id = Jetpack_Options::get_option( 'id' ); + $response = Jetpack_Client::wpcom_json_api_request_as_user( + "/sites/$blog_id/{$this->rest_base}/product", + 'v2', + array( + 'method' => 'POST', + ), + array( + 'title' => $request['title'], + 'price' => $request['price'], + 'currency' => $request['currency'], + 'interval' => $request['interval'], + ) + ); + if ( is_wp_error( $response ) ) { + if ( $response->get_error_code() === 'missing_token' ) { + return new WP_Error( 'missing_token', __( 'Please connect your user account to WordPress.com', 'jetpack' ), 404 ); + } + return new WP_Error( 'wpcom_connection_error', __( 'Could not connect to WordPress.com', 'jetpack' ), 404 ); + } + $data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null; + // If endpoint returned error, we have to detect it. + if ( 200 !== $response['response']['code'] && $data['code'] && $data['message'] ) { + return new WP_Error( $data['code'], $data['message'], 401 ); + } + return $data; + } + + return $request; + } + + /** + * Get a status of connection for the site. If this is Jetpack, pass the request to wpcom. + * + * @return array|WP_Error + */ + public function get_status() { + $connected_account_id = Jetpack_Memberships::get_connected_account_id(); + $connect_url = ''; + if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) { + require_lib( 'memberships' ); + $blog_id = get_current_blog_id(); + if ( ! $connected_account_id ) { + $connect_url = get_memberships_connected_account_redirect( get_current_user_id(), $blog_id ); + } + $products = get_memberships_plans( $blog_id ); + } else { + $blog_id = Jetpack_Options::get_option( 'id' ); + $response = Jetpack_Client::wpcom_json_api_request_as_user( + "/sites/$blog_id/{$this->rest_base}/status", + 'v2', + array(), + null + ); + if ( is_wp_error( $response ) ) { + if ( $response->get_error_code() === 'missing_token' ) { + return new WP_Error( 'missing_token', __( 'Please connect your user account to WordPress.com', 'jetpack' ), 404 ); + } + return new WP_Error( 'wpcom_connection_error', __( 'Could not connect to WordPress.com', 'jetpack' ), 404 ); + } + $data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null; + if ( ! $connected_account_id ) { + $connect_url = empty( $data['connect_url'] ) ? '' : $data['connect_url']; + } + $products = empty( $data['products'] ) ? array() : $data['products']; + } + return array( + 'connected_account_id' => $connected_account_id, + 'connect_url' => $connect_url, + 'products' => $products, + ); + } +} + +if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Jetpack::is_active() ) { + wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Memberships' ); +} + diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connection-test-results.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connection-test-results.php new file mode 100644 index 00000000..6e04a289 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connection-test-results.php @@ -0,0 +1,121 @@ +<?php + +require_once dirname( __FILE__ ) . '/publicize-connections.php'; + +/** + * Publicize: List Connection Test Result Data + * + * All the same data as the Publicize Connections Endpoint, plus test results. + * + * @since 6.8 + */ +class WPCOM_REST_API_V2_Endpoint_List_Publicize_Connection_Test_Results extends WPCOM_REST_API_V2_Endpoint_List_Publicize_Connections { + public function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'publicize/connection-test-results'; + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Called automatically on `rest_api_init()`. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permission_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Adds the test results properties to the Connection schema. + * + * @return array + */ + public function get_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'jetpack-publicize-connection-test-results', + 'type' => 'object', + 'properties' => $this->get_connection_schema_properties() + array( + 'test_success' => array( + 'description' => __( 'Did the Publicize Connection test pass?', 'jetpack' ), + 'type' => 'boolean', + ), + 'test_message' => array( + 'description' => __( 'Publicize Connection success or error message', 'jetpack' ), + 'type' => 'string', + ), + 'can_refresh' => array( + 'description' => __( 'Can the current user refresh the Publicize Connection?', 'jetpack' ), + 'type' => 'boolean', + ), + 'refresh_text' => array( + 'description' => __( 'Message instructing the user to refresh their Connection to the Publicize Service', 'jetpack' ), + 'type' => 'string', + ), + 'refresh_url' => array( + 'description' => __( 'URL for refreshing the Connection to the Publicize Service', 'jetpack' ), + 'type' => 'string', + 'format' => 'uri', + ), + ), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * @param WP_REST_Request + * @see Publicize::get_publicize_conns_test_results() + * @return WP_REST_Response suitable for 1-page collection + */ + public function get_items( $request ) { + global $publicize; + + $items = $this->get_connections(); + + $test_results = $publicize->get_publicize_conns_test_results(); + $test_results_by_unique_id = array(); + foreach ( $test_results as $test_result ) { + $test_results_by_unique_id[ $test_result['unique_id'] ] = $test_result; + } + + $mapping = array( + 'test_success' => 'connectionTestPassed', + 'test_message' => 'connectionTestMessage', + 'can_refresh' => 'userCanRefresh', + 'refresh_text' => 'refreshText', + 'refresh_url' => 'refreshURL', + ); + + foreach ( $items as &$item ) { + $test_result = $test_results_by_unique_id[ $item['id'] ]; + + foreach ( $mapping as $field => $test_result_field ) { + $item[ $field ] = $test_result[ $test_result_field ]; + } + } + + if ( 'linkedin' === $item['id'] && 'must_reauth' === $test_result['connectionTestPassed'] ) { + $item['test_success'] = 'must_reauth'; + } + + $response = rest_ensure_response( $items ); + + $response->header( 'X-WP-Total', count( $items ) ); + $response->header( 'X-WP-TotalPages', 1 ); + + return $response; + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_List_Publicize_Connection_Test_Results' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connections.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connections.php new file mode 100644 index 00000000..34d6b2a6 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connections.php @@ -0,0 +1,194 @@ +<?php + +/** + * Publicize: List Connections + * + * [ + * { # Connnection Object. See schema for more detail. + * id: (string) Connection unique_id + * service_name: (string) Service slug + * display_name: (string) User name/display name of user/connection on Service + * global: (boolean) Is the Connection available to all users of the site? + * }, + * ... + * ] + * + * @since 6.8 + */ +class WPCOM_REST_API_V2_Endpoint_List_Publicize_Connections extends WP_REST_Controller { + /** + * Flag to help WordPress.com decide where it should look for + * Publicize data. Ignored for direct requests to Jetpack sites. + * + * @var bool $wpcom_is_wpcom_only_endpoint + */ + public $wpcom_is_wpcom_only_endpoint = true; + + public function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'publicize/connections'; + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Called automatically on `rest_api_init()`. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permission_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Helper for generating schema. Used by this endpoint and by the + * Connection Test Result endpoint. + * + * @internal + * @return array + */ + protected function get_connection_schema_properties() { + return array( + 'id' => array( + 'description' => __( 'Unique identifier for the Publicize Connection', 'jetpack' ), + 'type' => 'string', + ), + 'service_name' => array( + 'description' => __( 'Alphanumeric identifier for the Publicize Service', 'jetpack' ), + 'type' => 'string', + ), + 'display_name' => array( + 'description' => __( 'Username of the connected account', 'jetpack' ), + 'type' => 'string', + ), + 'global' => array( + 'description' => __( 'Is this connection available to all users?', 'jetpack' ), + 'type' => 'boolean', + ), + ); + } + + /** + * @return array + */ + public function get_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'jetpack-publicize-connection', + 'type' => 'object', + 'properties' => $this->get_connection_schema_properties(), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Helper for retrieving Connections. Used by this endpoint and by + * the Connection Test Result endpoint. + * + * @internal + * @return array + */ + protected function get_connections() { + global $publicize; + + $items = array(); + + foreach ( (array) $publicize->get_services( 'connected' ) as $service_name => $connections ) { + foreach ( $connections as $connection ) { + $connection_meta = $publicize->get_connection_meta( $connection ); + $connection_data = $connection_meta['connection_data']; + + $items[] = array( + 'id' => (string) $publicize->get_connection_unique_id( $connection ), + 'service_name' => $service_name, + 'display_name' => $publicize->get_display_name( $service_name, $connection ), + // We expect an integer, but do loose comparison below in case some other type is stored + 'global' => 0 == $connection_data['user_id'], + ); + } + } + + return $items; + } + + /** + * @param WP_REST_Request $request + * @return WP_REST_Response suitable for 1-page collection + */ + public function get_items( $request ) { + $items = array(); + + foreach ( $this->get_connections() as $item ) { + $items[] = $this->prepare_item_for_response( $item, $request ); + } + + $response = rest_ensure_response( $items ); + $response->header( 'X-WP-Total', count( $items ) ); + $response->header( 'X-WP-TotalPages', 1 ); + + return $response; + } + + /** + * Filters out data based on ?_fields= request parameter + * + * @param array $connection + * @param WP_REST_Request $request + * @return array filtered $connection + */ + public function prepare_item_for_response( $connection, $request ) { + if ( ! is_callable( array( $this, 'get_fields_for_response' ) ) ) { + return $connection; + } + + $fields = $this->get_fields_for_response( $request ); + + $response_data = array(); + foreach ( $connection as $field => $value ) { + if ( in_array( $field, $fields, true ) ) { + $response_data[ $field ] = $value; + } + } + + return $response_data; + } + + /** + * Verify that user can access Publicize data + * + * @return true|WP_Error + */ + public function get_items_permission_check() { + global $publicize; + + if ( ! $publicize ) { + return new WP_Error( + 'publicize_not_available', + __( 'Sorry, Publicize is not available on your site right now.', 'jetpack' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( $publicize->current_user_can_access_publicize_data() ) { + return true; + } + + return new WP_Error( + 'invalid_user_permission_publicize', + __( 'Sorry, you are not allowed to access Publicize data on this site.', 'jetpack' ), + array( 'status' => rest_authorization_required_code() ) + ); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_List_Publicize_Connections' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-services.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-services.php new file mode 100644 index 00000000..4641b218 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-services.php @@ -0,0 +1,167 @@ +<?php + +/** + * Publicize: List Publicize Services + * + * [ + * { # Service Object. See schema for more detail. + * name: (string) Service slug + * label: (string) Human readable label for the Service + * url: (string) Connect URL + * }, + * ... + * ] + * + * @since 6.8 + */ +class WPCOM_REST_API_V2_Endpoint_List_Publicize_Services extends WP_REST_Controller { + /** + * Flag to help WordPress.com decide where it should look for + * Publicize data. Ignored for direct requests to Jetpack sites. + * + * @var bool $wpcom_is_wpcom_only_endpoint + */ + public $wpcom_is_wpcom_only_endpoint = true; + + public function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'publicize/services'; + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Called automatically on `rest_api_init()`. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permission_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * @return array + */ + public function get_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'jetpack-publicize-service', + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'description' => __( 'Alphanumeric identifier for the Publicize Service', 'jetpack' ), + 'type' => 'string', + ), + 'label' => array( + 'description' => __( 'Human readable label for the Publicize Service', 'jetpack' ), + 'type' => 'string', + ), + 'url' => array( + 'description' => __( 'The URL used to connect to the Publicize Service', 'jetpack' ), + 'type' => 'string', + 'format' => 'uri', + ), + ), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Retrieves available Publicize Services. + * + * @see Publicize::get_available_service_data() + * + * @param WP_REST_Request $request + * @return WP_REST_Response suitable for 1-page collection + */ + public function get_items( $request ) { + global $publicize; + /** + * We need this because Publicize::get_available_service_data() uses `Jetpack_Keyring_Service_Helper` + * and `Jetpack_Keyring_Service_Helper` relies on `menu_page_url()`. + * + * We also need add_submenu_page(), as the URLs for connecting each service + * rely on the `sharing` menu subpage being present. + */ + include_once ABSPATH . 'wp-admin/includes/plugin.php'; + + // The `sharing` submenu page must exist for service connect URLs to be correct. + add_submenu_page( 'options-general.php', '', '', 'manage_options', 'sharing', '__return_empty_string' ); + + $services_data = $publicize->get_available_service_data(); + + $services = array(); + foreach ( $services_data as $service_data ) { + $services[] = $this->prepare_item_for_response( $service_data, $request ); + } + + $response = rest_ensure_response( $services ); + $response->header( 'X-WP-Total', count( $services ) ); + $response->header( 'X-WP-TotalPages', 1 ); + + return $response; + } + + /** + * Filters out data based on ?_fields= request parameter + * + * @param array $service + * @param WP_REST_Request $request + * @return array filtered $service + */ + public function prepare_item_for_response( $service, $request ) { + if ( ! is_callable( array( $this, 'get_fields_for_response' ) ) ) { + return $service; + } + + $fields = $this->get_fields_for_response( $request ); + + $response_data = array(); + foreach ( $service as $field => $value ) { + if ( in_array( $field, $fields, true ) ) { + $response_data[ $field ] = $value; + } + } + + return $response_data; + } + + /** + * Verify that user can access Publicize data + * + * @return true|WP_Error + */ + public function get_items_permission_check() { + global $publicize; + + if ( ! $publicize ) { + return new WP_Error( + 'publicize_not_available', + __( 'Sorry, Publicize is not available on your site right now.', 'jetpack' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( $publicize->current_user_can_access_publicize_data() ) { + return true; + } + + return new WP_Error( + 'invalid_user_permission_publicize', + __( 'Sorry, you are not allowed to access Publicize data on this site.', 'jetpack' ), + array( 'status' => rest_authorization_required_code() ) + ); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_List_Publicize_Services' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/service-api-keys.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/service-api-keys.php new file mode 100644 index 00000000..05d0ddd3 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/service-api-keys.php @@ -0,0 +1,281 @@ +<?php +/* + * Service API Keys: Exposes 3rd party api keys that are used on a site. + * + * [ + * { # Availabilty Object. See schema for more detail. + * code: (string) Displays success if the operation was successfully executed and an error code if it was not + * service: (string) The name of the service in question + * service_api_key: (string) The API key used by the service empty if one is not set yet + * message: (string) User friendly message + * }, + * ... + * ] + * + * @since 6.9 + */ +class WPCOM_REST_API_V2_Endpoint_Service_API_Keys extends WP_REST_Controller { + + function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'service-api-keys'; + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + public function register_routes() { + register_rest_route( + 'wpcom/v2', + '/service-api-keys/(?P<service>[a-z\-_]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_service_api_key' ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( __CLASS__, 'update_service_api_key' ), + 'permission_callback' => array( __CLASS__, 'edit_others_posts_check' ), + 'args' => array( + 'service_api_key' => array( + 'required' => true, + 'type' => 'text', + ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( __CLASS__, 'delete_service_api_key' ), + 'permission_callback' => array( __CLASS__, 'edit_others_posts_check' ), + ), + ) + ); + } + + public static function edit_others_posts_check() { + if ( current_user_can( 'edit_others_posts' ) ) { + return true; + } + + $user_permissions_error_msg = esc_html__( + 'You do not have the correct user permissions to perform this action. + Please contact your site admin if you think this is a mistake.', + 'jetpack' + ); + + return new WP_Error( 'invalid_user_permission_edit_others_posts', $user_permissions_error_msg, rest_authorization_required_code() ); + } + + /** + * Return the available Gutenberg extensions schema + * + * @return array Service API Key schema + */ + public function get_public_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'service-api-keys', + 'type' => 'object', + 'properties' => array( + 'code' => array( + 'description' => __( 'Displays success if the operation was successfully executed and an error code if it was not', 'jetpack' ), + 'type' => 'string', + ), + 'service' => array( + 'description' => __( 'The name of the service in question', 'jetpack' ), + 'type' => 'string', + ), + 'service_api_key' => array( + 'description' => __( 'The API key used by the service. Empty if none has been set yet', 'jetpack' ), + 'type' => 'string', + ), + 'message' => array( + 'description' => __( 'User friendly message', 'jetpack' ), + 'type' => 'string', + ), + ), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Get third party plugin API keys. + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $slug Plugin slug with the syntax 'plugin-directory/plugin-main-file.php'. + * } + */ + public static function get_service_api_key( $request ) { + + $service = self::validate_service_api_service( $request['service'] ); + if ( ! $service ) { + return self::service_api_invalid_service_response(); + } + $option = self::key_for_api_service( $service ); + $message = esc_html__( 'API key retrieved successfully.', 'jetpack' ); + return array( + 'code' => 'success', + 'service' => $service, + 'service_api_key' => Jetpack_Options::get_option( $option, '' ), + 'message' => $message, + ); + } + + /** + * Update third party plugin API keys. + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $slug Plugin slug with the syntax 'plugin-directory/plugin-main-file.php'. + * } + */ + public static function update_service_api_key( $request ) { + $service = self::validate_service_api_service( $request['service'] ); + if ( ! $service ) { + return self::service_api_invalid_service_response(); + } + $json_params = $request->get_json_params(); + $params = ! empty( $json_params ) ? $json_params : $request->get_body_params(); + $service_api_key = trim( $params['service_api_key'] ); + $option = self::key_for_api_service( $service ); + + $validation = self::validate_service_api_key( $service_api_key, $service, $params ); + if ( ! $validation['status'] ) { + return new WP_Error( 'invalid_key', esc_html__( 'Invalid API Key', 'jetpack' ), array( 'status' => 404 ) ); + } + $message = esc_html__( 'API key updated successfully.', 'jetpack' ); + Jetpack_Options::update_option( $option, $service_api_key ); + return array( + 'code' => 'success', + 'service' => $service, + 'service_api_key' => Jetpack_Options::get_option( $option, '' ), + 'message' => $message, + ); + } + + /** + * Delete a third party plugin API key. + * + * @param WP_REST_Request $request { + * Array of parameters received by request. + * + * @type string $slug Plugin slug with the syntax 'plugin-directory/plugin-main-file.php'. + * } + */ + public static function delete_service_api_key( $request ) { + $service = self::validate_service_api_service( $request['service'] ); + if ( ! $service ) { + return self::service_api_invalid_service_response(); + } + $option = self::key_for_api_service( $service ); + Jetpack_Options::delete_option( $option ); + $message = esc_html__( 'API key deleted successfully.', 'jetpack' ); + return array( + 'code' => 'success', + 'service' => $service, + 'service_api_key' => Jetpack_Options::get_option( $option, '' ), + 'message' => $message, + ); + } + + /** + * Validate the service provided in /service-api-keys/ endpoints. + * To add a service to these endpoints, add the service name to $valid_services + * and add '{service name}_api_key' to the non-compact return array in get_option_names(), + * in class-jetpack-options.php + * + * @param string $service The service the API key is for. + * @return string Returns the service name if valid, null if invalid. + */ + public static function validate_service_api_service( $service = null ) { + $valid_services = array( + 'mapbox', + ); + return in_array( $service, $valid_services, true ) ? $service : null; + } + + /** + * Error response for invalid service API key requests with an invalid service. + */ + public static function service_api_invalid_service_response() { + return new WP_Error( + 'invalid_service', + esc_html__( 'Invalid Service', 'jetpack' ), + array( 'status' => 404 ) + ); + } + + /** + * Validate API Key + * + * @param string $key The API key to be validated. + * @param string $service The service the API key is for. + */ + public static function validate_service_api_key( $key = null, $service = null ) { + $validation = false; + switch ( $service ) { + case 'mapbox': + $validation = self::validate_service_api_key_mapbox( $key ); + break; + } + return $validation; + } + + /** + * Validate Mapbox API key + * Based loosely on https://github.com/mapbox/geocoding-example/blob/master/php/MapboxTest.php + * + * @param string $key The API key to be validated. + */ + public static function validate_service_api_key_mapbox( $key ) { + $status = true; + $msg = null; + $mapbox_url = sprintf( + 'https://api.mapbox.com?%s', + $key + ); + $mapbox_response = wp_safe_remote_get( esc_url_raw( $mapbox_url ) ); + $mapbox_body = wp_remote_retrieve_body( $mapbox_response ); + if ( '{"api":"mapbox"}' !== $mapbox_body ) { + $status = false; + $msg = esc_html__( 'Can\'t connect to Mapbox', 'jetpack' ); + return array( + 'status' => $status, + 'error_message' => $msg, + ); + } + $mapbox_geocode_url = esc_url_raw( + sprintf( + 'https://api.mapbox.com/geocoding/v5/mapbox.places/%s.json?access_token=%s', + '1+broadway+new+york+ny+usa', + $key + ) + ); + $mapbox_geocode_response = wp_safe_remote_get( esc_url_raw( $mapbox_geocode_url ) ); + $mapbox_geocode_body = wp_remote_retrieve_body( $mapbox_geocode_response ); + $mapbox_geocode_json = json_decode( $mapbox_geocode_body ); + if ( isset( $mapbox_geocode_json->message ) && ! isset( $mapbox_geocode_json->query ) ) { + $status = false; + $msg = $mapbox_geocode_json->message; + } + return array( + 'status' => $status, + 'error_message' => $msg, + ); + } + + /** + * Create site option key for service + * + * @param string $service The service to create key for. + */ + private static function key_for_api_service( $service ) { + return $service . '_api_key'; + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Service_API_Keys' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/sites-posts-featured-media-url.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/sites-posts-featured-media-url.php new file mode 100644 index 00000000..4c34161c --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/sites-posts-featured-media-url.php @@ -0,0 +1,37 @@ +<?php + +/* + * Plugin Name: WPCOM Add Featured Media URL + * + * Adds `jetpack_featured_media_url` to post responses + */ + +class WPCOM_REST_API_V2_Sites_Posts_Add_Featured_Media_URL { + function __construct() { + add_action( 'rest_api_init', array( $this, 'add_featured_media_url' ) ); + } + + function add_featured_media_url() { + register_rest_field( 'post', 'jetpack_featured_media_url', + array( + 'get_callback' => array( $this, 'get_featured_media_url' ), + 'update_callback' => null, + 'schema' => null, + ) + ); + } + + function get_featured_media_url( $object, $field_name, $request ) { + $featured_media_url = ''; + $image_attributes = wp_get_attachment_image_src( + get_post_thumbnail_id( $object['id'] ), + 'full' + ); + if ( is_array( $image_attributes ) && isset( $image_attributes[0] ) ) { + $featured_media_url = (string) $image_attributes[0]; + } + return $featured_media_url; + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Sites_Posts_Add_Featured_Media_URL' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/subscribers.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/subscribers.php new file mode 100644 index 00000000..c1a712bd --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/subscribers.php @@ -0,0 +1,62 @@ +<?php + +/** + * Subscribers: Get subscriber count + * + * @since 6.9 + */ +class WPCOM_REST_API_V2_Endpoint_Subscribers extends WP_REST_Controller { + function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'subscribers'; + // This endpoint *does not* need to connect directly to Jetpack sites. + $this->wpcom_is_wpcom_only_endpoint = true; + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + public function register_routes() { + // GET /sites/<blog_id>/subscribers/count - Return number of subscribers for this site. + register_rest_route( $this->namespace, '/' . $this->rest_base . '/count', array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_subscriber_count' ), + 'permission_callback' => array( $this, 'readable_permission_check' ), + ) + ) ); + } + + public function readable_permission_check() { + if ( ! current_user_can_for_blog( get_current_blog_id(), 'edit_posts' ) ) { + return new WP_Error( 'authorization_required', 'Only users with the permission to edit posts can see the subscriber count.', array( 'status' => 401 ) ); + } + + return true; + } + + /** + * Retrieves subscriber count + * + * @param WP_REST_Request $request incoming API request info + * @return array data object containing subscriber count + */ + public function get_subscriber_count( $request ) { + // Get the most up to date subscriber count when request is not a test + if ( ! Jetpack_Constants::is_defined( 'TESTING_IN_JETPACK' ) ) { + delete_transient( 'wpcom_subscribers_total' ); + } + + $subscriber_info = Jetpack_Subscriptions_Widget::fetch_subscriber_count(); + $subscriber_count = $subscriber_info['value']; + + return array( + 'count' => $subscriber_count + ); + } +} + +if ( + Jetpack::is_module_active( 'subscriptions' ) || + ( Jetpack_Constants::is_defined( 'TESTING_IN_JETPACK' ) && Jetpack_Constants::get_constant( 'TESTING_IN_JETPACK' ) ) +) { + wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Subscribers' ); +} diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-fields/attachment-fields-videopress.php b/plugins/jetpack/_inc/lib/core-api/wpcom-fields/attachment-fields-videopress.php new file mode 100644 index 00000000..b615c4e6 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-fields/attachment-fields-videopress.php @@ -0,0 +1,171 @@ +<?php +/** + * Extend the REST API functionality for VideoPress users. + * + * @package Jetpack + */ + +/** + * Add per-attachment VideoPress data. + * + * { # Attachment Object + * ... + * jetpack_videopress_guid: (string) VideoPress identifier + * ... + * } + * + * @since 7.1.0 + */ +class WPCOM_REST_API_V2_Attachment_VideoPress_Field extends WPCOM_REST_API_V2_Field_Controller { + /** + * The REST Object Type to which the jetpack_videopress_guid field will be added. + * + * @var string + */ + protected $object_type = 'attachment'; + + /** + * The name of the REST API field to add. + * + * @var string $field_name + */ + protected $field_name = 'jetpack_videopress_guid'; + + /** + * Registers the jetpack_videopress field and adds a filter to remove it for attachments that are not videos. + */ + public function register_fields() { + parent::register_fields(); + + add_filter( 'rest_prepare_attachment', array( $this, 'remove_field_for_non_videos' ), 10, 2 ); + } + + /** + * Defines data structure and what elements are visible in which contexts + */ + public function get_schema() { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->field_name, + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'description' => __( 'Unique VideoPress ID', 'jetpack' ), + ); + } + + /** + * Getter: Retrieve current VideoPress data for a given attachment. + * + * @param array $attachment Response from the attachment endpoint. + * @param WP_REST_Request $request Request to the attachment endpoint. + * + * @return string + */ + public function get( $attachment, $request ) { + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { + $blog_id = get_current_blog_id(); + } else { + $blog_id = Jetpack_Options::get_option( 'id' ); + } + + $post_id = absint( $attachment['id'] ); + + $videopress_guid = $this->get_videopress_guid( $post_id, $blog_id ); + + if ( ! $videopress_guid ) { + return ''; + } + + return $videopress_guid; + } + + /** + * Gets the VideoPress GUID for a given attachment. + * + * This is pulled out into a separate method to support unit test mocking. + * + * @param int $attachment_id Attachment ID. + * @param int $blog_id Blog ID. + * + * @return string + */ + public function get_videopress_guid( $attachment_id, $blog_id ) { + return video_get_info_by_blogpostid( $blog_id, $attachment_id )->guid; + } + + /** + * Checks if the given attachment is a video. + * + * @param object $attachment The attachment object. + * + * @return false|int + */ + public function is_video( $attachment ) { + return wp_startswith( $attachment->post_mime_type, 'video/' ); + } + + /** + * Removes the jetpack_videopress_guid field from the response if the + * given attachment is not a video. + * + * @param WP_REST_Response $response Response from the attachment endpoint. + * @param WP_Post $attachment The original attachment object. + * + * @return mixed + */ + public function remove_field_for_non_videos( $response, $attachment ) { + if ( ! $this->is_video( $attachment ) ) { + unset( $response->data[ $this->field_name ] ); + } + + return $response; + } + + /** + * Setter: It does nothing since `jetpack_videopress` is a read-only field. + * + * @param mixed $value The new value for the field. + * @param WP_Post $object The attachment object. + * @param WP_REST_Request $request The request object. + * + * @return null + */ + public function update( $value, $object, $request ) { + return null; + } + + /** + * Permission Check for the field's getter. Delegate the responsibility to the + * attachment endpoint, so it always returns true. + * + * @param mixed $object Response from the attachment endpoint. + * @param WP_REST_Request $request Request to the attachment endpoint. + * + * @return true + */ + public function get_permission_check( $object, $request ) { + return true; + } + + /** + * Permission Check for the field's setter. Delegate the responsibility to the + * attachment endpoint, so it always returns true. + * + * @param mixed $value The new value for the field. + * @param WP_Post $object The attachment object. + * @param WP_REST_Request $request Request to the attachment endpoint. + * + * @return true + */ + public function update_permission_check( $value, $object, $request ) { + return true; + } +} + +if ( + ( method_exists( 'Jetpack', 'is_active' ) && Jetpack::is_active() ) || + ( defined( 'IS_WPCOM' ) && IS_WPCOM ) +) { + wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Attachment_VideoPress_Field' ); +} diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-fields/post-fields-publicize-connections.php b/plugins/jetpack/_inc/lib/core-api/wpcom-fields/post-fields-publicize-connections.php new file mode 100644 index 00000000..c4254a9d --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-fields/post-fields-publicize-connections.php @@ -0,0 +1,353 @@ +<?php + +/** + * Add per-post Publicize Connection data. + * + * { # Post Object + * ... + * jetpack_publicize_connections: { # Defined below in this file. See schema for more detail. + * id: (string) Connection unique_id + * service_name: (string) Service slug + * display_name: (string) User name/display name of user/connection on Service + * enabled: (boolean) Is this connection slated to be shared to? context=edit only + * done: (boolean) Is this post (or connection) done sharing? context=edit only + * toggleable: (boolean) Can the current user change the `enabled` setting for this Connection+Post? context=edit only + * } + * ... + * meta: { # Not defined in this file. Handled in modules/publicize/publicize.php via `register_meta()` + * jetpack_publicize_message: (string) The message to use instead of the post's title when sharing. + * } + * ... + * } + * + * @since 6.8.0 + */ +class WPCOM_REST_API_V2_Post_Publicize_Connections_Field extends WPCOM_REST_API_V2_Field_Controller { + protected $object_type = 'post'; + protected $field_name = 'jetpack_publicize_connections'; + + public $memoized_updates = array(); + + /** + * Registers the jetpack_publicize_connections field. Called + * automatically on `rest_api_init()`. + */ + public function register_fields() { + $this->object_type = get_post_types_by_support( 'publicize' ); + + foreach ( $this->object_type as $post_type ) { + // Adds meta support for those post types that don't already have it. + // Only runs during REST API requests, so it doesn't impact UI. + if ( ! post_type_supports( $post_type, 'custom-fields' ) ) { + add_post_type_support( $post_type, 'custom-fields' ); + } + + add_filter( 'rest_pre_insert_' . $post_type, array( $this, 'rest_pre_insert' ), 10, 2 ); + add_action( 'rest_insert_' . $post_type, array( $this, 'rest_insert' ), 10, 3 ); + } + + parent::register_fields(); + } + + /** + * Defines data structure and what elements are visible in which contexts + */ + public function get_schema() { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'jetpack-publicize-post-connections', + 'type' => 'array', + 'context' => array( 'view', 'edit' ), + 'items' => $this->post_connection_schema(), + 'default' => array(), + ); + } + + private function post_connection_schema() { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'jetpack-publicize-post-connection', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the Publicize Connection', 'jetpack' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'service_name' => array( + 'description' => __( 'Alphanumeric identifier for the Publicize Service', 'jetpack' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'display_name' => array( + 'description' => __( 'Username of the connected account', 'jetpack' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'enabled' => array( + 'description' => __( 'Whether to share to this connection', 'jetpack' ), + 'type' => 'boolean', + 'context' => array( 'edit' ), + ), + 'done' => array( + 'description' => __( 'Whether Publicize has already finished sharing for this post', 'jetpack' ), + 'type' => 'boolean', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'toggleable' => array( + 'description' => __( 'Whether `enable` can be changed for this post/connection', 'jetpack' ), + 'type' => 'boolean', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + ), + ); + } + + /** + * @param int $post_id + * @return true|WP_Error + */ + function permission_check( $post_id ) { + global $publicize; + + if ( ! $publicize ) { + return new WP_Error( + 'publicize_not_available', + __( 'Sorry, Publicize is not available on your site right now.', 'jetpack' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( $publicize->current_user_can_access_publicize_data( $post_id ) ) { + return true; + } + + return new WP_Error( + 'invalid_user_permission_publicize', + __( 'Sorry, you are not allowed to access Publicize data for this post.', 'jetpack' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Getter permission check + * + * @param array $post_array Response data from Post Endpoint + * @return true|WP_Error + */ + function get_permission_check( $post_array, $request ) { + return $this->permission_check( isset( $post_array['id'] ) ? $post_array['id'] : 0 ); + + } + + /** + * Setter permission check + * + * @param WP_Post $post + * @return true|WP_Error + */ + public function update_permission_check( $value, $post, $request ) { + return $this->permission_check( isset( $post->ID ) ? $post->ID : 0 ); + } + + /** + * Getter: Retrieve current list of connected social accounts for a given post. + * + * @see Publicize::get_filtered_connection_data() + * + * @param array $post_array Response from Post Endpoint + * @param WP_REST_Request + * + * @return array List of connections + */ + public function get( $post_array, $request ) { + global $publicize; + + if ( ! $publicize ) { + return array(); + } + + $schema = $this->post_connection_schema(); + $properties = array_keys( $schema['properties'] ); + + $connections = $publicize->get_filtered_connection_data( $post_array['id'] ); + + $output_connections = array(); + foreach ( $connections as $connection ) { + $output_connection = array(); + foreach ( $properties as $property ) { + if ( isset( $connection[ $property ] ) ) { + $output_connection[ $property ] = $connection[ $property ]; + } + } + + $output_connection['id'] = (string) $connection['unique_id']; + + $output_connections[] = $output_connection; + } + + return $output_connections; + } + + /** + * Prior to updating the post, first calculate which Services to + * Publicize to and which to skip. + * + * @param object $post Post data to insert/update. + * @param WP_REST_Request $request + * @return Filtered $post + */ + public function rest_pre_insert( $post, $request ) { + if ( ! isset( $request['jetpack_publicize_connections'] ) ) { + return $post; + } + + $permission_check = $this->update_permission_check( $request['jetpack_publicize_connections'], $post, $request ); + + if ( is_wp_error( $permission_check ) ) { + return $permission_check; + } + + // memoize + $this->get_meta_to_update( $request['jetpack_publicize_connections'], isset( $post->ID ) ? $post->ID : 0 ); + + return $post; + } + + /** + * After creating a new post, update our cached data to reflect + * the new post ID. + * + * @param WP_Post $post + * @param WP_REST_Request $request + * @param bool $is_new + */ + public function rest_insert( $post, $request, $is_new ) { + if ( ! $is_new ) { + // An existing post was edited - no need to update + // our cache - we started out knowing the correct + // post ID. + return; + } + + if ( ! isset( $request['jetpack_publicize_connections'] ) ) { + return; + } + + if ( ! isset( $this->memoized_updates[0] ) ) { + return; + } + + $this->memoized_updates[ $post->ID ] = $this->memoized_updates[0]; + unset( $this->memoized_updates[0] ); + } + + protected function get_meta_to_update( $requested_connections, $post_id = 0 ) { + global $publicize; + + if ( ! $publicize ) { + return array(); + } + + if ( isset( $this->memoized_updates[$post_id] ) ) { + return $this->memoized_updates[$post_id]; + } + + $available_connections = $publicize->get_filtered_connection_data( $post_id ); + + $changed_connections = array(); + + // Build lookup mappings + $available_connections_by_unique_id = array(); + $available_connections_by_service_name = array(); + foreach ( $available_connections as $available_connection ) { + $available_connections_by_unique_id[ $available_connection['unique_id'] ] = $available_connection; + + if ( ! isset( $available_connections_by_service_name[ $available_connection['service_name'] ] ) ) { + $available_connections_by_service_name[ $available_connection['service_name'] ] = array(); + } + $available_connections_by_service_name[ $available_connection['service_name'] ][] = $available_connection; + } + + // Handle { service_name: $service_name, enabled: (bool) } + foreach ( $requested_connections as $requested_connection ) { + if ( ! isset( $requested_connection['service_name'] ) ) { + continue; + } + + if ( ! isset( $available_connections_by_service_name[ $requested_connection['service_name'] ] ) ) { + continue; + } + + foreach ( $available_connections_by_service_name[ $requested_connection['service_name'] ] as $available_connection ) { + $changed_connections[ $available_connection['unique_id'] ] = $requested_connection['enabled']; + } + } + + // Handle { id: $id, enabled: (bool) } + // These override the service_name settings + foreach ( $requested_connections as $requested_connection ) { + if ( ! isset( $requested_connection['id'] ) ) { + continue; + } + + if ( ! isset( $available_connections_by_unique_id[ $requested_connection['id'] ] ) ) { + continue; + } + + $changed_connections[ $requested_connection['id'] ] = $requested_connection['enabled']; + } + + // Set all changed connections to their new value + foreach ( $changed_connections as $unique_id => $enabled ) { + $connection = $available_connections_by_unique_id[ $unique_id ]; + + if ( $connection['done'] || ! $connection['toggleable'] ) { + continue; + } + + $available_connections_by_unique_id[ $unique_id ]['enabled'] = $enabled; + } + + $meta_to_update = array(); + // For all connections, ensure correct post_meta + foreach ( $available_connections_by_unique_id as $unique_id => $available_connection ) { + if ( $available_connection['enabled'] ) { + $meta_to_update[$publicize->POST_SKIP . $unique_id] = null; + } else { + $meta_to_update[$publicize->POST_SKIP . $unique_id] = 1; + } + } + + $this->memoized_updates[$post_id] = $meta_to_update; + + return $meta_to_update; + } + + /** + * Update the connections slated to be shared to. + * + * @param array $requested_connections + * Items are either `{ id: (string) }` or `{ service_name: (string) }` + * @param WP_Post $post + * @param WP_REST_Request + */ + public function update( $requested_connections, $post, $request ) { + foreach ( $this->get_meta_to_update( $requested_connections, $post->ID ) as $meta_key => $meta_value ) { + if ( is_null( $meta_value ) ) { + delete_post_meta( $post->ID, $meta_key ); + } else { + update_post_meta( $post->ID, $meta_key, $meta_value ); + } + } + } +} + +if ( Jetpack::is_module_active( 'publicize' ) ) { + wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Post_Publicize_Connections_Field' ); +} diff --git a/plugins/jetpack/_inc/lib/debugger/0-load.php b/plugins/jetpack/_inc/lib/debugger/0-load.php new file mode 100644 index 00000000..a3eaf4e6 --- /dev/null +++ b/plugins/jetpack/_inc/lib/debugger/0-load.php @@ -0,0 +1,24 @@ +<?php +/** + * Loading the various functions used for Jetpack Debugging. + * + * @package Jetpack. + */ + +global $wp_version; + +/* Jetpack Connection Testing Framework */ +require_once 'class-jetpack-cxn-test-base.php'; +/* Jetpack Connection Tests */ +require_once 'class-jetpack-cxn-tests.php'; +/* Jetpack Debug Data */ +require_once 'class-jetpack-debug-data.php'; +/* The "In-Plugin Debugger" admin page. */ +require_once 'class-jetpack-debugger.php'; + +if ( version_compare( $wp_version, '5.2-alpha', 'ge' ) ) { + require_once 'debug-functions-for-php53.php'; + add_filter( 'debug_information', array( 'Jetpack_Debug_Data', 'core_debug_data' ) ); + add_filter( 'site_status_tests', 'jetpack_debugger_site_status_tests' ); + add_action( 'wp_ajax_health-check-jetpack-local_testing_suite', 'jetpack_debugger_ajax_local_testing_suite' ); +} diff --git a/plugins/jetpack/_inc/lib/debugger/class-jetpack-cxn-test-base.php b/plugins/jetpack/_inc/lib/debugger/class-jetpack-cxn-test-base.php new file mode 100644 index 00000000..56f21bc4 --- /dev/null +++ b/plugins/jetpack/_inc/lib/debugger/class-jetpack-cxn-test-base.php @@ -0,0 +1,471 @@ +<?php +/** + * Jetpack Connection Testing + * + * Framework for various "unit tests" against the Jetpack connection. + * + * Individual tests should be added to the class-jetpack-cxn-tests.php file. + * + * @author Brandon Kraft + * @package Jetpack + */ + +/** + * "Unit Tests" for the Jetpack connection. + * + * @since 7.1.0 + */ +class Jetpack_Cxn_Test_Base { + + /** + * Tests to run on the Jetpack connection. + * + * @var array $tests + */ + protected $tests = array(); + + /** + * Results of the Jetpack connection tests. + * + * @var array $results + */ + protected $results = array(); + + /** + * Status of the testing suite. + * + * Used internally to determine if a test should be skipped since the tests are already failing. Assume passing. + * + * @var bool $pass + */ + protected $pass = true; + + /** + * Jetpack_Cxn_Test constructor. + */ + public function __construct() { + $this->tests = array(); + $this->results = array(); + } + + /** + * Adds a new test to the Jetpack Connection Testing suite. + * + * @since 7.1.0 + * @since 7.3.0 Adds name parameter and returns WP_Error on failure. + * + * @param callable $callable Test to add to queue. + * @param string $name Unique name for the test. + * @param string $type Optional. Core Site Health type: 'direct' if test can be run during initial load or 'async' if test should run async. + * @param array $groups Optional. Testing groups to add test to. + * + * @return mixed True if successfully added. WP_Error on failure. + */ + public function add_test( $callable, $name, $type = 'direct', $groups = array( 'default' ) ) { + if ( is_array( $name ) ) { + // Pre-7.3.0 method passed the $groups parameter here. + return new WP_Error( __( 'add_test arguments changed in 7.3.0. Please reference inline documentation.', 'jetpack' ) ); + } + if ( array_key_exists( $name, $this->tests ) ) { + return new WP_Error( __( 'Test names must be unique.', 'jetpack' ) ); + } + if ( ! is_callable( $callable ) ) { + return new WP_Error( __( 'Tests must be valid PHP callables.', 'jetpack' ) ); + } + + $this->tests[ $name ] = array( + 'name' => $name, + 'test' => $callable, + 'group' => $groups, + 'type' => $type, + ); + return true; + } + + /** + * Lists all tests to run. + * + * @since 7.3.0 + * + * @param string $type Optional. Core Site Health type: 'direct' or 'async'. All by default. + * @param string $group Optional. A specific testing group. All by default. + * + * @return array $tests Array of tests with test information. + */ + public function list_tests( $type = 'all', $group = 'all' ) { + if ( ! ( 'all' === $type || 'direct' === $type || 'async' === $type ) ) { + _doing_it_wrong( 'Jetpack_Cxn_Test_Base->list_tests', 'Type must be all, direct, or async', '7.3.0' ); + } + + $tests = array(); + foreach ( $this->tests as $name => $value ) { + // Get all valid tests by group staged. + if ( 'all' === $group || $group === $value['group'] ) { + $tests[ $name ] = $value; + } + + // Next filter out any that do not match the type. + if ( 'all' !== $type && $type !== $value['type'] ) { + unset( $tests[ $name ] ); + } + } + + return $tests; + } + + /** + * Run a specific test. + * + * @since 7.3.0 + * + * @param string $name Name of test. + * + * @return mixed $result Test result array or WP_Error if invalid name. { + * @type string $name Test name + * @type mixed $pass True if passed, false if failed, 'skipped' if skipped. + * @type string $message Human-readable test result message. + * @type string $resolution Human-readable resolution steps. + * } + */ + public function run_test( $name ) { + if ( array_key_exists( $name, $this->tests ) ) { + return call_user_func( $this->tests[ $name ]['test'] ); + } + return new WP_Error( __( 'There is no test by that name: ', 'jetpack' ) . $name ); + } + + /** + * Runs the Jetpack connection suite. + */ + public function run_tests() { + foreach ( $this->tests as $test ) { + $result = call_user_func( $test['test'] ); + $result['group'] = $test['group']; + $result['type'] = $test['type']; + $this->results[] = $result; + if ( false === $result['pass'] ) { + $this->pass = false; + } + } + } + + /** + * Returns the full results array. + * + * @since 7.1.0 + * @since 7.3.0 Add 'type' + * + * @param string $type Test type, async or direct. + * @param string $group Testing group whose results we want. Defaults to all tests. + * @return array Array of test results. + */ + public function raw_results( $type = 'all', $group = 'all' ) { + if ( ! $this->results ) { + $this->run_tests(); + } + + $results = $this->results; + + if ( 'all' !== $group ) { + foreach ( $results as $test => $result ) { + if ( ! in_array( $group, $result['group'], true ) ) { + unset( $results[ $test ] ); + } + } + } + + if ( 'all' !== $type ) { + foreach ( $results as $test => $result ) { + if ( $type !== $result['type'] ) { + unset( $results[ $test ] ); + } + } + } + + return $results; + } + + /** + * Returns the status of the connection suite. + * + * @since 7.1.0 + * @since 7.3.0 Add 'type' + * + * @param string $type Test type, async or direct. Optional, direct all tests. + * @param string $group Testing group to check status of. Optional, default all tests. + * + * @return true|array True if all tests pass. Array of failed tests. + */ + public function pass( $type = 'all', $group = 'all' ) { + $results = $this->raw_results( $type, $group ); + + foreach ( $results as $result ) { + // 'pass' could be true, false, or 'skipped'. We only want false. + if ( isset( $result['pass'] ) && false === $result['pass'] ) { + return false; + } + } + + return true; + + } + + /** + * Return array of failed test messages. + * + * @since 7.1.0 + * @since 7.3.0 Add 'type' + * + * @param string $type Test type, direct or async. + * @param string $group Testing group whose failures we want. Defaults to "all". + * + * @return false|array False if no failed tests. Otherwise, array of failed tests. + */ + public function list_fails( $type = 'all', $group = 'all' ) { + $results = $this->raw_results( $type, $group ); + + foreach ( $results as $test => $result ) { + // We do not want tests that passed or ones that are misconfigured (no pass status or no failure message). + if ( ! isset( $result['pass'] ) || false !== $result['pass'] || ! isset( $result['message'] ) ) { + unset( $results[ $test ] ); + } + } + + return $results; + } + + /** + * Helper function to return consistent responses for a passing test. + * + * @param string $name Test name. + * + * @return array Test results. + */ + public static function passing_test( $name = 'Unnamed' ) { + return array( + 'name' => $name, + 'pass' => true, + 'message' => __( 'Test Passed!', 'jetpack' ), + 'resolution' => false, + 'severity' => false, + ); + } + + /** + * Helper function to return consistent responses for a skipped test. + * + * @param string $name Test name. + * @param string $message Reason for skipping the test. Optional. + * + * @return array Test results. + */ + public static function skipped_test( $name = 'Unnamed', $message = false ) { + return array( + 'name' => $name, + 'pass' => 'skipped', + 'message' => $message, + 'resolution' => false, + 'severity' => false, + ); + } + + /** + * Helper function to return consistent responses for a failing test. + * + * @since 7.1.0 + * @since 7.3.0 Added $action for resolution action link, $severity for issue severity. + * + * @param string $name Test name. + * @param string $message Message detailing the failure. + * @param string $resolution Optional. Steps to resolve. + * @param string $action Optional. URL to direct users to self-resolve. + * @param string $severity Optional. "critical" or "recommended" for failure stats. "good" for passing. + * + * @return array Test results. + */ + public static function failing_test( $name, $message, $resolution = false, $action = false, $severity = 'critical' ) { + // Provide standard resolutions steps, but allow pass-through of non-standard ones. + switch ( $resolution ) { + case 'cycle_connection': + $resolution = __( 'Please disconnect and reconnect Jetpack.', 'jetpack' ); // @todo: Link. + break; + case 'outbound_requests': + $resolution = __( 'Please ask your hosting provider to confirm your server can make outbound requests to jetpack.com.', 'jetpack' ); + break; + case 'support': + case false: + $resolution = __( 'Please contact Jetpack support.', 'jetpack' ); // @todo: Link to support. + break; + } + + return array( + 'name' => $name, + 'pass' => false, + 'message' => $message, + 'resolution' => $resolution, + 'action' => $action, + 'severity' => $severity, + ); + } + + /** + * Provide WP_CLI friendly testing results. + * + * @since 7.1.0 + * @since 7.3.0 Add 'type' + * + * @param string $type Test type, direct or async. + * @param string $group Testing group whose results we are outputting. Default all tests. + */ + public function output_results_for_cli( $type = 'all', $group = 'all' ) { + if ( defined( 'WP_CLI' ) && WP_CLI ) { + if ( Jetpack::is_development_mode() ) { + WP_CLI::line( __( 'Jetpack is in Development Mode:', 'jetpack' ) ); + WP_CLI::line( Jetpack::development_mode_trigger_text() ); + } + WP_CLI::line( __( 'TEST RESULTS:', 'jetpack' ) ); + foreach ( $this->raw_results( $group ) as $test ) { + if ( true === $test['pass'] ) { + WP_CLI::log( WP_CLI::colorize( '%gPassed:%n ' . $test['name'] ) ); + } elseif ( 'skipped' === $test['pass'] ) { + WP_CLI::log( WP_CLI::colorize( '%ySkipped:%n ' . $test['name'] ) ); + if ( $test['message'] ) { + WP_CLI::log( ' ' . $test['message'] ); // Number of spaces to "tab indent" the reason. + } + } else { // Failed. + WP_CLI::log( WP_CLI::colorize( '%rFailed:%n ' . $test['name'] ) ); + WP_CLI::log( ' ' . $test['message'] ); // Number of spaces to "tab indent" the reason. + } + } + } + } + + /** + * Output results of failures in format expected by Core's Site Health tool for async tests. + * + * Specifically not asking for a testing group since we're opinionated that Site Heath should see all. + * + * @since 7.3.0 + * + * @return array Array of test results + */ + public function output_results_for_core_async_site_health() { + $result = array( + 'label' => __( 'Jetpack passed all async tests.', 'jetpack' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Jetpack', 'jetpack' ), + 'color' => 'green', + ), + 'description' => sprintf( + '<p>%s</p>', + __( "Jetpack's async local testing suite passed all tests!", 'jetpack' ) + ), + 'actions' => '', + 'test' => 'jetpack_debugger_local_testing_suite_core', + ); + + if ( $this->pass() ) { + return $result; + } + + $fails = $this->list_fails( 'async' ); + $error = false; + foreach ( $fails as $fail ) { + if ( ! $error ) { + $error = true; + $result['label'] = $fail['message']; + $result['status'] = $fail['severity']; + $result['description'] = sprintf( + '<p>%s</p>', + $fail['resolution'] + ); + if ( ! empty( $fail['action'] ) ) { + $result['actions'] = sprintf( + '<a class="button button-primary" href="%1$s" target="_blank" rel="noopener noreferrer">%2$s <span class="screen-reader-text">%3$s</span><span aria-hidden="true" class="dashicons dashicons-external"></span></a>', + esc_url( $fail['action'] ), + __( 'Resolve', 'jetpack' ), + /* translators: accessibility text */ + __( '(opens in a new tab)', 'jetpack' ) + ); + } + } else { + $result['description'] .= sprintf( + '<p>%s</p>', + __( 'There was another problem:', 'jetpack' ) + ) . ' ' . $fail['message'] . ': ' . $fail['resolution']; + if ( 'critical' === $fail['severity'] ) { // In case the initial failure is only "recommended". + $result['status'] = 'critical'; + } + } + } + + return $result; + + } + + /** + * Provide single WP Error instance of all failures. + * + * @since 7.1.0 + * @since 7.3.0 Add 'type' + * + * @param string $type Test type, direct or async. + * @param string $group Testing group whose failures we want converted. Default all tests. + * + * @return WP_Error|false WP_Error with all failed tests or false if there were no failures. + */ + public function output_fails_as_wp_error( $type = 'all', $group = 'all' ) { + if ( $this->pass( $group ) ) { + return false; + } + $fails = $this->list_fails( $type, $group ); + $error = false; + + foreach ( $fails as $result ) { + $code = 'failed_' . $result['name']; + $message = $result['message']; + $data = array( + 'resolution' => $result['resolution'], + ); + if ( ! $error ) { + $error = new WP_Error( $code, $message, $data ); + } else { + $error->add( $code, $message, $data ); + } + } + + return $error; + } + + /** + * Encrypt data for sending to WordPress.com. + * + * @todo When PHP minimum is 5.3+, add cipher detection to use an agreed better cipher than RC4. RC4 should be the last resort. + * + * @param string $data Data to encrypt with the WP.com Public Key. + * + * @return false|array False if functionality not available. Array of encrypted data, encryption key. + */ + public function encrypt_string_for_wpcom( $data ) { + $return = false; + if ( ! function_exists( 'openssl_get_publickey' ) || ! function_exists( 'openssl_seal' ) ) { + return $return; + } + + $public_key = openssl_get_publickey( JETPACK__DEBUGGER_PUBLIC_KEY ); + + if ( $public_key && openssl_seal( $data, $encrypted_data, $env_key, array( $public_key ) ) ) { + // We are returning base64-encoded values to ensure they're characters we can use in JSON responses without issue. + $return = array( + 'data' => base64_encode( $encrypted_data ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + 'key' => base64_encode( $env_key[0] ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + 'cipher' => 'RC4', // When Jetpack's minimum WP version is at PHP 5.3+, we will add in detecting and using a stronger one. + ); + } + + openssl_free_key( $public_key ); + + return $return; + } +} diff --git a/plugins/jetpack/_inc/lib/debugger/class-jetpack-cxn-tests.php b/plugins/jetpack/_inc/lib/debugger/class-jetpack-cxn-tests.php new file mode 100644 index 00000000..274d032b --- /dev/null +++ b/plugins/jetpack/_inc/lib/debugger/class-jetpack-cxn-tests.php @@ -0,0 +1,340 @@ +<?php +/** + * Collection of tests to run on the Jetpack connection locally. + * + * @package Jetpack + */ + +/** + * Class Jetpack_Cxn_Tests contains all of the actual tests. + */ +class Jetpack_Cxn_Tests extends Jetpack_Cxn_Test_Base { + + /** + * Jetpack_Cxn_Tests constructor. + */ + public function __construct() { + parent::__construct(); + + $methods = get_class_methods( 'Jetpack_Cxn_Tests' ); + + foreach ( $methods as $method ) { + if ( false === strpos( $method, 'test__' ) ) { + continue; + } + $this->add_test( array( $this, $method ), $method, 'direct' ); + } + + /** + * Fires after loading default Jetpack Connection tests. + * + * @since 7.1.0 + */ + do_action( 'jetpack_connection_tests_loaded' ); + + /** + * Determines if the WP.com testing suite should be included. + * + * @since 7.1.0 + * + * @param bool $run_test To run the WP.com testing suite. Default true. + */ + if ( apply_filters( 'jetpack_debugger_run_self_test', true ) ) { + /** + * Intentionally added last as it checks for an existing failure state before attempting. + * Generally, any failed location condition would result in the WP.com check to fail too, so + * we will skip it to avoid confusing error messages. + * + * Note: This really should be an 'async' test. + */ + $this->add_test( array( $this, 'last__wpcom_self_test' ), 'test__wpcom_self_test', 'direct' ); + } + } + + /** + * Helper function to look up the expected master user and return the local WP_User. + * + * @return WP_User Jetpack's expected master user. + */ + protected function helper_retrieve_local_master_user() { + $master_user = Jetpack_Options::get_option( 'master_user' ); + return new WP_User( $master_user ); + } + + /** + * Is Jetpack even connected and supposed to be talking to WP.com? + */ + protected function helper_is_jetpack_connected() { + return ( Jetpack::is_active() && ! Jetpack::is_development_mode() ); + } + + /** + * Test if Jetpack is connected. + */ + protected function test__check_if_connected() { + $name = __FUNCTION__; + if ( $this->helper_is_jetpack_connected() ) { + $result = self::passing_test( $name ); + } elseif ( Jetpack::is_development_mode() ) { + $result = self::skipped_test( $name, __( 'Jetpack is in Development Mode:', 'jetpack' ) . ' ' . Jetpack::development_mode_trigger_text(), __( 'Disable development mode.', 'jetpack' ) ); + } else { + $result = self::failing_test( $name, __( 'Jetpack is not connected.', 'jetpack' ), 'cycle_connection' ); + } + + return $result; + } + + /** + * Test that the master user still exists on this site. + * + * @return array Test results. + */ + protected function test__master_user_exists_on_site() { + $name = __FUNCTION__; + if ( ! $this->helper_is_jetpack_connected() ) { + return self::skipped_test( $name, __( 'Jetpack is not connected. No master user to check.', 'jetpack' ) ); // Skip test. + } + $local_user = $this->helper_retrieve_local_master_user(); + + if ( $local_user->exists() ) { + $result = self::passing_test( $name ); + } else { + $result = self::failing_test( $name, __( 'The user who setup the Jetpack connection no longer exists on this site.', 'jetpack' ), 'cycle_connection' ); + } + + return $result; + } + + /** + * Test that the master user has the manage options capability (e.g. is an admin). + * + * Generic calls from WP.com execute on Jetpack as the master user. If it isn't an admin, random things will fail. + * + * @return array Test results. + */ + protected function test__master_user_can_manage_options() { + $name = __FUNCTION__; + if ( ! $this->helper_is_jetpack_connected() ) { + return self::skipped_test( $name, __( 'Jetpack is not connected.', 'jetpack' ) ); // Skip test. + } + $master_user = $this->helper_retrieve_local_master_user(); + + if ( user_can( $master_user, 'manage_options' ) ) { + $result = self::passing_test( $name ); + } else { + /* translators: a WordPress username */ + $result = self::failing_test( $name, sprintf( __( 'The user (%s) who setup the Jetpack connection is not an administrator.', 'jetpack' ), $master_user->user_login ), __( 'Either upgrade the user or disconnect and reconnect Jetpack.', 'jetpack' ) ); // @todo: Link to the right places. + } + + return $result; + } + + /** + * Test that the PHP's XML library is installed. + * + * While it should be installed by default, increasingly in PHP 7, some OSes require an additional php-xml package. + * + * @return array Test results. + */ + protected function test__xml_parser_available() { + $name = __FUNCTION__; + if ( function_exists( 'xml_parser_create' ) ) { + $result = self::passing_test( $name ); + } else { + $result = self::failing_test( $name, __( 'PHP XML manipluation libraries are not available.', 'jetpack' ), __( "Please ask your hosting provider to refer to our server requirements at https://jetpack.com/support/server-requirements/ and enable PHP's XML module.", 'jetpack' ) ); + } + + return $result; + } + + /** + * Test that the server is able to send an outbound http communication. + * + * @return array Test results. + */ + protected function test__outbound_http() { + $name = __FUNCTION__; + $request = wp_remote_get( preg_replace( '/^https:/', 'http:', JETPACK__API_BASE ) . 'test/1/' ); + $code = wp_remote_retrieve_response_code( $request ); + + if ( 200 === intval( $code ) ) { + $result = self::passing_test( $name ); + } else { + $result = self::failing_test( $name, __( 'Your server did not successfully connect to the Jetpack server using HTTP', 'jetpack' ), 'outbound_requests' ); + } + + return $result; + } + + /** + * Test that the server is able to send an outbound https communication. + * + * @return array Test results. + */ + protected function test__outbound_https() { + $name = __FUNCTION__; + $request = wp_remote_get( preg_replace( '/^http:/', 'https:', JETPACK__API_BASE ) . 'test/1/' ); + $code = wp_remote_retrieve_response_code( $request ); + + if ( 200 === intval( $code ) ) { + $result = self::passing_test( $name ); + } else { + $result = self::failing_test( $name, __( 'Your server did not successfully connect to the Jetpack server using HTTPS', 'jetpack' ), 'outbound_requests' ); + } + + return $result; + } + + /** + * Check for an IDC. + * + * @return array Test results. + */ + protected function test__identity_crisis() { + $name = __FUNCTION__; + if ( ! $this->helper_is_jetpack_connected() ) { + return self::skipped_test( $name, __( 'Jetpack is not connected.', 'jetpack' ) ); // Skip test. + } + $identity_crisis = Jetpack::check_identity_crisis(); + + if ( ! $identity_crisis ) { + $result = self::passing_test( $name ); + } else { + $message = sprintf( + /* translators: Two URLs. The first is the locally-recorded value, the second is the value as recorded on WP.com. */ + __( 'Your url is set as `%1$s`, but your WordPress.com connection lists it as `%2$s`!', 'jetpack' ), + $identity_crisis['home'], + $identity_crisis['wpcom_home'] + ); + $result = self::failing_test( $name, $message, 'support' ); + } + return $result; + } + + /** + * Tests connection status against wp.com's test-connection endpoint + * + * @todo: Compare with the wpcom_self_test. We only need one of these. + * + * @return array Test results. + */ + protected function test__wpcom_connection_test() { + $name = __FUNCTION__; + + if ( ! Jetpack::is_active() || Jetpack::is_development_mode() || Jetpack::is_staging_site() || ! $this->pass ) { + return self::skipped_test( $name ); + } + + $response = Jetpack_Client::wpcom_json_api_request_as_blog( + sprintf( '/jetpack-blogs/%d/test-connection', Jetpack_Options::get_option( 'id' ) ), + Jetpack_Client::WPCOM_JSON_API_VERSION + ); + + if ( is_wp_error( $response ) ) { + /* translators: %1$s is the error code, %2$s is the error message */ + $message = sprintf( __( 'Connection test failed (#%1$s: %2$s)', 'jetpack' ), $response->get_error_code(), $response->get_error_message() ); + return self::failing_test( $name, $message ); + } + + $body = wp_remote_retrieve_body( $response ); + if ( ! $body ) { + $message = __( 'Connection test failed (empty response body)', 'jetpack' ) . wp_remote_retrieve_response_code( $response ); + return self::failing_test( $name, $message ); + } + + $result = json_decode( $body ); + $is_connected = (bool) $result->connected; + $message = $result->message . ': ' . wp_remote_retrieve_response_code( $response ); + + if ( $is_connected ) { + return self::passing_test( $name ); + } else { + return self::failing_test( $name, $message ); + } + } + + /** + * Tests the port number to ensure it is an expected value. + * + * We expect that sites on be on one of: + * port 80, + * port 443 (https sites only), + * the value of JETPACK_SIGNATURE__HTTP_PORT, + * unless the site is intentionally on a different port (e.g. example.com:8080 is the site's URL). + * + * If the value isn't one of those and the site's URL doesn't include a port, then the signature verification will fail. + * + * This happens most commonly on sites with reverse proxies, so the edge (e.g. Varnish) is running on 80/443, but nginx + * or Apache is responding internally on a different port (e.g. 81). + * + * @return array Test results + */ + protected function test__server_port_value() { + $name = __FUNCTION__; + if ( ! isset( $_SERVER['HTTP_X_FORWARDED_PORT'] ) && ! isset( $_SERVER['SERVER_PORT'] ) ) { + $message = 'The server port values are not defined. This is most common when running PHP via a CLI.'; + return self::skipped_test( $name, $message ); + } + $site_port = wp_parse_url( home_url(), PHP_URL_PORT ); + $server_port = isset( $_SERVER['HTTP_X_FORWARDED_PORT'] ) ? (int) $_SERVER['HTTP_X_FORWARDED_PORT'] : (int) $_SERVER['SERVER_PORT']; + $http_ports = array( 80 ); + $https_ports = array( 80, 443 ); + + if ( defined( 'JETPACK_SIGNATURE__HTTP_PORT' ) ) { + $http_ports[] = JETPACK_SIGNATURE__HTTP_PORT; + } + + if ( defined( 'JETPACK_SIGNATURE__HTTPS_PORT' ) ) { + $https_ports[] = JETPACK_SIGNATURE__HTTPS_PORT; + } + + if ( $site_port ) { + return self::skipped_test( $name ); // Not currently testing for this situation. + } + + if ( is_ssl() && in_array( $server_port, $https_ports, true ) ) { + return self::passing_test( $name ); + } elseif ( in_array( $server_port, $http_ports, true ) ) { + return self::passing_test( $name ); + } else { + if ( is_ssl() ) { + $needed_constant = 'JETPACK_SIGNATURE__HTTPS_PORT'; + } else { + $needed_constant = 'JETPACK_SIGNATURE__HTTP_PORT'; + } + $message = __( 'The server port value is unexpected.', 'jetpack' ); + $resolution = __( 'Try adding the following to your wp-config.php file:', 'jetpack' ) . " define( '$needed_constant', $server_port );"; + return self::failing_test( $name, $message, $resolution ); + } + } + + /** + * Calls to WP.com to run the connection diagnostic testing suite. + * + * Intentionally added last as it will be skipped if any local failed conditions exist. + * + * @return array Test results. + */ + protected function last__wpcom_self_test() { + $name = 'test__wpcom_self_test'; + if ( ! Jetpack::is_active() || Jetpack::is_development_mode() || Jetpack::is_staging_site() || ! $this->pass ) { + return self::skipped_test( $name ); + } + + $self_xml_rpc_url = site_url( 'xmlrpc.php' ); + + $testsite_url = Jetpack::fix_url_for_bad_hosts( JETPACK__API_BASE . 'testsite/1/?url=' ); + + add_filter( 'http_request_timeout', array( 'Jetpack_Debugger', 'jetpack_increase_timeout' ) ); + + $response = wp_remote_get( $testsite_url . $self_xml_rpc_url ); + + remove_filter( 'http_request_timeout', array( 'Jetpack_Debugger', 'jetpack_increase_timeout' ) ); + + if ( 200 === wp_remote_retrieve_response_code( $response ) ) { + return self::passing_test( $name ); + } else { + return self::failing_test( $name, __( 'Jetpack.com detected an error.', 'jetpack' ), __( 'Visit the Jetpack.com debugging page for more information or contact support.', 'jetpack' ) ); // @todo direct links. + } + } +} diff --git a/plugins/jetpack/_inc/lib/debugger/class-jetpack-debug-data.php b/plugins/jetpack/_inc/lib/debugger/class-jetpack-debug-data.php new file mode 100644 index 00000000..31e38790 --- /dev/null +++ b/plugins/jetpack/_inc/lib/debugger/class-jetpack-debug-data.php @@ -0,0 +1,400 @@ +<?php +/** + * Jetpack Debug Data for the legacy Jetpack debugger page and the WP 5.2-era Site Health sections. + * + * @package jetpack + */ + +/** + * Class Jetpack_Debug_Data + * + * Collect and return debug data for Jetpack. + * + * @since 7.3.0 + */ +class Jetpack_Debug_Data { + /** + * Determine the active plan and normalize it for the debugger results. + * + * @since 7.3.0 + * + * @return string The plan slug. + */ + public static function what_jetpack_plan() { + $plan = Jetpack_Plan::get(); + return ! empty( $plan['class'] ) ? $plan['class'] : 'undefined'; + } + + /** + * Convert seconds to human readable time. + * + * A dedication function instead of using Core functionality to allow for output in seconds. + * + * @since 7.3.0 + * + * @param int $seconds Number of seconds to convert to human time. + * + * @return string Human readable time. + */ + public static function seconds_to_time( $seconds ) { + $seconds = intval( $seconds ); + $units = array( + 'week' => WEEK_IN_SECONDS, + 'day' => DAY_IN_SECONDS, + 'hour' => HOUR_IN_SECONDS, + 'minute' => MINUTE_IN_SECONDS, + 'second' => 1, + ); + // specifically handle zero. + if ( 0 === $seconds ) { + return '0 seconds'; + } + $human_readable = ''; + foreach ( $units as $name => $divisor ) { + $quot = intval( $seconds / $divisor ); + if ( $quot ) { + $human_readable .= "$quot $name"; + $human_readable .= ( abs( $quot ) > 1 ? 's' : '' ) . ', '; + $seconds -= $quot * $divisor; + } + } + return substr( $human_readable, 0, -2 ); + } + + /** + * Return debug data in the format expected by Core's Site Health Info tab. + * + * @since 7.3.0 + * + * @param array $debug { + * The debug information already compiled by Core. + * + * @type string $label The title for this section of the debug output. + * @type string $description Optional. A description for your information section which may contain basic HTML + * markup: `em`, `strong` and `a` for linking to documentation or putting emphasis. + * @type boolean $show_count Optional. If set to `true` the amount of fields will be included in the title for + * this section. + * @type boolean $private Optional. If set to `true` the section and all associated fields will be excluded + * from the copy-paste text area. + * @type array $fields { + * An associative array containing the data to be displayed. + * + * @type string $label The label for this piece of information. + * @type string $value The output that is of interest for this field. + * @type boolean $private Optional. If set to `true` the field will not be included in the copy-paste text area + * on top of the page, allowing you to show, for example, API keys here. + * } + * } + * + * @return array $args Debug information in the same format as the initial argument. + */ + public static function core_debug_data( $debug ) { + $jetpack = array( + 'jetpack' => array( + 'label' => __( 'Jetpack', 'jetpack' ), + 'description' => sprintf( + /* translators: %1$s is URL to jetpack.com's contact support page. %2$s accessibility text */ + __( + 'Diagnostic information helpful to <a href="%1$s" target="_blank" rel="noopener noreferrer">your Jetpack Happiness team<span class="screen-reader-text">%2$s</span></a>', + 'jetpack' + ), + esc_html( 'https://jetpack.com/contact-support/' ), + __( '(opens in a new tab)', 'jetpack' ) + ), + 'fields' => self::debug_data(), + ), + ); + $debug = array_merge( $debug, $jetpack ); + return $debug; + } + + /** + * Compile and return array of debug information. + * + * @since 7.3.0 + * + * @return array $args { + * Associated array of arrays with the following. + * @type string $label The label for this piece of information. + * @type string $value The output that is of interest for this field. + * @type boolean $private Optional. Set to true if data is sensitive (API keys, etc). + * } + */ + public static function debug_data() { + $debug_info = array(); + + /* Add various important Jetpack options */ + $debug_info['site_id'] = array( + 'label' => 'Jetpack Site ID', + 'value' => Jetpack_Options::get_option( 'id' ), + 'private' => false, + ); + $debug_info['ssl_cert'] = array( + 'label' => 'Jetpack SSL Verfication Bypass', + 'value' => ( Jetpack_Options::get_option( 'fallback_no_verify_ssl_certs' ) ) ? 'Yes' : 'No', + 'private' => false, + ); + $debug_info['time_diff'] = array( + 'label' => "Offset between Jetpack server's time and this server's time.", + 'value' => Jetpack_Options::get_option( 'time_diff' ), + 'private' => false, + ); + $debug_info['version_option'] = array( + 'label' => 'Current Jetpack Version Option', + 'value' => Jetpack_Options::get_option( 'version' ), + 'private' => false, + ); + $debug_info['old_version'] = array( + 'label' => 'Previous Jetpack Version', + 'value' => Jetpack_Options::get_option( 'old_version' ), + 'private' => false, + ); + $debug_info['public'] = array( + 'label' => 'Jetpack Site Public', + 'value' => ( Jetpack_Options::get_option( 'public' ) ) ? 'Public' : 'Private', + 'private' => false, + ); + $debug_info['master_user'] = array( + 'label' => 'Jetpack Master User', + 'value' => self::human_readable_master_user(), + 'private' => false, + ); + + /** + * Token information is private, but awareness if there one is set is helpful. + * + * To balance out information vs privacy, we only display and include the "key", + * which is a segment of the token prior to a period within the token and is + * technically not private. + * + * If a token does not contain a period, then it is malformed and we report it as such. + */ + $user_id = get_current_user_id(); + $user_tokens = Jetpack_Options::get_option( 'user_tokens' ); + $blog_token = Jetpack_Options::get_option( 'blog_token' ); + $user_token = null; + if ( is_array( $user_tokens ) && array_key_exists( $user_id, $user_tokens ) ) { + $user_token = $user_tokens[ $user_id ]; + } + unset( $user_tokens ); + + $tokenset = ''; + if ( $blog_token ) { + $tokenset = 'Blog '; + $blog_key = substr( $blog_token, 0, strpos( $blog_token, '.' ) ); + // Intentionally not translated since this is helpful when sent to Happiness. + $blog_key = ( $blog_key ) ? $blog_key : 'Potentially Malformed Token.'; + } + if ( $user_token ) { + $tokenset .= 'User'; + $user_key = substr( $user_token, 0, strpos( $user_token, '.' ) ); + // Intentionally not translated since this is helpful when sent to Happiness. + $user_key = ( $user_key ) ? $user_key : 'Potentially Malformed Token.'; + } + if ( ! $tokenset ) { + $tokenset = 'None'; + } + + $debug_info['current_user'] = array( + 'label' => 'Current User', + 'value' => self::human_readable_user( $user_id ), + 'private' => false, + ); + $debug_info['tokens_set'] = array( + 'label' => 'Tokens defined', + 'value' => $tokenset, + 'private' => false, + ); + $debug_info['blog_token'] = array( + 'label' => 'Blog Public Key', + 'value' => ( $blog_token ) ? $blog_key : 'Not set.', + 'private' => false, + ); + $debug_info['user_token'] = array( + 'label' => 'User Public Key', + 'value' => ( $user_token ) ? $user_key : 'Not set.', + 'private' => false, + ); + + /** Jetpack Environmental Information */ + $debug_info['version'] = array( + 'label' => 'Jetpack Version', + 'value' => JETPACK__VERSION, + 'private' => false, + ); + $debug_info['jp_plugin_dir'] = array( + 'label' => 'Jetpack Directory', + 'value' => JETPACK__PLUGIN_DIR, + 'private' => false, + ); + $debug_info['plan'] = array( + 'label' => 'Plan Type', + 'value' => self::what_jetpack_plan(), + 'private' => false, + ); + + foreach ( array( + 'HTTP_HOST', + 'SERVER_PORT', + 'HTTPS', + 'GD_PHP_HANDLER', + 'HTTP_AKAMAI_ORIGIN_HOP', + 'HTTP_CF_CONNECTING_IP', + 'HTTP_CLIENT_IP', + 'HTTP_FASTLY_CLIENT_IP', + 'HTTP_FORWARDED', + 'HTTP_FORWARDED_FOR', + 'HTTP_INCAP_CLIENT_IP', + 'HTTP_TRUE_CLIENT_IP', + 'HTTP_X_CLIENTIP', + 'HTTP_X_CLUSTER_CLIENT_IP', + 'HTTP_X_FORWARDED', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_IP_TRAIL', + 'HTTP_X_REAL_IP', + 'HTTP_X_VARNISH', + 'REMOTE_ADDR', + ) as $header ) { + if ( isset( $_SERVER[ $header ] ) ) { + $debug_info[ $header ] = array( + 'label' => 'Server Variable ' . $header, + 'value' => ( $_SERVER[ $header ] ) ? $_SERVER[ $header ] : 'false', + 'private' => false, + ); + } + } + + $debug_info['protect_header'] = array( + 'label' => 'Trusted IP', + 'value' => wp_json_encode( get_site_option( 'trusted_ip_header' ) ), + 'private' => false, + ); + + /** Sync Debug Information */ + /** Load Sync modules */ + require_once JETPACK__PLUGIN_DIR . 'sync/class.jetpack-sync-modules.php'; + /** Load Sync sender */ + require_once JETPACK__PLUGIN_DIR . 'sync/class.jetpack-sync-sender.php'; + /** Load Sync functions */ + require_once JETPACK__PLUGIN_DIR . 'sync/class.jetpack-sync-functions.php'; + + $sync_module = Jetpack_Sync_Modules::get_module( 'full-sync' ); + if ( $sync_module ) { + $sync_statuses = $sync_module->get_status(); + $human_readable_sync_status = array(); + foreach ( $sync_statuses as $sync_status => $sync_status_value ) { + $human_readable_sync_status[ $sync_status ] = + in_array( $sync_status, array( 'started', 'queue_finished', 'send_started', 'finished' ), true ) + ? date( 'r', $sync_status_value ) : $sync_status_value; + } + $debug_info['full_sync'] = array( + 'label' => 'Full Sync Status', + 'value' => wp_json_encode( $human_readable_sync_status ), + 'private' => false, + ); + } + + $queue = Jetpack_Sync_Sender::get_instance()->get_sync_queue(); + + $debug_info['sync_size'] = array( + 'label' => 'Sync Queue Size', + 'value' => $queue->size(), + 'private' => false, + ); + $debug_info['sync_lag'] = array( + 'label' => 'Sync Queue Lag', + 'value' => self::seconds_to_time( $queue->lag() ), + 'private' => false, + ); + + $full_sync_queue = Jetpack_Sync_Sender::get_instance()->get_full_sync_queue(); + + $debug_info['full_sync_size'] = array( + 'label' => 'Full Sync Queue Size', + 'value' => $full_sync_queue->size(), + 'private' => false, + ); + $debug_info['full_sync_lag'] = array( + 'label' => 'Full Sync Queue Lag', + 'value' => self::seconds_to_time( $full_sync_queue->lag() ), + 'private' => false, + ); + + /** + * IDC Information + * + * Must follow sync debug since it depends on sync functionality. + */ + $idc_urls = array( + 'home' => Jetpack_Sync_Functions::home_url(), + 'siteurl' => Jetpack_Sync_Functions::site_url(), + 'WP_HOME' => Jetpack_Constants::is_defined( 'WP_HOME' ) ? Jetpack_Constants::get_constant( 'WP_HOME' ) : '', + 'WP_SITEURL' => Jetpack_Constants::is_defined( 'WP_SITEURL' ) ? Jetpack_Constants::get_constant( 'WP_SITEURL' ) : '', + ); + + $debug_info['idc_urls'] = array( + 'label' => 'IDC URLs', + 'value' => wp_json_encode( $idc_urls ), + 'private' => false, + ); + $debug_info['idc_error_option'] = array( + 'label' => 'IDC Error Option', + 'value' => wp_json_encode( Jetpack_Options::get_option( 'sync_error_idc' ) ), + 'private' => false, + ); + $debug_info['idc_optin'] = array( + 'label' => 'IDC Opt-in', + 'value' => Jetpack::sync_idc_optin(), + 'private' => false, + ); + + // @todo -- Add testing results? + $cxn_tests = new Jetpack_Cxn_Tests(); + $debug_info['cxn_tests'] = array( + 'label' => 'Connection Tests', + 'value' => '', + 'private' => false, + ); + if ( $cxn_tests->pass() ) { + $debug_info['cxn_tests']['value'] = 'All Pass.'; + } else { + $debug_info['cxn_tests']['value'] = wp_json_encode( $cxn_tests->list_fails() ); + } + + return $debug_info; + } + + /** + * Returns a human readable string for which user is the master user. + * + * @return string + */ + private static function human_readable_master_user() { + $master_user = Jetpack_Options::get_option( 'master_user' ); + + if ( ! $master_user ) { + return __( 'No master user set.', 'jetpack' ); + } + + $user = new WP_User( $master_user ); + + if ( ! $user ) { + return __( 'Master user no longer exists. Please disconnect and reconnect Jetpack.', 'jetpack' ); + } + + return self::human_readable_user( $user ); + } + + /** + * Return human readable string for a given user object. + * + * @param WP_User|int $user Object or ID. + * + * @return string + */ + private static function human_readable_user( $user ) { + $user = new WP_User( $user ); + + return sprintf( '#%1$d %2$s (%3$s)', $user->ID, $user->user_login, $user->user_email ); // Format: "#1 username (user@example.com)". + } +} diff --git a/plugins/jetpack/_inc/lib/debugger/class-jetpack-debugger.php b/plugins/jetpack/_inc/lib/debugger/class-jetpack-debugger.php new file mode 100644 index 00000000..e7038902 --- /dev/null +++ b/plugins/jetpack/_inc/lib/debugger/class-jetpack-debugger.php @@ -0,0 +1,440 @@ +<?php +/** + * Jetpack Debugger functionality allowing for self-service diagnostic information via the legacy jetpack debugger. + * + * @package jetpack + */ + +/** Ensure the Jetpack_Debug_Data class is available. It should be via the library loaded, but defense is good. */ +require_once 'class-jetpack-debug-data.php'; + +/** + * Class Jetpack_Debugger + * + * A namespacing class for functionality related to the legacy in-plugin diagnostic tooling. + */ +class Jetpack_Debugger { + + /** + * Determine the active plan and normalize it for the debugger results. + * + * @return string The plan slug prepended with "JetpackPlan" + */ + private static function what_jetpack_plan() { + // Specifically not deprecating this function since it modifies the output of the Jetpack_Debug_Data::what_jetpack_plan return. + return 'JetpackPlan' . Jetpack_Debug_Data::what_jetpack_plan(); + } + + /** + * Convert seconds to human readable time. + * + * A dedication function instead of using Core functionality to allow for output in seconds. + * + * @deprecated 7.3.0 + * + * @param int $seconds Number of seconds to convert to human time. + * + * @return string Human readable time. + */ + public static function seconds_to_time( $seconds ) { + _deprecated_function( 'Jetpack_Debugger::seconds_to_time', 'Jetpack 7.3.0', 'Jeptack_Debug_Data::seconds_to_time' ); + return Jetpack_Debug_Data::seconds_to_time( $seconds ); + } + + /** + * Returns 30 for use with a filter. + * + * To allow time for WP.com to run upstream testing, this function exists to increase the http_request_timeout value + * to 30. + * + * @return int 30 + */ + public static function jetpack_increase_timeout() { + return 30; // seconds. + } + + /** + * Disconnect Jetpack and redirect user to connection flow. + */ + public static function disconnect_and_redirect() { + if ( ! ( isset( $_GET['nonce'] ) && wp_verify_nonce( $_GET['nonce'], 'jp_disconnect' ) ) ) { + return; + } + + if ( isset( $_GET['disconnect'] ) && $_GET['disconnect'] ) { + if ( Jetpack::is_active() ) { + Jetpack::disconnect(); + wp_safe_redirect( Jetpack::admin_url() ); + exit; + } + } + } + + /** + * Handles output to the browser for the in-plugin debugger. + */ + public static function jetpack_debug_display_handler() { + global $wp_version; + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'jetpack' ) ); + } + + $data = Jetpack_Debug_Data::debug_data(); + $debug_info = ''; + foreach ( $data as $datum ) { + $debug_info .= $datum['label'] . ': ' . $datum['value'] . "\r\n"; + } + + $debug_info .= "\r\n" . esc_html( 'PHP_VERSION: ' . PHP_VERSION ); + $debug_info .= "\r\n" . esc_html( 'WORDPRESS_VERSION: ' . $GLOBALS['wp_version'] ); + $debug_info .= "\r\n" . esc_html( 'SITE_URL: ' . site_url() ); + $debug_info .= "\r\n" . esc_html( 'HOME_URL: ' . home_url() ); + + $debug_info .= "\r\n\r\nTEST RESULTS:\r\n\r\n"; + + $cxntests = new Jetpack_Cxn_Tests(); + ?> + <div class="wrap"> + <h2><?php esc_html_e( 'Debugging Center', 'jetpack' ); ?></h2> + <h3><?php esc_html_e( "Testing your site's compatibility with Jetpack...", 'jetpack' ); ?></h3> + <div class="jetpack-debug-test-container"> + <?php + if ( $cxntests->pass() ) { + echo '<div class="jetpack-tests-succeed">' . esc_html__( 'Your Jetpack setup looks a-okay!', 'jetpack' ) . '</div>'; + $debug_info .= "All tests passed.\r\n"; + $debug_info .= print_r( $cxntests->raw_results(), true ); //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r + } else { + $failures = $cxntests->list_fails(); + foreach ( $failures as $fail ) { + echo '<div class="jetpack-test-error">'; + echo '<p><a class="jetpack-test-heading" href="#">' . esc_html( $fail['message'] ); + echo '<span class="noticon noticon-collapse"></span></a></p>'; + echo '<p class="jetpack-test-details">' . esc_html( $fail['resolution'] ) . '</p>'; + echo '</div>'; + + $debug_info .= "FAILED TESTS!\r\n"; + $debug_info .= $fail['name'] . ': ' . $fail['message'] . "\r\n"; + $debug_info .= print_r( $cxntests->raw_results(), true ); //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r + } + } + ?> + </div> + <div class="entry-content"> + <h3><?php esc_html_e( 'Trouble with Jetpack?', 'jetpack' ); ?></h3> + <h4><?php esc_html_e( 'It may be caused by one of these issues, which you can diagnose yourself:', 'jetpack' ); ?></h4> + <ol> + <li><b><em> + <?php + esc_html_e( 'A known issue.', 'jetpack' ); + ?> + </em></b> + <?php + echo sprintf( + wp_kses( + /* translators: URLs to Jetpack support pages. */ + __( 'Some themes and plugins have <a href="%1$s" target="_blank">known conflicts</a> with Jetpack – check the <a href="%2$s" target="_blank">list</a>. (You can also browse the <a href="%3$s" target="_blank">Jetpack support pages</a> or <a href="%4$s" target="_blank">Jetpack support forum</a> to see if others have experienced and solved the problem.)', 'jetpack' ), + array( + 'a' => array( + 'href' => array(), + 'target' => array(), + ), + ) + ), + 'http://jetpack.com/support/getting-started-with-jetpack/known-issues/', + 'http://jetpack.com/support/getting-started-with-jetpack/known-issues/', + 'http://jetpack.com/support/', + 'https://wordpress.org/support/plugin/jetpack' + ); + ?> + </li> + <li><b><em><?php esc_html_e( 'An incompatible plugin.', 'jetpack' ); ?></em></b> <?php esc_html_e( "Find out by disabling all plugins except Jetpack. If the problem persists, it's not a plugin issue. If the problem is solved, turn your plugins on one by one until the problem pops up again – there's the culprit! Let us know, and we'll try to help.", 'jetpack' ); ?></li> + <li> + <b><em><?php esc_html_e( 'A theme conflict.', 'jetpack' ); ?></em></b> + <?php + $default_theme = wp_get_theme( WP_DEFAULT_THEME ); + + if ( $default_theme->exists() ) { + /* translators: %s is the name of a theme */ + echo esc_html( sprintf( __( "If your problem isn't known or caused by a plugin, try activating %s (the default WordPress theme).", 'jetpack' ), $default_theme->get( 'Name' ) ) ); + } else { + esc_html_e( "If your problem isn't known or caused by a plugin, try activating the default WordPress theme.", 'jetpack' ); + } + ?> + <?php esc_html_e( "If this solves the problem, something in your theme is probably broken – let the theme's author know.", 'jetpack' ); ?> + </li> + <li><b><em><?php esc_html_e( 'A problem with your XMLRPC file.', 'jetpack' ); ?></em></b> + <?php + echo sprintf( + wp_kses( + /* translators: The URL to the site's xmlrpc.php file. */ + __( 'Load your <a href="%s">XMLRPC file</a>. It should say “XML-RPC server accepts POST requests only.” on a line by itself.', 'jetpack' ), + array( 'a' => array( 'href' => array() ) ) + ), + esc_attr( site_url( 'xmlrpc.php' ) ) + ); + ?> + <ul> + <li>- <?php esc_html_e( "If it's not by itself, a theme or plugin is displaying extra characters. Try steps 2 and 3.", 'jetpack' ); ?></li> + <li>- <?php esc_html_e( 'If you get a 404 message, contact your web host. Their security may block XMLRPC.', 'jetpack' ); ?></li> + </ul> + </li> + <?php if ( current_user_can( 'jetpack_disconnect' ) && Jetpack::is_active() ) : ?> + <li> + <strong><em><?php esc_html_e( 'A connection problem with WordPress.com.', 'jetpack' ); ?></em></strong> + <?php + echo sprintf( + wp_kses( + /* translators: URL to disconnect and reconnect Jetpack. */ + __( 'Jetpack works by connecting to WordPress.com for a lot of features. Sometimes, when the connection gets messed up, you need to disconnect and reconnect to get things working properly. <a href="%s">Disconnect from WordPress.com</a>', 'jetpack' ), + array( + 'a' => array( + 'href' => array(), + 'class' => array(), + ), + ) + ), + esc_attr( + wp_nonce_url( + Jetpack::admin_url( + array( + 'page' => 'jetpack-debugger', + 'disconnect' => true, + ) + ), + 'jp_disconnect', + 'nonce' + ) + ) + ); + ?> + </li> + <?php endif; ?> + </ol> + <h4><?php esc_html_e( 'Still having trouble?', 'jetpack' ); ?></h4> + <p><b><em><?php esc_html_e( 'Ask us for help!', 'jetpack' ); ?></em></b> + <?php + /** + * Offload to new WordPress debug data in WP 5.2+ + * + * @todo remove fallback when 5.2 is the minimum supported. + */ + if ( version_compare( $wp_version, '5.2-alpha', '>=' ) ) { + echo sprintf( + wp_kses( + /* translators: URL for Jetpack support. URL for WordPress's Site Health */ + __( '<a href="%1$s">Contact our Happiness team</a>. When you do, please include the <a href="%2$s">full debug information from your site</a>.', 'jetpack' ), + array( 'a' => array( 'href' => array() ) ) + ), + 'https://jetpack.com/contact-support/', + esc_url( admin_url() . 'site-health.php?tab=debug' ) + ); + $hide_debug = true; + } else { // Versions before 5.2, fallback. + echo sprintf( + wp_kses( + /* translators: URL for Jetpack support. */ + __( '<a href="%s">Contact our Happiness team</a>. When you do, please include the full debug information below.', 'jetpack' ), + array( 'a' => array( 'href' => array() ) ) + ), + 'https://jetpack.com/contact-support/' + ); + $hide_debug = false; + } + ?> + </p> + <hr /> + <?php if ( Jetpack::is_active() ) : ?> + <div id="connected-user-details"> + <h3><?php esc_html_e( 'More details about your Jetpack settings', 'jetpack' ); ?></h3> + <p> + <?php + printf( + wp_kses( + /* translators: %s is an e-mail address */ + __( 'The primary connection is owned by <strong>%s</strong>\'s WordPress.com account.', 'jetpack' ), + array( 'strong' => array() ) + ), + esc_html( Jetpack::get_master_user_email() ) + ); + ?> + </p> + </div> + <?php else : ?> + <div id="dev-mode-details"> + <p> + <?php + printf( + wp_kses( + /* translators: Link to a Jetpack support page. */ + __( 'Would you like to use Jetpack on your local development site? You can do so thanks to <a href="%s">Jetpack\'s development mode</a>.', 'jetpack' ), + array( 'a' => array( 'href' => array() ) ) + ), + 'https://jetpack.com/support/development-mode/' + ); + ?> + </p> + </div> + <?php endif; ?> + <?php + if ( + current_user_can( 'jetpack_manage_modules' ) + && ( Jetpack::is_development_mode() || Jetpack::is_active() ) + ) { + printf( + wp_kses( + '<p><a href="%1$s">%2$s</a></p>', + array( + 'a' => array( 'href' => array() ), + 'p' => array(), + ) + ), + esc_attr( Jetpack::admin_url( 'page=jetpack_modules' ) ), + esc_html__( 'Access the full list of Jetpack modules available on your site.', 'jetpack' ) + ); + } + ?> + </div> + <hr /> + <?php + if ( ! $hide_debug ) { + ?> + <div id="toggle_debug_info"><?php esc_html_e( 'Advanced Debug Results', 'jetpack' ); ?></div> + <div id="debug_info_div"> + <h4><?php esc_html_e( 'Debug Info', 'jetpack' ); ?></h4> + <div id="debug_info"><pre><?php echo esc_html( $debug_info ); ?></pre></div> + </div> + <?php + } + ?> + </div> + <?php + } + + /** + * Outputs html needed within the <head> for the in-plugin debugger page. + */ + public static function jetpack_debug_admin_head() { + + Jetpack_Admin_Page::load_wrapper_styles(); + ?> + <style type="text/css"> + + .jetpack-debug-test-container { + margin-top: 20px; + margin-bottom: 30px; + } + + .jetpack-tests-succeed { + font-size: large; + color: #8BAB3E; + } + + .jetpack-test-details { + margin: 4px 6px; + padding: 10px; + overflow: auto; + display: none; + } + + .jetpack-test-error { + margin-bottom: 10px; + background: #FFEBE8; + border: solid 1px #C00; + border-radius: 3px; + } + + .jetpack-test-error p { + margin: 0; + padding: 0; + } + + p.jetpack-test-details { + margin: 4px 6px; + padding: 10px; + } + + .jetpack-test-error a.jetpack-test-heading { + padding: 4px 6px; + display: block; + text-decoration: none; + color: inherit; + } + + .jetpack-test-error .noticon { + float: right; + } + + .formbox { + margin: 0 0 25px 0; + } + + .formbox input[type="text"], .formbox input[type="email"], .formbox input[type="url"], .formbox textarea, #debug_info_div { + border: 1px solid #e5e5e5; + border-radius: 11px; + box-shadow: inset 0 1px 1px rgba(0,0,0,0.1); + color: #666; + font-size: 14px; + padding: 10px; + width: 97%; + } + #debug_info_div { + border-radius: 0; + margin-top: 16px; + background: #FFF; + padding: 16px; + } + .formbox .contact-support input[type="submit"] { + float: right; + margin: 0 !important; + border-radius: 20px !important; + cursor: pointer; + font-size: 13pt !important; + height: auto !important; + margin: 0 0 2em 10px !important; + padding: 8px 16px !important; + background-color: #ddd; + border: 1px solid rgba(0,0,0,0.05); + border-top-color: rgba(255,255,255,0.1); + border-bottom-color: rgba(0,0,0,0.15); + color: #333; + font-weight: 400; + display: inline-block; + text-align: center; + text-decoration: none; + } + + .formbox span.errormsg { + margin: 0 0 10px 10px; + color: #d00; + display: none; + } + + .formbox.error span.errormsg { + display: block; + } + + #debug_info_div, #toggle_debug_info, #debug_info_div p { + font-size: 12px; + } + + #category_div ul li { + list-style-type: none; + } + + </style> + <script type="text/javascript"> + jQuery( document ).ready( function($) { + + $( '#debug_info' ).prepend( 'jQuery version: ' + jQuery.fn.jquery + "\r\n" ); + $( '#debug_form_info' ).prepend( 'jQuery version: ' + jQuery.fn.jquery + "\r\n" ); + + $( '.jetpack-test-error .jetpack-test-heading' ).on( 'click', function() { + $( this ).parents( '.jetpack-test-error' ).find( '.jetpack-test-details' ).slideToggle(); + return false; + } ); + + } ); + </script> + <?php + } +} diff --git a/plugins/jetpack/_inc/lib/debugger/debug-functions-for-php53.php b/plugins/jetpack/_inc/lib/debugger/debug-functions-for-php53.php new file mode 100644 index 00000000..a32d9fee --- /dev/null +++ b/plugins/jetpack/_inc/lib/debugger/debug-functions-for-php53.php @@ -0,0 +1,92 @@ +<?php +/** + * WP Site Health functionality temporarily stored in this file until all of Jetpack is PHP 5.3+ + * + * @package Jetpack. + */ + +/** + * Test runner for Core's Site Health module. + * + * @since 7.3.0 + */ +function jetpack_debugger_ajax_local_testing_suite() { + check_ajax_referer( 'health-check-site-status' ); + if ( ! current_user_can( 'jetpack_manage_modules' ) ) { + wp_send_json_error(); + } + $tests = new Jetpack_Cxn_Tests(); + wp_send_json_success( $tests->output_results_for_core_async_site_health() ); +} +/** + * Adds the Jetpack Local Testing Suite to the Core Site Health system. + * + * @since 7.3.0 + * + * @param array $core_tests Array of tests from Core's Site Health. + * + * @return array $core_tests Array of tests for Core's Site Health. + */ +function jetpack_debugger_site_status_tests( $core_tests ) { + $cxn_tests = new Jetpack_Cxn_Tests(); + $tests = $cxn_tests->list_tests( 'direct' ); + foreach ( $tests as $test ) { + $core_tests['direct'][ $test['name'] ] = array( + 'label' => __( 'Jetpack: ', 'jetpack' ) . $test['name'], + 'test' => function() use ( $test, $cxn_tests ) { // phpcs:ignore PHPCompatibility.FunctionDeclarations.NewClosure.Found + $results = $cxn_tests->run_test( $test['name'] ); + // Test names are, by default, `test__some_string_of_text`. Let's convert to "Some String Of Text" for humans. + $label = ucwords( + str_replace( + '_', + ' ', + str_replace( 'test__', '', $test['name'] ) + ) + ); + $return = array( + 'label' => $label, + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Jetpack', 'jetpack' ), + 'color' => 'green', + ), + 'description' => sprintf( + '<p>%s</p>', + __( 'This test successfully passed!', 'jetpack' ) + ), + 'actions' => '', + 'test' => 'jetpack_' . $test['name'], + ); + if ( is_wp_error( $results ) ) { + return; + } + if ( false === $results['pass'] ) { + $return['label'] = $results['message']; + $return['status'] = $results['severity']; + $return['description'] = sprintf( + '<p>%s</p>', + $results['resolution'] + ); + if ( ! empty( $results['action'] ) ) { + $return['actions'] = sprintf( + '<a class="button button-primary" href="%1$s" target="_blank" rel="noopener noreferrer">%2$s <span class="screen-reader-text">%3$s</span><span aria-hidden="true" class="dashicons dashicons-external"></span></a>', + esc_url( $results['action'] ), + __( 'Resolve', 'jetpack' ), + /* translators: accessibility text */ + __( '(opens in a new tab)', 'jetpack' ) + ); + } + } + + return $return; + }, + ); + } + $core_tests['async']['jetpack_test_suite'] = array( + 'label' => __( 'Jetpack Tests', 'jetpack' ), + 'test' => 'jetpack_local_testing_suite', + ); + + return $core_tests; +} + diff --git a/plugins/jetpack/_inc/lib/functions.wp-notify.php b/plugins/jetpack/_inc/lib/functions.wp-notify.php new file mode 100644 index 00000000..6be0c3ac --- /dev/null +++ b/plugins/jetpack/_inc/lib/functions.wp-notify.php @@ -0,0 +1,353 @@ +<?php + +if ( ! function_exists( 'wp_notify_postauthor' ) && Jetpack::is_active() ) : + /** + * Notify an author (and/or others) of a comment/trackback/pingback on a post. + * + * @since 1.0.0 + * + * @param int|WP_Comment $comment_id Comment ID or WP_Comment object. + * @param string $deprecated Not used + * @return bool True on completion. False if no email addresses were specified. + */ + function wp_notify_postauthor( $comment_id, $deprecated = null ) { + if ( null !== $deprecated ) { + _deprecated_argument( __FUNCTION__, '3.8.0' ); + } + + $comment = get_comment( $comment_id ); + + if ( empty( $comment ) || empty( $comment->comment_post_ID ) ) { + return false; + } + + $post = get_post( $comment->comment_post_ID ); + $author = get_userdata( $post->post_author ); + + // Who to notify? By default, just the post author, but others can be added. + $emails = array(); + if ( $author ) { + $emails[] = $author->user_email; + } + + /** This filter is documented in core/src/wp-includes/pluggable.php */ + $emails = apply_filters( 'comment_notification_recipients', $emails, $comment->comment_ID ); + $emails = array_filter( $emails ); + + // If there are no addresses to send the comment to, bail. + if ( ! count( $emails ) ) { + return false; + } + + // Facilitate unsetting below without knowing the keys. + $emails = array_flip( $emails ); + + /** This filter is documented in core/src/wp-includes/pluggable.php */ + $notify_author = apply_filters( 'comment_notification_notify_author', false, $comment->comment_ID ); + + // The comment was left by the author + if ( $author && ! $notify_author && $comment->user_id == $post->post_author ) { + unset( $emails[ $author->user_email ] ); + } + + // The author moderated a comment on their own post + if ( $author && ! $notify_author && $post->post_author == get_current_user_id() ) { + unset( $emails[ $author->user_email ] ); + } + + // The post author is no longer a member of the blog + if ( $author && ! $notify_author && ! user_can( $post->post_author, 'read_post', $post->ID ) ) { + unset( $emails[ $author->user_email ] ); + } + + // If there's no email to send the comment to, bail, otherwise flip array back around for use below + if ( ! count( $emails ) ) { + return false; + } else { + $emails = array_flip( $emails ); + } + + $switched_locale = switch_to_locale( get_locale() ); + + $comment_author_domain = @gethostbyaddr( $comment->comment_author_IP ); + + // The blogname option is escaped with esc_html on the way into the database in sanitize_option + // we want to reverse this for the plain text arena of emails. + $blogname = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + $comment_content = wp_specialchars_decode( $comment->comment_content ); + + function is_user_connected( $email ) { + $user = get_user_by( 'email', $email ); + return Jetpack::is_user_connected( $user->ID ); + } + + $moderate_on_wpcom = ! in_array( false, array_map( 'is_user_connected', $emails ) ); + + $primary_site_slug = Jetpack::build_raw_urls( get_home_url() ); + + switch ( $comment->comment_type ) { + case 'trackback': + /* translators: 1: Post title */ + $notify_message = sprintf( __( 'New trackback on your post "%s"' ), $post->post_title ) . "\r\n"; + /* translators: 1: Trackback/pingback website name, 2: website IP address, 3: website hostname */ + $notify_message .= sprintf( __( 'Website: %1$s (IP address: %2$s, %3$s)' ), $comment->comment_author, $comment->comment_author_IP, $comment_author_domain ) . "\r\n"; + $notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n"; + $notify_message .= sprintf( __( 'Comment: %s' ), "\r\n" . $comment_content ) . "\r\n\r\n"; + $notify_message .= __( 'You can see all trackbacks on this post here:' ) . "\r\n"; + /* translators: 1: blog name, 2: post title */ + $subject = sprintf( __( '[%1$s] Trackback: "%2$s"' ), $blogname, $post->post_title ); + break; + case 'pingback': + /* translators: 1: Post title */ + $notify_message = sprintf( __( 'New pingback on your post "%s"' ), $post->post_title ) . "\r\n"; + /* translators: 1: Trackback/pingback website name, 2: website IP address, 3: website hostname */ + $notify_message .= sprintf( __( 'Website: %1$s (IP address: %2$s, %3$s)' ), $comment->comment_author, $comment->comment_author_IP, $comment_author_domain ) . "\r\n"; + $notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n"; + $notify_message .= sprintf( __( 'Comment: %s' ), "\r\n" . $comment_content ) . "\r\n\r\n"; + $notify_message .= __( 'You can see all pingbacks on this post here:' ) . "\r\n"; + /* translators: 1: blog name, 2: post title */ + $subject = sprintf( __( '[%1$s] Pingback: "%2$s"' ), $blogname, $post->post_title ); + break; + default: // Comments + $notify_message = sprintf( __( 'New comment on your post "%s"' ), $post->post_title ) . "\r\n"; + /* translators: 1: comment author, 2: comment author's IP address, 3: comment author's hostname */ + $notify_message .= sprintf( __( 'Author: %1$s (IP address: %2$s, %3$s)' ), $comment->comment_author, $comment->comment_author_IP, $comment_author_domain ) . "\r\n"; + $notify_message .= sprintf( __( 'Email: %s' ), $comment->comment_author_email ) . "\r\n"; + $notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n"; + $notify_message .= sprintf( __( 'Comment: %s' ), "\r\n" . $comment_content ) . "\r\n\r\n"; + $notify_message .= __( 'You can see all comments on this post here:' ) . "\r\n"; + /* translators: 1: blog name, 2: post title */ + $subject = sprintf( __( '[%1$s] Comment: "%2$s"' ), $blogname, $post->post_title ); + break; + } + + $notify_message .= $moderate_on_wpcom + ? "https://wordpress.com/comments/all/{$primary_site_slug}/{$comment->comment_post_ID}/\r\n\r\n" + : get_permalink( $comment->comment_post_ID ) . "#comments\r\n\r\n"; + + $notify_message .= sprintf( __( 'Permalink: %s' ), get_comment_link( $comment ) ) . "\r\n"; + + if ( user_can( $post->post_author, 'edit_comment', $comment->comment_ID ) ) { + if ( EMPTY_TRASH_DAYS ) { + $notify_message .= sprintf( + __( 'Trash it: %s' ), $moderate_on_wpcom + ? "https://wordpress.com/comment/{$primary_site_slug}/{$comment_id}?action=trash" + : admin_url( "comment.php?action=trash&c={$comment->comment_ID}#wpbody-content" ) + ) . "\r\n"; + } else { + $notify_message .= sprintf( + __( 'Delete it: %s' ), $moderate_on_wpcom + ? "https://wordpress.com/comment/{$primary_site_slug}/{$comment_id}?action=delete" + : admin_url( "comment.php?action=delete&c={$comment->comment_ID}#wpbody-content" ) + ) . "\r\n"; + } + $notify_message .= sprintf( + __( 'Spam it: %s' ), $moderate_on_wpcom ? + "https://wordpress.com/comment/{$primary_site_slug}/{$comment_id}?action=spam" + : admin_url( "comment.php?action=spam&c={$comment->comment_ID}#wpbody-content" ) + ) . "\r\n"; + } + + $wp_email = 'wordpress@' . preg_replace( '#^www\.#', '', strtolower( $_SERVER['SERVER_NAME'] ) ); + + if ( '' == $comment->comment_author ) { + $from = "From: \"$blogname\" <$wp_email>"; + if ( '' != $comment->comment_author_email ) { + $reply_to = "Reply-To: $comment->comment_author_email"; + } + } else { + $from = "From: \"$comment->comment_author\" <$wp_email>"; + if ( '' != $comment->comment_author_email ) { + $reply_to = "Reply-To: \"$comment->comment_author_email\" <$comment->comment_author_email>"; + } + } + + $message_headers = "$from\n" + . 'Content-Type: text/plain; charset="' . get_option( 'blog_charset' ) . "\"\n"; + + if ( isset( $reply_to ) ) { + $message_headers .= $reply_to . "\n"; + } + + /** This filter is documented in core/src/wp-includes/pluggable.php */ + $notify_message = apply_filters( 'comment_notification_text', $notify_message, $comment->comment_ID ); + + /** This filter is documented in core/src/wp-includes/pluggable.php */ + $subject = apply_filters( 'comment_notification_subject', $subject, $comment->comment_ID ); + + /** This filter is documented in core/src/wp-includes/pluggable.php */ + $message_headers = apply_filters( 'comment_notification_headers', $message_headers, $comment->comment_ID ); + + foreach ( $emails as $email ) { + @wp_mail( $email, wp_specialchars_decode( $subject ), $notify_message, $message_headers ); + } + + if ( $switched_locale ) { + restore_previous_locale(); + } + + return true; + } +endif; + +if ( ! function_exists( 'wp_notify_moderator' ) && Jetpack::is_active() ) : + /** + * Notifies the moderator of the site about a new comment that is awaiting approval. + * + * @since 1.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * Uses the {@see 'notify_moderator'} filter to determine whether the site moderator + * should be notified, overriding the site setting. + * + * @param int $comment_id Comment ID. + * @return true Always returns true. + */ + function wp_notify_moderator( $comment_id ) { + global $wpdb; + + $maybe_notify = get_option( 'moderation_notify' ); + + /** This filter is documented in core/src/wp-includes/pluggable.php */ + $maybe_notify = apply_filters( 'notify_moderator', $maybe_notify, $comment_id ); + + if ( ! $maybe_notify ) { + return true; + } + + $comment = get_comment( $comment_id ); + $post = get_post( $comment->comment_post_ID ); + $user = get_userdata( $post->post_author ); + // Send to the administration and to the post author if the author can modify the comment. + $emails = array( get_option( 'admin_email' ) ); + if ( $user && user_can( $user->ID, 'edit_comment', $comment_id ) && ! empty( $user->user_email ) ) { + if ( 0 !== strcasecmp( $user->user_email, get_option( 'admin_email' ) ) ) { + $emails[] = $user->user_email; + } + } + + $switched_locale = switch_to_locale( get_locale() ); + + $comment_author_domain = @gethostbyaddr( $comment->comment_author_IP ); + $comments_waiting = $wpdb->get_var( "SELECT count(comment_ID) FROM $wpdb->comments WHERE comment_approved = '0'" ); + + // The blogname option is escaped with esc_html on the way into the database in sanitize_option + // we want to reverse this for the plain text arena of emails. + $blogname = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + $comment_content = wp_specialchars_decode( $comment->comment_content ); + + switch ( $comment->comment_type ) { + case 'trackback': + /* translators: 1: Post title */ + $notify_message = sprintf( __( 'A new trackback on the post "%s" is waiting for your approval' ), $post->post_title ) . "\r\n"; + $notify_message .= get_permalink( $comment->comment_post_ID ) . "\r\n\r\n"; + /* translators: 1: Trackback/pingback website name, 2: website IP address, 3: website hostname */ + $notify_message .= sprintf( __( 'Website: %1$s (IP address: %2$s, %3$s)' ), $comment->comment_author, $comment->comment_author_IP, $comment_author_domain ) . "\r\n"; + /* translators: 1: Trackback/pingback/comment author URL */ + $notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n"; + $notify_message .= __( 'Trackback excerpt: ' ) . "\r\n" . $comment_content . "\r\n\r\n"; + break; + case 'pingback': + /* translators: 1: Post title */ + $notify_message = sprintf( __( 'A new pingback on the post "%s" is waiting for your approval' ), $post->post_title ) . "\r\n"; + $notify_message .= get_permalink( $comment->comment_post_ID ) . "\r\n\r\n"; + /* translators: 1: Trackback/pingback website name, 2: website IP address, 3: website hostname */ + $notify_message .= sprintf( __( 'Website: %1$s (IP address: %2$s, %3$s)' ), $comment->comment_author, $comment->comment_author_IP, $comment_author_domain ) . "\r\n"; + /* translators: 1: Trackback/pingback/comment author URL */ + $notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n"; + $notify_message .= __( 'Pingback excerpt: ' ) . "\r\n" . $comment_content . "\r\n\r\n"; + break; + default: // Comments + /* translators: 1: Post title */ + $notify_message = sprintf( __( 'A new comment on the post "%s" is waiting for your approval' ), $post->post_title ) . "\r\n"; + $notify_message .= get_permalink( $comment->comment_post_ID ) . "\r\n\r\n"; + /* translators: 1: Comment author name, 2: comment author's IP address, 3: comment author's hostname */ + $notify_message .= sprintf( __( 'Author: %1$s (IP address: %2$s, %3$s)' ), $comment->comment_author, $comment->comment_author_IP, $comment_author_domain ) . "\r\n"; + /* translators: 1: Comment author URL */ + $notify_message .= sprintf( __( 'Email: %s' ), $comment->comment_author_email ) . "\r\n"; + /* translators: 1: Trackback/pingback/comment author URL */ + $notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n"; + /* translators: 1: Comment text */ + $notify_message .= sprintf( __( 'Comment: %s' ), "\r\n" . $comment_content ) . "\r\n\r\n"; + break; + } + + /** This filter is documented in core/src/wp-includes/pluggable.php */ + $emails = apply_filters( 'comment_moderation_recipients', $emails, $comment_id ); + + function is_user_connected( $email ) { + $user = get_user_by( 'email', $email ); + return Jetpack::is_user_connected( $user->ID ); + } + + $moderate_on_wpcom = ! in_array( false, array_map( 'is_user_connected', $emails ) ); + + $primary_site_slug = Jetpack::build_raw_urls( get_home_url() ); + + /* translators: Comment moderation. 1: Comment action URL */ + $notify_message .= sprintf( + __( 'Approve it: %s' ), $moderate_on_wpcom + ? "https://wordpress.com/comment/{$primary_site_slug}/{$comment_id}?action=approve" + : admin_url( "comment.php?action=approve&c={$comment_id}#wpbody-content" ) + ) . "\r\n"; + + if ( EMPTY_TRASH_DAYS ) { + /* translators: Comment moderation. 1: Comment action URL */ + $notify_message .= sprintf( + __( 'Trash it: %s' ), $moderate_on_wpcom + ? "https://wordpress.com/comment/{$primary_site_slug}/{$comment_id}?action=trash" + : admin_url( "comment.php?action=trash&c={$comment_id}#wpbody-content" ) + ) . "\r\n"; + } else { + /* translators: Comment moderation. 1: Comment action URL */ + $notify_message .= sprintf( + __( 'Delete it: %s' ), $moderate_on_wpcom + ? "https://wordpress.com/comment/{$primary_site_slug}/{$comment_id}?action=delete" + : admin_url( "comment.php?action=delete&c={$comment_id}#wpbody-content" ) + ) . "\r\n"; + } + + /* translators: Comment moderation. 1: Comment action URL */ + $notify_message .= sprintf( + __( 'Spam it: %s' ), $moderate_on_wpcom + ? "https://wordpress.com/comment/{$primary_site_slug}/{$comment_id}?action=spam" + : admin_url( "comment.php?action=spam&c={$comment_id}#wpbody-content" ) + ) . "\r\n"; + + /* translators: Comment moderation. 1: Number of comments awaiting approval */ + $notify_message .= sprintf( + _n( + 'Currently %s comment is waiting for approval. Please visit the moderation panel:', + 'Currently %s comments are waiting for approval. Please visit the moderation panel:', $comments_waiting + ), number_format_i18n( $comments_waiting ) + ) . "\r\n"; + + $notify_message .= $moderate_on_wpcom + ? "https://wordpress.com/comments/pending/{$primary_site_slug}/" + : admin_url( 'edit-comments.php?comment_status=moderated#wpbody-content' ) . "\r\n"; + + /* translators: Comment moderation notification email subject. 1: Site name, 2: Post title */ + $subject = sprintf( __( '[%1$s] Please moderate: "%2$s"' ), $blogname, $post->post_title ); + $message_headers = ''; + + /** This filter is documented in core/src/wp-includes/pluggable.php */ + $notify_message = apply_filters( 'comment_moderation_text', $notify_message, $comment_id ); + + /** This filter is documented in core/src/wp-includes/pluggable.php */ + $subject = apply_filters( 'comment_moderation_subject', $subject, $comment_id ); + + /** This filter is documented in core/src/wp-includes/pluggable.php */ + $message_headers = apply_filters( 'comment_moderation_headers', $message_headers, $comment_id ); + + foreach ( $emails as $email ) { + @wp_mail( $email, wp_specialchars_decode( $subject ), $notify_message, $message_headers ); + } + + if ( $switched_locale ) { + restore_previous_locale(); + } + + return true; + } +endif; diff --git a/plugins/jetpack/_inc/lib/icalendar-reader.php b/plugins/jetpack/_inc/lib/icalendar-reader.php new file mode 100644 index 00000000..f7e047f9 --- /dev/null +++ b/plugins/jetpack/_inc/lib/icalendar-reader.php @@ -0,0 +1,913 @@ +<?php + +/** + * Gets and renders iCal feeds for the Upcoming Events widget and shortcode + */ + +class iCalendarReader { + + public $todo_count = 0; + public $event_count = 0; + public $cal = array(); + public $_lastKeyWord = ''; + public $timezone = null; + + /** + * Class constructor + * + * @return void + */ + public function __construct() {} + + /** + * Return an array of events + * + * @param string $url (default: '') + * @return array | false on failure + */ + public function get_events( $url = '', $count = 5 ) { + $count = (int) $count; + $transient_id = 'icalendar_vcal_' . md5( $url ) . '_' . $count; + + $vcal = get_transient( $transient_id ); + + if ( ! empty( $vcal ) ) { + if ( isset( $vcal['TIMEZONE'] ) ) + $this->timezone = $this->timezone_from_string( $vcal['TIMEZONE'] ); + + if ( isset( $vcal['VEVENT'] ) ) { + $vevent = $vcal['VEVENT']; + + if ( $count > 0 ) + $vevent = array_slice( $vevent, 0, $count ); + + $this->cal['VEVENT'] = $vevent; + + return $this->cal['VEVENT']; + } + } + + if ( ! $this->parse( $url ) ) + return false; + + $vcal = array(); + + if ( $this->timezone ) { + $vcal['TIMEZONE'] = $this->timezone->getName(); + } else { + $this->timezone = $this->timezone_from_string( '' ); + } + + if ( ! empty( $this->cal['VEVENT'] ) ) { + $vevent = $this->cal['VEVENT']; + + // check for recurring events + // $vevent = $this->add_recurring_events( $vevent ); + + // remove before caching - no sense in hanging onto the past + $vevent = $this->filter_past_and_recurring_events( $vevent ); + + // order by soonest start date + $vevent = $this->sort_by_recent( $vevent ); + + $vcal['VEVENT'] = $vevent; + } + + set_transient( $transient_id, $vcal, HOUR_IN_SECONDS ); + + if ( !isset( $vcal['VEVENT'] ) ) + return false; + + if ( $count > 0 ) + return array_slice( $vcal['VEVENT'], 0, $count ); + + return $vcal['VEVENT']; + } + + function apply_timezone_offset( $events ) { + if ( ! $events ) { + return $events; + } + + // get timezone offset from the timezone name. + $timezone_name = get_option( 'timezone_string' ); + if ( $timezone_name ) { + $timezone = new DateTimeZone( $timezone_name ); + $timezone_offset_interval = false; + } else { + // If the timezone isn't set then the GMT offset must be set. + // generate a DateInterval object from the timezone offset + $gmt_offset = get_option( 'gmt_offset' ) * HOUR_IN_SECONDS; + $timezone_offset_interval = date_interval_create_from_date_string( "{$gmt_offset} seconds" ); + $timezone = new DateTimeZone( 'UTC' ); + } + + $offsetted_events = array(); + + foreach ( $events as $event ) { + // Don't handle all-day events + if ( 8 < strlen( $event['DTSTART'] ) ) { + $start_time = preg_replace( '/Z$/', '', $event['DTSTART'] ); + $start_time = new DateTime( $start_time, $this->timezone ); + $start_time->setTimeZone( $timezone ); + + $end_time = preg_replace( '/Z$/', '', $event['DTEND'] ); + $end_time = new DateTime( $end_time, $this->timezone ); + $end_time->setTimeZone( $timezone ); + + if ( $timezone_offset_interval ) { + $start_time->add( $timezone_offset_interval ); + $end_time->add( $timezone_offset_interval ); + } + + $event['DTSTART'] = $start_time->format( 'YmdHis\Z' ); + $event['DTEND'] = $end_time->format( 'YmdHis\Z' ); + } + + $offsetted_events[] = $event; + } + + return $offsetted_events; + } + + protected function filter_past_and_recurring_events( $events ) { + $upcoming = array(); + $set_recurring_events = array(); + $recurrences = array(); + /** + * This filter allows any time to be passed in for testing or changing timezones, etc... + * + * @module widgets + * + * @since 3.4.0 + * + * @param object time() A time object. + */ + $current = apply_filters( 'ical_get_current_time', time() ); + + foreach ( $events as $event ) { + + $date_from_ics = strtotime( $event['DTSTART'] ); + if ( isset( $event['DTEND'] ) ) { + $duration = strtotime( $event['DTEND'] ) - strtotime( $event['DTSTART'] ); + } else { + $duration = 0; + } + + if ( isset( $event['RRULE'] ) && $this->timezone->getName() && 8 != strlen( $event['DTSTART'] ) ) { + try { + $adjusted_time = new DateTime( $event['DTSTART'], new DateTimeZone('UTC') ); + $adjusted_time->setTimeZone( new DateTimeZone( $this->timezone->getName() ) ); + $event['DTSTART'] = $adjusted_time->format('Ymd\THis'); + $date_from_ics = strtotime( $event['DTSTART'] ); + + $event['DTEND'] = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) + $duration ); + } catch ( Exception $e ) { + // Invalid argument to DateTime + } + + if ( isset( $event['EXDATE'] ) ) { + $exdates = array(); + foreach ( (array) $event['EXDATE'] as $exdate ) { + try { + $adjusted_time = new DateTime( $exdate, new DateTimeZone('UTC') ); + $adjusted_time->setTimeZone( new DateTimeZone( $this->timezone->getName() ) ); + if ( 8 == strlen( $event['DTSTART'] ) ) { + $exdates[] = $adjusted_time->format( 'Ymd' ); + } else { + $exdates[] = $adjusted_time->format( 'Ymd\THis' ); + } + } catch ( Exception $e ) { + // Invalid argument to DateTime + } + } + $event['EXDATE'] = $exdates; + } else { + $event['EXDATE'] = array(); + } + } + + if ( ! isset( $event['DTSTART'] ) ) { + continue; + } + + // Process events with RRULE before other events + $rrule = isset( $event['RRULE'] ) ? $event['RRULE'] : false ; + $uid = $event['UID']; + + if ( $rrule && ! in_array( $uid, $set_recurring_events ) ) { + + // Break down the RRULE into digestible chunks + $rrule_array = array(); + + foreach ( explode( ";", $event['RRULE'] ) as $rline ) { + list( $rkey, $rvalue ) = explode( "=", $rline, 2 ); + $rrule_array[$rkey] = $rvalue; + } + + $interval = ( isset( $rrule_array['INTERVAL'] ) ) ? $rrule_array['INTERVAL'] : 1; + $rrule_count = ( isset( $rrule_array['COUNT'] ) ) ? $rrule_array['COUNT'] : 0; + $until = ( isset( $rrule_array['UNTIL'] ) ) ? strtotime( $rrule_array['UNTIL'] ) : strtotime( '+1 year', $current ); + + // Used to bound event checks + $echo_limit = 10; + $noop = false; + + // Set bydays for the event + $weekdays = array( 'SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA' ); + $bydays = $weekdays; + + // Calculate a recent start date for incrementing depending on the frequency and interval + switch ( $rrule_array['FREQ'] ) { + + case 'DAILY': + $frequency = 'day'; + $echo_limit = 10; + + if ( $date_from_ics >= $current ) { + $recurring_event_date_start = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) ); + } else { + // Interval and count + $catchup = floor( ( $current - strtotime( $event['DTSTART'] ) ) / ( $interval * DAY_IN_SECONDS ) ); + if ( $rrule_count && $catchup > 0 ) { + if ( $catchup < $rrule_count ) { + $rrule_count = $rrule_count - $catchup; + $recurring_event_date_start = date( 'Ymd', strtotime( '+ ' . ( $interval * $catchup ) . ' days', strtotime( $event['DTSTART'] ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) ); + } else { + $noop = true; + } + } else { + $recurring_event_date_start = date( 'Ymd', strtotime( '+ ' . ( $interval * $catchup ) . ' days', strtotime( $event['DTSTART'] ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) ); + } + } + break; + + case 'WEEKLY': + $frequency = 'week'; + $echo_limit = 4; + + // BYDAY exception to current date + $day = false; + if ( ! isset( $rrule_array['BYDAY'] ) ) { + $day = $rrule_array['BYDAY'] = strtoupper( substr( date( 'D', strtotime( $event['DTSTART'] ) ), 0, 2 ) ); + } + $bydays = explode( ',', $rrule_array['BYDAY'] ); + + if ( $date_from_ics >= $current ) { + $recurring_event_date_start = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) ); + } else { + // Interval and count + $catchup = floor( ( $current - strtotime( $event['DTSTART'] ) ) / ( $interval * WEEK_IN_SECONDS ) ); + if ( $rrule_count && $catchup > 0 ) { + if ( ( $catchup * count( $bydays ) ) < $rrule_count ) { + $rrule_count = $rrule_count - ( $catchup * count( $bydays ) ); // Estimate current event count + $recurring_event_date_start = date( 'Ymd', strtotime( '+ ' . ( $interval * $catchup ) . ' weeks', strtotime( $event['DTSTART'] ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) ); + } else { + $noop = true; + } + } else { + $recurring_event_date_start = date( 'Ymd', strtotime( '+ ' . ( $interval * $catchup ) . ' weeks', strtotime( $event['DTSTART'] ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) ); + } + } + + // Set to Sunday start + if ( ! $noop && 'SU' !== strtoupper( substr( date( 'D', strtotime( $recurring_event_date_start ) ), 0, 2 ) ) ) { + $recurring_event_date_start = date( 'Ymd', strtotime( "last Sunday", strtotime( $recurring_event_date_start ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) ); + } + break; + + case 'MONTHLY': + $frequency = 'month'; + $echo_limit = 1; + + if ( $date_from_ics >= $current ) { + $recurring_event_date_start = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) ); + } else { + // Describe the date in the month + if ( isset( $rrule_array['BYDAY'] ) ) { + $day_number = substr( $rrule_array['BYDAY'], 0, 1 ); + $week_day = substr( $rrule_array['BYDAY'], 1 ); + $day_cardinals = array( 1 => 'first', 2 => 'second', 3 => 'third', 4 => 'fourth', 5 => 'fifth' ); + $weekdays = array( 'SU' => 'Sunday', 'MO' => 'Monday', 'TU' => 'Tuesday', 'WE' => 'Wednesday', 'TH' => 'Thursday', 'FR' => 'Friday', 'SA' => 'Saturday' ); + $event_date_desc = "{$day_cardinals[$day_number]} {$weekdays[$week_day]} of "; + } else { + $event_date_desc = date( 'd ', strtotime( $event['DTSTART'] ) ); + } + + // Interval only + if ( $interval > 1 ) { + $catchup = 0; + $maybe = strtotime( $event['DTSTART'] ); + while ( $maybe < $current ) { + $maybe = strtotime( '+ ' . ( $interval * $catchup ) . ' months', strtotime( $event['DTSTART'] ) ); + $catchup++; + } + $recurring_event_date_start = date( 'Ymd', strtotime( $event_date_desc . date( 'F Y', strtotime( '+ ' . ( $interval * ( $catchup - 1 ) ) . ' months', strtotime( $event['DTSTART'] ) ) ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) ); + } else { + $recurring_event_date_start = date( 'Ymd', strtotime( $event_date_desc . date( 'F Y', $current ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) ); + } + + // Add one interval if necessary + if ( strtotime( $recurring_event_date_start ) < $current ) { + if ( $interval > 1 ) { + $recurring_event_date_start = date( 'Ymd', strtotime( $event_date_desc . date( 'F Y', strtotime( '+ ' . ( $interval * $catchup ) . ' months', strtotime( $event['DTSTART'] ) ) ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) ); + } else { + try { + $adjustment = new DateTime( date( 'Y-m-d', $current ) ); + $adjustment->modify( 'first day of next month' ); + $recurring_event_date_start = date( 'Ymd', strtotime( $event_date_desc . $adjustment->format( 'F Y' ) ) ) . date( '\THis', strtotime( $event['DTSTART'] ) ); + } catch ( Exception $e ) { + // Invalid argument to DateTime + } + } + } + } + break; + + case 'YEARLY': + $frequency = 'year'; + $echo_limit = 1; + + if ( $date_from_ics >= $current ) { + $recurring_event_date_start = date( "Ymd\THis", strtotime( $event['DTSTART'] ) ); + } else { + $recurring_event_date_start = date( 'Y', $current ) . date( "md\THis", strtotime( $event['DTSTART'] ) ); + if ( strtotime( $recurring_event_date_start ) < $current ) { + try { + $next = new DateTime( date( 'Y-m-d', $current ) ); + $next->modify( 'first day of next year' ); + $recurring_event_date_start = $next->format( 'Y' ) . date ( 'md\THis', strtotime( $event['DTSTART'] ) ); + } catch ( Exception $e ) { + // Invalid argument to DateTime + } + } + } + break; + + default: + $frequency = false; + } + + if ( $frequency !== false && ! $noop ) { + $count_counter = 1; + + // If no COUNT limit, go to 10 + if ( empty( $rrule_count ) ) { + $rrule_count = 10; + } + + // Set up EXDATE handling for the event + $exdates = ( isset( $event['EXDATE'] ) ) ? $event['EXDATE'] : array(); + + for ( $i = 1; $i <= $echo_limit; $i++ ) { + + // Weeks need a daily loop and must check for inclusion in BYDAYS + if ( 'week' == $frequency ) { + $byday_event_date_start = strtotime( $recurring_event_date_start ); + + foreach ( $weekdays as $day ) { + + $event_start_timestamp = $byday_event_date_start; + $start_time = date( 'His', $event_start_timestamp ); + $event_end_timestamp = $event_start_timestamp + $duration; + $end_time = date( 'His', $event_end_timestamp ); + if ( 8 == strlen( $event['DTSTART'] ) ) { + $exdate_compare = date( 'Ymd', $event_start_timestamp ); + } else { + $exdate_compare = date( 'Ymd\THis', $event_start_timestamp ); + } + + if ( in_array( $day, $bydays ) && $event_end_timestamp > $current && $event_start_timestamp < $until && $count_counter <= $rrule_count && $event_start_timestamp >= $date_from_ics && ! in_array( $exdate_compare, $exdates ) ) { + if ( 8 == strlen( $event['DTSTART'] ) ) { + $event['DTSTART'] = date( 'Ymd', $event_start_timestamp ); + $event['DTEND'] = date( 'Ymd', $event_end_timestamp ); + } else { + $event['DTSTART'] = date( 'Ymd\THis', $event_start_timestamp ); + $event['DTEND'] = date( 'Ymd\THis', $event_end_timestamp ); + } + if ( $this->timezone->getName() && 8 != strlen( $event['DTSTART'] ) ) { + try { + $adjusted_time = new DateTime( $event['DTSTART'], new DateTimeZone( $this->timezone->getName() ) ); + $adjusted_time->setTimeZone( new DateTimeZone( 'UTC' ) ); + $event['DTSTART'] = $adjusted_time->format('Ymd\THis'); + + $event['DTEND'] = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) + $duration ); + } catch ( Exception $e ) { + // Invalid argument to DateTime + } + } + $upcoming[] = $event; + $count_counter++; + } + + // Move forward one day + $byday_event_date_start = strtotime( date( 'Ymd\T', strtotime( '+ 1 day', $event_start_timestamp ) ) . $start_time ); + } + + // Restore first event timestamp + $event_start_timestamp = strtotime( $recurring_event_date_start ); + + } else { + + $event_start_timestamp = strtotime( $recurring_event_date_start ); + $start_time = date( 'His', $event_start_timestamp ); + $event_end_timestamp = $event_start_timestamp + $duration; + $end_time = date( 'His', $event_end_timestamp ); + if ( 8 == strlen( $event['DTSTART'] ) ) { + $exdate_compare = date( 'Ymd', $event_start_timestamp ); + } else { + $exdate_compare = date( 'Ymd\THis', $event_start_timestamp ); + } + + if ( $event_end_timestamp > $current && $event_start_timestamp < $until && $count_counter <= $rrule_count && $event_start_timestamp >= $date_from_ics && ! in_array( $exdate_compare, $exdates ) ) { + if ( 8 == strlen( $event['DTSTART'] ) ) { + $event['DTSTART'] = date( 'Ymd', $event_start_timestamp ); + $event['DTEND'] = date( 'Ymd', $event_end_timestamp ); + } else { + $event['DTSTART'] = date( 'Ymd\T', $event_start_timestamp ) . $start_time; + $event['DTEND'] = date( 'Ymd\T', $event_end_timestamp ) . $end_time; + } + if ( $this->timezone->getName() && 8 != strlen( $event['DTSTART'] ) ) { + try { + $adjusted_time = new DateTime( $event['DTSTART'], new DateTimeZone( $this->timezone->getName() ) ); + $adjusted_time->setTimeZone( new DateTimeZone( 'UTC' ) ); + $event['DTSTART'] = $adjusted_time->format('Ymd\THis'); + + $event['DTEND'] = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) + $duration ); + } catch ( Exception $e ) { + // Invalid argument to DateTime + } + } + $upcoming[] = $event; + $count_counter++; + } + } + + // Set up next interval and reset $event['DTSTART'] and $event['DTEND'], keeping timestamps intact + $next_start_timestamp = strtotime( "+ {$interval} {$frequency}s", $event_start_timestamp ); + if ( 8 == strlen( $event['DTSTART'] ) ) { + $event['DTSTART'] = date( 'Ymd', $next_start_timestamp ); + $event['DTEND'] = date( 'Ymd', strtotime( $event['DTSTART'] ) + $duration ); + } else { + $event['DTSTART'] = date( 'Ymd\THis', $next_start_timestamp ); + $event['DTEND'] = date( 'Ymd\THis', strtotime( $event['DTSTART'] ) + $duration ); + } + + // Move recurring event date forward + $recurring_event_date_start = $event['DTSTART']; + } + $set_recurring_events[] = $uid; + + } + + } else { + // Process normal events + if ( strtotime( isset( $event['DTEND'] ) ? $event['DTEND'] : $event['DTSTART'] ) >= $current ) { + $upcoming[] = $event; + } + } + } + return $upcoming; + } + + /** + * Parse events from an iCalendar feed + * + * @param string $url (default: '') + * @return array | false on failure + */ + public function parse( $url = '' ) { + $cache_group = 'icalendar_reader_parse'; + $disable_get_key = 'disable:' . md5( $url ); + + // Check to see if previous attempts have failed + if ( false !== wp_cache_get( $disable_get_key, $cache_group ) ) + return false; + + // rewrite webcal: URI schem to HTTP + $url = preg_replace('/^webcal/', 'http', $url ); + // try to fetch + $r = wp_remote_get( $url, array( 'timeout' => 3, 'sslverify' => false ) ); + if ( 200 !== wp_remote_retrieve_response_code( $r ) ) { + // We were unable to fetch any content, so don't try again for another 60 seconds + wp_cache_set( $disable_get_key, 1, $cache_group, 60 ); + return false; + } + + $body = wp_remote_retrieve_body( $r ); + if ( empty( $body ) ) + return false; + + $body = str_replace( "\r\n", "\n", $body ); + $lines = preg_split( "/\n(?=[A-Z])/", $body ); + + if ( empty( $lines ) ) + return false; + + if ( false === stristr( $lines[0], 'BEGIN:VCALENDAR' ) ) + return false; + + foreach ( $lines as $line ) { + $add = $this->key_value_from_string( $line ); + if ( ! $add ) { + $this->add_component( $type, false, $line ); + continue; + } + list( $keyword, $value ) = $add; + + switch ( $keyword ) { + case 'BEGIN': + case 'END': + switch ( $line ) { + case 'BEGIN:VTODO': + $this->todo_count++; + $type = 'VTODO'; + break; + case 'BEGIN:VEVENT': + $this->event_count++; + $type = 'VEVENT'; + break; + case 'BEGIN:VCALENDAR': + case 'BEGIN:DAYLIGHT': + case 'BEGIN:VTIMEZONE': + case 'BEGIN:STANDARD': + $type = $value; + break; + case 'END:VTODO': + case 'END:VEVENT': + case 'END:VCALENDAR': + case 'END:DAYLIGHT': + case 'END:VTIMEZONE': + case 'END:STANDARD': + $type = 'VCALENDAR'; + break; + } + break; + case 'TZID': + if ( 'VTIMEZONE' == $type && ! $this->timezone ) + $this->timezone = $this->timezone_from_string( $value ); + break; + case 'X-WR-TIMEZONE': + if ( ! $this->timezone ) + $this->timezone = $this->timezone_from_string( $value ); + break; + default: + $this->add_component( $type, $keyword, $value ); + break; + } + } + + // Filter for RECURRENCE-IDs + $recurrences = array(); + if ( array_key_exists( 'VEVENT', $this->cal ) ) { + foreach ( $this->cal['VEVENT'] as $event ) { + if ( isset( $event['RECURRENCE-ID'] ) ) { + $recurrences[] = $event; + } + } + foreach ( $recurrences as $recurrence ) { + for ( $i = 0; $i < count( $this->cal['VEVENT'] ); $i++ ) { + if ( $this->cal['VEVENT'][ $i ]['UID'] == $recurrence['UID'] && ! isset( $this->cal['VEVENT'][ $i ]['RECURRENCE-ID'] ) ) { + $this->cal['VEVENT'][ $i ]['EXDATE'][] = $recurrence['RECURRENCE-ID']; + break; + } + } + } + } + + return $this->cal; + } + + /** + * Parse key:value from a string + * + * @param string $text (default: '') + * @return array + */ + public function key_value_from_string( $text = '' ) { + preg_match( '/([^:]+)(;[^:]+)?[:]([\w\W]*)/', $text, $matches ); + + if ( 0 == count( $matches ) ) + return false; + + return array( $matches[1], $matches[3] ); + } + + /** + * Convert a timezone name into a timezone object. + * + * @param string $text Timezone name. Example: America/Chicago + * @return object|null A DateTimeZone object if the conversion was successful. + */ + private function timezone_from_string( $text ) { + try { + $timezone = new DateTimeZone( $text ); + } catch ( Exception $e ) { + $blog_timezone = get_option( 'timezone_string' ); + if ( ! $blog_timezone ) { + $blog_timezone = 'Etc/UTC'; + } + + $timezone = new DateTimeZone( $blog_timezone ); + } + + return $timezone; + } + + /** + * Add a component to the calendar array + * + * @param string $component (default: '') + * @param string $keyword (default: '') + * @param string $value (default: '') + * @return void + */ + public function add_component( $component = '', $keyword = '', $value = '' ) { + if ( false == $keyword ) { + $keyword = $this->last_keyword; + switch ( $component ) { + case 'VEVENT': + $value = $this->cal[ $component ][ $this->event_count - 1 ][ $keyword ] . $value; + break; + case 'VTODO' : + $value = $this->cal[ $component ][ $this->todo_count - 1 ][ $keyword ] . $value; + break; + } + } + + /* + * Some events have a specific timezone set in their start/end date, + * and it may or may not be different than the calendar timzeone. + * Valid formats include: + * DTSTART;TZID=Pacific Standard Time:20141219T180000 + * DTEND;TZID=Pacific Standard Time:20141219T200000 + * EXDATE:19960402T010000Z,19960403T010000Z,19960404T010000Z + * EXDATE;VALUE=DATE:2015050 + * EXDATE;TZID=America/New_York:20150424T170000 + * EXDATE;TZID=Pacific Standard Time:20120615T140000,20120629T140000,20120706T140000 + */ + + // Always store EXDATE as an array + if ( stristr( $keyword, 'EXDATE' ) ) { + $value = explode( ',', $value ); + } + + // Adjust DTSTART, DTEND, and EXDATE according to their TZID if set + if ( strpos( $keyword, ';' ) && ( stristr( $keyword, 'DTSTART' ) || stristr( $keyword, 'DTEND' ) || stristr( $keyword, 'EXDATE' ) || stristr( $keyword, 'RECURRENCE-ID' ) ) ) { + $keyword = explode( ';', $keyword ); + + $tzid = false; + if ( 2 == count( $keyword ) ) { + $tparam = $keyword[1]; + + if ( strpos( $tparam, "TZID" ) !== false ) { + $tzid = $this->timezone_from_string( str_replace( 'TZID=', '', $tparam ) ); + } + } + + // Normalize all times to default UTC + if ( $tzid ) { + $adjusted_times = array(); + foreach ( (array) $value as $v ) { + try { + $adjusted_time = new DateTime( $v, $tzid ); + $adjusted_time->setTimeZone( new DateTimeZone( 'UTC' ) ); + $adjusted_times[] = $adjusted_time->format('Ymd\THis'); + } catch ( Exception $e ) { + // Invalid argument to DateTime + return; + } + } + $value = $adjusted_times; + } + + // Format for adding to event + $keyword = $keyword[0]; + if ( 'EXDATE' != $keyword ) { + $value = implode( (array) $value ); + } + } + + foreach ( (array) $value as $v ) { + switch ($component) { + case 'VTODO': + if ( 'EXDATE' == $keyword ) { + $this->cal[ $component ][ $this->todo_count - 1 ][ $keyword ][] = $v; + } else { + $this->cal[ $component ][ $this->todo_count - 1 ][ $keyword ] = $v; + } + break; + case 'VEVENT': + if ( 'EXDATE' == $keyword ) { + $this->cal[ $component ][ $this->event_count - 1 ][ $keyword ][] = $v; + } else { + $this->cal[ $component ][ $this->event_count - 1 ][ $keyword ] = $v; + } + break; + default: + $this->cal[ $component ][ $keyword ] = $v; + break; + } + } + $this->last_keyword = $keyword; + } + + /** + * Escape strings with wp_kses, allow links + * + * @param string $string (default: '') + * @return string + */ + public function escape( $string = '' ) { + // Unfold content lines per RFC 5545 + $string = str_replace( "\n\t", '', $string ); + $string = str_replace( "\n ", '', $string ); + + $allowed_html = array( + 'a' => array( + 'href' => array(), + 'title' => array() + ) + ); + + $allowed_tags = ''; + foreach ( array_keys( $allowed_html ) as $tag ) { + $allowed_tags .= "<{$tag}>"; + } + + // Running strip_tags() first with allowed tags to get rid of remaining gallery markup, etc + // because wp_kses() would only htmlentity'fy that. Then still running wp_kses(), for extra + // safety and good measure. + return wp_kses( strip_tags( $string, $allowed_tags ), $allowed_html ); + } + + /** + * Render the events + * + * @param string $url (default: '') + * @param string $context (default: 'widget') or 'shortcode' + * @return mixed bool|string false on failure, rendered HTML string on success. + */ + public function render( $url = '', $args = array() ) { + + $args = wp_parse_args( $args, array( + 'context' => 'widget', + 'number' => 5 + ) ); + + $events = $this->get_events( $url, $args['number'] ); + $events = $this->apply_timezone_offset( $events ); + + if ( empty( $events ) ) + return false; + + ob_start(); + + if ( 'widget' == $args['context'] ) : ?> + <ul class="upcoming-events"> + <?php foreach ( $events as $event ) : ?> + <li> + <strong class="event-summary"><?php echo $this->escape( stripslashes( $event['SUMMARY'] ) ); ?></strong> + <span class="event-when"><?php echo $this->formatted_date( $event ); ?></span> + <?php if ( ! empty( $event['LOCATION'] ) ) : ?> + <span class="event-location"><?php echo $this->escape( stripslashes( $event['LOCATION'] ) ); ?></span> + <?php endif; ?> + <?php if ( ! empty( $event['DESCRIPTION'] ) ) : ?> + <span class="event-description"><?php echo wp_trim_words( $this->escape( stripcslashes( $event['DESCRIPTION'] ) ) ); ?></span> + <?php endif; ?> + </li> + <?php endforeach; ?> + </ul> + <?php endif; + + if ( 'shortcode' == $args['context'] ) : ?> + <table class="upcoming-events"> + <thead> + <tr> + <th><?php esc_html_e( 'Location', 'jetpack' ); ?></th> + <th><?php esc_html_e( 'When', 'jetpack' ); ?></th> + <th><?php esc_html_e( 'Summary', 'jetpack' ); ?></th> + <th><?php esc_html_e( 'Description', 'jetpack' ); ?></th> + </tr> + </thead> + <tbody> + <?php foreach ( $events as $event ) : ?> + <tr> + <td><?php echo empty( $event['LOCATION'] ) ? ' ' : $this->escape( stripslashes( $event['LOCATION'] ) ); ?></td> + <td><?php echo $this->formatted_date( $event ); ?></td> + <td><?php echo empty( $event['SUMMARY'] ) ? ' ' : $this->escape( stripslashes( $event['SUMMARY'] ) ); ?></td> + <td><?php echo empty( $event['DESCRIPTION'] ) ? ' ' : wp_trim_words( $this->escape( stripcslashes( $event['DESCRIPTION'] ) ) ); ?></td> + </tr> + <?php endforeach; ?> + </tbody> + </table> + <?php endif; + + $rendered = ob_get_clean(); + + if ( empty( $rendered ) ) + return false; + + return $rendered; + } + + public function formatted_date( $event ) { + + $date_format = get_option( 'date_format' ); + $time_format = get_option( 'time_format' ); + $start = strtotime( $event['DTSTART'] ); + $end = isset( $event['DTEND'] ) ? strtotime( $event['DTEND'] ) : false; + + $all_day = ( 8 == strlen( $event['DTSTART'] ) ); + + if ( !$all_day && $this->timezone ) { + try { + $start_time = new DateTime( $event['DTSTART'] ); + $timezone_offset = $this->timezone->getOffset( $start_time ); + $start += $timezone_offset; + + if ( $end ) { + $end += $timezone_offset; + } + } catch ( Exception $e ) { + // Invalid argument to DateTime + } + } + $single_day = $end ? ( $end - $start ) <= DAY_IN_SECONDS : true; + + /* translators: Date and time */ + $date_with_time = __( '%1$s at %2$s' , 'jetpack' ); + /* translators: Two dates with a separator */ + $two_dates = __( '%1$s – %2$s' , 'jetpack' ); + + // we'll always have the start date. Maybe with time + if ( $all_day ) + $date = date_i18n( $date_format, $start ); + else + $date = sprintf( $date_with_time, date_i18n( $date_format, $start ), date_i18n( $time_format, $start ) ); + + // single day, timed + if ( $single_day && ! $all_day && false !== $end ) + $date = sprintf( $two_dates, $date, date_i18n( $time_format, $end ) ); + + // multi-day + if ( ! $single_day ) { + + if ( $all_day ) { + // DTEND for multi-day events represents "until", not "including", so subtract one minute + $end_date = date_i18n( $date_format, $end - 60 ); + } else { + $end_date = sprintf( $date_with_time, date_i18n( $date_format, $end ), date_i18n( $time_format, $end ) ); + } + + $date = sprintf( $two_dates, $date, $end_date ); + + } + + return $date; + } + + protected function sort_by_recent( $list ) { + $dates = $sorted_list = array(); + + foreach ( $list as $key => $row ) { + $date = $row['DTSTART']; + // pad some time onto an all day date + if ( 8 === strlen( $date ) ) + $date .= 'T000000Z'; + $dates[$key] = $date; + } + asort( $dates ); + foreach( $dates as $key => $value ) { + $sorted_list[$key] = $list[$key]; + } + unset($list); + return $sorted_list; + } + +} + + +/** + * Wrapper function for iCalendarReader->get_events() + * + * @param string $url (default: '') + * @return array + */ +function icalendar_get_events( $url = '', $count = 5 ) { + // Find your calendar's address http://support.google.com/calendar/bin/answer.py?hl=en&answer=37103 + $ical = new iCalendarReader(); + return $ical->get_events( $url, $count ); +} + +/** + * Wrapper function for iCalendarReader->render() + * + * @param string $url (default: '') + * @param string $context (default: 'widget') or 'shortcode' + * @return mixed bool|string false on failure, rendered HTML string on success. + */ +function icalendar_render_events( $url = '', $args = array() ) { + $ical = new iCalendarReader(); + return $ical->render( $url, $args ); +} diff --git a/plugins/jetpack/_inc/lib/jetpack-wpes-query-builder/jetpack-wpes-query-builder.php b/plugins/jetpack/_inc/lib/jetpack-wpes-query-builder/jetpack-wpes-query-builder.php new file mode 100644 index 00000000..d3481ce5 --- /dev/null +++ b/plugins/jetpack/_inc/lib/jetpack-wpes-query-builder/jetpack-wpes-query-builder.php @@ -0,0 +1,341 @@ +<?php + + +/** + * Provides an interface for easily building a complex search query that + * combines multiple ranking signals. + * + * + * $bldr = new Jetpack_WPES_Query_Builder(); + * $bldr->add_filter( ... ); + * $bldr->add_filter( ... ); + * $bldr->add_query( ... ); + * $es_query = $bldr->build_query(); + * + * + * All ES queries take a standard form with main query (with some filters), + * wrapped in a function_score + * + * Bucketed queries use an aggregation to diversify results. eg a bunch + * of separate filters where to get different sets of results. + * + */ + +class Jetpack_WPES_Query_Builder { + + protected $es_filters = array(); + + // Custom boosting with function_score + protected $functions = array(); + protected $decays = array(); + protected $scripts = array(); + protected $functions_max_boost = 2.0; + protected $functions_score_mode = 'multiply'; + protected $query_bool_boost = null; + + // General aggregations for buckets and metrics + protected $aggs_query = false; + protected $aggs = array(); + + // The set of top level text queries to combine + protected $must_queries = array(); + protected $should_queries = array(); + protected $dis_max_queries = array(); + + protected $diverse_buckets_query = false; + protected $bucket_filters = array(); + protected $bucket_sub_aggs = array(); + + //////////////////////////////////// + // Methods for building a query + + public function add_filter( $filter ) { + $this->es_filters[] = $filter; + } + + public function add_query( $query, $type = 'must' ) { + switch ( $type ) { + case 'dis_max': + $this->dis_max_queries[] = $query; + break; + + case 'should': + $this->should_queries[] = $query; + break; + + case 'must': + default: + $this->must_queries[] = $query; + break; + } + } + + /** + * Add a scoring function to the query + * + * NOTE: For decays (linear, exp, or gauss), use Jetpack_WPES_Query_Builder::add_decay() instead + * + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html + * + * @param $function string name of the function + * @param $params array functions parameters + * + * @return void + */ + public function add_function( $function, $params ) { + $this->functions[ $function ][] = $params; + } + + /** + * Add a decay function to score results + * + * This method should be used instead of Jetpack_WPES_Query_Builder::add_function() for decays, as the internal ES structure + * is slightly different for them. + * + * @see https://www.elastic.co/guide/en/elasticsearch/guide/current/decay-functions.html + * + * @param $function string name of the decay function - linear, exp, or gauss + * @param $params array The decay functions parameters, passed to ES directly + * + * @return void + */ + public function add_decay( $function, $params ) { + $this->decays[ $function ][] = $params; + } + + /** + * Add a scoring mode to the query + * + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html + * + * @param $mode string name of how to score + * + * @return void + */ + public function add_score_mode_to_functions( $mode='multiply' ) { + $this->functions_score_mode = $mode; + } + + public function add_max_boost_to_functions( $boost ) { + $this->functions_max_boost = $boost; + } + + public function add_boost_to_query_bool( $boost ) { + $this->query_bool_boost = $boost; + } + + public function add_aggs( $aggs_name, $aggs ) { + $this->aggs_query = true; + $this->aggs[$aggs_name] = $aggs; + } + + public function add_aggs_sub_aggs( $aggs_name, $sub_aggs ) { + if ( ! array_key_exists( 'aggs', $this->aggs[$aggs_name] ) ) { + $this->aggs[$aggs_name]['aggs'] = array(); + } + $this->aggs[$aggs_name]['aggs'] = $sub_aggs; + } + + public function add_bucketed_query( $name, $query ) { + $this->_add_bucket_filter( $name, $query ); + + $this->add_query( $query, 'dis_max' ); + } + + public function add_bucketed_terms( $name, $field, $terms, $boost = 1 ) { + if ( ! is_array( $terms ) ) { + $terms = array( $terms ); + } + + $this->_add_bucket_filter( $name, array( + 'terms' => array( + $field => $terms, + ), + )); + + $this->add_query( array( + 'constant_score' => array( + 'filter' => array( + 'terms' => array( + $field => $terms, + ), + ), + 'boost' => $boost, + ), + ), 'dis_max' ); + } + + public function add_bucket_sub_aggs( $agg ) { + $this->bucket_sub_aggs = array_merge( $this->bucket_sub_aggs, $agg ); + } + + protected function _add_bucket_filter( $name, $filter ) { + $this->diverse_buckets_query = true; + $this->bucket_filters[ $name ] = $filter; + } + + //////////////////////////////////// + // Building Final Query + + /** + * Combine all the queries, functions, decays, scripts, and max_boost into an ES query + * + * @return array Array representation of the built ES query + */ + public function build_query() { + $query = array(); + + //dis_max queries just become a single must query + if ( ! empty( $this->dis_max_queries ) ) { + $this->must_queries[] = array( + 'dis_max' => array( + 'queries' => $this->dis_max_queries, + ), + ); + } + + if ( empty( $this->must_queries ) ) { + $this->must_queries = array( + array( + 'match_all' => array(), + ), + ); + } + + if ( empty( $this->should_queries ) ) { + if ( 1 == count( $this->must_queries ) ) { + $query = $this->must_queries[0]; + } else { + $query = array( + 'bool' => array( + 'must' => $this->must_queries, + ), + ); + } + } else { + $query = array( + 'bool' => array( + 'must' => $this->must_queries, + 'should' => $this->should_queries, + ), + ); + } + + if ( ! is_null( $this->query_bool_boost ) && isset( $query['bool'] ) ) { + $query['bool']['boost'] = $this->query_bool_boost; + } + + // If there are any function score adjustments, then combine those + if ( $this->functions || $this->decays || $this->scripts ) { + $weighting_functions = array(); + + if ( $this->functions ) { + foreach ( $this->functions as $function_type => $configs ) { + foreach ( $configs as $config ) { + foreach ( $config as $field => $params ) { + $func_arr = $params; + + $func_arr['field'] = $field; + + $weighting_functions[] = array( + $function_type => $func_arr, + ); + } + } + } + } + + if ( $this->decays ) { + foreach ( $this->decays as $decay_type => $configs ) { + foreach ( $configs as $config ) { + foreach ( $config as $field => $params ) { + $weighting_functions[] = array( + $decay_type => array( + $field => $params, + ), + ); + } + } + } + } + + if ( $this->scripts ) { + foreach ( $this->scripts as $script ) { + $weighting_functions[] = array( + 'script_score' => array( + 'script' => $script, + ), + ); + } + } + + $query = array( + 'function_score' => array( + 'query' => $query, + 'functions' => $weighting_functions, + 'max_boost' => $this->functions_max_boost, + 'score_mode' => $this->functions_score_mode, + ), + ); + } // End if(). + + return $query; + } + + /** + * Assemble the 'filter' portion of an ES query, from all registered filters + * + * @return array|null Combined ES filters, or null if none have been defined + */ + public function build_filter() { + if ( empty( $this->es_filters ) ) { + $filter = null; + } elseif ( 1 == count( $this->es_filters ) ) { + $filter = $this->es_filters[0]; + } else { + $filter = array( + 'and' => $this->es_filters, + ); + } + + return $filter; + } + + /** + * Assemble the 'aggregation' portion of an ES query, from all general aggregations. + * + * @return array An aggregation query as an array of topics, filters, and bucket names + */ + public function build_aggregation() { + if ( empty( $this->bucket_sub_aggs ) && empty( $this->aggs_query ) ) { + return array(); + } + + if ( ! $this->diverse_buckets_query && empty( $this->aggs_query ) ) { + return $this->bucket_sub_aggs; + } + + $aggregations = array( + 'topics' => array( + 'filters' => array( + 'filters' => array(), + ), + ), + ); + + if ( ! empty( $this->bucket_sub_aggs ) ) { + $aggregations['topics']['aggs'] = $this->bucket_sub_aggs; + } + + foreach ( $this->bucket_filters as $bucket_name => $filter ) { + $aggregations['topics']['filters']['filters'][ $bucket_name ] = $filter; + } + + if ( ! empty( $this->aggs_query ) ) { + $aggregations = $this->aggs; + } + + return $aggregations; + } + +} diff --git a/plugins/jetpack/_inc/lib/jetpack-wpes-query-builder/jetpack-wpes-query-parser.php b/plugins/jetpack/_inc/lib/jetpack-wpes-query-builder/jetpack-wpes-query-parser.php new file mode 100644 index 00000000..2b7710cb --- /dev/null +++ b/plugins/jetpack/_inc/lib/jetpack-wpes-query-builder/jetpack-wpes-query-parser.php @@ -0,0 +1,683 @@ +<?php + +/** + * Parse a pure text query into WordPress Elasticsearch query. This builds on + * the Jetpack_WPES_Query_Builder() to provide search query parsing. + * + * The key part of this parser is taking a user's query string typed into a box + * and converting it into an ES search query. + * + * This varies by application, but roughly it means extracting some parts of the query + * (authors, tags, and phrases) that are treated as a filter. Then taking the + * remaining words and building the correct query (possibly with prefix searching + * if we are doing search as you type) + * + * This class only supports ES 2.x+ + * + * This parser builds queries of the form: + * bool: + * must: + * AND match of a single field (ideally an edgengram field) + * filter: + * filter clauses from context (eg @gibrown, #news, etc) + * should: + * boosting of results by various fields + * + * Features supported: + * - search as you type + * - phrases + * - supports querying across multiple languages at once + * + * Example usage (from Search on Reader Manage): + * + * require_lib( 'jetpack-wpes-query-builder/jetpack-wpes-search-query-parser' ); + * $parser = new WPES_Search_Query_Parser( $args['q'], array( $lang ) ); + * + * //author + * $parser->author_field_filter( array( + * 'prefixes' => array( '@' ), + * 'wpcom_id_field' => 'author_id', + * 'must_query_fields' => array( 'author.engram', 'author_login.engram' ), + * 'boost_query_fields' => array( 'author^2', 'author_login^2', 'title.default.engram' ), + * ) ); + * + * //remainder of query + * $match_content_fields = $parser->merge_ml_fields( + * array( + * 'all_content' => 0.1, + * ), + * array( + * 'all_content.default.engram^0.1', + * ) + * ); + * $boost_content_fields = $parser->merge_ml_fields( + * array( + * 'title' => 2, + * 'description' => 1, + * 'tags' => 1, + * ), + * array( + * 'author_login^2', + * 'author^2', + * ) + * ); + * + * $parser->phrase_filter( array( + * 'must_query_fields' => $match_content_fields, + * 'boost_query_fields' => $boost_content_fields, + * ) ); + * $parser->remaining_query( array( + * 'must_query_fields' => $match_content_fields, + * 'boost_query_fields' => $boost_content_fields, + * ) ); + * + * //Boost on phrases + * $parser->remaining_query( array( + * 'boost_query_fields' => $boost_content_fields, + * 'boost_query_type' => 'phrase', + * ) ); + * + * //boosting + * $parser->add_max_boost_to_functions( 20 ); + * $parser->add_function( 'field_value_factor', array( + * 'follower_count' => array( + * 'modifier' => 'sqrt', + * 'factor' => 1, + * 'missing' => 0, + * ) ) ); + * + * //Filtering + * $parser->add_filter( array( + * 'exists' => array( 'field' => 'langs.' . $lang ) + * ) ); + * + * //run the query + * $es_query_args = array( + * 'name' => 'feeds', + * 'blog_id' => false, + * 'security_strategy' => 'a8c', + * 'type' => 'feed,blog', + * 'fields' => array( 'blog_id', 'feed_id' ), + * 'query' => $parser->build_query(), + * 'filter' => $parser->build_filter(), + * 'size' => $size, + * 'from' => $from + * ); + * $es_results = es_api_search_index( $es_query_args, 'api-feed-find' ); + * + */ + +jetpack_require_lib( 'jetpack-wpes-query-builder' ); + +class Jetpack_WPES_Search_Query_Parser extends Jetpack_WPES_Query_Builder { + + protected $orig_query = ''; + protected $current_query = ''; + protected $langs; + protected $avail_langs = array( 'ar', 'bg', 'ca', 'cs', 'da', 'de', 'el', 'en', 'es', 'eu', 'fa', 'fi', 'fr', 'he', 'hi', 'hu', 'hy', 'id', 'it', 'ja', 'ko', 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' ); + + public function __construct( $user_query, $langs ) { + $this->orig_query = $user_query; + $this->current_query = $this->orig_query; + $this->langs = $this->norm_langs( $langs ); + } + + protected $extracted_phrases = array(); + + /////////////////////////////////////////////////////// + // Methods for Building arrays of multilingual fields + + /* + * Normalize language codes + */ + public function norm_langs( $langs ) { + $lst = array(); + foreach( $langs as $l ) { + $l = strtok( $l, '-_' ); + if ( in_array( $l, $this->avail_langs ) ) { + $lst[$l] = true; + } else { + $lst['default'] = true; + } + } + return array_keys( $lst ); + } + + /* + * Take a list of field prefixes and expand them for multi-lingual + * with the provided boostings. + */ + public function merge_ml_fields( $fields2boosts, $additional_fields ) { + $flds = array(); + foreach( $fields2boosts as $f => $b ) { + foreach( $this->langs as $l ) { + $flds[] = $f . '.' . $l . '^' . $b; + } + } + foreach( $additional_fields as $f ) { + $flds[] = $f; + } + return $flds; + } + + //////////////////////////////////// + // Extract Fields for Filtering on + + /* + * Extract any @mentions from the user query + * use them as a filter if we can find a wp.com id + * otherwise use them as a + * + * args: + * wpcom_id_field: wp.com id field + * must_query_fields: array of fields to search for matching results (optional) + * boost_query_fields: array of fields to search in for boosting results (optional) + * prefixes: array of prefixes that the user can use to indicate an author + * + * returns true/false of whether any were found + * + * See also: https://github.com/twitter/twitter-text/blob/master/java/src/com/twitter/Regex.java + */ + public function author_field_filter( $args ) { + $defaults = array( + 'wpcom_id_field' => 'author_id', + 'must_query_fields' => null, + 'boost_query_fields' => null, + 'prefixes' => array( '@' ), + ); + $args = wp_parse_args( $args, $defaults ); + + $names = array(); + foreach( $args['prefixes'] as $p ) { + $found = $this->get_fields( $p ); + if ( $found ) { + foreach( $found as $f ) { + $names[] = $f; + } + } + } + + if ( empty( $names ) ) { + return false; + } + + foreach( $args['prefixes'] as $p ) { + $this->remove_fields( $p ); + } + + $user_ids = array(); + $query_names = array(); + + //loop through the matches and separate into filters and queries + foreach( $names as $n ) { + //check for exact match on login + $userdata = get_user_by( 'login', strtolower( $n ) ); + $filtering = false; + if ( $userdata ) { + $user_ids[ $userdata->ID ] = true; + $filtering = true; + } + + $is_phrase = false; + if ( preg_match( '/"/', $n ) ) { + $is_phrase = true; + $n = preg_replace( '/"/', '', $n ); + } + + if ( !empty( $args['must_query_fields'] ) && !$filtering ) { + if ( $is_phrase ) { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['must_query_fields'], + 'query' => $n, + 'type' => 'phrase', + ) ) ); + } else { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['must_query_fields'], + 'query' => $n, + ) ) ); + } + } + + if ( !empty( $args['boost_query_fields'] ) ) { + if ( $is_phrase ) { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['boost_query_fields'], + 'query' => $n, + 'type' => 'phrase', + ) ), 'should' ); + } else { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['boost_query_fields'], + 'query' => $n, + ) ), 'should' ); + } + } + } + + if ( ! empty( $user_ids ) ) { + $user_ids = array_keys( $user_ids ); + $this->add_filter( array( 'terms' => array( $args['wpcom_id_field'] => $user_ids ) ) ); + } + + return true; + } + + /* + * Extract any prefix followed by text use them as a must clause, + * and optionally as a boost to the should query + * This can be used for hashtags. eg #News, or #"current events", + * but also works for any arbitrary field. eg from:Greg + * + * args: + * must_query_fields: array of fields that must match the tag (optional) + * boost_query_fields: array of fields to boost search on (optional) + * prefixes: array of prefixes that the user can use to indicate a tag + * + * returns true/false of whether any were found + * + */ + public function text_field_filter( $args ) { + $defaults = array( + 'must_query_fields' => array( 'tag.name' ), + 'boost_query_fields' => array( 'tag.name' ), + 'prefixes' => array( '#' ), + ); + $args = wp_parse_args( $args, $defaults ); + + $tags = array(); + foreach( $args['prefixes'] as $p ) { + $found = $this->get_fields( $p ); + if ( $found ) { + foreach( $found as $f ) { + $tags[] = $f; + } + } + } + + if ( empty( $tags ) ) { + return false; + } + + foreach( $args['prefixes'] as $p ) { + $this->remove_fields( $p ); + } + + foreach( $tags as $t ) { + $is_phrase = false; + if ( preg_match( '/"/', $t ) ) { + $is_phrase = true; + $t = preg_replace( '/"/', '', $t ); + } + + if ( ! empty( $args['must_query_fields'] ) ) { + if ( $is_phrase ) { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['must_query_fields'], + 'query' => $t, + 'type' => 'phrase', + ) ) ); + } else { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['must_query_fields'], + 'query' => $t, + ) ) ); + } + } + + if ( ! empty( $args['boost_query_fields'] ) ) { + if ( $is_phrase ) { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['boost_query_fields'], + 'query' => $t, + 'type' => 'phrase', + ) ), 'should' ); + } else { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['boost_query_fields'], + 'query' => $t, + ) ), 'should' ); + } + } + } + + return true; + } + + /* + * Extract anything surrounded by quotes or if there is an opening quote + * that is not complete, and add them to the query as a phrase query. + * Quotes can be either '' or "" + * + * args: + * must_query_fields: array of fields that must match the phrases + * boost_query_fields: array of fields to boost the phrases on (optional) + * + * returns true/false of whether any were found + * + */ + public function phrase_filter( $args ) { + $defaults = array( + 'must_query_fields' => array( 'all_content' ), + 'boost_query_fields' => array( 'title' ), + ); + $args = wp_parse_args( $args, $defaults ); + + $phrases = array(); + if ( preg_match_all( '/"([^"]+)"/', $this->current_query, $matches ) ) { + foreach ( $matches[1] as $match ) { + $phrases[] = $match; + } + $this->current_query = preg_replace( '/"([^"]+)"/', '', $this->current_query ); + } + + if ( preg_match_all( "/'([^']+)'/", $this->current_query, $matches ) ) { + foreach ( $matches[1] as $match ) { + $phrases[] = $match; + } + $this->current_query = preg_replace( "/'([^']+)'/", '', $this->current_query ); + } + + //look for a final, uncompleted phrase + $phrase_prefix = false; + if ( preg_match_all( '/"([^"]+)$/', $this->current_query, $matches ) ) { + $phrase_prefix = $matches[1][0]; + $this->current_query = preg_replace( '/"([^"]+)$/', '', $this->current_query ); + } + if ( preg_match_all( "/(?:'\B|\B')([^']+)$/", $this->current_query, $matches ) ) { + $phrase_prefix = $matches[1][0]; + $this->current_query = preg_replace( "/(?:'\B|\B')([^']+)$/", '', $this->current_query ); + } + + if ( $phrase_prefix ) { + $phrases[] = $phrase_prefix; + } + if ( empty( $phrases ) ) { + return false; + } + + foreach ( $phrases as $p ) { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['must_query_fields'], + 'query' => $p, + 'type' => 'phrase', + ) ) ); + + if ( ! empty( $args['boost_query_fields'] ) ) { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['boost_query_fields'], + 'query' => $p, + 'operator' => 'and', + ) ), 'should' ); + } + } + + return true; + } + + /* + * Query fields based on the remaining parts of the query + * This could be the final AND part of the query terms to match, or it + * could be boosting certain elements of the query + * + * args: + * must_query_fields: array of fields that must match the remaining terms (optional) + * boost_query_fields: array of fields to boost the remaining terms on (optional) + * + */ + public function remaining_query( $args ) { + $defaults = array( + 'must_query_fields' => null, + 'boost_query_fields' => null, + 'boost_operator' => 'and', + 'boost_query_type' => 'best_fields', + ); + $args = wp_parse_args( $args, $defaults ); + + if ( empty( $this->current_query ) || ctype_space( $this->current_query ) ) { + return; + } + + if ( ! empty( $args['must_query_fields'] ) ) { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['must_query_fields'], + 'query' => $this->current_query, + 'operator' => 'and', + ) ) ); + } + + if ( ! empty( $args['boost_query_fields'] ) ) { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['boost_query_fields'], + 'query' => $this->current_query, + 'operator' => $args['boost_operator'], + 'type' => $args['boost_query_type'], + ) ), 'should' ); + } + + } + + /* + * Query fields using a prefix query (alphabetical expansions on the index). + * This is not recommended. Slower performance and worse relevancy. + * + * (UNTESTED! Copied from old prefix expansion code) + * + * args: + * must_query_fields: array of fields that must match the remaining terms (optional) + * boost_query_fields: array of fields to boost the remaining terms on (optional) + * + */ + public function remaining_prefix_query( $args ) { + $defaults = array( + 'must_query_fields' => array( 'all_content' ), + 'boost_query_fields' => array( 'title' ), + 'boost_operator' => 'and', + 'boost_query_type' => 'best_fields', + ); + $args = wp_parse_args( $args, $defaults ); + + if ( empty( $this->current_query ) || ctype_space( $this->current_query ) ) { + return; + } + + ////////////////////////////////// + // Example cases to think about: + // "elasticse" + // "elasticsearch" + // "elasticsearch " + // "elasticsearch lucen" + // "elasticsearch lucene" + // "the future" - note the stopword which will match nothing! + // "F1" - an exact match that also has tons of expansions + // "こんにちは" ja "hello" + // "こんにちは友人" ja "hello friend" - we just rely on the prefix phrase and ES to split words + // - this could still be better I bet. Maybe we need to analyze with ES first? + // + + ///////////////////////////// + //extract pieces of query + // eg: "PREFIXREMAINDER PREFIXWORD" + // "elasticsearch lucen" + + $prefix_word = false; + $prefix_remainder = false; + if ( preg_match_all( '/([^ ]+)$/', $this->current_query, $matches ) ) { + $prefix_word = $matches[1][0]; + } + + $prefix_remainder = preg_replace( '/([^ ]+)$/', '', $this->current_query ); + if ( ctype_space( $prefix_remainder ) ) { + $prefix_remainder = false; + } + + if ( ! $prefix_word ) { + //Space at the end of the query, so skip using a prefix query + if ( ! empty( $args['must_query_fields'] ) ) { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['must_query_fields'], + 'query' => $this->current_query, + 'operator' => 'and', + ) ) ); + } + + if ( ! empty( $args['boost_query_fields'] ) ) { + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['boost_query_fields'], + 'query' => $this->current_query, + 'operator' => $args['boost_operator'], + 'type' => $args['boost_query_type'], + ) ), 'should' ); + } + } else { + + //must match the prefix word and the prefix remainder + if ( ! empty( $args['must_query_fields'] ) ) { + //need to do an OR across a few fields to handle all cases + $must_q = array( 'bool' => array( 'should' => array( ), 'minimum_should_match' => 1 ) ); + + //treat all words as an exact search (boosts complete word like "news" + //from prefixes of "newspaper") + $must_q['bool']['should'][] = array( 'multi_match' => array( + 'fields' => $this->all_fields, + 'query' => $full_text, + 'operator' => 'and', + 'type' => 'cross_fields', + ) ); + + //always optimistically try and match the full text as a phrase + //prefix "the futu" should try to match "the future" + //otherwise the first stopword kinda breaks + //This also works as the prefix match for a single word "elasticsea" + $must_q['bool']['should'][] = array( 'multi_match' => array( + 'fields' => $this->phrase_fields, + 'query' => $full_text, + 'operator' => 'and', + 'type' => 'phrase_prefix', + 'max_expansions' => 100, + ) ); + + if ( $prefix_remainder ) { + //Multiple words found, so treat each word on its own and not just as + //a part of a phrase + //"elasticsearch lucen" => "elasticsearch" exact AND "lucen" prefix + $q['bool']['should'][] = array( 'bool' => array( + 'must' => array( + array( 'multi_match' => array( + 'fields' => $this->phrase_fields, + 'query' => $prefix_word, + 'operator' => 'and', + 'type' => 'phrase_prefix', + 'max_expansions' => 100, + ) ), + array( 'multi_match' => array( + 'fields' => $this->all_fields, + 'query' => $prefix_remainder, + 'operator' => 'and', + 'type' => 'cross_fields', + ) ), + ) + ) ); + } + + $this->add_query( $must_q ); + } + + //Now add any boosting of the query + if ( ! empty( $args['boost_query_fields'] ) ) { + //treat all words as an exact search (boosts complete word like "news" + //from prefixes of "newspaper") + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['boost_query_fields'], + 'query' => $this->current_query, + 'operator' => $args['boost_query_operator'], + 'type' => $args['boost_query_type'], + ) ), 'should' ); + + //optimistically boost the full phrase prefix match + $this->add_query( array( + 'multi_match' => array( + 'fields' => $args['boost_query_fields'], + 'query' => $this->current_query, + 'operator' => 'and', + 'type' => 'phrase_prefix', + 'max_expansions' => 100, + ) ) ); + } + } + } + + /* + * Boost results based on the lang probability overlaps + * + * args: + * langs2prob: list of languages to search in with associated boosts + */ + public function boost_lang_probs( $langs2prob ) { + foreach( $langs2prob as $l => $p ) { + $this->add_function( 'field_value_factor', array( + 'modifier' => 'none', + 'factor' => $p, + 'missing' => 0.01, //1% chance doc did not have right lang detected + ) ); + } + } + + //////////////////////////////////// + // Helper Methods + + //Get the text after some prefix. eg @gibrown, or @"Greg Brown" + protected function get_fields( $field_prefix ) { + $regex = '/' . $field_prefix . '(("[^"]+")|([^\\p{Z}]+))/'; + if ( preg_match_all( $regex, $this->current_query, $match ) ) { + return $match[1]; + } + return false; + } + + //Remove the prefix and text from the query + protected function remove_fields( $field_name ) { + $regex = '/' . $field_name . '(("[^"]+")|([^\\p{Z}]+))/'; + $this->current_query = preg_replace( $regex, '', $this->current_query ); + } + + //Best effort string truncation that splits on word breaks + protected function truncate_string( $string, $limit, $break=" " ) { + if ( mb_strwidth( $string ) <= $limit ) { + return $string; + } + + // walk backwards from $limit to find first break + $breakpoint = $limit; + $broken = false; + while ( $breakpoint > 0 ) { + if ( $break === mb_strimwidth( $string, $breakpoint, 1 ) ) { + $string = mb_strimwidth( $string, 0, $breakpoint ); + $broken = true; + break; + } + $breakpoint--; + } + // if we weren't able to find a break, need to chop mid-word + if ( !$broken ) { + $string = mb_strimwidth( $string, 0, $limit ); + } + return $string; + } + +} diff --git a/plugins/jetpack/_inc/lib/markdown/0-load.php b/plugins/jetpack/_inc/lib/markdown/0-load.php new file mode 100644 index 00000000..bf5993e3 --- /dev/null +++ b/plugins/jetpack/_inc/lib/markdown/0-load.php @@ -0,0 +1,6 @@ +<?php + +if ( ! class_exists( 'MarkdownExtra_Parser' ) ) + jetpack_require_lib( 'markdown/extra' ); + +jetpack_require_lib( 'markdown/gfm' ); diff --git a/plugins/jetpack/_inc/lib/markdown/README.md b/plugins/jetpack/_inc/lib/markdown/README.md new file mode 100644 index 00000000..45f298d1 --- /dev/null +++ b/plugins/jetpack/_inc/lib/markdown/README.md @@ -0,0 +1,19 @@ +# Markdown parsing library + +Contains two libraries: + +* `/extra` + - Gives you `MardownExtra_Parser` and `Markdown_Parser` + - Docs at http://michelf.ca/projects/php-markdown/extra/ + +* `/gfm` -- Github Flavored Markdown + - Gives you `WPCom_GHF_Markdown_Parser` + - It has the same interface as `MarkdownExtra_Parser` + - Adds support for fenced code blocks: https://help.github.com/articles/creating-and-highlighting-code-blocks/#fenced-code-blocks + - By default it replaces them with a code shortcode + - You can change this using the `$use_code_shortcode` member variable + - You can change the code shortcode wrapping with `$shortcode_start` and `$shortcode_end` member variables + - The `$preserve_shortcodes` member variable will preserve all registered shortcodes untouched. Requires WordPress to be loaded for `get_shortcode_regex()` + - The `$preserve_latex` member variable will preserve oldskool $latex yer-latex$ codes untouched. + - The `$strip_paras` member variable will strip <p> tags because that's what WordPress likes. + - See `WPCom_GHF_Markdown_Parser::__construct()` for how the above member variable defaults are set. diff --git a/plugins/jetpack/_inc/lib/markdown/extra.php b/plugins/jetpack/_inc/lib/markdown/extra.php new file mode 100644 index 00000000..fd85a3c8 --- /dev/null +++ b/plugins/jetpack/_inc/lib/markdown/extra.php @@ -0,0 +1,3207 @@ +<?php +# +# Markdown Extra - A text-to-HTML conversion tool for web writers +# +# PHP Markdown & Extra +# Copyright (c) 2004-2013 Michel Fortin +# <http://michelf.ca/projects/php-markdown/> +# +# Original Markdown +# Copyright (c) 2004-2006 John Gruber +# <http://daringfireball.net/projects/markdown/> +# +# Tweaked to remove WordPress interface + + +define( 'MARKDOWN_VERSION', "1.0.2" ); # 29 Nov 2013 +define( 'MARKDOWNEXTRA_VERSION', "1.2.8" ); # 29 Nov 2013 + + +# +# Global default settings: +# + +# Change to ">" for HTML output +@define( 'MARKDOWN_EMPTY_ELEMENT_SUFFIX', " />"); + +# Define the width of a tab for code blocks. +@define( 'MARKDOWN_TAB_WIDTH', 4 ); + +# Optional title attribute for footnote links and backlinks. +@define( 'MARKDOWN_FN_LINK_TITLE', "" ); +@define( 'MARKDOWN_FN_BACKLINK_TITLE', "" ); + +# Optional class attribute for footnote links and backlinks. +@define( 'MARKDOWN_FN_LINK_CLASS', "jetpack-footnote" ); +@define( 'MARKDOWN_FN_BACKLINK_CLASS', "" ); + +# Optional class prefix for fenced code block. +@define( 'MARKDOWN_CODE_CLASS_PREFIX', "language-" ); + +# Class attribute for code blocks goes on the `code` tag; +# setting this to true will put attributes on the `pre` tag instead. +@define( 'MARKDOWN_CODE_ATTR_ON_PRE', false ); + + + +### Standard Function Interface ### + +@define( 'MARKDOWN_PARSER_CLASS', 'MarkdownExtra_Parser' ); + +function Markdown($text) { +# +# Initialize the parser and return the result of its transform method. +# + # Setup static parser variable. + static $parser; + if (!isset($parser)) { + $parser_class = MARKDOWN_PARSER_CLASS; + $parser = new $parser_class; + } + + # Transform text using parser. + return $parser->transform($text); +} + +/** + * Returns the length of $text loosely counting the number of UTF-8 characters with regular expression. + * Used by the Markdown_Parser class when mb_strlen is not available. + * + * @since 5.9 + * + * @return string Length of the multibyte string + * + */ +function jetpack_utf8_strlen( $text ) { + return preg_match_all( "/[\\x00-\\xBF]|[\\xC0-\\xFF][\\x80-\\xBF]*/", $text, $m ); +} + +# +# Markdown Parser Class +# + +class Markdown_Parser { + + ### Configuration Variables ### + + # Change to ">" for HTML output. + public $empty_element_suffix = MARKDOWN_EMPTY_ELEMENT_SUFFIX; + public $tab_width = MARKDOWN_TAB_WIDTH; + + # Change to `true` to disallow markup or entities. + public $no_markup = false; + public $no_entities = false; + + # Predefined urls and titles for reference links and images. + public $predef_urls = array(); + public $predef_titles = array(); + + + ### Parser Implementation ### + + # Regex to match balanced [brackets]. + # Needed to insert a maximum bracked depth while converting to PHP. + public $nested_brackets_depth = 6; + public $nested_brackets_re; + + public $nested_url_parenthesis_depth = 4; + public $nested_url_parenthesis_re; + + # Table of hash values for escaped characters: + public $escape_chars = '\`*_{}[]()>#+-.!'; + public $escape_chars_re; + + + function __construct() { + # + # Constructor function. Initialize appropriate member variables. + # + $this->_initDetab(); + $this->prepareItalicsAndBold(); + + $this->nested_brackets_re = + str_repeat('(?>[^\[\]]+|\[', $this->nested_brackets_depth). + str_repeat('\])*', $this->nested_brackets_depth); + + $this->nested_url_parenthesis_re = + str_repeat('(?>[^()\s]+|\(', $this->nested_url_parenthesis_depth). + str_repeat('(?>\)))*', $this->nested_url_parenthesis_depth); + + $this->escape_chars_re = '['.preg_quote($this->escape_chars).']'; + + # Sort document, block, and span gamut in ascendent priority order. + asort($this->document_gamut); + asort($this->block_gamut); + asort($this->span_gamut); + } + + + # Internal hashes used during transformation. + public $urls = array(); + public $titles = array(); + public $html_hashes = array(); + + # Status flag to avoid invalid nesting. + public $in_anchor = false; + + + function setup() { + # + # Called before the transformation process starts to setup parser + # states. + # + # Clear global hashes. + $this->urls = $this->predef_urls; + $this->titles = $this->predef_titles; + $this->html_hashes = array(); + + $this->in_anchor = false; + } + + function teardown() { + # + # Called after the transformation process to clear any variable + # which may be taking up memory unnecessarly. + # + $this->urls = array(); + $this->titles = array(); + $this->html_hashes = array(); + } + + + function transform($text) { + # + # Main function. Performs some preprocessing on the input text + # and pass it through the document gamut. + # + $this->setup(); + + # Remove UTF-8 BOM and marker character in input, if present. + $text = preg_replace('{^\xEF\xBB\xBF|\x1A}', '', $text); + + # Standardize line endings: + # DOS to Unix and Mac to Unix + $text = preg_replace('{\r\n?}', "\n", $text); + + # Make sure $text ends with a couple of newlines: + $text .= "\n\n"; + + # Convert all tabs to spaces. + $text = $this->detab($text); + + # Turn block-level HTML blocks into hash entries + $text = $this->hashHTMLBlocks($text); + + # Strip any lines consisting only of spaces and tabs. + # This makes subsequent regexen easier to write, because we can + # match consecutive blank lines with /\n+/ instead of something + # contorted like /[ ]*\n+/ . + $text = preg_replace('/^[ ]+$/m', '', $text); + + # Run document gamut methods. + foreach ($this->document_gamut as $method => $priority) { + $text = $this->$method($text); + } + + $this->teardown(); + + return $text . "\n"; + } + + public $document_gamut = array( + # Strip link definitions, store in hashes. + "stripLinkDefinitions" => 20, + + "runBasicBlockGamut" => 30, + ); + + + function stripLinkDefinitions($text) { + # + # Strips link definitions from text, stores the URLs and titles in + # hash references. + # + $less_than_tab = $this->tab_width - 1; + + # Link defs are in the form: ^[id]: url "optional title" + $text = preg_replace_callback('{ + ^[ ]{0,'.$less_than_tab.'}\[(.+)\][ ]?: # id = $1 + [ ]* + \n? # maybe *one* newline + [ ]* + (?: + <(.+?)> # url = $2 + | + (\S+?) # url = $3 + ) + [ ]* + \n? # maybe one newline + [ ]* + (?: + (?<=\s) # lookbehind for whitespace + ["(] + (.*?) # title = $4 + [")] + [ ]* + )? # title is optional + (?:\n+|\Z) + }xm', + array(&$this, '_stripLinkDefinitions_callback'), + $text); + return $text; + } + function _stripLinkDefinitions_callback($matches) { + $link_id = strtolower($matches[1]); + $url = $matches[2] == '' ? $matches[3] : $matches[2]; + $this->urls[$link_id] = $url; + $this->titles[$link_id] =& $matches[4]; + return ''; # String that will replace the block + } + + + function hashHTMLBlocks($text) { + if ($this->no_markup) return $text; + + $less_than_tab = $this->tab_width - 1; + + # Hashify HTML blocks: + # We only want to do this for block-level HTML tags, such as headers, + # lists, and tables. That's because we still want to wrap <p>s around + # "paragraphs" that are wrapped in non-block-level tags, such as anchors, + # phrase emphasis, and spans. The list of tags we're looking for is + # hard-coded: + # + # * List "a" is made of tags which can be both inline or block-level. + # These will be treated block-level when the start tag is alone on + # its line, otherwise they're not matched here and will be taken as + # inline later. + # * List "b" is made of tags which are always block-level; + # + $block_tags_a_re = 'ins|del'; + $block_tags_b_re = 'p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|address|'. + 'script|noscript|form|fieldset|iframe|math|svg|'. + 'article|section|nav|aside|hgroup|header|footer|'. + 'figure'; + + # Regular expression for the content of a block tag. + $nested_tags_level = 4; + $attr = ' + (?> # optional tag attributes + \s # starts with whitespace + (?> + [^>"/]+ # text outside quotes + | + /+(?!>) # slash not followed by ">" + | + "[^"]*" # text inside double quotes (tolerate ">") + | + \'[^\']*\' # text inside single quotes (tolerate ">") + )* + )? + '; + $content = + str_repeat(' + (?> + [^<]+ # content without tag + | + <\2 # nested opening tag + '.$attr.' # attributes + (?> + /> + | + >', $nested_tags_level). # end of opening tag + '.*?'. # last level nested tag content + str_repeat(' + </\2\s*> # closing nested tag + ) + | + <(?!/\2\s*> # other tags with a different name + ) + )*', + $nested_tags_level); + $content2 = str_replace('\2', '\3', $content); + + # First, look for nested blocks, e.g.: + # <div> + # <div> + # tags for inner block must be indented. + # </div> + # </div> + # + # The outermost tags must start at the left margin for this to match, and + # the inner nested divs must be indented. + # We need to do this before the next, more liberal match, because the next + # match will start at the first `<div>` and stop at the first `</div>`. + $text = preg_replace_callback('{(?> + (?> + (?<=\n\n) # Starting after a blank line + | # or + \A\n? # the beginning of the doc + ) + ( # save in $1 + + # Match from `\n<tag>` to `</tag>\n`, handling nested tags + # in between. + + [ ]{0,'.$less_than_tab.'} + <('.$block_tags_b_re.')# start tag = $2 + '.$attr.'> # attributes followed by > and \n + '.$content.' # content, support nesting + </\2> # the matching end tag + [ ]* # trailing spaces/tabs + (?=\n+|\Z) # followed by a newline or end of document + + | # Special version for tags of group a. + + [ ]{0,'.$less_than_tab.'} + <('.$block_tags_a_re.')# start tag = $3 + '.$attr.'>[ ]*\n # attributes followed by > + '.$content2.' # content, support nesting + </\3> # the matching end tag + [ ]* # trailing spaces/tabs + (?=\n+|\Z) # followed by a newline or end of document + + | # Special case just for <hr />. It was easier to make a special + # case than to make the other regex more complicated. + + [ ]{0,'.$less_than_tab.'} + <(hr) # start tag = $2 + '.$attr.' # attributes + /?> # the matching end tag + [ ]* + (?=\n{2,}|\Z) # followed by a blank line or end of document + + | # Special case for standalone HTML comments: + + [ ]{0,'.$less_than_tab.'} + (?s: + <!-- .*? --> + ) + [ ]* + (?=\n{2,}|\Z) # followed by a blank line or end of document + + | # PHP and ASP-style processor instructions (<? and <%) + + [ ]{0,'.$less_than_tab.'} + (?s: + <([?%]) # $2 + .*? + \2> + ) + [ ]* + (?=\n{2,}|\Z) # followed by a blank line or end of document + + ) + )}Sxmi', + array(&$this, '_hashHTMLBlocks_callback'), + $text); + + return $text; + } + function _hashHTMLBlocks_callback($matches) { + $text = $matches[1]; + $key = $this->hashBlock($text); + return "\n\n$key\n\n"; + } + + + function hashPart($text, $boundary = 'X') { + # + # Called whenever a tag must be hashed when a function insert an atomic + # element in the text stream. Passing $text to through this function gives + # a unique text-token which will be reverted back when calling unhash. + # + # The $boundary argument specify what character should be used to surround + # the token. By convension, "B" is used for block elements that needs not + # to be wrapped into paragraph tags at the end, ":" is used for elements + # that are word separators and "X" is used in the general case. + # + # Swap back any tag hash found in $text so we do not have to `unhash` + # multiple times at the end. + $text = $this->unhash($text); + + # Then hash the block. + static $i = 0; + $key = "$boundary\x1A" . ++$i . $boundary; + $this->html_hashes[$key] = $text; + return $key; # String that will replace the tag. + } + + + function hashBlock($text) { + # + # Shortcut function for hashPart with block-level boundaries. + # + return $this->hashPart($text, 'B'); + } + + + public $block_gamut = array( + # + # These are all the transformations that form block-level + # tags like paragraphs, headers, and list items. + # + "doHeaders" => 10, + "doHorizontalRules" => 20, + + "doLists" => 40, + "doCodeBlocks" => 50, + "doBlockQuotes" => 60, + ); + + function runBlockGamut($text) { + # + # Run block gamut tranformations. + # + # We need to escape raw HTML in Markdown source before doing anything + # else. This need to be done for each block, and not only at the + # beginning in the Markdown function since hashed blocks can be part of + # list items and could have been indented. Indented blocks would have + # been seen as a code block in a previous pass of hashHTMLBlocks. + $text = $this->hashHTMLBlocks($text); + + return $this->runBasicBlockGamut($text); + } + + function runBasicBlockGamut($text) { + # + # Run block gamut tranformations, without hashing HTML blocks. This is + # useful when HTML blocks are known to be already hashed, like in the first + # whole-document pass. + # + foreach ($this->block_gamut as $method => $priority) { + $text = $this->$method($text); + } + + # Finally form paragraph and restore hashed blocks. + $text = $this->formParagraphs($text); + + return $text; + } + + + function doHorizontalRules($text) { + # Do Horizontal Rules: + return preg_replace( + '{ + ^[ ]{0,3} # Leading space + ([-*_]) # $1: First marker + (?> # Repeated marker group + [ ]{0,2} # Zero, one, or two spaces. + \1 # Marker character + ){2,} # Group repeated at least twice + [ ]* # Tailing spaces + $ # End of line. + }mx', + "\n".$this->hashBlock("<hr$this->empty_element_suffix")."\n", + $text); + } + + + public $span_gamut = array( + # + # These are all the transformations that occur *within* block-level + # tags like paragraphs, headers, and list items. + # + # Process character escapes, code spans, and inline HTML + # in one shot. + "parseSpan" => -30, + + # Process anchor and image tags. Images must come first, + # because ![foo][f] looks like an anchor. + "doImages" => 10, + "doAnchors" => 20, + + # Make links out of things like `<http://example.com/>` + # Must come after doAnchors, because you can use < and > + # delimiters in inline links like [this](<url>). + "doAutoLinks" => 30, + "encodeAmpsAndAngles" => 40, + + "doItalicsAndBold" => 50, + "doHardBreaks" => 60, + ); + + function runSpanGamut($text) { + # + # Run span gamut tranformations. + # + foreach ($this->span_gamut as $method => $priority) { + $text = $this->$method($text); + } + + return $text; + } + + + function doHardBreaks($text) { + # Do hard breaks: + return preg_replace_callback('/ {2,}\n/', + array(&$this, '_doHardBreaks_callback'), $text); + } + function _doHardBreaks_callback($matches) { + return $this->hashPart("<br$this->empty_element_suffix\n"); + } + + + function doAnchors($text) { + # + # Turn Markdown link shortcuts into XHTML <a> tags. + # + if ($this->in_anchor) return $text; + $this->in_anchor = true; + + # + # First, handle reference-style links: [link text] [id] + # + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + ('.$this->nested_brackets_re.') # link text = $2 + \] + + [ ]? # one optional space + (?:\n[ ]*)? # one optional newline followed by spaces + + \[ + (.*?) # id = $3 + \] + ) + }xs', + array(&$this, '_doAnchors_reference_callback'), $text); + + # + # Next, inline-style links: [link text](url "optional title") + # + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + ('.$this->nested_brackets_re.') # link text = $2 + \] + \( # literal paren + [ \n]* + (?: + <(.+?)> # href = $3 + | + ('.$this->nested_url_parenthesis_re.') # href = $4 + ) + [ \n]* + ( # $5 + ([\'"]) # quote char = $6 + (.*?) # Title = $7 + \6 # matching quote + [ \n]* # ignore any spaces/tabs between closing quote and ) + )? # title is optional + \) + ) + }xs', + array(&$this, '_doAnchors_inline_callback'), $text); + + # + # Last, handle reference-style shortcuts: [link text] + # These must come last in case you've also got [link text][1] + # or [link text](/foo) + # + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + ([^\[\]]+) # link text = $2; can\'t contain [ or ] + \] + ) + }xs', + array(&$this, '_doAnchors_reference_callback'), $text); + + $this->in_anchor = false; + return $text; + } + function _doAnchors_reference_callback($matches) { + $whole_match = $matches[1]; + $link_text = $matches[2]; + $link_id =& $matches[3]; + + if ($link_id == "") { + # for shortcut links like [this][] or [this]. + $link_id = $link_text; + } + + # lower-case and turn embedded newlines into spaces + $link_id = strtolower($link_id); + $link_id = preg_replace('{[ ]?\n}', ' ', $link_id); + + if (isset($this->urls[$link_id])) { + $url = $this->urls[$link_id]; + $url = $this->encodeAttribute($url); + + $result = "<a href=\"$url\""; + if ( isset( $this->titles[$link_id] ) ) { + $title = $this->titles[$link_id]; + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; + } + + $link_text = $this->runSpanGamut($link_text); + $result .= ">$link_text</a>"; + $result = $this->hashPart($result); + } + else { + $result = $whole_match; + } + return $result; + } + function _doAnchors_inline_callback($matches) { + $whole_match = $matches[1]; + $link_text = $this->runSpanGamut($matches[2]); + $url = $matches[3] == '' ? $matches[4] : $matches[3]; + $title =& $matches[7]; + + $url = $this->encodeAttribute($url); + + $result = "<a href=\"$url\""; + if (isset($title)) { + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; + } + + $link_text = $this->runSpanGamut($link_text); + $result .= ">$link_text</a>"; + + return $this->hashPart($result); + } + + + function doImages($text) { + # + # Turn Markdown image shortcuts into <img> tags. + # + # + # First, handle reference-style labeled images: ![alt text][id] + # + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + !\[ + ('.$this->nested_brackets_re.') # alt text = $2 + \] + + [ ]? # one optional space + (?:\n[ ]*)? # one optional newline followed by spaces + + \[ + (.*?) # id = $3 + \] + + ) + }xs', + array(&$this, '_doImages_reference_callback'), $text); + + # + # Next, handle inline images: ![alt text](url "optional title") + # Don't forget: encode * and _ + # + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + !\[ + ('.$this->nested_brackets_re.') # alt text = $2 + \] + \s? # One optional whitespace character + \( # literal paren + [ \n]* + (?: + <(\S*)> # src url = $3 + | + ('.$this->nested_url_parenthesis_re.') # src url = $4 + ) + [ \n]* + ( # $5 + ([\'"]) # quote char = $6 + (.*?) # title = $7 + \6 # matching quote + [ \n]* + )? # title is optional + \) + ) + }xs', + array(&$this, '_doImages_inline_callback'), $text); + + return $text; + } + function _doImages_reference_callback($matches) { + $whole_match = $matches[1]; + $alt_text = $matches[2]; + $link_id = strtolower($matches[3]); + + if ($link_id == "") { + $link_id = strtolower($alt_text); # for shortcut links like ![this][]. + } + + $alt_text = $this->encodeAttribute($alt_text); + if (isset($this->urls[$link_id])) { + $url = $this->encodeAttribute($this->urls[$link_id]); + $result = "<img src=\"$url\" alt=\"$alt_text\""; + if (isset($this->titles[$link_id])) { + $title = $this->titles[$link_id]; + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; + } + $result .= $this->empty_element_suffix; + $result = $this->hashPart($result); + } + else { + # If there's no such link ID, leave intact: + $result = $whole_match; + } + + return $result; + } + function _doImages_inline_callback($matches) { + $whole_match = $matches[1]; + $alt_text = $matches[2]; + $url = $matches[3] == '' ? $matches[4] : $matches[3]; + $title =& $matches[7]; + + $alt_text = $this->encodeAttribute($alt_text); + $url = $this->encodeAttribute($url); + $result = "<img src=\"$url\" alt=\"$alt_text\""; + if (isset($title)) { + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; # $title already quoted + } + $result .= $this->empty_element_suffix; + + return $this->hashPart($result); + } + + + function doHeaders($text) { + # Setext-style headers: + # Header 1 + # ======== + # + # Header 2 + # -------- + # + $text = preg_replace_callback('{ ^(.+?)[ ]*\n(=+|-+)[ ]*\n+ }mx', + array(&$this, '_doHeaders_callback_setext'), $text); + + # atx-style headers: + # # Header 1 + # ## Header 2 + # ## Header 2 with closing hashes ## + # ... + # ###### Header 6 + # + $text = preg_replace_callback('{ + ^(\#{1,6}) # $1 = string of #\'s + [ ]* + (.+?) # $2 = Header text + [ ]* + \#* # optional closing #\'s (not counted) + \n+ + }xm', + array(&$this, '_doHeaders_callback_atx'), $text); + + return $text; + } + function _doHeaders_callback_setext($matches) { + # Terrible hack to check we haven't found an empty list item. + if ($matches[2] == '-' && preg_match('{^-(?: |$)}', $matches[1])) + return $matches[0]; + + $level = $matches[2]{0} == '=' ? 1 : 2; + $block = "<h$level>".$this->runSpanGamut($matches[1])."</h$level>"; + return "\n" . $this->hashBlock($block) . "\n\n"; + } + function _doHeaders_callback_atx($matches) { + $level = strlen($matches[1]); + $block = "<h$level>".$this->runSpanGamut($matches[2])."</h$level>"; + return "\n" . $this->hashBlock($block) . "\n\n"; + } + + + function doLists($text) { + # + # Form HTML ordered (numbered) and unordered (bulleted) lists. + # + $less_than_tab = $this->tab_width - 1; + + # Re-usable patterns to match list item bullets and number markers: + $marker_ul_re = '[*+-]'; + $marker_ol_re = '\d+[\.]'; + $marker_any_re = "(?:$marker_ul_re|$marker_ol_re)"; + + $markers_relist = array( + $marker_ul_re => $marker_ol_re, + $marker_ol_re => $marker_ul_re, + ); + + foreach ($markers_relist as $marker_re => $other_marker_re) { + # Re-usable pattern to match any entirel ul or ol list: + $whole_list_re = ' + ( # $1 = whole list + ( # $2 + ([ ]{0,'.$less_than_tab.'}) # $3 = number of spaces + ('.$marker_re.') # $4 = first list item marker + [ ]+ + ) + (?s:.+?) + ( # $5 + \z + | + \n{2,} + (?=\S) + (?! # Negative lookahead for another list item marker + [ ]* + '.$marker_re.'[ ]+ + ) + | + (?= # Lookahead for another kind of list + \n + \3 # Must have the same indentation + '.$other_marker_re.'[ ]+ + ) + ) + ) + '; // mx + + # We use a different prefix before nested lists than top-level lists. + # See extended comment in _ProcessListItems(). + + if ($this->list_level) { + $text = preg_replace_callback('{ + ^ + '.$whole_list_re.' + }mx', + array(&$this, '_doLists_callback'), $text); + } + else { + $text = preg_replace_callback('{ + (?:(?<=\n)\n|\A\n?) # Must eat the newline + '.$whole_list_re.' + }mx', + array(&$this, '_doLists_callback'), $text); + } + } + + return $text; + } + function _doLists_callback($matches) { + # Re-usable patterns to match list item bullets and number markers: + $marker_ul_re = '[*+-]'; + $marker_ol_re = '\d+[\.]'; + $marker_any_re = "(?:$marker_ul_re|$marker_ol_re)"; + + $list = $matches[1]; + $list_type = preg_match("/$marker_ul_re/", $matches[4]) ? "ul" : "ol"; + + $marker_any_re = ( $list_type == "ul" ? $marker_ul_re : $marker_ol_re ); + + $list .= "\n"; + $result = $this->processListItems($list, $marker_any_re); + + $result = $this->hashBlock("<$list_type>\n" . $result . "</$list_type>"); + return "\n". $result ."\n\n"; + } + + public $list_level = 0; + + function processListItems($list_str, $marker_any_re) { + # + # Process the contents of a single ordered or unordered list, splitting it + # into individual list items. + # + # The $this->list_level global keeps track of when we're inside a list. + # Each time we enter a list, we increment it; when we leave a list, + # we decrement. If it's zero, we're not in a list anymore. + # + # We do this because when we're not inside a list, we want to treat + # something like this: + # + # I recommend upgrading to version + # 8. Oops, now this line is treated + # as a sub-list. + # + # As a single paragraph, despite the fact that the second line starts + # with a digit-period-space sequence. + # + # Whereas when we're inside a list (or sub-list), that line will be + # treated as the start of a sub-list. What a kludge, huh? This is + # an aspect of Markdown's syntax that's hard to parse perfectly + # without resorting to mind-reading. Perhaps the solution is to + # change the syntax rules such that sub-lists must start with a + # starting cardinal number; e.g. "1." or "a.". + + $this->list_level++; + + # trim trailing blank lines: + $list_str = preg_replace("/\n{2,}\\z/", "\n", $list_str); + + $list_str = preg_replace_callback('{ + (\n)? # leading line = $1 + (^[ ]*) # leading whitespace = $2 + ('.$marker_any_re.' # list marker and space = $3 + (?:[ ]+|(?=\n)) # space only required if item is not empty + ) + ((?s:.*?)) # list item text = $4 + (?:(\n+(?=\n))|\n) # tailing blank line = $5 + (?= \n* (\z | \2 ('.$marker_any_re.') (?:[ ]+|(?=\n)))) + }xm', + array(&$this, '_processListItems_callback'), $list_str); + + $this->list_level--; + return $list_str; + } + function _processListItems_callback($matches) { + $item = $matches[4]; + $leading_line =& $matches[1]; + $leading_space =& $matches[2]; + $marker_space = $matches[3]; + $tailing_blank_line =& $matches[5]; + + if ($leading_line || $tailing_blank_line || + preg_match('/\n{2,}/', $item)) + { + # Replace marker with the appropriate whitespace indentation + $item = $leading_space . str_repeat(' ', strlen($marker_space)) . $item; + $item = $this->runBlockGamut($this->outdent($item)."\n"); + } + else { + # Recursion for sub-lists: + $item = $this->doLists($this->outdent($item)); + $item = preg_replace('/\n+$/', '', $item); + $item = $this->runSpanGamut($item); + } + + return "<li>" . $item . "</li>\n"; + } + + + function doCodeBlocks($text) { + # + # Process Markdown `<pre><code>` blocks. + # + $text = preg_replace_callback('{ + (?:\n\n|\A\n?) + ( # $1 = the code block -- one or more lines, starting with a space/tab + (?> + [ ]{'.$this->tab_width.'} # Lines must start with a tab or a tab-width of spaces + .*\n+ + )+ + ) + ((?=^[ ]{0,'.$this->tab_width.'}\S)|\Z) # Lookahead for non-space at line-start, or end of doc + }xm', + array(&$this, '_doCodeBlocks_callback'), $text); + + return $text; + } + function _doCodeBlocks_callback($matches) { + $codeblock = $matches[1]; + + $codeblock = $this->outdent($codeblock); + $codeblock = htmlspecialchars($codeblock, ENT_NOQUOTES); + + # trim leading newlines and trailing newlines + $codeblock = preg_replace('/\A\n+|\n+\z/', '', $codeblock); + + $codeblock = "<pre><code>$codeblock\n</code></pre>"; + return "\n\n".$this->hashBlock($codeblock)."\n\n"; + } + + + function makeCodeSpan($code) { + # + # Create a code span markup for $code. Called from handleSpanToken. + # + $code = htmlspecialchars(trim($code), ENT_NOQUOTES); + return $this->hashPart("<code>$code</code>"); + } + + + public $em_relist = array( + '' => '(?:(?<!\*)\*(?!\*)|(?<!_)_(?!_))(?=\S|$)(?![\.,:;]\s)', + '*' => '(?<=\S|^)(?<!\*)\*(?!\*)', + '_' => '(?<=\S|^)(?<!_)_(?!_)', + ); + public $strong_relist = array( + '' => '(?:(?<!\*)\*\*(?!\*)|(?<!_)__(?!_))(?=\S|$)(?![\.,:;]\s)', + '**' => '(?<=\S|^)(?<!\*)\*\*(?!\*)', + '__' => '(?<=\S|^)(?<!_)__(?!_)', + ); + public $em_strong_relist = array( + '' => '(?:(?<!\*)\*\*\*(?!\*)|(?<!_)___(?!_))(?=\S|$)(?![\.,:;]\s)', + '***' => '(?<=\S|^)(?<!\*)\*\*\*(?!\*)', + '___' => '(?<=\S|^)(?<!_)___(?!_)', + ); + public $em_strong_prepared_relist; + + function prepareItalicsAndBold() { + # + # Prepare regular expressions for searching emphasis tokens in any + # context. + # + foreach ($this->em_relist as $em => $em_re) { + foreach ($this->strong_relist as $strong => $strong_re) { + # Construct list of allowed token expressions. + $token_relist = array(); + if (isset($this->em_strong_relist["$em$strong"])) { + $token_relist[] = $this->em_strong_relist["$em$strong"]; + } + $token_relist[] = $em_re; + $token_relist[] = $strong_re; + + # Construct master expression from list. + $token_re = '{('. implode('|', $token_relist) .')}'; + $this->em_strong_prepared_relist["$em$strong"] = $token_re; + } + } + } + + function doItalicsAndBold($text) { + $token_stack = array(''); + $text_stack = array(''); + $em = ''; + $strong = ''; + $tree_char_em = false; + + while (1) { + # + # Get prepared regular expression for seraching emphasis tokens + # in current context. + # + $token_re = $this->em_strong_prepared_relist["$em$strong"]; + + # + # Each loop iteration search for the next emphasis token. + # Each token is then passed to handleSpanToken. + # + $parts = preg_split($token_re, $text, 2, PREG_SPLIT_DELIM_CAPTURE); + $text_stack[0] .= $parts[0]; + $token =& $parts[1]; + $text =& $parts[2]; + + if (empty($token)) { + # Reached end of text span: empty stack without emitting. + # any more emphasis. + while ($token_stack[0]) { + $text_stack[1] .= array_shift($token_stack); + $text_stack[0] .= array_shift($text_stack); + } + break; + } + + $token_len = strlen($token); + if ($tree_char_em) { + # Reached closing marker while inside a three-char emphasis. + if ($token_len == 3) { + # Three-char closing marker, close em and strong. + array_shift($token_stack); + $span = array_shift($text_stack); + $span = $this->runSpanGamut($span); + $span = "<strong><em>$span</em></strong>"; + $text_stack[0] .= $this->hashPart($span); + $em = ''; + $strong = ''; + } else { + # Other closing marker: close one em or strong and + # change current token state to match the other + $token_stack[0] = str_repeat($token{0}, 3-$token_len); + $tag = $token_len == 2 ? "strong" : "em"; + $span = $text_stack[0]; + $span = $this->runSpanGamut($span); + $span = "<$tag>$span</$tag>"; + $text_stack[0] = $this->hashPart($span); + $$tag = ''; # $$tag stands for $em or $strong + } + $tree_char_em = false; + } else if ($token_len == 3) { + if ($em) { + # Reached closing marker for both em and strong. + # Closing strong marker: + for ($i = 0; $i < 2; ++$i) { + $shifted_token = array_shift($token_stack); + $tag = strlen($shifted_token) == 2 ? "strong" : "em"; + $span = array_shift($text_stack); + $span = $this->runSpanGamut($span); + $span = "<$tag>$span</$tag>"; + $text_stack[0] .= $this->hashPart($span); + $$tag = ''; # $$tag stands for $em or $strong + } + } else { + # Reached opening three-char emphasis marker. Push on token + # stack; will be handled by the special condition above. + $em = $token{0}; + $strong = "$em$em"; + array_unshift($token_stack, $token); + array_unshift($text_stack, ''); + $tree_char_em = true; + } + } else if ($token_len == 2) { + if ($strong) { + # Unwind any dangling emphasis marker: + if (strlen($token_stack[0]) == 1) { + $text_stack[1] .= array_shift($token_stack); + $text_stack[0] .= array_shift($text_stack); + } + # Closing strong marker: + array_shift($token_stack); + $span = array_shift($text_stack); + $span = $this->runSpanGamut($span); + $span = "<strong>$span</strong>"; + $text_stack[0] .= $this->hashPart($span); + $strong = ''; + } else { + array_unshift($token_stack, $token); + array_unshift($text_stack, ''); + $strong = $token; + } + } else { + # Here $token_len == 1 + if ($em) { + if (strlen($token_stack[0]) == 1) { + # Closing emphasis marker: + array_shift($token_stack); + $span = array_shift($text_stack); + $span = $this->runSpanGamut($span); + $span = "<em>$span</em>"; + $text_stack[0] .= $this->hashPart($span); + $em = ''; + } else { + $text_stack[0] .= $token; + } + } else { + array_unshift($token_stack, $token); + array_unshift($text_stack, ''); + $em = $token; + } + } + } + return $text_stack[0]; + } + + + function doBlockQuotes($text) { + $text = preg_replace_callback('/ + ( # Wrap whole match in $1 + (?> + ^[ ]*>[ ]? # ">" at the start of a line + .+\n # rest of the first line + (.+\n)* # subsequent consecutive lines + \n* # blanks + )+ + ) + /xm', + array(&$this, '_doBlockQuotes_callback'), $text); + + return $text; + } + function _doBlockQuotes_callback($matches) { + $bq = $matches[1]; + # trim one level of quoting - trim whitespace-only lines + $bq = preg_replace('/^[ ]*>[ ]?|^[ ]+$/m', '', $bq); + $bq = $this->runBlockGamut($bq); # recurse + + $bq = preg_replace('/^/m', " ", $bq); + # These leading spaces cause problem with <pre> content, + # so we need to fix that: + $bq = preg_replace_callback('{(\s*<pre>.+?</pre>)}sx', + array(&$this, '_doBlockQuotes_callback2'), $bq); + + return "\n". $this->hashBlock("<blockquote>\n$bq\n</blockquote>")."\n\n"; + } + function _doBlockQuotes_callback2($matches) { + $pre = $matches[1]; + $pre = preg_replace('/^ /m', '', $pre); + return $pre; + } + + + function formParagraphs($text) { + # + # Params: + # $text - string to process with html <p> tags + # + # Strip leading and trailing lines: + $text = preg_replace('/\A\n+|\n+\z/', '', $text); + + $grafs = preg_split('/\n{2,}/', $text, -1, PREG_SPLIT_NO_EMPTY); + + # + # Wrap <p> tags and unhashify HTML blocks + # + foreach ($grafs as $key => $value) { + if (!preg_match('/^B\x1A[0-9]+B$/', $value)) { + # Is a paragraph. + $value = $this->runSpanGamut($value); + $value = preg_replace('/^([ ]*)/', "<p>", $value); + $value .= "</p>"; + $grafs[$key] = $this->unhash($value); + } + else { + # Is a block. + # Modify elements of @grafs in-place... + $graf = $value; + $block = $this->html_hashes[$graf]; + $graf = $block; +// if (preg_match('{ +// \A +// ( # $1 = <div> tag +// <div \s+ +// [^>]* +// \b +// markdown\s*=\s* ([\'"]) # $2 = attr quote char +// 1 +// \2 +// [^>]* +// > +// ) +// ( # $3 = contents +// .* +// ) +// (</div>) # $4 = closing tag +// \z +// }xs', $block, $matches)) +// { +// list(, $div_open, , $div_content, $div_close) = $matches; +// +// # We can't call Markdown(), because that resets the hash; +// # that initialization code should be pulled into its own sub, though. +// $div_content = $this->hashHTMLBlocks($div_content); +// +// # Run document gamut methods on the content. +// foreach ($this->document_gamut as $method => $priority) { +// $div_content = $this->$method($div_content); +// } +// +// $div_open = preg_replace( +// '{\smarkdown\s*=\s*([\'"]).+?\1}', '', $div_open); +// +// $graf = $div_open . "\n" . $div_content . "\n" . $div_close; +// } + $grafs[$key] = $graf; + } + } + + return implode("\n\n", $grafs); + } + + + function encodeAttribute($text) { + # + # Encode text for a double-quoted HTML attribute. This function + # is *not* suitable for attributes enclosed in single quotes. + # + $text = $this->encodeAmpsAndAngles($text); + $text = str_replace('"', '"', $text); + return $text; + } + + + function encodeAmpsAndAngles($text) { + # + # Smart processing for ampersands and angle brackets that need to + # be encoded. Valid character entities are left alone unless the + # no-entities mode is set. + # + if ($this->no_entities) { + $text = str_replace('&', '&', $text); + } else { + # Ampersand-encoding based entirely on Nat Irons's Amputator + # MT plugin: <http://bumppo.net/projects/amputator/> + $text = preg_replace('/&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)/', + '&', $text);; + } + # Encode remaining <'s + $text = str_replace('<', '<', $text); + + return $text; + } + + + function doAutoLinks($text) { + $text = preg_replace_callback('{<((https?|ftp|dict):[^\'">\s]+)>}i', + array(&$this, '_doAutoLinks_url_callback'), $text); + + # Email addresses: <address@domain.foo> + $text = preg_replace_callback('{ + < + (?:mailto:)? + ( + (?: + [-!#$%&\'*+/=?^_`.{|}~\w\x80-\xFF]+ + | + ".*?" + ) + \@ + (?: + [-a-z0-9\x80-\xFF]+(\.[-a-z0-9\x80-\xFF]+)*\.[a-z]+ + | + \[[\d.a-fA-F:]+\] # IPv4 & IPv6 + ) + ) + > + }xi', + array(&$this, '_doAutoLinks_email_callback'), $text); + $text = preg_replace_callback('{<(tel:([^\'">\s]+))>}i',array(&$this, '_doAutoLinks_tel_callback'), $text); + + return $text; + } + function _doAutoLinks_tel_callback($matches) { + $url = $this->encodeAttribute($matches[1]); + $tel = $this->encodeAttribute($matches[2]); + $link = "<a href=\"$url\">$tel</a>"; + return $this->hashPart($link); + } + function _doAutoLinks_url_callback($matches) { + $url = $this->encodeAttribute($matches[1]); + $link = "<a href=\"$url\">$url</a>"; + return $this->hashPart($link); + } + function _doAutoLinks_email_callback($matches) { + $address = $matches[1]; + $link = $this->encodeEmailAddress($address); + return $this->hashPart($link); + } + + + function encodeEmailAddress($addr) { + # + # Input: an email address, e.g. "foo@example.com" + # + # Output: the email address as a mailto link, with each character + # of the address encoded as either a decimal or hex entity, in + # the hopes of foiling most address harvesting spam bots. E.g.: + # + # <p><a href="mailto:foo + # @example.co + # m">foo@exampl + # e.com</a></p> + # + # Based by a filter by Matthew Wickline, posted to BBEdit-Talk. + # With some optimizations by Milian Wolff. + # + $addr = "mailto:" . $addr; + $chars = preg_split('/(?<!^)(?!$)/', $addr); + $seed = (int)abs(crc32($addr) / strlen($addr)); # Deterministic seed. + + foreach ($chars as $key => $char) { + $ord = ord($char); + # Ignore non-ascii chars. + if ($ord < 128) { + $r = ($seed * (1 + $key)) % 100; # Pseudo-random function. + # roughly 10% raw, 45% hex, 45% dec + # '@' *must* be encoded. I insist. + if ($r > 90 && $char != '@') /* do nothing */; + else if ($r < 45) $chars[$key] = '&#x'.dechex($ord).';'; + else $chars[$key] = '&#'.$ord.';'; + } + } + + $addr = implode('', $chars); + $text = implode('', array_slice($chars, 7)); # text without `mailto:` + $addr = "<a href=\"$addr\">$text</a>"; + + return $addr; + } + + + function parseSpan($str) { + # + # Take the string $str and parse it into tokens, hashing embedded HTML, + # escaped characters and handling code spans. + # + $output = ''; + + $span_re = '{ + ( + \\\\'.$this->escape_chars_re.' + | + (?<![`\\\\]) + `+ # code span marker + '.( $this->no_markup ? '' : ' + | + <!-- .*? --> # comment + | + <\?.*?\?> | <%.*?%> # processing instruction + | + <[!$]?[-a-zA-Z0-9:_]+ # regular tags + (?> + \s + (?>[^"\'>]+|"[^"]*"|\'[^\']*\')* + )? + > + | + <[-a-zA-Z0-9:_]+\s*/> # xml-style empty tag + | + </[-a-zA-Z0-9:_]+\s*> # closing tag + ').' + ) + }xs'; + + while (1) { + # + # Each loop iteration search for either the next tag, the next + # openning code span marker, or the next escaped character. + # Each token is then passed to handleSpanToken. + # + $parts = preg_split($span_re, $str, 2, PREG_SPLIT_DELIM_CAPTURE); + + # Create token from text preceding tag. + if ($parts[0] != "") { + $output .= $parts[0]; + } + + # Check if we reach the end. + if (isset($parts[1])) { + $output .= $this->handleSpanToken($parts[1], $parts[2]); + $str = $parts[2]; + } + else { + break; + } + } + + return $output; + } + + + function handleSpanToken($token, &$str) { + # + # Handle $token provided by parseSpan by determining its nature and + # returning the corresponding value that should replace it. + # + switch ($token{0}) { + case "\\": + return $this->hashPart("&#". ord($token{1}). ";"); + case "`": + # Search for end marker in remaining text. + if (preg_match('/^(.*?[^`])'.preg_quote($token).'(?!`)(.*)$/sm', + $str, $matches)) + { + $str = $matches[2]; + $codespan = $this->makeCodeSpan($matches[1]); + return $this->hashPart($codespan); + } + return $token; // return as text since no ending marker found. + default: + return $this->hashPart($token); + } + } + + + function outdent($text) { + # + # Remove one level of line-leading tabs or spaces + # + return preg_replace('/^(\t|[ ]{1,'.$this->tab_width.'})/m', '', $text); + } + + + # String length function for detab. `_initDetab` will create a function to + # hanlde UTF-8 if the default function does not exist. + public $utf8_strlen = 'mb_strlen'; + + function detab($text) { + # + # Replace tabs with the appropriate amount of space. + # + # For each line we separate the line in blocks delemited by + # tab characters. Then we reconstruct every line by adding the + # appropriate number of space between each blocks. + + $text = preg_replace_callback('/^.*\t.*$/m', + array(&$this, '_detab_callback'), $text); + + return $text; + } + function _detab_callback($matches) { + $line = $matches[0]; + $strlen = $this->utf8_strlen; # strlen function for UTF-8. + + # Split in blocks. + $blocks = explode("\t", $line); + # Add each blocks to the line. + $line = $blocks[0]; + unset($blocks[0]); # Do not add first block twice. + foreach ($blocks as $block) { + # Calculate amount of space, insert spaces, insert block. + $amount = $this->tab_width - + $strlen($line, 'UTF-8') % $this->tab_width; + $line .= str_repeat(" ", $amount) . $block; + } + return $line; + } + function _initDetab() { + # + # Check for the availability of the function in the `utf8_strlen` property + # (initially `mb_strlen`). If the function is not available, use jetpack_utf8_strlen + # that will loosely count the number of UTF-8 characters with a + # regular expression. + # + if ( function_exists( $this->utf8_strlen ) ) { + return; + } + $this->utf8_strlen = 'jetpack_utf8_strlen'; + } + + + function unhash($text) { + # + # Swap back in all the tags hashed by _HashHTMLBlocks. + # + return preg_replace_callback('/(.)\x1A[0-9]+\1/', + array(&$this, '_unhash_callback'), $text); + } + function _unhash_callback($matches) { + return $this->html_hashes[$matches[0]]; + } + +} + + +# +# Markdown Extra Parser Class +# + +class MarkdownExtra_Parser extends Markdown_Parser { + + ### Configuration Variables ### + + # Prefix for footnote ids. + public $fn_id_prefix = ""; + + # Optional title attribute for footnote links and backlinks. + public $fn_link_title = MARKDOWN_FN_LINK_TITLE; + public $fn_backlink_title = MARKDOWN_FN_BACKLINK_TITLE; + + # Optional class attribute for footnote links and backlinks. + public $fn_link_class = MARKDOWN_FN_LINK_CLASS; + public $fn_backlink_class = MARKDOWN_FN_BACKLINK_CLASS; + + # Optional class prefix for fenced code block. + public $code_class_prefix = MARKDOWN_CODE_CLASS_PREFIX; + # Class attribute for code blocks goes on the `code` tag; + # setting this to true will put attributes on the `pre` tag instead. + public $code_attr_on_pre = MARKDOWN_CODE_ATTR_ON_PRE; + + # Predefined abbreviations. + public $predef_abbr = array(); + + + ### Parser Implementation ### + + function __construct() { + # + # Constructor function. Initialize the parser object. + # + # Add extra escapable characters before parent constructor + # initialize the table. + $this->escape_chars .= ':|'; + + # Insert extra document, block, and span transformations. + # Parent constructor will do the sorting. + $this->document_gamut += array( + "doFencedCodeBlocks" => 5, + "stripFootnotes" => 15, + "stripAbbreviations" => 25, + "appendFootnotes" => 50, + ); + $this->block_gamut += array( + "doFencedCodeBlocks" => 5, + "doTables" => 15, + "doDefLists" => 45, + ); + $this->span_gamut += array( + "doFootnotes" => 5, + "doAbbreviations" => 70, + ); + + parent::__construct(); + } + + + # Extra variables used during extra transformations. + public $footnotes = array(); + public $footnotes_ordered = array(); + public $footnotes_ref_count = array(); + public $footnotes_numbers = array(); + public $abbr_desciptions = array(); + public $abbr_word_re = ''; + + # Give the current footnote number. + public $footnote_counter = 1; + + + function setup() { + # + # Setting up Extra-specific variables. + # + parent::setup(); + + $this->footnotes = array(); + $this->footnotes_ordered = array(); + $this->footnotes_ref_count = array(); + $this->footnotes_numbers = array(); + $this->abbr_desciptions = array(); + $this->abbr_word_re = ''; + $this->footnote_counter = 1; + + foreach ($this->predef_abbr as $abbr_word => $abbr_desc) { + if ($this->abbr_word_re) + $this->abbr_word_re .= '|'; + $this->abbr_word_re .= preg_quote($abbr_word); + $this->abbr_desciptions[$abbr_word] = trim($abbr_desc); + } + } + + function teardown() { + # + # Clearing Extra-specific variables. + # + $this->footnotes = array(); + $this->footnotes_ordered = array(); + $this->footnotes_ref_count = array(); + $this->footnotes_numbers = array(); + $this->abbr_desciptions = array(); + $this->abbr_word_re = ''; + + parent::teardown(); + } + + + ### Extra Attribute Parser ### + + # Expression to use to catch attributes (includes the braces) + public $id_class_attr_catch_re = '\{((?:[ ]*[#.][-_:a-zA-Z0-9]+){1,})[ ]*\}'; + # Expression to use when parsing in a context when no capture is desired + public $id_class_attr_nocatch_re = '\{(?:[ ]*[#.][-_:a-zA-Z0-9]+){1,}[ ]*\}'; + + function doExtraAttributes($tag_name, $attr) { + # + # Parse attributes caught by the $this->id_class_attr_catch_re expression + # and return the HTML-formatted list of attributes. + # + # Currently supported attributes are .class and #id. + # + if (empty($attr)) return ""; + + # Split on components + preg_match_all('/[#.][-_:a-zA-Z0-9]+/', $attr, $matches); + $elements = $matches[0]; + + # handle classes and ids (only first id taken into account) + $classes = array(); + $id = false; + foreach ($elements as $element) { + if ($element{0} == '.') { + $classes[] = substr($element, 1); + } else if ($element{0} == '#') { + if ($id === false) $id = substr($element, 1); + } + } + + # compose attributes as string + $attr_str = ""; + if (!empty($id)) { + $attr_str .= ' id="'.$id.'"'; + } + if (!empty($classes)) { + $attr_str .= ' class="'.implode(" ", $classes).'"'; + } + return $attr_str; + } + + + function stripLinkDefinitions($text) { + # + # Strips link definitions from text, stores the URLs and titles in + # hash references. + # + $less_than_tab = $this->tab_width - 1; + + # Link defs are in the form: ^[id]: url "optional title" + $text = preg_replace_callback('{ + ^[ ]{0,'.$less_than_tab.'}\[(.+)\][ ]?: # id = $1 + [ ]* + \n? # maybe *one* newline + [ ]* + (?: + <(.+?)> # url = $2 + | + (\S+?) # url = $3 + ) + [ ]* + \n? # maybe one newline + [ ]* + (?: + (?<=\s) # lookbehind for whitespace + ["(] + (.*?) # title = $4 + [")] + [ ]* + )? # title is optional + (?:[ ]* '.$this->id_class_attr_catch_re.' )? # $5 = extra id & class attr + (?:\n+|\Z) + }xm', + array(&$this, '_stripLinkDefinitions_callback'), + $text); + return $text; + } + function _stripLinkDefinitions_callback($matches) { + $link_id = strtolower($matches[1]); + $url = $matches[2] == '' ? $matches[3] : $matches[2]; + $this->urls[$link_id] = $url; + $this->titles[$link_id] =& $matches[4]; + $this->ref_attr[$link_id] = $this->doExtraAttributes("", $dummy =& $matches[5]); + return ''; # String that will replace the block + } + + + ### HTML Block Parser ### + + # Tags that are always treated as block tags: + public $block_tags_re = 'p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|address|form|fieldset|iframe|hr|legend|article|section|nav|aside|hgroup|header|footer|figcaption'; + + # Tags treated as block tags only if the opening tag is alone on its line: + public $context_block_tags_re = 'script|noscript|ins|del|iframe|object|source|track|param|math|svg|canvas|audio|video'; + + # Tags where markdown="1" default to span mode: + public $contain_span_tags_re = 'p|h[1-6]|li|dd|dt|td|th|legend|address'; + + # Tags which must not have their contents modified, no matter where + # they appear: + public $clean_tags_re = 'script|math|svg'; + + # Tags that do not need to be closed. + public $auto_close_tags_re = 'hr|img|param|source|track'; + + + function hashHTMLBlocks($text) { + # + # Hashify HTML Blocks and "clean tags". + # + # We only want to do this for block-level HTML tags, such as headers, + # lists, and tables. That's because we still want to wrap <p>s around + # "paragraphs" that are wrapped in non-block-level tags, such as anchors, + # phrase emphasis, and spans. The list of tags we're looking for is + # hard-coded. + # + # This works by calling _HashHTMLBlocks_InMarkdown, which then calls + # _HashHTMLBlocks_InHTML when it encounter block tags. When the markdown="1" + # attribute is found within a tag, _HashHTMLBlocks_InHTML calls back + # _HashHTMLBlocks_InMarkdown to handle the Markdown syntax within the tag. + # These two functions are calling each other. It's recursive! + # + if ($this->no_markup) return $text; + + # + # Call the HTML-in-Markdown hasher. + # + list($text, ) = $this->_hashHTMLBlocks_inMarkdown($text); + + return $text; + } + function _hashHTMLBlocks_inMarkdown($text, $indent = 0, + $enclosing_tag_re = '', $span = false) + { + # + # Parse markdown text, calling _HashHTMLBlocks_InHTML for block tags. + # + # * $indent is the number of space to be ignored when checking for code + # blocks. This is important because if we don't take the indent into + # account, something like this (which looks right) won't work as expected: + # + # <div> + # <div markdown="1"> + # Hello World. <-- Is this a Markdown code block or text? + # </div> <-- Is this a Markdown code block or a real tag? + # <div> + # + # If you don't like this, just don't indent the tag on which + # you apply the markdown="1" attribute. + # + # * If $enclosing_tag_re is not empty, stops at the first unmatched closing + # tag with that name. Nested tags supported. + # + # * If $span is true, text inside must treated as span. So any double + # newline will be replaced by a single newline so that it does not create + # paragraphs. + # + # Returns an array of that form: ( processed text , remaining text ) + # + if ($text === '') return array('', ''); + + # Regex to check for the presence of newlines around a block tag. + $newline_before_re = '/(?:^\n?|\n\n)*$/'; + $newline_after_re = + '{ + ^ # Start of text following the tag. + (?>[ ]*<!--.*?-->)? # Optional comment. + [ ]*\n # Must be followed by newline. + }xs'; + + # Regex to match any tag. + $block_tag_re = + '{ + ( # $2: Capture whole tag. + </? # Any opening or closing tag. + (?> # Tag name. + '.$this->block_tags_re.' | + '.$this->context_block_tags_re.' | + '.$this->clean_tags_re.' | + (?!\s)'.$enclosing_tag_re.' + ) + (?: + (?=[\s"\'/a-zA-Z0-9]) # Allowed characters after tag name. + (?> + ".*?" | # Double quotes (can contain `>`) + \'.*?\' | # Single quotes (can contain `>`) + .+? # Anything but quotes and `>`. + )*? + )? + > # End of tag. + | + <!-- .*? --> # HTML Comment + | + <\?.*?\?> | <%.*?%> # Processing instruction + | + <!\[CDATA\[.*?\]\]> # CData Block + '. ( !$span ? ' # If not in span. + | + # Indented code block + (?: ^[ ]*\n | ^ | \n[ ]*\n ) + [ ]{'.($indent+4).'}[^\n]* \n + (?> + (?: [ ]{'.($indent+4).'}[^\n]* | [ ]* ) \n + )* + | + # Fenced code block marker + (?<= ^ | \n ) + [ ]{0,'.($indent+3).'}(?:~{3,}|`{3,}) + [ ]* + (?: + \.?[-_:a-zA-Z0-9]+ # standalone class name + | + '.$this->id_class_attr_nocatch_re.' # extra attributes + )? + [ ]* + (?= \n ) + ' : '' ). ' # End (if not is span). + | + # Code span marker + # Note, this regex needs to go after backtick fenced + # code blocks but it should also be kept outside of the + # "if not in span" condition adding backticks to the parser + `+ + ) + }xs'; + + + $depth = 0; # Current depth inside the tag tree. + $parsed = ""; # Parsed text that will be returned. + + # + # Loop through every tag until we find the closing tag of the parent + # or loop until reaching the end of text if no parent tag specified. + # + do { + # + # Split the text using the first $tag_match pattern found. + # Text before pattern will be first in the array, text after + # pattern will be at the end, and between will be any catches made + # by the pattern. + # + $parts = preg_split($block_tag_re, $text, 2, + PREG_SPLIT_DELIM_CAPTURE); + + # If in Markdown span mode, add a empty-string span-level hash + # after each newline to prevent triggering any block element. + if ($span) { + $void = $this->hashPart("", ':'); + $newline = "$void\n"; + $parts[0] = $void . str_replace("\n", $newline, $parts[0]) . $void; + } + + $parsed .= $parts[0]; # Text before current tag. + + # If end of $text has been reached. Stop loop. + if (count($parts) < 3) { + $text = ""; + break; + } + + $tag = $parts[1]; # Tag to handle. + $text = $parts[2]; # Remaining text after current tag. + $tag_re = preg_quote($tag); # For use in a regular expression. + + # + # Check for: Fenced code block marker. + # Note: need to recheck the whole tag to disambiguate backtick + # fences from code spans + # + if (preg_match('{^\n?([ ]{0,'.($indent+3).'})(~{3,}|`{3,})[ ]*(?:\.?[-_:a-zA-Z0-9]+|'.$this->id_class_attr_nocatch_re.')?[ ]*\n?$}', $tag, $capture)) { + # Fenced code block marker: find matching end marker. + $fence_indent = strlen($capture[1]); # use captured indent in re + $fence_re = $capture[2]; # use captured fence in re + if (preg_match('{^(?>.*\n)*?[ ]{'.($fence_indent).'}'.$fence_re.'[ ]*(?:\n|$)}', $text, + $matches)) + { + # End marker found: pass text unchanged until marker. + $parsed .= $tag . $matches[0]; + $text = substr($text, strlen($matches[0])); + } + else { + # No end marker: just skip it. + $parsed .= $tag; + } + } + # + # Check for: Indented code block. + # + else if ($tag{0} == "\n" || $tag{0} == " ") { + # Indented code block: pass it unchanged, will be handled + # later. + $parsed .= $tag; + } + # + # Check for: Code span marker + # Note: need to check this after backtick fenced code blocks + # + else if ($tag{0} == "`") { + # Find corresponding end marker. + $tag_re = preg_quote($tag); + if (preg_match('{^(?>.+?|\n(?!\n))*?(?<!`)'.$tag_re.'(?!`)}', + $text, $matches)) + { + # End marker found: pass text unchanged until marker. + $parsed .= $tag . $matches[0]; + $text = substr($text, strlen($matches[0])); + } + else { + # Unmatched marker: just skip it. + $parsed .= $tag; + } + } + # + # Check for: Opening Block level tag or + # Opening Context Block tag (like ins and del) + # used as a block tag (tag is alone on it's line). + # + else if (preg_match('{^<(?:'.$this->block_tags_re.')\b}', $tag) || + ( preg_match('{^<(?:'.$this->context_block_tags_re.')\b}', $tag) && + preg_match($newline_before_re, $parsed) && + preg_match($newline_after_re, $text) ) + ) + { + # Need to parse tag and following text using the HTML parser. + list($block_text, $text) = + $this->_hashHTMLBlocks_inHTML($tag . $text, "hashBlock", true); + + # Make sure it stays outside of any paragraph by adding newlines. + $parsed .= "\n\n$block_text\n\n"; + } + # + # Check for: Clean tag (like script, math) + # HTML Comments, processing instructions. + # + else if (preg_match('{^<(?:'.$this->clean_tags_re.')\b}', $tag) || + $tag{1} == '!' || $tag{1} == '?') + { + # Need to parse tag and following text using the HTML parser. + # (don't check for markdown attribute) + list($block_text, $text) = + $this->_hashHTMLBlocks_inHTML($tag . $text, "hashClean", false); + + $parsed .= $block_text; + } + # + # Check for: Tag with same name as enclosing tag. + # + else if ($enclosing_tag_re !== '' && + # Same name as enclosing tag. + preg_match('{^</?(?:'.$enclosing_tag_re.')\b}', $tag)) + { + # + # Increase/decrease nested tag count. + # + if ($tag{1} == '/') $depth--; + else if ($tag{strlen($tag)-2} != '/') $depth++; + + if ($depth < 0) { + # + # Going out of parent element. Clean up and break so we + # return to the calling function. + # + $text = $tag . $text; + break; + } + + $parsed .= $tag; + } + else { + $parsed .= $tag; + } + } while ($depth >= 0); + + return array($parsed, $text); + } + function _hashHTMLBlocks_inHTML($text, $hash_method, $md_attr) { + # + # Parse HTML, calling _HashHTMLBlocks_InMarkdown for block tags. + # + # * Calls $hash_method to convert any blocks. + # * Stops when the first opening tag closes. + # * $md_attr indicate if the use of the `markdown="1"` attribute is allowed. + # (it is not inside clean tags) + # + # Returns an array of that form: ( processed text , remaining text ) + # + if ($text === '') return array('', ''); + + # Regex to match `markdown` attribute inside of a tag. + $markdown_attr_re = ' + { + \s* # Eat whitespace before the `markdown` attribute + markdown + \s*=\s* + (?> + (["\']) # $1: quote delimiter + (.*?) # $2: attribute value + \1 # matching delimiter + | + ([^\s>]*) # $3: unquoted attribute value + ) + () # $4: make $3 always defined (avoid warnings) + }xs'; + + # Regex to match any tag. + $tag_re = '{ + ( # $2: Capture whole tag. + </? # Any opening or closing tag. + [\w:$]+ # Tag name. + (?: + (?=[\s"\'/a-zA-Z0-9]) # Allowed characters after tag name. + (?> + ".*?" | # Double quotes (can contain `>`) + \'.*?\' | # Single quotes (can contain `>`) + .+? # Anything but quotes and `>`. + )*? + )? + > # End of tag. + | + <!-- .*? --> # HTML Comment + | + <\?.*?\?> | <%.*?%> # Processing instruction + | + <!\[CDATA\[.*?\]\]> # CData Block + ) + }xs'; + + $original_text = $text; # Save original text in case of faliure. + + $depth = 0; # Current depth inside the tag tree. + $block_text = ""; # Temporary text holder for current text. + $parsed = ""; # Parsed text that will be returned. + + # + # Get the name of the starting tag. + # (This pattern makes $base_tag_name_re safe without quoting.) + # + if (preg_match('/^<([\w:$]*)\b/', $text, $matches)) + $base_tag_name_re = $matches[1]; + + # + # Loop through every tag until we find the corresponding closing tag. + # + do { + # + # Split the text using the first $tag_match pattern found. + # Text before pattern will be first in the array, text after + # pattern will be at the end, and between will be any catches made + # by the pattern. + # + $parts = preg_split($tag_re, $text, 2, PREG_SPLIT_DELIM_CAPTURE); + + if (count($parts) < 3) { + # + # End of $text reached with unbalenced tag(s). + # In that case, we return original text unchanged and pass the + # first character as filtered to prevent an infinite loop in the + # parent function. + # + return array($original_text{0}, substr($original_text, 1)); + } + + $block_text .= $parts[0]; # Text before current tag. + $tag = $parts[1]; # Tag to handle. + $text = $parts[2]; # Remaining text after current tag. + + # + # Check for: Auto-close tag (like <hr/>) + # Comments and Processing Instructions. + # + if (preg_match('{^</?(?:'.$this->auto_close_tags_re.')\b}', $tag) || + $tag{1} == '!' || $tag{1} == '?') + { + # Just add the tag to the block as if it was text. + $block_text .= $tag; + } + else { + # + # Increase/decrease nested tag count. Only do so if + # the tag's name match base tag's. + # + if (preg_match('{^</?'.$base_tag_name_re.'\b}', $tag)) { + if ($tag{1} == '/') $depth--; + else if ($tag{strlen($tag)-2} != '/') $depth++; + } + + # + # Check for `markdown="1"` attribute and handle it. + # + if ($md_attr && + preg_match($markdown_attr_re, $tag, $attr_m) && + preg_match('/^1|block|span$/', $attr_m[2] . $attr_m[3])) + { + # Remove `markdown` attribute from opening tag. + $tag = preg_replace($markdown_attr_re, '', $tag); + + # Check if text inside this tag must be parsed in span mode. + $this->mode = $attr_m[2] . $attr_m[3]; + $span_mode = $this->mode == 'span' || $this->mode != 'block' && + preg_match('{^<(?:'.$this->contain_span_tags_re.')\b}', $tag); + + # Calculate indent before tag. + if (preg_match('/(?:^|\n)( *?)(?! ).*?$/', $block_text, $matches)) { + $strlen = $this->utf8_strlen; + $indent = $strlen($matches[1], 'UTF-8'); + } else { + $indent = 0; + } + + # End preceding block with this tag. + $block_text .= $tag; + $parsed .= $this->$hash_method($block_text); + + # Get enclosing tag name for the ParseMarkdown function. + # (This pattern makes $tag_name_re safe without quoting.) + preg_match('/^<([\w:$]*)\b/', $tag, $matches); + $tag_name_re = $matches[1]; + + # Parse the content using the HTML-in-Markdown parser. + list ($block_text, $text) + = $this->_hashHTMLBlocks_inMarkdown($text, $indent, + $tag_name_re, $span_mode); + + # Outdent markdown text. + if ($indent > 0) { + $block_text = preg_replace("/^[ ]{1,$indent}/m", "", + $block_text); + } + + # Append tag content to parsed text. + if (!$span_mode) $parsed .= "\n\n$block_text\n\n"; + else $parsed .= "$block_text"; + + # Start over with a new block. + $block_text = ""; + } + else $block_text .= $tag; + } + + } while ($depth > 0); + + # + # Hash last block text that wasn't processed inside the loop. + # + $parsed .= $this->$hash_method($block_text); + + return array($parsed, $text); + } + + + function hashClean($text) { + # + # Called whenever a tag must be hashed when a function inserts a "clean" tag + # in $text, it passes through this function and is automaticaly escaped, + # blocking invalid nested overlap. + # + return $this->hashPart($text, 'C'); + } + + + function doAnchors($text) { + # + # Turn Markdown link shortcuts into XHTML <a> tags. + # + if ($this->in_anchor) return $text; + $this->in_anchor = true; + + # + # First, handle reference-style links: [link text] [id] + # + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + ('.$this->nested_brackets_re.') # link text = $2 + \] + + [ ]? # one optional space + (?:\n[ ]*)? # one optional newline followed by spaces + + \[ + (.*?) # id = $3 + \] + ) + }xs', + array(&$this, '_doAnchors_reference_callback'), $text); + + # + # Next, inline-style links: [link text](url "optional title") + # + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + ('.$this->nested_brackets_re.') # link text = $2 + \] + \( # literal paren + [ \n]* + (?: + <(.+?)> # href = $3 + | + ('.$this->nested_url_parenthesis_re.') # href = $4 + ) + [ \n]* + ( # $5 + ([\'"]) # quote char = $6 + (.*?) # Title = $7 + \6 # matching quote + [ \n]* # ignore any spaces/tabs between closing quote and ) + )? # title is optional + \) + (?:[ ]? '.$this->id_class_attr_catch_re.' )? # $8 = id/class attributes + ) + }xs', + array(&$this, '_doAnchors_inline_callback'), $text); + + # + # Last, handle reference-style shortcuts: [link text] + # These must come last in case you've also got [link text][1] + # or [link text](/foo) + # + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + ([^\[\]]+) # link text = $2; can\'t contain [ or ] + \] + ) + }xs', + array(&$this, '_doAnchors_reference_callback'), $text); + + $this->in_anchor = false; + return $text; + } + function _doAnchors_reference_callback($matches) { + $whole_match = $matches[1]; + $link_text = $matches[2]; + $link_id =& $matches[3]; + + if ($link_id == "") { + # for shortcut links like [this][] or [this]. + $link_id = $link_text; + } + + # lower-case and turn embedded newlines into spaces + $link_id = strtolower($link_id); + $link_id = preg_replace('{[ ]?\n}', ' ', $link_id); + + if (isset($this->urls[$link_id])) { + $url = $this->urls[$link_id]; + $url = $this->encodeAttribute($url); + + $result = "<a href=\"$url\""; + if ( isset( $this->titles[$link_id] ) ) { + $title = $this->titles[$link_id]; + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; + } + if (isset($this->ref_attr[$link_id])) + $result .= $this->ref_attr[$link_id]; + + $link_text = $this->runSpanGamut($link_text); + $result .= ">$link_text</a>"; + $result = $this->hashPart($result); + } + else { + $result = $whole_match; + } + return $result; + } + function _doAnchors_inline_callback($matches) { + $whole_match = $matches[1]; + $link_text = $this->runSpanGamut($matches[2]); + $url = $matches[3] == '' ? $matches[4] : $matches[3]; + $title =& $matches[7]; + $attr = $this->doExtraAttributes("a", $dummy =& $matches[8]); + + + $url = $this->encodeAttribute($url); + + $result = "<a href=\"$url\""; + if (isset($title)) { + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; + } + $result .= $attr; + + $link_text = $this->runSpanGamut($link_text); + $result .= ">$link_text</a>"; + + return $this->hashPart($result); + } + + + function doImages($text) { + # + # Turn Markdown image shortcuts into <img> tags. + # + # + # First, handle reference-style labeled images: ![alt text][id] + # + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + !\[ + ('.$this->nested_brackets_re.') # alt text = $2 + \] + + [ ]? # one optional space + (?:\n[ ]*)? # one optional newline followed by spaces + + \[ + (.*?) # id = $3 + \] + + ) + }xs', + array(&$this, '_doImages_reference_callback'), $text); + + # + # Next, handle inline images: ![alt text](url "optional title") + # Don't forget: encode * and _ + # + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + !\[ + ('.$this->nested_brackets_re.') # alt text = $2 + \] + \s? # One optional whitespace character + \( # literal paren + [ \n]* + (?: + <(\S*)> # src url = $3 + | + ('.$this->nested_url_parenthesis_re.') # src url = $4 + ) + [ \n]* + ( # $5 + ([\'"]) # quote char = $6 + (.*?) # title = $7 + \6 # matching quote + [ \n]* + )? # title is optional + \) + (?:[ ]? '.$this->id_class_attr_catch_re.' )? # $8 = id/class attributes + ) + }xs', + array(&$this, '_doImages_inline_callback'), $text); + + return $text; + } + function _doImages_reference_callback($matches) { + $whole_match = $matches[1]; + $alt_text = $matches[2]; + $link_id = strtolower($matches[3]); + + if ($link_id == "") { + $link_id = strtolower($alt_text); # for shortcut links like ![this][]. + } + + $alt_text = $this->encodeAttribute($alt_text); + if (isset($this->urls[$link_id])) { + $url = $this->encodeAttribute($this->urls[$link_id]); + $result = "<img src=\"$url\" alt=\"$alt_text\""; + if (isset($this->titles[$link_id])) { + $title = $this->titles[$link_id]; + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; + } + if (isset($this->ref_attr[$link_id])) + $result .= $this->ref_attr[$link_id]; + $result .= $this->empty_element_suffix; + $result = $this->hashPart($result); + } + else { + # If there's no such link ID, leave intact: + $result = $whole_match; + } + + return $result; + } + function _doImages_inline_callback($matches) { + $whole_match = $matches[1]; + $alt_text = $matches[2]; + $url = $matches[3] == '' ? $matches[4] : $matches[3]; + $title =& $matches[7]; + $attr = $this->doExtraAttributes("img", $dummy =& $matches[8]); + + $alt_text = $this->encodeAttribute($alt_text); + $url = $this->encodeAttribute($url); + $result = "<img src=\"$url\" alt=\"$alt_text\""; + if (isset($title)) { + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; # $title already quoted + } + $result .= $attr; + $result .= $this->empty_element_suffix; + + return $this->hashPart($result); + } + + + function doHeaders($text) { + # + # Redefined to add id and class attribute support. + # + # Setext-style headers: + # Header 1 {#header1} + # ======== + # + # Header 2 {#header2 .class1 .class2} + # -------- + # + $text = preg_replace_callback( + '{ + (^.+?) # $1: Header text + (?:[ ]+ '.$this->id_class_attr_catch_re.' )? # $3 = id/class attributes + [ ]*\n(=+|-+)[ ]*\n+ # $3: Header footer + }mx', + array(&$this, '_doHeaders_callback_setext'), $text); + + # atx-style headers: + # # Header 1 {#header1} + # ## Header 2 {#header2} + # ## Header 2 with closing hashes ## {#header3.class1.class2} + # ... + # ###### Header 6 {.class2} + # + $text = preg_replace_callback('{ + ^(\#{1,6}) # $1 = string of #\'s + [ ]* + (.+?) # $2 = Header text + [ ]* + \#* # optional closing #\'s (not counted) + (?:[ ]+ '.$this->id_class_attr_catch_re.' )? # $3 = id/class attributes + [ ]* + \n+ + }xm', + array(&$this, '_doHeaders_callback_atx'), $text); + + return $text; + } + function _doHeaders_callback_setext($matches) { + if ($matches[3] == '-' && preg_match('{^- }', $matches[1])) + return $matches[0]; + $level = $matches[3]{0} == '=' ? 1 : 2; + $attr = $this->doExtraAttributes("h$level", $dummy =& $matches[2]); + $block = "<h$level$attr>".$this->runSpanGamut($matches[1])."</h$level>"; + return "\n" . $this->hashBlock($block) . "\n\n"; + } + function _doHeaders_callback_atx($matches) { + $level = strlen($matches[1]); + $attr = $this->doExtraAttributes("h$level", $dummy =& $matches[3]); + $block = "<h$level$attr>".$this->runSpanGamut($matches[2])."</h$level>"; + return "\n" . $this->hashBlock($block) . "\n\n"; + } + + + function doTables($text) { + # + # Form HTML tables. + # + $less_than_tab = $this->tab_width - 1; + # + # Find tables with leading pipe. + # + # | Header 1 | Header 2 + # | -------- | -------- + # | Cell 1 | Cell 2 + # | Cell 3 | Cell 4 + # + $text = preg_replace_callback(' + { + ^ # Start of a line + [ ]{0,'.$less_than_tab.'} # Allowed whitespace. + [|] # Optional leading pipe (present) + (.+) \n # $1: Header row (at least one pipe) + + [ ]{0,'.$less_than_tab.'} # Allowed whitespace. + [|] ([ ]*[-:]+[-| :]*) \n # $2: Header underline + + ( # $3: Cells + (?> + [ ]* # Allowed whitespace. + [|] .* \n # Row content. + )* + ) + (?=\n|\Z) # Stop at final double newline. + }xm', + array(&$this, '_doTable_leadingPipe_callback'), $text); + + # + # Find tables without leading pipe. + # + # Header 1 | Header 2 + # -------- | -------- + # Cell 1 | Cell 2 + # Cell 3 | Cell 4 + # + $text = preg_replace_callback(' + { + ^ # Start of a line + [ ]{0,'.$less_than_tab.'} # Allowed whitespace. + (\S.*[|].*) \n # $1: Header row (at least one pipe) + + [ ]{0,'.$less_than_tab.'} # Allowed whitespace. + ([-:]+[ ]*[|][-| :]*) \n # $2: Header underline + + ( # $3: Cells + (?> + .* [|] .* \n # Row content + )* + ) + (?=\n|\Z) # Stop at final double newline. + }xm', + array(&$this, '_DoTable_callback'), $text); + + return $text; + } + function _doTable_leadingPipe_callback($matches) { + $head = $matches[1]; + $underline = $matches[2]; + $content = $matches[3]; + + # Remove leading pipe for each row. + $content = preg_replace('/^ *[|]/m', '', $content); + + return $this->_doTable_callback(array($matches[0], $head, $underline, $content)); + } + function _doTable_callback($matches) { + $head = $matches[1]; + $underline = $matches[2]; + $content = $matches[3]; + + # Remove any tailing pipes for each line. + $head = preg_replace('/[|] *$/m', '', $head); + $underline = preg_replace('/[|] *$/m', '', $underline); + $content = preg_replace('/[|] *$/m', '', $content); + + # Reading alignement from header underline. + $separators = preg_split('/ *[|] */', $underline); + foreach ($separators as $n => $s) { + if (preg_match('/^ *-+: *$/', $s)) $attr[$n] = ' align="right"'; + else if (preg_match('/^ *:-+: *$/', $s))$attr[$n] = ' align="center"'; + else if (preg_match('/^ *:-+ *$/', $s)) $attr[$n] = ' align="left"'; + else $attr[$n] = ''; + } + + # Parsing span elements, including code spans, character escapes, + # and inline HTML tags, so that pipes inside those gets ignored. + $head = $this->parseSpan($head); + $headers = preg_split('/ *[|] */', $head); + $col_count = count($headers); + $attr = array_pad($attr, $col_count, ''); + + # Write column headers. + $text = "<table>\n"; + $text .= "<thead>\n"; + $text .= "<tr>\n"; + foreach ($headers as $n => $header) + $text .= " <th$attr[$n]>".$this->runSpanGamut(trim($header))."</th>\n"; + $text .= "</tr>\n"; + $text .= "</thead>\n"; + + # Split content by row. + $rows = explode("\n", trim($content, "\n")); + + $text .= "<tbody>\n"; + foreach ($rows as $row) { + # Parsing span elements, including code spans, character escapes, + # and inline HTML tags, so that pipes inside those gets ignored. + $row = $this->parseSpan($row); + + # Split row by cell. + $row_cells = preg_split('/ *[|] */', $row, $col_count); + $row_cells = array_pad($row_cells, $col_count, ''); + + $text .= "<tr>\n"; + foreach ($row_cells as $n => $cell) + $text .= " <td$attr[$n]>".$this->runSpanGamut(trim($cell))."</td>\n"; + $text .= "</tr>\n"; + } + $text .= "</tbody>\n"; + $text .= "</table>"; + + return $this->hashBlock($text) . "\n"; + } + + + function doDefLists($text) { + # + # Form HTML definition lists. + # + $less_than_tab = $this->tab_width - 1; + + # Re-usable pattern to match any entire dl list: + $whole_list_re = '(?> + ( # $1 = whole list + ( # $2 + [ ]{0,'.$less_than_tab.'} + ((?>.*\S.*\n)+) # $3 = defined term + \n? + [ ]{0,'.$less_than_tab.'}:[ ]+ # colon starting definition + ) + (?s:.+?) + ( # $4 + \z + | + \n{2,} + (?=\S) + (?! # Negative lookahead for another term + [ ]{0,'.$less_than_tab.'} + (?: \S.*\n )+? # defined term + \n? + [ ]{0,'.$less_than_tab.'}:[ ]+ # colon starting definition + ) + (?! # Negative lookahead for another definition + [ ]{0,'.$less_than_tab.'}:[ ]+ # colon starting definition + ) + ) + ) + )'; // mx + + $text = preg_replace_callback('{ + (?>\A\n?|(?<=\n\n)) + '.$whole_list_re.' + }mx', + array(&$this, '_doDefLists_callback'), $text); + + return $text; + } + function _doDefLists_callback($matches) { + # Re-usable patterns to match list item bullets and number markers: + $list = $matches[1]; + + # Turn double returns into triple returns, so that we can make a + # paragraph for the last item in a list, if necessary: + $result = trim($this->processDefListItems($list)); + $result = "<dl>\n" . $result . "\n</dl>"; + return $this->hashBlock($result) . "\n\n"; + } + + + function processDefListItems($list_str) { + # + # Process the contents of a single definition list, splitting it + # into individual term and definition list items. + # + $less_than_tab = $this->tab_width - 1; + + # trim trailing blank lines: + $list_str = preg_replace("/\n{2,}\\z/", "\n", $list_str); + + # Process definition terms. + $list_str = preg_replace_callback('{ + (?>\A\n?|\n\n+) # leading line + ( # definition terms = $1 + [ ]{0,'.$less_than_tab.'} # leading whitespace + (?!\:[ ]|[ ]) # negative lookahead for a definition + # mark (colon) or more whitespace. + (?> \S.* \n)+? # actual term (not whitespace). + ) + (?=\n?[ ]{0,3}:[ ]) # lookahead for following line feed + # with a definition mark. + }xm', + array(&$this, '_processDefListItems_callback_dt'), $list_str); + + # Process actual definitions. + $list_str = preg_replace_callback('{ + \n(\n+)? # leading line = $1 + ( # marker space = $2 + [ ]{0,'.$less_than_tab.'} # whitespace before colon + \:[ ]+ # definition mark (colon) + ) + ((?s:.+?)) # definition text = $3 + (?= \n+ # stop at next definition mark, + (?: # next term or end of text + [ ]{0,'.$less_than_tab.'} \:[ ] | + <dt> | \z + ) + ) + }xm', + array(&$this, '_processDefListItems_callback_dd'), $list_str); + + return $list_str; + } + function _processDefListItems_callback_dt($matches) { + $terms = explode("\n", trim($matches[1])); + $text = ''; + foreach ($terms as $term) { + $term = $this->runSpanGamut(trim($term)); + $text .= "\n<dt>" . $term . "</dt>"; + } + return $text . "\n"; + } + function _processDefListItems_callback_dd($matches) { + $leading_line = $matches[1]; + $marker_space = $matches[2]; + $def = $matches[3]; + + if ($leading_line || preg_match('/\n{2,}/', $def)) { + # Replace marker with the appropriate whitespace indentation + $def = str_repeat(' ', strlen($marker_space)) . $def; + $def = $this->runBlockGamut($this->outdent($def . "\n\n")); + $def = "\n". $def ."\n"; + } + else { + $def = rtrim($def); + $def = $this->runSpanGamut($this->outdent($def)); + } + + return "\n<dd>" . $def . "</dd>\n"; + } + + + function doFencedCodeBlocks($text) { + # + # Adding the fenced code block syntax to regular Markdown: + # + # ~~~ + # Code block + # ~~~ + # + $less_than_tab = $this->tab_width; + + $text = preg_replace_callback('{ + (?:\n|\A) + # 1: Opening marker + ( + (?:~{3,}|`{3,}) # 3 or more tildes/backticks. + ) + [ ]* + (?: + \.?([-_:a-zA-Z0-9]+) # 2: standalone class name + | + '.$this->id_class_attr_catch_re.' # 3: Extra attributes + )? + [ ]* \n # Whitespace and newline following marker. + + # 4: Content + ( + (?> + (?!\1 [ ]* \n) # Not a closing marker. + .*\n+ + )+ + ) + + # Closing marker. + \1 [ ]* (?= \n ) + }xm', + array(&$this, '_doFencedCodeBlocks_callback'), $text); + + return $text; + } + function _doFencedCodeBlocks_callback($matches) { + $classname =& $matches[2]; + $attrs =& $matches[3]; + $codeblock = $matches[4]; + $codeblock = htmlspecialchars($codeblock, ENT_NOQUOTES); + $codeblock = preg_replace_callback('/^\n+/', + array(&$this, '_doFencedCodeBlocks_newlines'), $codeblock); + + if ($classname != "") { + if ($classname{0} == '.') + $classname = substr($classname, 1); + $attr_str = ' class="'.$this->code_class_prefix.$classname.'"'; + } else { + $attr_str = $this->doExtraAttributes($this->code_attr_on_pre ? "pre" : "code", $attrs); + } + $pre_attr_str = $this->code_attr_on_pre ? $attr_str : ''; + $code_attr_str = $this->code_attr_on_pre ? '' : $attr_str; + $codeblock = "<pre$pre_attr_str><code$code_attr_str>$codeblock</code></pre>"; + + return "\n\n".$this->hashBlock($codeblock)."\n\n"; + } + function _doFencedCodeBlocks_newlines($matches) { + return str_repeat("<br$this->empty_element_suffix", + strlen($matches[0])); + } + + + # + # Redefining emphasis markers so that emphasis by underscore does not + # work in the middle of a word. + # + public $em_relist = array( + '' => '(?:(?<!\*)\*(?!\*)|(?<![a-zA-Z0-9_])_(?!_))(?=\S|$)(?![\.,:;]\s)', + '*' => '(?<=\S|^)(?<!\*)\*(?!\*)', + '_' => '(?<=\S|^)(?<!_)_(?![a-zA-Z0-9_])', + ); + public $strong_relist = array( + '' => '(?:(?<!\*)\*\*(?!\*)|(?<![a-zA-Z0-9_])__(?!_))(?=\S|$)(?![\.,:;]\s)', + '**' => '(?<=\S|^)(?<!\*)\*\*(?!\*)', + '__' => '(?<=\S|^)(?<!_)__(?![a-zA-Z0-9_])', + ); + public $em_strong_relist = array( + '' => '(?:(?<!\*)\*\*\*(?!\*)|(?<![a-zA-Z0-9_])___(?!_))(?=\S|$)(?![\.,:;]\s)', + '***' => '(?<=\S|^)(?<!\*)\*\*\*(?!\*)', + '___' => '(?<=\S|^)(?<!_)___(?![a-zA-Z0-9_])', + ); + + + function formParagraphs($text) { + # + # Params: + # $text - string to process with html <p> tags + # + # Strip leading and trailing lines: + $text = preg_replace('/\A\n+|\n+\z/', '', $text); + + $grafs = preg_split('/\n{2,}/', $text, -1, PREG_SPLIT_NO_EMPTY); + + # + # Wrap <p> tags and unhashify HTML blocks + # + foreach ($grafs as $key => $value) { + $value = trim($this->runSpanGamut($value)); + + # Check if this should be enclosed in a paragraph. + # Clean tag hashes & block tag hashes are left alone. + $is_p = !preg_match('/^B\x1A[0-9]+B|^C\x1A[0-9]+C$/', $value); + + if ($is_p) { + $value = "<p>$value</p>"; + } + $grafs[$key] = $value; + } + + # Join grafs in one text, then unhash HTML tags. + $text = implode("\n\n", $grafs); + + # Finish by removing any tag hashes still present in $text. + $text = $this->unhash($text); + + return $text; + } + + + ### Footnotes + + function stripFootnotes($text) { + # + # Strips link definitions from text, stores the URLs and titles in + # hash references. + # + $less_than_tab = $this->tab_width - 1; + + # Link defs are in the form: [^id]: url "optional title" + $text = preg_replace_callback('{ + ^[ ]{0,'.$less_than_tab.'}\[\^(.+?)\][ ]?: # note_id = $1 + [ ]* + \n? # maybe *one* newline + ( # text = $2 (no blank lines allowed) + (?: + .+ # actual text + | + \n # newlines but + (?!\[\^.+?\]:\s)# negative lookahead for footnote marker. + (?!\n+[ ]{0,3}\S)# ensure line is not blank and followed + # by non-indented content + )* + ) + }xm', + array(&$this, '_stripFootnotes_callback'), + $text); + return $text; + } + function _stripFootnotes_callback($matches) { + $note_id = $this->fn_id_prefix . $matches[1]; + $this->footnotes[$note_id] = $this->outdent($matches[2]); + return ''; # String that will replace the block + } + + + function doFootnotes($text) { + # + # Replace footnote references in $text [^id] with a special text-token + # which will be replaced by the actual footnote marker in appendFootnotes. + # + if (!$this->in_anchor) { + $text = preg_replace('{\[\^(.+?)\]}', "F\x1Afn:\\1\x1A:", $text); + } + return $text; + } + + + function appendFootnotes($text) { + # + # Append footnote list to text. + # + $text = preg_replace_callback('{F\x1Afn:(.*?)\x1A:}', + array(&$this, '_appendFootnotes_callback'), $text); + + if (!empty($this->footnotes_ordered)) { + $text .= "\n\n"; + $text .= "<div class=\"footnotes\">\n"; + $text .= "<hr". $this->empty_element_suffix ."\n"; + $text .= "<ol>\n\n"; + + $attr = ""; + if ($this->fn_backlink_class != "") { + $class = $this->fn_backlink_class; + $class = $this->encodeAttribute($class); + $attr .= " class=\"$class\""; + } + if ($this->fn_backlink_title != "") { + $title = $this->fn_backlink_title; + $title = $this->encodeAttribute($title); + $attr .= " title=\"$title\""; + } + $num = 0; + + while (!empty($this->footnotes_ordered)) { + $footnote = reset($this->footnotes_ordered); + $note_id = key($this->footnotes_ordered); + unset($this->footnotes_ordered[$note_id]); + $ref_count = $this->footnotes_ref_count[$note_id]; + unset($this->footnotes_ref_count[$note_id]); + unset($this->footnotes[$note_id]); + + $footnote .= "\n"; # Need to append newline before parsing. + $footnote = $this->runBlockGamut("$footnote\n"); + $footnote = preg_replace_callback('{F\x1Afn:(.*?)\x1A:}', + array(&$this, '_appendFootnotes_callback'), $footnote); + + $attr = str_replace("%%", ++$num, $attr); + $note_id = $this->encodeAttribute($note_id); + + # Prepare backlink, multiple backlinks if multiple references + $backlink = "<a href=\"#fnref:$note_id\"$attr>↩</a>"; + for ($ref_num = 2; $ref_num <= $ref_count; ++$ref_num) { + $backlink .= " <a href=\"#fnref$ref_num:$note_id\"$attr>↩</a>"; + } + # Add backlink to last paragraph; create new paragraph if needed. + if (preg_match('{</p>$}', $footnote)) { + $footnote = substr($footnote, 0, -4) . " $backlink</p>"; + } else { + $footnote .= "\n\n<p>$backlink</p>"; + } + + $text .= "<li id=\"fn:$note_id\">\n"; + $text .= $footnote . "\n"; + $text .= "</li>\n\n"; + } + + $text .= "</ol>\n"; + $text .= "</div>"; + } + return $text; + } + function _appendFootnotes_callback($matches) { + $node_id = $this->fn_id_prefix . $matches[1]; + + # Create footnote marker only if it has a corresponding footnote *and* + # the footnote hasn't been used by another marker. + if (isset($this->footnotes[$node_id])) { + $num =& $this->footnotes_numbers[$node_id]; + if (!isset($num)) { + # Transfer footnote content to the ordered list and give it its + # number + $this->footnotes_ordered[$node_id] = $this->footnotes[$node_id]; + $this->footnotes_ref_count[$node_id] = 1; + $num = $this->footnote_counter++; + $ref_count_mark = ''; + } else { + $ref_count_mark = $this->footnotes_ref_count[$node_id] += 1; + } + + $attr = ""; + if ($this->fn_link_class != "") { + $class = $this->fn_link_class; + $class = $this->encodeAttribute($class); + $attr .= " class=\"$class\""; + } + if ($this->fn_link_title != "") { + $title = $this->fn_link_title; + $title = $this->encodeAttribute($title); + $attr .= " title=\"$title\""; + } + + $attr = str_replace("%%", $num, $attr); + $node_id = $this->encodeAttribute($node_id); + + return + "<sup id=\"fnref$ref_count_mark:$node_id\">". + "<a href=\"#fn:$node_id\"$attr>$num</a>". + "</sup>"; + } + + return "[^".$matches[1]."]"; + } + + + ### Abbreviations ### + + function stripAbbreviations($text) { + # + # Strips abbreviations from text, stores titles in hash references. + # + $less_than_tab = $this->tab_width - 1; + + # Link defs are in the form: [id]*: url "optional title" + $text = preg_replace_callback('{ + ^[ ]{0,'.$less_than_tab.'}\*\[(.+?)\][ ]?: # abbr_id = $1 + (.*) # text = $2 (no blank lines allowed) + }xm', + array(&$this, '_stripAbbreviations_callback'), + $text); + return $text; + } + function _stripAbbreviations_callback($matches) { + $abbr_word = $matches[1]; + $abbr_desc = $matches[2]; + if ($this->abbr_word_re) + $this->abbr_word_re .= '|'; + $this->abbr_word_re .= preg_quote($abbr_word); + $this->abbr_desciptions[$abbr_word] = trim($abbr_desc); + return ''; # String that will replace the block + } + + + function doAbbreviations($text) { + # + # Find defined abbreviations in text and wrap them in <abbr> elements. + # + if ($this->abbr_word_re) { + // cannot use the /x modifier because abbr_word_re may + // contain significant spaces: + $text = preg_replace_callback('{'. + '(?<![\w\x1A])'. + '(?:'.$this->abbr_word_re.')'. + '(?![\w\x1A])'. + '}', + array(&$this, '_doAbbreviations_callback'), $text); + } + return $text; + } + function _doAbbreviations_callback($matches) { + $abbr = $matches[0]; + if (isset($this->abbr_desciptions[$abbr])) { + $desc = $this->abbr_desciptions[$abbr]; + if (empty($desc)) { + return $this->hashPart("<abbr>$abbr</abbr>"); + } else { + $desc = $this->encodeAttribute($desc); + return $this->hashPart("<abbr title=\"$desc\">$abbr</abbr>"); + } + } else { + return $matches[0]; + } + } + +} + + +/* + +PHP Markdown Extra +================== + +Description +----------- + +This is a PHP port of the original Markdown formatter written in Perl +by John Gruber. This special "Extra" version of PHP Markdown features +further enhancements to the syntax for making additional constructs +such as tables and definition list. + +Markdown is a text-to-HTML filter; it translates an easy-to-read / +easy-to-write structured text format into HTML. Markdown's text format +is mostly similar to that of plain text email, and supports features such +as headers, *emphasis*, code blocks, blockquotes, and links. + +Markdown's syntax is designed not as a generic markup language, but +specifically to serve as a front-end to (X)HTML. You can use span-level +HTML tags anywhere in a Markdown document, and you can use block level +HTML tags (like <div> and <table> as well). + +For more information about Markdown's syntax, see: + +<http://daringfireball.net/projects/markdown/> + + +Bugs +---- + +To file bug reports please send email to: + +<michel.fortin@michelf.ca> + +Please include with your report: (1) the example input; (2) the output you +expected; (3) the output Markdown actually produced. + + +Version History +--------------- + +See the readme file for detailed release notes for this version. + + +Copyright and License +--------------------- + +PHP Markdown & Extra +Copyright (c) 2004-2013 Michel Fortin +<http://michelf.ca/> +All rights reserved. + +Based on Markdown +Copyright (c) 2003-2006 John Gruber +<http://daringfireball.net/> +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* Neither the name "Markdown" nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +This software is provided by the copyright holders and contributors "as +is" and any express or implied warranties, including, but not limited +to, the implied warranties of merchantability and fitness for a +particular purpose are disclaimed. In no event shall the copyright owner +or contributors be liable for any direct, indirect, incidental, special, +exemplary, or consequential damages (including, but not limited to, +procurement of substitute goods or services; loss of use, data, or +profits; or business interruption) however caused and on any theory of +liability, whether in contract, strict liability, or tort (including +negligence or otherwise) arising in any way out of the use of this +software, even if advised of the possibility of such damage. + +*/ +?> diff --git a/plugins/jetpack/_inc/lib/markdown/gfm.php b/plugins/jetpack/_inc/lib/markdown/gfm.php new file mode 100644 index 00000000..081e1a11 --- /dev/null +++ b/plugins/jetpack/_inc/lib/markdown/gfm.php @@ -0,0 +1,400 @@ +<?php +/** + * GitHub-Flavoured Markdown. Inspired by Evan's plugin, but modified. + * + * @author Evan Solomon + * @author Matt Wiebe <wiebe@automattic.com> + * @link https://github.com/evansolomon/wp-github-flavored-markdown-comments + * + * Add a few extras from GitHub's Markdown implementation. Must be used in a WordPress environment. + */ + +class WPCom_GHF_Markdown_Parser extends MarkdownExtra_Parser { + + /** + * Hooray somewhat arbitrary numbers that are fearful of 1.0.x. + */ + const WPCOM_GHF_MARDOWN_VERSION = '0.9.0'; + + /** + * Use a [code] shortcode when encountering a fenced code block + * @var boolean + */ + public $use_code_shortcode = true; + + /** + * Preserve shortcodes, untouched by Markdown. + * This requires use within a WordPress installation. + * @var boolean + */ + public $preserve_shortcodes = true; + + /** + * Preserve the legacy $latex your-latex-code-here$ style + * LaTeX markup + */ + public $preserve_latex = true; + + /** + * Preserve single-line <code> blocks. + * @var boolean + */ + public $preserve_inline_code_blocks = true; + + /** + * Strip paragraphs from the output. This is the right default for WordPress, + * which generally wants to create its own paragraphs with `wpautop` + * @var boolean + */ + public $strip_paras = true; + + // Will run through sprintf - you can supply your own syntax if you want + public $shortcode_start = '[code lang=%s]'; + public $shortcode_end = '[/code]'; + + // Stores shortcodes we remove and then replace + protected $preserve_text_hash = array(); + + /** + * Set environment defaults based on presence of key functions/classes. + */ + public function __construct() { + $this->use_code_shortcode = class_exists( 'SyntaxHighlighter' ); + /** + * Allow processing shortcode contents. + * + * @module markdown + * + * @since 4.4.0 + * + * @param boolean $preserve_shortcodes Defaults to $this->preserve_shortcodes. + */ + $this->preserve_shortcodes = apply_filters( 'jetpack_markdown_preserve_shortcodes', $this->preserve_shortcodes ) && function_exists( 'get_shortcode_regex' ); + $this->preserve_latex = function_exists( 'latex_markup' ); + $this->strip_paras = function_exists( 'wpautop' ); + + parent::__construct(); + } + + /** + * Overload to specify heading styles only if the hash has space(s) after it. This is actually in keeping with + * the documentation and eases the semantic overload of the hash character. + * #Will Not Produce a Heading 1 + * # This Will Produce a Heading 1 + * + * @param string $text Markdown text + * @return string HTML-transformed text + */ + public function transform( $text ) { + // Preserve anything inside a single-line <code> element + if ( $this->preserve_inline_code_blocks ) { + $text = $this->single_line_code_preserve( $text ); + } + // Remove all shortcodes so their interiors are left intact + if ( $this->preserve_shortcodes ) { + $text = $this->shortcode_preserve( $text ); + } + // Remove legacy LaTeX so it's left intact + if ( $this->preserve_latex ) { + $text = $this->latex_preserve( $text ); + } + + // escape line-beginning # chars that do not have a space after them. + $text = preg_replace_callback( '|^#{1,6}( )?|um', array( $this, '_doEscapeForHashWithoutSpacing' ), $text ); + + /** + * Allow third-party plugins to define custom patterns that won't be processed by Markdown. + * + * @module markdown + * + * @since 3.9.2 + * + * @param array $custom_patterns Array of custom patterns to be ignored by Markdown. + */ + $custom_patterns = apply_filters( 'jetpack_markdown_preserve_pattern', array() ); + if ( is_array( $custom_patterns ) && ! empty( $custom_patterns ) ) { + foreach ( $custom_patterns as $pattern ) { + $text = preg_replace_callback( $pattern, array( $this, '_doRemoveText'), $text ); + } + } + + // run through core Markdown + $text = parent::transform( $text ); + + // Occasionally Markdown Extra chokes on a para structure, producing odd paragraphs. + $text = str_replace( "<p><</p>\n\n<p>p>", '<p>', $text ); + + // put start-of-line # chars back in place + $text = $this->restore_leading_hash( $text ); + + // Strip paras if set + if ( $this->strip_paras ) { + $text = $this->unp( $text ); + } + + // Restore preserved things like shortcodes/LaTeX + $text = $this->do_restore( $text ); + + return $text; + } + + /** + * Prevents blocks like <code>__this__</code> from turning into <code><strong>this</strong></code> + * @param string $text Text that may need preserving + * @return string Text that was preserved if needed + */ + public function single_line_code_preserve( $text ) { + return preg_replace_callback( '|<code\b[^>]*>(.*?)</code>|', array( $this, 'do_single_line_code_preserve' ), $text ); + } + + /** + * Regex callback for inline code presevation + * @param array $matches Regex matches + * @return string Hashed content for later restoration + */ + public function do_single_line_code_preserve( $matches ) { + return '<code>' . $this->hash_block( $matches[1] ) . '</code>'; + } + + /** + * Preserve code block contents by HTML encoding them. Useful before getting to KSES stripping. + * @param string $text Markdown/HTML content + * @return string Markdown/HTML content with escaped code blocks + */ + public function codeblock_preserve( $text ) { + return preg_replace_callback( "/^([`~]{3})([^`\n]+)?\n([^`~]+)(\\1)/m", array( $this, 'do_codeblock_preserve' ), $text ); + } + + /** + * Regex callback for code block preservation. + * @param array $matches Regex matches + * @return string Codeblock with escaped interior + */ + public function do_codeblock_preserve( $matches ) { + $block = stripslashes( $matches[3] ); + $block = esc_html( $block ); + $block = str_replace( '\\', '\\\\', $block ); + $open = $matches[1] . $matches[2] . "\n"; + return $open . $block . $matches[4]; + } + + /** + * Restore previously preserved (i.e. escaped) code block contents. + * @param string $text Markdown/HTML content with escaped code blocks + * @return string Markdown/HTML content + */ + public function codeblock_restore( $text ) { + return preg_replace_callback( "/^([`~]{3})([^`\n]+)?\n([^`~]+)(\\1)/m", array( $this, 'do_codeblock_restore' ), $text ); + } + + /** + * Regex callback for code block restoration (unescaping). + * @param array $matches Regex matches + * @return string Codeblock with unescaped interior + */ + public function do_codeblock_restore( $matches ) { + $block = html_entity_decode( $matches[3], ENT_QUOTES ); + $open = $matches[1] . $matches[2] . "\n"; + return $open . $block . $matches[4]; + } + + /** + * Called to preserve legacy LaTeX like $latex some-latex-text $ + * @param string $text Text in which to preserve LaTeX + * @return string Text with LaTeX replaced by a hash that will be restored later + */ + protected function latex_preserve( $text ) { + // regex from latex_remove() + $regex = '% + \$latex(?:=\s*|\s+) + ((?: + [^$]+ # Not a dollar + | + (?<=(?<!\\\\)\\\\)\$ # Dollar preceded by exactly one slash + )+) + (?<!\\\\)\$ # Dollar preceded by zero slashes + %ix'; + $text = preg_replace_callback( $regex, array( $this, '_doRemoveText'), $text ); + return $text; + } + + /** + * Called to preserve WP shortcodes from being formatted by Markdown in any way. + * @param string $text Text in which to preserve shortcodes + * @return string Text with shortcodes replaced by a hash that will be restored later + */ + protected function shortcode_preserve( $text ) { + $text = preg_replace_callback( $this->get_shortcode_regex(), array( $this, '_doRemoveText' ), $text ); + return $text; + } + + /** + * Restores any text preserved by $this->hash_block() + * @param string $text Text that may have hashed preservation placeholders + * @return string Text with hashed preseravtion placeholders replaced by original text + */ + protected function do_restore( $text ) { + // Reverse hashes to ensure nested blocks are restored. + $hashes = array_reverse( $this->preserve_text_hash, true ); + foreach( $hashes as $hash => $value ) { + $placeholder = $this->hash_maker( $hash ); + $text = str_replace( $placeholder, $value, $text ); + } + // reset the hash + $this->preserve_text_hash = array(); + return $text; + } + + /** + * Regex callback for text preservation + * @param array $m Regex $matches array + * @return string A placeholder that will later be replaced by the original text + */ + protected function _doRemoveText( $m ) { + return $this->hash_block( $m[0] ); + } + + /** + * Call this to store a text block for later restoration. + * @param string $text Text to preserve for later + * @return string Placeholder that will be swapped out later for the original text + */ + protected function hash_block( $text ) { + $hash = md5( $text ); + $this->preserve_text_hash[ $hash ] = $text; + $placeholder = $this->hash_maker( $hash ); + return $placeholder; + } + + /** + * Less glamorous than the Keymaker + * @param string $hash An md5 hash + * @return string A placeholder hash + */ + protected function hash_maker( $hash ) { + return 'MARKDOWN_HASH' . $hash . 'MARKDOWN_HASH'; + } + + /** + * Remove bare <p> elements. <p>s with attributes will be preserved. + * @param string $text HTML content + * @return string <p>-less content + */ + public function unp( $text ) { + return preg_replace( "#<p>(.*?)</p>(\n|$)#ums", '$1$2', $text ); + } + + /** + * A regex of all shortcodes currently registered by the current + * WordPress installation + * @uses get_shortcode_regex() + * @return string A regex for grabbing shortcodes. + */ + protected function get_shortcode_regex() { + $pattern = get_shortcode_regex(); + + // don't match markdown link anchors that could be mistaken for shortcodes. + $pattern .= '(?!\()'; + + return "/$pattern/s"; + } + + /** + * Since we escape unspaced #Headings, put things back later. + * @param string $text text with a leading escaped hash + * @return string text with leading hashes unescaped + */ + protected function restore_leading_hash( $text ) { + return preg_replace( "/^(<p>)?(#|\\\\#)/um", "$1#", $text ); + } + + /** + * Overload to support ```-fenced code blocks for pre-Markdown Extra 1.2.8 + * https://help.github.com/articles/github-flavored-markdown#fenced-code-blocks + */ + public function doFencedCodeBlocks( $text ) { + // If we're at least at 1.2.8, native fenced code blocks are in. + // Below is just copied from it in case we somehow got loaded on + // top of someone else's Markdown Extra + if ( version_compare( MARKDOWNEXTRA_VERSION, '1.2.8', '>=' ) ) + return parent::doFencedCodeBlocks( $text ); + + # + # Adding the fenced code block syntax to regular Markdown: + # + # ~~~ + # Code block + # ~~~ + # + $less_than_tab = $this->tab_width; + + $text = preg_replace_callback('{ + (?:\n|\A) + # 1: Opening marker + ( + (?:~{3,}|`{3,}) # 3 or more tildes/backticks. + ) + [ ]* + (?: + \.?([-_:a-zA-Z0-9]+) # 2: standalone class name + | + '.$this->id_class_attr_catch_re.' # 3: Extra attributes + )? + [ ]* \n # Whitespace and newline following marker. + + # 4: Content + ( + (?> + (?!\1 [ ]* \n) # Not a closing marker. + .*\n+ + )+ + ) + + # Closing marker. + \1 [ ]* (?= \n ) + }xm', + array($this, '_doFencedCodeBlocks_callback'), $text); + + return $text; + } + + /** + * Callback for pre-processing start of line hashes to slyly escape headings that don't + * have a leading space + * @param array $m preg_match matches + * @return string possibly escaped start of line hash + */ + public function _doEscapeForHashWithoutSpacing( $m ) { + if ( ! isset( $m[1] ) ) + $m[0] = '\\' . $m[0]; + return $m[0]; + } + + /** + * Overload to support Viper's [code] shortcode. Because awesome. + */ + public function _doFencedCodeBlocks_callback( $matches ) { + // in case we have some escaped leading hashes right at the start of the block + $matches[4] = $this->restore_leading_hash( $matches[4] ); + // just MarkdownExtra_Parser if we're not going ultra-deluxe + if ( ! $this->use_code_shortcode ) { + return parent::_doFencedCodeBlocks_callback( $matches ); + } + + // default to a "text" class if one wasn't passed. Helps with encoding issues later. + if ( empty( $matches[2] ) ) { + $matches[2] = 'text'; + } + + $classname =& $matches[2]; + $codeblock = preg_replace_callback('/^\n+/', array( $this, '_doFencedCodeBlocks_newlines' ), $matches[4] ); + + if ( $classname{0} == '.' ) + $classname = substr( $classname, 1 ); + + $codeblock = esc_html( $codeblock ); + $codeblock = sprintf( $this->shortcode_start, $classname ) . "\n{$codeblock}" . $this->shortcode_end; + return "\n\n" . $this->hashBlock( $codeblock ). "\n\n"; + } + +} diff --git a/plugins/jetpack/_inc/lib/plugins.php b/plugins/jetpack/_inc/lib/plugins.php new file mode 100644 index 00000000..9c8e3bc4 --- /dev/null +++ b/plugins/jetpack/_inc/lib/plugins.php @@ -0,0 +1,132 @@ +<?php +/** + * Plugins Library + * + * Helper functions for installing and activating plugins. + * + * Used by the REST API + * + * @autounit api plugins + */ + +include_once( 'class.jetpack-automatic-install-skin.php' ); + +class Jetpack_Plugins { + + /** + * Install and activate a plugin. + * + * @since 5.8.0 + * + * @param string $slug Plugin slug. + * + * @return bool|WP_Error True if installation succeeded, error object otherwise. + */ + public static function install_and_activate_plugin( $slug ) { + $plugin_id = self::get_plugin_id_by_slug( $slug ); + + if ( ! $plugin_id ) { + $installed = self::install_plugin( $slug ); + if ( is_wp_error( $installed ) ) { + return $installed; + } + $plugin_id = self::get_plugin_id_by_slug( $slug ); + } else if ( is_plugin_active( $plugin_id ) ) { + return true; // Already installed and active + } + + if ( ! current_user_can( 'activate_plugins' ) ) { + return new WP_Error( 'not_allowed', __( 'You are not allowed to activate plugins on this site.', 'jetpack' ) ); + } + + $activated = activate_plugin( $plugin_id ); + if ( is_wp_error( $activated ) ) { + return $activated; + } + + return true; + } + + /** + * Install a plugin. + * + * @since 5.8.0 + * + * @param string $slug Plugin slug. + * + * @return bool|WP_Error True if installation succeeded, error object otherwise. + */ + public static function install_plugin( $slug ) { + if ( is_multisite() && ! current_user_can( 'manage_network' ) ) { + return new WP_Error( 'not_allowed', __( 'You are not allowed to install plugins on this site.', 'jetpack' ) ); + } + + $skin = new Jetpack_Automatic_Install_Skin(); + $upgrader = new Plugin_Upgrader( $skin ); + $zip_url = self::generate_wordpress_org_plugin_download_link( $slug ); + + $result = $upgrader->install( $zip_url ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $plugin = Jetpack_Plugins::get_plugin_id_by_slug( $slug ); + $error_code = 'install_error'; + if ( ! $plugin ) { + $error = __( 'There was an error installing your plugin', 'jetpack' ); + } + + if ( ! $result ) { + $error_code = $upgrader->skin->get_main_error_code(); + $message = $upgrader->skin->get_main_error_message(); + $error = $message ? $message : __( 'An unknown error occurred during installation', 'jetpack' ); + } + + if ( ! empty( $error ) ) { + if ( 'download_failed' === $error_code ) { + // For backwards compatibility: versions prior to 3.9 would return no_package instead of download_failed. + $error_code = 'no_package'; + } + + return new WP_Error( $error_code, $error, 400 ); + } + + return (array) $upgrader->skin->get_upgrade_messages(); + } + + protected static function generate_wordpress_org_plugin_download_link( $plugin_slug ) { + return "https://downloads.wordpress.org/plugin/$plugin_slug.latest-stable.zip"; + } + + public static function get_plugin_id_by_slug( $slug ) { + // Check if get_plugins() function exists. This is required on the front end of the + // site, since it is in a file that is normally only loaded in the admin. + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + /** This filter is documented in wp-admin/includes/class-wp-plugins-list-table.php */ + $plugins = apply_filters( 'all_plugins', get_plugins() ); + if ( ! is_array( $plugins ) ) { + return false; + } + foreach ( $plugins as $plugin_file => $plugin_data ) { + if ( self::get_slug_from_file_path( $plugin_file ) === $slug ) { + return $plugin_file; + } + } + + return false; + } + + protected static function get_slug_from_file_path( $plugin_file ) { + // Similar to get_plugin_slug() method. + $slug = dirname( $plugin_file ); + if ( '.' === $slug ) { + $slug = preg_replace( "/(.+)\.php$/", "$1", $plugin_file ); + } + + return $slug; + } +} diff --git a/plugins/jetpack/_inc/lib/tonesque.php b/plugins/jetpack/_inc/lib/tonesque.php new file mode 100644 index 00000000..17158e3d --- /dev/null +++ b/plugins/jetpack/_inc/lib/tonesque.php @@ -0,0 +1,237 @@ +<?php +/* +Plugin Name: Tonesque +Plugin URI: http://automattic.com/ +Description: Grab an average color representation from an image. +Version: 1.0 +Author: Automattic, Matias Ventura +Author URI: http://automattic.com/ +License: GNU General Public License v2 or later +License URI: http://www.gnu.org/licenses/gpl-2.0.html +*/ + +class Tonesque { + + private $image_url = ''; + private $image_obj = NULL; + private $color = ''; + + function __construct( $image_url ) { + if ( ! class_exists( 'Jetpack_Color' ) ) { + jetpack_require_lib( 'class.color' ); + } + + $this->image_url = esc_url_raw( $image_url ); + $this->image_url = trim( $this->image_url ); + /** + * Allows any image URL to be passed in for $this->image_url. + * + * @module theme-tools + * + * @since 2.5.0 + * + * @param string $image_url The URL to any image + */ + $this->image_url = apply_filters( 'tonesque_image_url', $this->image_url ); + + $this->image_obj = self::imagecreatefromurl( $this->image_url ); + } + + public static function imagecreatefromurl( $image_url ) { + $data = null; + + // If it's a URL: + if ( preg_match( '#^https?://#i', $image_url ) ) { + // If it's a url pointing to a local media library url: + $content_url = content_url(); + $_image_url = set_url_scheme( $image_url ); + if ( wp_startswith( $_image_url, $content_url ) ) { + $_image_path = str_replace( $content_url, WP_CONTENT_DIR, $_image_url ); + if ( file_exists( $_image_path ) ) { + $filetype = wp_check_filetype( $_image_path ); + $ext = $filetype['ext']; + $type = $filetype['type']; + + if ( wp_startswith( $type, 'image/' ) ) { + $data = file_get_contents( $_image_path ); + } + } + } + + if ( empty( $data ) ) { + $response = wp_remote_get( $image_url ); + if ( is_wp_error( $response ) ) { + return false; + } + $data = wp_remote_retrieve_body( $response ); + } + } + + // If it's a local path in our WordPress install: + if ( file_exists( $image_url ) ) { + $filetype = wp_check_filetype( $image_url ); + $ext = $filetype['ext']; + $type = $filetype['type']; + + if ( wp_startswith( $type, 'image/' ) ) { + $data = file_get_contents( $image_url ); + } + } + + // Now turn it into an image and return it. + return imagecreatefromstring( $data ); + } + + /** + * + * Construct object from image. + * + * @param optional $type (hex, rgb, hsv) + * @return color as a string formatted as $type + * + */ + function color( $type = 'hex' ) { + // Bail if there is no image to work with + if ( ! $this->image_obj ) + return false; + + // Finds dominant color + $color = self::grab_color(); + // Passes value to Color class + $color = self::get_color( $color, $type ); + return $color; + } + + /** + * + * Grabs the color index for each of five sample points of the image + * + * @param $image + * @param $type can be 'index' or 'hex' + * @return array() with color indices + * + */ + function grab_points( $type = 'index' ) { + $img = $this->image_obj; + if ( ! $img ) + return false; + + $height = imagesy( $img ); + $width = imagesx( $img ); + + // Sample five points in the image + // Based on rule of thirds and center + $topy = round( $height / 3 ); + $bottomy = round( ( $height / 3 ) * 2 ); + $leftx = round( $width / 3 ); + $rightx = round( ( $width / 3 ) * 2 ); + $centery = round( $height / 2 ); + $centerx = round( $width / 2 ); + + // Cast those colors into an array + $points = array( + imagecolorat( $img, $leftx, $topy ), + imagecolorat( $img, $rightx, $topy ), + imagecolorat( $img, $leftx, $bottomy ), + imagecolorat( $img, $rightx, $bottomy ), + imagecolorat( $img, $centerx, $centery ), + ); + + if ( 'hex' == $type ) { + foreach ( $points as $i => $p ) { + $c = imagecolorsforindex( $img, $p ); + $points[ $i ] = self::get_color( array( + 'r' => $c['red'], + 'g' => $c['green'], + 'b' => $c['blue'], + ), 'hex' ); + } + } + + return $points; + } + + /** + * + * Finds the average color of the image based on five sample points + * + * @param $image + * @return array() with rgb color + * + */ + function grab_color() { + $img = $this->image_obj; + if ( ! $img ) + return false; + + $rgb = self::grab_points(); + + // Process the color points + // Find the average representation + foreach ( $rgb as $color ) { + $index = imagecolorsforindex( $img, $color ); + $r[] = $index['red']; + $g[] = $index['green']; + $b[] = $index['blue']; + + $red = round( array_sum( $r ) / 5 ); + $green = round( array_sum( $g ) / 5 ); + $blue = round( array_sum( $b ) / 5 ); + } + + // The average color of the image as rgb array + $color = array( + 'r' => $red, + 'g' => $green, + 'b' => $blue, + ); + + return $color; + } + + /** + * + * Get a Color object using /lib class.color + * Convert to appropriate type + * + * @return string + * + */ + function get_color( $color, $type ) { + $c = new Jetpack_Color( $color, 'rgb' ); + $this->color = $c; + + switch ( $type ) { + case 'rgb' : + $color = implode( $c->toRgbInt(), ',' ); + break; + case 'hex' : + $color = $c->toHex(); + break; + case 'hsv' : + $color = implode( $c->toHsvInt(), ',' ); + break; + default: + return $color = $c->toHex(); + } + + return $color; + } + + /** + * + * Checks contrast against main color + * Gives either black or white for using with opacity + * + * @return string + * + */ + function contrast() { + if ( ! $this->color ) + return false; + + $c = $this->color->getMaxContrastColor(); + return implode( $c->toRgbInt(), ',' ); + } + +}; diff --git a/plugins/jetpack/_inc/lib/tracks/class.tracks-client.php b/plugins/jetpack/_inc/lib/tracks/class.tracks-client.php new file mode 100644 index 00000000..b83c94f1 --- /dev/null +++ b/plugins/jetpack/_inc/lib/tracks/class.tracks-client.php @@ -0,0 +1,191 @@ +<?php + +/** + * Jetpack_Tracks_Client + * @autounit nosara tracks-client + * + * Send Tracks events on behalf of a user + * + * Example Usage: +```php + require( dirname(__FILE__).'path/to/tracks/class.tracks-client' ); + + $result = Jetpack_Tracks_Client::record_event( array( + '_en' => $event_name, // required + '_ui' => $user_id, // required unless _ul is provided + '_ul' => $user_login, // required unless _ui is provided + + // Optional, but recommended + '_ts' => $ts_in_ms, // Default: now + '_via_ip' => $client_ip, // we use it for geo, etc. + + // Possibly useful to set some context for the event + '_via_ua' => $client_user_agent, + '_via_url' => $client_url, + '_via_ref' => $client_referrer, + + // For user-targeted tests + 'abtest_name' => $abtest_name, + 'abtest_variation' => $abtest_variation, + + // Your application-specific properties + 'custom_property' => $some_value, + ) ); + + if ( is_wp_error( $result ) ) { + // Handle the error in your app + } +``` + */ + +require_once( dirname(__FILE__).'/class.tracks-client.php' ); + +class Jetpack_Tracks_Client { + const PIXEL = 'https://pixel.wp.com/t.gif'; + const BROWSER_TYPE = 'php-agent'; + const USER_AGENT_SLUG = 'tracks-client'; + const VERSION = '0.3'; + + /** + * record_event + * @param mixed $event Event object to send to Tracks. An array will be cast to object. Required. + * Properties are included directly in the pixel query string after light validation. + * @return mixed True on success, WP_Error on failure + */ + static function record_event( $event ) { + if ( ! Jetpack::jetpack_tos_agreed() || ! empty( $_COOKIE['tk_opt-out'] ) ) { + return false; + } + + if ( ! $event instanceof Jetpack_Tracks_Event ) { + $event = new Jetpack_Tracks_Event( $event ); + } + if ( is_wp_error( $event ) ) { + return $event; + } + + $pixel = $event->build_pixel_url( $event ); + + if ( ! $pixel ) { + return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 ); + } + + return self::record_pixel( $pixel ); + } + + /** + * Synchronously request the pixel + */ + static function record_pixel( $pixel ) { + // Add the Request Timestamp and URL terminator just before the HTTP request. + $pixel .= '&_rt=' . self::build_timestamp() . '&_=_'; + + $response = wp_remote_get( $pixel, array( + 'blocking' => true, // The default, but being explicit here :) + 'timeout' => 1, + 'redirection' => 2, + 'httpversion' => '1.1', + 'user-agent' => self::get_user_agent(), + ) ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $code = isset( $response['response']['code'] ) ? $response['response']['code'] : 0; + + if ( $code !== 200 ) { + return new WP_Error( 'request_failed', 'Tracks pixel request failed', $code ); + } + + return true; + } + + static function get_user_agent() { + return Jetpack_Tracks_Client::USER_AGENT_SLUG . '-v' . Jetpack_Tracks_Client::VERSION; + } + + /** + * Build an event and return its tracking URL + * @deprecated Call the `build_pixel_url` method on a Jetpack_Tracks_Event object instead. + * @param array $event Event keys and values + * @return string URL of a tracking pixel + */ + static function build_pixel_url( $event ) { + $_event = new Jetpack_Tracks_Event( $event ); + return $_event->build_pixel_url(); + } + + /** + * Validate input for a tracks event. + * @deprecated Instantiate a Jetpack_Tracks_Event object instead + * @param array $event Event keys and values + * @return mixed Validated keys and values or WP_Error on failure + */ + private static function validate_and_sanitize( $event ) { + $_event = new Jetpack_Tracks_Event( $event ); + if ( is_wp_error( $_event ) ) { + return $_event; + } + return get_object_vars( $_event ); + } + + // Milliseconds since 1970-01-01 + static function build_timestamp() { + $ts = round( microtime( true ) * 1000 ); + return number_format( $ts, 0, '', '' ); + } + + /** + * Grabs the user's anon id from cookies, or generates and sets a new one + * + * @return string An anon id for the user + */ + static function get_anon_id() { + static $anon_id = null; + + if ( ! isset( $anon_id ) ) { + + // Did the browser send us a cookie? + if ( isset( $_COOKIE[ 'tk_ai' ] ) && preg_match( '#^[A-Za-z0-9+/=]{24}$#', $_COOKIE[ 'tk_ai' ] ) ) { + $anon_id = $_COOKIE[ 'tk_ai' ]; + } else { + + $binary = ''; + + // Generate a new anonId and try to save it in the browser's cookies + // Note that base64-encoding an 18 character string generates a 24-character anon id + for ( $i = 0; $i < 18; ++$i ) { + $binary .= chr( mt_rand( 0, 255 ) ); + } + + $anon_id = 'jetpack:' . base64_encode( $binary ); + + if ( ! headers_sent() + && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) + && ! ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) + ) { + setcookie( 'tk_ai', $anon_id ); + } + } + } + + return $anon_id; + } + + /** + * Gets the WordPress.com user's Tracks identity, if connected. + * + * @return array|bool + */ + static function get_connected_user_tracks_identity() { + if ( ! $user_data = Jetpack::get_connected_user_data() ) { + return false; + } + + return array( + 'userid' => $user_data['ID'], + 'username' => $user_data['login'], + ); + } +} diff --git a/plugins/jetpack/_inc/lib/tracks/class.tracks-event.php b/plugins/jetpack/_inc/lib/tracks/class.tracks-event.php new file mode 100644 index 00000000..fb86e0ba --- /dev/null +++ b/plugins/jetpack/_inc/lib/tracks/class.tracks-event.php @@ -0,0 +1,149 @@ +<?php + +/** + * @autounit nosara tracks-client + * + * Example Usage: +```php + require_once( dirname(__FILE__) . 'path/to/tracks/class.tracks-event' ); + + $event = new Jetpack_Tracks_Event( array( + '_en' => $event_name, // required + '_ui' => $user_id, // required unless _ul is provided + '_ul' => $user_login, // required unless _ui is provided + + // Optional, but recommended + '_via_ip' => $client_ip, // for geo, etc. + + // Possibly useful to set some context for the event + '_via_ua' => $client_user_agent, + '_via_url' => $client_url, + '_via_ref' => $client_referrer, + + // For user-targeted tests + 'abtest_name' => $abtest_name, + 'abtest_variation' => $abtest_variation, + + // Your application-specific properties + 'custom_property' => $some_value, + ) ); + + if ( is_wp_error( $event->error ) ) { + // Handle the error in your app + } + + $bump_and_redirect_pixel = $event->build_signed_pixel_url(); +``` + */ + +require_once( dirname(__FILE__) . '/class.tracks-client.php' ); + +class Jetpack_Tracks_Event { + const EVENT_NAME_REGEX = '/^(([a-z0-9]+)_){2}([a-z0-9_]+)$/'; + const PROP_NAME_REGEX = '/^[a-z_][a-z0-9_]*$/'; + public $error; + + function __construct( $event ) { + $_event = self::validate_and_sanitize( $event ); + if ( is_wp_error( $_event ) ) { + $this->error = $_event; + return; + } + + foreach( $_event as $key => $value ) { + $this->{$key} = $value; + } + } + + function record() { + return Jetpack_Tracks_Client::record_event( $this ); + } + + /** + * Annotate the event with all relevant info. + * @param mixed $event Object or (flat) array + * @return mixed The transformed event array or WP_Error on failure. + */ + static function validate_and_sanitize( $event ) { + $event = (object) $event; + + // Required + if ( ! $event->_en ) { + return new WP_Error( 'invalid_event', 'A valid event must be specified via `_en`', 400 ); + } + + // delete non-routable addresses otherwise geoip will discard the record entirely + if ( property_exists( $event, '_via_ip' ) && preg_match( '/^192\.168|^10\./', $event->_via_ip ) ) { + unset($event->_via_ip); + } + + $validated = array( + 'browser_type' => Jetpack_Tracks_Client::BROWSER_TYPE, + '_aua' => Jetpack_Tracks_Client::get_user_agent(), + ); + + $_event = (object) array_merge( (array) $event, $validated ); + + // If you want to blacklist property names, do it here. + + // Make sure we have an event timestamp. + if ( ! isset( $_event->_ts ) ) { + $_event->_ts = Jetpack_Tracks_Client::build_timestamp(); + } + + return $_event; + } + + /** + * Build a pixel URL that will send a Tracks event when fired. + * On error, returns an empty string (''). + * + * @return string A pixel URL or empty string ('') if there were invalid args. + */ + function build_pixel_url() { + if ( $this->error ) { + return ''; + } + + $args = get_object_vars( $this ); + + // Request Timestamp and URL Terminator must be added just before the HTTP request or not at all. + unset( $args['_rt'] ); + unset( $args['_'] ); + + $validated = self::validate_and_sanitize( $args ); + + if ( is_wp_error( $validated ) ) + return ''; + + return Jetpack_Tracks_Client::PIXEL . '?' . http_build_query( $validated ); + } + + static function event_name_is_valid( $name ) { + return preg_match( Jetpack_Tracks_Event::EVENT_NAME_REGEX, $name ); + } + + static function prop_name_is_valid( $name ) { + return preg_match( Jetpack_Tracks_Event::PROP_NAME_REGEX, $name ); + } + + static function scrutinize_event_names( $event ) { + if ( ! Jetpack_Tracks_Event::event_name_is_valid( $event->_en ) ) { + return; + } + + $whitelisted_key_names = array( + 'anonId', + 'Browser_Type', + ); + + foreach ( array_keys( (array) $event ) as $key ) { + if ( in_array( $key, $whitelisted_key_names ) ) { + continue; + } + if ( ! Jetpack_Tracks_Event::prop_name_is_valid( $key ) ) { + return; + } + } + } +} diff --git a/plugins/jetpack/_inc/lib/tracks/client.php b/plugins/jetpack/_inc/lib/tracks/client.php new file mode 100644 index 00000000..bd92d272 --- /dev/null +++ b/plugins/jetpack/_inc/lib/tracks/client.php @@ -0,0 +1,130 @@ +<?php +/** + * PHP Tracks Client + * @autounit nosara tracks-client + * Example Usage: + * +```php + include( plugin_dir_path( __FILE__ ) . 'lib/tracks/client.php'); + $result = jetpack_tracks_record_event( $user, $event_name, $properties ); + + if ( is_wp_error( $result ) ) { + // Handle the error in your app + } +``` + */ + +// Load the client classes +require_once( dirname(__FILE__) . '/class.tracks-event.php' ); +require_once( dirname(__FILE__) . '/class.tracks-client.php' ); + +// Now, let's export a sprinkling of syntactic sugar! + +/** + * Procedurally (vs. Object-oriented), track an event object (or flat array) + * NOTE: Use this only when the simpler jetpack_tracks_record_event() function won't work for you. + * @param \Jetpack_Tracks_Event $event The event object. + * @return \Jetpack_Tracks_Event|\WP_Error + */ +function jetpack_tracks_record_event_raw( $event ) { + return Jetpack_Tracks_Client::record_event( $event ); +} + +/** + * Procedurally build a Tracks Event Object. + * NOTE: Use this only when the simpler jetpack_tracks_record_event() function won't work for you. + * @param $identity WP_user object + * @param string $event_name The name of the event + * @param array $properties Custom properties to send with the event + * @param int $event_timestamp_millis The time in millis since 1970-01-01 00:00:00 when the event occurred + * @return \Jetpack_Tracks_Event|\WP_Error + */ +function jetpack_tracks_build_event_obj( $user, $event_name, $properties = array(), $event_timestamp_millis = false ) { + + $identity = jetpack_tracks_get_identity( $user->ID ); + + $properties['user_lang'] = $user->get( 'WPLANG' ); + + $blog_details = array( + 'blog_lang' => isset( $properties['blog_lang'] ) ? $properties['blog_lang'] : get_bloginfo( 'language' ) + ); + + $timestamp = ( $event_timestamp_millis !== false ) ? $event_timestamp_millis : round( microtime( true ) * 1000 ); + $timestamp_string = is_string( $timestamp ) ? $timestamp : number_format( $timestamp, 0, '', '' ); + + return new Jetpack_Tracks_Event( array_merge( $blog_details, (array) $properties, $identity, array( + '_en' => $event_name, + '_ts' => $timestamp_string + ) ) ); +} + +/* + * Get the identity to send to tracks. + * + * @param int $user_id The user id of the local user + * @return array $identity + */ +function jetpack_tracks_get_identity( $user_id ) { + + // Meta is set, and user is still connected. Use WPCOM ID + $wpcom_id = get_user_meta( $user_id, 'jetpack_tracks_wpcom_id', true ); + if ( $wpcom_id && Jetpack::is_user_connected( $user_id ) ) { + return array( + '_ut' => 'wpcom:user_id', + '_ui' => $wpcom_id + ); + } + + // User is connected, but no meta is set yet. Use WPCOM ID and set meta. + if ( Jetpack::is_user_connected( $user_id ) ) { + $wpcom_user_data = Jetpack::get_connected_user_data( $user_id ); + update_user_meta( $user_id, 'jetpack_tracks_wpcom_id', $wpcom_user_data['ID'] ); + + return array( + '_ut' => 'wpcom:user_id', + '_ui' => $wpcom_user_data['ID'] + ); + } + + // User isn't linked at all. Fall back to anonymous ID. + $anon_id = get_user_meta( $user_id, 'jetpack_tracks_anon_id', true ); + if ( ! $anon_id ) { + $anon_id = Jetpack_Tracks_Client::get_anon_id(); + add_user_meta( $user_id, 'jetpack_tracks_anon_id', $anon_id, false ); + } + + if ( ! isset( $_COOKIE[ 'tk_ai' ] ) && ! headers_sent() ) { + setcookie( 'tk_ai', $anon_id ); + } + + return array( + '_ut' => 'anon', + '_ui' => $anon_id + ); + +} + +/** + * Record an event in Tracks - this is the preferred way to record events from PHP. + * + * @param mixed $identity username, user_id, or WP_user object + * @param string $event_name The name of the event + * @param array $properties Custom properties to send with the event + * @param int $event_timestamp_millis The time in millis since 1970-01-01 00:00:00 when the event occurred + * @return bool true for success | \WP_Error if the event pixel could not be fired + */ +function jetpack_tracks_record_event( $user, $event_name, $properties = array(), $event_timestamp_millis = false ) { + + // We don't want to track user events during unit tests/CI runs. + if ( $user instanceof WP_User && 'wptests_capabilities' === $user->cap_key ) { + return false; + } + + $event_obj = jetpack_tracks_build_event_obj( $user, $event_name, $properties, $event_timestamp_millis ); + + if ( is_wp_error( $event_obj->error ) ) { + return $event_obj->error; + } + + return $event_obj->record(); +} diff --git a/plugins/jetpack/_inc/lib/tracks/tracks-ajax.js b/plugins/jetpack/_inc/lib/tracks/tracks-ajax.js new file mode 100644 index 00000000..911275bd --- /dev/null +++ b/plugins/jetpack/_inc/lib/tracks/tracks-ajax.js @@ -0,0 +1,62 @@ +/* global jpTracksAJAX, jQuery */ +( function( $, jpTracksAJAX ) { + window.jpTracksAJAX = window.jpTracksAJAX || {}; + const debugSet = localStorage.getItem( 'debug' ) === 'dops:analytics'; + + window.jpTracksAJAX.record_ajax_event = function( eventName, eventType, eventProp ) { + var data = { + tracksNonce: jpTracksAJAX.jpTracksAJAX_nonce, + action: 'jetpack_tracks', + tracksEventType: eventType, + tracksEventName: eventName, + tracksEventProp: eventProp || false, + }; + + return $.ajax( { + type: 'POST', + url: jpTracksAJAX.ajaxurl, + data: data, + success: function( response ) { + if ( debugSet ) { + // eslint-disable-next-line + console.log( 'AJAX tracks event recorded: ', data, response ); + } + }, + } ); + }; + + $( document ).ready( function() { + $( 'body' ).on( 'click', '.jptracks a, a.jptracks', function( event ) { + var $this = $( event.target ); + // We know that the jptracks element is either this, or its ancestor + var $jptracks = $this.closest( '.jptracks' ); + // We need an event name at least + var eventName = $jptracks.attr( 'data-jptracks-name' ); + if ( undefined === eventName ) { + return; + } + + var eventProp = $jptracks.attr( 'data-jptracks-prop' ) || false; + + var url = $this.attr( 'href' ); + var target = $this.get( 0 ).target; + if ( url && target && '_self' !== target ) { + var newTabWindow = window.open( '', target ); + newTabWindow.opener = null; + } + + event.preventDefault(); + + window.jpTracksAJAX.record_ajax_event( eventName, 'click', eventProp ).always( function() { + // Continue on to whatever url they were trying to get to. + if ( url && ! $this.hasClass( 'thickbox' ) ) { + if ( newTabWindow ) { + newTabWindow.location = url; + return; + } + window.location = url; + } + } ); + } ); + } ); +} )( jQuery, jpTracksAJAX ); diff --git a/plugins/jetpack/_inc/lib/tracks/tracks-callables.js b/plugins/jetpack/_inc/lib/tracks/tracks-callables.js new file mode 100644 index 00000000..d4e53af6 --- /dev/null +++ b/plugins/jetpack/_inc/lib/tracks/tracks-callables.js @@ -0,0 +1,76 @@ +/** + * This was abstracted from wp-calypso's analytics lib: https://github.com/Automattic/wp-calypso/blob/master/client/lib/analytics/README.md + * Some stuff was removed like GA tracking and other things not necessary for Jetpack tracking. + * + * This library should only be used and loaded if the Jetpack site is connected. + */ + +// Load tracking scripts +window._tkq = window._tkq || []; + +function buildQuerystring( group, name ) { + var uriComponent = ''; + + if ( 'object' === typeof group ) { + for ( var key in group ) { + uriComponent += '&x_' + encodeURIComponent( key ) + '=' + encodeURIComponent( group[ key ] ); + } + } else { + uriComponent = '&x_' + encodeURIComponent( group ) + '=' + encodeURIComponent( name ); + } + + return uriComponent; +} + +var analytics = { + initialize: function( userId, username ) { + analytics.setUser( userId, username ); + analytics.identifyUser(); + }, + + mc: { + bumpStat: function( group, name ) { + var uriComponent = buildQuerystring( group, name ); // prints debug info + new Image().src = + document.location.protocol + + '//pixel.wp.com/g.gif?v=wpcom-no-pv' + + uriComponent + + '&t=' + + Math.random(); + }, + }, + + tracks: { + recordEvent: function( eventName, eventProperties ) { + eventProperties = eventProperties || {}; + + if ( eventName.indexOf( 'jetpack_' ) !== 0 ) { + debug( '- Event name must be prefixed by "jetpack_"' ); + return; + } + + window._tkq.push( [ 'recordEvent', eventName, eventProperties ] ); + }, + + recordPageView: function( urlPath ) { + analytics.tracks.recordEvent( 'jetpack_page_view', { + path: urlPath, + } ); + }, + }, + + setUser: function( userId, username ) { + _user = { ID: userId, username: username }; + }, + + identifyUser: function() { + // Don't identify the user if we don't have one + if ( _user ) { + window._tkq.push( [ 'identifyUser', _user.ID, _user.username ] ); + } + }, + + clearedIdentity: function() { + window._tkq.push( [ 'clearIdentity' ] ); + }, +}; diff --git a/plugins/jetpack/_inc/lib/widgets.php b/plugins/jetpack/_inc/lib/widgets.php new file mode 100644 index 00000000..3f072b75 --- /dev/null +++ b/plugins/jetpack/_inc/lib/widgets.php @@ -0,0 +1,776 @@ +<?php +/** + * Widgets and Sidebars Library + * + * Helper functions for manipulating widgets on a per-blog basis. + * Only helpful on `wp_loaded` or later (currently requires widgets to be registered and the theme context to already be loaded). + * + * Used by the REST API + * + * @autounit api widgets + */ + +class Jetpack_Widgets { + + /** + * Returns the `sidebars_widgets` option with the `array_version` element removed. + * + * @return array The current value of sidebars_widgets + */ + public static function get_sidebars_widgets() { + $sidebars = get_option( 'sidebars_widgets', array() ); + if ( isset( $sidebars['array_version'] ) ) { + unset( $sidebars['array_version'] ); + } + return $sidebars; + } + + /** + * Format widget data for output and for use by other widget functions. + * + * The output looks like: + * + * array( + * 'id' => 'text-3', + * 'sidebar' => 'sidebar-1', + * 'position' => '0', + * 'settings' => array( + * 'title' => 'hello world' + * ) + * ) + * + * + * @param string|integer $position The position of the widget in its sidebar. + * @param string $widget_id The widget's id (eg: 'text-3'). + * @param string $sidebar The widget's sidebar id (eg: 'sidebar-1'). + * @param array (Optional) $settings The settings for the widget. + * + * @return array A normalized array representing this widget. + */ + public static function format_widget( $position, $widget_id, $sidebar, $settings = null ) { + if ( ! $settings ) { + $all_settings = get_option( self::get_widget_option_name( $widget_id ) ); + $instance = self::get_widget_instance_key( $widget_id ); + $settings = $all_settings[$instance]; + } + $widget = array(); + + $widget['id'] = $widget_id; + $widget['id_base'] = self::get_widget_id_base( $widget_id ); + $widget['settings'] = $settings; + $widget['sidebar'] = $sidebar; + $widget['position'] = $position; + + return $widget; + } + + /** + * Return a widget's id_base from its id. + * + * @param string $widget_id The id of a widget. (eg: 'text-3') + * + * @return string The id_base of a widget (eg: 'text'). + */ + public static function get_widget_id_base( $widget_id ) { + // Grab what's before the hyphen. + return substr( $widget_id, 0, strrpos( $widget_id, '-' ) ); + } + + /** + * Determine a widget's option name (the WP option where the widget's settings + * are stored - generally `widget_` + the widget's id_base). + * + * @param string $widget_id The id of a widget. (eg: 'text-3') + * + * @return string The option name of the widget's settings. (eg: 'widget_text') + */ + public static function get_widget_option_name( $widget_id ) { + return 'widget_' . self::get_widget_id_base( $widget_id ); + } + + /** + * Determine a widget instance key from its ID. (eg: 'text-3' becomes '3'). + * Used to access the widget's settings. + * + * @param string $widget_id The id of a widget. + * + * @return integer The instance key of that widget. + */ + public static function get_widget_instance_key( $widget_id ) { + // Grab all numbers from the end of the id. + preg_match('/(\d+)$/', $widget_id, $matches ); + + return intval( $matches[0] ); + } + + /** + * Return a widget by ID (formatted for output) or null if nothing is found. + * + * @param string $widget_id The id of a widget to look for. + * + * @return array|null The matching formatted widget (see format_widget). + */ + public static function get_widget_by_id( $widget_id ) { + $found = null; + foreach ( self::get_all_widgets() as $widget ) { + if ( $widget['id'] === $widget_id ) { + $found = $widget; + } + } + return $found; + } + + /** + * Return an array of all widgets (active and inactive) formatted for output. + * + * @return array An array of all widgets (see format_widget). + */ + public static function get_all_widgets() { + $all_widgets = array(); + $sidebars_widgets = self::get_all_sidebars(); + + foreach ( $sidebars_widgets as $sidebar => $widgets ) { + if ( ! is_array( $widgets ) ) { + continue; + } + foreach ( $widgets as $key => $widget_id ) { + array_push( $all_widgets, self::format_widget( $key, $widget_id, $sidebar ) ); + } + } + + return $all_widgets; + } + + /** + * Return an array of all active widgets formatted for output. + * + * @return array An array of all active widgets (see format_widget). + */ + public static function get_active_widgets() { + $active_widgets = array(); + $all_widgets = self::get_all_widgets(); + foreach( $all_widgets as $widget ) { + if ( 'wp_inactive_widgets' === $widget['sidebar'] ) { + continue; + } + array_push( $active_widgets, $widget ); + } + return $active_widgets; + } + + /** + * Return an array of all widget IDs (active and inactive) + * + * @return array An array of all widget IDs. + */ + public static function get_all_widget_ids() { + $all_widgets = array(); + $sidebars_widgets = self::get_all_sidebars(); + foreach ( array_values( $sidebars_widgets ) as $widgets ) { + if ( ! is_array( $widgets ) ) { + continue; + } + foreach ( array_values( $widgets ) as $widget_id ) { + array_push( $all_widgets, $widget_id ); + } + } + return $all_widgets; + } + + /** + * Return an array of widgets with a specific id_base (eg: `text`). + * + * @param string $id_base The id_base of a widget type. + * + * @return array All the formatted widgets matching that widget type (see format_widget). + */ + public static function get_widgets_with_id_base( $id_base ) { + $matching_widgets = array(); + foreach ( self::get_all_widgets() as $widget ) { + if ( self::get_widget_id_base( $widget['id'] ) === $id_base ) { + array_push( $matching_widgets, $widget ); + } + } + return $matching_widgets; + } + + /** + * Return the array of widget IDs in a sidebar or null if that sidebar does + * not exist. Will return an empty array for an existing empty sidebar. + * + * @param string $sidebar The id of a sidebar. + * + * @return array|null The array of widget IDs in the sidebar. + */ + public static function get_widgets_in_sidebar( $sidebar ) { + $sidebars = self::get_all_sidebars(); + + + if ( ! $sidebars || ! is_array( $sidebars ) ) { + return null; + } + if ( ! $sidebars[ $sidebar ] && array_key_exists( $sidebar, $sidebars ) ) { + return array(); + } + return $sidebars[ $sidebar ]; + } + + /** + * Return an associative array of all registered sidebars for this theme, + * active and inactive, including the hidden disabled widgets sidebar (keyed + * by `wp_inactive_widgets`). Each sidebar is keyed by the ID of the sidebar + * and its value is an array of widget IDs for that sidebar. + * + * @return array An associative array of all sidebars and their widget IDs. + */ + public static function get_all_sidebars() { + $sidebars_widgets = self::get_sidebars_widgets(); + + if ( ! is_array( $sidebars_widgets ) ) { + return array(); + } + return $sidebars_widgets; + } + + /** + * Return an associative array of all active sidebars for this theme, Each + * sidebar is keyed by the ID of the sidebar and its value is an array of + * widget IDs for that sidebar. + * + * @return array An associative array of all active sidebars and their widget IDs. + */ + public static function get_active_sidebars() { + $sidebars = array(); + foreach ( self::get_all_sidebars() as $sidebar => $widgets ) { + if ( 'wp_inactive_widgets' === $sidebar || ! isset( $widgets ) || ! is_array( $widgets ) ) { + continue; + } + $sidebars[ $sidebar ] = $widgets; + } + return $sidebars; + } + + /** + * Activates a widget in a sidebar. Does not validate that the sidebar exists, + * so please do that first. Also does not save the widget's settings. Please + * do that with `set_widget_settings`. + * + * If position is not set, it will be set to the next available position. + * + * @param string $widget_id The newly-formed id of the widget to be added. + * @param string $sidebar The id of the sidebar where the widget will be added. + * @param string|integer $position (Optional) The position within the sidebar where the widget will be added. + * + * @return bool + */ + public static function add_widget_to_sidebar( $widget_id, $sidebar, $position ) { + return self::move_widget_to_sidebar( array( 'id' => $widget_id ), $sidebar, $position ); + } + + /** + * Removes a widget from a sidebar. Does not validate that the sidebar exists + * or remove any settings from the widget, so please do that separately. + * + * @param array $widget The widget to be removed. + */ + public static function remove_widget_from_sidebar( $widget ) { + $sidebars_widgets = self::get_sidebars_widgets(); + // Remove the widget from its old location and reflow the positions of the remaining widgets. + array_splice( $sidebars_widgets[ $widget['sidebar'] ], $widget['position'], 1 ); + + update_option( 'sidebars_widgets', $sidebars_widgets ); + } + + /** + * Moves a widget to a sidebar. Does not validate that the sidebar exists, + * so please do that first. Also does not save the widget's settings. Please + * do that with `set_widget_settings`. The first argument should be a + * widget as returned by `format_widget` including `id`, `sidebar`, and + * `position`. + * + * If $position is not set, it will be set to the next available position. + * + * Can be used to add a new widget to a sidebar if + * $widget['sidebar'] === NULL + * + * Can be used to move a widget within a sidebar as well if + * $widget['sidebar'] === $sidebar. + * + * @param array $widget The widget to be moved (see format_widget). + * @param string $sidebar The sidebar where this widget will be moved. + * @param string|integer $position (Optional) The position where this widget will be moved in the sidebar. + * + * @return bool + */ + public static function move_widget_to_sidebar( $widget, $sidebar, $position ) { + $sidebars_widgets = self::get_sidebars_widgets(); + + // If a position is passed and the sidebar isn't empty, + // splice the widget into the sidebar, update the sidebar option, and return the result + if ( isset( $widget['sidebar'] ) && isset( $widget['position'] ) ) { + array_splice( $sidebars_widgets[ $widget['sidebar'] ], $widget['position'], 1 ); + } + + // Sometimes an existing empty sidebar is NULL, so initialize it. + if ( array_key_exists( $sidebar, $sidebars_widgets ) && ! is_array( $sidebars_widgets[ $sidebar ] ) ) { + $sidebars_widgets[ $sidebar ] = array(); + } + + // If no position is passed, set one from items in sidebar + if ( ! isset( $position ) ) { + $position = 0; + $last_position = self::get_last_position_in_sidebar( $sidebar ); + if ( isset( $last_position ) && is_numeric( $last_position ) ) { + $position = $last_position + 1; + } + } + + // Add the widget to the sidebar and reflow the positions of the other widgets. + if ( empty( $sidebars_widgets[ $sidebar ] ) ) { + $sidebars_widgets[ $sidebar ][] = $widget['id']; + } else { + array_splice( $sidebars_widgets[ $sidebar ], (int)$position, 0, $widget['id'] ); + } + + set_theme_mod( 'sidebars_widgets', array( 'time' => time(), 'data' => $sidebars_widgets ) ); + return update_option( 'sidebars_widgets', $sidebars_widgets ); + } + + /** + * Return an integer containing the largest position number in a sidebar or + * null if there are no widgets in that sidebar. + * + * @param string $sidebar The id of a sidebar. + * + * @return integer|null The last index position of a widget in that sidebar. + */ + public static function get_last_position_in_sidebar( $sidebar ) { + $widgets = self::get_widgets_in_sidebar( $sidebar ); + if ( ! $widgets ) { + return null; + } + $last_position = 0; + foreach ( $widgets as $widget_id ) { + $widget = self::get_widget_by_id( $widget_id ); + if ( intval( $widget['position'] ) > intval( $last_position ) ) { + $last_position = intval( $widget['position'] ); + } + } + return $last_position; + } + + /** + * Saves settings for a widget. Does not add that widget to a sidebar. Please + * do that with `move_widget_to_sidebar` first. Will merge the settings of + * any existing widget with the same `$widget_id`. + * + * @param string $widget_id The id of a widget. + * @param array $settings An associative array of settings to merge with any existing settings on this widget. + * + * @return boolean|WP_Error True if update was successful. + */ + public static function set_widget_settings( $widget_id, $settings ) { + $widget_option_name = self::get_widget_option_name( $widget_id ); + $widget_settings = get_option( $widget_option_name ); + $instance_key = self::get_widget_instance_key( $widget_id ); + $old_settings = $widget_settings[ $instance_key ]; + + if ( ! $settings = self::sanitize_widget_settings( $widget_id, $settings, $old_settings ) ) { + return new WP_Error( 'invalid_data', 'Update failed.', 500 ); + } + if ( is_array( $old_settings ) ) { + // array_filter prevents empty arguments from replacing existing ones + $settings = wp_parse_args( array_filter( $settings ), $old_settings ); + } + + $widget_settings[ $instance_key ] = $settings; + + return update_option( $widget_option_name, $widget_settings ); + } + + /** + * Sanitize an associative array for saving. + * + * @param string $widget_id The id of a widget. + * @param array $settings A widget settings array. + * @param array $old_settings The existing widget settings array. + * + * @return array|false The settings array sanitized by `WP_Widget::update` or false if sanitization failed. + */ + private static function sanitize_widget_settings( $widget_id, $settings, $old_settings ) { + if ( ! $widget = self::get_registered_widget_object( self::get_widget_id_base( $widget_id ) ) ) { + return false; + } + $new_settings = $widget->update( $settings, $old_settings ); + if ( ! is_array( $new_settings ) ) { + return false; + } + return $new_settings; + } + + /** + * Deletes settings for a widget. Does not remove that widget to a sidebar. Please + * do that with `remove_widget_from_sidebar` first. + * + * @param array $widget The widget which will have its settings removed (see format_widget). + */ + public static function remove_widget_settings( $widget ) { + $widget_option_name = self::get_widget_option_name( $widget['id'] ); + $widget_settings = get_option( $widget_option_name ); + unset( $widget_settings[ self::get_widget_instance_key( $widget['id'] ) ] ); + update_option( $widget_option_name, $widget_settings ); + } + + /** + * Update a widget's settings, sidebar, and position. Returns the (updated) + * formatted widget if successful or a WP_Error if it fails. + * + * @param string $widget_id The id of a widget to update. + * @param string $sidebar (Optional) A sidebar to which this widget will be moved. + * @param string|integer (Optional) A new position to which this widget will be moved within its new or existing sidebar. + * @param array|object|string $settings Settings to merge with the existing settings of the widget (will be passed through `decode_settings`). + * + * @return array|WP_Error The newly added widget as an associative array with all the above properties. + */ + public static function update_widget( $widget_id, $sidebar, $position, $settings ) { + $settings = self::decode_settings( $settings ); + if ( isset( $settings ) && ! is_array( $settings ) ) { + return new WP_Error( 'invalid_data', 'Invalid settings', 400 ); + } + // Default to an empty array if nothing is specified. + if ( ! is_array( $settings ) ) { + $settings = array(); + } + $widget = self::get_widget_by_id( $widget_id ); + if ( ! $widget ) { + return new WP_Error( 'not_found', 'No widget found.', 400 ); + } + if ( ! $sidebar ) { + $sidebar = $widget['sidebar']; + } + if ( ! isset( $position ) ) { + $position = $widget['position']; + } + if ( ! is_numeric( $position ) ) { + return new WP_Error( 'invalid_data', 'Invalid position', 400 ); + } + $widgets_in_sidebar = self::get_widgets_in_sidebar( $sidebar ); + if ( ! isset( $widgets_in_sidebar ) ) { + return new WP_Error( 'invalid_data', 'No such sidebar exists', 400 ); + } + self::move_widget_to_sidebar( $widget, $sidebar, $position ); + $widget_save_status = self::set_widget_settings( $widget_id, $settings ); + if ( is_wp_error( $widget_save_status ) ) { + return $widget_save_status; + } + return self::get_widget_by_id( $widget_id ); + } + + /** + * Deletes a widget entirely including all its settings. Returns a WP_Error if + * the widget could not be found. Otherwise returns an empty array. + * + * @param string $widget_id The id of a widget to delete. (eg: 'text-2') + * + * @return array|WP_Error An empty array if successful. + */ + public static function delete_widget( $widget_id ) { + $widget = self::get_widget_by_id( $widget_id ); + if ( ! $widget ) { + return new WP_Error( 'not_found', 'No widget found.', 400 ); + } + self::remove_widget_from_sidebar( $widget ); + self::remove_widget_settings( $widget ); + return array(); + } + + /** + * Return an array of settings. The input can be either an object, a JSON + * string, or an array. + * + * @param array|string|object $settings The settings of a widget as passed into the API. + * + * @return array Decoded associative array of settings. + */ + public static function decode_settings( $settings ) { + // Treat as string in case JSON was passed + if ( is_object( $settings ) && property_exists( $settings, 'scalar' ) ) { + $settings = $settings->scalar; + } + if ( is_object( $settings ) ) { + $settings = (array) $settings; + } + // Attempt to decode JSON string + if ( is_string( $settings ) ) { + $settings = (array) json_decode( $settings ); + } + return $settings; + } + + /** + * Activate a new widget. + * + * @param string $id_base The id_base of the new widget (eg: 'text') + * @param string $sidebar The id of the sidebar where this widget will go. Dependent on theme. (eg: 'sidebar-1') + * @param string|integer $position (Optional) The position of the widget in the sidebar. Defaults to the last position. + * @param array|object|string $settings (Optional) An associative array of settings for this widget (will be passed through `decode_settings`). Varies by widget. + * + * @return array|WP_Error The newly added widget as an associative array with all the above properties except 'id_base' replaced with the generated 'id'. + */ + public static function activate_widget( $id_base, $sidebar, $position, $settings ) { + if ( ! isset( $id_base ) || ! self::validate_id_base( $id_base ) ) { + return new WP_Error( 'invalid_data', 'Invalid ID base', 400 ); + } + + if ( ! isset( $sidebar ) ) { + return new WP_Error( 'invalid_data', 'No sidebar provided', 400 ); + } + + if ( isset( $position ) && ! is_numeric( $position ) ) { + return new WP_Error( 'invalid_data', 'Invalid position', 400 ); + } + + $settings = self::decode_settings( $settings ); + if ( isset( $settings ) && ! is_array( $settings ) ) { + return new WP_Error( 'invalid_data', 'Invalid settings', 400 ); + } + + // Default to an empty array if nothing is specified. + if ( ! is_array( $settings ) ) { + $settings = array(); + } + + $widget_counter = 1 + self::get_last_widget_instance_key_with_id_base( $id_base ); + $widget_id = $id_base . '-' . $widget_counter; + if ( 0 >= $widget_counter ) { + return new WP_Error( 'invalid_data', 'Error creating widget ID' . $widget_id, 500 ); + } + if ( self::get_widget_by_id( $widget_id ) ) { + return new WP_Error( 'invalid_data', 'Widget ID already exists', 500 ); + } + + self::add_widget_to_sidebar( $widget_id, $sidebar, $position ); + $widget_save_status = self::set_widget_settings( $widget_id, $settings ); + if ( is_wp_error( $widget_save_status ) ) { + return $widget_save_status; + } + + // Add a Tracks event for non-Headstart activity. + if ( ! defined( 'HEADSTART' ) ) { + jetpack_require_lib( 'tracks/client' ); + jetpack_tracks_record_event( wp_get_current_user(), 'wpcom_widgets_activate_widget', array( + 'widget' => $id_base, + 'settings' => json_encode( $settings ), + ) ); + } + + return self::get_widget_by_id( $widget_id ); + } + + /** + * Activate an array of new widgets. Like calling `activate_widget` multiple times. + * + * @param array $widgets An array of widget arrays. Each sub-array must be of the format required by `activate_widget`. + * + * @return array|WP_Error The newly added widgets in the form returned by `get_all_widgets`. + */ + public static function activate_widgets( $widgets ) { + if ( ! is_array( $widgets ) ) { + return new WP_Error( 'invalid_data', 'Invalid widgets', 400 ); + } + + $added_widgets = array(); + + foreach( $widgets as $widget ) { + $added_widgets[] = self::activate_widget( $widget['id_base'], $widget['sidebar'], $widget['position'], $widget['settings'] ); + } + + return $added_widgets; + } + + /** + * Return the last instance key (integer) of an existing widget matching + * `$id_base`. So if you pass in `text`, and there is a widget with the id + * `text-2`, this function will return `2`. + * + * @param string $id_base The id_base of a type of widget. (eg: 'rss') + * + * @return integer The last instance key of that type of widget. + */ + public static function get_last_widget_instance_key_with_id_base( $id_base ) { + $similar_widgets = self::get_widgets_with_id_base( $id_base ); + + if ( ! empty( $similar_widgets ) ) { + // If the last widget with the same name is `text-3`, we want `text-4` + usort( $similar_widgets, __CLASS__ . '::sort_widgets' ); + + $last_widget = array_pop( $similar_widgets ); + $last_val = intval( self::get_widget_instance_key( $last_widget['id'] ) ); + + return $last_val; + } + + return 0; + } + + /** + * Method used to sort widgets + * + * @since 5.4 + * + * @param array $a + * @param array $b + * + * @return int + */ + public static function sort_widgets( $a, $b ) { + $a_val = intval( self::get_widget_instance_key( $a['id'] ) ); + $b_val = intval( self::get_widget_instance_key( $b['id'] ) ); + if ( $a_val > $b_val ) { + return 1; + } + if ( $a_val < $b_val ) { + return -1; + } + return 0; + } + + /** + * Retrieve a given widget object instance by ID base (eg. 'text' or 'archives'). + * + * @param string $id_base The id_base of a type of widget. + * + * @return WP_Widget|false The found widget object or false if the id_base was not found. + */ + public static function get_registered_widget_object( $id_base ) { + if ( ! $id_base ) { + return false; + } + + // Get all of the registered widgets. + global $wp_widget_factory; + if ( ! isset( $wp_widget_factory ) ) { + return false; + } + + $registered_widgets = $wp_widget_factory->widgets; + if ( empty( $registered_widgets ) ) { + return false; + } + + foreach ( array_values( $registered_widgets ) as $registered_widget_object ) { + if ( $registered_widget_object->id_base === $id_base ) { + return $registered_widget_object; + } + } + return false; + } + + /** + * Validate a given widget ID base (eg. 'text' or 'archives'). + * + * @param string $id_base The id_base of a type of widget. + * + * @return boolean True if the widget is of a known type. + */ + public static function validate_id_base( $id_base ) { + return ( false !== self::get_registered_widget_object( $id_base ) ); + } + + /** + * Insert a new widget in a given sidebar. + * + * @param string $widget_id ID of the widget. + * @param array $widget_options Content of the widget. + * @param string $sidebar ID of the sidebar to which the widget will be added. + * + * @return WP_Error|true True when data has been saved correctly, error otherwise. + */ + static function insert_widget_in_sidebar( $widget_id, $widget_options, $sidebar ) { + // Retrieve sidebars, widgets and their instances + $sidebars_widgets = get_option( 'sidebars_widgets', array() ); + $widget_instances = get_option( 'widget_' . $widget_id, array() ); + + // Retrieve the key of the next widget instance + $numeric_keys = array_filter( array_keys( $widget_instances ), 'is_int' ); + $next_key = $numeric_keys ? max( $numeric_keys ) + 1 : 2; + + // Add this widget to the sidebar + if ( ! isset( $sidebars_widgets[ $sidebar ] ) ) { + $sidebars_widgets[ $sidebar ] = array(); + } + $sidebars_widgets[ $sidebar ][] = $widget_id . '-' . $next_key; + + // Add the new widget instance + $widget_instances[ $next_key ] = $widget_options; + + // Store updated sidebars, widgets and their instances + if ( + ! ( update_option( 'sidebars_widgets', $sidebars_widgets ) ) + || ( ! ( update_option( 'widget_' . $widget_id, $widget_instances ) ) ) + ) { + return new WP_Error( 'widget_update_failed', 'Failed to update widget or sidebar.', 400 ); + }; + + return true; + } + + /** + * Update the content of an existing widget in a given sidebar. + * + * @param string $widget_id ID of the widget. + * @param array $widget_options New content for the update. + * @param string $sidebar ID of the sidebar to which the widget will be added. + * + * @return WP_Error|true True when data has been updated correctly, error otherwise. + */ + static function update_widget_in_sidebar( $widget_id, $widget_options, $sidebar ) { + // Retrieve sidebars, widgets and their instances + $sidebars_widgets = get_option( 'sidebars_widgets', array() ); + $widget_instances = get_option( 'widget_' . $widget_id, array() ); + + // Retrieve index of first widget instance in that sidebar + $widget_key = false; + foreach ( $sidebars_widgets[ $sidebar ] as $widget ) { + if ( strpos( $widget, $widget_id ) !== false ) { + $widget_key = absint( str_replace( $widget_id . '-', '', $widget ) ); + break; + } + } + + // There is no widget instance + if ( ! $widget_key ) { + return new WP_Error( 'invalid_data', 'No such widget.', 400 ); + } + + // Update the widget instance and option if the data has changed + if ( $widget_instances[ $widget_key ]['title'] !== $widget_options['title'] + || $widget_instances[ $widget_key ]['address'] !== $widget_options['address'] + ) { + + $widget_instances[ $widget_key ] = array_merge( $widget_instances[ $widget_key ], $widget_options ); + + // Store updated widget instances and return Error when not successful + if ( ! ( update_option( 'widget_' . $widget_id, $widget_instances ) ) ) { + return new WP_Error( 'widget_update_failed', 'Failed to update widget.', 400 ); + }; + }; + return true; + } + + /** + * Retrieve the first active sidebar. + * + * @return string|WP_Error First active sidebar, error if none exists. + */ + static function get_first_sidebar() { + $active_sidebars = get_option( 'sidebars_widgets', array() ); + unset( $active_sidebars[ 'wp_inactive_widgets' ], $active_sidebars[ 'array_version' ] ); + + if ( empty( $active_sidebars ) ) { + return false; + } + $active_sidebars_keys = array_keys( $active_sidebars ); + return array_shift( $active_sidebars_keys ); + } +} |