This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -0,0 +1,611 @@
<?php
/**
* Class for the Jetpack About Page within the wp-admin.
*
* @package automattic/jetpack
*/
/**
* Disable direct access and execution.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
require_once __DIR__ . '/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;
/**
* Anonymous info about a12s. The method fetch_a8c_data() stores the response from wpcom here.
*
* @var array
*/
private $a8c_data = null;
/**
* 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(
'',
esc_html__( 'About Jetpack', 'jetpack' ),
'',
'jetpack_admin_page',
'jetpack_about',
array( $this, 'render' )
);
}
/**
* Add page action
*
* @param string $hook Hook of current page.
*/
public function add_page_actions( $hook ) {
if ( 'admin_page_jetpack_about' === $hook ) {
$this->a8c_data = $this->fetch_a8c_data();
}
}
/**
* 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 ) );
}
/**
* Render the page content
*/
public function page_render() {
?>
<div class="jp-lower">
<h1 class="screen-reader-text"><?php esc_html_e( 'About Jetpack', 'jetpack' ); ?></h1>
<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>
<svg class="gridicons-external" height="24" width="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g><path d="M19 13v6c0 1.105-.895 2-2 2H5c-1.105 0-2-.895-2-2V7c0-1.105.895-2 2-2h6v2H5v12h12v-6h2zM13 3v2h4.586l-7.793 7.793 1.414 1.414L19 6.414V11h2V3h-8z"></path></g></svg>
</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>
<svg class="gridicons-external" height="24" width="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g><path d="M19 13v6c0 1.105-.895 2-2 2H5c-1.105 0-2-.895-2-2V7c0-1.105.895-2 2-2h6v2H5v12h12v-6h2zM13 3v2h4.586l-7.793 7.793 1.414 1.414L19 6.414V11h2V3h-8z"></path></g></svg>
</p>
<p>
<?php
echo esc_html(
sprintf(
/* translators: first placeholder is the number of Automattic employees. The second is the number of countries of origin*/
__( 'Were a distributed company with over %1$s Automatticians in more than %2$s countries speaking at least %3$s 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' ),
$this->a8c_data['a12s'],
$this->a8c_data['countries'],
$this->a8c_data['languages']
)
);
?>
</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><svg class="gridicons-external" height="24" width="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g><path d="M19 13v6c0 1.105-.895 2-2 2H5c-1.105 0-2-.895-2-2V7c0-1.105.895-2 2-2h6v2H5v12h12v-6h2zM13 3v2h4.586l-7.793 7.793 1.414 1.414L19 6.414V11h2V3h-8z"></path></g></svg>', 'jetpack' ),
array(
'a' => array(
'href' => array(),
'class' => array(),
'target' => array(),
'rel' => array(),
'data-jptracks-name' => array(),
),
'svg' => array(
'class' => array(),
'height' => array(),
'width' => array(),
'xmlns' => array(),
'viewbox' => array(),
),
'g' => array(),
'path' => array(
'd' => 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><svg class="gridicons-external" height="24" width="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g><path d="M19 13v6c0 1.105-.895 2-2 2H5c-1.105 0-2-.895-2-2V7c0-1.105.895-2 2-2h6v2H5v12h12v-6h2zM13 3v2h4.586l-7.793 7.793 1.414 1.414L19 6.414V11h2V3h-8z"></path></g></svg>
</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 take a look at <a href="https://profiles.wordpress.org/automattic/#content-plugins" target="_blank" rel="noopener noreferrer" class="jptracks" data-jptracks-name="jetpack_about_wporg_profile">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 = $this->a8c_data['featured_plugins'];
// 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 is-rna" 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&amp;plugin={$plugin['slug']}&amp;TB_iframe=true&amp;width=600&amp;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&#8217;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( wp_get_update_php_url() )
);
wp_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( wp_get_update_php_url() )
);
wp_update_php_annotation();
}
} elseif ( ! $compatible_wp ) {
esc_html_e( 'This plugin doesn&#8217;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&#8217;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( wp_get_update_php_url() )
);
wp_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_url( $plugin_icon_url ); ?>" class="plugin-icon" alt="<?php esc_attr_e( 'Plugin icon', 'jetpack' ); ?>" aria-hidden="true">
</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 anonymous data about A12s from wpcom: total count, number of countries, languages spoken.
*
* @since 7.4
*
* @return array $data
*/
private function fetch_a8c_data() {
$data = get_transient( 'jetpack_a8c_data' );
if ( false === $data ) {
$data = json_decode(
wp_remote_retrieve_body(
wp_remote_get( 'https://public-api.wordpress.com/wpcom/v2/jetpack-about' )
),
true
);
if ( ! empty( $data ) && is_array( $data ) ) {
set_transient( 'jetpack_a8c_data', $data, WEEK_IN_SECONDS );
} else {
// Fallback if everything fails.
$data = array(
'a12s' => 888,
'countries' => 69,
'languages' => 83,
'featured_plugins' => array(
'woocommerce',
'wp-super-cache',
'wp-job-manager',
'co-authors-plus',
),
);
}
}
return $data;
}
/**
* Compile and display a list of avatars for A12s that gave their permission.
*
* @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/1ec3571e0201a990ceca5e365e780efa',
'https://0.gravatar.com/avatar/0619d4de8aef78c81b2194ff1d164d85',
'https://0.gravatar.com/avatar/7fdcad31a04def0ab9583af475c9036c',
'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,
),
)
);
}
}
@@ -0,0 +1,515 @@
<?php
/**
* A utility class that generates the initial state for Redux in wp-admin.
* Modularized from `class.jetpack-react-page.php`.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Blaze;
use Automattic\Jetpack\Boost_Speed_Score\Speed_Score_History;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Connection\Plugin_Storage as Connection_Plugin_Storage;
use Automattic\Jetpack\Connection\REST_Connector;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
use Automattic\Jetpack\Device_Detection\User_Agent_Info;
use Automattic\Jetpack\Identity_Crisis;
use Automattic\Jetpack\Image_CDN\Image_CDN_Core;
use Automattic\Jetpack\Image_CDN\Image_CDN_Image;
use Automattic\Jetpack\IP\Utils as IP_Utils;
use Automattic\Jetpack\Licensing;
use Automattic\Jetpack\Licensing\Endpoints as Licensing_Endpoints;
use Automattic\Jetpack\My_Jetpack\Initializer as My_Jetpack_Initializer;
use Automattic\Jetpack\My_Jetpack\Jetpack_Manage;
use Automattic\Jetpack\Partner;
use Automattic\Jetpack\Partner_Coupon as Jetpack_Partner_Coupon;
use Automattic\Jetpack\Stats\Options as Stats_Options;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Host;
/**
* Responsible for populating the initial Redux state.
*/
class Jetpack_Redux_State_Helper {
/**
* Generate minimal state for React to fetch its own data asynchronously after load
* This can improve user experience, reducing time spent on server requests before serving the page
* e.g. used by React Disconnect Dialog on plugins page where the full initial state is not needed
*/
public static function get_minimal_state() {
return array(
'WP_API_root' => esc_url_raw( rest_url() ),
'WP_API_nonce' => wp_create_nonce( 'wp_rest' ),
);
}
/**
* Generate the initial state array to be used by the Redux store.
*/
public static function get_initial_state() {
global $is_safari;
// 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';
require_once JETPACK__PLUGIN_DIR . '_inc/lib/core-api/class.jetpack-core-api-site-endpoints.php';
$module_list_endpoint = new Jetpack_Core_API_Module_List_Endpoint();
$modules = $module_list_endpoint->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'], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
$modules[ $slug ]['description'] = html_entity_decode( $data['description'], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
$modules[ $slug ]['short_description'] = html_entity_decode( $data['short_description'], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
$modules[ $slug ]['long_description'] = html_entity_decode( $data['long_description'], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
}
// "mock" a block module in order to get it searchable in the settings.
$modules['blocks']['module'] = 'blocks';
$modules['blocks']['additional_search_queries'] = esc_html_x( 'blocks, block, gutenberg', 'Search terms', 'jetpack' );
// "mock" an Earn module in order to get it searchable in the settings.
$modules['earn']['module'] = 'earn';
$modules['earn']['additional_search_queries'] = esc_html_x( 'earn, paypal, stripe, payments, pay', 'Search terms', 'jetpack' );
// Collecting roles that can view site stats.
$stats_roles = array();
$enabled_roles = Stats_Options::get_option( 'roles' );
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();
$current_user_data = jetpack_current_user_data();
/**
* Adds information to the `connectionStatus` API field that is unique to the Jetpack React dashboard.
*/
$connection_status = array(
'isInIdentityCrisis' => Identity_Crisis::validate_sync_error_idc_option(),
'sandboxDomain' => JETPACK__SANDBOX_DOMAIN,
/**
* Filter to add connection errors
* Format: array( array( 'code' => '...', 'message' => '...', 'action' => '...' ), ... )
*
* @since 8.7.0
*
* @param array $errors Connection errors.
*/
'errors' => apply_filters( 'react_connection_errors_initial_state', array() ),
);
$connection_status = array_merge( REST_Connector::connection_status( false ), $connection_status );
$host = new Host();
$speed_score_history = new Speed_Score_History( wp_parse_url( get_site_url(), PHP_URL_HOST ) );
$block_availability = Jetpack_Gutenberg::get_cached_availability();
return array(
'WP_API_root' => esc_url_raw( rest_url() ),
'WP_API_nonce' => wp_create_nonce( 'wp_rest' ),
'registrationNonce' => wp_create_nonce( 'jetpack-registration-nonce' ),
'purchaseToken' => self::get_purchase_token(),
'partnerCoupon' => Jetpack_Partner_Coupon::get_coupon(),
'pluginBaseUrl' => plugins_url( '', JETPACK__PLUGIN_FILE ),
'connectionStatus' => $connection_status,
'connectedPlugins' => Connection_Plugin_Storage::get_all(),
'connectUrl' => false == $current_user_data['isConnected'] // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
? Jetpack::init()->build_connect_url( true, false, false )
: '',
'dismissedNotices' => self::get_dismissed_jetpack_notices(),
'isDevVersion' => Jetpack::is_development_version(),
'currentVersion' => JETPACK__VERSION,
'is_gutenberg_available' => true,
'getModules' => $modules,
'rawUrl' => ( new Status() )->get_site_suffix(),
'adminUrl' => esc_url( admin_url() ),
'siteTitle' => (string) htmlspecialchars_decode( get_option( 'blogname' ), ENT_QUOTES ),
'stats' => array(
// data is populated asynchronously on page load.
'data' => array(
'general' => false,
'day' => false,
'week' => false,
'month' => false,
),
'roles' => $stats_roles,
),
'aff' => Partner::init()->get_partner_code( Partner::AFFILIATE_CODE ),
'partnerSubsidiaryId' => Partner::init()->get_partner_code( Partner::SUBSIDIARY_CODE ),
'settings' => self::get_flattened_settings(),
'userData' => array(
'currentUser' => $current_user_data,
),
'siteData' => array(
'blog_id' => Jetpack_Options::get_option( 'id', 0 ),
'icon' => has_site_icon()
? apply_filters( 'jetpack_photon_url', get_site_icon_url(), array( 'w' => 64 ) )
: '',
'siteVisibleToSearchEngines' => '1' == get_option( 'blog_public' ), // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
/**
* 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' => $host->is_woa_site(),
'isWoASite' => $host->is_woa_site(),
'isAtomicPlatform' => $host->is_atomic_platform(),
'plan' => Jetpack_Plan::get(),
'showBackups' => Jetpack::show_backups_ui(),
'showRecommendations' => Jetpack_Recommendations::is_enabled(),
/** This filter is documented in my-jetpack/src/class-initializer.php */
'showMyJetpack' => My_Jetpack_Initializer::should_initialize(),
'isMultisite' => is_multisite(),
'dateFormat' => get_option( 'date_format' ),
'latestBoostSpeedScores' => $speed_score_history->latest(),
'isSharingBlockAvailable' => (bool) isset( $block_availability['sharing-buttons'] )
&& $block_availability['sharing-buttons']['available'],
),
'themeData' => array(
'name' => $current_theme->get( 'Name' ),
'stylesheet' => $current_theme->get_stylesheet(),
'hasUpdate' => (bool) get_theme_update_available( $current_theme ),
'isBlockTheme' => (bool) $current_theme->is_block_theme(),
'support' => array(
'infinite-scroll' => current_theme_supports( 'infinite-scroll' ) || in_array( $current_theme->get_stylesheet(), $inf_scr_support_themes, true ),
'widgets' => current_theme_supports( 'widgets' ),
'webfonts' => wp_theme_has_theme_json()
&& ( function_exists( 'wp_register_webfont_provider' ) || function_exists( 'wp_register_webfonts' ) ),
),
),
'jetpackStateNotices' => array(
'messageCode' => Jetpack::state( 'message' ),
'errorCode' => Jetpack::state( 'error' ),
'errorDescription' => Jetpack::state( 'error_description' ),
'messageContent' => Jetpack::state( 'display_update_modal' ) ? self::get_update_modal_data() : null,
),
'tracksUserData' => Jetpack_Tracks_Client::get_connected_user_tracks_identity(),
'currentIp' => IP_Utils::get_ip(),
'lastPostUrl' => esc_url( $last_post ),
'externalServicesConnectUrls' => self::get_external_services_connect_urls(),
'calypsoEnv' => ( new Host() )->get_calypso_env(),
'products' => Jetpack::get_products_for_purchase(),
'recommendationsStep' => Jetpack_Core_Json_Api_Endpoints::get_recommendations_step()['step'],
'isSafari' => $is_safari || User_Agent_Info::is_opera_desktop(), // @todo Rename isSafari everywhere.
'doNotUseConnectionIframe' => Constants::is_true( 'JETPACK_SHOULD_NOT_USE_CONNECTION_IFRAME' ),
'licensing' => array(
'error' => Licensing::instance()->last_error(),
'showLicensingUi' => Licensing::instance()->is_licensing_input_enabled(),
'userCounts' => Licensing_Endpoints::get_user_license_counts(),
'activationNoticeDismiss' => Licensing::instance()->get_license_activation_notice_dismiss(),
),
'jetpackManage' => array(
'isEnabled' => Jetpack_Manage::could_use_jp_manage(),
'isAgencyAccount' => Jetpack_Manage::is_agency_account(),
),
'hasSeenWCConnectionModal' => Jetpack_Options::get_option( 'has_seen_wc_connection_modal', false ),
'newRecommendations' => Jetpack_Recommendations::get_new_conditional_recommendations(),
// Check if WooCommerce plugin is active (based on https://docs.woocommerce.com/document/create-a-plugin/).
'isWooCommerceActive' => in_array( 'woocommerce/woocommerce.php', apply_filters( 'active_plugins', Jetpack::get_active_plugins() ), true ),
'useMyJetpackLicensingUI' => My_Jetpack_Initializer::is_licensing_ui_enabled(),
'isOdysseyStatsEnabled' => Stats_Options::get_option( 'enable_odyssey_stats' ),
'shouldInitializeBlaze' => Blaze::should_initialize(),
'isBlazeDashboardEnabled' => Blaze::is_dashboard_enabled(),
'socialInitialState' => self::get_publicize_initial_state(),
'isSubscriptionSiteEnabled' => apply_filters( 'jetpack_subscription_site_enabled', false ),
'newsletterDateExample' => gmdate( get_option( 'date_format' ), time() ),
'subscriptionSiteEditSupported' => $current_theme->is_block_theme(),
);
}
/**
* Gets the initial state for the Publicize module.
*
* @return array|null
*/
public static function get_publicize_initial_state() {
$jetpack_social_settings = new Automattic\Jetpack\Publicize\Jetpack_Social_Settings\Settings();
$initial_state = $jetpack_social_settings->get_initial_state();
if ( empty( $initial_state ) ) {
return null;
}
return $initial_state;
}
/**
* Gets array of any Jetpack notices that have been dismissed.
*
* @return mixed|void
*/
public static function get_dismissed_jetpack_notices() {
$jetpack_dismissed_notices = get_option( 'jetpack_dismissed_notices', array() );
/**
* Array of notices that have been dismissed.
*
* @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;
}
/**
* Returns an array of modules and settings both as first class members of the object.
*
* @return array flattened settings with modules.
*/
public static function get_flattened_settings() {
$core_api_endpoint = new Jetpack_Core_API_Data();
$settings = $core_api_endpoint->get_all_options();
return $settings->data;
}
/**
* Returns the release post content and image data as an associative array.
* This data is used to create the update modal.
*/
public static function get_update_modal_data() {
$post_data = self::get_release_post_data();
if ( ! isset( $post_data['posts'][0] ) ) {
return;
}
$post = $post_data['posts'][0];
if ( empty( $post['content'] ) ) {
return;
}
// This allows us to embed videopress videos into the release post.
add_filter( 'wp_kses_allowed_html', array( __CLASS__, 'allow_post_embed_iframe' ), 10, 2 );
$content = wp_kses_post( $post['content'] );
remove_filter( 'wp_kses_allowed_html', array( __CLASS__, 'allow_post_embed_iframe' ), 10 );
$post_title = isset( $post['title'] ) ? $post['title'] : null;
$title = wp_kses( $post_title, array() );
$post_thumbnail = isset( $post['post_thumbnail'] ) ? $post['post_thumbnail'] : null;
if ( ! empty( $post_thumbnail ) ) {
$photon_image = new Image_CDN_Image(
array(
'file' => Image_CDN_Core::cdn_url( $post_thumbnail['URL'] ),
'width' => $post_thumbnail['width'],
'height' => $post_thumbnail['height'],
),
$post_thumbnail['mime_type']
);
$photon_image->resize(
array(
'width' => 600,
'height' => null,
'crop' => false,
)
);
$post_thumbnail_url = $photon_image->get_raw_filename();
} else {
$post_thumbnail_url = null;
}
$post_array = array(
'release_post_content' => $content,
'release_post_featured_image' => $post_thumbnail_url,
'release_post_title' => $title,
);
return $post_array;
}
/**
* Temporarily allow post content to contain iframes, e.g. for videopress.
*
* @param string $tags The tags.
* @param string $context The context.
*/
public static function allow_post_embed_iframe( $tags, $context ) {
if ( 'post' === $context ) {
$tags['iframe'] = array(
'src' => true,
'height' => true,
'width' => true,
'frameborder' => true,
'allowfullscreen' => true,
);
}
return $tags;
}
/**
* Obtains the release post from the Jetpack release post blog. A release post will be displayed in the
* update modal when a post has a tag equal to the Jetpack version number.
*
* The response parameters for the post array can be found here:
* https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/posts/%24post_ID/#apidoc-response
*
* @return array|null Returns an associative array containing the release post data at index ['posts'][0].
* Returns null if the release post data is not available.
*/
public static function get_release_post_data() {
if ( Constants::is_defined( 'TESTING_IN_JETPACK' ) && Constants::get_constant( 'TESTING_IN_JETPACK' ) ) {
return null;
}
$release_post_src = add_query_arg(
array(
'order_by' => 'date',
'tag' => JETPACK__VERSION,
'number' => '1',
),
'https://public-api.wordpress.com/rest/v1/sites/' . JETPACK__RELEASE_POST_BLOG_SLUG . '/posts'
);
$response = wp_remote_get( $release_post_src );
if ( ! is_array( $response ) ) {
return null;
}
return json_decode( wp_remote_retrieve_body( $response ), true );
}
/**
* Get external services connect URLs.
*/
public static function get_external_services_connect_urls() {
$connect_urls = array();
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.jetpack-keyring-service-helper.php';
// phpcs:disable
foreach ( Jetpack_Keyring_Service_Helper::SERVICES as $service_name => $service_info ) {
// phpcs:enable
$connect_urls[ $service_name ] = Jetpack_Keyring_Service_Helper::connect_url( $service_name, $service_info['for'] );
}
return $connect_urls;
}
/**
* Gets a purchase token that is used for Jetpack logged out visitor checkout.
* The purchase token should be appended to all CTA url's that lead to checkout.
*
* @since 9.8.0
* @return string|boolean
*/
public static function get_purchase_token() {
if ( ! Jetpack::current_user_can_purchase() ) {
return false;
}
$purchase_token = Jetpack_Options::get_option( 'purchase_token', false );
if ( $purchase_token ) {
return $purchase_token;
}
// If the purchase token is not saved in the options table yet, then add it.
Jetpack_Options::update_option( 'purchase_token', self::generate_purchase_token(), true );
return Jetpack_Options::get_option( 'purchase_token', false );
}
/**
* Generates a purchase token that is used for Jetpack logged out visitor checkout.
*
* @since 9.8.0
* @return string
*/
public static function generate_purchase_token() {
return wp_generate_password( 12, false );
}
}
// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- TODO: Move these functions to some other file.
/**
* Gather data about the current user.
*
* @since 4.1.0
*
* @return array
*/
function jetpack_current_user_data() {
$jetpack_connection = new Connection_Manager( 'jetpack' );
$current_user = wp_get_current_user();
$is_user_connected = $jetpack_connection->is_user_connected( $current_user->ID );
$is_master_user = $is_user_connected && (int) $current_user->ID && (int) Jetpack_Options::get_option( 'master_user' ) === (int) $current_user->ID;
$dotcom_data = $jetpack_connection->get_connected_user_data();
// Add connected user gravatar to the returned dotcom_data.
// Probably we shouldn't do this when $dotcom_data is false, but we have been since 2016 so
// clients probably expect that by now.
if ( false === $dotcom_data ) {
$dotcom_data = array();
}
$dotcom_data['avatar'] = ( ! empty( $dotcom_data['email'] ) ?
get_avatar_url(
$dotcom_data['email'],
array(
'size' => 64,
'default' => 'mysteryman',
)
)
: false );
$current_user_data = array(
'isConnected' => $is_user_connected,
'isMaster' => $is_master_user,
'username' => $current_user->user_login,
'displayName' => $current_user->display_name,
'email' => $current_user->user_email,
'id' => $current_user->ID,
'wpcomUser' => $dotcom_data,
'gravatar' => get_avatar_url( $current_user->ID ),
'permissions' => array(
'admin_page' => current_user_can( 'jetpack_admin_page' ),
'connect' => current_user_can( 'jetpack_connect' ),
'connect_user' => current_user_can( 'jetpack_connect_user' ),
'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;
}
@@ -0,0 +1,402 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Main class file for Jetpack Admin pages.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
use Automattic\Jetpack\Identity_Crisis;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Status;
/**
* Shared logic between Jetpack admin pages.
*/
abstract class Jetpack_Admin_Page {
/**
* Add page specific actions given the page hook.
*
* @param string $hook Hook of current page.
*/
abstract public function add_page_actions( $hook );
/**
* Create a menu item for the page and returns the hook.
*
* @return string|false Return value from WordPress's `add_menu_page()` or `add_submenu_page()`.
*/
abstract public function get_page_hook();
/**
* Enqueue and localize page specific scripts.
*/
abstract public function page_admin_scripts();
/**
* Render page specific HTML
*/
abstract public function page_render();
/**
* Function called after admin_styles to load any additional needed styles.
*
* @since 4.3.0
*/
public function additional_styles() {}
/**
* Add common page actions and attach page-specific actions.
*/
public function add_actions() {
$is_offline_mode = ( new Status() )->is_offline_mode();
// If user is not an admin and site is in Offline Mode or not connected yet then don't do anything.
if ( ! current_user_can( 'manage_options' ) && ( $is_offline_mode || ! Jetpack::is_connection_ready() ) ) {
return;
}
// Is Jetpack not connected and not offline?
// True means that Jetpack is NOT connected and NOT in offline mode.
// If Jetpack is connected OR in offline mode, this will be false.
$connectable = ! Jetpack::is_connection_ready() && ! $is_offline_mode;
// Don't add in the modules page unless modules are available!
if ( $this->dont_show_if_not_active && $connectable ) {
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_page_load' ) );
add_action( "admin_print_styles-$hook", array( $this, 'admin_styles' ) );
add_action( "admin_print_scripts-$hook", array( $this, 'admin_scripts' ) );
add_action( "admin_print_styles-$hook", array( $this, 'additional_styles' ) );
// 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.
*/
public function render() {
/** This action is documented in class.jetpack.php */
do_action( 'jetpack_initialize_tracking' );
// We're in an IDC: we need a decision made before we show the UI again.
if ( $this->block_page_rendering_for_idc() ) {
return;
}
// Check if we are looking at the main dashboard.
if ( isset( $_GET['page'] ) && 'jetpack' === $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- View logic.
$this->page_render();
return;
}
$args = array();
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['page'] ) && 'jetpack_modules' === $_GET['page'] ) {
$args['is-wide'] = true;
}
self::wrap_ui( array( $this, 'page_render' ), $args );
}
/**
* Call the existing admin page events.
*/
public function admin_page_load() {
Jetpack::init()->admin_page_load();
}
/**
* Add page specific scripts and jetpack stats for all menu pages.
*/
public function admin_scripts() {
$this->page_admin_scripts(); // Delegate to inheriting class.
add_action( 'admin_footer', array( Jetpack::init(), 'do_stats' ) );
}
/**
* Enqueue the Jetpack admin stylesheet.
*/
public 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-connection' ), 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
*/
public 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_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 WP_Screen $page Current WP_Screen object.
*
* @return array
*/
public function check_plan_deactivate_modules( $page ) {
if (
( new Status() )->is_offline_mode()
|| ! in_array(
$page->base,
array(
'toplevel_page_jetpack',
'admin_page_jetpack_modules',
'jetpack_page_vaultpress',
'jetpack_page_stats',
'jetpack_page_akismet-key-config',
),
true
)
) {
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':
case 'jetpack_personal':
case 'jetpack_personal_monthly':
$to_deactivate = array( 'google-analytics', 'wordads', 'search' );
break;
case 'jetpack_premium':
case 'jetpack_premium_monthly':
$to_deactivate = array( '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,
);
}
/**
* Enqueue inline wrapper styles for the main container.
*/
public static function load_wrapper_styles() {
$rtl = is_rtl() ? '.rtl' : '';
wp_enqueue_style( 'dops-css', plugins_url( "_inc/build/admin{$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( 'wp-components' ), JETPACK__VERSION );
}
/**
* Build header, content, and footer for admin page.
*
* @param callable $callback Callback to produce the content of the page. The callback is responsible for any needed escaping.
* @param array $args Options for the wrapping. Also passed to the `jetpack_admin_pages_wrap_ui_after_callback` action.
* - is-wide: (bool) Set the "is-wide" class on the wrapper div, which increases the max width. Default false.
* - show-nav: (bool) Whether to show the navigation bar at the top of the page. Default true.
*/
public static function wrap_ui( $callback, $args = array() ) {
$defaults = array(
'is-wide' => false,
'show-nav' => true,
);
$args = wp_parse_args( $args, $defaults );
// Is Jetpack not connected and not offline?
// True means that Jetpack is NOT connected and NOT in offline mode.
// If Jetpack is connected OR in offline mode, this will be false.
$connectable = ! Jetpack::is_connection_ready() && ! ( new Status() )->is_offline_mode();
$jetpack_admin_url = admin_url( 'admin.php?page=jetpack' );
$jetpack_about_url = ! $connectable
? admin_url( 'admin.php?page=jetpack_about' )
: Redirect::get_url( 'jetpack' );
$jetpack_privacy_url = ! $connectable
? $jetpack_admin_url . '#/privacy'
: Redirect::get_url( 'a8c-privacy' );
$external_link_icon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="jp-footer__menu-item__icon" aria-hidden="true" focusable="false"><path d="M18.2 17c0 .7-.6 1.2-1.2 1.2H7c-.7 0-1.2-.6-1.2-1.2V7c0-.7.6-1.2 1.2-1.2h3.2V4.2H7C5.5 4.2 4.2 5.5 4.2 7v10c0 1.5 1.2 2.8 2.8 2.8h10c1.5 0 2.8-1.2 2.8-2.8v-3.6h-1.5V17zM14.9 3v1.5h3.7l-6.4 6.4 1.1 1.1 6.4-6.4v3.7h1.5V3h-6.3z"></path></svg>';
?>
<div id="jp-plugin-container" class="
<?php
if ( $args['is-wide'] ) {
echo 'is-wide'; }
?>
">
<div class="jp-masthead jp-masthead-middle">
<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="40" viewBox="0 0 118 32"><path fill="#069e08" 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;// phpcs:ignore WordPress.Security.EscapeOutput -- Callback is responsible for any needed escaping.
?>
<!-- END OF CALLBACK -->
<div id="jp-stats-report-bottom">
<div class="wrap">
<?php
/**
* Fires at the bottom of the Jetpack admin page template, after the dynamic content section.
*
* @since 10.0.0
*
* @param string $callback The callback sent to the Jetpack_Admin_Page::wrap_ui method.
* @param array $args The arguments sent to the Jetpack_Admin_Page::wrap_ui method.
*/
do_action( 'jetpack_admin_pages_wrap_ui_after_callback', $callback, $args );
?>
</div>
</div>
<div class="jp-footer jp-footer--static">
<div class="jp-footer__container">
<div class="jp-footer__logo">
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 32 32" class="jetpack-logo jp-footer__jetpack-symbol" aria-labelledby="jetpack-logo-title" height="16" aria-label="<?php esc_html_e( 'Jetpack logo', 'jetpack' ); ?>">
<desc id="jetpack-logo-title">
<?php esc_html_e( 'Jetpack Logo', 'jetpack' ); ?>
</desc>
<path fill="#000" 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>
</svg>
<span class="jp-footer__module-name">
<a href="<?php echo esc_url( Redirect::get_url( 'jetpack' ) ); ?>" target="_blank" rel="noopener noreferrer" aria-label="Jetpack 12.2-a.0">
<?php esc_html_e( 'Jetpack', 'jetpack' ); ?>
</a>
</span>
</div>
<div class="jp-footer__menu">
<a href="<?php echo esc_url( $jetpack_about_url ); ?>" title="<?php esc_attr__( 'About Jetpack', 'jetpack' ); ?>" class="jp-footer__menu-item">
<?php echo esc_html__( 'About', 'jetpack' ); ?>
</a>
<a href="<?php echo esc_url( $jetpack_privacy_url ); ?>" rel="noopener noreferrer" title="<?php esc_html_e( "Automattic's Privacy Policy", 'jetpack' ); ?>" class="jp-footer__menu-item <?php echo ! $connectable ? 'is-external' : ''; ?> ?>" target="<?php echo ! $connectable ? '_blank' : '_self'; ?>">
<?php echo esc_html_x( 'Privacy', 'Navigation item', 'jetpack' ); ?>
<?php echo ! $connectable ? $external_link_icon : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</a>
<a href="<?php echo esc_url( Redirect::get_url( 'wpcom-tos' ) ); ?>" target="_blank" rel="noopener noreferrer" title="<?php esc_html__( 'WordPress.com Terms of Service', 'jetpack' ); ?>" class="jp-footer__menu-item is-external">
<?php echo esc_html_x( 'Terms', 'Navigation item', 'jetpack' ); ?>
<?php echo $external_link_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</a>
<a href="<?php echo esc_url( Redirect::get_url( 'jetpack' ) ); ?>" target="_blank" rel="noopener noreferrer" aria-label="Jetpack 12.2-a.0" class="jp-footer__menu-item is-external">
Version <?php echo esc_html( JETPACK__VERSION ); ?>
<?php echo $external_link_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</a>
<?php if ( is_multisite() && current_user_can( 'jetpack_network_sites_page' ) ) { ?>
<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__menu-item"><?php echo esc_html_x( 'Network Sites', 'Navigation item', 'jetpack' ); ?></a>
<?php } ?>
<?php if ( is_multisite() && current_user_can( 'jetpack_network_settings_page' ) ) { ?>
<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__menu-item"><?php echo esc_html_x( 'Network Settings', 'Navigation item', 'jetpack' ); ?></a>
<?php } ?>
<?php if ( current_user_can( 'manage_options' ) ) { ?>
<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__menu-item"><?php echo esc_html_x( 'Modules', 'Navigation item', 'jetpack' ); ?></a>
<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__menu-item"><?php echo esc_html_x( 'Debug', 'Navigation item', 'jetpack' ); ?></a>
<?php } ?>
</div>
<a class="jp-footer__a8c-logo" href="<?php echo esc_url( $jetpack_about_url ); ?>" aria-label="<?php echo esc_attr__( 'An Automattic Airline', 'jetpack' ); ?>">
<svg role="img" x="0" y="0" viewBox="0 0 935 38.2" enable-background="new 0 0 935 38.2" aria-labelledby="jp-automattic-byline-logo-title" height="7" class="jp-automattic-byline-logo">
<desc id="jp-automattic-byline-logo-title">
<?php echo esc_attr__( 'An Automattic Airline', 'jetpack' ); ?>
</desc>
<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>
</div>
</div>
<?php
}
/**
* Should we block the page rendering because the site is in IDC?
*
* @return bool
*/
protected function block_page_rendering_for_idc() {
return Jetpack::is_connection_ready() && Identity_Crisis::validate_sync_error_idc_option() && ! Jetpack_Options::get_option( 'safe_mode_confirmed' );
}
}
@@ -0,0 +1,6 @@
<?php // phpcs:ignore WordPress.Files.FileName.NotHyphenatedLowercase
/** This is intentionally left empty as a stub because some sites were caching the require()
*
* @see https://github.com/Automattic/jetpack/issues/5091
* @package automattic/jetpack
*/
@@ -0,0 +1,334 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Assets\Logo;
use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Status;
require_once __DIR__ . '/class.jetpack-admin-page.php';
require_once __DIR__ . '/class-jetpack-redux-state-helper.php';
/**
* Builds the landing page and its menu.
*/
class Jetpack_React_Page extends Jetpack_Admin_Page {
/**
* Show the landing page only when Jetpack is connected.
*
* @var bool
*/
protected $dont_show_if_not_active = false;
/**
* Used for fallback when REST API is disabled.
*
* @var bool
*/
protected $is_redirecting = false;
/**
* Add the main admin Jetpack menu.
*
* @return string|false Return value from WordPress's `add_menu_page()`.
*/
public function get_page_hook() {
$icon = ( new Logo() )->get_base64_logo();
return add_menu_page( 'Jetpack', 'Jetpack', 'jetpack_admin_page', 'jetpack', array( $this, 'render' ), $icon, 3 );
}
/**
* Add page action.
*
* @param string $hook Hook of current page.
* @return void
*/
public function add_page_actions( $hook ) {
/** This action is documented in class.jetpack-admin.php */
do_action( 'jetpack_admin_menu', $hook );
if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
$page = sanitize_text_field( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( 'jetpack' !== $page ) {
if ( strpos( $page, 'jetpack/' ) === 0 ) {
$section = substr( $page, 8 );
wp_safe_redirect( admin_url( 'admin.php?page=jetpack#/' . $section ) );
exit( 0 );
}
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_connection_ready() && ! 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' );
}
}
/**
* Remove the main Jetpack submenu if a site is in offline mode or connected.
* At that point, admins can access the Jetpack Dashboard instead.
*
* @since 13.8
*/
public function remove_jetpack_menu() {
if (
( new Status() )->is_offline_mode()
|| Jetpack::is_connection_ready()
) {
remove_submenu_page( 'jetpack', 'jetpack' );
}
}
/**
* 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
*/
public function jetpack_add_dashboard_sub_nav_item() {
if ( ( new Status() )->is_offline_mode() || Jetpack::is_connection_ready() ) {
Admin_Menu::add_menu(
__( 'Dashboard', 'jetpack' ),
__( 'Dashboard', 'jetpack' ),
'jetpack_admin_page',
Jetpack::admin_url( array( 'page' => 'jetpack#/dashboard' ) ),
null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- See https://core.trac.wordpress.org/ticket/52539.
14
);
}
}
/**
* Determine whether a user can access the Jetpack Settings page.
*
* Rules are:
* - user is allowed to see the Jetpack Admin
* - site is connected or in offline mode
* - non-admins only need access to the settings when there are modules they can manage.
*
* @return bool $can_access_settings Can the user access settings.
*/
private function can_access_settings() {
$connection = new Connection_Manager( 'jetpack' );
$status = new Status();
// User must have the necessary permissions to see the Jetpack settings pages.
if ( ! current_user_can( 'edit_posts' ) ) {
return false;
}
// In offline mode, allow access to admins.
if ( $status->is_offline_mode() && current_user_can( 'manage_options' ) ) {
return true;
}
// If not in offline mode but site is not connected, bail.
if ( ! Jetpack::is_connection_ready() ) {
return false;
}
/*
* Additional checks for non-admins.
*/
if ( ! current_user_can( 'manage_options' ) ) {
// If the site isn't connected at all, bail.
if ( ! $connection->has_connected_owner() ) {
return false;
}
/*
* If they haven't connected their own account yet,
* they have no use for the settings page.
* They will not be able to manage any settings.
*/
if ( ! $connection->is_user_connected() ) {
return false;
}
/*
* Non-admins only have access to settings
* for the following modules:
* - Publicize
* - Post By Email
* If those modules are not available, bail.
*/
if (
! Jetpack::is_module_active( 'post-by-email' )
&& ! Jetpack::is_module_active( 'publicize' )
) {
return false;
}
}
// fallback.
return true;
}
/**
* Jetpack Settings sub-link.
*
* @since 4.3.0
* @since 9.7.0 If Connection does not have an owner, restrict it to admins
*/
public function jetpack_add_settings_sub_nav_item() {
if ( $this->can_access_settings() ) {
Admin_Menu::add_menu(
__( 'Settings', 'jetpack' ),
__( 'Settings', 'jetpack' ),
'jetpack_admin_page',
Jetpack::admin_url( array( 'page' => 'jetpack#/settings' ) ),
null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- See https://core.trac.wordpress.org/ticket/52539.
13
);
}
}
/**
* Fallback redirect meta tag if the REST API is disabled.
*
* @return void
*/
public function add_fallback_head_meta() {
echo '<meta http-equiv="refresh" content="0; url=?page=jetpack_modules">';
}
/**
* Fallback meta tag wrapped in noscript tags for all browsers in case they have JavaScript disabled.
*
* @return void
*/
public function add_noscript_head_meta() {
echo '<noscript>';
$this->add_fallback_head_meta();
echo '</noscript>';
}
/**
* Add action to render page specific HTML.
*
* @return void
*/
public function page_render() {
/** This action is already documented in class.jetpack-admin-page.php */
do_action( 'jetpack_notices' );
// Fetch static.html.
$static_html = @file_get_contents( JETPACK__PLUGIN_DIR . '_inc/build/static.html' ); //phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, Not fetching a remote file.
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>pnpm run distclean && pnpm jetpack build plugins/jetpack</code>';
echo '</p>';
} else {
// We got the static.html so let's display it.
echo $static_html; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
/**
* Allow robust deep links to React.
*
* The Jetpack dashboard requires fragments/hash values to make
* a deep link to it but passing fragments as part of a return URL
* will most often be discarded throughout the process.
* This logic aims to bridge this gap and reduce the chance of React
* specific links being broken while passing them along.
*/
public function react_redirects() {
global $pagenow;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( 'admin.php' !== $pagenow || ! isset( $_GET['jp-react-redirect'] ) ) {
return;
}
$allowed_paths = array(
'product-purchased' => admin_url( '/admin.php?page=jetpack#/recommendations/product-purchased' ),
);
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$target = sanitize_text_field( wp_unslash( $_GET['jp-react-redirect'] ) );
if ( isset( $allowed_paths[ $target ] ) ) {
wp_safe_redirect( $allowed_paths[ $target ] );
exit( 0 );
}
}
/**
* Load styles for static page.
*/
public function additional_styles() {
Jetpack_Admin_Page::load_wrapper_styles();
}
/**
* Load admin page scripts.
*/
public function page_admin_scripts() {
if ( $this->is_redirecting ) {
return; // No need for scripts on a fallback page.
}
$status = new Status();
$is_offline_mode = $status->is_offline_mode();
$site_suffix = $status->get_site_suffix();
$script_deps_path = JETPACK__PLUGIN_DIR . '_inc/build/admin.asset.php';
$script_dependencies = array( 'jquery', 'wp-polyfill' );
$version = JETPACK__VERSION;
if ( file_exists( $script_deps_path ) ) {
$asset_manifest = include $script_deps_path;
$script_dependencies = $asset_manifest['dependencies'];
$version = $asset_manifest['version'];
}
$blog_id_prop = '';
if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
$blog_id = Connection_Manager::get_site_id( true );
if ( $blog_id ) {
$blog_id_prop = ', currentBlogID: "' . (int) $blog_id . '"';
}
}
wp_enqueue_script(
'react-plugin',
plugins_url( '_inc/build/admin.js', JETPACK__PLUGIN_FILE ),
$script_dependencies,
$version,
true
);
if ( ! $is_offline_mode && Jetpack::is_connection_ready() ) {
// Required for Analytics.
wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
}
wp_set_script_translations( 'react-plugin', 'jetpack' );
// Add objects to be passed to the initial state of the app.
// Use wp_add_inline_script instead of wp_localize_script, see https://core.trac.wordpress.org/ticket/25280.
wp_add_inline_script( 'react-plugin', 'var Initial_State=JSON.parse(decodeURIComponent("' . rawurlencode( wp_json_encode( Jetpack_Redux_State_Helper::get_initial_state() ) ) . '"));', 'before' );
// This will set the default URL of the jp_redirects lib.
wp_add_inline_script( 'react-plugin', 'var jetpack_redirects = { currentSiteRawUrl: "' . $site_suffix . '"' . $blog_id_prop . ' };', 'before' );
// Adds Connection package initial state.
Connection_Initial_State::render_script( 'react-plugin' );
}
}
@@ -0,0 +1,194 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Tracking;
require_once __DIR__ . '/class.jetpack-admin-page.php';
require_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.
*
* @var boolean
*/
protected $dont_show_if_not_active = true;
/**
* Add page action.
*
* @param string $hook Hook of current page.
* @return void
*/
public function add_page_actions( $hook ) {} //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
/**
* Adds the Settings sub menu.
*/
public function get_page_hook() {
return add_submenu_page(
'',
__( '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
*/
public 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' ); //phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, Not fetching a remote file.
$rest_api_notice = @file_get_contents( JETPACK__PLUGIN_DIR . '_inc/build/static-version-notice.html' ); //phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, Not fetching a remote file.
$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; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo $noscript_notice; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<div class="jetpack-module-list">
<div class="wrap">
<div class="manage-left jp-static-block">
<table class="table table-bordered fixed-top jetpack-modules">
<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>
<form class="jetpack-modules-list-table-form" onsubmit="return false;">
<table class="<?php echo esc_attr( implode( ' ', $list_table->get_table_classes() ) ); ?>">
<tbody id="the-list">
<?php $list_table->display_rows_or_placeholder(); ?>
</tbody>
</table>
</form>
</div>
<div class="manage-right">
<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>
<span class="dops-button-group button-group filter-active">
<button type="button" class="dops-button is-compact button
<?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is view logic.
if ( empty( $_GET['activated'] ) ) {
echo 'active';
}
?>
">
<?php esc_html_e( 'All', 'jetpack' ); ?></button>
<button type="button" class="dops-button button
<?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is view logic.
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="dops-button button
<?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is view logic.
if ( ! empty( $_GET['activated'] ) && 'false' === $_GET['activated'] ) {
echo 'active';
}
?>
" data-filter-by="activated" data-filter-value="false"><?php esc_html_e( 'Inactive', 'jetpack' ); ?></button>
</span>
<p><?php esc_html_e( 'Sort by', 'jetpack' ); ?></p>
<span class="dops-button-group button-group sort">
<button type="button" class="dops-button button
<?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is view logic.
if ( empty( $_GET['sort_by'] ) ) {
echo 'active';
}
?>
" data-sort-by="name"><?php esc_html_e( 'Alphabetical', 'jetpack' ); ?></button>
<button type="button" class="dops-button button
<?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is view logic.
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="dops-button button
<?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is view logic.
if ( ! empty( $_GET['sort_by'] ) && 'sort' === $_GET['sort_by'] ) {
echo 'active';
}
?>
" data-sort-by="sort"><?php esc_html_e( 'Popular', 'jetpack' ); ?></button>
</span>
<p><?php esc_html_e( 'Show', 'jetpack' ); ?></p>
<?php $list_table->views(); ?>
</form>
</div>
</div>
</div>
</div><!-- /.content -->
<?php
$tracking = new Tracking();
$tracking->record_user_event( 'wpa_page_view', array( 'path' => 'old_settings' ) );
}
/**
* Load styles for static page.
*
* @since 4.3.0
*/
public function additional_styles() {
Jetpack_Admin_Page::load_wrapper_styles();
}
/**
* Javascript logic specific to the list table
*/
public function page_admin_scripts() {
wp_enqueue_script(
'jetpack-admin-js',
Assets::get_file_url_for_environment( '_inc/build/jetpack-admin.min.js', '_inc/jetpack-admin.js' ),
array( 'jquery' ),
JETPACK__VERSION,
true
);
}
}
@@ -0,0 +1,471 @@
<?php
/**
* API helper for the AI blocks.
*
* @package automattic/jetpack
* @since 11.8
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager;
use Automattic\Jetpack\Search\Plan as Search_Plan;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Visitor;
/**
* Class Jetpack_AI_Helper
*
* @since 11.8
*/
class Jetpack_AI_Helper {
/**
* Allow new completion every X seconds. Will return cached result otherwise.
*
* @var int
*/
public static $text_completion_cooldown_seconds = 15;
/**
* Cache images for a prompt for a month.
*
* @var int
*/
public static $image_generation_cache_timeout = MONTH_IN_SECONDS;
/**
* Cache AI-assistant feature for 60 seconds.
*
* @var int
*/
public static $ai_assistant_feature_cache_timeout = 60;
/**
* Cache AI-assistant errors for ten seconds.
*
* @var int
*/
public static $ai_assistant_feature_error_cache_timeout = 10;
/**
* Stores the number of JetpackAI calls in case we want to mark AI-assisted posts some way.
*
* @var int
*/
public static $post_meta_with_ai_generation_number = '_jetpack_ai_calls';
/**
* Storing the error to prevent repeated requests to WPCOM after failure.
*
* @var null|WP_Error
*/
private static $ai_assistant_failed_request = null;
/**
* Checks if a given request is allowed to get AI data from WordPress.com.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return true|WP_Error True if the request has access, WP_Error object otherwise.
*/
public static function get_status_permission_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
/*
* This may need to be updated
* to take into account the different ways we can make requests
* (from a WordPress.com site, from a Jetpack site).
*/
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to access Jetpack AI help on this site.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Return true if these features should be active on the current site.
* Currently, it's limited to WPCOM Simple and Atomic.
*/
public static function is_enabled() {
$default = false;
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$default = true;
} elseif ( ( new Automattic\Jetpack\Status\Host() )->is_woa_site() ) {
$default = true;
}
/**
* Filter whether the AI features are enabled in the Jetpack plugin.
*
* @since 11.8
*
* @param bool $default Are AI features enabled? Defaults to false.
*/
return apply_filters( 'jetpack_ai_enabled', $default );
}
/**
* Return true if the AI chat feature should be active on the current site.
*
* @todo IS_WPCOM (the endpoints need to be updated too).
*
* @return bool
*/
public static function is_ai_chat_enabled() {
$default = false;
$connection = new Manager();
$plan = new Search_Plan();
if ( $connection->is_connected() && $plan->supports_search() ) {
$default = true;
}
/**
* Filter whether the AI chat feature is enabled in the Jetpack plugin.
*
* @since 12.6
*
* @param bool $default Is AI chat enabled? Defaults to false.
*/
return apply_filters( 'jetpack_ai_chat_enabled', $default );
}
/**
* Get the name of the transient for image generation. Unique per prompt and allows for reuse of results for the same prompt across entire WPCOM.
* I expext "puppy" to always be from cache.
*
* @param string $prompt - Supplied prompt.
*/
public static function transient_name_for_image_generation( $prompt ) {
return 'jetpack_openai_image_' . md5( $prompt );
}
/**
* Get the name of the transient for text completion. Unique per user, but not per text. Serves more as a cooldown.
*/
public static function transient_name_for_completion() {
return 'jetpack_openai_completion_' . get_current_user_id(); // Cache for each user, so that other users dont get weird cached version from somebody else.
}
/**
* Get the name of the transient for AI assistance feature. Unique per user.
*
* @param int $blog_id - Blog ID to get the transient name for.
* @return string
*/
public static function transient_name_for_ai_assistance_feature( $blog_id ) {
return 'jetpack_openai_ai_assistance_feature_' . $blog_id;
}
/**
* Mark the edited post as "touched" by AI stuff.
*
* @param int $post_id Post ID for which the content is being generated.
* @return void
*/
private static function mark_post_as_ai_assisted( $post_id ) {
if ( ! $post_id ) {
return;
}
$previous = get_post_meta( $post_id, self::$post_meta_with_ai_generation_number, true );
if ( ! $previous ) {
$previous = 0;
} elseif ( ! is_numeric( $previous ) ) {
// Data corrupted, nothing to do.
return;
}
$new_value = intval( $previous ) + 1;
update_post_meta( $post_id, self::$post_meta_with_ai_generation_number, $new_value );
}
/**
* Get text back from WordPress.com based off a starting text.
*
* @param string $content The content provided to send to the AI.
* @param int $post_id Post ID for which the content is being generated.
* @param bool $skip_cache Skip cache and force a new request.
* @return mixed
*/
public static function get_gpt_completion( $content, $post_id, $skip_cache = false ) {
$content = wp_strip_all_tags( $content );
$cache = get_transient( self::transient_name_for_completion() );
if ( $cache && ! $skip_cache ) {
return $cache;
}
if ( ( new Status() )->is_offline_mode() ) {
return new WP_Error(
'dev_mode',
__( 'Jetpack AI is not available in offline mode.', 'jetpack' )
);
}
$site_id = Manager::get_site_id();
if ( is_wp_error( $site_id ) ) {
return $site_id;
}
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
if ( ! class_exists( 'OpenAI' ) ) {
\require_lib( 'openai' );
}
// Set the content for chatGPT endpoint
$data = array(
array(
'role' => 'user',
'content' => $content,
),
);
$openai = new OpenAI( 'openai', array( 'post_id' => $post_id ) );
$moderation_result = $openai->moderate(
implode(
' ',
array_map(
function ( $msg ) {
return $msg['role'] === 'user' ? $msg['content'] : '';
},
$data
)
)
);
if ( is_wp_error( $moderation_result ) ) {
return $moderation_result;
}
$max_tokens = 480; // Default
$result = $openai->request_chat_completion( $data, $max_tokens );
if ( is_wp_error( $result ) ) {
return $result;
}
$response = $result->choices[0]->message->content;
// In case of Jetpack we are setting a transient on the WPCOM and not the remote site. I think the 'get_current_user_id' may default for the connection owner at this point but we'll deal with this later.
set_transient( self::transient_name_for_completion(), $response, self::$text_completion_cooldown_seconds );
self::mark_post_as_ai_assisted( $post_id );
return $response;
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/jetpack-ai/completions', $site_id ),
2,
array(
'method' => 'post',
'headers' => array( 'content-type' => 'application/json' ),
),
wp_json_encode(
array(
'content' => $content,
)
),
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$data = json_decode( wp_remote_retrieve_body( $response ) );
if ( wp_remote_retrieve_response_code( $response ) >= 400 ) {
return new WP_Error( $data->code, $data->message, $data->data );
}
// Do not cache if it should be skipped.
if ( ! $skip_cache ) {
set_transient( self::transient_name_for_completion(), $data, self::$text_completion_cooldown_seconds );
}
self::mark_post_as_ai_assisted( $post_id );
return $data;
}
/**
* Get an array of image objects back from WordPress.com based off a prompt.
*
* @param string $prompt The prompt to generate images for.
* @param int $post_id Post ID for which the content is being generated.
* @return mixed
*/
public static function get_dalle_generation( $prompt, $post_id ) {
$cache = get_transient( self::transient_name_for_image_generation( $prompt ) );
if ( $cache ) {
self::mark_post_as_ai_assisted( $post_id );
return $cache;
}
if ( ( new Status() )->is_offline_mode() ) {
return new WP_Error(
'dev_mode',
__( 'Jetpack AI is not available in offline mode.', 'jetpack' )
);
}
$site_id = Manager::get_site_id();
if ( is_wp_error( $site_id ) ) {
return $site_id;
}
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
if ( ! class_exists( 'OpenAI' ) ) {
\require_lib( 'openai' );
}
$result = ( new OpenAI( 'openai', array( 'post_id' => $post_id ) ) )->request_dalle_generation( $prompt );
if ( is_wp_error( $result ) ) {
return $result;
}
set_transient( self::transient_name_for_image_generation( $prompt ), $result, self::$image_generation_cache_timeout );
self::mark_post_as_ai_assisted( $post_id );
return $result;
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/jetpack-ai/images/generations', $site_id ),
2,
array(
'method' => 'post',
'headers' => array( 'content-type' => 'application/json' ),
),
wp_json_encode(
array(
'prompt' => $prompt,
)
),
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$data = json_decode( wp_remote_retrieve_body( $response ) );
if ( wp_remote_retrieve_response_code( $response ) >= 400 ) {
return new WP_Error( $data->code, $data->message, $data->data );
}
set_transient( self::transient_name_for_image_generation( $prompt ), $data, self::$image_generation_cache_timeout );
self::mark_post_as_ai_assisted( $post_id );
return $data;
}
/**
* Get an object with useful data about the requests made to the AI.
*
* @return mixed
*/
public static function get_ai_assistance_feature() {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
// On WPCOM, we can get the ID from the site.
$blog_id = get_current_blog_id();
$has_ai_assistant_feature = \wpcom_site_has_feature( 'ai-assistant', $blog_id );
if ( ! class_exists( 'WPCOM\Jetpack_AI\Usage\Helper' ) ) {
if ( is_readable( WP_CONTENT_DIR . '/lib/jetpack-ai/usage/helper.php' ) ) {
require_once WP_CONTENT_DIR . '/lib/jetpack-ai/usage/helper.php';
} else {
return new WP_Error(
'jetpack_ai_usage_helper_not_found',
__( 'WPCOM\Jetpack_AI\Usage\Helper class not found.', 'jetpack' )
);
}
}
if ( ! class_exists( 'WPCOM\Jetpack_AI\Feature_Control' ) ) {
if ( is_readable( WP_CONTENT_DIR . '/lib/jetpack-ai/feature-control.php' ) ) {
require_once WP_CONTENT_DIR . '/lib/jetpack-ai/feature-control.php';
} else {
return new WP_Error(
'jetpack_ai_feature_control_not_found',
__( 'WPCOM\Jetpack_AI\Feature_Control class not found.', 'jetpack' )
);
}
}
// Determine the upgrade type
$upgrade_type = wpcom_is_vip( $blog_id ) ? 'vip' : 'default';
return array(
'has-feature' => $has_ai_assistant_feature,
'is-over-limit' => WPCOM\Jetpack_AI\Usage\Helper::is_over_limit( $blog_id ),
'requests-count' => WPCOM\Jetpack_AI\Usage\Helper::get_all_time_requests_count( $blog_id ),
'requests-limit' => WPCOM\Jetpack_AI\Usage\Helper::get_free_requests_limit( $blog_id ),
'usage-period' => WPCOM\Jetpack_AI\Usage\Helper::get_period_data( $blog_id ),
'site-require-upgrade' => WPCOM\Jetpack_AI\Usage\Helper::site_requires_upgrade( $blog_id ),
'upgrade-type' => $upgrade_type,
'upgrade-url' => WPCOM\Jetpack_AI\Usage\Helper::get_upgrade_url( $blog_id ),
'current-tier' => WPCOM\Jetpack_AI\Usage\Helper::get_current_tier( $blog_id ),
'next-tier' => WPCOM\Jetpack_AI\Usage\Helper::get_next_tier( $blog_id ),
'tier-plans' => WPCOM\Jetpack_AI\Usage\Helper::get_tier_plans_list(),
'tier-plans-enabled' => WPCOM\Jetpack_AI\Usage\Helper::ai_tier_plans_enabled(),
'costs' => WPCOM\Jetpack_AI\Usage\Helper::get_costs(),
'features-control' => WPCOM\Jetpack_AI\Feature_Control::get_features(),
);
}
// Outside of WPCOM, we need to fetch the data from the site.
$blog_id = Jetpack_Options::get_option( 'id' );
// Try to pick the AI Assistant feature from cache.
$transient_name = self::transient_name_for_ai_assistance_feature( $blog_id );
$cache = get_transient( $transient_name );
if ( $cache ) {
return $cache;
}
if ( null !== static::$ai_assistant_failed_request ) {
return static::$ai_assistant_failed_request;
}
$request_path = sprintf( '/sites/%d/jetpack-ai/ai-assistant-feature', $blog_id );
$wpcom_request = Client::wpcom_json_api_request_as_user(
$request_path,
'v2',
array(
'method' => 'GET',
'headers' => array(
'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
),
'timeout' => 30,
),
null,
'wpcom'
);
$response_code = wp_remote_retrieve_response_code( $wpcom_request );
if ( 200 === $response_code ) {
$ai_assistant_feature_data = json_decode( wp_remote_retrieve_body( $wpcom_request ), true );
// Cache the AI Assistant feature, for Jetpack sites.
set_transient( $transient_name, $ai_assistant_feature_data, self::$ai_assistant_feature_cache_timeout );
return $ai_assistant_feature_data;
} else {
$error = new WP_Error(
'failed_to_fetch_data',
esc_html__( 'Unable to fetch the requested data.', 'jetpack' ),
array(
'status' => $response_code,
'ts' => time(),
)
);
// Cache the AI Assistant feature error, for Jetpack sites, avoid API hammering.
set_transient( $transient_name, $error, self::$ai_assistant_feature_error_cache_timeout );
static::$ai_assistant_failed_request = $error;
return $error;
}
}
}
@@ -0,0 +1,180 @@
<?php
/**
* Jetpack_Currencies: Utils for displaying and managing currencies.
*
* @package Jetpack
* @since 9.1.0
*/
/**
* General currencies specific functionality
*/
class Jetpack_Currencies {
/**
* Currencies definition
*/
const CURRENCIES = array(
'USD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => '$',
'decimal' => 2,
),
'GBP' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => '&#163;',
'decimal' => 2,
),
'JPY' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => '&#165;',
'decimal' => 0,
),
'BRL' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'R$',
'decimal' => 2,
),
'EUR' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => '&#8364;',
'decimal' => 2,
),
'NZD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'NZ$',
'decimal' => 2,
),
'AUD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'A$',
'decimal' => 2,
),
'CAD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'C$',
'decimal' => 2,
),
'ILS' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => '₪',
'decimal' => 2,
),
'RUB' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => '₽',
'decimal' => 2,
),
'MXN' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'MX$',
'decimal' => 2,
),
'MYR' => array(
'format' => '%2$s%1$s', // 1: Symbol 2: currency value
'symbol' => 'RM',
'decimal' => 2,
),
'SEK' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'Skr',
'decimal' => 2,
),
'HUF' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'Ft',
'decimal' => 0, // Decimals are supported by Stripe but not by PayPal.
),
'CHF' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'CHF',
'decimal' => 2,
),
'CZK' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'Kč',
'decimal' => 2,
),
'DKK' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'Dkr',
'decimal' => 2,
),
'HKD' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'HK$',
'decimal' => 2,
),
'NOK' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'Kr',
'decimal' => 2,
),
'PHP' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => '₱',
'decimal' => 2,
),
'PLN' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => 'PLN',
'decimal' => 2,
),
'SGD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'S$',
'decimal' => 2,
),
'TWD' => array(
'format' => '%1$s%2$s', // 1: Symbol 2: currency value
'symbol' => 'NT$',
'decimal' => 0, // Decimals are supported by Stripe but not by PayPal.
),
'THB' => array(
'format' => '%2$s%1$s', // 1: Symbol 2: currency value
'symbol' => '฿',
'decimal' => 2,
),
'INR' => array(
'format' => '%2$s %1$s', // 1: Symbol 2: currency value
'symbol' => '₹',
'decimal' => 0,
),
);
/**
* Format a price with currency.
*
* Uses currency-aware formatting to output a formatted price with a simple fallback.
*
* Largely inspired by WordPress.com's Store_Price::display_currency
*
* @param string $price Price.
* @param string $currency Currency.
* @param bool $symbol Whether to display the currency symbol.
* @return string Formatted price.
*/
public static function format_price( $price, $currency, $symbol = true ) {
// Add some basic formatting for the price.
$formatted_number = new NumberFormatter( get_locale(), NumberFormatter::DECIMAL );
$price = (float) $formatted_number->parse( $price );
// Fall back to unspecified currency symbol like `¤1,234.05`.
// @link https://en.wikipedia.org/wiki/Currency_sign_(typography).
if ( ! array_key_exists( $currency, self::CURRENCIES ) ) {
return ( $symbol ? '¤' : '' ) . number_format_i18n( $price, 2 );
}
$currency_details = self::CURRENCIES[ $currency ];
// Ensure USD displays as 1234.56 even in non-US locales.
$amount = 'USD' === $currency
? number_format( $price, $currency_details['decimal'], '.', ',' )
: number_format_i18n( $price, $currency_details['decimal'] );
return sprintf(
$currency_details['format'],
$symbol ? $currency_details['symbol'] : '',
$amount
);
}
}
@@ -0,0 +1,98 @@
<?php
/**
* Instagram Gallery block and API helper.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager;
/**
* Class Jetpack_Instagram_Gallery_Helper
*/
class Jetpack_Instagram_Gallery_Helper {
const TRANSIENT_KEY_PREFIX = 'jetpack_instagram_gallery_block_';
/**
* Check whether an Instagram access token is valid,
* or has been permanently deleted elsewhere.
*
* @param string $access_token_id The ID of the external access token for Instagram.
* @return bool
*/
public static function is_instagram_access_token_valid( $access_token_id ) {
$site_id = Manager::get_site_id();
if ( is_wp_error( $site_id ) ) {
return false;
}
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
if ( ! class_exists( 'WPCOM_Instagram_Gallery_Helper' ) ) {
\require_lib( 'instagram-gallery-helper' );
}
$token = WPCOM_Instagram_Gallery_Helper::get_token( $access_token_id );
return ! is_wp_error( $token );
}
$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/sites/%d/instagram/%d/check-token', $site_id, $access_token_id ),
2,
array( 'headers' => array( 'content-type' => 'application/json' ) ),
null,
'wpcom'
);
return 200 === wp_remote_retrieve_response_code( $response );
}
/**
* Get the Instagram Gallery.
*
* @param string $access_token_id The ID of the external access token for Instagram.
* @param int $count The number of Instagram posts to fetch.
* @return mixed
*/
public static function get_instagram_gallery( $access_token_id, $count ) {
$site_id = Manager::get_site_id();
if ( is_wp_error( $site_id ) ) {
return $site_id;
}
$transient_key = self::TRANSIENT_KEY_PREFIX . $access_token_id;
// Check if the connection exists before trying to retrieve the cached gallery.
if ( ! self::is_instagram_access_token_valid( $access_token_id ) ) {
delete_transient( $transient_key );
return new WP_Error(
'instagram_connection_unavailable',
__( 'The requested Instagram connection is not available anymore.', 'jetpack' ),
403
);
}
$cached_gallery = get_transient( $transient_key );
if ( $cached_gallery ) {
$decoded_cached_gallery = json_decode( $cached_gallery );
// `images` can be an array of images or a string 'ERROR'.
$cached_count = is_array( $decoded_cached_gallery->images ) ? count( $decoded_cached_gallery->images ) : 0;
if ( $cached_count >= $count ) {
return $decoded_cached_gallery;
}
}
$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/sites/%d/instagram/%d?count=%d', $site_id, $access_token_id, $count ),
2,
array( 'headers' => array( 'content-type' => 'application/json' ) ),
null,
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$gallery = wp_remote_retrieve_body( $response );
set_transient( $transient_key, $gallery, HOUR_IN_SECONDS );
return json_decode( $gallery );
}
}
@@ -0,0 +1,111 @@
<?php
/**
* Mapbox API helper.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Status\Host;
/**
* Class Jetpack_Mapbox_Helper
*/
class Jetpack_Mapbox_Helper {
/**
* Site option key for the Mapbox service.
*
* @var string
*/
private static $site_option_key = 'mapbox_api_key';
/**
* Transient key for the WordPress.com Mapbox access token.
*
* @var string
*/
private static $transient_key = 'wpcom_mapbox_access_token';
/**
* Get the site's own Mapbox access token if set, or the WordPress.com's one otherwise.
*
* @return array An array containing the key (if any) and its source ("site" or "wpcom").
*/
public static function get_access_token() {
// If the site provides its own Mapbox access token, return it.
$service_api_key = Jetpack_Options::get_option( self::$site_option_key );
if ( $service_api_key ) {
return self::format_access_token( $service_api_key );
}
$site_id = self::get_wpcom_site_id();
// If on WordPress.com, try to return the access token straight away.
if ( self::is_wpcom() && defined( 'WPCOM_MAPBOX_ACCESS_TOKEN' ) ) {
require_lib( 'mapbox-blocklist' );
return wpcom_is_site_blocked_from_map_block( $site_id )
? self::format_access_token()
: self::format_access_token( WPCOM_MAPBOX_ACCESS_TOKEN, 'wpcom' );
}
// If not on WordPress.com or Atomic, return an empty access token.
if ( ! $site_id || ( ! self::is_wpcom() && ! ( new Host() )->is_woa_site() ) ) {
return self::format_access_token();
}
// If there is a cached token, return it.
$cached_token = get_transient( self::$transient_key );
if ( $cached_token ) {
return self::format_access_token( $cached_token, 'wpcom' );
}
// Otherwise get it from the WordPress.com endpoint.
$request_url = 'https://public-api.wordpress.com/wpcom/v2/sites/' . $site_id . '/mapbox';
$response = wp_remote_get( esc_url_raw( $request_url ) );
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
return self::format_access_token();
}
$response_body = json_decode( wp_remote_retrieve_body( $response ) );
$wpcom_mapbox_access_token = $response_body->wpcom_mapbox_access_token;
set_transient( self::$transient_key, $wpcom_mapbox_access_token, HOUR_IN_SECONDS );
return self::format_access_token( $wpcom_mapbox_access_token, 'wpcom' );
}
/**
* Check if we're in WordPress.com.
*
* @return bool
*/
private static function is_wpcom() {
return defined( 'IS_WPCOM' ) && IS_WPCOM;
}
/**
* Get the current site's WordPress.com ID.
*
* @return mixed The site's WordPress.com ID.
*/
private static function get_wpcom_site_id() {
if ( self::is_wpcom() ) {
return get_current_blog_id();
} elseif ( method_exists( 'Jetpack', 'is_connection_ready' ) && Jetpack::is_connection_ready() ) {
return Jetpack_Options::get_option( 'id' );
}
return false;
}
/**
* Format an access token and its source into an array.
*
* @param string $key The API key.
* @param string $source The key's source ("site" or "wpcom").
* @return array
*/
private static function format_access_token( $key = '', $source = 'site' ) {
return array(
'key' => $key,
'source' => $source,
);
}
}
@@ -0,0 +1,110 @@
<?php
/**
* Extension of the SimplePie\Locator class, to detect podcast feeds
*
* @package automattic/jetpack
*/
// Dummy comment to make phpcs happy.
require_once __DIR__ . '/jp-simplepie-alias.php';
/**
* Class Jetpack_Podcast_Feed_Locator
*/
class Jetpack_Podcast_Feed_Locator extends Jetpack\SimplePie\Locator {
/**
* Overrides the locator is_feed function to check for
* appropriate podcast elements.
*
* @param Jetpack\SimplePie\File $file The file being checked.
* @param boolean $check_html Adds text/html to the mimetypes checked.
*/
public function is_feed( $file, $check_html = false ) {
return parent::is_feed( $file, $check_html ) && $this->is_podcast_feed( $file );
}
/**
* Checks the contents of the file for elements that make
* it a podcast feed.
*
* @param Jetpack\SimplePie\File $file The file being checked.
*/
private function is_podcast_feed( $file ) {
// If we can't read the DOM assume it's a podcast feed, we'll work
// it out later.
if ( ! class_exists( 'DOMDocument' ) ) {
return true;
}
$feed_dom = $this->safely_load_xml( $file->body );
// Do this as either/or but prioritise the itunes namespace. It's pretty likely
// that it's a podcast feed we've found if that namespace is present.
return $feed_dom && $this->has_itunes_ns( $feed_dom ) && $this->has_audio_enclosures( $feed_dom );
}
/**
* Safely loads an XML file
*
* @param string $xml A string of XML to load.
* @return DOMDocument|false A restulting DOM document or `false` if there is an error.
*/
private function safely_load_xml( $xml ) {
$disable_entity_loader = PHP_VERSION_ID < 80000;
if ( $disable_entity_loader ) {
// This function has been deprecated in PHP 8.0 because in libxml 2.9.0, external entity loading
// is disabled by default, so this function is no longer needed to protect against XXE attacks.
// phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated, PHPCompatibility.FunctionUse.RemovedFunctions.libxml_disable_entity_loaderDeprecated
$loader = libxml_disable_entity_loader( true );
}
$errors = libxml_use_internal_errors( true );
$return = new DOMDocument();
if ( ! $return->loadXML( $xml ) ) {
return false;
}
libxml_use_internal_errors( $errors );
if ( $disable_entity_loader && isset( $loader ) ) {
// phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated, PHPCompatibility.FunctionUse.RemovedFunctions.libxml_disable_entity_loaderDeprecated
libxml_disable_entity_loader( $loader );
}
return $return;
}
/**
* Checks the RSS feed for the presence of the itunes podcast namespace.
* It's pretty loose and just checks the URI for itunes.com
*
* @param DOMDocument $dom The XML document to check.
* @return boolean Whether the itunes namespace is defined.
*/
private function has_itunes_ns( $dom ) {
$xpath = new DOMXPath( $dom );
foreach ( $xpath->query( 'namespace::*' ) as $node ) {
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
// nodeValue is not valid, but it's part of the DOM API that we don't control.
if ( strstr( $node->nodeValue, 'itunes.com' ) ) {
return true;
}
// phpcs:enable
}
return false;
}
/**
* Checks the RSS feed for the presence of enclosures with an audio mimetype.
*
* @param DOMDocument $dom The XML document to check.
* @return boolean Whether enclosures were found.
*/
private function has_audio_enclosures( $dom ) {
$xpath = new DOMXPath( $dom );
$enclosures = $xpath->query( "//enclosure[starts-with(@type,'audio/')]" );
return ! $enclosures ? false : $enclosures->length > 0;
}
}
@@ -0,0 +1,632 @@
<?php
/**
* Helper to massage Podcast data to be used in the Podcast block.
*
* @package automattic/jetpack
*/
// Dummy comment to make phpcs happy.
require_once __DIR__ . '/jp-simplepie-alias.php';
/**
* Class Jetpack_Podcast_Helper
*/
class Jetpack_Podcast_Helper {
/**
* The RSS feed of the podcast.
*
* @var string
*/
protected $feed = null;
/**
* The number of seconds to cache the podcast feed data.
* This value defaults to 1 hour specifically for podcast feeds.
* The value can be overridden specifically for podcasts using the
* `jetpack_podcast_feed_cache_timeout` filter. Note that the cache timeout value
* for all RSS feeds can be modified using the `wp_feed_cache_transient_lifetime`
* filter from WordPress core.
*
* @see https://developer.wordpress.org/reference/hooks/wp_feed_cache_transient_lifetime/
* @see WP_Feed_Cache_Transient
*
* @var int|null
*/
protected $cache_timeout = HOUR_IN_SECONDS;
/**
* Initialize class.
*
* @param string $feed The RSS feed of the podcast.
*/
public function __construct( $feed ) {
$this->feed = esc_url_raw( $feed );
/**
* Filter the number of seconds to cache a specific podcast URL for. The returned value will be ignored if it is null or not a valid integer.
* Note that this timeout will only work if the site is using the default `WP_Feed_Cache_Transient` cache implementation for RSS feeds,
* or their cache implementation relies on the `wp_feed_cache_transient_lifetime` filter.
*
* @since 11.3
* @see https://developer.wordpress.org/reference/hooks/wp_feed_cache_transient_lifetime/
*
* @param int|null $cache_timeout The number of seconds to cache the podcast data. Default value is null, so we don't override any defaults from existing filters.
* @param string $podcast_url The URL of the podcast feed.
*/
$podcast_cache_timeout = apply_filters( 'jetpack_podcast_feed_cache_timeout', $this->cache_timeout, $this->feed );
// Make sure we force new values for $this->cache_timeout to be integers.
if ( is_numeric( $podcast_cache_timeout ) ) {
$this->cache_timeout = (int) $podcast_cache_timeout;
}
}
/**
* Retrieves tracks quantity.
*
* @return int number of tracks
*/
public static function get_tracks_quantity() {
/**
* Allow requesting a specific number of tracks from SimplePie's `get_items` call.
* The default number of tracks is ten.
*
* @since 10.4.0
*
* @param int $number Number of tracks fetched. Default is 10.
*/
return (int) apply_filters( 'jetpack_podcast_helper_tracks_quantity', 10 );
}
/**
* Gets podcast data formatted to be used by the Podcast Player block in both server-side
* block rendering and in API `WPCOM_REST_API_V2_Endpoint_Podcast_Player`.
*
* The result is cached for one hour.
*
* @param array $args {
* Optional array of arguments.
* @type string|int $guid The ID of a specific episode to return rather than a list.
* }
*
* @return array|WP_Error The player data or a error object.
*/
public function get_player_data( $args = array() ) {
$guids = isset( $args['guids'] ) && $args['guids'] ? $args['guids'] : array();
$episode_options = isset( $args['episode-options'] ) && $args['episode-options'];
// Try loading data from the cache.
$transient_key = 'jetpack_podcast_' . md5( $this->feed . implode( ',', $guids ) . "-$episode_options" );
$player_data = get_transient( $transient_key );
// Fetch data if we don't have any cached.
if ( false === $player_data || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
// Load feed.
$rss = $this->load_feed();
if ( is_wp_error( $rss ) ) {
return $rss;
}
// Get a list of episodes by guid or all tracks in feed.
if ( count( $guids ) ) {
$tracks = array_map( array( $this, 'get_track_data' ), $guids );
$tracks = array_filter(
$tracks,
function ( $track ) {
return ! is_wp_error( $track );
}
);
} else {
$tracks = $this->get_track_list();
}
if ( is_wp_error( $tracks ) ) {
return $tracks;
}
if ( empty( $tracks ) ) {
return new WP_Error( 'no_tracks', __( 'Your Podcast couldn\'t be embedded as it doesn\'t contain any tracks. Please double check your URL.', 'jetpack' ) );
}
// Get podcast meta.
$title = $rss->get_title();
$title = $this->get_plain_text( $title );
$description = $rss->get_description();
$description = $this->get_plain_text( $description );
$cover = $rss->get_image_url();
$cover = ! empty( $cover ) ? esc_url( $cover ) : null;
$link = $rss->get_link();
$link = ! empty( $link ) ? esc_url( $link ) : null;
$player_data = array(
'title' => $title,
'description' => $description,
'link' => $link,
'cover' => $cover,
'tracks' => $tracks,
);
if ( $episode_options ) {
$player_data['options'] = array();
foreach ( $rss->get_items() as $episode ) {
$enclosure = $this->get_audio_enclosure( $episode );
// If the episode doesn't have playable audio, then don't include it.
if ( is_wp_error( $enclosure ) ) {
continue;
}
$player_data['options'][] = array(
'label' => $this->get_plain_text( $episode->get_title() ),
'value' => $episode->get_id(),
);
}
}
// Cache for 1 hour.
set_transient( $transient_key, $player_data, HOUR_IN_SECONDS );
}
return $player_data;
}
/**
* Gets a specific track from the supplied feed URL.
*
* @param string $guid The GUID of the track.
* @param boolean $force_refresh Clear the feed cache.
* @return array|WP_Error The track object or an error object.
*/
public function get_track_data( $guid, $force_refresh = false ) {
// Get the cache key.
$transient_key = 'jetpack_podcast_' . md5( "$this->feed::$guid" );
// Clear the cache if force_refresh param is true.
if ( true === $force_refresh ) {
delete_transient( $transient_key );
}
// Try loading track data from the cache.
$track_data = get_transient( $transient_key );
// Fetch data if we don't have any cached.
if ( false === $track_data || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
// Load feed.
$rss = $this->load_feed( $force_refresh );
if ( is_wp_error( $rss ) ) {
return $rss;
}
// Loop over all tracks to find the one.
foreach ( $rss->get_items() as $track ) {
if ( $guid === $track->get_id() ) {
$track_data = $this->setup_tracks_callback( $track );
break;
}
}
if ( false === $track_data ) {
return new WP_Error( 'no_track', __( 'The track was not found.', 'jetpack' ) );
}
// Cache for 1 hour.
set_transient( $transient_key, $track_data, HOUR_IN_SECONDS );
}
return $track_data;
}
/**
* Gets a list of tracks for the supplied RSS feed.
*
* @return array|WP_Error The feed's tracks or a error object.
*/
public function get_track_list() {
$rss = $this->load_feed();
if ( is_wp_error( $rss ) ) {
return $rss;
}
$tracks_quantity = static::get_tracks_quantity();
/**
* Allow requesting a specific number of tracks from SimplePie's `get_items` call.
* The default number of tracks is ten.
* Deprecated. Use jetpack_podcast_helper_tracks_quantity filter instead, which takes one less parameter.
*
* @since 9.5.0
* @deprecated 10.4.0
*
* @param int $tracks_quantity Number of tracks fetched. Default is 10.
* @param object $rss The SimplePie object built from core's `fetch_feed` call.
*/
$tracks_quantity = apply_filters_deprecated( 'jetpack_podcast_helper_list_quantity', array( $tracks_quantity, $rss ), '10.4.0', 'jetpack_podcast_helper_tracks_quantity' );
// Process the requested number of items from our feed.
$track_list = array_map( array( __CLASS__, 'setup_tracks_callback' ), $rss->get_items( 0, $tracks_quantity ) );
// Filter out any tracks that are empty.
// Reset the array indices.
return array_values( array_filter( $track_list ) );
}
/**
* Formats string as pure plaintext, with no HTML tags or entities present.
* This is ready to be used in React, innerText but needs to be escaped
* using standard `esc_html` when generating markup on server.
*
* @param string $str Input string.
* @return string Plain text string.
*/
protected function get_plain_text( $str ) {
return $this->sanitize_and_decode_text( $str, true );
}
/**
* Formats strings as safe HTML.
*
* @param string $str Input string.
* @return string HTML text string safe for post_content.
*/
protected function get_html_text( $str ) {
return $this->sanitize_and_decode_text( $str, false );
}
/**
* Strip unallowed html tags and decode entities.
*
* @param string $str Input string.
* @param boolean $strip_all_tags Strip all tags, otherwise allow post_content safe tags.
* @return string Sanitized and decoded text.
*/
protected function sanitize_and_decode_text( $str, $strip_all_tags = true ) {
// Trim string and return if empty.
$str = trim( (string) $str );
if ( empty( $str ) ) {
return '';
}
if ( $strip_all_tags ) {
// Make sure there are no tags.
$str = wp_strip_all_tags( $str );
} else {
$str = wp_kses_post( $str );
}
// Replace all entities with their characters, including all types of quotes.
$str = html_entity_decode( $str, ENT_QUOTES );
return $str;
}
/**
* Loads an RSS feed using `fetch_feed`.
*
* @param boolean $force_refresh Clear the feed cache.
* @return Jetpack\SimplePie\SimplePie|WP_Error The RSS object or error.
*/
public function load_feed( $force_refresh = false ) {
// Add action: clear the SimplePie Cache if $force_refresh param is true.
if ( true === $force_refresh ) {
add_action( 'wp_feed_options', array( __CLASS__, 'reset_simplepie_cache' ) );
}
// Add action: detect the podcast feed from the provided feed URL.
add_action( 'wp_feed_options', array( __CLASS__, 'set_podcast_locator' ) );
$cache_timeout_filter_added = false;
if ( $this->cache_timeout !== null ) {
// If we have a custom cache timeout, apply the custom timeout value.
add_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_podcast_cache_timeout' ), 20 );
$cache_timeout_filter_added = true;
}
/**
* Allow callers to set up any desired hooks when we fetch the content for a podcast.
* The `jetpack_podcast_post_fetch` action can be used to perform cleanup.
*
* @param string $podcast_url URL for the podcast's RSS feed.
*
* @since 11.2
*/
do_action( 'jetpack_podcast_pre_fetch', $this->feed );
// Fetch the feed.
$rss = fetch_feed( $this->feed );
// Remove added actions from wp_feed_options hook.
remove_action( 'wp_feed_options', array( __CLASS__, 'set_podcast_locator' ) );
if ( true === $force_refresh ) {
remove_action( 'wp_feed_options', array( __CLASS__, 'reset_simplepie_cache' ) );
}
if ( $cache_timeout_filter_added ) {
// Remove the cache timeout filter we added.
remove_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_podcast_cache_timeout' ), 20 );
}
/**
* Allow callers to identify when we have completed fetching a specified podcast feed.
* This makes it possible to clean up any actions or filters that were set up using the
* `jetpack_podcast_pre_fetch` action.
*
* Note that this action runs after other hooks added by Jetpack have been removed.
*
* @param string $podcast_url URL for the podcast's RSS feed.
* @param SimplePie\SimplePie|SimplePie|WP_Error $rss Either the SimplePie RSS object or an error.
*
* @since 11.2
*/
do_action( 'jetpack_podcast_post_fetch', $this->feed, $rss );
if ( is_wp_error( $rss ) ) {
return new WP_Error( 'invalid_url', __( 'Your podcast couldn\'t be embedded. Please double check your URL.', 'jetpack' ) );
}
if ( ! $rss->get_item_quantity() ) {
return new WP_Error( 'no_tracks', __( 'Podcast audio RSS feed has no tracks.', 'jetpack' ) );
}
return $rss;
}
/**
* Filter to override the default number of seconds to cache RSS feed data for the current feed.
* Note that we don't use the feed's URL because some of the SimplePie feed caches trigger this
* filter with a feed identifier and not a URL.
*
* @param int $cache_timeout_in_seconds Number of seconds to cache the podcast feed.
*
* @return int The number of seconds to cache the podcast feed.
*/
public function filter_podcast_cache_timeout( $cache_timeout_in_seconds ) {
if ( $this->cache_timeout !== null ) {
return $this->cache_timeout;
}
return $cache_timeout_in_seconds;
}
/**
* Action handler to set our podcast specific feed locator class on the SimplePie object.
*
* @param Jetpack\SimplePie\SimplePie $feed The SimplePie object, passed by reference.
*/
public static function set_podcast_locator( &$feed ) {
if ( ! class_exists( 'Jetpack_Podcast_Feed_Locator' ) ) {
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-podcast-feed-locator.php';
}
// @todo Once we drop support for WordPress 6.6, drop the class_exists check.
// @phan-suppress-next-line PhanUndeclaredClassReference -- Being tested for. @phan-suppress-current-line UnusedPluginSuppression
$feed->get_registry()->register( class_exists( SimplePie\Locator::class ) ? SimplePie\Locator::class : 'Locator', 'Jetpack_Podcast_Feed_Locator' );
}
/**
* Action handler to reset the SimplePie cache for the podcast feed.
*
* Note this only resets the cache for the specified url. If the feed locator finds the podcast feed
* within the markup of the that url, that feed itself may still be cached.
*
* @param Jetpack\SimplePie\SimplePie $feed The SimplePie object, passed by reference.
* @return void
*/
public static function reset_simplepie_cache( &$feed ) {
// Retrieve the cache object for a feed url. Based on:
// https://github.com/WordPress/WordPress/blob/fd1c2cb4011845ceb7244a062b09b2506082b1c9/wp-includes/class-simplepie.php#L1412.
// @todo This method of getting the cache is deprecated, and there doesn't seem to be a real replacement. `$feed->get_cache()` is private.
// @phan-suppress-next-line PhanUndeclaredClassReference
$cache = $feed->registry->call( 'Cache', 'get_handler', array( $feed->cache_location, call_user_func( $feed->cache_name_function, $feed->feed_url ), 'spc' ) );
if ( method_exists( $cache, 'unlink' ) ) {
$cache->unlink();
}
}
/**
* Prepares Episode data to be used by the Podcast Player block.
*
* @param Jetpack\SimplePie\Item $episode SimplePie Item object, representing a podcast episode.
* @return array
*/
protected function setup_tracks_callback( Jetpack\SimplePie\Item $episode ) {
$enclosure = $this->get_audio_enclosure( $episode );
// If the audio enclosure is empty then it is not playable.
// We therefore return an empty array for this track.
// It will be filtered out later.
if ( is_wp_error( $enclosure ) ) {
return array();
}
// If there is no link return an empty array. We will filter out later.
if ( empty( $enclosure->link ) ) {
return array();
}
$publish_date = $episode->get_gmdate( DATE_ATOM );
// Build track data.
$track = array(
'id' => wp_unique_id( 'podcast-track-' ),
'link' => esc_url( $episode->get_link() ),
'src' => esc_url( $enclosure->link ),
'type' => esc_attr( $enclosure->type ),
'description' => $this->get_plain_text( $episode->get_description() ),
'description_html' => $this->get_html_text( $episode->get_description() ),
'title' => $this->get_plain_text( $episode->get_title() ),
'image' => esc_url( $this->get_episode_image_url( $episode ) ),
'guid' => $this->get_plain_text( $episode->get_id() ),
'publish_date' => $publish_date ? $publish_date : null,
);
if ( empty( $track['title'] ) ) {
$track['title'] = esc_html__( '(no title)', 'jetpack' );
}
if ( ! empty( $enclosure->duration ) ) {
$track['duration'] = esc_html( $this->format_track_duration( $enclosure->duration ) );
}
return $track;
}
/**
* Retrieves an episode's image URL, if it's available.
*
* @param Jetpack\SimplePie\Item $episode SimplePie Item object, representing a podcast episode.
* @param string $itunes_ns The itunes namespace, defaulted to the standard 1.0 version.
* @return string|null The image URL or null if not found.
*/
protected function get_episode_image_url( Jetpack\SimplePie\Item $episode, $itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd' ) {
$image = $episode->get_item_tags( $itunes_ns, 'image' );
if ( isset( $image[0]['attribs']['']['href'] ) ) {
return $image[0]['attribs']['']['href'];
}
return null;
}
/**
* Retrieves an audio enclosure.
*
* @param Jetpack\SimplePie\Item $episode SimplePie Item object, representing a podcast episode.
* @return Jetpack\SimplePie\Enclosure|null
*/
protected function get_audio_enclosure( Jetpack\SimplePie\Item $episode ) {
foreach ( (array) $episode->get_enclosures() as $enclosure ) {
if ( str_starts_with( $enclosure->type, 'audio/' ) ) {
return $enclosure;
}
}
return new WP_Error( 'invalid_audio', __( 'Podcast audio is an invalid type.', 'jetpack' ) );
}
/**
* Returns the track duration as a formatted string.
*
* @param int|float $duration of the track in seconds.
* @return string
*/
protected function format_track_duration( $duration ) {
$format = $duration > HOUR_IN_SECONDS ? 'H:i:s' : 'i:s';
return date_i18n( $format, $duration );
}
/**
* Gets podcast player data schema.
*
* Useful for json schema in REST API endpoints.
*
* @return array Player data json schema.
*/
public static function get_player_data_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'jetpack-podcast-player-data',
'type' => 'object',
'properties' => array(
'title' => array(
'description' => __( 'The title of the podcast.', 'jetpack' ),
'type' => 'string',
),
'link' => array(
'description' => __( 'The URL of the podcast website.', 'jetpack' ),
'type' => 'string',
'format' => 'uri',
),
'cover' => array(
'description' => __( 'The URL of the podcast cover image.', 'jetpack' ),
'type' => 'string',
'format' => 'uri',
),
'tracks' => self::get_tracks_schema(),
'options' => self::get_options_schema(),
),
);
}
/**
* Gets tracks data schema.
*
* Useful for json schema in REST API endpoints.
*
* @return array Tracks json schema.
*/
public static function get_tracks_schema() {
return array(
'description' => __( 'Latest episodes of the podcast.', 'jetpack' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'The episode id. Generated per request, not globally unique.', 'jetpack' ),
'type' => 'string',
),
'link' => array(
'description' => __( 'The external link for the episode.', 'jetpack' ),
'type' => 'string',
'format' => 'uri',
),
'src' => array(
'description' => __( 'The audio file URL of the episode.', 'jetpack' ),
'type' => 'string',
'format' => 'uri',
),
'type' => array(
'description' => __( 'The mime type of the episode.', 'jetpack' ),
'type' => 'string',
),
'description' => array(
'description' => __( 'The episode description, in plaintext.', 'jetpack' ),
'type' => 'string',
),
'description_html' => array(
'description' => __( 'The episode description with allowed html tags.', 'jetpack' ),
'type' => 'string',
),
'title' => array(
'description' => __( 'The episode title.', 'jetpack' ),
'type' => 'string',
),
'publish_date' => array(
'description' => __( 'The UTC publish date and time of the episode', 'jetpack' ),
'type' => 'string',
'format' => 'date-time',
),
),
),
);
}
/**
* Gets the episode options schema.
*
* Useful for json schema in REST API endpoints.
*
* @return array Tracks json schema.
*/
public static function get_options_schema() {
return array(
'description' => __( 'The options that will be displayed in the episode selection UI', 'jetpack' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'label' => array(
'description' => __( 'The display label of the option, the episode title.', 'jetpack' ),
'type' => 'string',
),
'value' => array(
'description' => __( 'The value used for that option, the episode GUID', 'jetpack' ),
'type' => 'string',
),
),
),
);
}
}
@@ -0,0 +1,450 @@
<?php
/**
* Utilities related to the Jetpack Recommendations
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
use Automattic\Jetpack\Plugins_Installer;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Host;
/**
* Contains utilities related to the Jetpack Recommendations.
*
* @package automattic/jetpack
*/
/**
* Jetpack_Recommendations class
*/
class Jetpack_Recommendations {
const PUBLICIZE_RECOMMENDATION = 'publicize';
const PROTECT_RECOMMENDATION = 'protect';
const ANTI_SPAM_RECOMMENDATION = 'anti-spam';
const VIDEOPRESS_RECOMMENDATION = 'videopress';
const BACKUP_PLAN_RECOMMENDATION = 'backup-plan';
const BOOST_RECOMMENDATION = 'boost';
const CONDITIONAL_RECOMMENDATIONS_OPTION = 'recommendations_conditional';
const CONDITIONAL_RECOMMENDATIONS = array(
self::PUBLICIZE_RECOMMENDATION,
self::PROTECT_RECOMMENDATION,
self::ANTI_SPAM_RECOMMENDATION,
self::VIDEOPRESS_RECOMMENDATION,
self::BACKUP_PLAN_RECOMMENDATION,
self::BOOST_RECOMMENDATION,
);
const VIDEOPRESS_TIMED_ACTION = 'jetpack_recommend_videopress';
/**
* Returns a boolean indicating if the Jetpack Recommendations are enabled.
*
* @since 9.3.0
*
* @return bool
*/
public static function is_enabled() {
// Shortcircuit early if Jetpack is not active or we are in offline mode.
if ( ! Jetpack::is_connection_ready() || ( new Status() )->is_offline_mode() ) {
return false;
}
// No recommendations for Atomic sites, they already get onboarded in Calypso.
if ( ( new Host() )->is_woa_site() ) {
return false;
}
self::initialize_jetpack_recommendations();
return true;
}
/**
* Returns a boolean indicating if the Jetpack Banner is enabled.
*
* @since 9.3.0
*
* @deprecated 13.2
*
* @return bool
*/
public static function is_banner_enabled() {
_deprecated_function( __METHOD__, 'jetpack-13.2' );
return false;
}
/**
* Set up actions to monitor for things that trigger a recommendation.
*
* @return false|void
*/
public static function init_conditional_recommendation_actions() {
// Check to make sure that recommendations are enabled.
if ( ! self::is_enabled() ) {
return false;
}
// Monitor for the publishing of a new post.
add_action( 'transition_post_status', array( static::class, 'post_transition' ), 10, 3 );
add_action( 'jetpack_activate_module', array( static::class, 'jetpack_module_activated' ), 10, 2 );
// Monitor for activating a new plugin.
add_action( 'activated_plugin', array( static::class, 'plugin_activated' ), 10 );
// Monitor for the addition of a new comment.
add_action( 'comment_post', array( static::class, 'comment_added' ), 10, 3 );
// Monitor for Jetpack connection success.
add_action( 'jetpack_authorize_ending_authorized', array( static::class, 'jetpack_connected' ) );
add_action( self::VIDEOPRESS_TIMED_ACTION, array( static::class, 'recommend_videopress' ) );
// Monitor for changes in plugins that have auto-updates enabled
add_action( 'update_site_option_auto_update_plugins', array( static::class, 'plugin_auto_update_settings_changed' ), 10, 3 );
}
/**
* Check when Jetpack modules are activated if some recommendations should be skipped.
*
* @param string $module Name of the module activated.
* @param bool $success Whether the module activation was successful.
*/
public static function jetpack_module_activated( $module, $success ) {
if ( 'publicize' === $module && $success ) {
self::disable_conditional_recommendation( self::PUBLICIZE_RECOMMENDATION );
} elseif ( 'videopress' === $module && $success ) {
// If VideoPress is enabled and a recommendation for it is scheduled, cancel that recommendation.
$recommendation_timestamp = wp_next_scheduled( self::VIDEOPRESS_TIMED_ACTION );
if ( false !== $recommendation_timestamp ) {
wp_unschedule_event( $recommendation_timestamp, self::VIDEOPRESS_TIMED_ACTION );
}
}
}
/**
* Hook for transition_post_status that checks for the publishing of a new post or page.
* Used to enable the publicize and boost recommendations.
*
* @param string $new_status new status of post.
* @param string $old_status old status of post.
* @param WP_Post $post the post object being updated.
*/
public static function post_transition( $new_status, $old_status, $post ) {
// Check for condition when post has been published.
if ( 'post' === $post->post_type && 'publish' === $new_status && 'publish' !== $old_status && ! Jetpack::is_module_active( 'publicize' ) ) {
// Set the publicize recommendation to have met criteria to be shown.
self::enable_conditional_recommendation( self::PUBLICIZE_RECOMMENDATION );
return;
}
// A new page has been published
// Check to see if the boost plugin is active
if (
'page' === $post->post_type &&
'publish' === $new_status &&
'publish' !== $old_status &&
! Plugins_Installer::is_plugin_active( 'boost/jetpack-boost.php' ) &&
! Plugins_Installer::is_plugin_active( 'jetpack-boost/jetpack-boost.php' )
) {
self::enable_conditional_recommendation( self::BOOST_RECOMMENDATION );
}
}
/**
* Runs when a plugin gets activated
*
* @param string $plugin Path to the plugins file relative to the plugins directory.
*/
public static function plugin_activated( $plugin ) {
// If the plugin is in this list, don't enable the recommendation.
$plugin_whitelist = array(
'jetpack.php',
'akismet.php',
'creative-mail.php',
'jetpack-backup.php',
'jetpack-boost.php',
'jetpack-protect.php',
'crowdsignal.php',
'vaultpress.php',
'woocommerce.php',
);
$path_parts = explode( '/', $plugin );
$plugin_file = $path_parts ? array_pop( $path_parts ) : $plugin;
if ( ! in_array( $plugin_file, $plugin_whitelist, true ) ) {
$products = array_column( Jetpack_Plan::get_products(), 'product_slug' );
// Check for a plan or product that enables scan.
$plan_supports_scan = Jetpack_Plan::supports( 'scan' );
$has_scan_product = count( array_intersect( array( 'jetpack_scan', 'jetpack_scan_monthly' ), $products ) ) > 0;
$has_scan = $plan_supports_scan || $has_scan_product;
// Check if Jetpack Protect plugin is already active.
$has_protect = Plugins_Installer::is_plugin_active( 'jetpack-protect/jetpack-protect.php' ) || Plugins_Installer::is_plugin_active( 'protect/jetpack-protect.php' );
if ( ! $has_scan && ! $has_protect ) {
self::enable_conditional_recommendation( self::PROTECT_RECOMMENDATION );
}
}
}
/**
* Runs when the auto_update_plugins option has been changed
*
* @param string $option_name - the name of the option updated ( always auto_update_plugins ).
* @param array $new_auto_update_plugins - plugins that have auto update enabled following the change.
* @param array $old_auto_update_plugins - plugins that had auto update enabled before the most recent change.
* @return void
*/
public static function plugin_auto_update_settings_changed( $option_name, $new_auto_update_plugins, $old_auto_update_plugins ) {
if (
is_multisite() ||
self::is_conditional_recommendation_enabled( self::BACKUP_PLAN_RECOMMENDATION )
) {
return;
}
// Look for plugins that have had auto-update enabled in this most recent update.
$enabled_auto_updates = array_diff( $new_auto_update_plugins, $old_auto_update_plugins );
if ( ! empty( $enabled_auto_updates ) ) {
// Check the backup state.
$rewind_state = get_transient( 'jetpack_rewind_state' );
$has_backup = $rewind_state && in_array( $rewind_state->state, array( 'awaiting_credentials', 'provisioning', 'active' ), true );
if ( ! $has_backup ) {
self::enable_conditional_recommendation( self::BACKUP_PLAN_RECOMMENDATION );
}
}
}
/**
* Runs when a new comment is added.
*
* @param integer $comment_id The ID of the comment that was added.
* @param bool $comment_approved Whether or not the comment is approved.
* @param array $commentdata Comment data.
*/
public static function comment_added( $comment_id, $comment_approved, $commentdata ) {
if ( self::is_conditional_recommendation_enabled( self::ANTI_SPAM_RECOMMENDATION ) ) {
return;
}
if ( Plugins_Installer::is_plugin_active( 'akismet/akismet.php' ) ) {
return;
}
// The site has anti-spam features already.
$site_products = array_column( Jetpack_Plan::get_products(), 'product_slug' );
$has_anti_spam_product = count( array_intersect( array( 'jetpack_anti_spam', 'jetpack_anti_spam_monthly' ), $site_products ) ) > 0;
if ( Jetpack_Plan::supports( 'akismet' ) || Jetpack_Plan::supports( 'antispam' ) || $has_anti_spam_product ) {
return;
}
if ( isset( $commentdata['comment_post_ID'] ) ) {
$post_id = $commentdata['comment_post_ID'];
} else {
$comment = get_comment( $comment_id );
$post_id = $comment->comment_post_ID;
}
$comment_count = get_comments_number( $post_id );
if ( intval( $comment_count ) >= 5 ) {
self::enable_conditional_recommendation( self::ANTI_SPAM_RECOMMENDATION );
}
}
/**
* Runs after a successful connection is made.
*/
public static function jetpack_connected() {
// Schedule a recommendation for VideoPress in 2 weeks.
if ( false === wp_next_scheduled( self::VIDEOPRESS_TIMED_ACTION ) ) {
$date = new DateTime();
$date->add( new DateInterval( 'P14D' ) );
wp_schedule_single_event( $date->getTimestamp(), self::VIDEOPRESS_TIMED_ACTION );
}
}
/**
* Enable a recommendation for VideoPress.
*/
public static function recommend_videopress() {
// Check to see if the VideoPress recommendation is already enabled.
if ( self::is_conditional_recommendation_enabled( self::VIDEOPRESS_RECOMMENDATION ) ) {
return;
}
$site_plan = Jetpack_Plan::get();
$site_products = array_column( Jetpack_Plan::get_products(), 'product_slug' );
if ( self::should_recommend_videopress( $site_plan, $site_products ) ) {
self::enable_conditional_recommendation( self::VIDEOPRESS_RECOMMENDATION );
}
}
/**
* Should we provide a recommendation for videopress?
* This method exists to facilitate unit testing
*
* @param array $site_plan A representation of the site's plan.
* @param array $site_products An array of product slugs.
* @return boolean
*/
public static function should_recommend_videopress( $site_plan, $site_products ) {
// Does the site have the VideoPress module enabled?
if ( Jetpack::is_module_active( 'videopress' ) ) {
return false;
}
// Does the site plan have upgraded videopress features?
// For now, this just checks to see if the site has a free plan.
// Jetpack_Plan::supports('videopress') returns true for all plans, since there is a free tier.
$is_free_plan = 'free' === $site_plan['class'];
if ( ! $is_free_plan ) {
return false;
}
// Does this site already have a VideoPress product?
$has_videopress_product = count( array_intersect( array( 'jetpack_videopress', 'jetpack_videopress_monthly' ), $site_products ) ) > 0;
if ( $has_videopress_product ) {
return false;
}
return true;
}
/**
* Enable a recommendation.
*
* @param string $recommendation_name The name of the recommendation to enable.
* @return false|void
*/
public static function enable_conditional_recommendation( $recommendation_name ) {
if ( ! in_array( $recommendation_name, self::CONDITIONAL_RECOMMENDATIONS, true ) ) {
return false;
}
$conditional_recommendations = Jetpack_Options::get_option( self::CONDITIONAL_RECOMMENDATIONS_OPTION, array() );
if ( ! in_array( $recommendation_name, $conditional_recommendations, true ) ) {
$conditional_recommendations[] = $recommendation_name;
Jetpack_Options::update_option( self::CONDITIONAL_RECOMMENDATIONS_OPTION, $conditional_recommendations );
}
}
/**
* Disable a recommendation.
*
* @param string $recommendation_name The name of the recommendation to disable.
* @return false|void
*/
public static function disable_conditional_recommendation( $recommendation_name ) {
if ( ! in_array( $recommendation_name, self::CONDITIONAL_RECOMMENDATIONS, true ) ) {
return false;
}
$conditional_recommendations = Jetpack_Options::get_option( self::CONDITIONAL_RECOMMENDATIONS_OPTION, array() );
$recommendation_index = array_search( $recommendation_name, $conditional_recommendations, true );
if ( false !== $recommendation_index ) {
array_splice( $conditional_recommendations, $recommendation_index, 1 );
Jetpack_Options::update_option( self::CONDITIONAL_RECOMMENDATIONS_OPTION, $conditional_recommendations );
}
}
/**
* Check to see if a recommendation is enabled or not.
*
* @param string $recommendation_name The name of the recommendation to check for.
* @return bool
*/
public static function is_conditional_recommendation_enabled( $recommendation_name ) {
$conditional_recommendations = Jetpack_Options::get_option( self::CONDITIONAL_RECOMMENDATIONS_OPTION, array() );
return in_array( $recommendation_name, $conditional_recommendations, true );
}
/**
* Gets data for all conditional recommendations.
*
* @return mixed
*/
public static function get_conditional_recommendations() {
return Jetpack_Options::get_option( self::CONDITIONAL_RECOMMENDATIONS_OPTION, array() );
}
/**
* Get an array of new conditional recommendations that have not been viewed.
*
* @return array
*/
public static function get_new_conditional_recommendations() {
$conditional_recommendations = self::get_conditional_recommendations();
$recommendations_data = Jetpack_Options::get_option( 'recommendations_data', array() );
$viewed_recommendations = isset( $recommendations_data['viewedRecommendations'] ) ? $recommendations_data['viewedRecommendations'] : array();
// array_diff returns a keyed array - reduce to unique values.
return array_unique( array_values( array_diff( $conditional_recommendations, $viewed_recommendations ) ) );
}
/**
* Initializes the Recommendations step according to the Setup Wizard state.
*/
private static function initialize_jetpack_recommendations() {
if ( Jetpack_Options::get_option( 'recommendations_step' ) ) {
return;
}
$setup_wizard_status = Jetpack_Options::get_option( 'setup_wizard_status' );
if ( 'completed' === $setup_wizard_status ) {
Jetpack_Options::update_option( 'recommendations_step', 'setup-wizard-completed' );
}
}
/**
* Get the data for the recommendations
*
* @return array Recommendations data
*/
public static function get_recommendations_data() {
self::initialize_jetpack_recommendations();
return Jetpack_Options::get_option( 'recommendations_data', array() );
}
/**
* Update the data for the recommendations
*
* @param WP_REST_Request $data The data.
*/
public static function update_recommendations_data( $data ) {
if ( ! empty( $data ) ) {
Jetpack_Options::update_option( 'recommendations_data', $data );
}
}
/**
* Get the data for the recommendations
*
* @return array Recommendations data
*/
public static function get_recommendations_step() {
self::initialize_jetpack_recommendations();
return array(
'step' => Jetpack_Options::get_option( 'recommendations_step', 'not-started' ),
);
}
/**
* Update the step for the recommendations
*
* @param WP_REST_Request $step The step.
*/
public static function update_recommendations_step( $step ) {
if ( ! empty( $step ) ) {
Jetpack_Options::update_option( 'recommendations_step', $step );
}
}
}
@@ -0,0 +1,130 @@
<?php
/**
* Top Posts & Pages block helper.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Stats\WPCOM_Stats;
/**
* Class Jetpack_Top_Posts_Helper
*/
class Jetpack_Top_Posts_Helper {
/**
* Returns user's top posts.
*
* @param int $period Period of days to draw stats from.
* @param int $items_count Optional. Number of items to display.
* @param string $types Optional. Content types to include.
* @return array
*/
public static function get_top_posts( $period, $items_count = null, $types = null ) {
$all_time_days = floor( ( time() - strtotime( get_option( 'site_created_date' ) ) ) / ( 60 * 60 * 24 * 365 ) );
// While we only display ten posts, users can filter out content types.
// As such, we should obtain a few spare posts from the Stats endpoint.
$posts_to_obtain_count = 30;
// We should not override cache when displaying the block on the frontend.
// But we should allow instant preview of changes when editing the block.
$is_rendering_block = ! empty( $types );
$override_cache = ! $is_rendering_block;
$query_args = array(
'max' => $posts_to_obtain_count,
'summarize' => true,
'num' => $period !== 'all-time' ? $period : $all_time_days,
'period' => 'day',
);
$data = ( new WPCOM_Stats() )->get_top_posts( $query_args, $override_cache );
if ( is_wp_error( $data ) ) {
$data = array( 'summary' => array( 'postviews' => array() ) );
}
// Remove posts that have subsequently been deleted.
$data['summary']['postviews'] = array_filter(
$data['summary']['postviews'],
function ( $item ) {
return get_post_status( $item['id'] ) === 'publish';
}
);
$posts_retrieved = is_countable( $data['summary']['postviews'] ) ? count( $data['summary']['postviews'] ) : 0;
// Fallback to random posts if user does not have enough top content.
if ( $posts_retrieved < $posts_to_obtain_count ) {
$args = array(
'numberposts' => $posts_to_obtain_count - $posts_retrieved,
'exclude' => array_column( $data['summary']['postviews'], 'id' ),
'orderby' => 'rand',
'post_status' => 'publish',
);
$random_posts = get_posts( $args );
foreach ( $random_posts as $post ) {
$random_posts_data = array(
'id' => $post->ID,
'href' => get_permalink( $post->ID ),
'date' => $post->post_date,
'title' => $post->post_title,
'type' => 'post',
'public' => true,
);
$data['summary']['postviews'][] = $random_posts_data;
}
$data['summary']['postviews'] = array_slice( $data['summary']['postviews'], 0, 10 );
}
$top_posts = array();
foreach ( $data['summary']['postviews'] as $post ) {
$post_id = $post['id'];
$thumbnail = get_the_post_thumbnail_url( $post_id );
if ( ! $thumbnail ) {
$post_images = get_attached_media( 'image', $post_id );
$post_image = reset( $post_images );
if ( $post_image ) {
$thumbnail = wp_get_attachment_url( $post_image->ID );
}
}
if ( $post['public'] ) {
$top_posts[] = array(
'id' => $post_id,
'author' => get_the_author_meta( 'display_name', get_post_field( 'post_author', $post_id ) ),
'context' => get_the_category( $post_id ) ? get_the_category( $post_id ) : get_the_tags( $post_id ),
'href' => $post['href'],
'date' => get_the_date( '', $post_id ),
'title' => $post['title'],
'type' => $post['type'],
'public' => $post['public'],
'views' => isset( $post['views'] ) ? $post['views'] : 0,
'thumbnail' => $thumbnail,
);
}
}
// This applies for rendering the block front-end, but not for editing it.
if ( $is_rendering_block ) {
$acceptable_types = explode( ',', $types );
$top_posts = array_filter(
$top_posts,
function ( $item ) use ( $acceptable_types ) {
return in_array( $item['type'], $acceptable_types, true );
}
);
$top_posts = array_slice( $top_posts, 0, $items_count );
}
return $top_posts;
}
}
@@ -0,0 +1,904 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* 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 https://www.opensource.org/licenses/MIT
*
* @package automattic/jetpack
*/
// phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
if ( ! class_exists( 'Jetpack_Color' ) ) {
/**
* Color utilities
*/
class Jetpack_Color {
/**
* Color code (later array or string, depending on type)
*
* @var int|array|string
*/
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' ) {
_deprecated_function( 'Jetpack_Color::__construct', 'jetpack-13.8' );
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 $hex_value Color hex value.
*
* @return $this
* @throws RangeException Invalid color code range error.
*/
public function fromHex( $hex_value ) {
$hex_value = str_replace( '#', '', $hex_value );
// handle short hex codes like #fff.
if ( 3 === strlen( $hex_value ) ) {
$hex_value = $hex_value[0] . $hex_value[0] . $hex_value[1] . $hex_value[1] . $hex_value[2] . $hex_value[2];
}
return $this->fromInt( hexdec( $hex_value ) );
}
/**
* Init color from integer RGB values
*
* @param int $red Red color code.
* @param int $green Green color code.
* @param int $blue Blue color code.
*
* @return $this
* @throws RangeException Invalid color code range error.
*/
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 = ( intval( $red ) << 16 ) + ( intval( $green ) << 8 ) + intval( $blue );
return $this;
}
/**
* Init color from hex RGB values
*
* @param string $red Red color code.
* @param string $green Green color code.
* @param string $blue Blue color code.
*
* @return $this
*/
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 https://en.wikipedia.org/wiki/HSL_color_space.
*
* @param int $h Hue. [0-360].
* @param int $s Saturation [0, 100].
* @param int $l Lightness [0, 100].
*/
public function fromHsl( $h, $s, $l ) {
$h /= 360;
$s /= 100;
$l /= 100;
if ( 0 === $s ) {
// achromatic.
$r = $l;
$g = $l;
$b = $l;
} 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()
*
* @param float $p Minimum of R/G/B [0, 1].
* @param float $q Maximum of R/G/B [0, 1].
* @param float $t Adjusted hue [0, 1].
*/
private function hue2rgb( $p, $q, $t ) {
if ( $t < 0 ) {
++$t;
}
if ( $t > 1 ) {
--$t;
}
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 $int_value Color code.
*
* @return $this
* @throws RangeException Invalid color code range error.
*/
public function fromInt( $int_value ) {
if ( $int_value < 0 || $int_value > 16777215 ) {
throw new RangeException( $int_value . ' out of valid color code range' );
}
$this->color = $int_value;
return $this;
}
/**
* Convert color to hex
*
* @return string
*/
public function toHex() {
return sprintf( '%06x', $this->color );
}
/**
* 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();
$rgb_min = min( $rgb );
$rgb_max = max( $rgb );
$hsv = array(
'hue' => 0,
'sat' => 0,
'val' => $rgb_max,
);
// If v is 0, color is black.
if ( 0 === $hsv['val'] ) {
return $hsv;
}
// Normalize RGB values to 1.
$rgb['red'] /= $hsv['val'];
$rgb['green'] /= $hsv['val'];
$rgb['blue'] /= $hsv['val'];
$rgb_min = min( $rgb );
$rgb_max = max( $rgb );
// Calculate saturation.
$hsv['sat'] = $rgb_max - $rgb_min;
if ( 0 === $hsv['sat'] ) {
$hsv['hue'] = 0;
return $hsv;
}
// Normalize saturation to 1.
$rgb['red'] = ( $rgb['red'] - $rgb_min ) / ( $rgb_max - $rgb_min );
$rgb['green'] = ( $rgb['green'] - $rgb_min ) / ( $rgb_max - $rgb_min );
$rgb['blue'] = ( $rgb['blue'] - $rgb_min ) / ( $rgb_max - $rgb_min );
$rgb_min = min( $rgb );
$rgb_max = max( $rgb );
// Calculate hue.
if ( $rgb_max === $rgb['red'] ) {
$hsv['hue'] = 0.0 + 60 * ( $rgb['green'] - $rgb['blue'] );
if ( $hsv['hue'] < 0 ) {
$hsv['hue'] += 360;
}
} elseif ( $rgb_max === $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 array
*/
public function toHsvInt() {
$rgb = $this->toRgbInt();
$rgb_min = min( $rgb );
$rgb_max = max( $rgb );
$hsv = array(
'hue' => 0,
'sat' => 0,
'val' => $rgb_max,
);
// If value is 0, color is black.
if ( 0 === $hsv['val'] ) {
return $hsv;
}
// Calculate saturation.
$hsv['sat'] = round( 255 * ( $rgb_max - $rgb_min ) / $hsv['val'] );
if ( 0 === $hsv['sat'] ) {
$hsv['hue'] = 0;
return $hsv;
}
// Calculate hue.
if ( $rgb_max === $rgb['red'] ) {
$hsv['hue'] = round( 0 + 43 * ( $rgb['green'] - $rgb['blue'] ) / ( $rgb_max - $rgb_min ) );
} elseif ( $rgb_max === $rgb['green'] ) {
$hsv['hue'] = round( 85 + 43 * ( $rgb['blue'] - $rgb['red'] ) / ( $rgb_max - $rgb_min ) );
} else {
$hsv['hue'] = round( 171 + 43 * ( $rgb['red'] - $rgb['green'] ) / ( $rgb_max - $rgb_min ) );
}
if ( $hsv['hue'] < 0 ) {
$hsv['hue'] += 255;
}
return $hsv;
}
/**
* Converts an RGB color value to HSL. Conversion formula
* adapted from https://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 );
$l = ( $max + $min ) / 2;
if ( $max === $min ) {
// achromatic.
$s = 0;
$h = 0;
} 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' );
}
/**
* From a color code to a string to be used in CSS declaration.
*
* @param string $type Color code type.
* @param int $alpha Transparency.
*
* @return string
*/
public function toCSS( $type = 'hex', $alpha = 1 ) {
switch ( $type ) {
case 'hex':
return $this->toString();
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} )";
}
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} )";
}
default:
return $this->toString();
}
}
/**
* 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 Color code.
*
* @return int
*/
public function getDistanceRgbFrom( Jetpack_Color $color ) {
$rgb1 = $this->toRgbInt();
$rgb2 = $color->toRgbInt();
$r_diff = abs( $rgb1['red'] - $rgb2['red'] );
$g_diff = abs( $rgb1['green'] - $rgb2['green'] );
$b_diff = abs( $rgb1['blue'] - $rgb2['blue'] );
// Sum of RGB differences.
$diff = $r_diff + $g_diff + $b_diff;
return $diff;
}
/**
* Get distance from the given color using the Delta E method
*
* @param Jetpack_Color $color Color code.
*
* @return float
*/
public function getDistanceLabFrom( Jetpack_Color $color ) {
$lab1 = $this->toLabCie();
$lab2 = $color->toLabCie();
$l_diff = abs( $lab2['l'] - $lab1['l'] );
$a_diff = abs( $lab2['a'] - $lab1['a'] );
$b_diff = abs( $lab2['b'] - $lab1['b'] );
$delta = sqrt( $l_diff + $a_diff + $b_diff );
return $delta;
}
/**
* Calculate luminosity.
*
* @return float
*/
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 );
}
}
/**
* Get maximum contrast color.
*
* @return $this
*/
public function getMaxContrastColor() {
$with_black = $this->getDistanceLuminosityFrom( new Jetpack_Color( '#000' ) );
$with_white = $this->getDistanceLuminosityFrom( new Jetpack_Color( '#fff' ) );
$color = new Jetpack_Color();
$hex = ( $with_black >= $with_white ) ? '#000000' : '#ffffff';
return $color->fromHex( $hex );
}
/**
* Get grayscale contrasting color.
*
* @param bool|int $contrast Contrast.
*
* @return $this
*/
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 ( 0 === $this->color || 16777215 === $this->color ) {
break;
}
}
return $this;
}
/**
* Detect if color is grayscale
*
* @param int $threshold Max difference between colors.
*
* @return bool
*/
public function isGrayscale( $threshold = 16 ) {
$rgb = $this->toRgbInt();
// Get min and max rgb values, then difference between them.
$rgb_min = min( $rgb );
$rgb_max = max( $rgb );
$diff = $rgb_max - $rgb_min;
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 ) {
$match_dist = 10000;
$match_key = null;
foreach ( $colors as $key => $color ) {
if ( false === ( $color instanceof Jetpack_Color ) ) {
$c = new Jetpack_Color( $color );
}
$dist = $this->getDistanceLabFrom( $c );
if ( $dist < $match_dist ) {
$match_dist = $dist;
$match_key = $key;
}
}
return $match_key;
}
/* TRANSFORMS */
/**
* Transform -- Darken color.
*
* @param int $amount Amount. Default to 5.
*
* @return $this
*/
public function darken( $amount = 5 ) {
return $this->incrementLightness( - $amount );
}
/**
* Transform -- Lighten color.
*
* @param int $amount Amount. Default to 5.
*
* @return $this
*/
public function lighten( $amount = 5 ) {
return $this->incrementLightness( $amount );
}
/**
* Transform -- Increment lightness.
*
* @param int $amount Amount.
*
* @return $this
*/
public function incrementLightness( $amount ) {
$hsl = $this->toHsl();
$h = isset( $hsl['h'] ) ? $hsl['h'] : 0;
$s = isset( $hsl['s'] ) ? $hsl['s'] : 0;
$l = isset( $hsl['l'] ) ? $hsl['l'] : 0;
$l += $amount;
if ( $l < 0 ) {
$l = 0;
}
if ( $l > 100 ) {
$l = 100;
}
return $this->fromHsl( $h, $s, $l );
}
/**
* Transform -- Saturate color.
*
* @param int $amount Amount. Default to 15.
*
* @return $this
*/
public function saturate( $amount = 15 ) {
return $this->incrementSaturation( $amount );
}
/**
* Transform -- Desaturate color.
*
* @param int $amount Amount. Default to 15.
*
* @return $this
*/
public function desaturate( $amount = 15 ) {
return $this->incrementSaturation( - $amount );
}
/**
* Transform -- Increment saturation.
*
* @param int $amount Amount.
*
* @return $this
*/
public function incrementSaturation( $amount ) {
$hsl = $this->toHsl();
$h = isset( $hsl['h'] ) ? $hsl['h'] : 0;
$s = isset( $hsl['s'] ) ? $hsl['s'] : 0;
$l = isset( $hsl['l'] ) ? $hsl['l'] : 0;
$s += $amount;
if ( $s < 0 ) {
$s = 0;
}
if ( $s > 100 ) {
$s = 100;
}
return $this->fromHsl( $h, $s, $l );
}
/**
* Transform -- To grayscale.
*
* @return $this
*/
public function toGrayscale() {
$hsl = $this->toHsl();
$h = isset( $hsl['h'] ) ? $hsl['h'] : 0;
$s = 0;
$l = isset( $hsl['l'] ) ? $hsl['l'] : 0;
return $this->fromHsl( $h, $s, $l );
}
/**
* Transform -- To the complementary color.
*
* The complement is the color on the opposite side of the color wheel, 180° away.
*
* @return $this
*/
public function getComplement() {
return $this->incrementHue( 180 );
}
/**
* Transform -- To an analogous color of the complement.
*
* @param int $step Pass `1` or `-1` to choose which direction around the color wheel.
*
* @return $this
*/
public function getSplitComplement( $step = 1 ) {
$incr = 180 + ( $step * 30 );
return $this->incrementHue( $incr );
}
/**
* Transform -- To an analogous color.
*
* Analogous colors are those adjacent on the color wheel, separated by 30°.
*
* @param int $step Pass `1` or `-1` to choose which direction around the color wheel.
*
* @return $this
*/
public function getAnalog( $step = 1 ) {
$incr = $step * 30;
return $this->incrementHue( $incr );
}
/**
* Transform -- To a tetradic (rectangular) color.
*
* A rectangular color scheme uses a color, its complement, and the colors 60° from each.
* This transforms the color to its 60° "tetrad".
*
* @param int $step Pass `1` or `-1` to choose which direction around the color wheel.
*
* @return $this
*/
public function getTetrad( $step = 1 ) {
$incr = $step * 60;
return $this->incrementHue( $incr );
}
/**
* Transform -- To a triadic color.
*
* A triadic color scheme uses three colors evenly spaced (120°) around the color wheel.
* This transforms the color to one of its triadic colors.
*
* @param int $step Pass `1` or `-1` to choose which direction around the color wheel.
*
* @return $this
*/
public function getTriad( $step = 1 ) {
$incr = $step * 120;
return $this->incrementHue( $incr );
}
/**
* Transform -- Increment hue.
*
* @param int $amount Amount.
*
* @return $this
*/
public function incrementHue( $amount ) {
$hsl = $this->toHsl();
$h = isset( $hsl['h'] ) ? $hsl['h'] : 0;
$s = isset( $hsl['s'] ) ? $hsl['s'] : 0;
$l = isset( $hsl['l'] ) ? $hsl['l'] : 0;
$h = ( $h + $amount ) % 360;
if ( $h < 0 ) {
$h += 360;
}
return $this->fromHsl( $h, $s, $l );
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
<?php // phpcs:ignore WordPress.Files.FileName.NotHyphenatedLowercase
/**
* This file has been moved to the jetpack-plugins-installer package
*
* @deprecated 10.7
*
* @package jetpack
*/
class_alias( Automattic\Jetpack\Automatic_Install_Skin::class, 'Jetpack_Automatic_Install_Skin' );
@@ -0,0 +1,104 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Tweak a preview when rendered in an iframe.
* This is used when rendering iFrames in the Calypso app.
*
* This file is shared between WordPress.com and Jetpack.
* The canonical source is Jetpack and no WordPress.com-specific code should exist in this file.
*
* @package automattic/jetpack
*/
/**
* Tweak a preview when rendered in an iframe.
*/
class Jetpack_Iframe_Embed {
/**
* Initialize class.
*/
public 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' ) );
$ver = sprintf( '%s-%s', gmdate( 'oW' ), defined( 'JETPACK__VERSION' ) ? JETPACK__VERSION : '' );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
wp_enqueue_script(
'jetpack-iframe-embed',
'/wp-content/mu-plugins/jetpack-iframe-embed/jetpack-iframe-embed.js',
array( 'jquery' ),
$ver,
false
);
} else {
wp_enqueue_script(
'jetpack-iframe-embed',
'//s0.wp.com/wp-content/mu-plugins/jetpack-iframe-embed/jetpack-iframe-embed.js',
array( 'jquery' ),
$ver,
false
);
}
wp_localize_script( 'jetpack-iframe-embed', '_previewSite', array( 'siteURL' => get_site_url() ) );
}
/**
* Check that we are in an iFrame.
*
* @return bool
*/
private static function is_embedding_in_iframe() {
return (
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- No nonce needed, we're only checking for a specific screen view.
isset( $_GET['iframe'] ) && 'true' === $_GET['iframe']
&& (
isset( $_GET['preview'] ) && 'true' === $_GET['preview']
|| isset( $_GET['theme_preview'] ) && 'true' === $_GET['theme_preview']
)
// phpcs:enable WordPress.Security.NonceVerification.Recommended
);
}
/**
* 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.
*/
public 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`
*/
public 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`
*/
public static function base_target_blank() {
echo '<base target="_blank" />';
}
}
@@ -0,0 +1,316 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Utilities to interact with a Keyring instance.
* Used for Publicize as well as the Site Verification tools.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Secrets;
/**
* A series of utilities to interact with a Keyring instance.
*/
class Jetpack_Keyring_Service_Helper {
/**
* Class instance
*
* @var Jetpack_Keyring_Service_Helper
*/
private static $instance = null;
/**
* Whether the `sharing` page is registered.
*
* @var bool
*/
private static $is_sharing_page_registered = false;
/**
* Initialize instance.
*/
public static function init() {
if ( self::$instance === null ) {
self::$instance = new Jetpack_Keyring_Service_Helper();
}
return self::$instance;
}
const SERVICES = array(
'facebook' => array(
'for' => 'publicize',
),
// @todo Remove when Twitter has been dropped from 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',
),
);
/**
* Constructor
*/
private function __construct() {
add_action( 'admin_menu', array( __CLASS__, 'register_sharing_page' ) );
add_action( 'load-settings_page_sharing', array( __CLASS__, 'admin_page_load' ), 9 );
}
/**
* We need a `sharing` page to be able to connect and disconnect services.
*/
public static function register_sharing_page() {
if ( self::$is_sharing_page_registered ) {
return;
}
self::$is_sharing_page_registered = true;
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
global $_registered_pages;
require_once ABSPATH . 'wp-admin/includes/plugin.php';
$hookname = get_plugin_page_hookname( 'sharing', 'options-general.php' );
add_action( $hookname, array( __CLASS__, 'admin_page_load' ) );
$_registered_pages[ $hookname ] = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
/**
* Return a list of services.
*
* @param string $filter Choose 'all' to get all connected services, vs. just the connected ones.
*/
public 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.
* On WordPress.com this is/calls Keyring::admin_url.
*
* @param string $service Shortname of a specific service.
* @param array $params Parameters to append to an API connection URL.
*
* @return string URL to specific public-api process
*/
private 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;
}
/**
* Build a connection URL (sharing settings page with unique query args to create a connection).
*
* @param string $service_name Service name.
* @param string $for Feature name.
*/
public 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,
),
admin_url( 'options-general.php?page=sharing' )
);
}
/**
* Build a URL to refresh a connection (sharing settings page with unique query args to refresh a connection).
* Similar to connect_url, but with a refresh parameter.
*
* @param string $service_name Service name.
* @param string $for Feature name.
*/
public 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' )
);
}
/**
* Build a URL to delete a connection (sharing settings page with unique query args to delete a connection).
*
* @param string $service_name Service name.
* @param string $id Connection ID.
*/
public 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" ),
),
admin_url( 'options-general.php?page=sharing' )
);
}
/**
* Build contents handling Keyring connection management into Sharing settings screen.
*/
public static function admin_page_load() {
if ( isset( $_GET['action'] ) ) {
if ( isset( $_GET['service'] ) ) {
$service_name = sanitize_text_field( wp_unslash( $_GET['service'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We verify below.
}
switch ( $_GET['action'] ) {
case 'request':
check_admin_referer( 'keyring-request', 'kr_nonce' );
check_admin_referer( "keyring-request-$service_name", 'nonce' );
$verification = ( new Secrets() )->generate( 'publicize' );
if ( ! $verification ) {
$url = Jetpack::admin_url( 'jetpack#/settings' );
wp_die(
sprintf(
wp_kses(
/* Translators: placeholder is a URL to a Settings page. */
__( "Jetpack is not connected. Please connect Jetpack by visiting <a href='%s'>Settings</a>.", 'jetpack' ),
array(
'a' => array(
'href' => array(),
),
)
),
esc_url( $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 = self::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 ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- The API URL is an external URL and is filterable.
exit( 0 );
case 'completed':
/*
* We do not use a nonce here,
* since we're populating a local cache of
* the Publicize connections that were created and stored on WordPress.com.
*/
$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 = isset( $_GET['id'] ) ? sanitize_text_field( wp_unslash( $_GET['id'] ) ) : null;
check_admin_referer( 'keyring-request', 'kr_nonce' );
check_admin_referer( "keyring-request-$service_name", 'nonce' );
self::disconnect( $service_name, $id );
do_action( 'connection_disconnected', $service_name );
break;
}
}
}
/**
* Remove a Publicize connection
*
* @param string $service_name Service name.
* @param string $connection_id Connection ID.
* @param int|bool $_blog_id Blog ID.
* @param int|bool $_user_id User ID.
* @param bool $force_delete Force delete the connection.
*/
public static function disconnect( $service_name, $connection_id, $_blog_id = false, $_user_id = false, $force_delete = false ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$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;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Measure the performance of Jetpack Search queries.
*/
class Jetpack_Search_Performance_Logger {
/**
* Jetpack_Search_Performance_Logger instance.
*
* @var null|Jetpack_Search_Performance_Logger
*/
private static $instance = null;
/**
* WP_Query instance.
*
* @var null|WP_Query
*/
private $current_query = null;
/**
* Time when the query was started.
*
* @var null|float
*/
private $query_started = null;
/**
* Performance results.
*
* @var null|array
*/
private $stats = null;
/**
* Initialize the class.
*/
public static function init() {
if ( self::$instance === null ) {
self::$instance = new Jetpack_Search_Performance_Logger();
}
return self::$instance;
}
/**
* The constructor.
*/
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' ) );
}
/**
* Log the time when the query was started.
*
* @param WP_Query $query The query.
*/
public function begin_log_query( $query ) {
if ( $this->should_log_query( $query ) ) {
$this->query_started = microtime( true );
$this->current_query = $query;
}
}
/**
* Record the time when an SQL query was completed.
*
* @param int $found_posts The number of posts found.
* @param WP_Query $query The WP_Query instance (passed by reference).
*/
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;
}
/**
* Log Jetpack Search query.
*/
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();
}
/**
* Reset data after each log.
*/
private function reset_query_state() {
$this->query_started = null;
$this->current_query = null;
}
/**
* Check if a query should be logged (a main query, or a jetpack search query).
*
* @param WP_Query $query The WP_Query instance.
*/
private function should_log_query( $query ) {
return $query->is_main_query() && $query->is_search();
}
/**
* Record the time of a query.
*
* @param float $duration The duration of the query.
* @param bool $was_jetpack_search Was this a Jetpack Search query.
*/
private function record_query_time( $duration, $was_jetpack_search ) {
$this->stats[] = array( $was_jetpack_search, (int) ( $duration * 1000 ) );
}
/**
* Print performance stats in the footer.
*/
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 = rawurlencode( site_url() );
$url = "https://pixel.wp.com/boom.gif?v=0.9&u={$encoded_site_url}&json={$encoded_json}";
echo '<img src="' . esc_url( $url ) . '" width="1" height="1" style="display:none;" alt=""/>';
}
}
}
@@ -0,0 +1,595 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Class with methods to extract metadata from a post/page about videos, images, links, mentions embedded
* in or attached to the post/page.
*
* @package automattic/jetpack
*/
/**
* 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;
/**
* Shortcodes to keep.
*
* 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.
*
* @var string[]
*/
private static $keeper_shortcodes = array(
'audio',
'youtube',
'vimeo',
'hulu',
'ted',
'video',
'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 int $blog_id The ID of the blog.
* @param int $post_id The ID of the post.
* @param int $what_to_extract A mask of things to extract, e.g. Jetpack_Media_Meta_Extractor::IMAGES | Jetpack_Media_Meta_Extractor::MENTIONS.
* @param boolean $extract_alt_text Should alt_text be extracted, defaults to false.
*
* @return array|WP_Error a structure containing metadata about the embedded things, or empty array if nothing found, or WP_Error on error.
*/
public static function extract( $blog_id, $post_id, $what_to_extract = self::ALL, $extract_alt_text = false ) {
// multisite?
if ( function_exists( 'switch_to_blog' ) ) {
switch_to_blog( $blog_id );
}
$post = get_post( $post_id );
if ( ! $post instanceof WP_Post ) {
if ( function_exists( 'restore_current_blog' ) ) {
restore_current_blog();
}
return array();
}
$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, array(), $extract_alt_text );
// Turn off images so we can safely call extract_from_content() below.
$what_to_extract = $what_to_extract - self::IMAGES;
}
if ( function_exists( 'restore_current_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 string $content The HTML post_content of a post.
* @param int $what_to_extract A mask of things to extract, e.g. Jetpack_Media_Meta_Extractor::IMAGES | Jetpack_Media_Meta_Extractor::MENTIONS.
* @param array $already_extracted Previously extracted things, e.g. images from extract(), which can be used for x-referencing here.
*
* @return array a structure containing metadata about the embedded things, or empty array if nothing found, or WP_Error on error.
*/
public static 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 = self::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, true ) ) {
$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, true ) ) {
// Clear shortcode ID data left from the last shortcode.
$id = null;
// 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 );
} elseif ( method_exists( $shortcode_class_name, $shortcode_get_id_method ) ) {
$id = call_user_func( array( $shortcode_class_name, $shortcode_get_id_method ), $attr );
} elseif ( 'video' === $shortcode ) {
$id = $attr['src'] ?? $attr['url'] ?? $attr['mp4'] ?? $attr['m4v'] ?? $attr['webm'] ?? $attr['ogv'] ?? $attr['wmv'] ?? $attr['flv'] ?? null;
} elseif ( 'audio' === $shortcode ) {
preg_match( '#(https?://(?:[^\s"|\']+)\.(?:mp3|ogg|flac|m4a|wav))([ "\'|]|$)#', implode( ' ', $attr ), $audio_matches );
$id = $audio_matches[1] ?? null;
}
if ( ! empty( $id )
&& ( ! isset( $shortcode_details[ $shortcode_name ] ) || ! in_array( $id, $shortcode_details[ $shortcode_name ], true ) ) ) {
$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 = wp_parse_url( $link_raw );
// Data URI links.
if ( ! isset( $url['scheme'] ) || 'data' === $url['scheme'] ) {
continue;
}
// Reject invalid URLs.
if ( ! isset( $url['host'] ) ) {
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'], true ) ) {
continue;
}
}
list( $proto, $link_all_but_proto ) = explode( '://', $link_raw ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
// 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'];
}
}
$link = array(
'url' => $link_all_but_proto,
'host_reversed' => $host_reversed,
'host' => $url['host'],
);
if ( ! in_array( $link, $links, true ) ) {
$links[] = $link;
}
}
}
$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 = wp_parse_url( $link_raw );
list( $proto, $link_all_but_proto ) = explode( '://', $link_raw ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
// Check whether this "link" is really an embed.
foreach ( $oembed->providers as $matchmask => $data ) {
list( $providerurl, $regex ) = $data; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
// 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 ) ) {
$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;
}
/**
* Get image fields for matching images.
*
* @uses Jetpack_PostImages
*
* @param WP_Post $post A post object.
* @param array $args Optional args, see defaults list for details.
* @param boolean $extract_alt_text Should alt_text be extracted, defaults to false.
*
* @return array Returns an array of all images meeting the specified criteria in $args.
*/
private static function get_image_fields( $post, $args = array(), $extract_alt_text = false ) {
if ( ! $post instanceof WP_Post ) {
return 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 ) ) {
if ( $extract_alt_text ) {
$image_list = array_merge( $image_list, self::reduce_extracted_images( $from_featured_image ) );
} else {
$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 ) ) {
if ( $extract_alt_text ) {
$image_list = array_merge( $image_list, self::reduce_extracted_images( $from_slideshow ) );
} else {
$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 ) ) {
if ( $extract_alt_text ) {
$image_list = array_merge( $image_list, self::reduce_extracted_images( $from_gallery ) );
} else {
$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 = self::get_images_from_html( $post->post_content, $image_list, $extract_alt_text );
return self::build_image_struct( $image_list, $image_booleans );
}
/**
* Given an extracted image array reduce to src, alt_text, src_width, and src_height.
*
* @param array $images extracted image array.
*
* @return array reduced image array
*/
protected static function reduce_extracted_images( $images ) {
$ret_images = array();
foreach ( $images as $image ) {
// skip if src isn't set.
if ( empty( $image['src'] ) ) {
continue;
}
$ret_image = array(
'url' => $image['src'],
);
if ( ! empty( $image['src_height'] ) || ! empty( $image['src_width'] ) ) {
$ret_image['src_width'] = $image['src_width'] ?? '';
$ret_image['src_height'] = $image['src_height'] ?? '';
}
if ( ! empty( $image['alt_text'] ) ) {
$ret_image['alt_text'] = $image['alt_text'];
} else {
$ret_image = $image['src'];
}
$ret_images[] = $ret_image;
}
return $ret_images;
}
/**
* Helper function to get images from HTML and return it with the set sturcture.
*
* @param string $content HTML content.
* @param array $image_list Array of already found images.
* @param string $extract_alt_text Whether or not to extract the alt text.
*
* @return array|array[] Array of images.
*/
public static function extract_images_from_content( $content, $image_list, $extract_alt_text = false ) {
$image_list = self::get_images_from_html( $content, $image_list, $extract_alt_text );
return self::build_image_struct( $image_list, array() );
}
/**
* Produces a set structure for extracted media items.
*
* @param array $image_list Array of images.
* @param array $image_booleans Image booleans.
*
* @return array|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, SORT_REGULAR );
foreach ( $image_list as $img ) {
if ( is_string( $img ) ) {
$retval['image'][] = array( 'url' => $img );
} else {
$retval['image'][] = $img;
}
}
$image_booleans['image'] = count( $retval['image'] );
if ( ! empty( $image_booleans ) ) {
$retval['has'] = $image_booleans;
}
return $retval;
} else {
return array();
}
}
/**
* Extracts images from html.
*
* @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.
* @param boolean $extract_alt_text Should alt_text be extracted, defaults to false.
*
* @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, $extract_alt_text = false ) {
$image_list = $images_already_extracted;
$from_html = Jetpack_PostImages::from_html( $html );
// early return if no image in html.
if ( empty( $from_html ) ) {
return $image_list;
}
// process images.
foreach ( $from_html as $extracted_image ) {
$image_url = $extracted_image['src'];
$length = strpos( $image_url, '?' );
$src = wp_parse_url( $image_url );
if ( $src && isset( $src['scheme'] ) && isset( $src['host'] ) && isset( $src['path'] ) ) {
// Rebuild the URL without the query string.
$queryless = $src['scheme'] . '://' . $src['host'] . $src['path'];
} elseif ( $length ) {
// If wp_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, true ) ) {
$image_to_add = array(
'url' => $queryless,
);
if ( $extract_alt_text ) {
if ( ! empty( $extracted_image['alt_text'] ) ) {
$image_to_add['alt_text'] = $extracted_image['alt_text'];
}
if ( ! empty( $extracted_image['src_width'] ) || ! empty( $extracted_image['src_height'] ) ) {
$image_to_add['src_width'] = $extracted_image['src_width'];
$image_to_add['src_height'] = $extracted_image['src_height'];
}
} else {
$image_to_add = $queryless;
}
$image_list[] = $image_to_add;
}
}
return $image_list;
}
/**
* Strips concents of all tags, shortcodes, and decodes HTML entities.
*
* @param string $content Original content.
*
* @return string Cleaned content.
*/
private static function get_stripped_content( $content ) {
$clean_content = wp_strip_all_tags( $content );
$clean_content = html_entity_decode( $clean_content, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
// completely strip shortcodes and any content they enclose.
$clean_content = strip_shortcodes( $clean_content );
return $clean_content;
}
}
@@ -0,0 +1,463 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Provides media summary of a post.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Image_CDN\Image_CDN_Core;
/**
* Class Jetpack_Media_Summary
*
* Priority: embed [video] > gallery > image > text
*/
class Jetpack_Media_Summary {
/**
* Media cache.
*
* @var array
*/
private static $cache = array();
/**
* Get media summary for a post.
*
* @param ?int $post_id Post ID.
* @param int $blog_id Blog ID, if applicable.
* @param array $args {
* Optional. An array of arguments.
* @type int $max_words Maximum number of words.
* @type int $max_chars Maximum number of characters.
* }
*
* @return array|mixed|void
*/
public static function get( ?int $post_id, int $blog_id = 0, array $args = array() ) {
$post_id = (int) $post_id;
$blog_id = (int) $blog_id;
$defaults = array(
'max_words' => 16,
'max_chars' => 256,
);
$args = wp_parse_args( $args, $defaults );
$switched = false;
if ( ! empty( $blog_id ) && get_current_blog_id() !== $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 ] ) ) {
if ( $switched ) {
restore_current_blog();
}
return self::$cache[ $cache_key ];
}
if ( ! class_exists( 'Jetpack_Media_Meta_Extractor' ) ) {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.media-extractor.php';
}
$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 ( $post instanceof WP_Post && 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'] ) ) {
if ( $switched ) {
restore_current_blog();
}
self::$cache[ $cache_key ] = $return;
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'] ) {
if ( ! isset( $extract['shortcode']['youtube']['id'][0] ) ) {
break;
}
$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 = wp_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 ( str_contains( $embed, 'youtube' ) ) {
$return['image'] = self::get_video_poster( 'youtube', jetpack_get_youtube_id( $return['video'] ) );
$return['secure']['image'] = self::https( $return['image'] );
} elseif ( str_contains( $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'] );
} elseif ( str_contains( $embed, 'vimeo' ) ) {
$poster_image = get_post_meta( $post_id, 'vimeo_poster_image', true );
if ( ! empty( $poster_image ) ) {
$return['image'] = $poster_image;
$poster_url_parts = wp_parse_url( $poster_image );
$return['secure']['image'] = 'https://secure-a.vimeocdn.com' . $poster_url_parts['path'];
}
} elseif ( str_contains( $embed, 'dailymotion' ) ) {
$return['image'] = str_replace( 'dailymotion.com/video/', 'dailymotion.com/thumbnail/video/', $embed );
$return['image'] = wp_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( wp_strip_all_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'];
}
} elseif ( ! empty( $extract['has']['image'] ) ) {
// ... Or we try and select a single image that would make sense.
$content = wpautop( wp_strip_all_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 ( str_contains( $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 && is_countable( $extract['image'] ) && 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;
}
/**
* Converts http to https://
*
* @param string $str URL.
*
* @return string URL.
*/
public static function https( $str ) {
return str_replace( 'http://', 'https://', $str );
}
/**
* Returns a Photonized version of the URL.
*
* @param string $url URL.
*
* @return string URL.
*/
public static function ssl_img( $url ) {
if ( str_contains( $url, 'files.wordpress.com' ) ) {
return self::https( $url );
} else {
return self::https( Image_CDN_Core::cdn_url( $url ) );
}
}
/**
* Get the video poster.
*
* @param string $type Video service.
* @param string $id Video ID for the service.
*
* @return string URL of image thumbnail for the video.
*/
public 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 );
} elseif ( class_exists( 'VideoPress_Video' ) ) {
$video = new VideoPress_Video( $id );
return $video->poster_frame_uri;
}
} elseif ( 'youtube' === $type ) {
return 'http://img.youtube.com/vi/' . $id . '/0.jpg';
}
}
/**
* Clean text of shortcodes and tags.
*
* @param string $text Dirty text.
*
* @return string Clean text.
*/
public static function clean_text( $text ) {
return trim(
preg_replace(
'/[\s]+/',
' ',
preg_replace(
'@https?://[\S]+@',
'',
strip_shortcodes(
wp_strip_all_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.
**/
public 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 ( empty( $post_excerpt ) && 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 ) {
// @todo Refactor to not need to override the global.
// phpcs:ignore: WordPress.WP.GlobalVariablesOverride.Prohibited
$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 );
// phpcs:ignore: WordPress.WP.GlobalVariablesOverride.Prohibited
$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.
*
* @return array Array of words.
*/
public static function split_content_in_words( $text ) {
$words = preg_split( '/[\s!?;,.]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
// Return an empty array if the split above fails.
return $words ? $words : array();
}
/**
* Get the word count.
*
* @param string $post_content Post content.
*
* @return int Word count.
*/
public static function get_word_count( $post_content ) {
return (int) count( self::split_content_in_words( self::clean_text( $post_content ) ) );
}
/**
* Get remainder word count (after the excerpt).
*
* @param string $post_content Post content.
* @param string $excerpt_content Excerpt content.
*
* @return int Number of words after the excerpt.
*/
public 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;
}
/**
* Counts the number of links in a post.
*
* @param string $post_content Post content.
*
* @return false|int Number of links.
*/
public static function get_link_count( $post_content ) {
return preg_match_all( '/\<a[\> ]/', $post_content, $matches );
}
}
@@ -0,0 +1,502 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
require_once JETPACK__PLUGIN_DIR . 'sal/class.json-api-date.php';
/**
* Class to handle different actions related to media.
*/
class Jetpack_Media {
/**
* Original media meta data. Metadata key as stored by WP.
*
* @var string
*/
const WP_ORIGINAL_MEDIA = '_wp_original_post_media';
/**
* Revision history. Metadata key as stored by WP.
*
* @var string
*/
const WP_REVISION_HISTORY = '_wp_revision_history';
/**
* Maximum amount of revisions.
*
* @var int
*/
const REVISION_HISTORY_MAXIMUM_AMOUNT = 0;
/**
* Image Alt. Metadata key as stored by WP.
*
* @var string
*/
const 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 int $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'];
// Get the file parts from the current attachment.
$current_file = get_attached_file( $media_id );
$current_file_parts = pathinfo( $current_file );
$current_file_ext = $current_file_parts['extension'];
$current_file_dirname = $current_file_parts['dirname'];
// 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 {
$filename_base = $current_file_parts['filename'];
}
// Add unique seed based on the filename.
$filename_base .= '-' . crc32( $filename_base ) . '-';
$number_suffix = time() . wp_rand( 100, 999 );
do {
$filename = $filename_base;
$filename .= "e{$number_suffix}";
$file_ext = $new_file_ext ? $new_file_ext : $current_file_ext;
$new_filename = "{$filename}.{$file_ext}";
$new_path = "{$current_file_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 int $media_id Attachment ID.
* @return string
*/
private static function get_time_string_from_guid( $media_id ) {
$time = gmdate( 'Y/m', strtotime( current_time( 'mysql' ) ) );
$media = get_post( $media_id );
if ( $media ) {
$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.
*
* @param array $default_mime_types Array of mime types.
*
* @return array mime_type array
*/
public 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 ) {
return jetpack_is_file_supported_for_sideloading( $file );
}
/**
* Save the given uploaded temporary 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.
*
* Note this does not support sideloads, only uploads.
*
* @param array $file_array Data derived from `$_FILES` for an uploaded file.
* @param int $media_id Attachment 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 ( ! is_uploaded_file( $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 )
) {
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_upload( $file_array, $overrides, $time );
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 array|WP_Error $file - File data, or WP_Error on error.
* @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 int $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.
*
* @param int $media_id Attachment ID.
*/
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;
}
/**
* Delete a file.
*
* @param string $pathname Path name.
*/
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 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch
}
return wp_delete_file( $pathname );
}
/**
* Try to delete a file according to the dirname of
* the media attached file and the filename.
*
* @param int $media_id - media post ID.
* @param string $filename - basename of the file ( name-of-file.ext ).
*
* @return void
*/
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 $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 int $media_id - media post ID.
* @param array $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, $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 int $media_id - media post ID.
* @param null|int $limit - maximum amount of items. 20 as default.
*
* @return array items removed from `revision_history`
*/
public static function limit_revision_history( $media_id, $limit = null ) {
if ( $limit === null ) {
$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 int $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 int $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
*
* Note this does not support sideloads, only uploads.
*
* @param int $media_id - media post ID.
* @param array $file_array - Data derived from `$_FILES` for an uploaded file.
* @return WP_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 ) ) {
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 new WP_Error( 'update_error', 'Media update error' );
}
// Check maximum amount of revision_history before updating the attachment metadata.
self::limit_revision_history( $media_id );
$new_metadata = wp_generate_attachment_metadata( $media_id, $uploaded_path );
wp_update_attachment_metadata( $media_id, $new_metadata );
$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;
}
}
// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- TODO: Move these functions to some other file.
/**
* Clean revision history when the media item is deleted.
*
* @param int $media_id Attachment ID.
*/
function jetpack_clean_revision_history( $media_id ) {
Jetpack_Media::clean_revision_history( $media_id );
}
add_action( 'delete_attachment', 'jetpack_clean_revision_history' );
@@ -0,0 +1,119 @@
<?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
use Automattic\Jetpack\Status;
/**
* Components Library
*
* Load and display a pre-rendered component
*/
class Jetpack_Components {
/**
* Load and display a pre-rendered component
*
* @since 7.7.0
*
* @param string $name Component name.
* @param array $props Component properties.
*
* @return string The component markup
*/
public static function render_component( $name, $props ) {
$rtl = is_rtl() ? '.rtl' : '';
wp_enqueue_style( 'jetpack-components', plugins_url( "_inc/blocks/components{$rtl}.css", JETPACK__PLUGIN_FILE ), array( 'wp-components' ), JETPACK__VERSION );
ob_start();
// `include` fails gracefully and throws a warning, but doesn't halt execution.
include JETPACK__PLUGIN_DIR . "_inc/blocks/$name.html";
$markup = ob_get_clean();
foreach ( $props as $key => $value ) {
$markup = str_replace(
"#$key#",
$value,
$markup
);
// Workaround, required to replace strings in `sprintf`-expressions.
// See extensions/i18n-to-php.js for more information.
$markup = str_replace(
"%($key)s",
$value,
$markup
);
}
return $markup;
}
/**
* Renders the frontend-nudge with the provided props.
*
* @param array $props Component properties.
*
* @return string The component markup.
*/
public static function render_frontend_nudge( $props ) {
return self::render_component(
'frontend-nudge',
$props
);
}
/**
* Load and display a pre-rendered component
*
* @since 7.7.0
*
* @param array $props Component properties.
*
* @return string The component markup
*/
public static function render_upgrade_nudge( $props ) {
$plan_slug = $props['plan'];
require_once JETPACK__PLUGIN_DIR . '_inc/lib/plans.php';
$plan = Jetpack_Plans::get_plan( $plan_slug );
if ( ! $plan ) {
return self::render_component(
'upgrade-nudge',
array(
'checkoutUrl' => '',
)
);
}
// WP.com plan objects have a dedicated `path_slug` field, Jetpack plan objects don't.
$plan_path_slug = wp_startswith( $plan_slug, 'jetpack_' )
? $plan_slug
: $plan->path_slug;
$post_id = get_the_ID();
$site_slug = ( new Status() )->get_site_suffix();
// Post-checkout: redirect back to the editor.
$redirect_to = add_query_arg(
array(
'plan_upgraded' => 1,
),
get_edit_post_link( $post_id )
);
$upgrade_url =
$plan_path_slug
? add_query_arg(
'redirect_to',
$redirect_to,
"https://wordpress.com/checkout/{$site_slug}/{$plan_path_slug}"
) : '';
return self::render_component(
'upgrade-nudge',
array(
'checkoutUrl' => $upgrade_url,
)
);
}
}
@@ -0,0 +1,442 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* `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()`
*
* @todo - nicer API for array values?
*
* @package automattic/jetpack
*/
/**
* Abstract WPCOM_REST_API_V2_Field_Controller class extended for different fields needed in the Jetpack plugin.
*/
abstract class WPCOM_REST_API_V2_Field_Controller {
/**
* The REST Object Type(s) to which the field should be added.
*
* @var string|string[]
*/
protected $object_type;
/**
* The name of the REST API field to add.
*
* @var string
*/
protected $field_name;
/**
* Constructor
*/
public function __construct() {
if ( ! $this->object_type ) {
_doing_it_wrong(
'WPCOM_REST_API_V2_Field_Controller::$object_type',
sprintf(
/* translators: %s: object_type */
esc_html__( "Property '%s' must be overridden.", 'jetpack' ),
'object_type'
),
'jetpack-6.8'
);
return;
}
if ( ! $this->field_name ) {
_doing_it_wrong(
'WPCOM_REST_API_V2_Field_Controller::$field_name',
sprintf(
/* translators: %s: field_name */
esc_html__( "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 ) {
if ( $this->is_registered( $object_type ) ) {
continue;
}
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(),
)
);
}
}
/**
* Checks if the field is already registered for the object_type
*
* @param string $object_type The name of the object type.
* @return boolean Whether the field has been registered for the type.
*/
public function is_registered( $object_type ) {
global $wp_rest_additional_fields;
return ! empty( $wp_rest_additional_fields[ $object_type ][ $this->field_name ] );
}
/**
* Ensures the response matches the schema and request context.
*
* @param mixed $value Value passed in request.
* @param WP_REST_Request $request WP API 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 Schema to validate against.
*
* @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 WP API 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 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$permission_check = $this->get_permission_check( $object_data, $request );
if ( ! $permission_check ) {
_doing_it_wrong(
'WPCOM_REST_API_V2_Field_Controller::get_permission_check',
sprintf(
/* translators: %s: get_permission_check() */
esc_html__( "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 WP API 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 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$permission_check = $this->update_permission_check( $value, $object_data, $request );
if ( ! $permission_check ) {
_doing_it_wrong(
'WPCOM_REST_API_V2_Field_Controller::update_permission_check',
sprintf(
/* translators: %s: update_permission_check() */
esc_html__( "Method '%s' must return either true or WP_Error.", 'jetpack' ),
'update_permission_check'
),
'jetpack-6.8'
);
return new WP_Error(
'invalid_user_permission',
sprintf(
/* translators: %s: the name of an API response field */
__( "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 WP API request.
*
* @return true|WP_Error
*/
public function get_permission_check( $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
_doing_it_wrong(
'WPCOM_REST_API_V2_Field_Controller::get_permission_check',
sprintf(
/* translators: %s: method name. */
esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
__METHOD__
),
'jetpack-6.8'
);
return null;
}
/**
* 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 WP API request.
* @return mixed
*/
public function get( $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
_doing_it_wrong(
'WPCOM_REST_API_V2_Field_Controller::get',
sprintf(
/* translators: %s: method name. */
esc_html__( "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 WP API request.
*
* @return true|WP_Error
*/
public function update_permission_check( $value, $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
_doing_it_wrong(
'WPCOM_REST_API_V2_Field_Controller::update_permission_check',
sprintf(
/* translators: %s: method name. */
esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
__METHOD__
),
'jetpack-6.8'
);
return null;
}
/**
* 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 WP API request.
*
* @return mixed
*/
public function update( $value, $object_data, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
_doing_it_wrong(
'WPCOM_REST_API_V2_Field_Controller::update',
sprintf(
/* translators: %s: method name. */
esc_html__( "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() {
_doing_it_wrong(
'WPCOM_REST_API_V2_Field_Controller::get_schema',
sprintf(
/* translators: %s: method name. */
esc_html__( "Method '%s' must be overridden.", 'jetpack' ),
__METHOD__
),
'jetpack-6.8'
);
return null;
}
/**
* Ensure that our request matches its expected context.
*
* @param array $schema Schema to validate against.
* @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 Value passed to API request.
* @param array $schema Schema to validate against.
* @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;
}
}
@@ -0,0 +1,303 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* List of /site core REST API endpoints used in Jetpack's dashboard.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Publicize\Publicize;
use Automattic\Jetpack\Stats\WPCOM_Stats;
/**
* This is the endpoint class for `/site` endpoints.
*/
class Jetpack_Core_API_Site_Endpoint {
/**
* Returns commonly used WP_Error indicating failure to fetch data
*
* @return WP_Error that denotes our inability to fetch the requested data
*/
private static function get_failed_fetch_error() {
return new WP_Error(
'failed_to_fetch_data',
esc_html__( 'Unable to fetch the requested data.', 'jetpack' ),
array( 'status' => 500 )
);
}
/**
* 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 = 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 self::get_failed_fetch_error();
}
// Decode the results.
$results = json_decode( $response['body'], true );
// Bail if there were no results or plan details returned.
if ( ! is_array( $results ) ) {
return self::get_failed_fetch_error();
}
return rest_ensure_response(
array(
'code' => 'success',
'message' => esc_html__( 'Site features correctly received.', 'jetpack' ),
'data' => wp_remote_retrieve_body( $response ),
)
);
}
/**
* Returns the result of `/sites/%s/purchases` endpoint call.
*
* @return array of site purchases.
*/
public static function get_purchases() {
// Make the API request.
$request = sprintf( '/sites/%d/purchases', Jetpack_Options::get_option( 'id' ) );
$response = 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 self::get_failed_fetch_error();
}
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
return self::get_failed_fetch_error();
}
// Decode the results.
$results = json_decode( $response['body'], true );
// Bail if there were no results or purchase details returned.
if ( ! is_array( $results ) ) {
return self::get_failed_fetch_error();
}
return rest_ensure_response(
array(
'code' => 'success',
'message' => esc_html__( 'Site purchases correctly received.', 'jetpack' ),
'data' => wp_remote_retrieve_body( $response ),
)
);
}
/**
* Returns the result of `/sites/%d/products` endpoint call.
*
* @return array of site products.
*/
public static function get_products() {
$url = sprintf( '/sites/%d/products?locale=%s&type=jetpack', Jetpack_Options::get_option( 'id' ), get_user_locale() );
$response = Client::wpcom_json_api_request_as_blog( $url, '1.1' );
if ( is_wp_error( $response ) || ! is_array( $response ) || ! isset( $response['body'] ) ) {
return self::get_failed_fetch_error();
}
$results = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $results ) ) {
return self::get_failed_fetch_error();
}
return rest_ensure_response(
array(
'code' => 'success',
'message' => esc_html__( 'Site products correctly received.', 'jetpack' ),
'data' => $results,
)
);
}
/**
* 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' );
}
/**
* Gets an array of data that show how Jetpack is currently being used to benefit the site.
*
* @since 7.7
*
* @return WP_REST_Response
*/
public static function get_benefits() {
$benefits = array();
/*
* We get different benefits from Stats:
* - this year's visitors
* - Followers (only if subs module is active)
* - Sharing counts (not currently supported in Jetpack -- https://github.com/Automattic/jetpack/issues/844 )
*/
$wpcom_stats = new WPCOM_Stats();
$stats = $wpcom_stats->convert_stats_array_to_object(
$wpcom_stats->get_stats( array( 'fields' => 'stats' ) )
);
$has_stats = null !== $stats && ! is_wp_error( $stats );
// Yearly visitors.
if ( $has_stats && $stats->stats->visitors > 0 ) {
$benefits[] = array(
'name' => 'jetpack-stats',
'title' => esc_html__( 'Jetpack Stats', 'jetpack' ),
'description' => esc_html__( 'Visitors tracked by Jetpack', 'jetpack' ),
'value' => absint( $stats->stats->visitors ),
);
}
// Protect blocked logins.
if ( Jetpack::is_module_active( 'protect' ) ) {
$protect = get_site_option( 'jetpack_protect_blocked_attempts' );
if ( $protect > 0 ) {
$benefits[] = array(
'name' => 'protect',
'title' => esc_html__( 'Brute force protection', 'jetpack' ),
'description' => esc_html__( 'The number of malicious login attempts blocked by Jetpack', 'jetpack' ),
'value' => absint( $protect ),
);
}
}
// Number of followers.
if ( $has_stats && $stats->stats->followers_blog > 0 && Jetpack::is_module_active( 'subscriptions' ) ) {
$benefits[] = array(
'name' => 'subscribers',
'title' => esc_html__( 'Subscribers', 'jetpack' ),
'description' => esc_html__( 'People subscribed to your updates through Jetpack', 'jetpack' ),
'value' => absint( $stats->stats->followers_blog ),
);
}
// VaultPress backups.
if ( Jetpack::is_plugin_active( 'vaultpress/vaultpress.php' ) && class_exists( 'VaultPress' ) ) {
$vaultpress = new VaultPress();
if ( $vaultpress->is_registered() ) {
$data = json_decode( base64_decode( $vaultpress->contact_service( 'plugin_data' ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
if ( $data && $data->features->backups && ! empty( $data->backups->stats ) && $data->backups->stats->revisions > 0 ) {
$benefits[] = array(
'name' => 'jetpack-backup',
'title' => esc_html__( 'Jetpack Backup', 'jetpack' ),
'description' => esc_html__( 'The number of times Jetpack has backed up your site and kept it safe', 'jetpack' ),
'value' => absint( $data->backups->stats->revisions ),
);
}
}
}
// Number of forms sent via a Jetpack contact form.
if ( Jetpack::is_module_active( 'contact-form' ) ) {
$contact_form_count = array_sum( get_object_vars( wp_count_posts( 'feedback' ) ) );
if ( $contact_form_count > 0 ) {
$benefits[] = array(
'name' => 'contact-form-feedback',
'title' => esc_html__( 'Contact Form Feedback', 'jetpack' ),
'description' => esc_html__( 'Form submissions stored by Jetpack', 'jetpack' ),
'value' => absint( $contact_form_count ),
);
}
}
// Number of images in the library if Photon is active.
if ( Jetpack::is_module_active( 'photon' ) ) {
$photon_count = array_reduce(
get_object_vars( wp_count_attachments( array( 'image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp' ) ) ),
function ( $i, $j ) {
return $i + $j;
}
);
if ( $photon_count > 0 ) {
$benefits[] = array(
'name' => 'image-hosting',
'title' => esc_html__( 'Image Hosting', 'jetpack' ),
'description' => esc_html__( 'Super-fast, mobile-ready images served by Jetpack', 'jetpack' ),
'value' => absint( $photon_count ),
);
}
}
// Number of VideoPress videos on the site.
if ( Jetpack::is_module_active( 'videopress' ) ) {
$videopress_attachments = wp_count_attachments( 'video/videopress' );
if (
isset( $videopress_attachments->{'video/videopress'} )
&& $videopress_attachments->{'video/videopress'} > 0
) {
$benefits[] = array(
'name' => 'video-hosting',
'title' => esc_html__( 'Video Hosting', 'jetpack' ),
'description' => esc_html__( 'Ad-free, lightning-fast videos delivered by Jetpack', 'jetpack' ),
'value' => absint( $videopress_attachments->{'video/videopress'} ),
);
}
}
// Number of active Publicize connections.
if ( Jetpack::is_module_active( 'publicize' ) && class_exists( Publicize::class ) ) {
$publicize = new Publicize();
$connections = $publicize->get_all_connections();
$number_of_connections = 0;
if ( is_array( $connections ) && ! empty( $connections ) ) {
$number_of_connections = count( $connections );
}
if ( $number_of_connections > 0 ) {
$benefits[] = array(
'name' => 'publicize',
'title' => esc_html__( 'Jetpack Social', 'jetpack' ),
'description' => esc_html__( 'Live social media site connections, powered by Jetpack', 'jetpack' ),
'value' => absint( $number_of_connections ),
);
}
}
// Total number of shares.
if ( $has_stats && $stats->stats->shares > 0 ) {
$benefits[] = array(
'name' => 'sharing',
'title' => esc_html__( 'Sharing', 'jetpack' ),
'description' => esc_html__( 'The number of times visitors have shared your posts with the world using Jetpack', 'jetpack' ),
'value' => absint( $stats->stats->shares ),
);
}
if ( Jetpack::is_module_active( 'search' ) && ! class_exists( 'Automattic\\Jetpack\\Search_Plugin\\Jetpack_Search_Plugin' ) ) {
$benefits[] = array(
'name' => 'search',
'title' => esc_html__( 'Search', 'jetpack' ),
'description' => esc_html__( 'Help your visitors find exactly what they are looking for, fast', 'jetpack' ),
);
}
// Finally, return the whole list of benefits.
return rest_ensure_response(
array(
'code' => 'success',
'message' => esc_html__( 'Site benefits correctly received.', 'jetpack' ),
'data' => wp_json_encode( $benefits ),
)
);
}
}
@@ -0,0 +1,64 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Interact with a specific widget via the REST API.
* Currently only supports the Milestone widget.
*
* @package automattic/jetpack
*/
/**
* Widget information getter endpoint.
*/
class Jetpack_Core_API_Widget_Endpoint {
/**
* Get information about a widget that is supported by this 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;
}
}
@@ -0,0 +1,45 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* This is the base class for every Core API endpoint that needs an XMLRPC client.
*
* @package automattic/jetpack
*/
/**
* 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;
/**
* Constructor.
*
* @since 4.3.0
*
* @param Jetpack_IXR_Client $xmlrpc Jetpack_IXR_Client instance.
*/
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;
}
}
@@ -0,0 +1,62 @@
<?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/
*
* @package automattic/jetpack
*/
/**
* Disable direct access.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Loop through endpoint files and load them.
*
* @param string $file_pattern Path pattern to the endpoints (pattern must be supported by glob()).
*/
function wpcom_rest_api_v2_load_plugin_files( $file_pattern ) {
$plugins = glob( __DIR__ . '/' . $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.
*
* @param string $class_name The name of the class to load.
*/
function wpcom_rest_api_v2_load_plugin( $class_name ) {
global $wpcom_rest_api_v2_plugins;
if ( ! isset( $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 __DIR__ . '/class-wpcom-rest-field-controller.php';
/**
* Load the REST API v2 plugin files during the plugins_loaded action.
*/
function load_wpcom_rest_api_v2_plugin_files() {
wpcom_rest_api_v2_load_plugin_files( 'wpcom-endpoints/*.php' );
wpcom_rest_api_v2_load_plugin_files( 'wpcom-fields/*.php' );
}
add_action( 'plugins_loaded', 'load_wpcom_rest_api_v2_plugin_files' );
@@ -0,0 +1,65 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Build localized strings for use with the Business Hours Block.
*
* @package automattic/jetpack
*/
/**
* Business Hours: Localized week
*
* @since 7.1
*/
class WPCOM_REST_API_V2_Endpoint_Business_Hours extends WP_REST_Controller {
/**
* Constructor.
*/
public 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' ) );
}
/**
* Register endpoint route.
*/
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' ),
'permission_callback' => '__return_true',
),
)
);
}
/**
* 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' );
@@ -0,0 +1,81 @@
<?php
/**
* REST API endpoint for admin color.
*
* @package automattic/jetpack
*/
/**
* Class WPCOM_REST_API_V2_Endpoint_Admin_Color
*/
class WPCOM_REST_API_V2_Endpoint_Admin_Color extends WP_REST_Controller {
/**
* Namespace prefix.
*
* @var string
*/
public $namespace = 'wpcom/v2';
/**
* Endpoint base route.
*
* @var string
*/
public $rest_base = 'admin-color';
/**
* WPCOM_REST_API_V2_Endpoint_Admin_Color constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
)
);
}
/**
* Checks if a given request has access to admin color.
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
*/
public function get_item_permissions_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! current_user_can( 'read' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to view admin color on this site.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Retrieves the admin color.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_item( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$admin_color = get_user_option( 'admin_color' );
return rest_ensure_response( array( 'admin_color' => $admin_color ) );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Admin_Color' );
@@ -0,0 +1,520 @@
<?php
/**
* REST API endpoint for admin menus.
*
* @package automattic/jetpack
* @since 9.1.0
*/
use Automattic\Jetpack\Status\Host;
/**
* Class WPCOM_REST_API_V2_Endpoint_Admin_Menu
*/
class WPCOM_REST_API_V2_Endpoint_Admin_Menu extends WP_REST_Controller {
/**
* Namespace prefix.
*
* @var string
*/
public $namespace = 'wpcom/v2';
/**
* Endpoint base route.
*
* @var string
*/
public $rest_base = 'admin-menu';
/**
*
* Set of core dashicons.
*
* @var array
*/
private $dashicon_list;
/**
* WPCOM_REST_API_V2_Endpoint_Admin_Menu constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Checks if a given request has access to admin menus.
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
*/
public function get_item_permissions_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! current_user_can( 'read' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to view menus on this site.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Retrieves the admin menu.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_item( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! ( new Host() )->is_wpcom_platform() ) {
require_once JETPACK__PLUGIN_DIR . 'jetpack_vendor/automattic/jetpack-masterbar/src/admin-menu/load.php';
}
// All globals need to be declared for menu items to properly register.
global $admin_page_hooks, $menu, $menu_order, $submenu, $_wp_menu_nopriv, $_wp_submenu_nopriv; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$this->hide_customizer_menu_on_block_theme();
require_once ABSPATH . 'wp-admin/includes/admin.php';
require_once ABSPATH . 'wp-admin/menu.php';
return rest_ensure_response( $this->prepare_menu_for_response( $menu ) );
}
/**
* Hides the Customizer menu items when the block theme is active by removing the dotcom-specific actions.
* They are not needed for block themes.
*
* @see https://github.com/Automattic/jetpack/pull/36017
*/
private function hide_customizer_menu_on_block_theme() {
if ( wp_is_block_theme() && ! is_customize_preview() ) {
remove_action( 'customize_register', 'add_logotool_button', 20 );
remove_action( 'customize_register', 'footercredits_register', 99 );
remove_action( 'customize_register', 'wpcom_disable_customizer_site_icon', 20 );
if ( class_exists( '\Jetpack_Fonts' ) ) {
$jetpack_fonts_instance = \Jetpack_Fonts::get_instance();
remove_action( 'customize_register', array( $jetpack_fonts_instance, 'register_controls' ) );
remove_action( 'customize_register', array( $jetpack_fonts_instance, 'maybe_prepopulate_option' ), 0 );
}
remove_action( 'customize_register', array( 'Jetpack_Fonts_Typekit', 'maybe_override_for_advanced_mode' ), 20 );
if ( class_exists( 'Automattic\Jetpack\Masterbar' ) ) {
remove_action( 'customize_register', 'Automattic\Jetpack\Masterbar\register_css_nudge_control' );
}
// @phan-suppress-next-line PhanUndeclaredClassInCallable
remove_action( 'customize_register', array( 'Jetpack_Custom_CSS_Enhancements', 'customize_register' ) );
}
}
/**
* Prepares the admin menu for the REST response.
*
* @param array $menu Admin menu.
* @return array Admin menu
*/
public function prepare_menu_for_response( array $menu ) {
global $submenu;
$data = array();
/**
* Note: if the shape of the API endpoint data changes it is important to also update
* the corresponding schema.js file.
* See: https://github.com/Automattic/wp-calypso/blob/ebde236ec9b21ea9621c0b0523bd5ea185523731/client/state/admin-menu/schema.js
*/
foreach ( $menu as $menu_item ) {
$item = $this->prepare_menu_item( $menu_item );
// Are there submenu items to process?
if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
$submenu_items = array_values( $submenu[ $menu_item[2] ] );
// Add submenu items.
foreach ( $submenu_items as $submenu_item ) {
$submenu_item = $this->prepare_submenu_item( $submenu_item, $menu_item );
if ( ! empty( $submenu_item ) ) {
$item['children'][] = $submenu_item;
}
}
}
if ( ! empty( $item ) ) {
$data[] = $item;
}
}
return array_filter( $data );
}
/**
* Retrieves the admin menu's schema, conforming to JSON Schema.
*
* Note: if the shape of the API endpoint data changes it is important to also update
* the corresponding schema.js file.
*
* @see https://github.com/Automattic/wp-calypso/blob/ebde236ec9b21ea9621c0b0523bd5ea185523731/client/state/admin-menu/schema.js
*
* @return array Item schema data.
*/
public function get_item_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'Admin Menu',
'type' => 'object',
'properties' => array(
'count' => array(
'description' => 'Core/Plugin/Theme update count or unread comments count.',
'type' => 'integer',
),
'icon' => array(
'description' => 'Menu item icon. Dashicon slug or base64-encoded SVG.',
'type' => 'string',
),
'inlineText' => array(
'description' => 'Additional text to be added inline with the menu title.',
'type' => 'string',
),
'badge' => array(
'description' => 'Badge to be added inline with the menu title.',
'type' => 'string',
),
'slug' => array(
'type' => 'string',
),
'children' => array(
'items' => array(
'count' => array(
'description' => 'Core/Plugin/Theme update count or unread comments count.',
'type' => 'integer',
),
'parent' => array(
'type' => 'string',
),
'slug' => array(
'type' => 'string',
),
'title' => array(
'type' => 'string',
),
'type' => array(
'enum' => array( 'submenu-item' ),
'type' => 'string',
),
'url' => array(
'format' => 'uri',
'type' => 'string',
),
),
'type' => 'array',
),
'title' => array(
'type' => 'string',
),
'type' => array(
'enum' => array( 'separator', 'menu-item' ),
'type' => 'string',
),
'url' => array(
'format' => 'uri',
'type' => 'string',
),
),
);
}
/**
* Sets up a menu item for consumption by Calypso.
*
* @param array $menu_item Menu item.
* @return array Prepared menu item.
*/
private function prepare_menu_item( array $menu_item ) {
global $submenu;
$current_user_can_access_menu = current_user_can( $menu_item[1] );
$submenu_items = isset( $submenu[ $menu_item[2] ] ) ? array_values( $submenu[ $menu_item[2] ] ) : array();
$has_first_menu_item = isset( $submenu_items[0] );
// Exclude unauthorized menu items when the user does not have access to the menu and the first submenu item.
if ( ! $current_user_can_access_menu && $has_first_menu_item && ! current_user_can( $submenu_items[0][1] ) ) {
return array();
}
// Exclude unauthorized menu items that don't have submenus.
if ( ! $current_user_can_access_menu && ! $has_first_menu_item ) {
return array();
}
// Exclude hidden menu items.
if ( str_contains( $menu_item[4], 'hide-if-js' ) ) {
// Exclude submenu items as well.
if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$submenu[ $menu_item[2] ] = array();
}
return array();
}
// Handle menu separators.
if ( str_contains( $menu_item[4], 'wp-menu-separator' ) ) {
return array(
'type' => 'separator',
);
}
$url = $menu_item[2];
$parent_slug = '';
// If there are submenus, the parent menu should always link to the first submenu.
// @see https://core.trac.wordpress.org/browser/trunk/src/wp-admin/menu-header.php?rev=49193#L152.
if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
$parent_slug = $url;
$first_submenu_item = reset( $submenu[ $menu_item[2] ] );
$url = $first_submenu_item[2];
}
$item = array(
'icon' => $this->prepare_menu_item_icon( $menu_item[6] ),
'slug' => sanitize_title_with_dashes( $menu_item[2] ),
'title' => $menu_item[0],
'type' => 'menu-item',
'url' => $this->prepare_menu_item_url( $url, $parent_slug ),
);
$parsed_item = $this->parse_menu_item( $item['title'] );
if ( ! empty( $parsed_item ) ) {
$item = array_merge( $item, $parsed_item );
}
return $item;
}
/**
* Sets up a submenu item for consumption by Calypso.
*
* @param array $submenu_item Submenu item.
* @param array $menu_item Menu item.
* @return array Prepared submenu item.
*/
private function prepare_submenu_item( array $submenu_item, array $menu_item ) {
// Exclude unauthorized submenu items.
if ( ! current_user_can( $submenu_item[1] ) ) {
return array();
}
// Exclude hidden submenu items.
if ( isset( $submenu_item[4] ) && str_contains( $submenu_item[4], 'hide-if-js' ) ) {
return array();
}
$item = array(
'parent' => sanitize_title_with_dashes( $menu_item[2] ),
'slug' => sanitize_title_with_dashes( $submenu_item[2] ),
'title' => $submenu_item[0],
'type' => 'submenu-item',
'url' => $this->prepare_menu_item_url( $submenu_item[2], $menu_item[2] ),
);
$parsed_item = $this->parse_menu_item( $item['title'] );
if ( ! empty( $parsed_item ) ) {
$item = array_merge( $item, $parsed_item );
}
return $item;
}
/**
* Prepares a menu icon for consumption by Calypso.
*
* @param string $icon Menu icon.
* @return string
*/
private function prepare_menu_item_icon( $icon ) {
$img = 'dashicons-admin-generic';
if ( ! empty( $icon ) && 'none' !== $icon && 'div' !== $icon ) {
$img = esc_url( $icon );
if ( str_starts_with( $icon, 'data:image/svg+xml' ) ) {
$img = $icon;
} elseif ( str_starts_with( $icon, 'dashicons-' ) ) {
$img = $this->prepare_dashicon( $icon );
}
}
return $img;
}
/**
* Prepares the dashicon for consumption by Calypso. If the dashicon isn't found in a list of known icons
* we will return the default dashicon.
*
* @param string $icon The dashicon string to check.
*
* @return string If the dashicon exists in core we return the dashicon, otherwise we return the default dashicon.
*/
private function prepare_dashicon( $icon ) {
if ( empty( $this->dashicon_set ) ) {
$this->dashicon_list = include JETPACK__PLUGIN_DIR . 'jetpack_vendor/automattic/jetpack-masterbar/src/admin-menu/dashicon-set.php';
}
if ( isset( $this->dashicon_list[ $icon ] ) && $this->dashicon_list[ $icon ] ) {
return $icon;
}
return 'dashicons-admin-generic';
}
/**
* Prepares a menu item url for consumption by Calypso.
*
* @param string $url Menu slug.
* @param string $parent_slug Optional. Parent menu item slug. Default empty string.
* @return string
*/
private function prepare_menu_item_url( $url, $parent_slug = '' ) {
// External URLS.
if ( preg_match( '/^https?:\/\//', $url ) ) {
// Allow URLs pointing to WordPress.com.
if ( str_starts_with( $url, 'https://wordpress.com/' ) ) {
// Calypso needs the domain removed so they're not interpreted as external links.
$url = str_replace( 'https://wordpress.com', '', $url );
// Replace special characters with their correct entities e.g. &amp; to &.
return wp_specialchars_decode( esc_url_raw( $url ) );
}
// Allow URLs pointing to Jetpack.com.
if ( str_starts_with( $url, 'https://jetpack.com/' ) ) {
// Replace special characters with their correct entities e.g. &amp; to &.
return wp_specialchars_decode( esc_url_raw( $url ) );
}
// Disallow other external URLs.
if ( ! str_starts_with( $url, get_site_url() ) ) {
return '';
}
// The URL matches that of the site, treat it as an internal URL.
}
// Internal URLs.
$menu_hook = get_plugin_page_hook( $url, $parent_slug );
$menu_file = wp_parse_url( $url, PHP_URL_PATH ); // Removes query args to get a file name.
$parent_file = wp_parse_url( $parent_slug, PHP_URL_PATH );
if (
! empty( $menu_hook ) ||
(
'index.php' !== $url &&
file_exists( WP_PLUGIN_DIR . "/$menu_file" ) &&
! file_exists( ABSPATH . "/wp-admin/$menu_file" )
)
) {
$admin_is_parent = false;
if ( ! empty( $parent_slug ) ) {
$menu_hook = get_plugin_page_hook( $parent_slug, 'admin.php' );
$admin_is_parent = ! empty( $menu_hook ) || ( ( 'index.php' !== $parent_slug ) && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! file_exists( ABSPATH . "/wp-admin/$parent_file" ) );
}
if (
( false === $admin_is_parent && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! is_dir( WP_PLUGIN_DIR . "/$parent_file" ) ) ||
( file_exists( ABSPATH . "/wp-admin/$parent_file" ) && ! is_dir( ABSPATH . "/wp-admin/$parent_file" ) )
) {
$url = add_query_arg( array( 'page' => $url ), admin_url( $parent_slug ) );
} else {
$url = add_query_arg( array( 'page' => $url ), admin_url( 'admin.php' ) );
}
} elseif ( file_exists( ABSPATH . "/wp-admin/$menu_file" ) ) {
$url = admin_url( $url );
}
return wp_specialchars_decode( esc_url_raw( $url ) );
}
/**
* "Plugins", "Comments", "Updates" menu items have a count badge when there are updates available.
* This method parses that information, removes the associated markup and adds it to the response.
*
* Also sanitizes the titles from remaining unexpected markup.
*
* @param string $title Title to parse.
* @return array
*/
private function parse_menu_item( $title ) {
$item = array();
if (
str_contains( $title, 'count-' )
&& preg_match( '/<span class=".+\s?count-(\d*).+\s?<\/span><\/span>/', $title, $matches )
) {
$count = (int) ( $matches[1] );
if ( $count > 0 ) {
// Keep the counter in the item array.
$item['count'] = $count;
}
// Finally remove the markup.
$title = trim( str_replace( $matches[0], '', $title ) );
}
if (
str_contains( $title, 'inline-text' )
&& preg_match( '/<span class="inline-text".+\s?>(.+)<\/span>/', $title, $matches )
) {
$text = $matches[1];
if ( $text ) {
// Keep the text in the item array.
$item['inlineText'] = $text;
}
// Finally remove the markup.
$title = trim( str_replace( $matches[0], '', $title ) );
}
if (
str_contains( $title, 'awaiting-mod' )
&& preg_match( '/<span class="awaiting-mod">(.+)<\/span>/', $title, $matches )
) {
$text = $matches[1];
if ( $text ) {
// Keep the text in the item array.
$item['badge'] = $text;
}
// Finally remove the markup.
$title = trim( str_replace( $matches[0], '', $title ) );
}
// It's important we sanitize the title after parsing data to remove any unexpected markup but keep the content.
// We are also capitalizing the first letter in case there was a counter (now parsed) in front of the title.
$item['title'] = ucfirst( wp_strip_all_tags( $title ) );
return $item;
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Admin_Menu' );
@@ -0,0 +1,305 @@
<?php
/**
* REST API endpoint for the Jetpack AI blocks.
*
* @package automattic/jetpack
* @since 11.8
*/
use Automattic\Jetpack\Connection\Client;
/**
* Class WPCOM_REST_API_V2_Endpoint_AI
*/
class WPCOM_REST_API_V2_Endpoint_AI extends WP_REST_Controller {
/**
* Namespace prefix.
*
* @var string
*/
public $namespace = 'wpcom/v2';
/**
* Endpoint base route.
*
* @var string
*/
public $rest_base = 'jetpack-ai';
/**
* WPCOM_REST_API_V2_Endpoint_AI constructor.
*/
public function __construct() {
$this->is_wpcom = true;
$this->wpcom_is_wpcom_only_endpoint = true;
if ( ! class_exists( 'Jetpack_AI_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-helper.php';
}
// Register routes that don't require Jetpack AI to be enabled.
add_action( 'rest_api_init', array( $this, 'register_basic_routes' ) );
if ( Jetpack_AI_Helper::is_ai_chat_enabled() ) {
add_action( 'rest_api_init', array( $this, 'register_ai_chat_routes' ) );
}
if ( ! \Jetpack_AI_Helper::is_enabled() ) {
return;
}
// Register routes that require Jetpack AI to be enabled.
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/completions',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'request_gpt_completion' ),
'permission_callback' => array( 'Jetpack_AI_Helper', 'get_status_permission_check' ),
),
'args' => array(
'content' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_textarea_field',
),
'post_id' => array(
'required' => false,
'type' => 'integer',
),
'skip_cache' => array(
'required' => false,
'type' => 'boolean',
'description' => 'Whether to skip the cache and make a new request',
),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/images/generations',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'request_dalle_generation' ),
'permission_callback' => array( 'Jetpack_AI_Helper', 'get_status_permission_check' ),
),
'args' => array(
'prompt' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_textarea_field',
),
'post_id' => array(
'required' => false,
'type' => 'integer',
),
),
)
);
}
/**
* Register routes for the AI Chat block.
* Relies on a site connection and Jetpack Search.
*/
public function register_ai_chat_routes() {
register_rest_route(
$this->namespace,
'/jetpack-search/ai/search',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'request_chat_with_site' ),
'permission_callback' => '__return_true',
),
'args' => array(
'query' => array(
'description' => 'Your question to the site',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'answer_prompt' => array(
'description' => 'Answer prompt override',
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
register_rest_route(
$this->namespace,
'/jetpack-search/ai/rank',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'rank_response' ),
'permission_callback' => '__return_true',
),
'args' => array(
'cache_key' => array(
'description' => 'Cache key of your response',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'comment' => array(
'description' => 'Optional feedback',
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
),
'rank' => array(
'description' => 'How do you rank this response',
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}
/**
* Register routes that don't require Jetpack AI to be enabled.
*/
public function register_basic_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/ai-assistant-feature',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'request_get_ai_assistance_feature' ),
'permission_callback' => array( 'Jetpack_AI_Helper', 'get_status_permission_check' ),
),
)
);
}
/**
* Get a response from chatting with the site.
* Uses Jetpack Search.
*
* @param WP_REST_Request $request The request.
* @return mixed
*/
public function request_chat_with_site( $request ) {
$question = $request->get_param( 'query' );
$blog_id = \Jetpack_Options::get_option( 'id' );
$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/sites/%d/jetpack-search/ai/search', $blog_id ) . '?force=wpcom',
2,
array(
'method' => 'GET',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => MINUTE_IN_SECONDS,
),
array(
'query' => $question,
/**
* Filter for an answer prompt override.
* Example: "Talk like a cowboy."
*
* @param string $prompt_override The prompt override string.
*
* @since 12.6
*/
'answer_prompt' => apply_filters( 'jetpack_ai_chat_answer_prompt', false ),
),
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$data = json_decode( wp_remote_retrieve_body( $response ) );
if ( empty( $data->cache_key ) ) {
return new WP_Error( 'invalid_ask_response', __( 'Invalid response from the server.', 'jetpack' ), 400 );
}
return $data;
}
/**
* Rank a response from chatting with the site.
*
* @param WP_REST_Request $request The request.
* @return mixed
*/
public function rank_response( $request ) {
$rank = $request->get_param( 'rank' );
$comment = $request->get_param( 'comment' );
$cache_key = $request->get_param( 'cache_key' );
if ( strpos( $cache_key, 'jp-search-ai-' ) !== 0 ) {
return new WP_Error( 'invalid_cache_key', __( 'Invalid cached context for the answer feedback.', 'jetpack' ), 400 );
}
$blog_id = \Jetpack_Options::get_option( 'id' );
$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/sites/%d/jetpack-search/ai/rank', $blog_id ) . '?force=wpcom',
2,
array(
'method' => 'GET',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 30,
),
array(
'rank' => $rank,
'comment' => $comment,
'cache_key' => $cache_key,
),
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$data = json_decode( wp_remote_retrieve_body( $response ) );
if ( 'ok' !== $data ) {
return new WP_Error( 'invalid_feedback_response', __( 'Invalid response from the server.', 'jetpack' ), 400 );
}
return $data;
}
/**
* Get completions for a given text.
*
* @param WP_REST_Request $request The request.
*/
public function request_gpt_completion( $request ) {
return Jetpack_AI_Helper::get_gpt_completion( $request['content'], $request['post_id'], $request['skip_cache'] );
}
/**
* Get image generations for a given prompt.
*
* @param WP_REST_Request $request The request.
*/
public function request_dalle_generation( $request ) {
return Jetpack_AI_Helper::get_dalle_generation( $request['prompt'], $request['post_id'] );
}
/**
* Collect and provide relevat data about the AI feature,
* such as the number of requests made.
*/
public function request_get_ai_assistance_feature() {
return Jetpack_AI_Helper::get_ai_assistance_feature();
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_AI' );
@@ -0,0 +1,187 @@
<?php
/**
* REST API endpoint for the media uploaded by the Jetpack app.
*
* @package automattic/jetpack
* @since 13.1
*/
/**
* Media uploaded by the Jetpack app helper API.
*
* @since 13.1
*/
class WPCOM_REST_API_V2_Endpoint_App_Media extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'app-media';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Registers the routes for external media.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base,
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_media' ),
'permission_callback' => array( $this, 'permission_callback' ),
'args' => array(
'number' => array(
'description' => __( 'Number of media items in the request', 'jetpack' ),
'type' => 'number',
'default' => 20,
'required' => false,
'sanitize_callback' => 'absint',
),
'page_handle' => array(
'type' => 'number',
'required' => false,
'sanitize_callback' => 'absint',
),
'after' => array(
'description' => __( 'Timestamp since the media was uploaded', 'jetpack' ),
'type' => 'number',
'default' => 0,
'required' => true,
'sanitize_callback' => 'absint',
),
),
)
);
}
/**
* Checks if a given request has access to external media libraries.
*/
public function permission_callback() {
return current_user_can( 'upload_files' );
}
/**
* Sanitization callback for media parameter.
*
* @param array $param Media parameter.
* @return true|\WP_Error
*/
public function sanitize_media( $param ) {
$param = $this->prepare_media_param( $param );
return rest_sanitize_value_from_schema( $param, $this->media_schema );
}
/**
* Validation callback for media parameter.
*
* @param array $param Media parameter.
* @return true|\WP_Error
*/
public function validate_media( $param ) {
$param = $this->prepare_media_param( $param );
return rest_validate_value_from_schema( $param, $this->media_schema, 'media' );
}
/**
* Decodes guid json and sets parameter defaults.
*
* @param array $param Media parameter.
* @return array
*/
private function prepare_media_param( $param ) {
foreach ( $param as $key => $item ) {
if ( ! empty( $item['guid'] ) ) {
$param[ $key ]['guid'] = json_decode( $item['guid'], true );
}
if ( empty( $param[ $key ]['caption'] ) ) {
$param[ $key ]['caption'] = '';
}
if ( empty( $param[ $key ]['title'] ) ) {
$param[ $key ]['title'] = '';
}
}
return $param;
}
/**
* Retrieves media items from external libraries.
*
* @param \WP_REST_Request $request Full details about the request.
* @return array|\WP_Error|mixed
*/
public function get_media( \WP_REST_Request $request ) {
$params = $request->get_params();
$number = $params['number'];
$query_args = array(
'post_type' => 'attachment',
'post_status' => 'inherit',
'number' => $number,
'date_query' => array(
'after' => gmdate( DATE_RSS, intval( $params['after'] ) ),
),
'paged' => $params['page_handle'],
'author' => get_current_user_id(),
'orderby' => 'date',
);
$media_query = new WP_Query( $query_args );
$response = $this->format_response( $media_query );
wp_reset_postdata();
return $response;
}
/**
* Formats api the response.
*
* @param \WP_Query $media_query Media query.
*/
private function format_response( $media_query ) {
$response = array();
$response['media'] = array();
while ( $media_query->have_posts() ) {
$media_query->the_post();
// only include images.
if ( wp_attachment_is_image( $media_query->post->ID ) ) {
$response['media'][] = $this->format_item( $media_query->post );
}
}
$response['found'] = $media_query->found_posts;
$response['meta'] = array( 'next_page' => $media_query->paged + 1 );
return $response;
}
/**
* Formats a single item.
*
* @param \WP_Post $item Media item.
*/
private function format_item( $item ) {
return array(
'ID' => $item->ID,
'url' => wp_get_attachment_image_url( $item->ID, 'full', true ),
'date' => get_date_from_gmt( $item->post_date_gmt ),
'name' => get_the_title( $item ),
'file' => basename( wp_get_attachment_url( $item->ID ) ),
'title' => get_the_title( $item ),
'guid' => get_the_guid( $item ),
'type' => get_post_mime_type( $item ),
'caption' => '',
'thumbnails' => array(
'thumbnail' => wp_get_attachment_image_url( $item->ID, 'thumbnail' ),
'large' => wp_get_attachment_image_url( $item->ID, 'large' ),
),
);
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_App_Media' );
@@ -0,0 +1,83 @@
<?php
/**
* Get blog stats.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Stats\WPCOM_Stats;
/**
* Blog Stats block endpoint.
*/
class WPCOM_REST_API_V2_Endpoint_Blog_Stats extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register endpoint routes.
*/
public function register_routes() {
register_rest_route(
'wpcom/v2',
'/blog-stats',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_blog_stats' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'post_id' => array(
'description' => __( 'Post ID to obtain stats for.', 'jetpack' ),
'type' => array( 'string', 'integer' ),
'required' => false,
'validate_callback' => function ( $param ) {
return is_numeric( $param );
},
),
),
),
)
);
}
/**
* Get the blog stats.
*
* @param \WP_REST_Request $request Request object.
*
* @return array Blog stats.
*/
public function get_blog_stats( $request ) {
$wpcom_stats = new WPCOM_Stats();
$post_id = $request->get_param( 'post_id' );
$post_data = $wpcom_stats->convert_stats_array_to_object(
$wpcom_stats->get_post_views( $post_id, array( 'fields' => 'views' ) )
);
$blog_data = $wpcom_stats->convert_stats_array_to_object(
$wpcom_stats->get_stats( array( 'fields' => 'stats' ) )
);
if ( ! isset( $blog_data->stats->views ) || ! isset( $blog_data->stats->visitors ) ) {
return false;
}
if ( ! isset( $post_data->views ) ) {
$post_data->views = 0;
}
return array(
'post-views' => $post_data->views,
'blog-visitors' => $blog_data->stats->visitors,
'blog-views' => $blog_data->stats->views,
);
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Blog_Stats' );
@@ -0,0 +1,151 @@
<?php
/**
* Email Preview endpoint for the WordPress.com REST API.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Manager;
use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service;
use Automattic\Jetpack\Status\Host;
require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
/**
* Class WPCOM_REST_API_V2_Endpoint_Email_Preview
*
* Returns an email preview given a post id.
*/
class WPCOM_REST_API_V2_Endpoint_Email_Preview extends WP_REST_Controller {
use WPCOM_REST_API_Proxy_Request;
/**
* Constructor.
*/
public function __construct() {
$this->base_api_path = 'wpcom';
$this->version = 'v2';
$this->namespace = $this->base_api_path . '/' . $this->version;
$this->rest_base = '/email-preview';
$this->wpcom_is_wpcom_only_endpoint = true;
$this->wpcom_is_site_specific_endpoint = true;
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Registers the routes for email preview.
*
* @see register_rest_route()
*/
public function register_routes() {
$options = array(
'show_in_index' => true,
'methods' => 'GET',
// if this is not a wpcom site, we need to proxy the request to wpcom
'callback' => ( ( new Host() )->is_wpcom_simple() ) ? array(
$this,
'email_preview',
) : array( $this, 'proxy_request_to_wpcom_as_user' ),
'permission_callback' => array( $this, 'permissions_check' ),
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the post.', 'jetpack' ),
'type' => 'integer',
),
'access' => array(
'description' => __( 'Access level.', 'jetpack' ),
'enum' => array( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY, Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS, Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS ),
'default' => Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY,
'validate_callback' => function ( $param ) {
return in_array(
$param,
array(
Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY,
Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS,
Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS,
),
true
);
},
),
),
);
register_rest_route(
$this->namespace,
$this->rest_base,
$options
);
}
/**
* Checks if the user is connected and has access to edit the post
*
* @param WP_REST_Request $request Full data about the request.
*
* @return true|WP_Error True if the request has edit access, WP_Error object otherwise.
*/
public function permissions_check( $request ) {
if ( ! ( new Host() )->is_wpcom_simple() ) {
if ( ! ( new Manager() )->is_user_connected() ) {
return new WP_Error(
'rest_cannot_send_email_preview',
__( 'Please connect your user account to WordPress.com', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
}
$post = get_post( $request->get_param( 'post_id' ) );
if ( ! $post ) {
return new \WP_Error(
'post_not_found',
__( 'Post not found.', 'jetpack' ),
array( 'status' => 404 )
);
}
if ( ! current_user_can( 'edit_post', $post->ID ) ) {
return new WP_Error(
'rest_forbidden_context',
__( 'Please connect your user account to WordPress.com', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Returns an email preview of a post.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function email_preview( $request ) {
$post_id = $request['post_id'];
$access = $request['access'];
$post = get_post( $post_id );
return rest_ensure_response(
array(
/**
* Filters the generated email preview HTML.
*
* @since 13.8
*
* @param string $html The generated HTML for the email preview.
* @param WP_Post $post The post object.
* @param string $access The access level.
*/
'html' => apply_filters( 'jetpack_generate_email_preview_html', '', $post, $access ),
)
);
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Email_Preview' );
@@ -0,0 +1,848 @@
<?php
/**
* REST API endpoint for the External Media.
*
* @package automattic/jetpack
* @since 8.7.0
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager;
/**
* External Media helper API.
*
* @since 8.7.0
*/
class WPCOM_REST_API_V2_Endpoint_External_Media extends WP_REST_Controller {
/**
* Media argument schema for /copy endpoint.
*
* @var array
*/
public $media_schema = array(
'type' => 'array',
'items' => array(
'type' => 'object',
'required' => true,
'properties' => array(
'caption' => array(
'type' => 'string',
),
'guid' => array(
'type' => 'object',
'properties' => array(
'caption' => array(
'type' => 'string',
),
'name' => array(
'type' => 'string',
),
'title' => array(
'type' => 'string',
),
'url' => array(
'format' => 'uri',
'type' => 'string',
),
),
),
'title' => array(
'type' => 'string',
),
'meta' => array(
'type' => 'object',
'additionalProperties' => false,
'properties' => array(
'vertical_id' => array(
'type' => 'string',
'format' => 'text-field',
),
'pexels_object' => array(
'type' => 'object',
),
),
),
),
),
);
/**
* Service regex.
*
* @var string
*/
private static $services_regex = '(?P<service>google_photos|openverse|pexels)';
/**
* Temporary filename.
*
* Needed to cope with Google's very long file names.
*
* @var string
*/
private $tmp_name;
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'external-media';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Registers the routes for external media.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/list/' . self::$services_regex,
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_external_media' ),
'permission_callback' => array( $this, 'permission_callback' ),
'args' => array(
'search' => array(
'description' => __( 'Media collection search term.', 'jetpack' ),
'type' => 'string',
),
'number' => array(
'description' => __( 'Number of media items in the request', 'jetpack' ),
'type' => 'number',
'default' => 20,
),
'path' => array(
'type' => 'string',
),
'page_handle' => array(
'type' => 'string',
),
'session_id' => array(
'description' => __( 'Session id of a service, currently only Google Photos Picker', 'jetpack' ),
'type' => 'string',
),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/copy/' . self::$services_regex,
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'copy_external_media' ),
'permission_callback' => array( $this, 'create_item_permissions_check' ),
'args' => array(
'media' => array(
'description' => __( 'Media data to copy.', 'jetpack' ),
'items' => $this->media_schema,
'required' => true,
'type' => 'array',
'sanitize_callback' => array( $this, 'sanitize_media' ),
'validate_callback' => array( $this, 'validate_media' ),
),
'post_id' => array(
'description' => __( 'The post ID to attach the upload to.', 'jetpack' ),
'type' => 'number',
'minimum' => 0,
),
'should_proxy' => array(
'description' => __( 'Whether to proxy the media request.', 'jetpack' ),
'type' => 'boolean',
'default' => false,
),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/connection/(?P<service>google_photos)',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_connection_details' ),
'permission_callback' => array( $this, 'permission_callback' ),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/connection/(?P<service>google_photos)',
array(
'methods' => \WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_connection' ),
'permission_callback' => array( $this, 'permission_callback' ),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/connection/(?P<service>google_photos)/picker_status',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_picker_status' ),
'permission_callback' => array( $this, 'permission_callback' ),
)
);
// Add new session route, currently for Google Photos Picker only
register_rest_route(
$this->namespace,
$this->rest_base . '/session/(?P<service>google_photos)',
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_session' ),
'permission_callback' => array( $this, 'permission_callback' ),
)
);
// Get new session route, currently for Google Photos Picker only
register_rest_route(
$this->namespace,
$this->rest_base . '/session/(?P<service>google_photos)/(?P<session_id>.*)',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_session' ),
'permission_callback' => array( $this, 'permission_callback' ),
)
);
// Delete session route, currently for Google Photos Picker only
register_rest_route(
$this->namespace,
$this->rest_base . '/session/(?P<service>google_photos)/(?P<session_id>.*)',
array(
'methods' => \WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_session' ),
'permission_callback' => array( $this, 'permission_callback' ),
)
);
// Add new proxy route for media files
register_rest_route(
$this->namespace,
$this->rest_base . '/proxy/(?P<service>google_photos)',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'proxy_media_request' ),
'permission_callback' => array( $this, 'permission_callback' ),
'args' => array(
'url' => array(
'required' => true,
'type' => 'string',
),
),
)
);
}
/**
* Checks if a given request has access to external media libraries.
*/
public function permission_callback() {
return current_user_can( 'upload_files' );
}
/**
* Checks if a given request has access to create an attachment.
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
*/
public function create_item_permissions_check( $request ) {
if ( ! empty( $request['id'] ) ) {
return new WP_Error(
'rest_post_exists',
__( 'Cannot create existing post.', 'jetpack' ),
array( 'status' => 400 )
);
}
$post_type = get_post_type_object( 'attachment' );
if ( ! current_user_can( $post_type->cap->create_posts ) ) {
return new WP_Error(
'rest_cannot_create',
__( 'Sorry, you are not allowed to create posts as this user.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
if ( ! current_user_can( 'upload_files' ) ) {
return new WP_Error(
'rest_cannot_create',
__( 'Sorry, you are not allowed to upload media on this site.', 'jetpack' ),
array( 'status' => 400 )
);
}
return true;
}
/**
* Sanitization callback for media parameter.
*
* @param array $param Media parameter.
* @return true|\WP_Error
*/
public function sanitize_media( $param ) {
$param = $this->prepare_media_param( $param );
return rest_sanitize_value_from_schema( $param, $this->media_schema );
}
/**
* Validation callback for media parameter.
*
* @param array $param Media parameter.
* @return true|\WP_Error
*/
public function validate_media( $param ) {
$param = $this->prepare_media_param( $param );
return rest_validate_value_from_schema( $param, $this->media_schema, 'media' );
}
/**
* Decodes guid json and sets parameter defaults.
*
* @param array $param Media parameter.
* @return array
*/
private function prepare_media_param( $param ) {
foreach ( $param as $key => $item ) {
if ( ! empty( $item['guid'] ) ) {
$param[ $key ]['guid'] = json_decode( $item['guid'], true );
}
if ( empty( $param[ $key ]['caption'] ) ) {
$param[ $key ]['caption'] = '';
}
if ( empty( $param[ $key ]['title'] ) ) {
$param[ $key ]['title'] = '';
}
}
return $param;
}
/**
* Retrieves media items from external libraries.
*
* @param \WP_REST_Request $request Full details about the request.
* @return array|\WP_Error|mixed
*/
public function get_external_media( \WP_REST_Request $request ) {
$params = $request->get_params();
$wpcom_path = sprintf( '/meta/external-media/%s', rawurlencode( $params['service'] ) );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
$request->set_query_params( $params );
return rest_do_request( $request );
}
// Build query string to pass to wpcom endpoint.
$service_args = array_filter(
$params,
function ( $key ) {
return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter', 'session_id' ), true );
},
ARRAY_FILTER_USE_KEY
);
if ( ! empty( $service_args ) ) {
$wpcom_path .= '?' . http_build_query( $service_args );
}
$response = Client::wpcom_json_api_request_as_user( $wpcom_path );
switch ( wp_remote_retrieve_response_code( $response ) ) {
case 200:
$response = json_decode( wp_remote_retrieve_body( $response ), true );
break;
case 401:
$response = new WP_Error(
'authorization_required',
__( 'You are not connected to that service.', 'jetpack' ),
array( 'status' => 403 )
);
break;
case 403:
$error = json_decode( wp_remote_retrieve_body( $response ) );
$response = new WP_Error( $error->code, $error->message, $error->data );
break;
default:
if ( is_wp_error( $response ) ) {
$response->add_data( array( 'status' => 400 ) );
break;
}
$response = new WP_Error(
'rest_request_error',
__( 'An unknown error has occurred. Please try again later.', 'jetpack' ),
array( 'status' => wp_remote_retrieve_response_code( $response ) )
);
}
return $response;
}
/**
* Saves an external media item to the media library.
*
* @param \WP_REST_Request $request Full details about the request.
* @return array|\WP_Error|mixed
**/
public function copy_external_media( \WP_REST_Request $request ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$post_id = $request->get_param( 'post_id' );
$should_proxy = $request->get_param( 'should_proxy' );
$service = rawurlencode( $request->get_param( 'service' ) );
$responses = array();
foreach ( $request->get_param( 'media' ) as $item ) {
// Download file to temp dir.
if ( $should_proxy ) {
$wpcom_path = sprintf( '/meta/external-media/proxy/%s', $service );
$wpcom_path .= '?url=' . rawurlencode( $item['guid']['url'] );
$download_url = wp_tempnam();
$response = Client::wpcom_json_api_request_as_user(
$wpcom_path,
'2',
array(
'method' => 'POST',
)
);
if ( is_wp_error( $response ) ) {
$responses[] = $response;
continue;
}
$wp_filesystem = $this->get_wp_filesystem();
$written = $wp_filesystem->put_contents( $download_url, wp_remote_retrieve_body( $response ) );
if ( false === $written ) {
$responses[] = new WP_Error(
'rest_upload_error',
__( 'Could not download media file.', 'jetpack' ),
array( 'status' => 400 )
);
continue;
}
} else {
$download_url = $this->get_download_url( $item['guid'] );
}
if ( is_wp_error( $download_url ) ) {
$responses[] = $download_url;
continue;
}
$id = $this->sideload_media( $item['guid']['name'], $download_url, $post_id );
if ( is_wp_error( $id ) ) {
$responses[] = $id;
continue;
}
$this->update_attachment_meta( $id, $item );
// Add attachment data or WP_Error.
$responses[] = $this->get_attachment_data( $id, $item );
}
return $responses;
}
/**
* Gets connection authorization details.
*
* @param \WP_REST_Request $request Full details about the request.
* @return array|\WP_Error|mixed
*/
public function get_connection_details( \WP_REST_Request $request ) {
$service = $request->get_param( 'service' );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$wpcom_path = sprintf( '/meta/external-media/connection/%s', rawurlencode( $service ) );
$internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
$internal_request->set_query_params( $request->get_params() );
return rest_do_request( $internal_request );
}
$site_id = Manager::get_site_id();
if ( is_wp_error( $site_id ) ) {
return $site_id;
}
$path = sprintf( '/sites/%d/external-services', $site_id );
$response = Client::wpcom_json_api_request_as_user( $path );
if ( is_wp_error( $response ) ) {
return $response;
}
$body = json_decode( wp_remote_retrieve_body( $response ) );
if ( ! property_exists( $body, 'services' ) || ! property_exists( $body->services, $service ) ) {
return new WP_Error(
'bad_request',
__( 'An error occurred. Please try again later.', 'jetpack' ),
array( 'status' => 400 )
);
}
return $body->services->{ $service };
}
/**
* Deletes a Google Photos connection.
*
* @param WP_REST_Request $request Full details about the request.
* @return array|WP_Error|WP_REST_Response
*/
public function delete_connection( WP_REST_Request $request ) {
$service = rawurlencode( $request->get_param( 'service' ) );
$wpcom_path = sprintf( '/meta/external-media/connection/%s', $service );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$internal_request = new WP_REST_Request( 'DELETE', '/' . $this->namespace . $wpcom_path );
$internal_request->set_query_params( $request->get_params() );
return rest_do_request( $internal_request );
}
$response = Client::wpcom_json_api_request_as_user(
$wpcom_path,
'2',
array(
'method' => 'DELETE',
)
);
return json_decode( wp_remote_retrieve_body( $response ), true );
}
/**
* Gets Google Photos Picker enabled Status.
*
* @param \WP_REST_Request $request Full details about the request.
* @return array|\WP_Error|mixed
*/
public function get_picker_status( \WP_REST_Request $request ) {
$service = $request->get_param( 'service' );
$wpcom_path = sprintf( '/meta/external-media/connection/%s/picker_status', rawurlencode( $service ) );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
$internal_request->set_query_params( $request->get_params() );
return rest_do_request( $internal_request );
}
$response = Client::wpcom_json_api_request_as_user(
$wpcom_path,
'2',
array(
'method' => 'GET',
)
);
return json_decode( wp_remote_retrieve_body( $response ), true );
}
/**
* Creates a new session for a service.
*
* @param \WP_REST_Request $request Full details about the request.
* @return array|\WP_Error|mixed
*/
public function create_session( \WP_REST_Request $request ) {
$service = $request->get_param( 'service' );
$wpcom_path = sprintf( '/meta/external-media/session/%s', rawurlencode( $service ) );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$internal_request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
$internal_request->set_query_params( $request->get_params() );
return rest_do_request( $internal_request );
}
$response = Client::wpcom_json_api_request_as_user(
$wpcom_path,
'2',
array(
'method' => 'POST',
)
);
return json_decode( wp_remote_retrieve_body( $response ), true );
}
/**
* Gets a session for a service.
*
* @param \WP_REST_Request $request Full details about the request.
* @return array|\WP_Error|mixed
*/
public function get_session( \WP_REST_Request $request ) {
$service = $request->get_param( 'service' );
$session_id = $request->get_param( 'session_id' );
$wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
$internal_request->set_query_params( $request->get_params() );
return rest_do_request( $internal_request );
}
$response = Client::wpcom_json_api_request_as_user(
$wpcom_path,
'2',
array(
'method' => 'GET',
)
);
return json_decode( wp_remote_retrieve_body( $response ), true );
}
/**
* Deletes a session for a service.
*
* @param \WP_REST_Request $request Full details about the request.
* @return array|\WP_Error|mixed
*/
public function delete_session( \WP_REST_Request $request ) {
$service = $request->get_param( 'service' );
$session_id = $request->get_param( 'session_id' );
$wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$internal_request = new \WP_REST_Request( 'DELETE', '/' . $this->namespace . $wpcom_path );
$internal_request->set_query_params( $request->get_params() );
return rest_do_request( $internal_request );
}
$response = Client::wpcom_json_api_request_as_user(
$wpcom_path,
'2',
array(
'method' => 'DELETE',
)
);
return json_decode( wp_remote_retrieve_body( $response ), true );
}
/**
* Proxies media requests with proper authorization headers
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error|array Response object or WP_Error.
*/
public function proxy_media_request( $request ) {
$params = $request->get_params();
$service = rawurlencode( $request->get_param( 'service' ) );
$wpcom_path = sprintf( '/meta/external-media/proxy/%s', $service );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
$request->set_query_params( $params );
return rest_do_request( $request );
} else {
// Build query string to pass to wpcom endpoint.
$service_args = array_filter(
$params,
function ( $key ) {
return in_array( $key, array( 'url' ), true );
},
ARRAY_FILTER_USE_KEY
);
if ( ! empty( $service_args ) ) {
$wpcom_path .= '?' . http_build_query( $service_args );
}
$response = Client::wpcom_json_api_request_as_user(
$wpcom_path,
'2',
array(
'method' => 'POST',
)
);
$status_code = wp_remote_retrieve_response_code( $response );
$headers = wp_remote_retrieve_headers( $response );
$body = wp_remote_retrieve_body( $response );
// For non-200 responses, parse and return JSON error
if ( $status_code !== 200 ) {
$error_data = json_decode( $body, true );
return new \WP_REST_Response( $error_data, $status_code );
}
}
// Return binary content directly
$valid_headers = array(
'content-type',
'content-length',
'content-disposition',
);
// Set content headers
foreach ( $valid_headers as $header ) {
if ( ! empty( $headers[ $header ] ) ) {
header( ucwords( $header, '-' ) . ': ' . $headers[ $header ] );
}
}
// Set cache headers
header( 'Cache-Control: no-cache, no-store, must-revalidate' );
header( 'Pragma: no-cache' );
header( 'Expires: 0' );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Media binary data
echo $body;
exit( 0 );
}
/**
* Filter callback to provide a shorter file name for google images.
*
* @return string
*/
public function tmp_name() {
return $this->tmp_name;
}
/**
* Returns a download URL, dealing with Google's long file names.
*
* @param array $guid Media information.
* @return string|\WP_Error
*/
public function get_download_url( $guid ) {
$this->tmp_name = $guid['name'];
add_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) );
$download_url = download_url( $guid['url'] );
remove_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) );
if ( is_wp_error( $download_url ) ) {
$download_url->add_data( array( 'status' => 400 ) );
}
return $download_url;
}
/**
* Uploads media file and creates attachment object.
*
* @param string $file_name Name of media file.
* @param string $download_url Download URL.
* @param int $post_id The ID of the post to attach the image to.
*
* @return int|\WP_Error
*/
public function sideload_media( $file_name, $download_url, $post_id = 0 ) {
$file = array(
'name' => wp_basename( $file_name ),
'tmp_name' => $download_url,
);
$id = media_handle_sideload( $file, $post_id, null );
if ( is_wp_error( $id ) ) {
wp_delete_file( $file['tmp_name'] );
$id->add_data( array( 'status' => 400 ) );
}
return $id;
}
/**
* Updates attachment meta data for media item.
*
* @param int $id Attachment ID.
* @param array $item Media item.
*/
public function update_attachment_meta( $id, $item ) {
$meta = wp_get_attachment_metadata( $id );
$meta['image_meta']['title'] = $item['title'];
$meta['image_meta']['caption'] = $item['caption'];
wp_update_attachment_metadata( $id, $meta );
update_post_meta( $id, '_wp_attachment_image_alt', $item['title'] );
wp_update_post(
array(
'ID' => $id,
'post_excerpt' => $item['caption'],
)
);
if ( ! empty( $item['meta'] ) ) {
foreach ( $item['meta'] as $meta_key => $meta_value ) {
update_post_meta( $id, $meta_key, $meta_value );
}
}
}
/**
* Retrieves attachment data for media item.
*
* @param int $id Attachment ID.
* @param array $item Media item.
*
* @return array|\WP_REST_Response Attachment data on success, WP_Error on failure.
*/
public function get_attachment_data( $id, $item ) {
$image_src = wp_get_attachment_image_src( $id, 'full' );
if ( empty( $image_src[0] ) ) {
$response = new WP_Error(
'rest_upload_error',
__( 'Could not retrieve source URL.', 'jetpack' ),
array( 'status' => 400 )
);
} else {
$response = array(
'id' => $id,
'caption' => $item['caption'],
'alt' => $item['title'],
'type' => 'image',
'url' => $image_src[0],
);
}
return $response;
}
/**
* Get the wp filesystem.
*
* @return \WP_Filesystem_Base|null
*/
private function get_wp_filesystem() {
global $wp_filesystem;
if ( ! isset( $wp_filesystem ) ) {
require_once ABSPATH . '/wp-admin/includes/file.php';
WP_Filesystem();
}
return $wp_filesystem;
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_External_Media' );
@@ -0,0 +1,150 @@
<?php
/**
* REST API endpoint for the Jetpack Blogroll block.
*
* @package automattic/jetpack
* @since 12.2
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Status\Visitor;
/**
* Class WPCOM_REST_API_V2_Endpoint_Following
*/
class WPCOM_REST_API_V2_Endpoint_Following extends WP_REST_Controller {
/**
* Namespace prefix.
*
* @var string
*/
public $namespace = 'wpcom/v2';
/**
* Endpoint base route.
*
* @var string
*/
public $rest_base = 'following';
/**
* Constructor.
*/
public function __construct() {
$this->wpcom_is_wpcom_only_endpoint = true;
$this->wpcom_is_site_specific_endpoint = false;
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/mine',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_following' ),
'permission_callback' => 'is_user_logged_in',
'args' => array(
'ignore_user_blogs' => array(
'type' => 'boolean',
),
),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/recommendations',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_recommendations' ),
'permission_callback' => 'is_user_logged_in',
'args' => array(
'number' => array(
'type' => 'number',
'default' => 5,
'validate_callback' => function ( $param ) {
return is_numeric( $param ) && $param <= 20;
},
),
),
),
)
);
}
/**
* Gets the sites the user is following
*
* @param WP_REST_Request $request Full details about the request.
* @return array|WP_Error list of followed sites, WP_Error otherwise
*/
public function get_following( $request ) {
$ignore_user_blogs = $request->get_param( 'ignore_user_blogs' );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
require_lib( 'wpcom-get-user-followed-blogs' );
return get_user_followed_blogs( get_current_user_id(), $ignore_user_blogs );
}
$body = Client::wpcom_json_api_request_as_user(
sprintf( '/me/following%s', $ignore_user_blogs ? '?ignore_user_blogs=true' : '' ),
'2',
array(
'method' => 'GET',
'headers' => array(
'Content-Type' => 'application/json',
'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
),
)
);
if ( is_wp_error( $body ) ) {
return $body;
}
return json_decode( wp_remote_retrieve_body( $body ) );
}
/**
* Gets recommended sites for user
*
* @param WP_REST_Request $request Full details about the request.
* @return array|WP_Error list of following recommendations, WP_Error otherwise
*/
public function get_recommendations( $request ) {
$number_of_recommendations = $request->get_param( 'number' );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
require_lib( 'wpcom-get-user-followed-blogs' );
return get_user_following_recommendations( get_current_user_id(), $number_of_recommendations );
}
$body = Client::wpcom_json_api_request_as_user(
sprintf( '/me/following/recommendations?number=%d', $number_of_recommendations ),
'2',
array(
'method' => 'GET',
'headers' => array(
'Content-Type' => 'application/json',
'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
),
)
);
if ( is_wp_error( $body ) ) {
return $body;
}
return json_decode( wp_remote_retrieve_body( $body ) );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Following' );
@@ -0,0 +1,78 @@
<?php
/**
* Get the User ID of a Goodreads account using its Author ID.
*
* @package automattic/jetpack
*/
/**
* Goodreads block endpoint.
*/
class WPCOM_REST_API_V2_Endpoint_Goodreads extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register endpoint route.
*/
public function register_routes() {
register_rest_route(
'wpcom/v2',
'/goodreads/user-id',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_goodreads_user_id' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'id' => array(
'description' => __( 'Goodreads user ID', 'jetpack' ),
'type' => 'integer',
'required' => true,
'minimum' => 1,
'validate_callback' => function ( $param ) {
return is_numeric( $param ) && (int) $param > 0;
},
),
),
),
)
);
}
/**
* Get the user ID from the author ID.
*
* @param \WP_REST_Request $request request object.
*
* @return \WP_Error|int Goodreads user ID (or 404 error if not found).
*/
public function get_goodreads_user_id( $request ) {
$profile_id = $request->get_param( 'id' );
$url = 'https://www.goodreads.com/author/show/' . $profile_id;
$response = wp_remote_get( esc_url_raw( $url ) );
$not_found = new WP_Error( 'not_found', 'Goodreads user not found.', array( 'status' => 404 ) );
if ( is_wp_error( $response ) ) {
return $response;
}
$body = wp_remote_retrieve_body( $response );
$pattern = '/goodreads\.com\/user\/updates_rss\/(\d+)/';
if ( preg_match( $pattern, $body, $matches ) ) {
$user_id = intval( $matches[1] );
return $user_id;
}
return $not_found;
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Goodreads' );
@@ -0,0 +1,62 @@
<?php
/**
* Validate whether a google doc is available for embedding.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Extensions\GoogleDocsEmbed;
/**
* Google Docs block endpoint.
*/
class WPCOM_REST_API_V2_Endpoint_Google_Docs extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register endpoint route.
*/
public function register_routes() {
register_rest_route(
'wpcom/v2',
'/checkGoogleDocVisibility',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'check_document_visibility' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
),
)
);
}
/**
* Check URL
*
* @param \WP_REST_Request $request request object.
*
* @return \WP_REST_Response|\WP_Error
*/
public function check_document_visibility( $request ) {
$document_url = $request->get_param( 'url' );
$document_url = GoogleDocsEmbed\map_gsuite_url( $document_url );
$response_head = wp_safe_remote_head( $document_url );
$is_public_document = ! is_wp_error( $response_head ) && ! empty( $response_head['response']['code'] ) && 200 === absint( $response_head['response']['code'] );
if ( ! $is_public_document ) {
return new \WP_Error( 'Unauthorized', esc_html__( 'The document is not publicly accessible', 'jetpack' ), array( 'status' => 401 ) );
}
return new \WP_REST_Response( '', 200 );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Google_Docs' );
@@ -0,0 +1,171 @@
<?php
/**
* REST API endpoint for the Instagram connections.
*
* @package automattic/jetpack
* @since 8.5.0
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager;
/**
* Instagram connections helper API.
*
* @since 8.5
*/
class WPCOM_REST_API_V2_Endpoint_Instagram_Gallery extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'instagram-gallery';
$this->is_wpcom = false;
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$this->is_wpcom = true;
if ( ! class_exists( 'WPCOM_Instagram_Gallery_Helper' ) ) {
\require_lib( 'instagram-gallery-helper' );
}
}
if ( ! class_exists( 'Jetpack_Instagram_Gallery_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-instagram-gallery-helper.php';
}
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register the route.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/connect-url',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_instagram_connect_url' ),
'permission_callback' => '__return_true',
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/connections',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_instagram_connections' ),
'permission_callback' => 'is_user_logged_in',
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/gallery',
array(
'args' => array(
'access_token' => array(
'description' => __( 'An Instagram Keyring access token.', 'jetpack' ),
'type' => 'integer',
'required' => true,
'minimum' => 1,
'validate_callback' => function ( $param ) {
return is_numeric( $param ) && (int) $param > 0;
},
),
'count' => array(
'description' => __( 'How many Instagram posts?', 'jetpack' ),
'type' => 'integer',
'required' => true,
'minimum' => 1,
'validate_callback' => function ( $param ) {
return is_numeric( $param ) && (int) $param > 0;
},
),
),
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_instagram_gallery' ),
'permission_callback' => '__return_true',
)
);
}
/**
* Get the Instagram connect URL.
*
* @return mixed
*/
public function get_instagram_connect_url() {
if ( $this->is_wpcom ) {
return WPCOM_Instagram_Gallery_Helper::get_connect_url();
}
$site_id = Manager::get_site_id();
if ( is_wp_error( $site_id ) ) {
return $site_id;
}
$path = sprintf( '/sites/%d/external-services', $site_id );
$response = Client::wpcom_json_api_request_as_user( $path );
if ( is_wp_error( $response ) ) {
return $response;
}
$body = json_decode( wp_remote_retrieve_body( $response ) );
if ( ! property_exists( $body, 'services' ) || ! property_exists( $body->services, 'instagram-basic-display' ) ) {
return new WP_Error(
'bad_request',
__( 'An error occurred. Please try again later.', 'jetpack' ),
array( 'status' => 400 )
);
}
return $body->services->{ 'instagram-basic-display' }->connect_URL;
}
/**
* Get a list of stored Instagram connections for the current user.
*
* @return mixed
*/
public function get_instagram_connections() {
if ( $this->is_wpcom ) {
return WPCOM_Instagram_Gallery_Helper::get_connections();
}
$response = Client::wpcom_json_api_request_as_user( '/me/connections' );
if ( is_wp_error( $response ) ) {
return $response;
}
$body = json_decode( wp_remote_retrieve_body( $response ) );
$connections = array();
if ( isset( $body->connections ) && is_array( $body->connections ) ) {
foreach ( $body->connections as $connection ) {
if ( 'instagram-basic-display' === $connection->service && 'ok' === $connection->status ) {
$connections[] = array(
'token' => (string) $connection->ID,
'username' => $connection->external_name,
);
}
}
}
return $connections;
}
/**
* Get the Instagram Gallery.
*
* @param WP_REST_Request $request The request.
* @return mixed
*/
public function get_instagram_gallery( $request ) {
return Jetpack_Instagram_Gallery_Helper::get_instagram_gallery( $request['access_token'], $request['count'] );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Instagram_Gallery' );
@@ -0,0 +1,142 @@
<?php
/**
* API endpoints to interact with WordPress.com
* to get info from the Mailchimp API for use with the Mailchimp block.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Redirect;
/**
* 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 {
/**
* Constructor.
*/
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' ),
'permission_callback' => '__return_true',
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/groups',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_mailchimp_groups' ),
'permission_callback' => '__return_true',
),
)
);
}
/**
* 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'] ) && isset( $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 = Redirect::get_url(
'calypso-marketing-connections',
array(
'site' => rawurlencode( $site_id ),
'query' => 'mailchimp',
)
);
return array(
'code' => $this->is_connected() ? 'connected' : 'not_connected',
'connect_url' => $connect_url,
'site_id' => $site_id,
);
}
/**
* Get all Mailchimp groups for the accounted connected to the current blog
*
* @return mixed
* groups:array
* site_id:int
*/
public function get_mailchimp_groups() {
$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
);
}
// Do not attempt to fetch groups if Mailchimp is not connected.
if ( ! $this->is_connected() ) {
return new WP_Error(
'mailchimp_not_connected',
__( 'Your site is not connected to Mailchimp yet.', 'jetpack' ),
403
);
}
$path = sprintf( '/sites/%d/mailchimp/groups', absint( $site_id ) );
$request = Client::wpcom_json_api_request_as_blog( $path );
$body = wp_remote_retrieve_body( $request );
return json_decode( $body );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Mailchimp' );
@@ -0,0 +1,88 @@
<?php
/**
* REST API endpoint for the Newsletter Categories
*
* @package automattic/jetpack
* @since 12.6
*/
use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
use Automattic\Jetpack\Status\Host;
/**
* Class WPCOM_REST_API_V2_Endpoint_Following
*/
class WPCOM_REST_API_V2_Endpoint_Newsletter_Categories_List extends WP_REST_Controller {
use WPCOM_REST_API_Proxy_Request;
/**
* Constructor.
*/
public function __construct() {
$this->wpcom_is_wpcom_only_endpoint = true;
$this->wpcom_is_site_specific_endpoint = true;
$this->base_api_path = 'wpcom';
$this->version = 'v2';
$this->namespace = $this->base_api_path . '/' . $this->version;
$this->rest_base = '/newsletter-categories';
$this->wpcom_is_wpcom_only_endpoint = true;
$this->wpcom_is_site_specific_endpoint = true;
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register routes.
*/
public function register_routes() {
$options = array(
'show_in_index' => true,
'methods' => 'GET',
// if this is not a wpcom site, we need to proxy the request to wpcom
'callback' => ( ( new Host() )->is_wpcom_simple() ) ? array(
$this,
'get_newsletter_categories',
) : array( $this, 'proxy_request_to_wpcom_as_user' ),
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
);
register_rest_route(
$this->namespace,
$this->rest_base,
$options
);
}
/**
* Gets the site's newsletter categories
*
* @return array|WP_Error list of newsletter categories
*/
public function get_newsletter_categories() {
require_lib( 'newsletter-categories' );
$newsletter_categories = \Newsletter_Categories\get_newsletter_categories();
// Include subscription counts for each category if the user can manage categories.
if ( $this->can_manage_categories() === true ) {
$subscription_counts_per_category = \Newsletter_Categories\get_blog_subscription_counts_per_category();
array_walk(
$newsletter_categories,
function ( &$category ) use ( $subscription_counts_per_category ) {
$category['subscription_count'] = $subscription_counts_per_category[ $category['id'] ] ? $subscription_counts_per_category[ $category['id'] ] : 0;
}
);
}
return rest_ensure_response(
array(
'enabled' => (bool) get_option( 'wpcom_newsletter_categories_enabled', false ),
'newsletter_categories' => $newsletter_categories,
)
);
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Newsletter_Categories_List' );
@@ -0,0 +1,88 @@
<?php
/**
* REST API endpoint for the Newsletter Categories
*
* @package automattic/jetpack
* @since 12.6
*/
use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
use Automattic\Jetpack\Status\Host;
/**
* Class WPCOM_REST_API_V2_Endpoint_Newsletter_Categories_Subscriptions_Count
*/
class WPCOM_REST_API_V2_Endpoint_Newsletter_Categories_Subscriptions_Count extends WP_REST_Controller {
use WPCOM_REST_API_Proxy_Request;
/**
* Constructor.
*/
public function __construct() {
$this->wpcom_is_wpcom_only_endpoint = true;
$this->wpcom_is_site_specific_endpoint = true;
$this->base_api_path = 'wpcom';
$this->version = 'v2';
$this->namespace = $this->base_api_path . '/' . $this->version;
$this->rest_base = '/newsletter-categories/count';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register routes.
*/
public function register_routes() {
$options = array(
'show_in_index' => true,
'methods' => 'GET',
// if this is not a wpcom site, we need to proxy the request to wpcom
'callback' => ( ( new Host() )->is_wpcom_simple() ) ? array(
$this,
'get_newsletter_categories_subscriptions_count',
) : array( $this, 'proxy_request_to_wpcom_as_user' ),
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'args' => array(
'term_ids' => array(
'required' => false,
'validate_callback' => function ( $param ) {
return empty( $param ) || ( is_string( $param ) && preg_match( '/^(\d+,)*\d+$/', $param ) );
},
'default' => '',
),
),
);
register_rest_route(
$this->namespace,
$this->rest_base,
$options
);
}
/**
* Get the subscriptions count for the given categories.
*
* @param WP_REST_Request $request Request object.
*
* @return WP_REST_Response
*/
public function get_newsletter_categories_subscriptions_count( WP_REST_Request $request ) {
require_lib( 'newsletter-categories' );
$blog_id = get_current_blog_id();
$term_ids = explode( ',', $request->get_param( 'term_ids' ) );
$subscriptions_count = \Newsletter_Categories\get_blog_subscriptions_aggregate_count( $blog_id, $term_ids );
return rest_ensure_response(
array(
'subscriptions_count' => $subscriptions_count,
)
);
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Newsletter_Categories_Subscriptions_Count' );
@@ -0,0 +1,162 @@
<?php
/**
* Podcast Player API
*
* @package automattic/jetpack
* @since 8.4.0
*/
/**
* Fetch podcast feeds and parse data for the Podcast Player block.
*
* @since 8.4.0
*/
class WPCOM_REST_API_V2_Endpoint_Podcast_Player extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
if ( ! class_exists( 'Jetpack_Podcast_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-podcast-helper.php';
}
$this->namespace = 'wpcom/v2';
$this->rest_base = 'podcast-player';
// This endpoint *does not* need to connect directly to Jetpack sites.
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register the route.
*/
public function register_routes() {
// GET /sites/<blog_id>/podcast-player - Returns feed data.
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_player_data' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'url' => array(
'description' => __( 'The Podcast RSS feed URL.', 'jetpack' ),
'type' => 'string',
'required' => 'true',
'validate_callback' => function ( $param ) {
return wp_http_validate_url( $param );
},
),
'guids' => array(
'description' => __( 'A list of unique identifiers for fetching specific podcast episodes.', 'jetpack' ),
'type' => 'array',
'required' => 'false',
'validate_callback' => function ( $guids ) {
return is_array( $guids );
},
'sanitize_callback' => function ( $guids ) {
return array_map( 'sanitize_text_field', $guids );
},
),
'episode-options' => array(
'description' => __( 'Whether we should return the episodes list for use in the selection UI', 'jetpack' ),
'type' => 'boolean',
'required' => 'false',
),
),
'schema' => array( $this, 'get_public_item_schema' ),
),
)
);
// GET /sites/<blog_id>/podcast-player/track-quantity - Returns number of tracks.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/track-quantity',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_tracks_quantity' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
),
)
);
}
/**
* Retrieves tracks quantity
*
* @return Integer number of tracks.
* */
public function get_tracks_quantity() {
return rest_ensure_response( Jetpack_Podcast_Helper::get_tracks_quantity() );
}
/**
* Retrieves data needed to display a podcast player from RSS feed.
*
* @param WP_REST_Request $request The REST API request data.
* @return WP_REST_Response The REST API response.
*/
public function get_player_data( $request ) {
$helper = new Jetpack_Podcast_Helper( $request['url'] );
$args = array();
if ( isset( $request['guids'] ) ) {
$args['guids'] = $request['guids'];
}
if ( isset( $request['episode-options'] ) && $request['episode-options'] ) {
$args['episode-options'] = true;
}
$player_data = $helper->get_player_data( $args );
if ( is_wp_error( $player_data ) ) {
return rest_ensure_response( $player_data );
}
$player_data = $this->prepare_for_response( $player_data, $request );
return rest_ensure_response( $player_data );
}
/**
* Filters out data based on ?_fields= request parameter
*
* @param array $player_data Data for the player.
* @param WP_REST_Request $request The request.
* @return array filtered $player_data
*/
public function prepare_for_response( $player_data, $request ) {
if ( ! is_callable( array( $this, 'get_fields_for_response' ) ) ) {
return $player_data;
}
$fields = $this->get_fields_for_response( $request );
$response_data = array();
foreach ( $player_data as $field => $value ) {
if ( in_array( $field, $fields, true ) ) {
$response_data[ $field ] = $value;
}
}
return $response_data;
}
/**
* Retrieves the response schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
return Jetpack_Podcast_Helper::get_player_data_schema();
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Podcast_Player' );
@@ -0,0 +1,72 @@
<?php
/**
* REST API endpoint for user profile.
*
* @package automattic/jetpack
*/
/**
* Class WPCOM_REST_API_V2_Endpoint_Profile
*/
class WPCOM_REST_API_V2_Endpoint_Profile extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'profile';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
)
);
}
/**
* Checks if a given request has access to user profile.
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
*/
public function get_item_permissions_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! current_user_can( 'read' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to view your user profile on this site.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Retrieves the user profile.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_item( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return rest_ensure_response(
array(
'admin_color' => get_user_option( 'admin_color' ),
'locale' => get_user_locale(),
)
);
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Profile' );
@@ -0,0 +1,184 @@
<?php
/**
* Publicize: Share post
*
* This file is synced from the Jetpack monorepo to WPCOM.
*
* @package automattic/jetpack
* @since
*/
use Automattic\Jetpack\Connection\Client;
require_once __DIR__ . '/publicize-connections.php';
/**
* Publicize: Share post class.
*/
class WPCOM_REST_API_V2_Endpoint_Publicize_Share_Post extends WP_REST_Controller {
/**
* The constructor sets the route namespace, rest_base, and registers our API route and endpoint.
* Additionally, we check if we're executing this file on WPCOM or Jetpack.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
// $wpcom_is_wpcom_only_endpoint = true keeps WPCOM from trying to loop back to the Jetpack endpoint.
$this->wpcom_is_wpcom_only_endpoint = true;
// Determine if this endpoint is running on WPCOM or not.
$this->is_wpcom = false;
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$this->is_wpcom = true;
}
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* This file is synced from Jetpack to WPCOM and this method creates a slightly different route for both sites.
* Jetpack route: http://{$site}/wp-json/wpcom/v2/posts/{$postId}/publicize
* WPCOM route: https://public-api.wordpress.com/wpcom/v2/sites/{$siteId}/posts/{$postId}/publicize
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/posts/(?P<postId>\d+)/publicize',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'share_post' ),
'permission_callback' => array( $this, 'permissions_check' ),
'args' => array(
'message' => array(
'description' => __( 'The message to share.', 'jetpack' ),
'type' => 'string',
'required' => true,
'validate_callback' => function ( $param ) {
return is_string( $param );
},
'sanitize_callback' => 'sanitize_textarea_field',
),
'skipped_connections' => array(
'description' => __( 'Array of external connection IDs to skip sharing.', 'jetpack' ),
'type' => 'array',
'required' => false,
'validate_callback' => function ( $param ) {
return is_array( $param );
},
'sanitize_callback' => function ( $param ) {
return array_map( 'absint', $param );
},
),
'async' => array(
'description' => __( 'Whether to share the post asynchronously.', 'jetpack' ),
'type' => 'boolean',
'default' => false,
),
),
),
// override = true because this API route was commandeered from the file
// wp-content/rest-api-plugins/endpoints/sites-publicize.php on WPCOM.
true
);
}
/**
* Ensure the user has proper tokens and permissions to publish posts on this blog.
*
* @return WP_Error|boolean
*/
public function permissions_check() {
if ( ! get_current_user_id() ) {
return new WP_Error(
'rest_cannot_view',
__( 'Sorry, you cannot view this resource without a valid token for this blog.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
if ( ! current_user_can( 'publish_posts' ) ) {
return new WP_Error( 'unauthorized', 'Your token must have permission to publish posts.', array( 'status' => 401 ) );
}
return true;
}
/**
* If this method callback is executed on WPCOM, we share the post using republicize_post(). If this method callback
* is executed on a Jetpack site, we make an API call to WPCOM using wpcom_json_api_request_as_user() and return
* the results. In both cases, this file and method are executed, as this file is synced from Jetpack to WPCOM.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|array The publicize results, including two arrays: `results` and `errors`
*/
public function share_post( $request ) {
$post_id = $request->get_param( 'postId' );
$message = trim( $request->get_param( 'message' ) );
$skip_connection_ids = $request->get_param( 'skipped_connections' );
$async = (bool) $request->get_param( 'async' );
if ( $this->is_wpcom ) {
$post = get_post( $post_id );
if ( empty( $post ) ) {
return new WP_Error( 'not_found', 'Cannot find that post', array( 'status' => 404 ) );
}
if ( 'publish' !== $post->post_status ) {
return new WP_Error( 'not_published', 'Cannot share an unpublished post', array( 'status' => 400 ) );
}
$publicize = publicize_init();
$result = $publicize->republicize_post( (int) $post_id, $message, $skip_connection_ids, true, ! $async, get_current_user_id() );
if ( false === $result ) {
return new WP_Error( 'not_found', 'Cannot find that post', array( 'status' => 404 ) );
}
return $result;
} else {
$response = $this->proxy_request( $post_id, $message, $skip_connection_ids, $async );
if ( is_wp_error( $response ) ) {
return rest_ensure_response( $response );
}
return json_decode( wp_remote_retrieve_body( $response ), true );
}
}
/**
* Passes the request on to the WPCOM endpoint, and returns the result.
*
* @param int $post_id The post ID being shared.
* @param string $message The custom message to be used.
* @param array $skip_connection_ids An array of connection IDs where the post shouldn't be shared.
* @param bool $async Whether to share the post asynchronously.
*
* @return array|WP_Error $response Response data, else WP_Error on failure.
*/
public function proxy_request( $post_id, $message, $skip_connection_ids, $async = false ) {
/*
* Publicize endpoint on WPCOM:
* [POST] wpcom/v2/sites/{$siteId}/posts/{$postId}/publicize
* body:
* - message: string
* - skipped_connections: array of connection ids to skip
*/
$url = sprintf(
'/sites/%d/posts/%d/publicize',
Jetpack_Options::get_option( 'id' ),
$post_id
);
return Client::wpcom_json_api_request_as_user(
$url,
'v2',
array(
'method' => 'POST',
),
array(
'message' => $message,
'skipped_connections' => $skip_connection_ids,
'async' => $async,
)
);
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Publicize_Share_Post' );
@@ -0,0 +1,134 @@
<?php
/**
* Publicize: Share status
*
* This file is synced from the Jetpack monorepo to WPCOM.
*
* @package automattic/jetpack
* @since
*/
use Automattic\Jetpack\Connection\Client;
/**
* Publicize: Share post class.
*/
class WPCOM_REST_API_V2_Endpoint_Publicize_Share_Status extends WP_REST_Controller {
const SOCIAL_SHARES_POST_META_KEY = '_publicize_shares';
/**
* The constructor sets the route namespace, rest_base, and registers our API route and endpoint.
* Additionally, we check if we're executing this file on WPCOM or Jetpack.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
// $wpcom_is_wpcom_only_endpoint = true keeps WPCOM from trying to loop back to the Jetpack endpoint.
$this->wpcom_is_wpcom_only_endpoint = true;
// Determine if this endpoint is running on WPCOM or not.
$this->is_wpcom = false;
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$this->is_wpcom = true;
}
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* This file is synced from Jetpack to WPCOM and this method creates a slightly different route for both sites.
* Jetpack route: http://{$site}/wp-json/wpcom/v2/publicize/share-status/{$postId}
* WPCOM route: https://public-api.wordpress.com/wpcom/v2/sites/{$siteId}/publicize/share-status/{$postId}
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/publicize/share-status/(?P<post_id>\d+)',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_post_share_status' ),
'permission_callback' => array( $this, 'permissions_check' ),
),
true
);
}
/**
* Ensure the user has permissions to publish posts on this blog.
*
* @return WP_Error|boolean
*/
public function permissions_check() {
return current_user_can( 'publish_posts' );
}
/**
* If this method callback is executed on WPCOM, we share the post using republicize_post(). If this method callback
* is executed on a Jetpack site, we make an API call to WPCOM using wpcom_json_api_request_as_user() and return
* the results. In both cases, this file and method are executed, as this file is synced from Jetpack to WPCOM.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|array The share status.
*/
public function get_post_share_status( $request ) {
$post_id = $request->get_param( 'post_id' );
if ( $this->is_wpcom ) {
$post = get_post( $post_id );
if ( empty( $post ) ) {
return new WP_Error( 'not_found', 'Cannot find that post', array( 'status' => 404 ) );
}
if ( 'publish' !== $post->post_status ) {
return new WP_Error( 'not_published', 'Cannot get share status for an unpublished post', array( 'status' => 400 ) );
}
// Not passing the third argument as `true`.
$shares = get_post_meta( $post_id, self::SOCIAL_SHARES_POST_META_KEY );
$done = metadata_exists( 'post', $post_id, self::SOCIAL_SHARES_POST_META_KEY );
return array(
'shares' => $done ? $shares : array(),
'done' => $done,
);
} else {
$response = $this->proxy_request( $post_id );
if ( is_wp_error( $response ) ) {
return rest_ensure_response( $response );
}
return json_decode( wp_remote_retrieve_body( $response ), true );
}
}
/**
* Passes the request on to the WPCOM endpoint, and returns the result.
*
* @param int $post_id The post ID.
*
* @return array|WP_Error $response Response data, else WP_Error on failure.
*/
public function proxy_request( $post_id ) {
/*
* Publicize endpoint on WPCOM:
* [POST] wpcom/v2/sites/{$siteId}/publicize/share-status/{$postId}
*/
$url = sprintf(
'/sites/%d/publicize/share-status/%d',
Jetpack_Options::get_option( 'id' ),
$post_id
);
return Client::wpcom_json_api_request_as_user(
$url,
'v2',
array(
'method' => 'GET',
)
);
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Publicize_Share_Status' );
@@ -0,0 +1,186 @@
<?php
/**
* REST API endpoint for Related Posts
*
* @package automattic/jetpack
* @since 12.6
*/
/**
* Class WPCOM_REST_API_V2_Endpoint_Related_Posts
*/
class WPCOM_REST_API_V2_Endpoint_Related_Posts extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
$this->base_api_path = 'wpcom';
$this->version = 'v2';
$this->namespace = $this->base_api_path . '/' . $this->version;
$this->rest_base = '/related-posts';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base,
array(
'show_in_index' => true,
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_options' ),
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/enable',
array(
'show_in_index' => true,
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'enable_rp' ),
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/(?P<id>[\d]+)',
array(
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the post.', 'jetpack' ),
'type' => 'integer',
),
),
array(
'show_in_index' => true,
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_related_posts' ),
'permission_callback' => array( $this, 'get_related_posts_permissions_check' ),
),
)
);
}
/**
* Gets the site's Related Posts Options.
*
* @return WP_REST_Response Array of information about the site's Related Posts options.
* - enabled: Whether Related Posts is enabled.
* - options: Array of options for Related Posts.
*/
public function get_options() {
$options = Jetpack_Options::get_option( 'relatedposts', array() );
$enabled = isset( $options['enabled'] ) ? (bool) $options['enabled'] : false;
return rest_ensure_response(
array(
'enabled' => $enabled,
'options' => $options,
)
);
}
/**
* Enables the site's Related Posts.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response Array confirming the new status.
*/
public function enable_rp( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$old_relatedposts_options = Jetpack_Options::get_option( 'relatedposts', array() );
$relatedposts_options_to_save = $old_relatedposts_options;
$relatedposts_options_to_save['enabled'] = true;
// Enable Related Posts.
$enable = Jetpack_Options::update_option( 'relatedposts', $relatedposts_options_to_save );
return rest_ensure_response(
array(
'enabled' => (bool) $enable,
)
);
}
/**
* Checks if a given request has access to get the related posts.
*
* @since 4.7.0
*
* @param WP_REST_Request $request Full details about the request.
* @return bool|WP_Error True if the request has read access for the related posts, WP_Error object or false otherwise.
*/
public function get_related_posts_permissions_check( $request ) {
$post = $this->get_post( $request['id'] );
if ( is_wp_error( $post ) ) {
return $post;
}
if ( ! current_user_can( 'edit_post', $post->ID ) ) {
return new WP_Error(
'rest_forbidden_context',
__( 'Sorry, you are not allowed to get the related post.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Get the related posts
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response Array The related posts
*/
public function get_related_posts( $request ) {
$post = $this->get_post( $request['id'] );
if ( is_wp_error( $post ) ) {
return $post;
}
if ( ! class_exists( 'Jetpack_RelatedPosts' ) ) {
require_once JETPACK__PLUGIN_DIR . 'modules/related-posts/jetpack-related-posts.php';
}
$related_posts = \Jetpack_RelatedPosts::init()->get_for_post_id( $post->ID, array( 'size' => 6 ) );
return rest_ensure_response( $related_posts );
}
/**
* Gets the post, if the ID is valid.
*
* @param int $id Supplied ID.
* @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise.
*/
public function get_post( $id ) {
$error = new WP_Error(
'rest_post_invalid_id',
__( 'Invalid post ID.', 'jetpack' ),
array( 'status' => 404 )
);
if ( (int) $id <= 0 ) {
return $error;
}
$post = get_post( (int) $id );
if ( empty( $post ) || empty( $post->ID ) ) {
return $error;
}
return $post;
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Related_Posts' );
@@ -0,0 +1,136 @@
<?php
/**
* REST API endpoint for resolving URL redirects.
*
* @package automattic/jetpack
* @since 8.0.0
*/
/**
* Resolve URL redirects.
*
* @since 8.0.0
*/
class WPCOM_REST_API_V2_Endpoint_Resolve_Redirect extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'resolve-redirect';
// This endpoint *does not* need to connect directly to Jetpack sites.
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register the route.
*/
public function register_routes() {
// GET /sites/<blog_id>/resolve-redirect/<url> - Follow 301/302 redirects on a URL, and return the final destination.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/?(?P<url>.+)?',
array(
'args' => array(
'url' => array(
'description' => __( 'The URL to check for redirects.', 'jetpack' ),
'type' => 'string',
'required' => 'true',
'validate_callback' => function ( $param ) {
return wp_http_validate_url( $param );
},
),
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'follow_redirect' ),
'permission_callback' => 'is_user_logged_in',
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Follows 301/302 redirect for the passed URL, and returns the final destination and status code.
*
* @param WP_REST_Request $request The REST API request data.
* @return WP_REST_Response The REST API response.
*/
public function follow_redirect( $request ) {
// Add a User-Agent header since the request is sometimes blocked without it.
$response = wp_safe_remote_get(
$request['url'],
array(
'headers' => array(
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:71.0) Gecko/20100101 Firefox/71.0',
),
)
);
if ( is_wp_error( $response ) ) {
return rest_ensure_response(
array(
'url' => '',
'status' => $response->get_error_code(),
)
);
}
return rest_ensure_response(
array(
'url' => $this->get_response_url( $response['http_response']->get_response_object() ),
'status' => wp_remote_retrieve_response_code( $response ),
)
);
}
/**
* Retrieves the response schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'resolve-redirect',
'type' => 'object',
'properties' => array(
'url' => array(
'description' => __( 'The final destination of the URL being checked for redirects.', 'jetpack' ),
'type' => 'string',
),
'status' => array(
'description' => __( 'The status code of the URL\'s response.', 'jetpack' ),
'type' => 'integer',
),
),
);
return $schema;
}
/**
* Finds the destination url from an http response.
*
* @todo Add WpOrg\Requests\Response type hint to method when wpcom picks up the new Requests lib (it seems it was skipped during their update to 6.2).
*
* @param \WpOrg\Requests\Response $response Response object.
* @return string Final url of the response.
*/
protected function get_response_url( $response ) {
$history = $response->history;
if ( ! $history ) {
return $response->url;
}
$location = $history[0]->headers->getValues( 'location' );
if ( ! $location ) {
return $response->url;
}
return $location[0];
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Resolve_Redirect' );
@@ -0,0 +1,51 @@
<?php
/**
* Proxy endpoint for Jetpack Search
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Search\REST_Controller;
/**
* Jetpack Search: Makes authenticated requests to the site search API using blog tokens.
* This endpoint will only be used when trying to search private Jetpack and WordPress.com sites.
*
* @since 9.0.0
*/
class WPCOM_REST_API_V2_Endpoint_Search extends WP_REST_Controller {
/**
* Forward request to controller in Search package.
*
* @var REST_Controller
*/
protected $controller;
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'search';
$this->controller = new REST_Controller( defined( 'IS_WPCOM' ) && IS_WPCOM );
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(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this->controller, 'get_search_results' ),
'permission_callback' => 'is_user_logged_in',
)
);
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Search' );
@@ -0,0 +1,140 @@
<?php
/**
* Handles the sending of email previews via the WordPress.com REST API.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Manager;
use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
use Automattic\Jetpack\Status\Host;
/**
* Class WPCOM_REST_API_V2_Endpoint_Send_Email_Preview
* Handles the sending of email previews via the WordPress.com REST API
*/
class WPCOM_REST_API_V2_Endpoint_Send_Email_Preview extends WP_REST_Controller {
use WPCOM_REST_API_Proxy_Request;
/**
* Constructor.
*/
public function __construct() {
$this->base_api_path = 'wpcom';
$this->version = 'v2';
$this->namespace = $this->base_api_path . '/' . $this->version;
$this->rest_base = '/send-email-preview';
$this->wpcom_is_wpcom_only_endpoint = true;
$this->wpcom_is_site_specific_endpoint = true;
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Registers the routes for blogging prompts.
*
* @see register_rest_route()
*/
public function register_routes() {
$options = array(
'show_in_index' => true,
'methods' => 'POST',
// if this is not a wpcom site, we need to proxy the request to wpcom
'callback' => ( ( new Host() )->is_wpcom_simple() ) ? array(
$this,
'send_email_preview',
) : array( $this, 'proxy_request_to_wpcom_as_user' ),
'permission_callback' => array( $this, 'permissions_check' ),
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the post.', 'jetpack' ),
'type' => 'integer',
),
),
);
register_rest_route(
$this->namespace,
$this->rest_base,
$options
);
}
/**
* Checks if the user is connected and has access to edit the post
*
* @param WP_REST_Request $request Full data about the request.
*
* @return true|WP_Error True if the request has edit access, WP_Error object otherwise.
*/
public function permissions_check( $request ) {
if ( ! ( new Host() )->is_wpcom_simple() ) {
if ( ! ( new Manager() )->is_user_connected() ) {
return new WP_Error(
'rest_cannot_send_email_preview',
__( 'Please connect your user account to WordPress.com', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
}
$post = get_post( $request->get_param( 'id' ) );
if ( is_wp_error( $post ) ) {
return $post;
}
if ( $post && ! current_user_can( 'edit_post', $post->ID ) ) {
return new WP_Error(
'rest_forbidden_context',
__( 'Please connect your user account to WordPress.com', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Sends an email preview of a post to the current user.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function send_email_preview( $request ) {
$post_id = $request['id'];
$post = get_post( $post_id );
// Return error if the post cannot be retrieved
if ( is_wp_error( $post ) ) {
return $post;
}
// Check if the user's email is verified
if ( Email_Verification::is_email_unverified() ) {
return new WP_Error( 'unverified', __( 'Your email address must be verified.', 'jetpack' ), array( 'status' => rest_authorization_required_code() ) );
}
$current_user = wp_get_current_user();
$email = $current_user->user_email;
// Try to create a new subscriber with the user's email
$subscriber = Blog_Subscriber::create( $email );
if ( ! $subscriber ) {
return new WP_Error( 'unverified', __( 'Could not create subscriber.', 'jetpack' ), array( 'status' => rest_authorization_required_code() ) );
}
// Send the post to the subscriber
require_once ABSPATH . 'wp-content/mu-plugins/email-subscriptions/subscription-mailer.php';
$mailer = new Subscription_Mailer( $subscriber );
$subscription = $subscriber->get_subscription( get_current_blog_id() );
$mailer->send_post( $post, $subscription );
// Return a response
return new WP_REST_Response( 'Email preview sent successfully.', 200 );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Send_Email_Preview' );
@@ -0,0 +1,119 @@
<?php
/**
* REST API endpoint for resolving template.
*
* @package automattic/jetpack
*/
/**
* Returns the correct template for the site's page based on the template type
*/
class WPCOM_REST_API_V2_Endpoint_Template_Loader extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'template-loader';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Called automatically on `rest_api_init()`.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
sprintf(
'/%s/(?P<template_type>\w+)',
$this->rest_base
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'permissions_check' ),
'args' => array(
'template_type' => array(
'description' => __( 'The type of the template.', 'jetpack' ),
'type' => 'string',
'required' => true,
'validate_callback' => array( $this, 'validate_template_type' ),
),
),
)
);
}
/**
* Checks if the user has permissions to make the request.
*
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function permissions_check() {
// Verify if the current user has edit_theme_options capability.
// This capability is required to edit/view/delete templates.
if ( ! current_user_can( 'edit_theme_options' ) ) {
return new WP_Error(
'rest_cannot_manage_templates',
__( 'Sorry, you are not allowed to access the templates on this site.', 'jetpack' ),
array(
'status' => rest_authorization_required_code(),
)
);
}
return true;
}
/**
* Validate the template type.
*
* @param string $template_type The template type.
* @return boolean True if the template type is valid.
*/
public function validate_template_type( $template_type ) {
$template_types = array_keys( get_default_block_template_types() );
if ( ! in_array( $template_type, $template_types, true ) ) {
return new WP_Error(
'rest_invalid_param',
sprintf(
/* translators: %s: The template type. */
__( 'The template type %s are not allowed.', 'jetpack' ),
$template_type
),
array( 'status' => 400 )
);
}
return true;
}
/**
* Retrieves the template by specified type.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_item( $request ) {
$template_type = $request['template_type'];
// A list of template candidates, in descending order of priority.
$templates = apply_filters( "{$template_type}_template_hierarchy", array( "{$template_type}.php" ) );
$template = locate_template( $templates );
$block_template = resolve_block_template( $template_type, $templates, $template );
if ( empty( $block_template ) ) {
return new WP_Error( 'not_found', 'Template not found', array( 'status' => 404 ) );
}
return rest_ensure_response(
array(
'type' => $block_template->type,
'id' => $block_template->id,
)
);
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Template_Loader' );
@@ -0,0 +1,100 @@
<?php
/**
* Get post types and top posts.
*
* @package automattic/jetpack
*/
/**
* Top Posts & Pages block endpoint.
*/
class WPCOM_REST_API_V2_Endpoint_Top_Posts extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
if ( ! class_exists( 'Jetpack_Top_Posts_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-top-posts-helper.php';
}
}
/**
* Register endpoint routes.
*/
public function register_routes() {
register_rest_route(
'wpcom/v2',
'/post-types',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_post_types' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
),
)
);
// Number of posts and selected post types are not needed in the Editor.
// This is to minimise requests when it can already be handled by the block.
register_rest_route(
'wpcom/v2',
'/top-posts',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_top_posts' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'period' => array(
'description' => __( 'Timeframe for stats.', 'jetpack' ),
'type' => array( 'string', 'integer' ),
'required' => true,
'validate_callback' => function ( $param ) {
return is_numeric( $param ) || is_string( $param );
},
),
),
),
)
);
}
/**
* Get the site's post types.
*
* @return array Site's post types.
*/
public function get_post_types() {
$post_types = array_values( get_post_types( array( 'public' => true ) ) );
$post_types_array = array();
foreach ( $post_types as $type ) {
$post_types_array[] = array(
'label' => get_post_type_object( $type )->labels->name,
'id' => $type,
);
}
return $post_types_array;
}
/**
* Get the site's top content.
*
* @param \WP_REST_Request $request request object.
*
* @return array Data on top posts.
*/
public function get_top_posts( $request ) {
$period = $request->get_param( 'period' );
return Jetpack_Top_Posts_Helper::get_top_posts( $period );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Top_Posts' );
@@ -0,0 +1,87 @@
<?php
/**
* REST API endpoint for editing Jetpack Transients.
*
* @package automattic/jetpack
* @since 9.7.0
*/
/**
* Jetpack transients API.
*
* @since 9.7.0
*/
class WPCOM_REST_API_V2_Endpoint_Transient extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'transients';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Called automatically on `rest_api_init()`.
*/
public function register_routes() {
// DELETE /sites/<blog-id>/transients/$name route.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<name>\w{1,172})',
array(
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_transient' ),
'permission_callback' => array( $this, 'delete_transient_permissions_check' ),
'args' => array(
'name' => array(
'description' => __( 'The name of the transient to delete.', 'jetpack' ),
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
),
),
)
);
}
/**
* Delete transient callback.
*
* @param \WP_REST_Request $request Full details about the request.
* @return array
*/
public function delete_transient( \WP_REST_Request $request ) {
return array(
'success' => delete_transient( $request->get_param( 'name' ) ),
);
}
/**
* Check if the user has read access, the transient name starts with
* "jetpack_connected_user_data_", and that the user is editing
* their own transient.
*
* @param \WP_REST_Request $request Full details about the request.
* @return bool|WP_Error
*/
public function delete_transient_permissions_check( \WP_REST_Request $request ) {
$transient_name = $request->get_param( 'name' );
$current_user_id = get_current_user_id();
if ( current_user_can( 'read' ) &&
"jetpack_connected_user_data_{$current_user_id}" === $transient_name ) {
return true;
} else {
return new WP_Error(
'authorization_required',
__( 'Sorry, you are not allowed to delete this transient.', 'jetpack' ),
array( 'status' => 403 )
);
}
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Transient' );
@@ -0,0 +1,565 @@
<?php
/**
* Blogging prompts endpoint for wpcom/v3.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
/**
* REST API endpoint wpcom/v3/sites/%s/blogging-prompts.
*/
class WPCOM_REST_API_V3_Endpoint_Blogging_Prompts extends WP_REST_Posts_Controller {
use WPCOM_REST_API_Proxy_Request;
const TEMPLATE_BLOG_ID = 205876834;
/**
* Whether the endpoint is running on wpcom, or not.
*
* @var bool
*/
public $is_wpcom;
/**
* Day of the year, from 1 to 366, and 0 representing no query.
*
* Used with yearless dates like `--12-20`, to get prompts by month and day, regardless of year.
*
* @var integer
*/
public $day_of_year_query = 0;
/**
* A year used to force one prompt per day for a specific year.
*
* @var integer
*/
public $force_year = 0;
/**
* Constructor.
*/
public function __construct() {
$this->post_type = 'post';
$this->base_api_path = 'wpcom';
$this->version = 'v3';
$this->namespace = $this->base_api_path . '/' . $this->version;
$this->rest_base = 'blogging-prompts';
$this->wpcom_is_wpcom_only_endpoint = true;
$this->wpcom_is_site_specific_endpoint = true;
$this->is_wpcom = defined( 'IS_WPCOM' ) && IS_WPCOM;
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Registers the routes for blogging prompts.
*
* @see register_rest_route()
*/
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, 'permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the prompt.', 'jetpack' ),
'type' => 'integer',
),
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Retrieves a collection of blogging prompts.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
if ( ! $this->is_wpcom ) {
return $this->proxy_request_to_wpcom( $request, '', 'user', true );
}
if ( $request->get_param( 'force_year' ) ) {
$this->force_year = $request->get_param( 'force_year' );
}
switch_to_blog( self::TEMPLATE_BLOG_ID );
add_action( 'pre_get_posts', array( $this, 'modify_query' ) );
add_filter( 'posts_clauses', array( $this, 'filter_sql' ) );
$items = parent::get_items( $request );
remove_filter( 'posts_clauses', array( $this, 'filter_sql' ) );
remove_action( 'pre_get_posts', array( $this, 'modify_query' ) );
restore_current_blog();
return $items;
}
/**
* Retrieves a single blogging prompt.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_item( $request ) {
if ( ! $this->is_wpcom ) {
return $this->proxy_request_to_wpcom( $request, $request->get_param( 'id' ), 'user', true );
}
if ( $request->get_param( 'force_year' ) ) {
$this->force_year = $request->get_param( 'force_year' );
}
switch_to_blog( self::TEMPLATE_BLOG_ID );
$item = parent::get_item( $request );
restore_current_blog();
return $item;
}
/**
* Modify the posts query using the {@see 'pre_get_posts'} hook.
*
* @param WP_Query $wp_query The WP_Query instance (passed by reference).
*/
public function modify_query( &$wp_query ) {
if ( is_array( $wp_query->query_vars['date_query'] ) ) {
$wp_query->query_vars['date_query'] = array_map(
array( $this, 'map_date_query' ),
$wp_query->query_vars['date_query']
);
}
}
/**
* Modify date_query items when querying prompts.
*
* @link https://developer.wordpress.org/reference/classes/WP_Query/#date-parameters
*
* @param array|string|null $date_query Date query.
* @return array|string|null Modified date query.
*/
public function map_date_query( $date_query ) {
if ( isset( $date_query['after'] ) ) {
// `after` date queries should include posts on the specified date, so force `inclusive` queries.
$date_query['inclusive'] = true;
// If using a "year-less" date, e.g. `--03-16`, override the date_query, and prepare to modify sql manually.
// `after` should be a date string when making API requests, rather than an array.
if ( is_string( $date_query['after'] ) && str_starts_with( $date_query['after'], '-' ) ) {
$date = date_create_from_format( '--m-d', $date_query['after'] );
if ( false !== $date ) {
// PHP day of the year starts with 0; normalize to match SQL DAYOFTHEYEAR which starts with 1.
$this->day_of_year_query = $date->format( 'z' ) + 1;
// Unset the date query, since we'll by modifying the SQL manually.
return null;
}
}
}
return $date_query;
}
/**
* Modify post sql for custom date ordering using the {@see 'posts_clauses'} hook.
*
* @param array $clauses SQL clauses for the current query.
* @return array Modified SQL clauses.
*/
public function filter_sql( $clauses ) {
global $wpdb;
if ( $this->day_of_year_query > 0 ) {
$day = $this->day_of_year_query;
$year = $this->force_year ? $this->force_year : wp_date( 'Y' );
// Grab the current sort order, `ASC` or `DESC`, so we can reuse it.
$exploded = explode( ' ', $clauses['orderby'] );
$order = end( $exploded );
// Calculate the day of year for each prompt, from 1 to 366, but use the current year so that prompts published
// during leap years have the correct day for non-leap years.
$fields = $clauses['fields'] . $wpdb->prepare( ", DAYOFYEAR(CONCAT(%d, DATE_FORMAT({$wpdb->posts}.post_date, '-%%m-%%d'))) AS day_of_year", $year );
// When it's not a leap year, exclude posts used for Feb 29th. DAYOFYEAR will return null for Feb 29th on non-leap years.
$where = $clauses['where'] . $wpdb->prepare( " AND DAYOFYEAR(CONCAT(%d, DATE_FORMAT({$wpdb->posts}.post_date, '-%%m-%%d'))) IS NOT NULL", $year );
// Order posts regardless of year: get a list of posts for each day,
// starting with the query date through the end of the year, then from the start of the year through the day before.
$orderby = $wpdb->prepare(
'CASE ' .
'WHEN day_of_year < %d ' .
// Push posts from the beginning of the year until the day before to the end.
'THEN day_of_year + 366 ' .
// Otherwise order posts from the query date through the end of the year.
'ELSE day_of_year ' .
'END' .
// Sort posts for the same day by year, in asc or desc order.
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- order string cannot be escaped.
", YEAR({$wpdb->posts}.post_date) " . ( 'DESC' === $order ? 'DESC' : 'ASC' ),
$day
);
if ( $this->force_year ) {
// If we're forcing the year, group by day of year, so that we only get one prompt per day.
$clauses['groupby'] = 'day_of_year';
// Ensure we get either to newest or oldest prompt for each day of the year, depending on the sort order.
// GROUP BY runs and collects the prompts for each day of the year before ORDER BY is run, so we first need to use MAX/MIN on post_date
// to find the most recent/oldest prompt for each day and join the results to the main query.
$clauses['join'] = $wpdb->prepare(
'INNER JOIN (' .
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL function cannot be escaped.
'SELECT ' . ( 'DESC' === $order ? 'MAX' : 'MIN' ) . "({$wpdb->posts}.post_date) AS post_date, DAYOFYEAR(CONCAT(%d, DATE_FORMAT(post_date, '-%%m-%%d'))) AS day_of_year " .
"FROM {$wpdb->posts} " .
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- reuses unmodified existing clause.
"WHERE 1=1 {$clauses['where']} " .
'GROUP BY day_of_year' .
") AS newest_prompts ON {$wpdb->posts}.post_date = newest_prompts.post_date",
$year
);
}
$clauses['fields'] = $fields;
$clauses['where'] = $where;
$clauses['orderby'] = $orderby;
}
return $clauses;
}
/**
* Prepares a single blogging prompt output for response.
*
* @param WP_Post $prompt Post object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $prompt, $request ) {
require_once WP_CONTENT_DIR . '/lib/blogging-prompts/answers.php';
require_once WP_CONTENT_DIR . '/lib/blogging-prompts/utils.php';
$fields = $this->get_fields_for_response( $request );
// Base fields for every post.
$data = array();
if ( rest_is_field_included( 'id', $fields ) ) {
$data['id'] = $prompt->ID;
}
if ( rest_is_field_included( 'date', $fields ) ) {
$data['date'] = $this->prepare_date_response( $prompt->post_date_gmt );
}
if ( rest_is_field_included( 'label', $fields ) ) {
if ( $this->is_in_bloganuary( $prompt->post_date_gmt ) ) {
$data['label'] = __( 'Bloganuary writing prompt', 'jetpack' );
} else {
$data['label'] = __( 'Daily writing prompt', 'jetpack' );
}
}
if ( rest_is_field_included( 'text', $fields ) ) {
$text = \BloggingPrompts\prompt_without_blocks( $prompt->post_content );
// Allow translating a variable, since this text is imported from bloggingpromptstemplates.wordpress.com for translation.
$translated_text = __( $text, 'jetpack' ); // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
$data['text'] = wp_kses( $translated_text, wp_kses_allowed_html( 'post' ) );
}
if ( rest_is_field_included( 'attribution', $fields ) ) {
$data['attribution'] = esc_html( get_post_meta( $prompt->ID, 'blogging_prompts_attribution', true ) );
}
// Will always be false when requesting as blog.
if ( rest_is_field_included( 'answered', $fields ) ) {
$data['answered'] = (bool) \A8C\BloggingPrompts\Answers::is_answered_by_user( $prompt->ID, get_current_user_id() );
}
if ( rest_is_field_included( 'answered_users_count', $fields ) ) {
$data['answered_users_count'] = (int) \A8C\BloggingPrompts\Answers::get_count( $prompt->ID );
}
if ( rest_is_field_included( 'answered_users_sample', $fields ) ) {
$data['answered_users_sample'] = $this->build_answering_users_sample( $prompt->ID );
}
if ( rest_is_field_included( 'answered_link', $fields ) ) {
if ( $this->is_in_bloganuary( $prompt->post_date_gmt ) ) {
$bloganuary_id = $this->get_bloganuary_id( $prompt->post_date_gmt );
$data['answered_link'] = esc_url( "https://wordpress.com/tag/{$bloganuary_id}" );
} else {
$data['answered_link'] = esc_url( "https://wordpress.com/tag/dailyprompt-{$prompt->ID}" );
}
}
if ( rest_is_field_included( 'answered_link_text', $fields ) ) {
$data['answered_link_text'] = __( 'View all responses', 'jetpack' );
}
if ( $this->is_in_bloganuary( $prompt->post_date_gmt ) && rest_is_field_included( 'bloganuary_id', $fields ) ) {
$data['bloganuary_id'] = $this->get_bloganuary_id( $prompt->post_date_gmt );
}
return $data;
}
/**
* Return true if the post is in "Bloganuary"
*
* @param string $post_date_gmt Unused - Post date in GMT.
* @return bool Always returns false as Bloganuary is disabled.
*/
protected function is_in_bloganuary( $post_date_gmt ) { //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
/*
Disable for January 2025 and beyond (see https://wp.me/p5uIfZ-gxX).
Previously, this method would check if the post was published in January:
- Extract month from post_date_gmt -- $post_month = gmdate( 'm', strtotime( $post_date_gmt ) );
- Return true if month was '01' -- return $post_month === '01';
*/
return false;
}
/**
* Return the bloganuary id of the form `bloganuary-yyyy-dd`
*
* @param string $post_date_gmt Post date in GMT.
* @return string Bloganuary id.
*/
protected function get_bloganuary_id( $post_date_gmt ) {
$post_year_day = gmdate( 'Y-d', strtotime( $post_date_gmt ) );
if ( $this->force_year ) {
$post_year_day = $this->force_year . '-' . gmdate( 'd', strtotime( $post_date_gmt ) );
}
return 'bloganuary-' . $post_year_day;
}
/**
* Format a date for a blogging prompt, omiting the time.
*
* @param string $date_gmt Publish datetime of the prompt in GMT, i.e. 0000-00-00 00:00:00.
* @param string $date Publish datetime of the prompt, i.e. 0000-00-00 00:00:00.
* @return string Publish date of the prompt in YYYY-MM-DD format.
*/
public function prepare_date_response( $date_gmt, $date = null ) {
$post_date = $date ? $date : $date_gmt;
$date_obj = date_create( $post_date );
if ( $this->force_year ) {
$date_obj->setDate( $this->force_year, $date_obj->format( 'm' ), $date_obj->format( 'd' ) );
// If ascending by day of year, go to the next year when we pass the last day of the year.
if ( $date_obj->format( 'm-d' ) === '12-31' ) {
$this->force_year += 1;
}
}
return false !== $date_obj ? $date_obj->format( 'Y-m-d' ) : substr( $post_date, 0, 10 );
}
/**
* Retrieves the query params for blogging prompt collections.
*
* @return array Query parameters for the collection.
*/
public function get_collection_params() {
$parent_args = parent::get_collection_params();
$args = array(
// Modify date args so that will except a YYYY-MM-DD without a time.
'after' => array(
'description' => __( 'Show prompts following a given date.', 'jetpack' ),
'type' => 'string',
'validate_callback' => function ( $param ) {
// Allow month and day without year, e.g. `--02-28`
if ( str_starts_with( $param, '-' ) ) {
return false !== date_create_from_format( '--m-d', $param );
}
return false !== date_create( $param );
},
),
'before' => array(
'description' => __( 'Show prompts before a given date.', 'jetpack' ),
'type' => 'string',
'validate_callback' => function ( $param ) {
return false !== date_create( $param );
},
),
'force_year' => array(
'description' => __( 'Force the returned prompts to be for a specific year. Returns only one prompt for each day.', 'jetpack' ),
'type' => 'integer',
'validate_callback' => function ( $param ) {
return is_numeric( $param ) && intval( $param ) > 0 && intval( $param ) < 9999;
},
),
);
$args['exclude'] = $parent_args['exclude'];
$args['include'] = $parent_args['include'];
$args['page'] = $parent_args['page'];
$args['per_page'] = $parent_args['per_page'];
$args['order'] = $parent_args['order'];
$args['order']['default'] = 'asc';
$args['orderby'] = $parent_args['orderby'];
$args['search'] = $parent_args['search'];
return $args;
}
/**
* Retrieves the blogging prompt's schema, conforming to JSON Schema.
*
* @return array Item schema data.
*/
public function get_item_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'blogging-prompt',
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'Unique identifier for the post.', 'jetpack' ),
'type' => 'integer',
),
'date' => array(
'description' => __( "The date the post was published, in the site's timezone.", 'jetpack' ),
'type' => 'string',
),
'label' => array(
'description' => __( 'Label for the prompt.', 'jetpack' ),
'type' => 'string',
),
'text' => array(
'description' => __( 'The text of the prompt. May include html tags like <em>.', 'jetpack' ),
'type' => 'string',
),
'attribution' => array(
'description' => __( 'Source of the prompt, if known.', 'jetpack' ),
'type' => 'string',
),
'answered' => array(
'description' => __( 'Whether the user has answered the prompt.', 'jetpack' ),
'type' => 'boolean',
),
'answered_users_count' => array(
'description' => __( 'Number of users who have answered the prompt.', 'jetpack' ),
'type' => 'integer',
),
'answered_users_sample' => array(
'description' => __( 'Sample of users who have answered the prompt.', 'jetpack' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'avatar' => array(
'description' => __( "Gravatar URL for the user's avatar image.", 'jetpack' ),
'type' => 'string',
'format' => 'uri',
),
),
),
),
'answered_link' => array(
'description' => __( 'Link to answers for the prompt.', 'jetpack' ),
'type' => 'string',
'format' => 'uri',
),
'answered_link_text' => array(
'description' => __( 'Text for the link to answers for the prompt.', 'jetpack' ),
'type' => 'string',
),
'bloganuary_id' => array(
'description' => __( 'Id used by the bloganuary promotion', 'jetpack' ),
'type' => 'string',
),
),
);
}
/**
* Checks if a given request has access to read blogging prompts for a site.
*
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function permissions_check() {
if ( current_user_can( 'edit_posts' ) ) {
return true;
}
// Allow "as blog" requests to wpcom so users without accounts can insert the Writing prompt block in the editor.
if ( $this->is_wpcom && is_jetpack_site( get_current_blog_id() ) ) {
if ( ! class_exists( 'WPCOM_REST_API_V2_Endpoint_Jetpack_Auth' ) ) {
require_once dirname( __DIR__ ) . '/rest-api-plugins/endpoints/jetpack-auth.php';
}
$jp_auth_endpoint = new WPCOM_REST_API_V2_Endpoint_Jetpack_Auth();
if ( true === $jp_auth_endpoint->is_jetpack_authorized_for_site() ) {
return true;
}
}
return new WP_Error(
'rest_cannot_read_prompts',
__( 'Sorry, you are not allowed to access blogging prompts on this site.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Creates a sample of users who have answered a blogging prompt.
*
* @param int $prompt_id Prompt ID.
* @return array List of users, including a gravatar url for each user.
*/
protected function build_answering_users_sample( $prompt_id ) {
$results = \A8C\BloggingPrompts\Answers::get_sample_users_by( $prompt_id );
if ( ! $results ) {
return array();
}
$users = array();
foreach ( $results as $user ) {
$url = wpcom_get_avatar_url( $user->user_id, 96, 'identicon', false );
if ( has_gravatar( $user->user_id ) && ! empty( $url[0] ) && ! is_wp_error( $url[0] ) ) {
$users[] = array(
'avatar' => (string) esc_url_raw( htmlspecialchars_decode( $url[0], ENT_COMPAT ) ),
);
}
}
return array_slice( $users, 0, 3 );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V3_Endpoint_Blogging_Prompts' );
@@ -0,0 +1,87 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Interact with the list of available block editor extensions (blocks, plugins)
* made available by the Jetpack plugin.
*
* @package automattic/jetpack
*/
/**
* Gutenberg: List Available Gutenberg Extensions (Blocks and Plugins)
*
* [
* { # Availability 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 {
/**
* Constructor.
*/
public 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' ) );
}
/**
* Register the endpoint route.
*/
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' );
@@ -0,0 +1,45 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Example of a WP.com endpoint.
*
* @package automattic/jetpack
*/
/**
* Example endpoint.
*/
class WPCOM_REST_API_V2_Endpoint_Hello {
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register endpoint route.
*/
public function register_routes() {
register_rest_route(
'wpcom/v2',
'/hello',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_data' ),
'permission_callback' => '__return_true',
),
)
);
}
/**
* Get data in response to the endpoint request.
*
* @param WP_REST_Request $request API request.
*/
public function get_data( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return array( 'hello' => 'world' );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Hello' );
@@ -0,0 +1,546 @@
<?php // phpcs:disable WordPress.Files.FileName.InvalidClassFileName
/**
* Memberships: API to communicate with "product" database.
*
* @package Jetpack
* @since 7.3.0
*/
use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
/**
* Class WPCOM_REST_API_V2_Endpoint_Memberships
* This introduces V2 endpoints.
*/
class WPCOM_REST_API_V2_Endpoint_Memberships extends WP_REST_Controller {
use WPCOM_REST_API_Proxy_Request;
/**
* WPCOM_REST_API_V2_Endpoint_Memberships constructor.
*/
public function __construct() {
$this->base_api_path = 'wpcom';
$this->version = 'v2';
$this->namespace = $this->base_api_path . '/' . $this->version;
$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' ),
'args' => array(
'type' => array(
'type' => 'string',
'required' => false,
'validate_callback' => function ( $param ) {
return in_array( $param, array( 'donation', 'all' ), true );
},
),
'source' => array(
'type' => 'string',
'required' => false,
'validate_callback' => function ( $param ) {
return in_array(
$param,
array(
'calypso',
'earn',
'earn-newsletter',
'gutenberg',
'gutenberg-wpcom',
'launchpad',
'import-paid-subscribers',
),
true
);
},
),
'is_editable' => array(
'type' => 'boolean',
'required' => false,
),
),
),
)
);
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' => 'number',
'required' => true,
),
'currency' => array(
'type' => 'string',
'required' => true,
),
'interval' => array(
'type' => 'string',
'required' => true,
),
'is_editable' => array(
'type' => 'boolean',
'required' => false,
),
'buyer_can_change_amount' => array(
'type' => 'boolean',
),
'tier' => array(
'type' => 'integer',
'required' => false,
),
),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/products/?',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_products' ),
'permission_callback' => array( $this, 'can_modify_products_permission_check' ),
'args' => array(
'currency' => array(
'type' => 'string',
'required' => true,
),
'type' => array(
'type' => 'string',
'required' => true,
),
'is_editable' => array(
'type' => 'boolean',
'required' => false,
),
),
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'list_products' ),
'permission_callback' => array( $this, 'get_status_permission_check' ),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/product/(?P<product_id>[0-9]+)/?',
array(
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_product' ),
'permission_callback' => array( $this, 'can_modify_products_permission_check' ),
'args' => array(
'title' => array(
'type' => 'string',
'required' => true,
),
'price' => array(
'type' => 'number',
'required' => true,
),
'currency' => array(
'type' => 'string',
'required' => true,
),
'interval' => array(
'type' => 'string',
'required' => true,
),
'is_editable' => array(
'type' => 'boolean',
'required' => false,
),
'buyer_can_change_amount' => array(
'type' => 'boolean',
),
'tier' => array(
'type' => 'integer',
'required' => false,
),
),
),
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_product' ),
'permission_callback' => array( $this, 'can_modify_products_permission_check' ),
'args' => array(
'cancel_subscriptions' => array(
'type' => 'boolean',
'required' => false,
),
),
),
)
);
}
/**
* Ensure the user has proper permissions for getting status and listing products
*
* @return boolean
*/
public function get_status_permission_check() {
return current_user_can( 'edit_posts' );
}
/**
* Ensure the user has proper permissions to modify products
*
* @return boolean
*/
public function can_modify_products_permission_check() {
return current_user_can( 'manage_options' );
}
/**
* Automatically generate products according to type.
*
* @param object $request - request passed from WP.
*
* @return array|WP_Error
*/
public function create_products( $request ) {
$is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
if ( $this->is_wpcom() ) {
require_lib( 'memberships' );
Memberships_Store_Sandbox::get_instance()->init( true );
$result = Memberships_Product::generate_default_products( get_current_blog_id(), $request['type'], $request['currency'], $is_editable );
if ( is_wp_error( $result ) ) {
$status = 'invalid_param' === $result->get_error_code() ? 400 : 500;
return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
}
return $result;
} else {
return $this->proxy_request_to_wpcom_as_user( $request, 'products' );
}
return $request;
}
/**
* List already-created products.
*
* @param \WP_REST_Request $request - request passed from WP.
*
* @return WP_Error|array ['products']
*/
public function list_products( WP_REST_Request $request ) {
$is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
$type = isset( $request['type'] ) ? $request['type'] : null;
if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
require_lib( 'memberships' );
require_once JETPACK__PLUGIN_DIR . '/modules/memberships/class-jetpack-memberships.php';
try {
return array( 'products' => $this->list_products_from_wpcom( $request, $type, $is_editable ) );
} catch ( \Exception $e ) {
return array( 'error' => $e->getMessage() );
}
} else {
return $this->proxy_request_to_wpcom_as_user( $request, 'products' );
}
}
/**
* Do create a product based on data, or pass request to wpcom.
*
* @param WP_REST_Request $request - request passed from WP.
*
* @return array|WP_Error
*/
public function create_product( WP_REST_Request $request ) {
$payload = $this->get_payload_for_product( $request );
if ( $this->is_wpcom() ) {
require_lib( 'memberships' );
try {
return $this->create_product_from_wpcom( $payload );
} catch ( \Exception $e ) {
return array( 'error' => $e->getMessage() );
}
} else {
return $this->proxy_request_to_wpcom_as_user( $request, 'product' );
}
}
/**
* Update an existing memberships product
*
* @param \WP_REST_Request $request The request passed from WP.
*
* @return array|WP_Error
*/
public function update_product( \WP_REST_Request $request ) {
$product_id = $request->get_param( 'product_id' );
$payload = $this->get_payload_for_product( $request );
if ( $this->is_wpcom() ) {
require_lib( 'memberships' );
try {
return array( 'product' => $this->update_product_from_wpcom( $product_id, $payload ) );
} catch ( \Exception $e ) {
return array( 'error' => $e->getMessage() );
}
} else {
return $this->proxy_request_to_wpcom_as_user( $request, "product/$product_id" );
}
}
/**
* Delete an existing memberships product
*
* @param \WP_REST_Request $request The request passed from WP.
*
* @return array|WP_Error
*/
public function delete_product( \WP_REST_Request $request ) {
$product_id = $request->get_param( 'product_id' );
$cancel_subscriptions = $request->get_param( 'cancel_subscriptions' );
if ( $this->is_wpcom() ) {
require_lib( 'memberships' );
try {
$this->delete_product_from_wpcom( $product_id, $cancel_subscriptions );
return array( 'deleted' => true );
} catch ( \Exception $e ) {
return array( 'error' => $e->getMessage() );
}
} else {
return $this->proxy_request_to_wpcom_as_user( $request, "product/$product_id" );
}
}
/**
* Get a status of connection for the site. If this is Jetpack, pass the request to wpcom.
*
* @param \WP_REST_Request $request - request passed from WP.
*
* @return WP_Error|array ['products','connected_account_id','connect_url']
*/
public function get_status( \WP_REST_Request $request ) {
$product_type = $request['type'];
if ( ! empty( $request['source'] ) ) {
$source = sanitize_text_field( wp_unslash( $request['source'] ) );
} else {
$source = 'gutenberg';
}
$is_editable = ! isset( $request['is_editable'] ) ? null : (bool) $request['is_editable'];
if ( $this->is_wpcom() ) {
require_lib( 'memberships' );
Memberships_Store_Sandbox::get_instance()->init( true );
$blog_id = get_current_blog_id();
$membership_settings = get_memberships_settings_for_site( $blog_id, $product_type, $is_editable, $source );
if ( is_wp_error( $membership_settings ) ) {
// Get error messages from the $membership_settings.
$error_codes = $membership_settings->get_error_codes();
$error_messages = array();
foreach ( $error_codes as $code ) {
$messages = $membership_settings->get_error_messages( $code );
foreach ( $messages as $message ) {
// Sanitize error message
$error_messages[] = esc_html( $message );
}
}
$error_messages_string = implode( ' ', $error_messages );
// translators: %s is a list of error messages.
$base_message = __( 'Could not get the membership settings due to the following error(s): %s', 'jetpack' );
$full_message = sprintf( $base_message, $error_messages_string );
return new WP_Error( 'membership_settings_error', $full_message, array( 'status' => 404 ) );
}
return (array) $membership_settings;
} else {
return $this->proxy_request_to_wpcom_as_user( $request, 'status' );
}
}
/**
* This function throws an exception if it is run outside of wpcom.
*
* @return void
* @throws \Exception If the function is run outside of WPCOM.
*/
private function prevent_running_outside_of_wpcom() {
if ( ! $this->is_wpcom() || ! class_exists( 'Memberships_Product' ) ) {
throw new \Exception( 'This function is intended to be run from WPCOM' );
}
}
/**
* List products via the WPCOM-specific Memberships_Product class.
*
* @param WP_REST_Request $request The request for this endpoint.
* @param ?string $type The type of the products to list.
* @param ?bool $is_editable If we are looking for editable or non-editable products.
* @throws \Exception If blog is not known or if there is an error getting products.
* @return array List of products.
*/
private function list_products_from_wpcom( WP_REST_Request $request, $type, $is_editable ) {
$this->prevent_running_outside_of_wpcom();
Memberships_Store_Sandbox::get_instance()->init( true );
$blog_id = $request->get_param( 'blog_id' );
if ( is_wp_error( $blog_id ) ) {
throw new \Exception( 'Unknown blog' );
}
$list = Memberships_Product::get_product_list( get_current_blog_id(), $type, $is_editable );
if ( is_wp_error( $list ) ) {
throw new \Exception( $list->get_error_message() );
}
return $list;
}
/**
* Find a product by product id via the WPCOM-specific Memberships_Product class.
*
* @param string|int $product_id The ID of the product to be found.
* @throws \Exception If there is an error getting the product or if the product was not found.
* @return object The found product.
*/
private function find_product_from_wpcom( $product_id ) {
$this->prevent_running_outside_of_wpcom();
Memberships_Store_Sandbox::get_instance()->init( true );
$product = Memberships_Product::get_from_post( get_current_blog_id(), $product_id );
if ( is_wp_error( $product ) ) {
throw new \Exception( $product->get_error_message() );
}
if ( ! $product || ! $product instanceof Memberships_Product ) {
throw new \Exception( __( 'Product not found.', 'jetpack' ) );
}
return $product;
}
/**
* Create a product via the WPCOM-specific Memberships_Product class.
*
* @param array $payload The request payload which contains details about the product.
* @throws \Exception When the product failed to be created.
* @return array The newly created product.
*/
private function create_product_from_wpcom( $payload ) {
$this->prevent_running_outside_of_wpcom();
Memberships_Store_Sandbox::get_instance()->init( true );
$product = Memberships_Product::create( get_current_blog_id(), $payload );
if ( is_wp_error( $product ) ) {
throw new \Exception( __( 'Creating product has failed.', 'jetpack' ) );
}
return $product->to_array();
}
/**
* Update a product via the WPCOM-specific Memberships_Product class.
*
* @param string|int $product_id The ID of the product being updated.
* @param array $payload The request payload which contains details about the product.
* @throws \Exception When there is a problem updating the product.
* @return object The newly updated product.
*/
private function update_product_from_wpcom( $product_id, $payload ) {
Memberships_Store_Sandbox::get_instance()->init( true );
$product = $this->find_product_from_wpcom( $product_id ); // prevents running outside of wpcom
$updated_product = $product->update( $payload );
if ( is_wp_error( $updated_product ) ) {
throw new \Exception( $updated_product->get_error_message() );
}
return $updated_product->to_array();
}
/**
* Delete a product via the WPCOM-specific Memberships_Product class.
*
* @param string|int $product_id The ID of the product being deleted.
* @param bool $cancel_subscriptions Whether to cancel subscriptions to the product as well.
* @throws \Exception When there is a problem deleting the product.
* @return void
*/
private function delete_product_from_wpcom( $product_id, $cancel_subscriptions = false ) {
Memberships_Store_Sandbox::get_instance()->init( true );
$product = $this->find_product_from_wpcom( $product_id ); // prevents running outside of wpcom
$result = $product->delete( $cancel_subscriptions ? Memberships_Product::CANCEL_SUBSCRIPTIONS : Memberships_Product::KEEP_SUBSCRIPTIONS );
if ( is_wp_error( $result ) ) {
throw new \Exception( $result->get_error_message() );
}
}
/**
* Get a payload for creating or updating products by parsing the request.
*
* @param WP_REST_Request $request The request for this endpoint, containing the details needed to build the payload.
* @return array The built payload.
*/
private function get_payload_for_product( WP_REST_Request $request ) {
$is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
$type = isset( $request['type'] ) ? $request['type'] : null;
$tier = isset( $request['tier'] ) ? $request['tier'] : null;
$buyer_can_change_amount = isset( $request['buyer_can_change_amount'] ) && (bool) $request['buyer_can_change_amount'];
$payload = array(
'title' => $request['title'],
'price' => $request['price'],
'currency' => $request['currency'],
'buyer_can_change_amount' => $buyer_can_change_amount,
'interval' => $request['interval'],
'type' => $type,
'welcome_email_content' => $request['welcome_email_content'],
'subscribe_as_site_subscriber' => $request['subscribe_as_site_subscriber'],
'multiple_per_user' => $request['multiple_per_user'],
);
if ( null !== $tier ) {
$payload['tier'] = $tier;
}
// If we pass directly the value "null", it will break the argument validation.
if ( null !== $is_editable ) {
$payload['is_editable'] = $is_editable;
}
return $payload;
}
/**
* Returns true if run from WPCOM.
*
* @return boolean true if run from wpcom, otherwise false.
*/
private function is_wpcom() {
return defined( 'IS_WPCOM' ) && IS_WPCOM;
}
}
if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Jetpack::is_connection_ready() ) {
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Memberships' );
}
@@ -0,0 +1,141 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Fetch information about Publicize connections on a site, including tests and connection status.
*
* @package automattic/jetpack
*/
require_once __DIR__ . '/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 {
/**
* Constructor.
*/
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 Jetpack Social connection test pass?', 'jetpack' ),
'type' => 'boolean',
),
'error_code' => array(
'description' => __( 'Jetpack Social connection error code', 'jetpack' ),
'type' => 'string',
),
'test_message' => array(
'description' => __( 'Jetpack Social connection success or error message', 'jetpack' ),
'type' => 'string',
),
'can_refresh' => array(
'description' => __( 'Can the current user refresh the Jetpack Social connection?', 'jetpack' ),
'type' => 'boolean',
),
'refresh_text' => array(
'description' => __( 'Message instructing the user to refresh their Connection to the Jetpack Social service', 'jetpack' ),
'type' => 'string',
),
'refresh_url' => array(
'description' => __( 'URL for refreshing the Connection to the Jetpack Social service', 'jetpack' ),
'type' => 'string',
'format' => 'uri',
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get list of Publicize Connections.
*
* @param WP_REST_Request $request Full details about the request.
*
* @see Publicize::get_publicize_conns_test_results()
* @return WP_REST_Response suitable for 1-page collection
*/
public function get_items( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
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['connectionID'] ] = $test_result;
}
$mapping = array(
'test_success' => 'connectionTestPassed',
'test_message' => 'connectionTestMessage',
'error_code' => 'connectionTestErrorCode',
'can_refresh' => 'userCanRefresh',
'refresh_text' => 'refreshText',
'refresh_url' => 'refreshURL',
'connection_id' => 'connectionID',
);
foreach ( $items as &$item ) {
$test_result = $test_results_by_unique_id[ $item['connection_id'] ];
foreach ( $mapping as $field => $test_result_field ) {
$item[ $field ] = $test_result[ $test_result_field ];
}
}
if (
isset( $item['id'] )
&& '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' );
@@ -0,0 +1,207 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Fetch information about Publicize connections on a site.
*
* @package automattic/jetpack
*/
/**
* Publicize: List Connections
*
* [
* { # Connection 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;
/**
* Called automatically on `rest_api_init()`.
*/
public function register_routes() {
// Endpoint moved to publicize package.
}
/**
* 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 Jetpack Social connection', 'jetpack' ),
'type' => 'string',
),
'service_name' => array(
'description' => __( 'Alphanumeric identifier for the Jetpack Social service', 'jetpack' ),
'type' => 'string',
),
'display_name' => array(
'description' => __( 'Display name of the connected account', 'jetpack' ),
'type' => 'string',
),
'username' => array(
'description' => __( 'Username of the connected account', 'jetpack' ),
'type' => 'string',
),
'profile_display_name' => array(
'description' => __( 'The name to display in the profile of the connected account', 'jetpack' ),
'type' => 'string',
),
'profile_picture' => array(
'description' => __( 'Profile picture of the connected account', 'jetpack' ),
'type' => 'string',
),
'global' => array(
'description' => __( 'Is this connection available to all users?', 'jetpack' ),
'type' => 'boolean',
),
'external_id' => array(
'description' => __( 'The external ID of the connected account', 'jetpack' ),
'type' => 'string',
),
);
}
/**
* Schema for the endpoint.
*
* @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 ),
'connection_id' => (string) $publicize->get_connection_id( $connection ),
'service_name' => $service_name,
'display_name' => $publicize->get_display_name( $service_name, $connection ),
'username' => $publicize->get_username( $service_name, $connection ),
'profile_display_name' => ! empty( $connection_meta['profile_display_name'] ) ? $connection_meta['profile_display_name'] : '',
'profile_picture' => ! empty( $connection_meta['profile_picture'] ) ? $connection_meta['profile_picture'] : '',
// phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- We expect an integer, but do loose comparison below in case some other type is stored.
'global' => 0 == $connection_data['user_id'],
'external_id' => $connection_meta['external_id'] ?? '',
);
}
}
return $items;
}
/**
* Get list of connected Publicize connections.
*
* @param WP_REST_Request $request Full details about the 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 Array of info about a specific Publicize connection.
* @param WP_REST_Request $request Full details about the 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, Jetpack Social 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 Jetpack Social 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' );
@@ -0,0 +1,176 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Endpoint used to fetch information to connect to a Publicize service.
*
* @package automattic/jetpack
*/
/**
* 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;
/**
* Constructor.
*/
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' ),
)
);
}
/**
* Schema for the publicize services endpoint.
*
* @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 Jetpack Social service', 'jetpack' ),
'type' => 'string',
),
'label' => array(
'description' => __( 'Human readable label for the Jetpack Social service', 'jetpack' ),
'type' => 'string',
),
'url' => array(
'description' => __( 'The URL used to connect to the Jetpack Social 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 Full details about the request.
*
* @return WP_REST_Response suitable for 1-page collection
*/
public function get_items( $request ) {
global $publicize;
if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
/**
* We need this because Publicize::get_available_service_data() uses `Jetpack_Keyring_Service_Helper`
* and `Jetpack_Keyring_Service_Helper` needs a `sharing` page to be registered.
*/
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.jetpack-keyring-service-helper.php';
Jetpack_Keyring_Service_Helper::register_sharing_page();
}
$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 UI service connection data for a specific Publicize service.
* @param WP_REST_Request $request Full details about the 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, Jetpack Social 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 Jetpack Social 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' );
@@ -0,0 +1,335 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Get and save API keys for a site.
*
* @package automattic/jetpack
*/
/**
* Service API Keys: Exposes 3rd party api keys that are used on a site.
*
* [
* { # Availability 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
* service_api_key_source: (string) The source of the API key, defaults to "site"
* message: (string) User friendly message
* },
* ...
* ]
*
* @since 6.9
*/
class WPCOM_REST_API_V2_Endpoint_Service_API_Keys extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'service-api-keys';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register endpoint 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' ),
'permission_callback' => '__return_true',
),
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' => 'string',
),
),
),
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( __CLASS__, 'delete_service_api_key' ),
'permission_callback' => array( __CLASS__, 'edit_others_posts_check' ),
),
)
);
}
/**
* Permission 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',
),
'service_api_key_source' => array(
'description' => __( 'The source of the API key. Defaults to "site"', '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();
}
switch ( $service ) {
case 'mapbox':
if ( ! class_exists( 'Jetpack_Mapbox_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-mapbox-helper.php';
}
$mapbox = Jetpack_Mapbox_Helper::get_access_token();
$service_api_key = $mapbox['key'];
$service_api_key_source = $mapbox['source'];
break;
default:
$option = self::key_for_api_service( $service );
$service_api_key = Jetpack_Options::get_option( $option, '' );
$service_api_key_source = 'site';
}
$message = esc_html__( 'API key retrieved successfully.', 'jetpack' );
return array(
'code' => 'success',
'service' => $service,
'service_api_key' => $service_api_key,
'service_api_key_source' => $service_api_key_source,
'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 );
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, '' ),
'service_api_key_source' => 'site',
'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' );
switch ( $service ) {
case 'mapbox':
// After deleting a custom Mapbox key, try to revert to the WordPress.com one if available.
if ( ! class_exists( 'Jetpack_Mapbox_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-mapbox-helper.php';
}
$mapbox = Jetpack_Mapbox_Helper::get_access_token();
$service_api_key = $mapbox['key'];
$service_api_key_source = $mapbox['source'];
break;
default:
$service_api_key = Jetpack_Options::get_option( $option, '' );
$service_api_key_source = 'site';
}
return array(
'code' => 'success',
'service' => $service,
'service_api_key' => $service_api_key,
'service_api_key_source' => $service_api_key_source,
'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 = isset( $mapbox_geocode_json->message ) ? $mapbox_geocode_json->message : 'Unknown error';
}
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' );
@@ -0,0 +1,60 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* WPCOM Add Featured Media URL
* Adds `jetpack_featured_media_url` to post responses
*
* @package automattic/jetpack
*/
/**
* Add featured media url to API post responses.
*/
class WPCOM_REST_API_V2_Sites_Posts_Add_Featured_Media_URL {
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'add_featured_media_url' ) );
}
/**
* Add featured media url to post responses.
*/
public 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,
)
);
}
/**
* Get featured media url.
*
* @param mixed $object What the endpoint returns.
* @param string $field_name Should always match `->field_name`.
* @param WP_REST_Request $request WP API request.
*
* @return string
*/
public function get_featured_media_url( $object, $field_name, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! isset( $object['id'] ) ) {
return '';
}
$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' );
@@ -0,0 +1,109 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Get subscriber count from Jetpack's Subscriptions module.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Constants;
/**
* Subscribers: Get subscriber count
*
* @since 6.9
*/
class WPCOM_REST_API_V2_Endpoint_Subscribers extends WP_REST_Controller {
/**
* Constructor.
*/
public 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' ) );
}
/**
* Register API 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' ),
),
)
);
// GET /sites/<blog_id>/subscriber/counts - Return splitted number of subscribers for this site
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/counts',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_subscriber_counts' ),
'permission_callback' => array( $this, 'readable_permission_check' ),
),
)
);
}
/**
* Permission check. Only authors can access this endpoint.
*/
public function readable_permission_check() {
// @phan-suppress-next-line PhanDeprecatedFunction -- @todo Switch to current_user_can_for_site when we drop support for WP 6.6.
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 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
// Get the most up to date subscriber count when request is not a test.
if ( ! Constants::is_defined( 'TESTING_IN_JETPACK' ) ) {
delete_transient( 'wpcom_subscribers_total' );
delete_transient( 'wpcom_subscribers_total_no_publicize' );
}
$subscriber_count = Jetpack_Subscriptions_Widget::fetch_subscriber_count();
return array(
'count' => $subscriber_count,
);
}
/**
* Retrieves splitted subscriber counts
*
* @return array data object containing subscriber counts ['email_subscribers' => 0, 'social_followers' => 0]
*/
public function get_subscriber_counts() {
if ( ! Constants::is_defined( 'TESTING_IN_JETPACK' ) ) {
delete_transient( 'wpcom_subscribers_totals' );
}
$subscriber_info = Automattic\Jetpack\Extensions\Subscriptions\fetch_subscriber_counts();
$subscriber_counts = $subscriber_info['value'];
return array( 'counts' => $subscriber_counts );
}
}
if (
Jetpack::is_module_active( 'subscriptions' ) ||
( Constants::is_defined( 'TESTING_IN_JETPACK' ) && Constants::get_constant( 'TESTING_IN_JETPACK' ) )
) {
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Subscribers' );
}
@@ -0,0 +1,444 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Handle Publicize connection information for each post.
*
* @package automattic/jetpack
*/
/**
* 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
* profile_picture: (string) Profile picture 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_feature_enabled: (boolean) Is this publicize feature enabled?
* jetpack_publicize_message: (string) The message to use instead of the post's title when sharing.
* jetpack_social_options: {
* attached_media: (array) List of media that will be attached to the social media post.
* image_generator_settings: (array) List of settings related to the generated image.
* }
* ...
* }
*
* @since 6.8.0
*/
class WPCOM_REST_API_V2_Post_Publicize_Connections_Field extends WPCOM_REST_API_V2_Field_Controller {
/**
* Array of post types that can handle Publicize.
*
* @var array
*/
protected $object_type = array( 'post' );
/**
* Field name
*
* @var string
*/
protected $field_name = 'jetpack_publicize_connections';
/**
* Array of post IDs that have been updated.
*
* @var array
*/
private $meta_saved = array();
/**
* Used to memoize the updates for a given post.
*
* @var array
*/
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 ) {
if ( $this->is_registered( $post_type ) ) {
continue;
}
// 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(),
);
}
/**
* Schema for the endpoint.
*/
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 Jetpack Social connection', 'jetpack' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'service_name' => array(
'description' => __( 'Alphanumeric identifier for the Jetpack Social service', 'jetpack' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'display_name' => array(
'description' => __( 'Display name of the connected account', 'jetpack' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'username' => array(
'description' => __( 'Username of the connected account', 'jetpack' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'profile_picture' => array(
'description' => __( 'Profile picture 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 Jetpack Social 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,
),
'external_id' => array(
'description' => __( 'The external ID of the connected account', 'jetpack' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
}
/**
* Permission check, based on module availability and user capabilities.
*
* @param int $post_id Post ID.
*
* @return true|WP_Error
*/
public function permission_check( $post_id ) {
global $publicize;
if ( ! $publicize ) {
return new WP_Error(
'publicize_not_available',
__( 'Sorry, Jetpack Social 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 Jetpack Social data for this post.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Getter permission check
*
* @param mixed $post_array Response from the post endpoint.
* @param WP_REST_Request $request API request.
*
* @return true|WP_Error
*/
public function get_permission_check( $post_array, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return $this->permission_check( isset( $post_array['id'] ) ? $post_array['id'] : 0 );
}
/**
* Setter permission check.
*
* @param mixed $value The new value for the field.
* @param WP_Post $post The post object.
* @param WP_REST_Request $request API request.
*
* @return true|WP_Error
*/
public function update_permission_check( $value, $post, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
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 $request API request.
*
* @return array List of connections
*/
public function get( $post_array, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
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_connection['connection_id'] = (string) $connection['id'];
$output_connection['can_disconnect'] = current_user_can( 'edit_others_posts' ) || get_current_user_id() === (int) $connection['user_id'];
$output_connection['shared'] = $connection['global'];
$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 API request.
*
* @return object|WP_Error 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 );
if ( isset( $post->ID ) ) {
// Set the meta before we mark the post as published so that publicize works as expected.
// If this is not the case post end up on social media when they are marked as skipped.
$this->update( $request['jetpack_publicize_connections'], $post, $request );
}
return $post;
}
/**
* After creating a new post, update our cached data to reflect
* the new post ID.
*
* @param WP_Post $post Post data to update.
* @param WP_REST_Request $request API request.
* @param bool $is_new Is this a new post.
*/
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] );
}
/**
* Get list of meta data to update per post ID.
*
* @param array $requested_connections Publicize connections to update.
* Items are either `{ id: (string) }` or `{ service_name: (string) }`.
* @param int $post_id Post ID.
*/
protected function get_meta_to_update( $requested_connections, $post_id = 0 ) {
global $publicize;
if ( ! $publicize || ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) ) {
return array();
}
$post = get_post( $post_id );
if ( isset( $post->post_status ) && 'publish' === $post->post_status ) {
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_connection_id = array();
$available_connections_by_service_name = array();
foreach ( $available_connections as $available_connection ) {
$available_connections_by_connection_id[ $available_connection['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) }.
// If the service is not available, it will be skipped.
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 ) {
if ( $requested_connection['connection_id'] === $available_connection['id'] ) {
$changed_connections[ $available_connection['id'] ] = $requested_connection['enabled'];
break;
}
}
}
// Handle { id: $id, enabled: (bool) }
// These override the service_name settings.
foreach ( $requested_connections as $requested_connection ) {
if ( ! isset( $requested_connection['connection_id'] ) ) {
continue;
}
if ( ! isset( $available_connections_by_connection_id[ $requested_connection['connection_id'] ] ) ) {
continue;
}
$changed_connections[ $requested_connection['connection_id'] ] = $requested_connection['enabled'];
}
// Set all changed connections to their new value.
foreach ( $changed_connections as $id => $enabled ) {
$connection = $available_connections_by_connection_id[ $id ];
if ( $connection['done'] || ! $connection['toggleable'] ) {
continue;
}
$available_connections_by_connection_id[ $id ]['enabled'] = $enabled;
}
$meta_to_update = array();
// For all connections, ensure correct post_meta.
foreach ( $available_connections_by_connection_id as $connection_id => $available_connection ) {
if ( $available_connection['enabled'] ) {
$meta_to_update[ $publicize->POST_SKIP_PUBLICIZE . $connection_id ] = null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
} else {
$meta_to_update[ $publicize->POST_SKIP_PUBLICIZE . $connection_id ] = 1; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
}
}
$this->memoized_updates[ $post_id ] = $meta_to_update;
return $meta_to_update;
}
/**
* Update the connections slated to be shared to.
*
* @param array $requested_connections Publicize conenctions to update.
* Items are either `{ id: (string) }` or `{ service_name: (string) }`.
* @param WP_Post $post Post data.
* @param WP_REST_Request $request API request.
*/
public function update( $requested_connections, $post, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( isset( $this->meta_saved[ $post->ID ] ) ) { // Make sure we only save it once - per request.
return;
}
foreach ( $this->get_meta_to_update( $requested_connections, $post->ID ) as $meta_key => $meta_value ) {
if ( $meta_value === null ) {
delete_post_meta( $post->ID, $meta_key );
} else {
update_post_meta( $post->ID, $meta_key, $meta_value );
}
}
$this->meta_saved[ $post->ID ] = true;
}
}
if ( Jetpack::is_module_active( 'publicize' ) ) {
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Post_Publicize_Connections_Field' );
}
@@ -0,0 +1,21 @@
<?php
/**
* Loading the various functions used for Jetpack Debugging.
*
* @package automattic/jetpack
*/
/* Jetpack Connection Testing Framework */
require_once __DIR__ . '/debugger/class-jetpack-cxn-test-base.php';
/* Jetpack Connection Tests */
require_once __DIR__ . '/debugger/class-jetpack-cxn-tests.php';
/* Jetpack Debug Data */
require_once __DIR__ . '/debugger/class-jetpack-debug-data.php';
/* The "In-Plugin Debugger" admin page. */
require_once __DIR__ . '/debugger/class-jetpack-debugger.php';
/* General Debugging Functions */
require_once __DIR__ . '/debugger/debug-functions.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' );
@@ -0,0 +1,549 @@
<?php
/**
* Base class for Jetpack's debugging tests.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Status;
/**
* 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 automattic/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['short_description'] ) ) {
unset( $results[ $test ] );
}
}
return $results;
}
/**
* Helper function to return consistent responses for a passing test.
* Possible Args:
* - name: string The raw method name that runs the test. Default 'unnamed_test'.
* - label: bool|string If false, tests will be labeled with their `name`. You can pass a string to override this behavior. Default false.
* - short_description: bool|string A brief, non-html description that will appear in CLI results. Default 'Test passed!'.
* - long_description: bool|string An html description that will appear in the site health page. Default false.
* - severity: bool|string 'critical', 'recommended', or 'good'. Default: false.
* - action: bool|string A URL for the recommended action. Default: false
* - action_label: bool|string The label for the recommended action. Default: false
* - show_in_site_health: bool True if the test should be shown on the Site Health page. Default: true
*
* @param array $args Arguments to override defaults.
*
* @return array Test results.
*/
public static function passing_test( $args ) {
$defaults = self::test_result_defaults();
$defaults['short_description'] = __( 'Test passed!', 'jetpack' );
$args = wp_parse_args( $args, $defaults );
$args['pass'] = true;
return $args;
}
/**
* Helper function to return consistent responses for a skipped test.
* Possible Args:
* - name: string The raw method name that runs the test. Default unnamed_test.
* - label: bool|string If false, tests will be labeled with their `name`. You can pass a string to override this behavior. Default false.
* - short_description: bool|string A brief, non-html description that will appear in CLI results, and as headings in admin UIs. Default false.
* - long_description: bool|string An html description that will appear in the site health page. Default false.
* - severity: bool|string 'critical', 'recommended', or 'good'. Default: false.
* - action: bool|string A URL for the recommended action. Default: false
* - action_label: bool|string The label for the recommended action. Default: false
* - show_in_site_health: bool True if the test should be shown on the Site Health page. Default: true
*
* @param array $args Arguments to override defaults.
*
* @return array Test results.
*/
public static function skipped_test( $args = array() ) {
$args = wp_parse_args(
$args,
self::test_result_defaults()
);
$args['pass'] = 'skipped';
return $args;
}
/**
* Helper function to return consistent responses for an informational test.
* Possible Args:
* - name: string The raw method name that runs the test. Default unnamed_test.
* - label: bool|string If false, tests will be labeled with their `name`. You can pass a string to override this behavior. Default false.
* - short_description: bool|string A brief, non-html description that will appear in CLI results, and as headings in admin UIs. Default false.
* - long_description: bool|string An html description that will appear in the site health page. Default false.
* - severity: bool|string 'critical', 'recommended', or 'good'. Default: false.
* - action: bool|string A URL for the recommended action. Default: false
* - action_label: bool|string The label for the recommended action. Default: false
* - show_in_site_health: bool True if the test should be shown on the Site Health page. Default: true
*
* @param array $args Arguments to override defaults.
*
* @return array Test results.
*/
public static function informational_test( $args = array() ) {
$args = wp_parse_args(
$args,
self::test_result_defaults()
);
$args['pass'] = 'informational';
return $args;
}
/**
* Helper function to return consistent responses for a failing test.
* Possible Args:
* - name: string The raw method name that runs the test. Default unnamed_test.
* - label: bool|string If false, tests will be labeled with their `name`. You can pass a string to override this behavior. Default false.
* - short_description: bool|string A brief, non-html description that will appear in CLI results, and as headings in admin UIs. Default 'Test failed!'.
* - long_description: bool|string An html description that will appear in the site health page. Default false.
* - severity: bool|string 'critical', 'recommended', or 'good'. Default: 'critical'.
* - action: bool|string A URL for the recommended action. Default: false.
* - action_label: bool|string The label for the recommended action. Default: false.
* - show_in_site_health: bool True if the test should be shown on the Site Health page. Default: true
*
* @since 7.1.0
*
* @param array $args Arguments to override defaults.
*
* @return array Test results.
*/
public static function failing_test( $args ) {
$defaults = self::test_result_defaults();
$defaults['short_description'] = __( 'Test failed!', 'jetpack' );
$defaults['severity'] = 'critical';
$args = wp_parse_args( $args, $defaults );
$args['pass'] = false;
return $args;
}
/**
* Provides defaults for test arguments.
*
* @since 8.5.0
*
* @return array Result defaults.
*/
private static function test_result_defaults() {
return array(
'name' => 'unnamed_test',
'label' => false,
'short_description' => false,
'long_description' => false,
'severity' => false,
'action' => false,
'action_label' => false,
'show_in_site_health' => true,
);
}
/**
* 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 ( ( new Status() )->is_offline_mode() ) {
WP_CLI::line( __( 'Jetpack is in Offline 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['short_description'] ) {
WP_CLI::log( ' ' . $test['short_description'] ); // Number of spaces to "tab indent" the reason.
}
} elseif ( 'informational' === $test['pass'] ) {
WP_CLI::log( WP_CLI::colorize( '%yInfo:%n ' . $test['name'] ) );
if ( $test['short_description'] ) {
WP_CLI::log( ' ' . $test['short_description'] ); // Number of spaces to "tab indent" the reason.
}
} else { // Failed.
WP_CLI::log( WP_CLI::colorize( '%rFailed:%n ' . $test['name'] ) );
WP_CLI::log( ' ' . $test['short_description'] ); // 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['short_description'];
$data = array(
'resolution' => $result['action'] ?
$result['action_label'] . ' :' . $result['action'] :
'',
);
if ( ! $error ) {
$error = new WP_Error( $code, $message, $data );
} else {
$error->add( $code, $message, $data );
}
}
return $error;
}
/**
* Encrypt data for sending to WordPress.com.
*
* @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 );
// Select the first allowed cipher method.
$allowed_methods = array( 'aes-256-ctr', 'aes-256-cbc' );
$methods = array_intersect( $allowed_methods, openssl_get_cipher_methods() );
$method = array_shift( $methods );
$iv = '';
if ( $public_key && $method && openssl_seal( $data, $encrypted_data, $env_key, array( $public_key ), $method, $iv ) ) {
// 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
'iv' => base64_encode( $iv ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
'cipher' => strtoupper( $method ),
);
}
// openssl_free_key was deprecated as no longer needed in PHP 8.0+. Can remove when PHP 8.0 is our minimum. (lol).
if ( PHP_VERSION_ID < 80000 ) {
openssl_free_key( $public_key ); // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.openssl_free_keyDeprecated, Generic.PHP.DeprecatedFunctions.Deprecated
}
return $return;
}
}
@@ -0,0 +1,815 @@
<?php
/**
* Collection of tests to run on the Jetpack connection locally.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Connection\Tokens;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Sync\Health as Sync_Health;
use Automattic\Jetpack\Sync\Settings as Sync_Settings;
/**
* 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 ( ! str_contains( $method, 'test__' ) ) {
continue;
}
$this->add_test( array( $this, $method ), $method, 'direct' );
}
/**
* Fires after loading default Jetpack Connection tests.
*
* @since 7.1.0
* @since 8.3.0 Passes the Jetpack_Cxn_Tests instance.
*/
do_action( 'jetpack_connection_tests_loaded', $this );
/**
* Determines if the WP.com testing suite should be included.
*
* @since 7.1.0
* @since 8.1.0 Default false.
*
* @param bool $run_test To run the WP.com testing suite. Default false.
*/
if ( apply_filters( 'jetpack_debugger_run_self_test', false ) ) {
/**
* 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_connection_ready() && ! ( new Status() )->is_offline_mode();
}
/**
* Retrieve the `blog_token` if it exists.
*
* @return object|false
*/
protected function helper_get_blog_token() {
return ( new Tokens() )->get_access_token();
}
/**
* Returns a support url based on using a development version.
*/
protected function helper_get_support_url() {
return Jetpack::is_development_version()
? Redirect::get_url( 'jetpack-contact-support-beta-group' )
: Redirect::get_url( 'jetpack-contact-support' );
}
/**
* Returns the url to reconnect Jetpack.
*
* @return string The reconnect url.
*/
protected static function helper_get_reconnect_url() {
return admin_url( 'admin.php?page=jetpack#/reconnect' );
}
/**
* Gets translated support text.
*/
protected function helper_get_support_text() {
return __( 'Please contact Jetpack support.', 'jetpack' );
}
/**
* Returns the translated text to reconnect Jetpack.
*
* @return string The translated reconnect text.
*/
protected static function helper_get_reconnect_text() {
return __( 'Reconnect Jetpack now', 'jetpack' );
}
/**
* Returns the translated text for failing tests due to timeouts.
*
* @return string The translated timeout text.
*/
protected static function helper_get_timeout_text() {
return __( 'The test timed out which may sometimes indicate a failure or may be a false failure. Please relaunch tests.', 'jetpack' );
}
/**
* Gets translated reconnect long description.
*
* @param string $connection_error The connection specific error.
* @param string $recommendation The recommendation for resolving the connection error.
*
* @return string The translated long description for reconnection recommendations.
*/
protected static function helper_get_reconnect_long_description( $connection_error, $recommendation ) {
return sprintf(
'<p>%1$s</p>' .
'<p><span class="dashicons fail"><span class="screen-reader-text">%2$s</span></span> %3$s</p><p><strong>%4$s</strong></p>',
__( 'A healthy connection ensures Jetpack essential services are provided to your WordPress site, such as Stats and Site Security.', 'jetpack' ),
/* translators: screen reader text indicating a test failed */
__( 'Error', 'jetpack' ),
$connection_error,
$recommendation
);
}
/**
* Helper function to return consistent responses for a connection failing test.
*
* @param string $name The raw method name that runs the test. Default unnamed_test.
* @param string $connection_error The connection specific error. Default 'Your site is not connected to Jetpack.'.
* @param string $recommendation The recommendation for resolving the connection error. Default 'We recommend reconnecting Jetpack.'.
*
* @return array Test results.
*/
public static function connection_failing_test( $name, $connection_error = '', $recommendation = '' ) {
$connection_error = empty( $connection_error ) ? __( 'Your site is not connected to Jetpack.', 'jetpack' ) : $connection_error;
$recommendation = empty( $recommendation ) ? __( 'We recommend reconnecting Jetpack.', 'jetpack' ) : $recommendation;
$args = array(
'name' => $name,
'short_description' => $connection_error,
'action' => self::helper_get_reconnect_url(),
'action_label' => self::helper_get_reconnect_text(),
'long_description' => self::helper_get_reconnect_long_description( $connection_error, $recommendation ),
);
return self::failing_test( $args );
}
/**
* Gets translated text to enable outbound requests.
*
* @param string $protocol Either 'HTTP' or 'HTTPS'.
*
* @return string The translated text.
*/
protected function helper_enable_outbound_requests( $protocol ) {
return sprintf(
/* translators: %1$s - request protocol, either http or https */
__(
'Your server did not successfully connect to the Jetpack server using %1$s
Please ask your hosting provider to confirm your server can make outbound requests to jetpack.com.',
'jetpack'
),
$protocol
);
}
/**
* 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 increase_timeout() {
return 30; // seconds.
}
/**
* The test verifies the blog token exists.
*
* @return array
*/
protected function test__blog_token_if_exists() {
$name = __FUNCTION__;
if ( ! $this->helper_is_jetpack_connected() ) {
return self::skipped_test(
array(
'name' => $name,
'short_description' => __( 'Jetpack is not connected. No blog token to check.', 'jetpack' ),
)
);
}
$blog_token = $this->helper_get_blog_token();
if ( $blog_token ) {
$result = self::passing_test( array( 'name' => $name ) );
} else {
$connection_error = __( 'Blog token is missing.', 'jetpack' );
$result = self::connection_failing_test( $name, $connection_error );
}
return $result;
}
/**
* Test if Jetpack is connected.
*/
protected function test__check_if_connected() {
$name = __FUNCTION__;
if ( ! $this->helper_get_blog_token() ) {
return self::skipped_test(
array(
'name' => $name,
'short_description' => __( 'Blog token is missing.', 'jetpack' ),
)
);
}
if ( $this->helper_is_jetpack_connected() ) {
$result = self::passing_test(
array(
'name' => $name,
'label' => __( 'Your site is connected to Jetpack', 'jetpack' ),
'long_description' => sprintf(
'<p>%1$s</p>' .
'<p><span class="dashicons pass"><span class="screen-reader-text">%2$s</span></span> %3$s</p>',
__( 'A healthy connection ensures Jetpack essential services are provided to your WordPress site, such as Stats and Site Security.', 'jetpack' ),
/* translators: Screen reader text indicating a test has passed */
__( 'Passed', 'jetpack' ),
__( 'Your site is connected to Jetpack.', 'jetpack' )
),
)
);
} elseif ( ( new Status() )->is_offline_mode() ) {
$result = self::skipped_test(
array(
'name' => $name,
'short_description' => __( 'Jetpack is in Offline Mode:', 'jetpack' ) . ' ' . Jetpack::development_mode_trigger_text(),
)
);
} else {
$connection_error = __( 'Your site is not connected to Jetpack', 'jetpack' );
$result = self::connection_failing_test( $name, $connection_error );
}
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(
array(
'name' => $name,
'short_description' => __( 'Jetpack is not connected. No master user to check.', 'jetpack' ),
)
);
}
if ( ! ( new Connection_Manager() )->get_connection_owner_id() ) {
return self::skipped_test(
array(
'name' => $name,
'short_description' => __( 'Jetpack is running without a connected user. No master user to check.', 'jetpack' ),
)
);
}
$local_user = $this->helper_retrieve_local_master_user();
if ( $local_user->exists() ) {
$result = self::passing_test( array( 'name' => $name ) );
} else {
$connection_error = __( 'The user who setup the Jetpack connection no longer exists on this site.', 'jetpack' );
$result = self::connection_failing_test( $name, $connection_error );
}
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(
array(
'name' => $name,
'short_description' => __( 'Jetpack is not connected.', 'jetpack' ),
)
);
}
if ( ! ( new Connection_Manager() )->get_connection_owner_id() ) {
return self::skipped_test(
array(
'name' => $name,
'short_description' => __( 'Jetpack is running without a connected user. No master user to check.', 'jetpack' ),
)
);
}
$master_user = $this->helper_retrieve_local_master_user();
if ( user_can( $master_user, 'manage_options' ) ) {
$result = self::passing_test( array( 'name' => $name ) );
} else {
/* translators: a WordPress username */
$connection_error = sprintf( __( 'The user (%s) who setup the Jetpack connection is not an administrator.', 'jetpack' ), $master_user->user_login );
/* translators: a WordPress username */
$recommendation = sprintf( __( 'We recommend either upgrading the user (%s) or reconnecting Jetpack.', 'jetpack' ), $master_user->user_login );
$result = self::connection_failing_test( $name, $connection_error, $recommendation );
}
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( array( 'name' => $name ) );
} else {
$result = self::failing_test(
array(
'name' => $name,
'label' => __( 'PHP XML manipulation libraries are not available.', 'jetpack' ),
'short_description' => __( 'Please ask your hosting provider to refer to our server requirements and enable PHP\'s XML module.', 'jetpack' ),
'action_label' => __( 'View our server requirements', 'jetpack' ),
'action' => Redirect::get_url( 'jetpack-support-server-requirements' ),
)
);
}
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 === (int) $code ) {
$result = self::passing_test( array( 'name' => $name ) );
} else {
$result = self::failing_test(
array(
'name' => $name,
'short_description' => $this->helper_enable_outbound_requests( 'HTTP' ),
)
);
}
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 === (int) $code ) {
$result = self::passing_test( array( 'name' => $name ) );
} else {
$result = self::failing_test(
array(
'name' => $name,
'short_description' => $this->helper_enable_outbound_requests( 'HTTPS' ),
)
);
}
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(
array(
'name' => $name,
'short_description' => __( 'Jetpack is not connected.', 'jetpack' ),
)
);
}
$identity_crisis = Jetpack::check_identity_crisis();
if ( ! $identity_crisis ) {
$result = self::passing_test( array( 'name' => $name ) );
} else {
$result = self::failing_test(
array(
'name' => $name,
'short_description' => 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']
),
'action_label' => $this->helper_get_support_text(),
'action' => $this->helper_get_support_url(),
)
);
}
return $result;
}
/**
* Tests the health of the Connection tokens.
*
* This will always check the blog token health. It will also check the user token health if
* a user is logged in and connected, or if there's a connected owner.
*
* @since 9.0.0
* @since 9.6.0 Checks only blog token if current user not connected or site does not have a connected owner.
*
* @return array Test results.
*/
protected function test__connection_token_health() {
$name = __FUNCTION__;
$m = new Connection_Manager();
$user_id = get_current_user_id();
// Check if there's a connected logged in user.
if ( $user_id && ! $m->is_user_connected( $user_id ) ) {
$user_id = false;
}
// If no logged in user to check, let's see if there's a master_user set.
if ( ! $user_id ) {
$user_id = Jetpack_Options::get_option( 'master_user' );
if ( $user_id && ! $m->is_user_connected( $user_id ) ) {
return self::connection_failing_test( $name, __( 'Missing token for the connection owner.', 'jetpack' ) );
}
}
if ( $user_id ) {
return $this->check_tokens_health( $user_id );
} else {
return $this->check_blog_token_health();
}
}
/**
* Tests blog and user's token against wp.com's check-token-health endpoint.
*
* @since 9.6.0
*
* @return array Test results.
*/
protected function check_blog_token_health() {
$name = 'test__connection_token_health';
$valid = ( new Tokens() )->validate_blog_token();
if ( ! $valid ) {
return self::connection_failing_test( $name, __( 'Blog token validation failed.', 'jetpack' ) );
} else {
return self::passing_test( array( 'name' => $name ) );
}
}
/**
* Tests blog token against wp.com's check-token-health endpoint.
*
* @since 9.6.0
*
* @param int $user_id The user ID to check the tokens for.
*
* @return array Test results.
*/
protected function check_tokens_health( $user_id ) {
$name = 'test__connection_token_health';
$validated_tokens = ( new Tokens() )->validate( $user_id );
if ( ! is_array( $validated_tokens ) || count( array_diff_key( array_flip( array( 'blog_token', 'user_token' ) ), $validated_tokens ) ) ) {
return self::skipped_test(
array(
'name' => $name,
'short_description' => __( 'Token health check failed to validate tokens.', 'jetpack' ),
)
);
}
$invalid_tokens_exist = false;
foreach ( $validated_tokens as $validated_token ) {
if ( ! $validated_token['is_healthy'] ) {
$invalid_tokens_exist = true;
break;
}
}
if ( false === $invalid_tokens_exist ) {
return self::passing_test( array( 'name' => $name ) );
}
$connection_error = __( 'Invalid Jetpack connection tokens.', 'jetpack' );
return self::connection_failing_test( $name, $connection_error );
}
/**
* 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__;
$status = new Status();
if ( ! Jetpack::is_connection_ready() || $status->is_offline_mode() || $status->in_safe_mode() || ! $this->pass ) {
return self::skipped_test( array( 'name' => $name ) );
}
add_filter( 'http_request_timeout', array( 'Jetpack_Cxn_Tests', 'increase_timeout' ) );
$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/jetpack-blogs/%d/test-connection', Jetpack_Options::get_option( 'id' ) ),
Client::WPCOM_JSON_API_VERSION
);
remove_filter( 'http_request_timeout', array( 'Jetpack_Cxn_Tests', 'increase_timeout' ) );
if ( is_wp_error( $response ) ) {
if ( str_contains( $response->get_error_message(), 'cURL error 28' ) ) { // Timeout.
$result = self::skipped_test(
array(
'name' => $name,
'short_description' => self::helper_get_timeout_text(),
)
);
} else {
/* 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() );
$result = self::connection_failing_test( $name, $message );
}
return $result;
}
$body = wp_remote_retrieve_body( $response );
if ( ! $body ) {
return self::failing_test(
array(
'name' => $name,
'short_description' => __( 'Connection test failed (empty response body)', 'jetpack' ) . wp_remote_retrieve_response_code( $response ),
'action_label' => $this->helper_get_support_text(),
'action' => $this->helper_get_support_url(),
)
);
}
if ( 404 === wp_remote_retrieve_response_code( $response ) ) {
return self::skipped_test(
array(
'name' => $name,
'short_description' => __( 'The WordPress.com API returned a 404 error.', 'jetpack' ),
)
);
}
$result = json_decode( $body );
$is_connected = ! empty( $result->connected );
$message = $result->message . ': ' . wp_remote_retrieve_response_code( $response );
if ( $is_connected ) {
$res = self::passing_test( array( 'name' => $name ) );
} else {
$res = self::connection_failing_test( $name, $message );
}
return $res;
}
/**
* 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'] ) ) {
return self::skipped_test(
array(
'name' => $name,
'short_description' => __( 'The server port values are not defined. This is most common when running PHP via a CLI.', 'jetpack' ),
)
);
}
$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( array( 'name' => $name ) ); // Not currently testing for this situation.
}
if ( is_ssl() && in_array( $server_port, $https_ports, true ) ) {
return self::passing_test( array( 'name' => $name ) );
} elseif ( in_array( $server_port, $http_ports, true ) ) {
return self::passing_test( array( 'name' => $name ) );
} else {
if ( is_ssl() ) {
$needed_constant = 'JETPACK_SIGNATURE__HTTPS_PORT';
} else {
$needed_constant = 'JETPACK_SIGNATURE__HTTP_PORT';
}
return self::failing_test(
array(
'name' => $name,
'short_description' => sprintf(
/* translators: %1$s - a PHP code snippet */
__(
'The server port value is unexpected.
Try adding the following to your wp-config.php file: %1$s',
'jetpack'
),
"define( '$needed_constant', $server_port )"
),
)
);
}
}
/**
* Sync Health Tests.
*
* Disabled: Results in a failing test (recommended)
* Delayed: Results in failing test (recommended)
* Error: Results in failing test (critical)
*/
protected function test__sync_health() {
$name = __FUNCTION__;
if ( ! $this->helper_is_jetpack_connected() ) {
// If the site is not connected, there is no point in testing Sync health.
return self::skipped_test(
array(
'name' => $name,
'show_in_site_health' => false,
)
);
}
// Sync is disabled.
if ( ! Sync_Settings::is_sync_enabled() ) {
return self::failing_test(
array(
'name' => $name,
'label' => __( 'Jetpack Sync has been disabled on your site.', 'jetpack' ),
'severity' => 'recommended',
'action' => 'https://github.com/Automattic/jetpack/blob/trunk/projects/packages/sync/src/class-settings.php',
'action_label' => __( 'See Github for more on Sync Settings', 'jetpack' ),
'short_description' => __( 'Jetpack Sync has been disabled on your site. This could be impacting some of your sites Jetpack-powered features. Developers may enable / disable syncing using the Sync Settings API.', 'jetpack' ),
)
);
}
// Sync has experienced Data Loss.
if ( Sync_Health::get_status() === Sync_Health::STATUS_OUT_OF_SYNC ) {
return self::failing_test(
array(
'name' => $name,
'label' => __( 'Jetpack has detected a problem with the communication between your site and WordPress.com', 'jetpack' ),
'severity' => 'critical',
'action' => Redirect::get_url( 'jetpack-contact-support' ),
'action_label' => __( 'Contact Jetpack Support', 'jetpack' ),
'short_description' => __( 'There is a problem with the communication between your site and WordPress.com. This could be impacting some of your sites Jetpack-powered features. If you continue to see this error, please contact support for assistance.', 'jetpack' ),
)
);
}
return self::passing_test( array( 'name' => $name ) );
}
/**
* 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.
*
* @since 7.1.0
* @since 7.9.0 Timeout waiting for a WP.com response no longer fails the test. Test is marked skipped instead.
*
* @return array Test results.
*/
protected function last__wpcom_self_test() {
$name = 'test__wpcom_self_test';
$status = new Status();
if ( ! Jetpack::is_connection_ready() || $status->is_offline_mode() || $status->in_safe_mode() || ! $this->pass ) {
return self::skipped_test( array( 'name' => $name ) );
}
$self_xml_rpc_url = site_url( 'xmlrpc.php' );
$testsite_url = JETPACK__API_BASE . 'testsite/1/?url=';
// Using PHP_INT_MAX - 1 so that there is still a way to override this if needed and since it only impacts this one call.
add_filter( 'http_request_timeout', array( 'Jetpack_Cxn_Tests', 'increase_timeout' ), PHP_INT_MAX - 1 );
$response = wp_remote_get( $testsite_url . $self_xml_rpc_url );
remove_filter( 'http_request_timeout', array( 'Jetpack_Cxn_Tests', 'increase_timeout' ), PHP_INT_MAX - 1 );
if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
$result = self::passing_test( array( 'name' => $name ) );
} elseif ( is_wp_error( $response ) && str_contains( $response->get_error_message(), 'cURL error 28' ) ) { // Timeout.
$result = self::skipped_test(
array(
'name' => $name,
'short_description' => self::helper_get_timeout_text(),
)
);
} else {
$result = self::failing_test(
array(
'name' => $name,
'short_description' => sprintf(
/* translators: %1$s - A debugging url */
__( 'Jetpack.com detected an error on the WP.com Self Test. Visit the Jetpack Debug page for more info: %1$s, or contact support.', 'jetpack' ),
Redirect::get_url( 'jetpack-support-debug', array( 'query' => 'url=' . rawurlencode( site_url() ) ) )
),
'action_label' => $this->helper_get_support_text(),
'action' => $this->helper_get_support_url(),
)
);
}
return $result;
}
}
@@ -0,0 +1,413 @@
<?php
/**
* Jetpack Debug Data for the Site Health sections.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Tokens;
use Automattic\Jetpack\Connection\Urls;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
use Automattic\Jetpack\Identity_Crisis;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Sync\Modules;
use Automattic\Jetpack\Sync\Sender;
/**
* 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 = (int) $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 = (int) ( $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 ) {
$support_url = Jetpack::is_development_version()
? Redirect::get_url( 'jetpack-contact-support-beta-group' )
: Redirect::get_url( 'jetpack-contact-support' );
$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_url( $support_url ),
__( '(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(), // Only ID number and user name.
'private' => false,
);
$debug_info['is_offline_mode'] = array(
'label' => 'Jetpack Offline Mode',
'value' => ( new Status() )->is_offline_mode() ? 'on' : 'off',
'private' => false,
);
$debug_info['is_offline_mode_constant'] = array(
'label' => 'JETPACK_DEV_DEBUG Constant',
'value' => ( defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG ) ? 'on' : 'off',
'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();
$blog_token = ( new Tokens() )->get_access_token();
$user_token = ( new Tokens() )->get_access_token( $user_id );
$tokenset = '';
if ( $blog_token ) {
$tokenset = 'Blog ';
$blog_key = substr( $blog_token->secret, 0, strpos( $blog_token->secret, '.' ) );
// 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->secret, 0, strpos( $user_token->secret, '.' ) );
// 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' => empty( $_SERVER[ $header ] ) ? 'false' : filter_var( wp_unslash( $_SERVER[ $header ] ) ),
'private' => true, // This isn't really 'private' information, but we don't want folks to easily paste these into public forums.
);
}
}
$debug_info['protect_header'] = array(
'label' => 'Trusted IP',
'value' => wp_json_encode( get_site_option( 'trusted_ip_header' ) ),
'private' => false,
);
/** Sync Debug Information */
$sync_module = Modules::get_module( 'full-sync' );
'@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $sync_module';
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 )
? gmdate( '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 = 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 = 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' => Urls::home_url(),
'siteurl' => Urls::site_url(),
'WP_HOME' => Constants::is_defined( 'WP_HOME' ) ? Constants::get_constant( 'WP_HOME' ) : '',
'WP_SITEURL' => Constants::is_defined( 'WP_SITEURL' ) ? 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' => Identity_Crisis::should_handle_idc(),
'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', $user->ID, $user->user_login ); // Format: "#1 username".
}
}
@@ -0,0 +1,363 @@
<?php
/**
* Jetpack Debugger functionality allowing for self-service diagnostic information via the legacy jetpack debugger.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Status;
/**
* Class Jetpack_Debugger
*
* A namespacing class for functionality related to the legacy in-plugin diagnostic tooling.
*/
class Jetpack_Debugger {
/**
* Disconnect Jetpack and redirect user to connection flow.
*
* Used in class.jetpack-admin.php.
*/
public static function disconnect_and_redirect() {
if ( ! ( isset( $_GET['nonce'] ) && wp_verify_nonce( $_GET['nonce'], 'jp_disconnect' ) ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
return;
}
if ( ! empty( $_GET['disconnect'] ) ) {
if ( Jetpack::is_connection_ready() ) {
Jetpack::disconnect();
wp_safe_redirect( Jetpack::admin_url() );
exit( 0 );
}
}
}
/**
* Handles output to the browser for the in-plugin debugger.
*/
public static function jetpack_debug_display_handler() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'jetpack' ) );
}
$support_url = Jetpack::is_development_version()
? Redirect::get_url( 'jetpack-contact-support-beta-group' )
: Redirect::get_url( 'jetpack-contact-support' );
$cxntests = new Jetpack_Cxn_Tests();
?>
<div class="wrap">
<div class="jp-static-block">
<h2><?php esc_html_e( 'Debugging Center', 'jetpack' ); ?></h2>
<div class="jp-static-block-body">
<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>';
} else {
$failures = $cxntests->list_fails();
foreach ( $failures as $fail ) {
?>
<div class="notice notice-error inline">
<div class="notice-icon-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 24 24" width="24" height="24" class="y_IPyP1wIAOhyNaqvXJq" aria-hidden="true" focusable="false"><path d="M10 2c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8 3.58-8 8-8zm1.13 9.38l.35-6.46H8.52l.35 6.46h2.26zm-.09 3.36c.24-.23.37-.55.37-.96 0-.42-.12-.74-.36-.97s-.59-.35-1.06-.35-.82.12-1.07.35-.37.55-.37.97c0 .41.13.73.38.96.26.23.61.34 1.06.34s.8-.11 1.05-.34z"></path></svg>
</div>
<div class="notice-main-content">
<div class="notice-title"><?php echo esc_html( $fail['short_description'] ); ?></div>
<div class="notice-action-bar">
<div>
<a href="<?php echo esc_attr( $fail['action'] ); ?>" aria-disabled="false" class="components-button is-primary"><span><?php echo esc_html( $fail['action_label'] ); ?></span></a>
</div>
</div>
</div>
</div>
<?php
}
}
?>
</div>
<div class="entry-content">
<h4><?php esc_html_e( 'Trouble with Jetpack?', 'jetpack' ); ?></h4>
<p><?php esc_html_e( 'It may be caused by one of these issues, which you can diagnose yourself:', 'jetpack' ); ?></p>
<ol>
<li><?php esc_html_e( 'A known issue.', 'jetpack' ); ?>
<?php
printf(
wp_kses(
/* translators: URLs to Jetpack support pages. */
__( 'Some themes and plugins have <a href="%1$s" target="_blank" rel="noopener noreferrer">known conflicts</a> with Jetpack check the list. (You can also browse the <a href="%2$s" target="_blank" rel="noopener noreferrer">Jetpack support pages</a> or <a href="%3$s" target="_blank" rel="noopener noreferrer">Jetpack support forum</a> to see if others have experienced and solved the problem.)', 'jetpack' ),
array(
'a' => array(
'href' => array(),
'target' => array(),
'rel' => array(),
),
)
),
esc_url( Redirect::get_url( 'jetpack-contact-support-known-issues' ) ),
esc_url( Redirect::get_url( 'jetpack-support' ) ),
esc_url( Redirect::get_url( 'wporg-support-plugin-jetpack' ) )
);
?>
</li>
<li>
<?php esc_html_e( 'An incompatible plugin.', 'jetpack' ); ?>
<?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>
<?php esc_html_e( 'A theme conflict.', 'jetpack' ); ?>
<?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>
<?php esc_html_e( 'A problem with your XML-RPC file.', 'jetpack' ); ?>
<?php
printf(
wp_kses(
/* translators: The URL to the site's xmlrpc.php file. */
__( 'Load your <a href="%s">XML-RPC 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 the XML-RPC file.', 'jetpack' ); ?></li>
</ul>
</li>
<?php if ( current_user_can( 'jetpack_disconnect' ) && Jetpack::is_connection_ready() ) : ?>
<li>
<?php esc_html_e( 'A connection problem with WordPress.com.', 'jetpack' ); ?>
<?php
printf(
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>
<?php esc_html_e( 'Ask us for help!', 'jetpack' ); ?>
<?php
/**
* Offload to new WordPress debug data.
*/
printf(
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() ) )
),
esc_url( $support_url ),
esc_url( admin_url() . 'site-health.php?tab=debug' )
);
?>
</p>
</div>
</div>
</div>
<div class="jp-static-block">
<h2><?php esc_html_e( 'More details about your Jetpack settings', 'jetpack' ); ?></h2>
<div class="jp-static-block-body">
<?php if ( Jetpack::is_connection_ready() ) : ?>
<div id="connected-user-details">
<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 offline mode</a>.', 'jetpack' ),
array( 'a' => array( 'href' => array() ) )
),
esc_url( Redirect::get_url( 'jetpack-support-development-mode' ) )
);
?>
</p>
</div>
<?php endif; ?>
<?php
if (
current_user_can( 'jetpack_manage_modules' )
&& ( ( new Status() )->is_offline_mode() || Jetpack::is_connection_ready() )
) {
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>
</div>
</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: 8px 0;
}
#connected-user-details p strong {
word-break: break-all;
}
.jetpack-tests-succeed {
font-size: large;
color: #069E08;
}
.jetpack-test-details {
margin: 4px 6px;
padding: 10px;
overflow: auto;
display: none;
}
.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 #dcdcde;
border-radius: 11px;
box-shadow: inset 0 1px 1px rgba(0,0,0,0.1);
color: #646970;
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: #dcdcde;
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
}
}
@@ -0,0 +1,118 @@
<?php
/**
* WP Site Health debugging functions.
*
* @package automattic/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'],
/**
* Callable for Core's Site Health system to execute.
*
* @var array $test A Jetpack Testing Suite test array.
* @var Jetpack_Cxn_Tests $cxn_tests An instance of the Jetpack Test Suite.
*
* @return array {
* A results array to match the format expected by WordPress Core.
*
* @type string $label Name for the test.
* @type string $status 'critical', 'recommended', or 'good'.
* @type array $badge Array for Site Health status. Keys label and color.
* @type string $description Description of the test result.
* @type string $action HTML to a link to resolve issue.
* @type string $test Unique test identifier.
* }
*/
'test' => function () use ( $test, $cxn_tests ) {
$results = $cxn_tests->run_test( $test['name'] );
if ( is_wp_error( $results ) ) {
return;
}
$label = $results['label'] ?
$results['label'] :
ucwords(
str_replace(
'_',
' ',
str_replace( 'test__', '', $test['name'] )
)
);
if ( $results['long_description'] ) {
$description = $results['long_description'];
} elseif ( $results['short_description'] ) {
$description = sprintf(
'<p>%s</p>',
$results['short_description']
);
} else {
$description = sprintf(
'<p>%s</p>',
__( 'This test successfully passed!', 'jetpack' )
);
}
$return = array(
'label' => $label,
'status' => 'good',
'badge' => array(
'label' => __( 'Jetpack', 'jetpack' ),
'color' => 'green',
),
'description' => $description,
'actions' => '',
'test' => 'jetpack_' . $test['name'],
);
if ( false === $results['pass'] ) {
$return['status'] = $results['severity'];
if ( ! empty( $results['action'] ) ) {
$return['actions'] = sprintf(
'<a 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'] ),
$results['action_label'],
/* 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;
}
@@ -0,0 +1,431 @@
<?php
/** phpcs:disable Squiz.Commenting.FileComment.MissingPackageTag,Generic.Commenting.DocComment.MissingShort
*
* Declare two functions to handle notification emails to authors and moderators.
*
* These functions are hooked into filters to short circuit the regular flow and send the emails.
* Code was copied from the original pluggable functions and slightly modified (modifications are commented).
*
* In the past, we used to overwrite the whole pluggable function, but we started using filters to avoid having
* to check for Jetpack::is_active() too early in the load flow.
*
* @deprecated 13.9 File became unused.
*/
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Redirect;
_deprecated_file( __FILE__, 'jetpack-13.9' );
// phpcs:disable WordPress.WP.I18n.MissingArgDomain --reason: Code copied from Core, so using Core strings.
// phpcs:disable WordPress.Utils.I18nTextDomainFixer.MissingArgDomain --reason: Code copied from Core, so using Core strings.
/**
* Short circuits the {@see `wp_notify_postauthor`} function via the `comment_notification_recipients` filter.
*
* Notify an author (and/or others) of a comment/trackback/pingback on a post.
*
* @since 5.8.0
* @since 9.3.0 Switched from pluggable function to filter callback
*
* @param array $emails List of recipients.
* @param int|WP_Comment $comment_id Comment ID or WP_Comment object.
* @return array Empty array to shortcircuit wp_notify_postauthor execution. $emails if we want to disable the filter.
*/
function jetpack_notify_postauthor( $emails, $comment_id ) {
// Don't do anything if Jetpack isn't connected.
if ( ! Jetpack::is_connection_ready() || empty( $emails ) ) {
return $emails;
}
// Original function modified: Code before the comment_notification_recipients filter removed.
$comment = get_comment( $comment_id );
if ( ! $comment ) {
return $emails;
}
$post = get_post( $comment->comment_post_ID );
$author = get_userdata( $post->post_author );
// 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 && get_current_user_id() === $post->post_author ) {
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 ( array() === $emails ) {
return array(); // Original function modified. Return empty array instead of false.
} else {
$emails = array_flip( $emails );
}
$switched_locale = switch_to_locale( get_locale() );
$comment_author_domain = '';
if ( WP_Http::is_ip_address( $comment->comment_author_IP ) ) {
$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 );
// Original function modified.
$moderate_on_wpcom = ! in_array( // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
false,
array_map( 'jetpack_notify_is_user_connected_by_email', $emails )
);
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";
/* translators: %s: Site URL */
$notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n";
/* translators: %s: Comment Content */
$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";
/* translators: %s: Site URL */
$notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n";
/* translators: %s: Comment Content */
$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.
/* translators: 1: Post title */
$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";
/* translators: %s: Email address */
$notify_message .= sprintf( __( 'Email: %s' ), $comment->comment_author_email ) . "\r\n";
/* translators: %s: Site URL */
$notify_message .= sprintf( __( 'URL: %s' ), $comment->comment_author_url ) . "\r\n";
/* translators: %s: Comment Content */
$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;
}
// Original function modified: Consider $moderate_on_wpcom when building $notify_message.
$notify_message .= $moderate_on_wpcom
? Redirect::get_url(
'calypso-comments-all',
array(
'path' => $comment->comment_post_ID,
)
) . "/\r\n\r\n"
: get_permalink( $comment->comment_post_ID ) . "#comments\r\n\r\n";
/* translators: %s: URL */
$notify_message .= sprintf( __( 'Permalink: %s' ), get_comment_link( $comment ) ) . "\r\n";
$base_wpcom_edit_comment_url = Redirect::get_url(
'calypso-edit-comment',
array(
'path' => $comment_id,
'query' => 'action=__action__', // __action__ will be replaced by the actual action.
)
);
// Original function modified: Consider $moderate_on_wpcom when building $notify_message.
if ( user_can( $post->post_author, 'edit_comment', $comment->comment_ID ) ) {
if ( EMPTY_TRASH_DAYS ) {
$notify_message .= sprintf(
/* translators: Placeholder is the edit URL */
__( 'Trash it: %s' ),
$moderate_on_wpcom
? str_replace( '__action__', 'trash', $base_wpcom_edit_comment_url )
: admin_url( "comment.php?action=trash&c={$comment->comment_ID}#wpbody-content" )
) . "\r\n";
} else {
$notify_message .= sprintf(
/* translators: Placeholder is the edit URL */
__( 'Delete it: %s' ),
$moderate_on_wpcom
? str_replace( '__action__', 'delete', $base_wpcom_edit_comment_url )
: admin_url( "comment.php?action=delete&c={$comment->comment_ID}#wpbody-content" )
) . "\r\n";
}
$notify_message .= sprintf(
/* translators: Placeholder is the edit URL */
__( 'Spam it: %s' ),
$moderate_on_wpcom
? str_replace( '__action__', 'spam', $base_wpcom_edit_comment_url )
: admin_url( "comment.php?action=spam&c={$comment->comment_ID}#wpbody-content" )
) . "\r\n";
}
$wp_email = 'wordpress@' . preg_replace( '#^www\.#', '', strtolower( isset( $_SERVER['SERVER_NAME'] ) ? filter_var( wp_unslash( $_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 array();
}
/**
* Short circuits the {@see `wp_notify_moderator`} function via the `notify_moderator` filter.
*
* Notifies the moderator of the site about a new comment that is awaiting approval.
*
* @since 5.8.0
* @since 9.2.0 Switched from pluggable function to filter callback
* @since 9.5.0 Updated the passing condition to call get_option( 'moderation_notify' ); directly.
*
* @global wpdb $wpdb WordPress database abstraction object.
*
* @param string $notify_moderator The value of the moderation_notify option OR if the comment is awaiting moderation.
* @param int $comment_id Comment ID.
* @return boolean Returns false to shortcircuit the execution of wp_notify_moderator
*/
function jetpack_notify_moderator( $notify_moderator, $comment_id ) {
/*
* $notify_moderator is a tricky one. This filter is called in two places in Core. One is just to pass if a comment
* is being held for moderation. See https://core.trac.wordpress.org/browser/tags/5.6/src/wp-includes/comment.php#L2296
*
* So we can't just assume that a true value here is what we need. The second time the filter is called, it checks
* the option -- which is what we expected here. See https://core.trac.wordpress.org/browser/tags/5.6/src/wp-includes/pluggable.php#L1737
*
* It's possible another plugin would be filtering this value to true despite the option setting; however, since we're running at priority 1,
* they can still do that. They'll just get the Core flow instead of this one.
*/
// If Jetpack is not active, or if Notify moderators options is not set, let the default flow go on.
if ( ! $notify_moderator || ! get_option( 'moderation_notify' ) || ! Jetpack::is_connection_ready() ) {
return $notify_moderator;
}
// Original function modified: Removed code before the notify_moderator filter.
global $wpdb;
$comment = get_comment( $comment_id );
if ( ! $comment ) {
return $notify_moderator;
}
$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 = '';
if ( WP_Http::is_ip_address( $comment->comment_author_IP ) ) {
$comment_author_domain = gethostbyaddr( $comment->comment_author_IP );
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$comments_waiting = (int) $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 );
// Original function modified.
$moderate_on_wpcom = ! in_array( // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
false,
array_map( 'jetpack_notify_is_user_connected_by_email', $emails )
);
$base_wpcom_edit_comment_url = Redirect::get_url(
'calypso-edit-comment',
array(
'path' => $comment_id,
'query' => 'action=__action__', // __action__ will be replaced by the actual action.
)
);
// Original function modified: Consider $moderate_on_wpcom when building $notify_message.
$notify_message .= sprintf(
/* translators: Comment moderation. 1: Comment action URL */
__( 'Approve it: %s' ),
$moderate_on_wpcom
? str_replace( '__action__', 'approve', $base_wpcom_edit_comment_url )
: admin_url( "comment.php?action=approve&c={$comment_id}#wpbody-content" )
) . "\r\n";
if ( EMPTY_TRASH_DAYS ) {
$notify_message .= sprintf(
/* translators: Comment moderation. 1: Comment action URL */
__( 'Trash it: %s' ),
$moderate_on_wpcom
? str_replace( '__action__', 'trash', $base_wpcom_edit_comment_url )
: admin_url( "comment.php?action=trash&c={$comment_id}#wpbody-content" )
) . "\r\n";
} else {
$notify_message .= sprintf(
/* translators: Comment moderation. 1: Comment action URL */
__( 'Delete it: %s' ),
$moderate_on_wpcom
? str_replace( '__action__', 'delete', $base_wpcom_edit_comment_url )
: admin_url( "comment.php?action=delete&c={$comment_id}#wpbody-content" )
) . "\r\n";
}
$notify_message .= sprintf(
/* translators: Comment moderation. 1: Comment action URL */
__( 'Spam it: %s' ),
$moderate_on_wpcom
? str_replace( '__action__', 'spam', $base_wpcom_edit_comment_url )
: admin_url( "comment.php?action=spam&c={$comment_id}#wpbody-content" )
) . "\r\n";
$notify_message .= sprintf(
/* translators: Comment moderation. 1: Number of comments awaiting approval */
_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
? Redirect::get_url( 'calypso-comments-pending' )
: 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 false;
}
/**
* Gets an user by email and verify if it's connected
*
* @param string $email The user email.
* @return boolean
*/
function jetpack_notify_is_user_connected_by_email( $email ) {
$user = get_user_by( 'email', $email );
return ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user->ID );
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
<?php
/**
* Class aliases for SimplePie.
*
* Core renamed the classes in 6.7, and for type declarations and such to work right we need to use the correct names.
* This provides aliases for use with WordPress 6.7.
*
* @todo Remove this once we drop support for WordPress 6.6
*
* @package automattic/jetpack
*/
class_alias( SimplePie\SimplePie::class, Jetpack\SimplePie\SimplePie::class );
class_alias( SimplePie\File::class, Jetpack\SimplePie\File::class );
class_alias( SimplePie\Item::class, Jetpack\SimplePie\Item::class );
class_alias( SimplePie\Locator::class, Jetpack\SimplePie\Locator::class );
class_alias( SimplePie\Enclosure::class, Jetpack\SimplePie\Enclosure::class );
@@ -0,0 +1,17 @@
<?php
/**
* Class aliases for SimplePie.
*
* Core renamed the classes in 6.7, and for type declarations and such to work right we need to use the correct names.
* This provides aliases for use with WordPress 6.6.
*
* @todo Remove this once we drop support for WordPress 6.6
*
* @package automattic/jetpack
*/
class_alias( SimplePie::class, Jetpack\SimplePie\SimplePie::class );
class_alias( SimplePie_File::class, Jetpack\SimplePie\File::class );
class_alias( SimplePie_Item::class, Jetpack\SimplePie\Item::class );
class_alias( SimplePie_Locator::class, Jetpack\SimplePie\Locator::class );
class_alias( SimplePie_Enclosure::class, Jetpack\SimplePie\Enclosure::class );
@@ -0,0 +1,23 @@
<?php
/**
* Class aliases for SimplePie.
*
* Core renamed the classes in 6.7, and for type declarations and such to work right we need to use the correct names.
* This selects the correct alias file for the current version of WordPress.
*
* @todo Remove this once we drop support for WordPress 6.6
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
return;
}
require_once ABSPATH . '/wp-includes/class-simplepie.php';
// @phan-suppress-next-line PhanUndeclaredClassReference -- Being tested for. @phan-suppress-current-line UnusedPluginSuppression
if ( class_exists( SimplePie\SimplePie::class ) ) {
require_once __DIR__ . '/jp-simplepie-alias-new.php';
} else {
require_once __DIR__ . '/jp-simplepie-alias-old.php';
}
@@ -0,0 +1,14 @@
<?php
/**
* Loader for the Markdown library.
*
* This file loads in a couple specific things from the markdown dir.
*
* @package automattic/jetpack
*/
if ( ! class_exists( 'MarkdownExtra_Parser' ) ) {
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/markdown/extra.php';
}
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/markdown/gfm.php';
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,445 @@
<?php
/**
* GitHub-Flavoured Markdown. Inspired by Evan's plugin, but modified.
*
* @author Evan Solomon
* @author Matt Wiebe <wiebe@automattic.com>
* @author Brandon Kraft <kraft@automattic.com> -- Strikedown support converted from GPL code at https://github.com/annaesvensson/yellow-markdown/blob/main/markdown.php
* @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.1';
/**
* 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' );
$this->span_gamut['do_strikethrough'] = 55;
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 );
}
// Do not process characters inside URLs.
$text = $this->urls_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>&lt;</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;
}
/**
* Avoid characters inside URLs from being formatted by Markdown in any way.
*
* @param string $text Text in which to preserve URLs.
*
* @return string Text with URLs replaced by a hash that will be restored later.
*/
protected function urls_preserve( $text ) {
$text = preg_replace_callback(
'#(?<!<)(?:https?|ftp)://([^\s<>"\'\[\]()]+|\[(?1)*+\]|\((?1)*+\))+(?<![_*.?])#i',
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>)?(&#35;|\\\\#)/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";
}
/**
* Add strikethrough support.
*
* GPL code modified from https://github.com/annaesvensson/yellow-markdown/blob/main/markdown.php
*/
public function do_strikethrough($text) {
$parts = preg_split("/(?<![~])(~~)(?![~])/", $text, -1, PREG_SPLIT_DELIM_CAPTURE);
if (count($parts)>3) {
$text = "";
$open = false;
foreach ($parts as $part) {
if ($part=="~~") {
$text .= $open ? "</del>" : "<del>";
$open = !$open;
} else {
$text .= $part;
}
}
if ($open) $text .= "</del>";
}
return $text;
}
}
@@ -0,0 +1,75 @@
<?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Plans Library
*
* Fetch plans data from WordPress.com.
*
* Not to be confused with the `Jetpack_Plan` (singular)
* class, which stores and syncs data about the site's _current_ plan.
*
* @package automattic/jetpack
*/
class Jetpack_Plans {
/**
* Get a list of all available plans from WordPress.com
*
* @since 7.7.0
*
* @return array The plans list
*/
public static function get_plans() {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
if ( ! class_exists( 'Store_Product_List' ) ) {
require WP_CONTENT_DIR . '/admin-plugins/wpcom-billing/store-product-list.php';
}
return Store_Product_List::api_only_get_active_plans_v1_4();
}
// We're on Jetpack, so it's safe to use this namespace.
$request = Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_user(
'/plans?_locale=' . get_user_locale(),
// We're using version 1.5 of the endpoint rather than the default version 2
// since the latter only returns Jetpack Plans, but we're also interested in
// WordPress.com plans, for consumers of this method that run on WP.com.
'1.5',
array(
'method' => 'GET',
'headers' => array(
'X-Forwarded-For' => ( new Automattic\Jetpack\Status\Visitor() )->get_ip( true ),
),
),
null,
'rest'
);
$body = wp_remote_retrieve_body( $request );
if ( 200 === wp_remote_retrieve_response_code( $request ) ) {
return json_decode( $body );
} else {
return $body;
}
}
/**
* Get plan information for a plan given its slug
*
* @since 7.7.0
*
* @param string $plan_slug Plan slug.
*
* @return object The plan object
*/
public static function get_plan( $plan_slug ) {
$plans = self::get_plans();
if ( ! is_array( $plans ) ) {
return;
}
foreach ( $plans as $plan ) {
if ( $plan_slug === $plan->product_slug ) {
return $plan;
}
}
}
}
@@ -0,0 +1,10 @@
<?php
/**
* This file has been moved to the jetpack-plugins-installer package
*
* @deprecated 10.7
*
* @package jetpack
*/
class_alias( Automattic\Jetpack\Plugins_Installer::class, 'Jetpack_Plugins' );
@@ -0,0 +1,284 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Tonesque
* Grab an average color representation from an image.
*
* @author Automattic
* @author Matias Ventura
* @package automattic/jetpack
*/
if ( ! class_exists( 'Tonesque' ) ) {
/**
* Color representation class.
*/
class Tonesque {
/**
* Image URL.
*
* @var string
*/
private $image_url = '';
/**
* Image identifier representing the image.
*
* @var null|object
*/
private $image_obj = null;
/**
* Color code.
*
* @var string
*/
private $color = '';
/**
* Constructor.
*
* @param string $image_url Image URL.
*/
public function __construct( $image_url ) {
_deprecated_function( 'Tonesque::__construct', 'jetpack-13.8' );
if ( ! class_exists( 'Jetpack_Color' ) ) {
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class.color.php';
}
$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 );
}
/**
* Get an image object from a URL.
*
* @param string $image_url Image URL.
*
* @return object|bool Image object or false if the image could not be loaded.
*/
public static function imagecreatefromurl( $image_url ) {
_deprecated_function( 'Tonesque::imagecreatefromurl', 'jetpack-13.8' );
$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 ( str_starts_with( $_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 );
$type = $filetype['type'];
if ( str_starts_with( $type, 'image/' ) ) {
$data = file_get_contents( $_image_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
}
}
}
if ( empty( $data ) ) {
$response = wp_safe_remote_get( $image_url );
$response_code = wp_remote_retrieve_response_code( $response );
if (
is_wp_error( $response )
|| ! $response_code
|| $response_code < 200
|| $response_code >= 300
) {
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 );
$type = $filetype['type'];
if ( str_starts_with( $type, 'image/' ) ) {
$data = file_get_contents( $image_url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
}
}
if ( null === $data ) {
return false;
}
// Now turn it into an image and return it.
return imagecreatefromstring( $data );
}
/**
* Construct object from image.
*
* @param string $type Type (hex, rgb, hsv) (optional).
*
* @return string|bool color as a string formatted as $type or false if the image could not be loaded.
*/
public 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.
return self::get_color( $color, $type );
}
/**
* Grabs the color index for each of five sample points of the image
*
* @param string $type can be 'index' or 'hex'.
*
* @return array|false color indices or false if the image could not be loaded.
*/
public 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
*
* @return array|bool array with rgb color or false if the image could not be loaded.
*/
public function grab_color() {
$img = $this->image_obj;
if ( ! $img ) {
return false;
}
$rgb = self::grab_points();
$r = array();
$g = array();
$b = array();
/*
* 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
*
* @param string $color Color code.
* @param string $type Color type (rgb, hex, hsv).
*
* @return string
*/
public 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 $c->toHex();
}
return $color;
}
/**
*
* Checks contrast against main color
* Gives either black or white for using with opacity
*
* @return string|bool Returns black or white or false if the image could not be loaded.
*/
public function contrast() {
if ( ! $this->color ) {
return false;
}
$c = $this->color->getMaxContrastColor();
return implode( ',', $c->toRgbInt() );
}
}
}
@@ -0,0 +1,787 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* 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
*
* @package automattic/jetpack
*/
/**
* Widgets and Sidebars Library
*/
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|null $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 (int) $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 ( (int) $widget['position'] > (int) $last_position ) {
$last_position = (int) $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 ];
$settings = self::sanitize_widget_settings( $widget_id, $settings, $old_settings );
if ( ! $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 ) {
$widget = self::get_registered_widget_object( self::get_widget_id_base( $widget_id ) );
if ( ! $widget ) {
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 $position (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' ) ) {
$tracking = new Automattic\Jetpack\Tracking();
$tracking->tracks_record_event(
wp_get_current_user(),
'wpcom_widgets_activate_widget',
array(
'widget' => $id_base,
'settings' => wp_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 = (int) self::get_widget_instance_key( $last_widget['id'] );
return $last_val;
}
return 0;
}
/**
* Method used to sort widgets
*
* @since 5.4
*
* @param array $a A normalized array representing a widget.
* @param array $b A normalized array representing a widget.
*
* @return int
*/
public static function sort_widgets( $a, $b ) {
$a_val = (int) self::get_widget_instance_key( $a['id'] );
$b_val = (int) self::get_widget_instance_key( $b['id'] );
return $a_val <=> $b_val;
}
/**
* 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.
*/
public 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.
*/
public 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 ( str_contains( $widget, $widget_id ) ) {
$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.
*/
public 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 );
}
}