This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,352 @@
<?php
/**
* Renders the unified Jetpack admin header and footer chrome on Akismet's admin pages.
*
* Akismet (5.7+) exposes two action hooks in its admin views:
* - `akismet_header`: when an action is registered, it REPLACES Akismet's default masthead.
* - `akismet_footer`: rendered at the bottom of every admin view; empty by default.
*
* This integration consumes those hooks from the Jetpack plugin so that Akismet's pages
* share the same branded header bar, standardized footer AND contained layout as the rest
* of the unified Jetpack admin (the `@wordpress/admin-ui` page header + `JetpackFooter` look
* used by My Jetpack, Protect, Social, etc.): a fixed header, an internally-scrolling middle
* and a footer pinned to the bottom of the viewport.
*
* The markup mirrors the admin-ui page header and `.jetpack-footer` component, and the
* styling is a small self-contained stylesheet (no external CSS, no JS, no build step) that
* reproduces the measured computed styles of those components plus the
* `jetpack-admin-page-layout` mixin, adapted to Akismet's markup
* (`#wpbody-content > #akismet-plugin-container > header / .akismet-lower / footer`). This
* keeps the integration resilient to the CSS-Module class hashing used by the real React
* components.
*
* NOTE: This class does not modify the Akismet plugin in any way. It only registers
* callbacks on Akismet's own action hooks.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Host;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Wires the unified Jetpack header, footer and contained layout onto Akismet's admin pages.
*/
class Akismet_Admin_Chrome {
/**
* The green Jetpack logo mark, sized via the `height` attribute by callers.
*
* @param int $height Pixel height of the logo.
* @return string SVG markup.
*/
private function jetpack_logo( $height ) {
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" height="' . (int) $height . '" class="jp-akismet-logo" aria-hidden="true"><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></svg>';
}
/**
* The Akismet logo mark — the green rounded square with the white "A", taken from
* Akismet's own `akismet-refresh-logo.svg`. Sized via the `height` attribute by callers.
*
* @param int $height Pixel height of the logo.
* @return string SVG markup.
*/
private function akismet_logo( $height ) {
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" height="' . (int) $height . '" class="jp-akismet-mark" aria-hidden="true"><rect width="44" height="44" fill="#357B49" rx="6"/><path fill="#fff" fill-rule="evenodd" d="m29.746 28.31-6.392-16.797c-.152-.397-.305-.672-.789-.675-.673 0-1.408.611-1.746 1.316l-7.378 16.154c-.072.16-.143.311-.214.454-.5.995-1.045 1.546-2.357 1.626a.399.399 0 0 0-.16.033l-.01.004a.399.399 0 0 0-.23.392v.01c0 .054.01.106.03.155l.004.01a.416.416 0 0 0 .394.252h6.212a.417.417 0 0 0 .307-.12.416.416 0 0 0 .124-.305.398.398 0 0 0-.105-.302.399.399 0 0 0-.294-.127c-.757 0-2.197-.062-2.197-1.164.02-.318.103-.63.245-.916l1.399-3.152c.52-1.163 1.654-1.163 2.572-1.163h5.843c.023 0 .044 0 .062.003.13.014.16.081.214.242l1.534 4.07a2.857 2.857 0 0 1 .216 1.04c0 .054-.003.104-.01.153-.09.726-.831.887-1.49.887a.4.4 0 0 0-.294.127l-.007.008-.007.008a.401.401 0 0 0-.092.286v.01c0 .054.01.106.03.155l.005.01a.42.42 0 0 0 .395.252h7.011a.413.413 0 0 0 .279-.13.412.412 0 0 0 .11-.297.387.387 0 0 0-.09-.294.388.388 0 0 0-.277-.135c-1.448-.122-2.295-.643-2.847-2.08Zm-11.985-5.844 2.847-6.304c.361-.728.659-1.486.889-2.265 0-.06.03-.092.06-.092s.061.032.061.091c.02.122.045.247.073.374.197.888.584 1.878.914 2.723l.176.453 1.684 4.529a.927.927 0 0 1 .092.4.473.473 0 0 1-.009.094c-.041.202-.228.272-.602.272h-6.063c-.122 0-.184-.03-.184-.092a.36.36 0 0 1 .062-.183Zm17.107-.721c0 .786-.446 1.231-1.25 1.231-.806 0-1.125-.409-1.125-1.034 0-.786.465-1.231 1.25-1.231.785 0 1.125.427 1.125 1.034ZM9.629 23.002c.803 0 1.25-.447 1.25-1.231 0-.607-.343-1.036-1.128-1.036-.785 0-1.25.447-1.25 1.231 0 .625.325 1.036 1.128 1.036Z" clip-rule="evenodd"/></svg>';
}
/**
* Register the hooks that drive the chrome.
*
* Safe to call unconditionally: the header/footer callbacks are only ever fired by
* Akismet's own admin views, and the inline stylesheet is printed alongside the header.
*
* Idempotent: this can be wired from more than one place depending on the platform —
* `Jetpack_Admin` on Atomic/self-hosted, and `Akismet_Admin_WPCOM` on WordPress.com
* Simple sites (the two run under different load orders). The static guard ensures the
* `akismet_header` / `akismet_footer` callbacks are only ever registered once, so the
* chrome can never render twice regardless of how many call sites fire.
*/
public function init_hooks() {
static $registered = false;
if ( $registered ) {
return;
}
$registered = true;
add_action( 'akismet_header', array( $this, 'render_header' ) );
add_action( 'akismet_footer', array( $this, 'render_footer' ) );
}
/**
* The self-contained stylesheet reproducing the admin-ui page header + `.jetpack-footer`
* computed styles. Printed once, alongside the header.
*/
private function print_styles() {
static $printed = false;
if ( $printed ) {
return;
}
$printed = true;
?>
<style id="jp-akismet-chrome-css">
/* ── Header (admin-ui page header look) ── */
.jp-akismet-header {
display: flex;
align-items: center;
box-sizing: border-box;
min-height: 72px;
padding: 16px 24px;
background: #fff;
/* `box-shadow` (not `border-bottom`) so the hairline never adds to the
box and nudges the logo/title off the admin-ui header's vertical center. */
box-shadow: inset 0 -1px 0 #e4e4e4;
}
.jp-akismet-header__title {
display: flex;
align-items: center;
gap: 8px;
}
/* 24×24 visual slot centering the 20px logo, matching My Jetpack/Boost. */
.jp-akismet-header__visual {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
}
.jp-akismet-header__title h1 {
margin: 0;
padding: 0;
font-size: 15px;
font-weight: 500;
line-height: 20px;
color: #1e1e1e;
}
/* ── Footer (.jetpack-footer look) ── */
.jp-akismet-footer {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 24px;
box-sizing: border-box;
padding: 20px 24px;
border-top: 1px solid #e4e4e4;
background: #fff;
color: #1e1e1e;
font-size: 13px;
}
.jp-akismet-footer__logo {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.jp-akismet-footer__menu {
display: flex;
gap: 16px;
}
/* `#akismet-plugin-container a` is green in Akismet's own CSS; this scoped
selector outranks it so the footer links read as neutral grey (#707070,
matching My Jetpack's footer links). */
#akismet-plugin-container .jp-akismet-footer__menu a {
color: #707070;
text-decoration: none;
}
#akismet-plugin-container .jp-akismet-footer__menu a:hover {
color: #1e1e1e;
text-decoration: underline;
}
.jp-akismet-footer__a8c {
margin-inline-start: auto;
display: inline-flex;
align-items: center;
}
/* Mobile: the byline drops its right-push and wraps to its own full-width
row, left-aligned — matching how JetpackFooter stacks on other pages. */
@media (max-width: 782px) {
.jp-akismet-footer__a8c {
margin-inline-start: 0;
flex-basis: 100%;
}
}
.jp-akismet-footer__a8c svg path {
fill: #707070;
}
/* ── Hello Dolly ──
The `.jetpack-admin-page #dolly` treatment (right-aligned, italic,
WPDS colors) ships in the jetpack-components / My Jetpack admin CSS,
which doesn't load on Akismet's page — so without this, Dolly's lyric
falls back to left-aligned here. Re-declare it so Dolly lands top-right
exactly like every other unified Jetpack admin page. */
.jetpack-admin-page #dolly {
float: none;
text-align: end;
background: var(--wpds-color-bg-surface-neutral-strong, #fff);
font-style: italic;
color: var(--wpds-color-fg-content-neutral-weak, #87a6bc);
border-bottom: none;
}
@media (max-width: 659px) {
.jetpack-admin-page #dolly {
display: none;
}
}
/* ── Contained layout: fixed header, scrolling middle, pinned footer ──
Mirrors the jetpack-admin-page-layout mixin, adapted to Akismet's
markup (#wpbody-content > #akismet-plugin-container > header/.akismet-lower/footer).
Scoped to both menu locations: jetpack_page_… and settings_page_…
The mixin uses physical `left`/`right` because its SCSS is compiled
through rtlcss, which emits a flipped stylesheet. This inline `<style>`
has no such build step, so it uses CSS logical properties
(`inset-inline-*`, `padding-inline-*`, `margin-inline`) to flip with the
admin menu under RTL locales. */
body[class*="_page_akismet-key-config"] #wpcontent {
padding-inline-start: 0;
}
body[class*="_page_akismet-key-config"] #wpfooter {
display: none;
}
/* `#screen-meta-links` (the Screen Options / Help tabs container) is always
emitted by core's admin header even when empty, and core gives it
`margin: 0 10px 20px 0`. On wp.com Simple sites its contents are hidden
but the element — and its 20px bottom margin — remain, reserving a blank
slot at the very top of the page above the Jetpack header. The
`jetpack-admin-page-layout` mixin hides it for the same reason; do it here
too. Left UNSCOPED (not under `_page_akismet-key-config`) on purpose: the
inline stylesheet is only ever printed on Akismet admin views, and Simple
renders its stats UI under a different slug (`dashboard_page_akismet-stats`),
so an unscoped rule covers every page this chrome appears on. */
#screen-meta-links {
display: none;
}
body[class*="_page_akismet-key-config"] #wpbody-content {
box-sizing: border-box;
position: fixed;
top: var(--wp-admin-bar-height, 32px);
inset-inline-start: 160px;
inset-inline-end: 0;
bottom: 0;
width: auto;
padding-bottom: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
body[class*="_page_akismet-key-config"].folded #wpbody-content {
inset-inline-start: 36px;
}
@media (max-width: 960px) {
body[class*="_page_akismet-key-config"].auto-fold #wpbody-content {
inset-inline-start: 36px;
}
}
@media (min-width: 961px) {
body[class*="_page_akismet-key-config"].is-nav-unification:not(.folded) #wpbody-content {
inset-inline-start: 272px;
}
}
body[class*="_page_akismet-key-config"] #akismet-plugin-container {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
/* Drop Akismet's 1px container border: it insets the whole column by 1px,
pushing the header logo/title off the admin-ui alignment grid. */
border: 0;
}
body[class*="_page_akismet-key-config"] .jp-akismet-header {
flex-shrink: 0;
}
/* The scrollable middle. Target the generic child between header and
footer (not `.akismet-lower` specifically) so the config, start AND
stats (bare iframe) views all get a full-width scroll surface. The
`#wpbody-content` id keeps these rules ahead of Akismet's own
`.akismet-lower { width: 720px }`. */
body[class*="_page_akismet-key-config"] #wpbody-content > #akismet-plugin-container > :not(.jp-akismet-header):not(.jp-akismet-footer) {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
width: auto;
max-width: none;
margin: 0;
overflow: auto;
}
/* Move the readable-width restriction onto the content blocks so the
scroll surface itself spans the full width (scrolling works wherever
the pointer is), while the cards stay a comfortable column, left-
aligned with the header. 45rem ≈ Akismet's original 720px column. */
body[class*="_page_akismet-key-config"] .akismet-lower > * {
box-sizing: border-box;
max-width: 45rem;
margin-inline: auto;
}
body[class*="_page_akismet-key-config"] .jp-akismet-footer {
flex-shrink: 0;
}
@media (max-width: 782px) {
body[class*="_page_akismet-key-config"] #wpbody-content,
body[class*="_page_akismet-key-config"].folded #wpbody-content,
body[class*="_page_akismet-key-config"].auto-fold #wpbody-content {
top: var(--wp-admin-bar-height, 46px);
inset-inline-start: 0;
}
}
</style>
<?php
}
/**
* Render the admin-ui-style header (Akismet logo + title) that replaces Akismet's default masthead.
*/
public function render_header() {
$this->print_styles();
?>
<header class="jp-akismet-header">
<div class="jp-akismet-header__title">
<span class="jp-akismet-header__visual" aria-hidden="true"><?php echo $this->akismet_logo( 20 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?></span>
<h1><?php esc_html_e( 'Akismet Anti-spam', 'jetpack' ); ?></h1>
</div>
</header>
<?php
}
/**
* Render the unified Jetpack footer (`.jetpack-footer` look) at the bottom of the page.
*/
public function render_footer() {
// Match wrap_ui(): link the byline to the local About page when Jetpack isn't connectable,
// otherwise to the external jetpack.com redirect.
$connectable = ! Jetpack::is_connection_ready() && ! ( new Status() )->is_offline_mode();
$a8c_url = ! $connectable
? admin_url( 'admin.php?page=jetpack_about' )
: Redirect::get_url( 'jetpack' );
?>
<footer class="jp-akismet-footer jetpack-footer" aria-label="<?php esc_attr_e( 'Jetpack', 'jetpack' ); ?>" role="contentinfo">
<div class="jp-akismet-footer__logo">
<?php echo $this->jetpack_logo( 16 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?>
<span><?php esc_html_e( 'Jetpack', 'jetpack' ); ?></span>
</div>
<?php if ( ! ( new Host() )->is_wpcom_platform() ) : ?>
<nav class="jp-akismet-footer__menu">
<a href="<?php echo esc_url( admin_url( 'admin.php?page=my-jetpack#/products' ) ); ?>"><?php echo esc_html_x( 'Products', 'Navigation item', 'jetpack' ); ?></a>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=my-jetpack#/help' ) ); ?>"><?php echo esc_html_x( 'Help', 'Navigation item', 'jetpack' ); ?></a>
</nav>
<?php endif; ?>
<a class="jp-akismet-footer__a8c" href="<?php echo esc_url( $a8c_url ); ?>" aria-label="<?php esc_attr_e( 'An Automattic Airline', 'jetpack' ); ?>">
<svg role="img" x="0" y="0" viewBox="0 0 935 38.2" height="7" aria-hidden="true"><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>
</footer>
<?php
}
}
@@ -0,0 +1,605 @@
<?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__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',
'activitypub',
'wp-job-manager',
'wp-super-cache',
),
);
}
}
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,155 @@
<?php
/**
* Jetpack AI admin page.
*
* Registers the "AI" submenu item under Jetpack and mounts the React-based
* MCP settings interface.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Host;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
require_once __DIR__ . '/class.jetpack-admin-page.php';
/**
* Builds the Jetpack AI admin page and its sidebar menu entry.
*/
class Jetpack_AI_Page extends Jetpack_Admin_Page {
/**
* Hide the "AI" sidebar entry when Jetpack is not yet connected.
* Other Jetpack products follow the same convention.
*
* @var bool
*/
protected $dont_show_if_not_active = true;
/**
* Register the "AI" submenu under the Jetpack top-level menu.
*
* @return string|false Hook returned by Admin_Menu::add_menu().
*/
public function get_page_hook() {
return Admin_Menu::add_menu(
// "Jetpack AI" is a product name and should not be translated.
'Jetpack AI',
'AI',
'manage_options',
'jetpack-ai',
array( $this, 'render' ),
4
);
}
/**
* Attach page-specific actions.
*
* @param string $hook The page hook returned by get_page_hook().
*/
public function add_page_actions( $hook ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
// Nothing extra needed beyond the common hooks in Jetpack_Admin_Page::add_actions().
}
/**
* No additional styles needed: AdminPage from @automattic/jetpack-components
* owns the full layout and does not need the wrap_ui admin.css / style.min.css
* bundle (which zeroes out #wpcontent padding and conflicts with AdminPage's
* margin-left compensation).
*/
public function additional_styles() {}
/**
* Enqueue scripts and styles for the AI admin page.
*/
public function page_admin_scripts() {
$script_path = JETPACK__PLUGIN_DIR . '_inc/build/jetpack-ai-admin.asset.php';
$script_deps = array( 'wp-element', 'wp-components', 'wp-i18n', 'wp-polyfill' );
$script_version = JETPACK__VERSION;
if ( file_exists( $script_path ) ) {
$asset_manifest = include $script_path;
$script_deps = $asset_manifest['dependencies'];
$script_version = $asset_manifest['version'];
}
$blog_id = Connection_Manager::get_site_id( true );
$site_suffix = ( new Status() )->get_site_suffix();
// Use the plain hostname for the Atomic activity log URL — get_site_suffix() can
// include '::' for subdirectory installs, which would break the URL. This matches
// the approach used by jetpack-mu-wpcom for the sidebar Activity Log link.
$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
$activity_log_site = ( is_string( $site_host ) && '' !== $site_host ) ? $site_host : $site_suffix;
// On Atomic link to WPCOM activity log; on self-hosted link to the local wp-admin page.
$activity_log_url = ( new Host() )->is_woa_site()
? 'https://wordpress.com/activity-log/' . $activity_log_site
: admin_url( 'admin.php?page=jetpack-activity-log' );
wp_enqueue_script(
'jetpack-ai-admin',
plugins_url( '_inc/build/jetpack-ai-admin.js', JETPACK__PLUGIN_FILE ),
$script_deps,
$script_version,
true
);
wp_set_script_translations( 'jetpack-ai-admin', 'jetpack' );
wp_add_inline_script(
'jetpack-ai-admin',
'var jetpackAiSettings = ' . wp_json_encode(
array(
'blogId' => $blog_id ? (int) $blog_id : 0,
'activityLogUrl' => $activity_log_url,
'siteAdminUrl' => admin_url(),
'apiRoot' => esc_url_raw( rest_url() ),
'apiNonce' => wp_create_nonce( 'wp_rest' ),
'pluginUrl' => plugins_url( '', JETPACK__PLUGIN_FILE ),
// Route through the Jetpack redirect service so the upgrade
// destination for the MCP upsell can be retargeted without
// shipping a code change.
'upgradeUrl' => Redirect::get_url( 'jetpack-ai-upgrade-url-for-jetpack-sites' ),
),
JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
) . ';',
'before'
);
wp_enqueue_style(
'jetpack-ai-admin',
plugins_url( '_inc/build/jetpack-ai-admin.css', JETPACK__PLUGIN_FILE ),
array( 'wp-components' ),
$script_version
);
}
/**
* Override the base render() to skip wrap_ui entirely.
*
* Wrap_ui renders the Jetpack masthead header and static footer, which
* duplicate the header/footer that AdminPage (React) already provides.
* Calling page_render() directly lets AdminPage own the full layout.
*/
public function render() {
$this->page_render();
}
/**
* Render the page container. The React app mounts into this div.
*
* AdminPage from @automattic/jetpack-components handles the full-page layout.
*/
public function page_render() {
?>
<div id="jetpack-ai-root"></div>
<?php
}
}
@@ -0,0 +1,520 @@
<?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\Publicize\Keyring_Helper;
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 );
$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' => '', // Not used, keeping it for compatibility reasons, see https://github.com/Automattic/jetpack/pull/42076
'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' => 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 ) )
: '',
'representativeImage' => self::get_site_image(),
'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 ),
'plan' => Jetpack_Plan::get(),
'showBackups' => Jetpack::show_backups_ui(),
'showScan' => Jetpack::show_scan_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' => isset( $block_availability['sharing-buttons'] )
&& $block_availability['sharing-buttons']['available'],
'isLikeBlockAvailable' => isset( $block_availability['like'] )
&& $block_availability['like']['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(),
'isSubscriptionSiteEnabled' => apply_filters( 'jetpack_subscription_site_enabled', false ),
'newsletterDateExample' => gmdate( get_option( 'date_format' ), time() ),
'subscriptionSiteEditSupported' => $current_theme->is_block_theme(),
/*
* This filter is already documented in jetpack/modules/subscriptions.php.
* Default is the staged rollout (Automatticians plus the percentage cohort,
* currently 0%, bucketed by the stable wpcom blog ID), delegated to the
* canonical Newsletter\Settings helper and guarded so an older packaged copy
* can't fatal.
*/
'isWpAdminSubscriberManagementEnabled' => apply_filters(
'jetpack_wp_admin_subscriber_management_enabled',
method_exists( '\Automattic\Jetpack\Newsletter\Settings', 'is_modernization_rollout_enabled' )
&& \Automattic\Jetpack\Newsletter\Settings::is_modernization_rollout_enabled()
),
);
}
/**
* 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 = $post['title'] ?? null;
$title = wp_kses( $post_title, array() );
$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();
foreach ( Keyring_Helper::SERVICES as $service_name => $service_info ) {
$connect_urls[ $service_name ] = Keyring_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 );
}
/**
* Get a representative image for the site.
*
* @since 15.0
*
* @return string
*/
public static function get_site_image(): string {
// Get the dynamic image generated for the Open Graph Meta tags.
require_once JETPACK__PLUGIN_DIR . 'functions.opengraph.php';
return jetpack_og_get_fallback_social_image( 200, 200 )['src'];
}
}
// 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,425 @@
<?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;
use Automattic\Jetpack\Status\Host;
/**
* Shared logic between Jetpack admin pages.
*/
abstract class Jetpack_Admin_Page {
/**
* Determines whether or not to hide if not active.
*
* @var bool
*/
protected $dont_show_if_not_active;
/**
* 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 );
// Override the page title for the module list page and the debugger page.
add_action( 'current_screen', array( __CLASS__, 'override_page_title' ) );
}
/**
* 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;
$args['show-nav'] = false;
}
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' );
?>
<div id="jp-plugin-container" class="
<?php
if ( $args['is-wide'] ) {
echo 'is-wide'; }
?>
">
<header class="jp-masthead">
<div class="jp-masthead__inside-container">
<div class="jp-masthead__title-container">
<a class="jp-masthead__logo-link" href="<?php echo esc_url( admin_url( 'admin.php?page=my-jetpack#/overview' ) ); ?>">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" height="20" aria-label="<?php esc_attr_e( 'Jetpack logo', 'jetpack' ); ?>"><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></svg>
</a>
<h2 class="jp-masthead__title">
<?php
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- View logic only.
$page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
$current_label = '';
if ( 'jetpack_modules' === $page ) {
$current_label = __( 'Modules', 'jetpack' );
} elseif ( 'jetpack_about' === $page ) {
$current_label = __( 'About', 'jetpack' );
} elseif ( 'jetpack-debugger' === $page ) {
$current_label = __( 'Debug', 'jetpack' );
}
?>
<?php if ( $current_label ) : ?>
<a class="jp-masthead__title-link" href="<?php echo esc_url( admin_url( 'admin.php?page=my-jetpack#/overview' ) ); ?>">Jetpack</a><?php // "Jetpack" is a product name, do not translate. ?>
<span class="jp-masthead__title-separator" aria-hidden="true">/</span>
<span class="jp-masthead__title-current"><?php echo esc_html( $current_label ); ?></span>
<?php else : ?>
Jetpack<?php // "Jetpack" is a product name, do not translate. ?>
<?php endif; ?>
</h2>
</div>
<?php
if ( $args['show-nav'] && ! ( new Host() )->is_wpcom_platform() ) :
?>
<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>
</header>
<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"><?php esc_html_e( 'Jetpack', 'jetpack' ); ?></span>
</div>
<?php if ( ! ( new Host() )->is_wpcom_platform() ) : ?>
<div class="jp-footer__menu">
<a href="<?php echo esc_url( admin_url( 'admin.php?page=my-jetpack#/products' ) ); ?>" class="jp-footer__menu-item"><?php echo esc_html_x( 'Products', 'Navigation item', 'jetpack' ); ?></a>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=my-jetpack#/help' ) ); ?>" class="jp-footer__menu-item"><?php echo esc_html_x( 'Help', 'Navigation item', 'jetpack' ); ?></a>
</div>
<?php endif; ?>
<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' );
}
/**
* Set a page title for both the module list page and the debugger page.
* We set these here because we do not register a page title when we register them,
* so they do not appear in the admin navigation.
*
* @since 15.4
*
* @param WP_Screen $screen The screen object.
*
* @return void
*/
public static function override_page_title( WP_Screen $screen ) {
global $title;
if ( 'admin_page_jetpack_modules' === $screen->id ) {
$title = __( 'Jetpack Settings', 'jetpack' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- we override the title global only on this page.
} elseif ( 'admin_page_jetpack-debugger' === $screen->id ) {
$title = __( 'Debugging Center', 'jetpack' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- we override the title global only on this page.
}
}
}
@@ -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,322 @@
<?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
* or if My Jetpack is available.
* At that point, admins can access the Jetpack Dashboard instead.
*
* @since 13.8
*/
public function remove_jetpack_menu() {
$is_offline_mode = ( new Status() )->is_offline_mode();
$has_my_jetpack = (
class_exists( 'Automattic\Jetpack\My_Jetpack\Initializer' ) &&
method_exists( 'Automattic\Jetpack\My_Jetpack\Initializer', 'should_initialize' ) &&
\Automattic\Jetpack\My_Jetpack\Initializer::should_initialize()
);
if ( $is_offline_mode || $has_my_jetpack || Jetpack::is_connection_ready() ) {
remove_submenu_page( 'jetpack', 'jetpack' );
}
}
/**
* 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' ) ||
! current_user_can( 'publish_posts' )
)
) {
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,
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=' . wp_json_encode( Jetpack_Redux_State_Helper::get_initial_state(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';', '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,198 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Tracking;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
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,473 @@
<?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,
),
JSON_UNESCAPED_SLASHES
),
'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,
),
JSON_UNESCAPED_SLASHES
),
'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,77 @@
<?php
/**
* Jetpack Application Password Extras
*
* Extends WordPress Application Passwords to work with additional abilities
* beyond the REST API.
*
* @package jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Extends Application Password functionality beyond the REST API.
*/
class Jetpack_Application_Password_Extras {
/**
* The AJAX action prefix for VideoPress actions.
*
* @var string
*/
private const VIDEOPRESS_AJAX_PREFIX = 'videopress-';
/**
* Initialize the main hooks.
*/
public static function init() {
add_filter( 'application_password_is_api_request', array( __CLASS__, 'application_password_extras' ) );
}
/**
* Allow Application Password access to additional abilities.
*
* NOTE: If expanding this to include more abilities, consider updating the
* `get_abilities` method to include new abilities.
*
* @param bool $original_value The original value of the filter.
* @return bool The new value of the filter.
*/
public static function application_password_extras( $original_value ) {
if ( $original_value ) {
return true;
}
return is_admin() && wp_doing_ajax() && self::is_ajax_action_allowed();
}
/**
* Check if the current AJAX action is allowed for Application Password authentication.
*
* @return bool True if the action is allowed, false otherwise.
*/
private static function is_ajax_action_allowed() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We're only checking the action name, not processing the request.
$action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
if ( empty( $action ) ) {
return false;
}
return str_starts_with( $action, self::VIDEOPRESS_AJAX_PREFIX );
}
/**
* Get the abilities that this extension provides.
*
* @return array Array of abilities with their status.
*/
public static function get_abilities() {
return array(
'admin-ajax' => true,
);
}
}
@@ -0,0 +1,91 @@
<?php
/**
* Blog Stats block helper.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Stats\WPCOM_Stats;
/**
* Class Jetpack_Blog_Stats_Helper
*/
class Jetpack_Blog_Stats_Helper {
/**
* Returns user's blog stats.
*
* @param array $stats_option Array containing the Blog Stats block attributes.
*
* @return int
*/
public static function get_stats( $stats_option ) {
$stats_option = wp_parse_args(
$stats_option,
array(
'statsOption' => 'blog',
'statsData' => 'views',
'postId' => get_the_ID(),
)
);
if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
// Jetpack sites.
$wpcom_stats = new WPCOM_Stats();
if ( $stats_option['statsOption'] === 'post' ) {
// Cache in post meta to prevent wp_options blowing up when retrieving views
// for multiple posts simultaneously (eg. when inserted into template).
$cache_in_meta = true;
$data = $wpcom_stats->convert_stats_array_to_object(
$wpcom_stats->get_post_views(
$stats_option['postId'],
array( 'fields' => 'views' ), // No visitor count for posts.
$cache_in_meta
)
);
if ( isset( $data->views ) ) {
return $data->views;
}
} else {
$data = $wpcom_stats->convert_stats_array_to_object(
$wpcom_stats->get_stats( array( 'fields' => 'stats' ) )
);
if ( $stats_option['statsData'] === 'views' && isset( $data->stats->views ) ) {
return $data->stats->views;
}
if ( $stats_option['statsData'] === 'visitors' && isset( $data->stats->visitors ) ) {
return $data->stats->visitors;
}
}
} elseif ( $stats_option['statsOption'] === 'post' ) {
// Simple sites.
if ( function_exists( 'get_all_time_postviews' ) ) {
// This is cached so no need to cache it again.
return (int) get_all_time_postviews( $stats_option['postId'] );
}
} else {
// Simple sites.
$_blog_id = get_current_blog_id();
if ( $stats_option['statsData'] === 'views' && function_exists( 'stats_grandtotal_views' ) ) {
// This is cached so no need to cache it again.
return stats_grandtotal_views( $_blog_id );
}
if ( $stats_option['statsData'] === 'visitors' && function_exists( 'stats_get_visitors' ) ) {
$stats = wp_cache_get( "stats_get_visitors_total_$_blog_id", 'blog-stats-block' );
if ( false !== $stats ) {
return $stats;
}
$stats = array_sum( stats_get_visitors( get_current_blog_id(), false, (int) gmdate( 'Y' ) - 2012, 365 ) );
wp_cache_set( "stats_get_visitors_total_$_blog_id", $stats, 'blog-stats-block', HOUR_IN_SECONDS );
return $stats;
}
}
return 0;
}
}
@@ -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,99 @@
<?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 );
// Assuming the json_decode succeeds, `images` can be an array of images or a string 'ERROR'.
$cached_images = $decoded_cached_gallery->images ?? null;
$cached_count = is_array( $cached_images ) ? count( $cached_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,90 @@
<?php
/**
* Contains utilities related to the Jetpack Newsletter Categories.
*
* @package automattic/jetpack
*/
/**
* Jetpack_Newsletter_Category_Helper class
*/
class Jetpack_Newsletter_Category_Helper {
const NEWSLETTER_CATEGORIES_OPTION = 'wpcom_newsletter_categories';
/**
* Return category ID's
*
* @return array An array of integers
*/
public static function get_category_ids() {
$newsletter_categories = maybe_unserialize( get_option( 'wpcom_newsletter_categories', array() ) );
if ( ! is_array( $newsletter_categories ) || empty( $newsletter_categories ) ) {
return array();
}
// Check if it is an array of integers.
// [123, 456]
if ( isset( $newsletter_categories[0] ) && is_int( $newsletter_categories[0] ) ) {
return $newsletter_categories;
}
// Check if it is an array of arrays with term_id keys.
// [{term_id: 123}, {term_id: 456}]
if ( isset( $newsletter_categories[0] ) && is_array( $newsletter_categories[0] ) && isset( $newsletter_categories[0]['term_id'] ) ) {
$ids = array();
foreach ( $newsletter_categories as $category ) {
if ( isset( $category['term_id'] ) && is_numeric( $category['term_id'] ) ) {
$ids[] = (int) $category['term_id'];
}
}
return $ids;
}
return array();
}
/**
* Handles category ID's ready to be saved as an option
*
* @param array $newsletter_categories An array of id's that could be in a few different forms.
* @return array|bool An associated array with term_id keys on success, or the boolean result from update_option.
* [{term_id: 123}, {term_id: 456}]
*/
public static function save_category_ids( $newsletter_categories ) {
if ( ! is_array( $newsletter_categories ) || empty( $newsletter_categories ) ) {
return false;
}
$formatted_categories = array();
// Check if it is an array of integers.
// [123, 456]
if ( isset( $newsletter_categories[0] ) && is_numeric( $newsletter_categories[0] ) ) {
foreach ( $newsletter_categories as $id ) {
if ( is_numeric( $id ) ) {
$formatted_categories[] = array( 'term_id' => (int) $id );
}
}
}
// Check if it is an array of arrays with term_id keys.
// [{term_id: 123}, {term_id: 456}]
if ( isset( $newsletter_categories[0] ) && is_array( $newsletter_categories[0] ) && isset( $newsletter_categories[0]['term_id'] ) ) {
foreach ( $newsletter_categories as $category ) {
if ( isset( $category['term_id'] ) && is_numeric( $category['term_id'] ) ) {
$formatted_categories[] = array( 'term_id' => (int) $category['term_id'] );
}
}
}
if ( empty( $formatted_categories ) ) {
return false;
}
return update_option( self::NEWSLETTER_CATEGORIES_OPTION, $formatted_categories )
? $formatted_categories
: false;
}
}
@@ -0,0 +1,124 @@
<?php
/**
* Extension of the SimplePie\Locator class, to detect podcast feeds
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class Jetpack_Podcast_Feed_Locator
*/
class Jetpack_Podcast_Feed_Locator extends SimplePie\Locator {
/**
* Overrides the locator is_feed function to check for
* appropriate podcast elements.
*
* @param SimplePie\HTTP\Response $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 ) &&
$file instanceof SimplePie\File &&
$this->is_podcast_feed( $file );
}
/**
* Checks the contents of the file for elements that make
* it a podcast feed.
*
* @param 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;
}
// @todo Drop is_callable check once Simple gets the SimplePie update that came with WordPress 6.9.
if ( is_callable( array( $file, 'get_body_content' ) ) ) {
$feed_dom = $this->safely_load_xml( $file->get_body_content() );
} else {
// @phan-suppress-next-line PhanDeprecatedProperty -- For compatibility only.
$feed_dom = $this->safely_load_xml( (string) $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 resulting DOM document or `false` if there is an error.
*/
private function safely_load_xml( $xml ) {
if ( empty( $xml ) ) {
return false;
}
$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,627 @@
<?php
/**
* Helper to massage Podcast data to be used in the Podcast block.
*
* @package automattic/jetpack
*/
/**
* 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 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 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';
}
$feed->get_registry()->register( SimplePie\Locator::class, '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 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 SimplePie\Item $episode SimplePie Item object, representing a podcast episode.
* @return array
*/
protected function setup_tracks_callback( 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( (string) $enclosure->link ),
'type' => esc_attr( (string) $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( (int) $enclosure->duration ) );
}
return $track;
}
/**
* Retrieves an episode's image URL, if it's available.
*
* @param 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( 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 SimplePie\Item $episode SimplePie Item object, representing a podcast episode.
* @return SimplePie\Enclosure|null
*/
protected function get_audio_enclosure( 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 = array_pop( $path_parts );
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 = $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,49 @@
<?php
/**
* Jetpack Spinner Helper
*
* Provides a standardized loading spinner SVG that visually matches
* the WordPress Core Spinner (@wordpress/components Spinner).
*
* For React/JS editor and admin contexts, use:
* import { Spinner } from '@wordpress/components';
*
* For PHP wp-admin contexts, use:
* <span class="spinner is-active"></span>
*
* For frontend contexts where wp-admin CSS is unavailable, use this helper:
* Jetpack_Spinner::render();
*
* @package automattic/jetpack
* @since 15.8
*/
/**
* Renders an inline SVG spinner matching the WordPress Core visual.
*/
class Jetpack_Spinner {
/**
* Returns the SVG markup for a spinner matching the WP Core Spinner visual:
* a gray circle track with a rotating quarter-arc indicator.
*
* Uses <animateTransform> for CSS-free animation, making it safe for
* frontend contexts where wp-admin styles are not enqueued.
*
* @since 15.8
*
* @param int $size Width and height in pixels. Default 24.
* @return string SVG markup.
*/
public static function render( $size = 24 ) {
return sprintf(
'<svg class="jetpack-spinner" width="%1$d" height="%1$d" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">'
. '<circle cx="50" cy="50" r="46" fill="none" stroke="#ddd" stroke-width="8"/>'
. '<path d="M 50 4 A 46 46 0 0 1 96 50" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round">'
. '<animateTransform attributeName="transform" type="rotate" dur="1.4s" from="0 50 50" to="360 50 50" repeatCount="indefinite"/>'
. '</path>'
. '</svg>',
(int) $size
);
}
}
@@ -0,0 +1,160 @@
<?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',
);
// Atomic or self-hosted sites via WPCOM public v1.1 endpoint.
if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
$data = ( new WPCOM_Stats() )->get_top_posts( $query_args, $override_cache );
} else {
// Directly access posts on WPCOM, as Simple sites run on the same environment.
require_lib( 'jetpack-stats' );
if ( class_exists( '\Jetpack\Stats\Top_Posts' ) ) {
// @phan-suppress-next-line PhanUndeclaredClassMethod
$data = ( new \Jetpack\Stats\Top_Posts() )->get_top_posts( get_current_blog_id(), $query_args );
} else {
$data = array( 'summary' => array( 'postviews' => array() ) );
}
}
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_post = 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 ),
// Use the local permalink to avoid stale values from the Stats API.
'href' => get_permalink( $post_id ),
'date' => get_the_date( '', $post_id ),
'title' => $post['title'],
'type' => $post['type'],
'public' => $post['public'],
'views' => $post['views'] ?? 0,
'thumbnail' => $thumbnail,
);
/**
* Allows modifying the title of each individual post returned by the Top Posts helper.
*
* Applies to both the Top Posts block's front-end output and the REST
* response used by the block editor preview.
*
* @module stats
*
* @since 15.8
*
* @param string $post_title Post title.
* @param array $top_post Information about the post.
*/
$top_post['title'] = apply_filters( 'jetpack_top_posts_item_title', $top_post['title'], $top_post );
$top_posts[] = $top_post;
}
}
// 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" />';
}
}
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,599 @@
<?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
*/
use Automattic\Jetpack\Post_Media\Images;
/**
* 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 -= 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 ) {
// Guard against malformed oEmbed providers.
if ( ! isset( $data[0] ) ) {
continue;
}
$regex = $data[1] ?? false;
// 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 Images
*
* @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 = Images::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 = Images::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 = Images::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'] );
$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 = Images::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,476 @@
<?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.
* @type bool $include_excerpt Whether to compute the excerpt and return it. Default true.
* @type bool $include_counts Whether to compute word/link counts. Default true.
* }
*
* @return array|mixed|void
*/
public static function get( ?int $post_id, int $blog_id = 0, array $args = array() ) {
$post_id = (int) $post_id;
$defaults = array(
'max_words' => 16,
'max_chars' => 256,
'include_excerpt' => true,
'include_counts' => true,
);
$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']}_"
. (int) $args['include_excerpt'] . '_' . (int) $args['include_counts'];
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,
'word_remaining' => 0,
'link' => 0,
),
);
if ( $post instanceof WP_Post && empty( $post->post_password ) ) {
if ( $args['include_excerpt'] ) {
$return['excerpt'] = self::get_excerpt( $post->post_content, $post->post_excerpt, $args['max_words'], $args['max_chars'], $post );
}
if ( $args['include_counts'] ) {
$return['count']['word'] = self::get_word_count( $post->post_content );
$return['count']['link'] = self::get_link_count( $post->post_content );
// Only compute word_remaining if we have an excerpt. If not, leave the default of 0.
if ( $args['include_excerpt'] && '' !== $return['excerpt'] ) {
$return['count']['word_remaining'] = self::get_word_remaining_count( $post->post_content, $return['excerpt'] );
}
}
}
$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'] ) {
if ( ! isset( $extract['shortcode']['vimeo']['id'][0] ) ) {
break;
}
$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 -= $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;
}
// @phan-suppress-next-line PhanTypeMismatchDimFetch -- Phan is understandably confused, as $extract has many forms, including this one.
if ( ! empty( $extract['image'][0]['url'] ) ) {
$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.
* @param bool $preserve_urls When true, keep http(s) URLs in the text instead of stripping them. Default false.
*
* @return string Clean text.
*/
public static function clean_text( $text, $preserve_urls = false ) {
$text = strip_shortcodes( wp_strip_all_tags( $text ) );
if ( ! $preserve_urls ) {
$text = preg_replace( '@https?://[\S]+@', '', $text );
}
return trim( preg_replace( '/[\s]+/', ' ', $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.
* @param bool $preserve_urls When true, keep http(s) URLs in the excerpt instead of stripping them. Default false.
* @return string Post excerpt.
**/
public static function get_excerpt( $post_content, $post_excerpt, $max_words = 16, $max_chars = 256, $requested_post = null, $preserve_urls = false ) {
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,
)
),
$preserve_urls
);
} 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, $preserve_urls );
}
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 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 $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,509 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
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, false ) );
}
/**
* 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 ) ) {
$dir = dirname( $pathname );
if ( is_dir( $dir ) ) {
// 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,134 @@
<?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
use Automattic\Jetpack\Status;
/**
* Components Library
*
* Load and display a pre-rendered component
*/
class Jetpack_Components {
/**
* Get the contents of a component file
*
* @since 14.7
*
* @param string $name Component name.
* @return string The component markup
*/
protected static function get_component_markup( $name ) {
ob_start();
// `include` fails gracefully and throws a warning, but doesn't halt execution.
include JETPACK__PLUGIN_DIR . "_inc/blocks/$name.html";
return ob_get_clean();
}
/**
* 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 );
$markup = self::get_component_markup( $name );
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 )
);
// Decode the URL to avoid double encoding.
$redirect_to = html_entity_decode( wp_unslash( $redirect_to ), ENT_QUOTES );
$upgrade_url =
$plan_path_slug
? add_query_arg(
'redirect_to',
rawurlencode( $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 ( $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 `/upgrades` endpoint call.
*
* @return array of site purchases.
*/
public static function get_purchases() {
// Make the API request.
$request = sprintf( '/upgrades?site=%d', Jetpack_Options::get_option( 'id' ) );
$response = Client::wpcom_json_api_request_as_blog( $request, '1.2' );
// 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, JSON_UNESCAPED_SLASHES ),
)
);
}
}
@@ -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,69 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Build localized strings for use with the Business Hours Block.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,145 @@
<?php
/**
* REST API endpoint for admin bar.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class WPCOM_REST_API_V2_Endpoint_Admin_Bar
*/
class WPCOM_REST_API_V2_Endpoint_Admin_Bar extends WP_REST_Controller {
/**
* Namespace prefix.
*
* @var string
*/
public $namespace = 'wpcom/v2';
/**
* Endpoint base route.
*
* @var string
*/
public $rest_base = 'admin-bar';
/**
* Top-level admin bar node IDs that are considered safe to show.
*
* @var string[]
*/
const ALLOWED_TOP_LEVEL_NODES = array( 'wp-logo', 'site-name', 'updates', 'comments', 'new-content', 'my-account' );
/**
* WPCOM_REST_API_V2_Endpoint_Admin_Bar 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 the admin bar.
*
* @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( 'manage_options' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to view the admin bar on this site.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Retrieves the admin bar registered for the current site, filtered to
* the allowed top-level nodes and their descendants.
*
* @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
global $wp_admin_bar;
if ( ! class_exists( 'WP_Screen' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php';
}
if ( ! function_exists( 'set_current_screen' ) ) {
require_once ABSPATH . 'wp-admin/includes/screen.php';
}
// Simulate a wp-admin context.
set_current_screen( 'dashboard' );
add_filter( 'show_admin_bar', '__return_true', 999 );
_wp_admin_bar_init();
ob_start();
do_action_ref_array( 'admin_bar_menu', array( &$wp_admin_bar ) );
ob_clean();
$nodes = $wp_admin_bar->get_nodes() ?? array();
$filtered_nodes = $this->filter_nodes( $nodes, self::ALLOWED_TOP_LEVEL_NODES );
return rest_ensure_response( array( 'nodes' => array_values( $filtered_nodes ) ) );
}
/**
* Filters admin bar nodes to only include allowed top-level items and
* their descendants.
*
* @param array $nodes All admin bar nodes keyed by ID.
* @param array $allowed_ids Top-level node IDs to keep.
* @return array Filtered nodes.
*/
private function filter_nodes( array $nodes, array $allowed_ids ) {
$allowed = array();
foreach ( $nodes as $id => $node ) {
if ( in_array( $id, $allowed_ids, true ) ) {
$allowed[ $id ] = $node;
continue;
}
$current = $node;
while ( ! empty( $current->parent ) && isset( $nodes[ $current->parent ] ) ) {
if ( in_array( $current->parent, $allowed_ids, true ) ) {
$allowed[ $id ] = $node;
break;
}
$current = $nodes[ $current->parent ];
}
}
return $allowed;
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Admin_Bar' );
@@ -0,0 +1,85 @@
<?php
/**
* REST API endpoint for admin color.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,150 @@
<?php
/**
* REST API proxy endpoint for AI-powered content guidelines suggestions.
*
* Proxies requests to the wpcom endpoint that generates guidelines
* using site content analysis.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Client;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class WPCOM_REST_API_V2_Endpoint_Agent_Guidelines_AI
*/
class WPCOM_REST_API_V2_Endpoint_Agent_Guidelines_AI extends WP_REST_Controller {
/**
* Namespace prefix.
*
* @var string
*/
public $namespace = 'wpcom/v2';
/**
* Endpoint base route.
*
* @var string
*/
public $rest_base = 'jetpack-ai/suggest-guidelines';
/**
* Constructor.
*/
public function __construct() {
$this->is_wpcom = true;
$this->wpcom_is_wpcom_only_endpoint = true;
add_action( 'rest_api_init', array( $this, 'maybe_register_routes' ) );
}
/**
* Register routes on `rest_api_init`, gating on the AI feature state.
*
* The Jetpack_AI_Helper check (which loads the helper and instantiates
* Status/Host classes) runs here rather than in the constructor so that code
* is only loaded when the REST API is actually in use, not on every
* front-end, cron, or login request.
*/
public function maybe_register_routes() {
if ( ! class_exists( 'Jetpack_AI_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-helper.php';
}
// Intentionally gated to Simple and Atomic sites only.
// Self-hosted Jetpack support is deferred — we want to roll out
// on WordPress.com platforms first before opening to all connected sites.
if ( ! \Jetpack_AI_Helper::is_enabled() ) {
return;
}
$this->register_routes();
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'suggest_guidelines' ),
'permission_callback' => array( $this, 'permission_callback' ),
'args' => array(
'categories' => array(
'description' => __( 'Categories to generate guidelines for.', 'jetpack' ),
'type' => 'object',
'required' => true,
),
),
)
);
}
/**
* Permission check — require manage_options.
*
* @return bool
*/
public function permission_callback() {
return current_user_can( 'manage_options' );
}
/**
* Proxy the suggest-guidelines request to wpcom.
*
* @param WP_REST_Request $request The request object.
* @return mixed|WP_Error
*/
public function suggest_guidelines( $request ) {
$blog_id = \Jetpack_Options::get_option( 'id' );
$body = array(
'categories' => $request->get_param( 'categories' ),
);
$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/sites/%d/jetpack-ai/suggest-guidelines', $blog_id ) . '?force=wpcom',
'2',
array(
'method' => 'POST',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 90,
),
wp_json_encode( $body, JSON_UNESCAPED_SLASHES ),
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status_code = wp_remote_retrieve_response_code( $response );
$body_str = wp_remote_retrieve_body( $response );
$data = json_decode( $body_str, true );
if ( $status_code !== 200 ) {
$message = is_array( $data ) && isset( $data['message'] ) ? $data['message'] : __( 'Failed to generate guidelines.', 'jetpack' );
$code = is_array( $data ) && isset( $data['code'] ) ? $data['code'] : 'upstream_error';
return new WP_Error( $code, $message, array( 'status' => $status_code ) );
}
if ( JSON_ERROR_NONE !== json_last_error() ) {
return new WP_Error(
'invalid_response',
__( 'The guidelines service returned a malformed response.', 'jetpack' ),
array( 'status' => 502 )
);
}
return $data;
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Agent_Guidelines_AI' );
@@ -0,0 +1,321 @@
<?php
/**
* REST API endpoint for the Jetpack AI blocks.
*
* @package automattic/jetpack
* @since 11.8
*/
use Automattic\Jetpack\Connection\Client;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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;
add_action( 'rest_api_init', array( $this, 'maybe_register_routes' ) );
}
/**
* Register routes on `rest_api_init`, gating on the AI feature state.
*
* The Jetpack_AI_Helper checks (which load the helper and instantiate
* Search/Connection classes) run here rather than in the constructor so that
* code is only loaded when the REST API is actually in use, not on every
* front-end, cron, or login request.
*/
public function maybe_register_routes() {
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.
$this->register_basic_routes();
if ( Jetpack_AI_Helper::is_ai_chat_enabled() ) {
$this->register_ai_chat_routes();
}
if ( ! \Jetpack_AI_Helper::is_enabled() ) {
return;
}
// Register routes that require Jetpack AI to be enabled.
$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,192 @@
<?php
/**
* REST API endpoint for the media uploaded by the Jetpack app.
*
* @package automattic/jetpack
* @since 13.1
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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',
'default' => 1,
'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();
$query_args = array(
'post_type' => 'attachment',
'post_status' => 'inherit',
'number' => $params['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,91 @@
<?php
/**
* REST API endpoint for application password extras abilities.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class WPCOM_REST_API_V2_Endpoint_Application_Password_Extras
*/
class WPCOM_REST_API_V2_Endpoint_Application_Password_Extras extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'application-password-extras';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/abilities',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_abilities' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/admin-ajax',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_abilities' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
)
);
}
/**
* Checks if a given request has access to application password extras.
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function get_item_permissions_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! is_user_logged_in() ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you must be logged in to access this endpoint.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Retrieves the application password extras abilities.
*
* @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_abilities( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! class_exists( 'Jetpack_Application_Password_Extras' ) ) {
return new WP_Error(
'rest_application_password_extras_unavailable',
__( 'Application password extras functionality is not available.', 'jetpack' ),
array( 'status' => 503 )
);
}
return rest_ensure_response( Jetpack_Application_Password_Extras::get_abilities() );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Application_Password_Extras' );
@@ -0,0 +1,89 @@
<?php
/**
* Get blog stats.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
if ( ! class_exists( 'Jetpack_Blog_Stats_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-blog-stats-helper.php';
}
/**
* 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 ) {
$post_id = $request->get_param( 'post_id' );
return array(
'post-views' => Jetpack_Blog_Stats_Helper::get_stats(
array(
'statsOption' => 'post',
'postId' => $post_id,
)
),
'blog-visitors' => Jetpack_Blog_Stats_Helper::get_stats(
array(
'statsOption' => 'blog',
'statsData' => 'visitors',
)
),
'blog-views' => Jetpack_Blog_Stats_Helper::get_stats(
array(
'statsOption' => 'blog',
'statsData' => 'views',
)
),
);
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Blog_Stats' );
@@ -0,0 +1,148 @@
<?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\Status\Host;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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( 'everybody', 'subscribers', 'paid_subscribers' ),
'default' => 'everybody',
'validate_callback' => function ( $param ) {
return in_array(
$param,
array( 'everybody', 'subscribers', '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,852 @@
<?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;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,154 @@
<?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;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,82 @@
<?php
/**
* Get the User ID of a Goodreads account using its Author ID.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,66 @@
<?php
/**
* Validate whether a google doc is available for embedding.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Extensions\GoogleDocsEmbed;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,121 @@
<?php
/**
* REST API endpoint for the Content Guidelines AI empty-state banner.
*
* Stores a per-user flag (so it persists across the user's devices/browsers)
* for whether the banner has been dismissed, instead of relying on per-browser
* localStorage. Modeled on the wpcom block-editor "recommended tags modal
* dismissed" flow, but scoped to the user via user meta.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class WPCOM_REST_API_V2_Endpoint_Guidelines_Banner_Dismissed
*
* @since 16.0
*/
class WPCOM_REST_API_V2_Endpoint_Guidelines_Banner_Dismissed extends WP_REST_Controller {
/**
* User meta key storing the dismissed flag.
*
* @var string
*/
const META_KEY = 'jetpack_content_guidelines_ai_banner_dismissed';
/**
* Namespace prefix.
*
* @var string
*/
public $namespace = 'wpcom/v2';
/**
* Endpoint base route.
*
* @var string
*/
public $rest_base = 'jetpack-ai/guidelines-banner-dismissed';
/**
* 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';
}
// Match the suggest-guidelines endpoint: register on Simple/Atomic only.
if ( ! \Jetpack_AI_Helper::is_enabled() ) {
return;
}
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::EDITABLE,
'callback' => array( $this, 'set_dismissed' ),
'permission_callback' => array( $this, 'permission_callback' ),
),
)
);
}
/**
* Permission check.
*
* Gated to the same capability as the Content Guidelines page (and the
* suggest-guidelines endpoint): only admins ever see the banner, so only
* they need to dismiss it.
*
* @return bool
*/
public function permission_callback() {
return current_user_can( 'manage_options' );
}
/**
* Whether the current user has dismissed the banner.
*
* Also used to preload the initial value into the page (see
* _inc/content-guidelines-ai.php) so the banner does not flash on load.
*
* @return bool
*/
public static function is_dismissed() {
return (bool) get_user_meta( get_current_user_id(), self::META_KEY, true );
}
/**
* Mark the banner as dismissed for the current user.
*
* Dismissal is one-way — the banner has no "show again" control — so this
* only ever sets the flag.
*
* @return WP_REST_Response
*/
public function set_dismissed() {
update_user_meta( get_current_user_id(), self::META_KEY, '1' );
// Just set above — return it directly instead of re-reading the meta.
return rest_ensure_response( array( 'dismissed' => true ) );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Guidelines_Banner_Dismissed' );
@@ -0,0 +1,179 @@
<?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;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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;
}
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 ) {
if ( ! class_exists( 'WPCOM_Instagram_Gallery_Helper' ) ) {
\require_lib( 'instagram-gallery-helper' );
}
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 ) {
if ( ! class_exists( 'WPCOM_Instagram_Gallery_Helper' ) ) {
\require_lib( 'instagram-gallery-helper' );
}
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 ) {
if ( ! class_exists( 'Jetpack_Instagram_Gallery_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-instagram-gallery-helper.php';
}
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,193 @@
<?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\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Status\Host;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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',
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/settings',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_mailchimp_settings' ),
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
),
)
);
}
/**
* 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 = '/wp-admin/options-writing.php';
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 );
}
/**
* Get the Mailchimp connection settings.
*
* @return mixed
*/
public function get_mailchimp_settings() {
$site_id = Connection_Manager::get_site_id();
if ( is_wp_error( $site_id ) ) {
return new WP_Error(
'unavailable_site_id',
__( 'Sorry, something is wrong with your Jetpack connection.', 'jetpack' ),
403
);
}
$settings = array();
if ( ( new Host() )->is_wpcom_simple() ) {
require_lib( 'mailchimp' );
$api = new MailchimpApi( $site_id, get_current_user_id() );
$settings = $api::get_settings( $site_id );
$settings['audiences'] = $api->get_lists();
} else {
$path = sprintf( '/sites/%d/mailchimp/settings', $site_id );
$response = Client::wpcom_json_api_request_as_user( $path, '1.1', array(), null, 'rest' );
if ( ! is_wp_error( $response ) ) {
$settings = json_decode( wp_remote_retrieve_body( $response ), true );
}
$path = sprintf( '/sites/%d/mailchimp/lists', $site_id );
$response = Client::wpcom_json_api_request_as_user( $path, '1.1', array(), null, 'rest' );
if ( ! is_wp_error( $response ) ) {
$settings['audiences'] = json_decode( wp_remote_retrieve_body( $response ), true );
}
}
return $settings;
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Mailchimp' );
@@ -0,0 +1,299 @@
<?php
/**
* REST API endpoint for Jetpack AI MCP settings.
*
* GET — proxies to WPCOM wpcom/v2/sites/{blog_id}/mcp-abilities (wpcom#205108) using
* user token auth so that WPCOM can resolve the requesting user's account-level
* MCP state. Reshapes the response into the { account, site, sites,
* site_level_enabled_default } structure the client utilities expect.
* POST — proxies to WPCOM POST /sites/{blog_id}/mcp-abilities (user token auth) which
* persists { site_level_enabled, abilities } to user settings via SettingsHelper,
* keeping Jetpack and Calypso in sync.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class WPCOM_REST_API_V2_Endpoint_MCP_Settings
*/
class WPCOM_REST_API_V2_Endpoint_MCP_Settings extends WP_REST_Controller {
/**
* Namespace prefix.
*
* @var string
*/
public $namespace = 'wpcom/v2';
/**
* Endpoint base route.
*
* @var string
*/
public $rest_base = 'jetpack-ai/mcp-settings';
/**
* 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_mcp_settings' ),
'permission_callback' => array( $this, 'permissions_check' ),
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_mcp_settings' ),
'permission_callback' => array( $this, 'permissions_check' ),
'args' => array(
'mcp_abilities' => array(
'type' => 'object',
'required' => true,
),
),
),
)
);
}
/**
* Check permissions.
*
* @return bool|WP_Error
*/
public function permissions_check() {
if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'You do not have permission to manage MCP settings.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Fetch MCP abilities from the WPCOM wpcom/v2/sites/{blog_id}/mcp-abilities endpoint.
*
* The WPCOM endpoint accepts user token auth and returns an array of abilities.
* The response is shaped to match the format the client utilities expect:
* { account: { tool-id: {...} }, site: { tool-id: {...} },
* sites: [{ blog_id, site_level_enabled, abilities }],
* site_level_enabled_default: bool }
*
* All state is owned by WPCOM and persisted via POST /sites/{blog_id}/mcp-abilities.
*
* @return WP_REST_Response|WP_Error
*/
public function get_mcp_settings() {
$blog_id = Manager::get_site_id();
if ( is_wp_error( $blog_id ) ) {
return rest_ensure_response( $blog_id );
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ),
'2',
array( 'method' => 'GET' ),
null,
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
// has_mcp_plan is set explicitly by the WPCOM endpoint using PaidPlanMiddleware.
// Fall back to 402/403 status handling for older WPCOM builds that predate the field.
$http_status = wp_remote_retrieve_response_code( $response );
$has_mcp_plan = isset( $body['has_mcp_plan'] )
? (bool) $body['has_mcp_plan']
: ! in_array( $http_status, array( 402, 403 ), true );
if ( ! $has_mcp_plan ) {
return rest_ensure_response(
array(
'has_mcp_access' => false,
'mcp_abilities' => array(),
)
);
}
// Transform [ {name, title, readonly, site_context, enabled, …}, … ] → { name: {…}, … }.
// readonly and site_context are now provided by the WPCOM endpoint (wpcom#205108).
// Fall back to name-suffix heuristic for readonly only if the field is absent, so
// this endpoint remains functional against older WPCOM builds.
$account_abilities = new stdClass();
$site_abilities = array();
if ( ! empty( $body['abilities'] ) && is_array( $body['abilities'] ) ) {
$account_abilities = array();
foreach ( $body['abilities'] as $ability ) {
if ( empty( $ability['name'] ) ) {
continue;
}
if ( ! array_key_exists( 'readonly', $ability ) ) {
$ability['readonly'] = ! (bool) preg_match( '/-(create|update|delete)$/i', $ability['name'] );
}
$account_abilities[ (string) $ability['name'] ] = $ability;
// `site` subset: tools marked site_context=true by WPCOM are the only ones
// relevant to the site-level settings UI. getSiteContextToolIds() in JS uses
// this to filter out account/notifications/billing/domains tools.
if ( ! empty( $ability['site_context'] ) ) {
$site_abilities[ $ability['name'] ] = $ability;
}
}
}
// site_level_enabled comes directly from WPCOM — it is the authoritative effective
// state for this site (account default applied, site exceptions factored in).
// Fall back to the enabled-abilities heuristic only if the field is absent.
$site_level_enabled = isset( $body['site_level_enabled'] )
? (bool) $body['site_level_enabled']
: ! empty(
array_filter(
(array) $account_abilities,
function ( $a ) {
return ! empty( $a['enabled'] );
}
)
);
// site_level_enabled_default mirrors Calypso: same value as site_level_enabled
// when derived from WPCOM (no per-site override concept at this layer).
$site_level_enabled_default = $site_level_enabled;
// Use only the explicit per-site user overrides returned by WPCOM in user_overrides.abilities.
// These are the raw values stored via SettingsHelper — not the computed effective states
// (account defaults merged with overrides). Keeping only explicit overrides here lets the
// JS fall back to site_level_enabled as the default for any tool not yet overridden,
// matching Calypso's display behaviour (all tools on when site_level_enabled:true and no
// per-tool overrides exist).
$site_tool_abilities = array();
if ( isset( $body['user_overrides']['abilities'] ) && is_array( $body['user_overrides']['abilities'] ) ) {
foreach ( $body['user_overrides']['abilities'] as $name => $value ) {
$site_tool_abilities[ (string) $name ] = (bool) $value;
}
}
return rest_ensure_response(
array(
'has_mcp_access' => true,
'mcp_abilities' => array(
'account' => $account_abilities,
'site' => $site_abilities,
'sites' => array(
array(
'blog_id' => (int) $blog_id,
'site_level_enabled' => $site_level_enabled,
'abilities' => (object) $site_tool_abilities,
),
),
'site_level_enabled_default' => $site_level_enabled_default,
),
)
);
}
/**
* Proxy mcp_abilities update to WPCOM POST /sites/{blog_id}/mcp-abilities.
*
* Accepts the sites[] format used by the client:
* { sites: [{ blog_id, site_level_enabled?, abilities?: { tool_id: bool } }] }
*
* Extracts site_level_enabled and abilities from sites[0] and forwards them
* to the WPCOM endpoint which persists them to user settings via SettingsHelper.
* This keeps Jetpack and Calypso in sync — both read/write the same store.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error
*/
public function update_mcp_settings( $request ) {
$blog_id = Manager::get_site_id();
if ( is_wp_error( $blog_id ) ) {
return rest_ensure_response( $blog_id );
}
$incoming = $request->get_param( 'mcp_abilities' );
if ( is_object( $incoming ) ) {
$incoming = get_object_vars( $incoming );
} elseif ( ! is_array( $incoming ) ) {
$incoming = array();
}
// Unpack sites[0] into the flat format WPCOM POST /sites/{id}/mcp-abilities expects.
$wpcom_body = array();
if ( ! empty( $incoming['sites'] ) && is_array( $incoming['sites'] ) ) {
$site_update = $incoming['sites'][0];
if ( is_object( $site_update ) ) {
$site_update = get_object_vars( $site_update );
}
if ( isset( $site_update['site_level_enabled'] ) ) {
$wpcom_body['site_level_enabled'] = (bool) $site_update['site_level_enabled'];
}
if ( isset( $site_update['abilities'] ) ) {
$abilities = is_object( $site_update['abilities'] )
? get_object_vars( $site_update['abilities'] )
: (array) $site_update['abilities'];
$normalised = array();
foreach ( $abilities as $name => $value ) {
$normalised[ $name ] = (bool) $value;
}
$wpcom_body['abilities'] = $normalised;
}
}
if ( ! empty( $wpcom_body ) ) {
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ),
'2',
array( 'method' => 'POST' ),
$wpcom_body,
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status = wp_remote_retrieve_response_code( $response );
if ( $status < 200 || $status >= 300 ) {
return new WP_Error(
'wpcom_update_failed',
__( 'Failed to save MCP settings on WordPress.com.', 'jetpack' ),
array( 'status' => 502 )
);
}
}
return $this->get_mcp_settings();
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_MCP_Settings' );
@@ -0,0 +1,92 @@
<?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;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,92 @@
<?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;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,159 @@
<?php
/**
* REST API endpoint for post-level newsletter email-sent state.
*
* On Jetpack sites, registers the route and proxies requests to WordPress.com.
* On WordPress.com Simple sites (when required via jetpack-endpoints), handles
* the request locally by reading WPCom-only post meta.
*
* GET /wpcom/v2/newsletter-email-sent-status?post_id=<id>
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
use Automattic\Jetpack\Status\Host;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class WPCOM_REST_API_V2_Endpoint_Newsletter_Email_Sent_Status
*/
class WPCOM_REST_API_V2_Endpoint_Newsletter_Email_Sent_Status 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-email-sent-status';
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' => 'GET',
'callback' => ( ( new Host() )->is_wpcom_simple() ) ? array( $this, 'get_email_sent_status' ) : array( $this, 'proxy_request_to_wpcom_as_user' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'post_id' => array(
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => function ( $param ) {
return $param > 0;
},
),
),
)
);
}
/**
* Get email-sent state for a post (WPCom-only meta).
*
* @param WP_REST_Request $request Request object with post_id.
* @return WP_REST_Response|WP_Error
*/
public function get_email_sent_status( $request ) {
$post_id = $request->get_param( 'post_id' );
$post = get_post( $post_id );
if ( ! $post instanceof \WP_Post || 'post' !== $post->post_type ) {
return new WP_Error(
'post_not_found',
__( 'Post not found.', 'jetpack' ),
array( 'status' => 404 )
);
}
$email_notification = get_post_meta( $post_id, 'email_notification', true );
$email_sent_at = null;
if ( ! empty( $email_notification ) && is_numeric( $email_notification ) ) {
$unix_ts = (int) $email_notification;
$email_sent_at = wp_date( get_option( 'date_format' ), $unix_ts );
}
$stats_meta = get_post_meta( $post_id, '_wpcom_newsletter_stats_on_email_send', true );
$stats_on_send = null;
if ( ! empty( $stats_meta ) && is_array( $stats_meta ) && isset( $stats_meta[0] ) ) {
$first = $stats_meta[0];
$ts = $first['timestamp'] ?? null;
if ( ! empty( $ts ) ) {
$unix_ts = strtotime( $ts );
$ts = ( false !== $unix_ts ) ? wp_date( get_option( 'date_format' ), $unix_ts ) : null;
} else {
$ts = null;
}
$access_level_raw = $first['access_level'] ?? null;
$access_level = $access_level_raw;
$paid_tier = null;
if ( is_string( $access_level_raw ) && preg_match( '/^paid_subscribers:\s*(.+)$/', $access_level_raw, $m ) ) {
$access_level = 'paid_subscribers';
$paid_tier = trim( $m[1] );
}
$stats_on_send = array(
'access_level' => $access_level,
'paid_tier' => $paid_tier,
'post_categories' => isset( $first['post_categories'] ) && is_array( $first['post_categories'] ) ? $first['post_categories'] : array(),
'has_newsletter_categories' => ! empty( $first['has_newsletter_categories'] ),
'has_paywall_block' => isset( $first['has_paywall_block'] ) ? (bool) $first['has_paywall_block'] : null,
'timestamp' => $ts,
);
}
return rest_ensure_response(
array(
'email_sent_at' => $email_sent_at,
'stats_on_send' => $stats_on_send,
)
);
}
/**
* Permission check for the endpoint.
*
* @param WP_REST_Request $request Request object.
* @return bool|WP_Error
*/
public function permission_check( $request ) {
if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'view_stats' ) && ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to access this endpoint.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
// post_id is validated as > 0 by the route definition; this check is defensive.
$post_id = absint( $request->get_param( 'post_id' ) );
if ( $post_id > 0 && ! current_user_can( 'manage_options' ) && ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to access this endpoint.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Newsletter_Email_Sent_Status' );
@@ -0,0 +1,174 @@
<?php
/**
* Podcast Player API
*
* @package automattic/jetpack
* @since 8.4.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 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() {
$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() {
if ( ! class_exists( 'Jetpack_Podcast_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-podcast-helper.php';
}
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 ) {
if ( ! class_exists( 'Jetpack_Podcast_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-podcast-helper.php';
}
$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() {
if ( ! class_exists( 'Jetpack_Podcast_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-podcast-helper.php';
}
return Jetpack_Podcast_Helper::get_player_data_schema();
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Podcast_Player' );
@@ -0,0 +1,76 @@
<?php
/**
* REST API endpoint for user profile.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,190 @@
<?php
/**
* REST API endpoint for Related Posts
*
* @package automattic/jetpack
* @since 12.6
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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( 'edit_posts' );
},
)
);
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,140 @@
<?php
/**
* REST API endpoint for resolving URL redirects.
*
* @package automattic/jetpack
* @since 8.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 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,60 @@
<?php
/**
* Proxy endpoint for Jetpack Search
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Search\REST_Controller;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Called automatically on `rest_api_init()`.
*
* The Search package controller is instantiated here rather than in the
* constructor so that the package class is only loaded when the REST API is
* actually in use, not on every front-end, cron, or login request.
*/
public function register_routes() {
$this->controller = new REST_Controller( defined( 'IS_WPCOM' ) && IS_WPCOM );
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,163 @@
<?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;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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() );
/**
* Fires immediately before an email preview is dispatched to the current user.
*
* Useful for inspecting the post content with an external classifier (e.g. an
* LLM-based content moderator) or for logging outbound previews. Fires after
* the subscriber has been resolved, so handlers receive a post that is about
* to be sent.
*
* @module subscriptions
*
* @since 15.8
*
* @param WP_Post $post The post being previewed.
* @param Blog_Subscriber $subscriber The subscriber receiving the preview.
* @param Blog_Subscription|false $subscription The subscriber's subscription for the current blog, or false if none exists.
*/
do_action( 'jetpack_before_send_email_preview', $post, $subscriber, $subscription );
$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,876 @@
<?php
/**
* Subscribers List REST endpoint.
*
* Proxies the WP.com `/wpcom/v2/sites/{blog_id}/subscribers` endpoint so the
* Subscribers Dashboard wp-admin page can render a live DataViews table on
* Jetpack-connected self-hosted sites.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class WPCOM_REST_API_V2_Endpoint_Subscribers_List
*/
class WPCOM_REST_API_V2_Endpoint_Subscribers_List extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'subscribers/list';
// On WPCOM the matching endpoint is registered via the wpcom-rest-api-v2 plugin loader,
// where it talks to subscriber storage directly.
$this->wpcom_is_wpcom_only_endpoint = true;
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register routes.
*
* Gated behind the same `rsm_jetpack_ui_modernization_newsletter` filter the dashboard UI uses
* (mirrors `Automattic\Jetpack\Newsletter\Settings::MODERNIZATION_FILTER`). Checked here, on
* `rest_api_init`, so theme-added filters have a chance to land before the gate evaluates.
*
* The filter default is the staged rollout (Automatticians plus the percentage cohort,
* currently 0%, bucketed by the stable wpcom blog ID), delegated to the canonical
* Newsletter\Settings helper and guarded so an older packaged copy can't fatal.
*/
public function register_routes() {
$modernization_rollout_default = method_exists( '\Automattic\Jetpack\Newsletter\Settings', 'is_modernization_rollout_enabled' )
&& \Automattic\Jetpack\Newsletter\Settings::is_modernization_rollout_enabled();
if ( ! apply_filters( 'rsm_jetpack_ui_modernization_newsletter', $modernization_rollout_default ) ) {
return;
}
register_rest_route(
$this->namespace,
'/subscribers/add',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'add_subscribers' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'emails' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
'required' => true,
'validate_callback' => function ( $value ) {
return is_array( $value ) && count( $value ) > 0;
},
),
),
),
)
);
register_rest_route(
$this->namespace,
'/subscribers/remove',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'remove_subscriber' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'user_id' => array(
'type' => 'integer',
'default' => 0,
'minimum' => 0,
),
'email_subscription_id' => array(
'type' => 'integer',
'default' => 0,
'minimum' => 0,
),
'paid_subscription_ids' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
'default' => array(),
),
),
),
)
);
register_rest_route(
$this->namespace,
'/subscribers/individual',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_subscriber_individual' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'subscription_id' => array(
'type' => 'integer',
'default' => 0,
'minimum' => 0,
),
'user_id' => array(
'type' => 'integer',
'default' => 0,
'minimum' => 0,
),
),
),
)
);
register_rest_route(
$this->namespace,
'/subscribers/stats',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_subscriber_stats' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'subscription_id' => array(
'type' => 'integer',
'default' => 0,
'minimum' => 0,
),
'user_id' => array(
'type' => 'integer',
'default' => 0,
'minimum' => 0,
),
),
),
)
);
register_rest_route(
$this->namespace,
'/subscribers/import',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_import_jobs' ),
'permission_callback' => array( $this, 'permission_check' ),
),
)
);
register_rest_route(
$this->namespace,
'/subscribers/import/reset-state',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'reset_import_state' ),
'permission_callback' => array( $this, 'permission_check' ),
),
)
);
register_rest_route(
$this->namespace,
'/subscribers/totals',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_subscriber_totals' ),
'permission_callback' => array( $this, 'permission_check' ),
),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_subscribers' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'page' => array(
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
'per_page' => array(
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
),
'sort' => array(
'type' => 'string',
'default' => 'date_subscribed',
'enum' => array( 'date_subscribed', 'name', 'plan', 'subscription_status' ),
),
'sort_order' => array(
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
),
'search' => array(
'type' => 'string',
'default' => '',
),
'filters' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
'default' => array( 'all' ),
),
'use_new_helper' => array(
'type' => 'boolean',
'default' => true,
),
),
),
)
);
register_rest_route(
$this->namespace,
'/subscribers/products',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_memberships_products' ),
'permission_callback' => array( $this, 'permission_check' ),
),
)
);
register_rest_route(
$this->namespace,
'/subscribers/comp',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'add_comp' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'user_id' => array(
'type' => 'integer',
'required' => true,
'minimum' => 1,
),
'plan_id' => array(
'type' => 'integer',
'required' => true,
'minimum' => 1,
),
'no_expiration' => array(
'type' => 'boolean',
'default' => false,
),
),
),
)
);
register_rest_route(
$this->namespace,
'/subscribers/remove-comp',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'remove_comp' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'comp_id' => array(
'type' => 'integer',
'required' => true,
'minimum' => 1,
),
),
),
)
);
}
/**
* Permission check manage_options matches the wp-admin Subscribers menu cap.
*
* @return true|WP_Error
*/
public function permission_check() {
if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error(
'authorization_required',
__( 'You are not allowed to view subscribers for this site.', 'jetpack' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Proxy GET /wpcom/v2/sites/{blog_id}/subscribers.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function get_subscribers( $request ) {
$blog_id = Connection_Manager::get_site_id();
if ( is_wp_error( $blog_id ) ) {
return $blog_id;
}
$query = array(
'page' => (int) $request->get_param( 'page' ),
'per_page' => (int) $request->get_param( 'per_page' ),
'sort' => (string) $request->get_param( 'sort' ),
'sort_order' => (string) $request->get_param( 'sort_order' ),
'use_new_helper' => $request->get_param( 'use_new_helper' ) ? 'true' : 'false',
);
$search = (string) $request->get_param( 'search' );
if ( '' !== $search ) {
$query['search'] = $search;
}
$query_string = http_build_query( $query );
$filters = (array) $request->get_param( 'filters' );
foreach ( $filters as $filter ) {
$query_string .= '&' . rawurlencode( 'filters[]' ) . '=' . rawurlencode( (string) $filter );
}
$path = sprintf( '/sites/%d/subscribers?%s', (int) $blog_id, $query_string );
$response = Client::wpcom_json_api_request_as_user(
$path,
'2',
array( 'method' => 'GET' ),
null,
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $status >= 400 ) {
return new WP_Error(
'subscribers_list_failed',
is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'Could not fetch subscribers.', 'jetpack' ),
array( 'status' => $status )
);
}
return rest_ensure_response( $body );
}
/**
* Proxy GET /wpcom/v2/sites/{blog_id}/subscribers/counts.
*
* @return WP_REST_Response|WP_Error
*/
public function get_subscriber_totals() {
$blog_id = Connection_Manager::get_site_id();
if ( is_wp_error( $blog_id ) ) {
return $blog_id;
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/subscribers/counts', (int) $blog_id ),
'2',
array( 'method' => 'GET' ),
null,
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $status >= 400 ) {
return new WP_Error(
'subscribers_totals_failed',
is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'Could not fetch subscriber totals.', 'jetpack' ),
array( 'status' => $status )
);
}
return rest_ensure_response( $body );
}
/**
* Remove a subscriber by cancelling any paid subscriptions and deleting both the WPCOM
* follower and email follower records, mirroring Calypso's `useSubscriberRemoveMutation`.
*
* Proxies to the consolidated wpcom `/sites/{blog_id}/subscribers/remove` (v2) endpoint, which
* runs all three steps in-process after switching to the blog and returns an aggregated
* `{ ok, errors }` result. Forwarded as the current user (not the blog token): the wpcom/v2
* authorization layer maps the Jetpack user token to the wpcom user, so the endpoint's
* `manage_options` gate evaluates against the acting admin unlike the classic v1.1 `/rest`
* API, which left the request with no current user.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function remove_subscriber( $request ) {
$blog_id = Connection_Manager::get_site_id();
if ( is_wp_error( $blog_id ) ) {
return $blog_id;
}
$user_id = (int) $request->get_param( 'user_id' );
$email_subscription_id = (int) $request->get_param( 'email_subscription_id' );
$paid_subscription_ids = array_values(
array_filter( array_map( 'strval', (array) $request->get_param( 'paid_subscription_ids' ) ) )
);
if ( ! $user_id && ! $email_subscription_id && empty( $paid_subscription_ids ) ) {
return new WP_Error(
'subscribers_remove_invalid',
__( 'No subscriber identifiers were provided.', 'jetpack' ),
array( 'status' => 400 )
);
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/subscribers/remove', (int) $blog_id ),
'2',
array(
'method' => 'POST',
'headers' => array( 'Content-Type' => 'application/json' ),
),
wp_json_encode(
array(
'user_id' => $user_id,
'email_subscription_id' => $email_subscription_id,
'paid_subscription_ids' => $paid_subscription_ids,
),
JSON_UNESCAPED_SLASHES
),
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $status >= 400 ) {
return new WP_Error(
'subscribers_remove_failed',
is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'Could not remove the subscriber.', 'jetpack' ),
array( 'status' => $status )
);
}
return rest_ensure_response( $body );
}
/**
* Proxy GET /wpcom/v2/sites/{blog_id}/subscribers/individual.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function get_subscriber_individual( $request ) {
$blog_id = Connection_Manager::get_site_id();
$subscription_id = (int) $request->get_param( 'subscription_id' );
$user_id = (int) $request->get_param( 'user_id' );
if ( is_wp_error( $blog_id ) ) {
return $blog_id;
}
if ( ! $subscription_id && ! $user_id ) {
return new WP_Error(
'subscriber_individual_missing_id',
__( 'Provide either a subscription_id or a user_id.', 'jetpack' ),
array( 'status' => 400 )
);
}
$type = $user_id ? 'wpcom' : 'email';
$query = $user_id
? sprintf( 'user_id=%d&type=%s', $user_id, rawurlencode( $type ) )
: sprintf( 'subscription_id=%d&type=%s', $subscription_id, rawurlencode( $type ) );
return $this->wpcom_get( sprintf( '/sites/%d/subscribers/individual?%s', (int) $blog_id, $query ) );
}
/**
* Proxy GET /wpcom/v2/sites/{blog_id}/individual-subscriber-stats.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function get_subscriber_stats( $request ) {
$blog_id = Connection_Manager::get_site_id();
$subscription_id = (int) $request->get_param( 'subscription_id' );
$user_id = (int) $request->get_param( 'user_id' );
if ( is_wp_error( $blog_id ) ) {
return $blog_id;
}
if ( ! $subscription_id && ! $user_id ) {
return new WP_Error(
'subscriber_stats_missing_id',
__( 'Provide either a subscription_id or a user_id.', 'jetpack' ),
array( 'status' => 400 )
);
}
$query = $user_id
? sprintf( 'user_id=%d', $user_id )
: sprintf( 'subscription_id=%d', $subscription_id );
return $this->wpcom_get( sprintf( '/sites/%d/individual-subscriber-stats?%s', (int) $blog_id, $query ) );
}
/**
* Add subscribers by email proxies to `/sites/{blog_id}/subscribers/import` (v2), the same
* async import job Calypso's Add Subscribers modal starts. Addresses are imported directly as
* subscribers (no invitation email); WP.com processes the job in the background and emails the
* importing user a "Subscriber import completed" summary when it finishes.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function add_subscribers( $request ) {
$blog_id = Connection_Manager::get_site_id();
if ( is_wp_error( $blog_id ) ) {
return $blog_id;
}
$emails = (array) $request->get_param( 'emails' );
$emails = array_values(
array_filter(
array_map( 'sanitize_email', $emails ),
static function ( $email ) {
return is_email( $email );
}
)
);
if ( empty( $emails ) ) {
return new WP_Error(
'subscribers_add_no_valid_emails',
__( 'Provide at least one valid email address.', 'jetpack' ),
array( 'status' => 400 )
);
}
// JSON body, not the form encoding Calypso submits: WP.com's Jetpack signature verifier
// canonicalizes `application/x-www-form-urlencoded` bodies differently from the Jetpack
// client (it re-encodes the parsed array as JSON before hashing), so a form-encoded POST
// fails the body-hash check and arrives unauthenticated (user 0) — surfacing as a 401
// `invalid_capabilities`. JSON bodies hash identically on both sides, and the endpoint
// reads its params from either encoding. `parse_only => false` runs the import rather
// than only validating the payload.
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/subscribers/import', (int) $blog_id ),
'2',
array(
'method' => 'POST',
'headers' => array( 'Content-Type' => 'application/json' ),
),
wp_json_encode(
array(
'emails' => $emails,
'parse_only' => false,
),
JSON_UNESCAPED_SLASHES
),
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
// A successful import start carries the async job id as `upload_id`. Mirror Calypso, which
// treats any response without one as a failure even when the HTTP status is 2xx.
if ( $status >= 400 || ! is_array( $body ) || empty( $body['upload_id'] ) ) {
return new WP_Error(
'subscribers_add_failed',
$this->get_wpcom_error_message( $body, __( 'Could not add subscribers.', 'jetpack' ) ),
array( 'status' => $status >= 400 ? $status : 400 )
);
}
return rest_ensure_response( $body );
}
/**
* Proxy GET /wpcom/v2/sites/{blog_id}/subscribers/import the site's subscriber import jobs,
* newest first. The dashboard polls this while the Add Subscribers modal is open so it can
* show the "import in progress" / stale-import notices (WP.com runs one import per site at a
* time).
*
* @return WP_REST_Response|WP_Error
*/
public function get_import_jobs() {
$blog_id = Connection_Manager::get_site_id();
if ( is_wp_error( $blog_id ) ) {
return $blog_id;
}
return $this->wpcom_get( sprintf( '/sites/%d/subscribers/import', (int) $blog_id ) );
}
/**
* POST /wpcom/v2/subscribers/import/reset-state cancel stuck (pending / importing)
* subscriber import jobs, mirroring Calypso's stale-import "Cancel import" action
* (`useSubscriberImportStatusReset`). Proxies to the wpcom
* `/sites/{blog_id}/subscribers/import/reset_state` endpoint and returns its
* `{ reset_count }` body.
*
* @return WP_REST_Response|WP_Error
*/
public function reset_import_state() {
$blog_id = Connection_Manager::get_site_id();
if ( is_wp_error( $blog_id ) ) {
return $blog_id;
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/subscribers/import/reset_state', (int) $blog_id ),
'2',
array( 'method' => 'POST' ),
null,
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $status >= 400 ) {
return new WP_Error(
'subscribers_reset_import_failed',
$this->get_wpcom_error_message( $body, __( 'Could not cancel the import.', 'jetpack' ) ),
array( 'status' => $status )
);
}
return rest_ensure_response( $body );
}
/**
* Proxy GET /wpcom/v2/sites/{blog_id}/memberships/products?type=all&is_editable=true the
* paid newsletter / membership tiers configured on this site. Used by the Comp-a-subscription
* plan picker so the modal can offer "comp this subscriber on plan X".
*
* @return WP_REST_Response|WP_Error
*/
public function get_memberships_products() {
$blog_id = Connection_Manager::get_site_id();
if ( is_wp_error( $blog_id ) ) {
return $blog_id;
}
return $this->wpcom_get(
sprintf(
'/sites/%d/memberships/products?type=all&is_editable=true',
(int) $blog_id
)
);
}
/**
* POST /wpcom/v2/subscribers/comp issue a complimentary subscription on a paid membership
* product for a single subscriber. Mirrors Calypso's `requestAddComp` thunk, which POSTs to
* `/sites/{id}/memberships/comps/{user_id}/{plan_id}`.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function add_comp( $request ) {
$blog_id = Connection_Manager::get_site_id();
if ( is_wp_error( $blog_id ) ) {
return $blog_id;
}
$user_id = (int) $request->get_param( 'user_id' );
$plan_id = (int) $request->get_param( 'plan_id' );
$no_expiration = (bool) $request->get_param( 'no_expiration' );
$body = $no_expiration
? wp_json_encode( array( 'no_expiration' => true ), JSON_UNESCAPED_SLASHES )
: null;
$response = Client::wpcom_json_api_request_as_user(
sprintf(
'/sites/%d/memberships/comps/%d/%d',
(int) $blog_id,
$user_id,
$plan_id
),
'2',
array(
'method' => 'POST',
'headers' => array( 'Content-Type' => 'application/json' ),
),
$body,
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
// The Memberships API can report a failure either with an HTTP error status or with a 2xx
// response carrying an `error` payload (e.g. "User has already been comped this plan"), so
// treat both as failures and surface the upstream message rather than a generic one.
if ( $status >= 400 || ( is_array( $body ) && ! empty( $body['error'] ) ) ) {
return new WP_Error(
'subscribers_comp_failed',
$this->get_wpcom_error_message( $body, __( 'Could not comp the subscription.', 'jetpack' ) ),
array( 'status' => $status >= 400 ? $status : 400 )
);
}
return rest_ensure_response( $body );
}
/**
* POST /wpcom/v2/subscribers/remove-comp revoke a complimentary subscription. Mirrors
* Calypso's `requestDeleteComp`, which DELETEs
* `/sites/{id}/memberships/comp/{compId}` (singular `comp`).
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function remove_comp( $request ) {
$blog_id = Connection_Manager::get_site_id();
if ( is_wp_error( $blog_id ) ) {
return $blog_id;
}
$comp_id = (int) $request->get_param( 'comp_id' );
$response = Client::wpcom_json_api_request_as_user(
sprintf(
'/sites/%d/memberships/comp/%d',
(int) $blog_id,
$comp_id
),
'2',
array( 'method' => 'DELETE' ),
null,
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
// Mirror add_comp: the Memberships API can report a failure with an error status or with a
// 2xx response that carries an `error` payload, so treat both as failures.
if ( $status >= 400 || ( is_array( $body ) && ! empty( $body['error'] ) ) ) {
return new WP_Error(
'subscribers_remove_comp_failed',
$this->get_wpcom_error_message( $body, __( 'Could not remove the comp.', 'jetpack' ) ),
array( 'status' => $status >= 400 ? $status : 400 )
);
}
return rest_ensure_response( $body );
}
/**
* Extract the most specific human-readable error message from a wpcom Memberships API response
* body. The Memberships endpoints nest the reason under `error.message` (e.g. "User has already
* been comped this plan"); fall back to a top-level `message`, a string `error`, then the default.
*
* @param mixed $body Decoded response body.
* @param string $default_message Fallback used when the body carries no message.
* @return string Error message.
*/
private function get_wpcom_error_message( $body, $default_message ) {
if ( is_array( $body ) ) {
if ( isset( $body['error']['message'] ) && is_string( $body['error']['message'] ) ) {
return $body['error']['message'];
}
if ( isset( $body['message'] ) && is_string( $body['message'] ) ) {
return $body['message'];
}
if ( isset( $body['error'] ) && is_string( $body['error'] ) ) {
return $body['error'];
}
}
return $default_message;
}
/**
* Helper: GET a wpcom v2 path on this site as the current user. Returns the parsed JSON
* response or a WP_Error.
*
* @param string $path Path under `/wpcom/v2`, including any query string.
* @return WP_REST_Response|WP_Error
*/
private function wpcom_get( $path ) {
$response = Client::wpcom_json_api_request_as_user(
$path,
'2',
array( 'method' => 'GET' ),
null,
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $status >= 400 ) {
return new WP_Error(
'wpcom_call_failed',
is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'WP.com call failed.', 'jetpack' ),
array( 'status' => $status )
);
}
return rest_ensure_response( $body );
}
}
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Subscribers_List' );
@@ -0,0 +1,123 @@
<?php
/**
* REST API endpoint for resolving template.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,104 @@
<?php
/**
* Get post types and top posts.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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' ) );
}
/**
* 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 ) {
if ( ! class_exists( 'Jetpack_Top_Posts_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-top-posts-helper.php';
}
$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,91 @@
<?php
/**
* REST API endpoint for editing Jetpack Transients.
*
* @package automattic/jetpack
* @since 9.7.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 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,569 @@
<?php
/**
* Blogging prompts endpoint for wpcom/v3.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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, (int) $date_obj->format( 'n' ), (int) $date_obj->format( 'j' ) );
// 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,207 @@
<?php
/**
* REST API endpoint for retrieving JITMs from the WPCOM API via the Jetpack JITM class
* infrastructure.
*
* Replaces projects/packages/jitm/src/class-rest-api-endpoints.php.
*
* Available on:
* - Simple - via Dotcom Public API (https://public-api.wordpress.com/wpcom/v3/sites/{site_id}/jitm).
* - WoA and Jetpack connected sites - via local site REST API (https://myjetpackconnectedsite.com/wp-json/wpcom/v3/jitm)
*
* Utilises Jetpack classes to orchestrate the request and response handling.
* All JITM configuration happens on the Dotcom Simple codebase.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\REST_Connector;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class WPCOM_REST_API_V3_Endpoint_JITM
*/
class WPCOM_REST_API_V3_Endpoint_JITM extends WP_REST_Controller {
/**
* Namespace prefix.
*
* @var string
*/
public $namespace = 'wpcom/v3';
/**
* Endpoint base route.
*
* @var string
*/
public $rest_base = 'jitm';
/**
* WPCOM_REST_API_V3_Endpoint_JITM constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register JITM 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' ),
'args' => array(
'message_path' => array(
'required' => true,
'type' => 'string',
'description' => __( 'The message path to fetch JITMs for', 'jetpack' ),
'validate_callback' => 'rest_validate_request_arg',
),
'query' => array(
'required' => false,
'type' => 'string',
'description' => __( 'Additional query parameters', 'jetpack' ),
),
'full_jp_logo_exists' => array(
'required' => false,
'type' => 'boolean',
'description' => __( 'Whether the full Jetpack logo exists', 'jetpack' ),
),
),
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'dismiss_item' ),
'permission_callback' => array( $this, 'dismiss_item_permissions_check' ),
'args' => array(
'id' => array(
'required' => true,
'type' => 'string',
'description' => __( 'The ID of the JITM to dismiss', 'jetpack' ),
),
'feature_class' => array(
'required' => true,
'type' => 'string',
'description' => __( 'The feature class of the JITM', 'jetpack' ),
),
),
),
)
);
}
/**
* Retrieves the JITMs.
*
* @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 ) {
$jitm = Automattic\Jetpack\JITMS\JITM::get_instance();
if ( ! $jitm->jitms_enabled() ) {
return rest_ensure_response( array() );
}
// add the search term to the query params if it exists
$query_param = $request['query'] ?? '';
// Disable the jetpack_user_auth_check filter on Dotcom Simple codebase.
// This allows the wpcom/v3/jitm endpoint to work for Simple sites.
// See fbhepr%2Skers%2Sjcpbz%2Sjc%2Qpbagrag%2Serfg%2Qncv%2Qcyhtvaf%2Sraqcbvagf%2Swrgcnpx.cuc%3Se%3Q4580oq59%2374-og.
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
add_filter( 'rest_api_jitm_jetpack_user_auth_check', '__return_true' );
}
$messages = $jitm->get_messages(
$request['message_path'],
urldecode_deep( array( 'query' => $query_param ) ),
'true' === $request['full_jp_logo_exists']
);
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
remove_filter( 'rest_api_jitm_jetpack_user_auth_check', '__return_true' );
}
return rest_ensure_response( $messages );
}
/**
* Checks if a given request has access to get JITMs.
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has permission to get JITMs, WP_Error object otherwise.
*/
public function get_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! current_user_can( 'read' ) ) {
return new WP_Error(
'invalid_user_permission_jetpack_get_jitm_message',
REST_Connector::get_user_permissions_error_msg(),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Checks if a given request has access to dismiss JITMs.
*
* @return true|WP_Error True if the request has permission to dismiss, WP_Error object otherwise.
*/
public function dismiss_item_permissions_check() {
if ( ! current_user_can( 'read' ) ) {
return new WP_Error(
'invalid_user_permission_jetpack_delete_jitm_message',
REST_Connector::get_user_permissions_error_msg(),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Dismisses a JITM message.
*
* @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 dismiss_item( $request ) {
$jitm = Automattic\Jetpack\JITMS\JITM::get_instance();
if ( ! $jitm->jitms_enabled() ) {
// Boolean return matches return type of $jitm->dismiss().
// Not returning a WP_Error avoids a 400 response code
// and allows the dismiss action to be silently ignored.
return rest_ensure_response( true );
}
// Disable the jetpack_user_auth_check filter on Dotcom Simple codebase.
// This allows the wpcom/v3/jitm endpoint to work for Simple sites.
// See fbhepr%2Skers%2Sjcpbz%2Sjc%2Qpbagrag%2Serfg%2Qncv%2Qcyhtvaf%2Sraqcbvagf%2Swrgcnpx.cuc%3Se%3Q4580oq59%2374-og.
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
add_filter( 'rest_api_jitm_jetpack_user_auth_check', '__return_true' );
}
$result = $jitm->dismiss( $request['id'], $request['feature_class'] );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
remove_filter( 'rest_api_jitm_jetpack_user_auth_check', '__return_true' );
}
return rest_ensure_response( $result );
}
}
// This function is badly named since it works for all versions of the REST API.
wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V3_Endpoint_JITM' );
@@ -0,0 +1,91 @@
<?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
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,49 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Example of a WP.com endpoint.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,683 @@
<?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;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class WPCOM_REST_API_V2_Endpoint_Memberships
* This introduces V2 endpoints.
*
* @phan-constructor-used-for-side-effects
*/
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,
),
'description' => array(
'type' => 'string',
'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,
),
'description' => array(
'type' => 'string',
'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 = $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 ( is_wp_error( $payload ) ) {
return $payload;
}
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 ( is_wp_error( $payload ) ) {
return $payload;
}
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|WP_Error The built payload or WP_Error on validation failure.
*/
private function get_payload_for_product( WP_REST_Request $request ) {
$is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
$type = $request['type'] ?? null;
$tier = $request['tier'] ?? null;
$buyer_can_change_amount = isset( $request['buyer_can_change_amount'] ) && (bool) $request['buyer_can_change_amount'];
$interval = $request['interval'];
// Validate tier field usage.
$tier_validation = $this->validate_tier_field( $request, $tier, $type, $interval );
if ( is_wp_error( $tier_validation ) ) {
return $tier_validation;
}
$payload = array(
'title' => $request['title'],
'price' => $request['price'],
'currency' => $request['currency'],
'buyer_can_change_amount' => $buyer_can_change_amount,
'interval' => $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;
}
if ( isset( $request['description'] ) ) {
$payload['description'] = $request['description'];
}
return $payload;
}
/**
* Validate tier field usage for newsletter plans.
*
* @param WP_REST_Request $request The request object.
* @param string|null $tier The tier value to validate.
* @param string|null $type The product type.
* @param string $interval The product interval.
* @return WP_Error|null Error object if validation fails, null if successful.
*/
private function validate_tier_field( WP_REST_Request $request, $tier, $type, $interval ) {
// Only apply tier validation for newsletter plans with type 'tier'.
if ( null === $tier || 'tier' !== $type ) {
return null;
}
// Monthly plans should not have a tier field.
if ( '1 month' === $interval ) {
return new WP_Error( 'invalid_tier_usage', __( 'Monthly plans should not have a tier field. The tier field is only used to link yearly plans to their corresponding monthly plans.', 'jetpack' ), array( 'status' => 400 ) );
}
// Yearly plans must have a valid tier that points to a monthly plan.
if ( '1 year' === $interval ) {
return $this->validate_yearly_tier( $request, $tier );
}
return null;
}
/**
* Validate yearly tier requirements.
*
* @param WP_REST_Request $request The request object.
* @param string|int $tier The tier value to validate.
* @return WP_Error|null Error object if validation fails, null if successful.
*/
private function validate_yearly_tier( WP_REST_Request $request, $tier ) {
if ( ! is_numeric( $tier ) || $tier <= 0 ) {
return new WP_Error( 'invalid_tier_id', __( 'Yearly plans must have a valid tier ID that points to an existing monthly plan.', 'jetpack' ), array( 'status' => 400 ) );
}
if ( ! $this->is_wpcom() ) {
return null; // Validation will happen on WPCOM side.
}
return $this->validate_tier_references( $request, $tier );
}
/**
* Validate that the tier references a valid monthly plan and check for duplicates.
*
* @param WP_REST_Request $request The request object.
* @param string|int $tier The tier value to validate.
* @return WP_Error|null Error object if validation fails, null if successful.
*/
private function validate_tier_references( WP_REST_Request $request, $tier ) {
require_lib( 'memberships' );
Memberships_Store_Sandbox::get_instance()->init( true );
// Check if the referenced monthly plan exists and is actually a monthly plan.
$monthly_plan = Memberships_Product::get_from_post( get_current_blog_id(), $tier );
if ( is_wp_error( $monthly_plan ) || ! $monthly_plan ) {
return new WP_Error( 'tier_not_found', __( 'The specified tier ID does not correspond to an existing monthly plan.', 'jetpack' ), array( 'status' => 400 ) );
}
$monthly_plan_data = $monthly_plan->to_array();
if ( '1 month' !== $monthly_plan_data['interval'] ) {
return new WP_Error( 'invalid_tier_interval', __( 'The specified tier ID must point to a monthly plan (1 month interval).', 'jetpack' ), array( 'status' => 400 ) );
}
return $this->check_duplicate_tier_references( $request, $tier );
}
/**
* Check for duplicate tier references.
*
* @param WP_REST_Request $request The request object.
* @param string|int $tier The tier value to check.
* @return WP_Error|null Error object if duplicate found, null if successful.
*/
private function check_duplicate_tier_references( WP_REST_Request $request, $tier ) {
$existing_yearly_plans = Memberships_Product::get_product_list( get_current_blog_id(), 'tier', null, false );
if ( is_wp_error( $existing_yearly_plans ) ) {
return new WP_Error( 'product_list_error', __( 'Could not retrieve existing products to check for duplicate tier references.', 'jetpack' ), array( 'status' => 500 ) );
}
// Ensure the result is iterable before foreach.
if ( ! is_array( $existing_yearly_plans ) && ! $existing_yearly_plans instanceof Traversable ) {
return new WP_Error( 'invalid_product_list', __( 'Unexpected error: product list is not iterable.', 'jetpack' ), array( 'status' => 500 ) );
}
foreach ( $existing_yearly_plans as $existing_plan ) {
if ( isset( $existing_plan['tier'] ) && (string) $existing_plan['tier'] === (string) $tier && '1 year' === $existing_plan['interval'] ) {
// If this is an update, allow it to reference itself.
$product_id = $request->get_param( 'product_id' );
if ( ! $product_id || (string) $existing_plan['id'] !== (string) $product_id ) {
return new WP_Error( 'duplicate_tier_reference', __( 'Another yearly plan already references this monthly plan. Each monthly plan can only have one corresponding yearly plan.', 'jetpack' ), array( 'status' => 400 ) );
}
}
}
return null;
}
/**
* 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,260 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Fetch information about Publicize connections on a site, including tests and connection status.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Publicize: List Connection Test Result Data
*
* All the same data as the Publicize Connections Endpoint, plus test results.
*
* @deprecated 14.4 Deprecated in favor of /wpcom/v2/publicize/connections?test_connections=1
*
* @since 6.8
*/
class WPCOM_REST_API_V2_Endpoint_List_Publicize_Connection_Test_Results 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/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 );
}
/**
* 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',
),
);
}
/**
* 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;
}
/**
* 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() )
);
}
/**
* 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,339 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Get and save API keys for a site.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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 = $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,64 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* WPCOM Add Featured Media URL
* Adds `jetpack_featured_media_url` to post responses
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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,113 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Get subscriber count from Jetpack's Subscriptions module.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Constants;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* 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() {
if ( ! current_user_can_for_site( 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.
*/
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,47 @@
<?php
/**
* Loading the various functions used for Jetpack Debugging.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/* 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';
add_filter( 'debug_information', array( 'Jetpack_Debug_Data', 'core_debug_data' ) );
/*
* Use the beta support group URL for development versions of Jetpack.
*/
add_filter(
'jetpack_connection_support_url',
function ( $url ) {
if ( Jetpack::is_development_version() ) {
return Automattic\Jetpack\Redirect::get_url( 'jetpack-contact-support-beta-group' );
}
return $url;
}
);
/*
* Provide the Jetpack reconnect URL for connection health test failures.
*/
add_filter(
'jetpack_connection_reconnect_url',
function () {
return admin_url( 'admin.php?page=jetpack#/reconnect' );
}
);
// Note: Jetpack-specific test registration via jetpack_connection_tests_loaded
// has moved to class.jetpack.php so it runs on all requests (not just admin).
@@ -0,0 +1,18 @@
<?php
/**
* Legacy base class for Jetpack's debugging tests.
*
* @deprecated Use Connection_Health_Test_Base or Connection_Health_Tests from the connection package directly.
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Connection_Health_Test_Base;
/**
* "Unit Tests" for the Jetpack connection.
*
* @since 7.1.0
* @deprecated Use Connection_Health_Test_Base or Connection_Health_Tests from the connection package directly.
*/
class Jetpack_Cxn_Test_Base extends Connection_Health_Test_Base {
}
@@ -0,0 +1,94 @@
<?php
/**
* Jetpack-specific connection tests.
*
* Extends the connection package's Connection_Health_Test_Base with Jetpack-specific
* tests (sync health) and Jetpack-specific helper overrides.
*
* @package automattic/jetpack
*/
use Automattic\Jetpack\Connection\Connection_Health_Test_Base;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Sync\Health as Sync_Health;
use Automattic\Jetpack\Sync\Settings as Sync_Settings;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class Jetpack_Cxn_Tests contains the Jetpack-specific connection tests.
*
* Extends the connection package's framework and provides Jetpack-specific
* tests (sync health), encryption, and Jetpack-specific helper overrides.
*
* Jetpack-specific tests are also registered with the connection package's
* Site Health integration via the jetpack_connection_tests_loaded action.
*/
class Jetpack_Cxn_Tests extends Connection_Health_Test_Base {
/**
* Register Jetpack-specific tests on an external test suite instance.
*
* Used to add Jetpack tests to the connection package's Site Health integration
* via the jetpack_connection_tests_loaded action.
*
* @param Connection_Health_Test_Base $target The test suite to register tests on.
*/
public function register_tests_on( $target ) {
$methods = get_class_methods( static::class );
foreach ( $methods as $method ) {
if ( ! str_contains( $method, 'test__' ) ) {
continue;
}
$target->add_test( array( $this, $method ), $method, 'direct' );
}
}
/**
* Sync Health Tests.
*
* @return array Test results.
*/
protected function test__sync_health() {
$name = 'test__sync_health';
if ( ! $this->helper_is_connected() ) {
return self::skipped_test(
array(
'name' => $name,
'show_in_site_health' => false,
)
);
}
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 site\'s Jetpack-powered features. Developers may enable / disable syncing using the Sync Settings API.', 'jetpack' ),
)
);
}
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 site\'s Jetpack-powered features. If you continue to see this error, please contact support for assistance.', 'jetpack' ),
)
);
}
return self::passing_test( array( 'name' => $name ) );
}
}
@@ -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_key ?? 'Not set.',
'private' => false,
);
$debug_info['user_token'] = array(
'label' => 'User Public Key',
'value' => $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' ), JSON_UNESCAPED_SLASHES ),
'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, JSON_UNESCAPED_SLASHES ),
'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, JSON_UNESCAPED_SLASHES ),
'private' => false,
);
$debug_info['idc_error_option'] = array(
'label' => 'IDC Error Option',
'value' => wp_json_encode( Jetpack_Options::get_option( 'sync_error_idc' ), JSON_UNESCAPED_SLASHES ),
'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 Automattic\Jetpack\Connection\Connection_Health_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(), JSON_UNESCAPED_SLASHES );
}
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->exists() ) {
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,367 @@
<?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 Automattic\Jetpack\Connection\Connection_Health_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() {
$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 );
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 var(--wpds-color-stroke-surface-neutral-weak, #e4e4e4);
border-radius: var(--wpds-border-radius-md, 4px);
color: #646970;
font-size: 14px;
padding: 10px;
width: 97%;
}
#debug_info_div {
border-radius: var(--wpds-border-radius-lg, 8px);
margin-top: 16px;
background: var(--wpds-color-bg-surface-neutral-strong, #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,125 @@
<?php
/**
* WP Site Health debugging functions.
*
* @deprecated 15.9 Site Health integration is now handled by the connection package.
* @package automattic/jetpack
*/
/**
* Test runner for Core's Site Health module.
*
* @since 7.3.0
* @deprecated 15.9 Use Automattic\Jetpack\Connection\Site_Health instead.
*/
function jetpack_debugger_ajax_local_testing_suite() {
_deprecated_function( __FUNCTION__, 'jetpack-15.9' );
check_ajax_referer( 'health-check-site-status' );
if ( ! current_user_can( 'jetpack_manage_modules' ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( null, null, JSON_UNESCAPED_SLASHES );
}
$tests = new Automattic\Jetpack\Connection\Connection_Health_Tests();
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_success( $tests->output_results_for_core_async_site_health(), null, JSON_UNESCAPED_SLASHES );
}
/**
* Adds the Jetpack Local Testing Suite to the Core Site Health system.
*
* @since 7.3.0
* @deprecated 15.9 Use Automattic\Jetpack\Connection\Site_Health instead.
*
* @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 ) {
_deprecated_function( __FUNCTION__, 'jetpack-15.9' );
$cxn_tests = new Automattic\Jetpack\Connection\Connection_Health_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 Automattic\Jetpack\Connection\Connection_Health_Tests $cxn_tests An instance of the connection 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;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
<?php
/**
* Loader for the Markdown library.
*
* This file loads in a couple specific things from the markdown dir.
*
* @package automattic/jetpack
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
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,449 @@
<?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.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
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 = (int) round( $height / 3 );
$bottomy = (int) round( ( $height / 3 ) * 2 );
$leftx = (int) round( $width / 3 );
$rightx = (int) round( ( $width / 3 ) * 2 );
$centery = (int) round( $height / 2 );
$centerx = (int) 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'] > $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 ] ?? array();
$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, JSON_UNESCAPED_SLASHES ),
)
);
}
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 );
}
}