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,236 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.0.10] - 2026-06-15
### Changed
- Internal updates.
## [3.0.9] - 2026-06-08
### Changed
- Internal updates.
## [3.0.8] - 2026-06-03
### Changed
- Internal updates.
## [3.0.7] - 2026-05-19
### Changed
- Internal updates.
## [3.0.6] - 2026-05-04
### Changed
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
## [3.0.5] - 2025-04-28
### Changed
- Internal updates.
## [3.0.4] - 2025-03-21
### Changed
- Internal updates.
## [3.0.3] - 2025-03-12
### Changed
- Internal updates.
## [3.0.2] - 2025-03-05
### Changed
- Internal updates.
## [3.0.1] - 2025-02-24
### Changed
- Update dependencies.
## [3.0.0] - 2024-11-14
### Removed
- General: Update minimum PHP version to 7.2. [#40147]
## [2.0.4] - 2024-11-04
### Added
- Enable test coverage. [#39961]
## [2.0.3] - 2024-09-30
### Fixed
- Added a check for function presence to avoid fatal errors. [#39581]
## [2.0.2] - 2024-08-23
### Changed
- Updated package dependencies. [#39004]
## [2.0.1] - 2024-03-12
### Changed
- Internal updates.
## [2.0.0] - 2023-11-20
### Changed
- Updated required PHP version to >= 7.0. [#34192]
## [1.4.22] - 2023-09-19
- Minor internal updates.
## [1.4.21] - 2023-08-23
### Changed
- Updated package dependencies. [#32605]
## [1.4.20] - 2023-04-10
### Added
- Add Jetpack Autoloader package suggestion. [#29988]
## [1.4.19] - 2023-02-20
### Changed
- Minor internal updates.
## [1.4.18] - 2023-01-11
### Changed
- Updated package dependencies.
## [1.4.17] - 2022-12-02
### Changed
- Updated package dependencies. [#27688]
## [1.4.16] - 2022-11-22
### Changed
- Updated package dependencies. [#27043]
## [1.4.15] - 2022-07-26
### Changed
- Updated package dependencies. [#25158]
## [1.4.14] - 2022-06-21
### Changed
- Renaming master to trunk.
## [1.4.13] - 2022-04-26
### Changed
- Updated package dependencies.
## [1.4.12] - 2022-01-25
### Changed
- Updated package dependencies.
## [1.4.11] - 2022-01-04
### Changed
- Switch to pcov for code coverage.
- Updated package dependencies
## [1.4.10] - 2021-12-14
### Changed
- Updated package dependencies.
## [1.4.9] - 2021-11-02
### Changed
- Set `convertDeprecationsToExceptions` true in PHPUnit config.
- Update PHPUnit configs to include just what needs coverage rather than include everything then try to exclude stuff that doesn't.
## [1.4.8] - 2021-10-13
### Changed
- Updated package dependencies.
## [1.4.7] - 2021-10-12
### Changed
- Updated package dependencies
## [1.4.6] - 2021-09-28
### Changed
- Updated package dependencies.
## [1.4.5] - 2021-08-30
### Changed
- Run composer update on test-php command instead of phpunit
- Tests: update PHPUnit polyfills dependency (yoast/phpunit-polyfills).
- updated annotations versions
## [1.4.4] - 2021-05-25
### Changed
- Updated package dependencies.
## [1.4.3] - 2021-04-08
### Changed
- Packaging and build changes, no change to the package itself.
## [1.4.2] - 2021-03-30
### Added
- Composer alias for dev-master, to improve dependencies
### Changed
- Update package dependencies.
### Fixed
- Use `composer update` rather than `install` in scripts, as composer.lock isn't checked in.
## [1.4.1] - 2021-02-05
- CI: Make tests more generic
## [1.4.0] - 2021-01-20
- Add mirror-repo information to all current composer packages
## [1.3.0] - 2020-12-17
- Coverage Update whitelist for backend tests
- Pin dependencies
- Packages: Update for PHP 8 testing
## [1.2.0] - 2020-09-17
## [1.1.1] - 2020-09-17
- a8c-mc-stats: Do not distribute test files
## [1.1.0] - 2020-08-13
- CI: Try collect js coverage
## 1.0.0 - 2020-07-27
- Creates the MC Stats package
[3.0.10]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.9...v3.0.10
[3.0.9]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.8...v3.0.9
[3.0.8]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.7...v3.0.8
[3.0.7]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.6...v3.0.7
[3.0.6]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.5...v3.0.6
[3.0.5]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.4...v3.0.5
[3.0.4]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.3...v3.0.4
[3.0.3]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.2...v3.0.3
[3.0.2]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.1...v3.0.2
[3.0.1]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.0...v3.0.1
[3.0.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v2.0.4...v3.0.0
[2.0.4]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v2.0.3...v2.0.4
[2.0.3]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v2.0.2...v2.0.3
[2.0.2]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v2.0.1...v2.0.2
[2.0.1]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v2.0.0...v2.0.1
[2.0.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.22...v2.0.0
[1.4.22]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.21...v1.4.22
[1.4.21]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.20...v1.4.21
[1.4.20]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.19...v1.4.20
[1.4.19]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.18...v1.4.19
[1.4.18]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.17...v1.4.18
[1.4.17]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.16...v1.4.17
[1.4.16]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.15...v1.4.16
[1.4.15]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.14...v1.4.15
[1.4.14]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.13...v1.4.14
[1.4.13]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.12...v1.4.13
[1.4.12]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.11...v1.4.12
[1.4.11]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.10...v1.4.11
[1.4.10]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.9...v1.4.10
[1.4.9]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.8...v1.4.9
[1.4.8]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.7...v1.4.8
[1.4.7]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.6...v1.4.7
[1.4.6]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.5...v1.4.6
[1.4.5]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.4...v1.4.5
[1.4.4]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.3...v1.4.4
[1.4.3]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.2...v1.4.3
[1.4.2]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.1...v1.4.2
[1.4.1]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.0...v1.4.1
[1.4.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.1.1...v1.2.0
[1.1.1]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.0.0...v1.1.0
@@ -0,0 +1,182 @@
<?php
/**
* Jetpack MC Stats package.
*
* @package automattic/jetpack-mc-stats
*/
namespace Automattic\Jetpack;
/**
* Class MC Stats, used to record stats using https://pixel.wp.com/g.gif
*/
class A8c_Mc_Stats {
/**
* Holds the stats to be processed
*
* @var array
*/
private $stats = array();
/**
* Indicates whether to use the transparent pixel (b.gif) instead of the regular smiley (g.gif)
*
* @var boolean
*/
public $use_transparent_pixel = true;
/**
* Class Constructor
*
* @param boolean $use_transparent_pixel Use the transparent pixel instead of the smiley.
*/
public function __construct( $use_transparent_pixel = true ) {
$this->use_transparent_pixel = $use_transparent_pixel;
}
/**
* Store a stat for later output.
*
* @param string $group The stat group.
* @param string $name The stat name to bump.
*
* @return boolean true if stat successfully added
*/
public function add( $group, $name ) {
if ( ! \is_string( $group ) || ! \is_string( $name ) ) {
return false;
}
if ( ! isset( $this->stats[ $group ] ) ) {
$this->stats[ $group ] = array();
}
if ( \in_array( $name, $this->stats[ $group ], true ) ) {
return false;
}
$this->stats[ $group ][] = $name;
return true;
}
/**
* Gets current stats stored to be processed
*
* @return array $stats
*/
public function get_current_stats() {
return $this->stats;
}
/**
* Return the stats from a group in an array ready to be added as parameters in a query string
*
* @param string $group_name The name of the group to retrieve.
* @return array Array with one item, where the key is the prefixed group and the value are all stats concatenated with a comma. If group not found, an empty array will be returned
*/
public function get_group_query_args( $group_name ) {
$stats = $this->get_current_stats();
if ( isset( $stats[ $group_name ] ) && ! empty( $stats[ $group_name ] ) ) {
return array( "x_jetpack-{$group_name}" => implode( ',', $stats[ $group_name ] ) );
}
return array();
}
/**
* Gets a list of trac URLs for every stored URL
*
* @return array An array of URLs
*/
public function get_stats_urls() {
$urls = array();
foreach ( $this->get_current_stats() as $group => $stat ) {
$group_query_string = $this->get_group_query_args( $group );
$urls[] = $this->build_stats_url( $group_query_string );
}
return $urls;
}
/**
* Outputs the tracking pixels for the current stats and empty the stored stats from the object
*
* @return void
*/
public function do_stats() {
$urls = $this->get_stats_urls();
foreach ( $urls as $url ) {
echo '<img src="' . esc_url( $url ) . '" width="1" height="1" style="display:none;" />';
}
$this->stats = array();
}
/**
* Pings the stats server for the current stats and empty the stored stats from the object
*
* @return void
*/
public function do_server_side_stats() {
$urls = $this->get_stats_urls();
foreach ( $urls as $url ) {
$this->do_server_side_stat( $url );
}
$this->stats = array();
}
/**
* Runs stats code for a one-off, server-side.
*
* @param string $url string The URL to be pinged. Should include `x_jetpack-{$group}={$stats}` or whatever we want to store.
*
* @return bool If it worked.
*/
public function do_server_side_stat( $url ) {
$response = wp_remote_get( esc_url_raw( $url ) );
if ( is_wp_error( $response ) ) {
return false;
}
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
return false;
}
return true;
}
/**
* Builds the stats url.
*
* @param array $args array|string The arguments to append to the URL.
*
* @return string The URL to be pinged.
*/
public function build_stats_url( $args ) {
$defaults = array(
'v' => 'wpcom2',
// phpcs:ignore WordPress.WP.AlternativeFunctions.rand_rand -- There can be a case where pluggables are not yet loaded.
'rand' => md5( ( function_exists( 'wp_rand' ) ? wp_rand( 0, 999 ) : rand( 0, 999 ) ) . time() ),
);
$args = wp_parse_args( $args, $defaults );
$gifname = true === $this->use_transparent_pixel ? 'b.gif' : 'g.gif';
/**
* Filter the URL used as the Stats tracking pixel.
*
* @since-jetpack 2.3.2
* @since 1.0.0
*
* @param string $url Base URL used as the Stats tracking pixel.
*/
$base_url = apply_filters(
'jetpack_stats_base_url',
'https://pixel.wp.com/' . $gifname
);
$url = add_query_arg( $args, $base_url );
return $url;
}
}
@@ -0,0 +1,375 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.6] - 2026-06-26
### Changed
- Update dependencies. [#48834]
## [0.9.5] - 2026-06-23
### Changed
- Update package dependencies. [#49831]
## [0.9.4] - 2026-06-22
### Changed
- Update package dependencies. [#49691]
## [0.9.3] - 2026-06-15
### Changed
- Update dependencies. [#48834]
## [0.9.2] - 2026-06-09
### Changed
- Update package dependencies. [#49273]
## [0.9.1] - 2026-06-08
### Changed
- Internal updates.
## [0.9.0] - 2026-06-05
### Added
- Add a shared, token-only WPDS design-tokens stylesheet and enqueue it on Jetpack admin pages so `var(--wpds-*)` values resolve at runtime. [#49345]
## [0.8.9] - 2026-06-03
### Changed
- Internal updates.
## [0.8.8] - 2026-06-02
### Changed
- Update dependencies. [#48834]
## [0.8.7] - 2026-06-01
### Added
- Hide WordPress core admin notices on Jetpack admin pages. [#49237]
## [0.8.6] - 2026-05-21
### Changed
- Update package dependencies. [#48405]
## [0.8.5] - 2026-05-19
### Changed
- Internal updates.
## [0.8.4] - 2026-05-14
### Changed
- Update dependencies. [#48778]
## [0.8.3] - 2026-05-11
### Changed
- Update dependencies.
## [0.8.2] - 2026-05-04
### Changed
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
## [0.8.1] - 2026-04-20
### Changed
- Update package dependencies. [#48106]
## [0.8.0] - 2026-04-10
### Changed
- Hide the sidebar upsell when the site is not connected to Jetpack or is in offline mode. [#47967]
## [0.7.1] - 2026-04-09
### Changed
- Update package dependencies. [#47890]
## [0.7.0] - 2026-04-06
### Added
- Add "Upgrade Jetpack" menu item for free users in the Jetpack admin menu. [#47418]
- Admin: Record Tracks when the Jetpack sidebar "Upgrade Jetpack" link is clicked. [#47937]
### Changed
- Update design of the sidebar upsell. [#47909]
### Fixed
- Fix upgrade menu item color on problematic admin color schemes and add build pipeline for CSS assets. [#47903]
## [0.6.0] - 2026-03-30
### Changed
- Sidebar: Change "Anti-spam" to "Akismet Anti-spam". [#47834]
## [0.5.12] - 2026-03-09
### Changed
- Simplify Akismet admin menu title from 'Akismet Anti-spam' to 'Anti-spam'. [#47417]
## [0.5.11] - 2025-08-04
### Changed
- Internal updates.
## [0.5.10] - 2025-06-06
### Fixed
- Fix the Jetpack submenu order for WordPress.com sites that use Nav Unification. [#43781]
## [0.5.9] - 2025-05-05
### Fixed
- Remove the Jetpack submenu only if My Jetpack is present. [#43282]
## [0.5.8] - 2025-04-28
### Changed
- Internal updates.
## [0.5.7] - 2025-03-21
### Changed
- Internal updates.
## [0.5.6] - 2025-03-17
### Changed
- Internal updates.
## [0.5.5] - 2025-03-12
### Changed
- Internal updates.
## [0.5.4] - 2025-03-05
### Changed
- Internal updates.
## [0.5.3] - 2025-02-24
### Changed
- Update dependencies.
## [0.5.2] - 2025-02-03
### Added
- Add `remove_menu` method to `Admin_Menu` class. [#41422]
## [0.5.1] - 2024-11-25
### Changed
- Update dependencies. [#40286]
## [0.5.0] - 2024-11-14
### Removed
- General: Update minimum PHP version to 7.2. [#40147]
## [0.4.6] - 2024-11-04
### Added
- Enable test coverage. [#39961]
## [0.4.5] - 2024-09-05
### Changed
- Jetpack menu: only register Jetpack admin page for contributor roles and above. [#39081]
## [0.4.4] - 2024-08-29
### Changed
- Admin menu: change order of Jetpack sub-menu items [#39095]
## [0.4.3] - 2024-08-23
### Changed
- Updated package dependencies. [#39004]
## [0.4.2] - 2024-04-22
### Changed
- Internal updates.
## [0.4.1] - 2024-03-12
### Changed
- Internal updates.
## [0.4.0] - 2024-03-01
### Added
- Register menus in network admin as well as regular admin. [#36058]
## [0.3.2] - 2024-01-29
### Fixed
- Wait until 'admin_menu' action to call `add_menu()`, to avoid triggering the l10n load too early. [#35279]
## [0.3.1] - 2023-11-24
## [0.3.0] - 2023-11-20
### Changed
- Updated required PHP version to >= 7.0. [#34192]
## [0.2.25] - 2023-11-14
## [0.2.24] - 2023-10-30
### Fixed
- Handle Akismet submenu even if Jetpack is present, as Jetpack now relies on this package to do so. [#33559]
## [0.2.23] - 2023-09-19
### Changed
- Updated Jetpack submenu sort order so individual features are alpha-sorted. [#32958]
## [0.2.22] - 2023-09-11
### Fixed
- Akismet: update naming to common form [#32908]
## [0.2.21] - 2023-08-23
### Changed
- Updated package dependencies. [#32605]
## [0.2.20] - 2023-04-25
### Fixed
- Avoid errors when used in combination with an older version of the Logo package. [#30136]
## [0.2.19] - 2023-04-10
### Added
- Add Jetpack Autoloader package suggestion. [#29988]
## [0.2.18] - 2023-04-04
### Changed
- Menu icon: update to latest version of the Jetpack logo [#29418]
## [0.2.17] - 2023-02-20
### Changed
- Minor internal updates.
## [0.2.16] - 2023-01-25
### Changed
- Minor internal updates.
## [0.2.15] - 2023-01-11
### Changed
- Updated package dependencies.
## [0.2.14] - 2022-12-02
### Changed
- Updated package dependencies. [#27688]
## [0.2.13] - 2022-11-22
### Changed
- Updated package dependencies. [#27043]
## [0.2.12] - 2022-09-20
### Changed
- Updated package dependencies.
## [0.2.11] - 2022-07-26
### Changed
- Updated package dependencies. [#25158]
## [0.2.10] - 2022-07-12
### Changed
- Updated package dependencies.
## [0.2.9] - 2022-06-21
### Changed
- Renaming master to trunk.
## [0.2.8] - 2022-06-14
### Changed
- Updated package dependencies.
## [0.2.7] - 2022-04-26
### Changed
- Update package.json metadata.
## [0.2.6] - 2022-04-05
### Changed
- Updated package dependencies.
## [0.2.5] - 2022-03-08
### Fixed
- Do not handle Akismet submenu if Jetpack plugin is present
## [0.2.4] - 2022-02-09
### Added
- Support for akismet menu with stand-alone plugins
### Fixed
- Fixes menu order working around a bug in add_submenu_page
## [0.2.3] - 2022-01-25
### Changed
- Updated package dependencies.
## [0.2.2] - 2022-01-18
### Changed
- General: update required node version to v16.13.2
## [0.2.1] - 2022-01-04
### Changed
- Switch to pcov for code coverage.
- Updated package dependencies
## [0.2.0] - 2021-12-14
### Added
- New method to get the top level menu item
## [0.1.1] - 2021-11-17
### Changed
- Set `convertDeprecationsToExceptions` true in PHPUnit config.
- Update PHPUnit configs to include just what needs coverage rather than include everything then try to exclude stuff that doesn't.
## 0.1.0 - 2021-10-13
### Added
- Created the package.
### Changed
- Updated package dependencies.
### Fixed
- Fixing menu visibility issues.
[0.9.6]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.5...0.9.6
[0.9.5]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.4...0.9.5
[0.9.4]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.3...0.9.4
[0.9.3]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.2...0.9.3
[0.9.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.1...0.9.2
[0.9.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.0...0.9.1
[0.9.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.9...0.9.0
[0.8.9]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.8...0.8.9
[0.8.8]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.7...0.8.8
[0.8.7]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.6...0.8.7
[0.8.6]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.5...0.8.6
[0.8.5]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.4...0.8.5
[0.8.4]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.3...0.8.4
[0.8.3]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.2...0.8.3
[0.8.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.1...0.8.2
[0.8.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.0...0.8.1
[0.8.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.7.1...0.8.0
[0.7.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.7.0...0.7.1
[0.7.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.6.0...0.7.0
[0.6.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.12...0.6.0
[0.5.12]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.11...0.5.12
[0.5.11]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.10...0.5.11
[0.5.10]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.9...0.5.10
[0.5.9]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.8...0.5.9
[0.5.8]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.7...0.5.8
[0.5.7]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.6...0.5.7
[0.5.6]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.5...0.5.6
[0.5.5]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.4...0.5.5
[0.5.4]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.3...0.5.4
[0.5.3]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.2...0.5.3
[0.5.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.1...0.5.2
[0.5.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.0...0.5.1
[0.5.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.6...0.5.0
[0.4.6]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.5...0.4.6
[0.4.5]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.4...0.4.5
[0.4.4]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.3...0.4.4
[0.4.3]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.2...0.4.3
[0.4.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.1...0.4.2
[0.4.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.0...0.4.1
[0.4.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.3.2...0.4.0
[0.3.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.3.1...0.3.2
[0.3.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.3.0...0.3.1
[0.3.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.25...0.3.0
[0.2.25]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.24...0.2.25
[0.2.24]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.23...0.2.24
[0.2.23]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.22...0.2.23
[0.2.22]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.21...0.2.22
[0.2.21]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.20...0.2.21
[0.2.20]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.19...0.2.20
[0.2.19]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.18...0.2.19
[0.2.18]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.17...0.2.18
[0.2.17]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.16...0.2.17
[0.2.16]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.15...0.2.16
[0.2.15]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.14...0.2.15
[0.2.14]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.13...0.2.14
[0.2.13]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.12...0.2.13
[0.2.12]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.11...0.2.12
[0.2.11]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.10...0.2.11
[0.2.10]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.9...0.2.10
[0.2.9]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.8...0.2.9
[0.2.8]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.7...0.2.8
[0.2.7]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.6...0.2.7
[0.2.6]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.5...0.2.6
[0.2.5]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.4...0.2.5
[0.2.4]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.3...0.2.4
[0.2.3]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.2...0.2.3
[0.2.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.1...0.2.2
[0.2.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.0...0.2.1
[0.2.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.1.1...0.2.0
[0.1.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.1.0...0.1.1
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => '9928b439705112de9a6a');
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",()=>{const e=window.jetpackAdminUiUpgradeMenu;if(void 0===e)return;const t=e.menuItemClass;if(!t)return;const a=document.querySelector(`li.${t} a`);a&&(void 0!==window?.analytics&&e.tracksUserData&&window.analytics.initialize(e.tracksUserData?.userid,e.tracksUserData?.username),a.addEventListener("click",function(){window.analytics?.tracks?.recordEvent("jetpack_sidebar_free_upgrade_click",e.tracksEventData)}))});
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => '3c06881fce01e490b54a');
@@ -0,0 +1 @@
:root{--font-title-large:36px;--font-title-small:24px;--font-body:16px;--font-label:12px;--jp-black:#000;--jp-black-80:#2c3338;--jp-white:#fff;--jp-white-off:#f9f9f6;--jp-gray:#dcdcde;--jp-gray-0:#f6f7f7;--jp-gray-5:#dcdcde;--jp-gray-10:#c3c4c7;--jp-gray-20:#a7aaad;--jp-gray-30:#8c8f94;--jp-gray-40:#787c82;--jp-gray-50:#646970;--jp-gray-60:#50575e;--jp-gray-70:#3c434a;--jp-gray-80:#2c3338;--jp-gray-90:#1d2327;--jp-gray-100:#101517;--jp-gray-off:#e2e2df;--jp-yellow-5:#f5e6b3;--jp-yellow-10:#f2cf75;--jp-yellow-40:#c08c00;--jp-orange-20:#faa754;--jp-blue-5:#ced9f2;--jp-red-0:#f7ebec;--jp-red-50:#d63638;--jp-red-60:#b32d2e;--jp-red-80:#8a2424;--jp-red:#d63639;--jp-pink:#c9356e;--jp-green-0:#f0f2eb;--jp-green-5:#d0e6b8;--jp-green-10:#9dd977;--jp-green-20:#64ca43;--jp-green-30:#2fb41f;--jp-green-40:#069e08;--jp-green-50:#008710;--jp-green-60:#007117;--jp-green-70:#005b18;--jp-green-80:#004515;--jp-green-90:#003010;--jp-green-100:#001c09;--jp-green:#069e08;--jp-green-mint:#d3f6d5;--jp-green-primary:var(--jp-green-40);--jp-green-secondary:var(--jp-green-30);--jp-border-radius:4px;--jp-border-radius-rna:8px;--jp-menu-border-height:1px;--jp-underline-thickness:2px;--jp-modal-padding-large:32px;--jp-modal-padding:24px;--jp-modal-padding-small:16px;--jp-modal-radius:8px;--jp-button-padding:8px;--jp-button-radius:4px;--jp-gap:16px}#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a,#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a:hover{background:var(--jp-green-40)!important;border-radius:var(--jp-border-radius)!important;box-shadow:none!important;color:var(--jp-white)!important;margin:2px 4px;text-align:center;text-wrap:balance}#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a:hover{background:var(--jp-green-30)!important}
@@ -0,0 +1 @@
:root{--font-title-large:36px;--font-title-small:24px;--font-body:16px;--font-label:12px;--jp-black:#000;--jp-black-80:#2c3338;--jp-white:#fff;--jp-white-off:#f9f9f6;--jp-gray:#dcdcde;--jp-gray-0:#f6f7f7;--jp-gray-5:#dcdcde;--jp-gray-10:#c3c4c7;--jp-gray-20:#a7aaad;--jp-gray-30:#8c8f94;--jp-gray-40:#787c82;--jp-gray-50:#646970;--jp-gray-60:#50575e;--jp-gray-70:#3c434a;--jp-gray-80:#2c3338;--jp-gray-90:#1d2327;--jp-gray-100:#101517;--jp-gray-off:#e2e2df;--jp-yellow-5:#f5e6b3;--jp-yellow-10:#f2cf75;--jp-yellow-40:#c08c00;--jp-orange-20:#faa754;--jp-blue-5:#ced9f2;--jp-red-0:#f7ebec;--jp-red-50:#d63638;--jp-red-60:#b32d2e;--jp-red-80:#8a2424;--jp-red:#d63639;--jp-pink:#c9356e;--jp-green-0:#f0f2eb;--jp-green-5:#d0e6b8;--jp-green-10:#9dd977;--jp-green-20:#64ca43;--jp-green-30:#2fb41f;--jp-green-40:#069e08;--jp-green-50:#008710;--jp-green-60:#007117;--jp-green-70:#005b18;--jp-green-80:#004515;--jp-green-90:#003010;--jp-green-100:#001c09;--jp-green:#069e08;--jp-green-mint:#d3f6d5;--jp-green-primary:var(--jp-green-40);--jp-green-secondary:var(--jp-green-30);--jp-border-radius:4px;--jp-border-radius-rna:8px;--jp-menu-border-height:1px;--jp-underline-thickness:2px;--jp-modal-padding-large:32px;--jp-modal-padding:24px;--jp-modal-padding-small:16px;--jp-modal-radius:8px;--jp-button-padding:8px;--jp-button-radius:4px;--jp-gap:16px}#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a,#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a:hover{background:var(--jp-green-40)!important;border-radius:var(--jp-border-radius)!important;box-shadow:none!important;color:var(--jp-white)!important;margin:2px 4px;text-align:center;text-wrap:balance}#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a:hover{background:var(--jp-green-30)!important}
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => '9909656c2d47702db3c4');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
/**
* Marks the wp-admin Jetpack sidebar "Upgrade Jetpack" item for Tracks.
*/
document.addEventListener( 'DOMContentLoaded', () => {
const config = window.jetpackAdminUiUpgradeMenu;
if ( typeof config === 'undefined' ) {
return;
}
const className = config.menuItemClass;
if ( ! className ) {
return;
}
// Class comes from PHP (UPGRADE_MENU_SLUG); safe for querySelector.
const item = document.querySelector( `li.${ className } a` );
if ( ! item ) {
return;
}
// Initialize Tracks
if ( 'undefined' !== typeof window?.analytics && config.tracksUserData ) {
window.analytics.initialize( config.tracksUserData?.userid, config.tracksUserData?.username );
}
item.addEventListener( 'click', function () {
window.analytics?.tracks?.recordEvent(
'jetpack_sidebar_free_upgrade_click',
config.tracksEventData
);
} );
} );
@@ -0,0 +1,601 @@
<?php
/**
* Admin Menu Registration
*
* @package automattic/jetpack-admin-ui
*/
namespace Automattic\Jetpack\Admin_UI;
use Automattic\Jetpack\Tracking;
use Jetpack_Options;
use Jetpack_Tracks_Client;
/**
* This class offers a wrapper to add_submenu_page and makes sure stand-alone plugin's menu items are always added under the Jetpack top level menu.
* If the Jetpack top level was not previously registered by other plugin, it will be registered here.
*/
class Admin_Menu {
const PACKAGE_VERSION = '0.9.6';
/**
* Slug used for the upgrade menu item and redirect URL.
*
* Keep the slug in sync with `$upgrade-menu-slug` at admin-ui-upgrade-menu.scss
*
* @var string
*/
const UPGRADE_MENU_SLUG = 'jetpack-wpadmin-sidebar-free-plan-upsell-menu-item';
/**
* Fallback upgrade URL when the Redirect class is unavailable.
*
* @var string
*/
const UPGRADE_MENU_FALLBACK_URL = 'https://jetpack.com/upgrade/';
/**
* Handle for the shared, token-only WPDS design-tokens stylesheet.
*
* Registered once and enqueued on every Jetpack admin page so that
* `var(--wpds-*)` values resolve at runtime instead of falling back to
* their hand-written hex defaults.
*
* @var string
*/
const DESIGN_TOKENS_HANDLE = 'jetpack-admin-ui-design-tokens';
/**
* Whether this class has been initialized
*
* @var boolean
*/
private static $initialized = false;
/**
* List of menu items enqueued to be added
*
* @var array
*/
private static $menu_items = array();
/**
* Hook suffixes of the pages registered through this class.
*
* Used to scope the design-tokens stylesheet to Jetpack admin pages.
*
* @var array
*/
private static $page_hooks = array();
/**
* Optional connection manager dependency.
*
* @var object|null
*/
private static $connection_manager = null;
/**
* Initialize the class and set up the main hook
*
* @return void
*/
public static function init() {
if ( ! self::$initialized ) {
self::$initialized = true;
self::handle_akismet_menu();
add_action( 'admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
add_action( 'network_admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'add_upgrade_menu_item_styles' ) );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_design_tokens' ) );
}
}
/**
* Handles the Akismet menu item when used alongside other stand-alone plugins
*
* When Jetpack plugin is present, Akismet menu item is moved under the Jetpack top level menu, but if Akismet is active alongside other stand-alone plugins,
* we use this method to move the menu item.
*/
private static function handle_akismet_menu() {
if ( class_exists( 'Akismet_Admin' ) ) {
add_action(
'admin_menu',
function () {
// Prevent Akismet from adding a menu item.
remove_action( 'admin_menu', array( 'Akismet_Admin', 'admin_menu' ), 5 );
// Add an Anti-spam menu item for Jetpack.
self::add_menu( __( 'Akismet Anti-spam', 'jetpack-admin-ui' ), __( 'Akismet Anti-spam', 'jetpack-admin-ui' ), 'manage_options', 'akismet-key-config', array( 'Akismet_Admin', 'display_page' ), 6 );
},
4
);
}
}
/**
* Callback to the admin_menu and network_admin_menu hooks that will register the enqueued menu items
*
* @return void
*/
public static function admin_menu_hook_callback() {
$can_see_toplevel_menu = true;
$jetpack_plugin_present = class_exists( 'Jetpack_React_Page' );
$icon = method_exists( '\Automattic\Jetpack\Assets\Logo', 'get_base64_logo' )
? ( new \Automattic\Jetpack\Assets\Logo() )->get_base64_logo()
: 'dashicons-admin-plugins';
if ( ! $jetpack_plugin_present ) {
add_menu_page(
'Jetpack',
'Jetpack',
'edit_posts',
'jetpack',
'__return_null',
$icon,
3
);
// If Jetpack plugin is not present, user will only be able to see this menu if they have enough capability to at least one of the sub menus being added.
$can_see_toplevel_menu = false;
}
/**
* The add_sub_menu function has a bug and will not keep the right order of menu items.
*
* @see https://core.trac.wordpress.org/ticket/52035
* Let's order the items before registering them.
* Since this all happens after the Jetpack plugin menu items were added, all items will be added after Jetpack plugin items - unless position is very low number (smaller than the number of menu items present in Jetpack plugin).
*/
usort(
self::$menu_items,
function ( $a, $b ) {
$position_a = empty( $a['position'] ) ? 0 : $a['position'];
$position_b = empty( $b['position'] ) ? 0 : $b['position'];
$result = $position_a <=> $position_b;
if ( 0 === $result ) {
$result = strcmp( $a['menu_title'], $b['menu_title'] );
}
return $result;
}
);
foreach ( self::$menu_items as $menu_item ) {
if ( ! current_user_can( $menu_item['capability'] ) ) {
continue;
}
$can_see_toplevel_menu = true;
add_submenu_page(
'jetpack',
$menu_item['page_title'],
$menu_item['menu_title'],
$menu_item['capability'],
$menu_item['menu_slug'],
$menu_item['function'],
$menu_item['position']
);
}
if ( ! $jetpack_plugin_present ) {
remove_submenu_page( 'jetpack', 'jetpack' );
}
if ( ! $can_see_toplevel_menu ) {
remove_menu_page( 'jetpack' );
}
self::maybe_add_upgrade_menu_item();
}
/**
* Adds a new submenu to the Jetpack Top level menu
*
* The parameters this method accepts are the same as @see add_submenu_page. This class will
* aggreagate all menu items registered by stand-alone plugins and make sure they all go under the same
* Jetpack top level menu. It will also handle the top level menu registration in case the Jetpack plugin is not present.
*
* @param string $page_title The text to be displayed in the title tags of the page when the menu
* is selected.
* @param string $menu_title The text to be used for the menu.
* @param string $capability The capability required for this menu to be displayed to the user.
* @param string $menu_slug The slug name to refer to this menu by. Should be unique for this menu
* and only include lowercase alphanumeric, dashes, and underscores characters
* to be compatible with sanitize_key().
* @param callable|null $function The function to be called to output the content for this page.
* @param int $position The position in the menu order this item should appear. Leave empty typically.
*
* @return string The resulting page's hook_suffix
*/
public static function add_menu( $page_title, $menu_title, $capability, $menu_slug, $function, $position = null ) {
self::init();
self::$menu_items[] = compact( 'page_title', 'menu_title', 'capability', 'menu_slug', 'function', 'position' );
/**
* Let's return the page hook so consumers can use.
* We know all pages will be under Jetpack top level menu page, so we can hardcode the first part of the string.
* Using get_plugin_page_hookname here won't work because the top level page is not registered yet.
*/
$hook = 'jetpack_page_' . $menu_slug;
// Track the page hook so the design-tokens stylesheet can be scoped to it.
self::$page_hooks[] = $hook;
// Hide WordPress core admin notices on this Jetpack page. The load-<hook>
// action only fires when the matching screen is being rendered, so this
// stays scoped to Jetpack pages and reaches every page registered here.
add_action( 'load-' . $hook, array( __CLASS__, 'hide_core_admin_notices' ) );
add_action( 'load-' . $hook . '-network', array( __CLASS__, 'hide_core_admin_notices' ) );
return $hook;
}
/**
* Queues an inline style that hides WordPress core admin notices on the current Jetpack page.
*
* Hooked from the page's load-<hook> action so it only runs on Jetpack screens.
*
* @return void
*/
public static function hide_core_admin_notices() {
add_action( 'admin_print_styles', array( __CLASS__, 'print_hide_core_admin_notices_style' ) );
}
/**
* Prints the inline style that hides WordPress core admin notices.
*
* We only target direct children of #wpbody-content (where core renders notices via the
* admin_notices / all_admin_notices hooks). This intentionally leaves JITMs untouched —
* they output `.jetpack-jitm-message`, not `.notice` — and leaves in-app/React notices
* untouched, since those render deeper inside `.wrap`. An inline style is used (rather than
* an enqueued build asset) so it also works on older Jetpack pages that ship no stylesheet.
*
* @return void
*/
public static function print_hide_core_admin_notices_style() {
?>
<style id="jetpack-admin-ui-hide-core-notices">
#wpbody-content > .notice,
#wpbody-content > .update-nag,
#wpbody-content > .updated,
#wpbody-content > .error { display: none !important; }
</style>
<?php
}
/**
* Removes an already added submenu
*
* @param string $menu_slug The slug of the submenu to remove.
*
* @return array|false The removed submenu on success, false if not found.
*/
public static function remove_menu( $menu_slug ) {
foreach ( self::$menu_items as $index => $menu_item ) {
if ( $menu_item['menu_slug'] === $menu_slug ) {
unset( self::$menu_items[ $index ] );
return $menu_item;
}
}
return false;
}
/**
* Gets the slug for the first item under the Jetpack top level menu
*
* @return string|null
*/
public static function get_top_level_menu_item_slug() {
global $submenu;
if ( ! empty( $submenu['jetpack'] ) ) {
$item = reset( $submenu['jetpack'] );
if ( isset( $item[2] ) ) {
return $item[2];
}
}
}
/**
* Gets the URL for the first item under the Jetpack top level menu
*
* @param string $fallback If Jetpack menu is not there or no children is found, return this fallback instead. Default to admin_url().
* @return string
*/
public static function get_top_level_menu_item_url( $fallback = false ) {
$slug = self::get_top_level_menu_item_slug();
if ( $slug ) {
$url = menu_page_url( $slug, false );
return $url;
}
$url = $fallback ? $fallback : admin_url();
return $url;
}
/**
* Checks whether the current site should show the upgrade menu item.
*
* The upgrade menu is only shown to administrators on free-plan sites
* that are not hosted on WordPress.com.
*
* @return bool True if the upgrade menu should be shown.
*/
private static function should_show_upgrade_menu() {
// Only show to administrators.
if ( ! current_user_can( 'manage_options' ) ) {
return false;
}
// Don't show upsells on WordPress.com platform.
if ( class_exists( '\Automattic\Jetpack\Status\Host' ) ) {
$host = new \Automattic\Jetpack\Status\Host();
if ( $host->is_wpcom_platform() ) {
return false;
}
}
// Don't show upsells in offline/development mode.
if ( class_exists( '\Automattic\Jetpack\Status' ) ) {
$status = new \Automattic\Jetpack\Status();
if ( $status->is_offline_mode() ) {
return false;
}
}
// Only show after the site and current user are connected.
if ( ! self::is_site_and_user_connected() ) {
return false;
}
// Only show to free-plan sites.
return self::is_free_plan();
}
/**
* Checks whether the site and current user are connected to WordPress.com.
*
* @return bool True if site and current user are connected.
*/
private static function is_site_and_user_connected() {
$connection_manager = self::$connection_manager;
if ( ! $connection_manager && class_exists( '\Automattic\Jetpack\Connection\Manager' ) ) {
$connection_manager = new \Automattic\Jetpack\Connection\Manager();
self::$connection_manager = $connection_manager;
}
if (
$connection_manager
&& is_callable( array( $connection_manager, 'is_connected' ) )
&& is_callable( array( $connection_manager, 'is_user_connected' ) )
) {
return (bool) $connection_manager->is_connected()
&& (bool) $connection_manager->is_user_connected( get_current_user_id() );
}
return false;
}
/**
* Sets the connection manager dependency; used by tests.
*
* @param object|null $connection_manager Connection manager object.
* @return void
*/
public static function set_connection_manager( $connection_manager ) {
self::$connection_manager = $connection_manager;
}
/**
* Checks whether the current site is on a free Jetpack plan with no active paid license.
*
* @return bool True if the site has no paid plan.
*/
private static function is_free_plan() {
// Check the active plan - use the is_free field or product_slug.
$plan = get_option( 'jetpack_active_plan', array() );
// Back-compat: older plan payloads use class to indicate paid plans.
if ( isset( $plan['class'] ) && 'free' !== $plan['class'] ) {
return false;
}
// If the plan explicitly says it's not free, trust that.
if ( isset( $plan['is_free'] ) && false === $plan['is_free'] ) {
return false;
}
// Check if the product slug indicates a paid plan.
if ( isset( $plan['product_slug'] ) && 'jetpack_free' !== $plan['product_slug'] ) {
return false;
}
// Also check for site products (licenses can add products without changing plan).
$products = get_option( 'jetpack_site_products', array() );
if ( ! empty( $products ) && is_array( $products ) ) {
return false;
}
return true;
}
/**
* Conditionally adds an "Upgrade Jetpack" submenu item for free-plan sites.
*
* Only shown to users with manage_options capability on self-hosted sites without a paid Jetpack plan or license.
*
* @return void
*/
private static function maybe_add_upgrade_menu_item() {
if ( ! self::should_show_upgrade_menu() ) {
return;
}
$upgrade_url = class_exists( '\Automattic\Jetpack\Redirect' )
? \Automattic\Jetpack\Redirect::get_url( self::UPGRADE_MENU_SLUG )
: self::UPGRADE_MENU_FALLBACK_URL;
$menu_title = esc_html__( 'Upgrade Jetpack', 'jetpack-admin-ui' );
add_submenu_page(
'jetpack',
$menu_title,
$menu_title,
'manage_options',
esc_url( $upgrade_url ),
null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
999
);
// Add a CSS class to the <li> element so styles can target it precisely.
global $submenu;
if ( ! empty( $submenu['jetpack'] ) ) {
foreach ( $submenu['jetpack'] as $index => $item ) {
if ( isset( $item[2] ) && false !== strpos( $item[2], self::UPGRADE_MENU_SLUG ) ) {
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$submenu['jetpack'][ $index ][4] = ( ! empty( $item[4] ) ? $item[4] . ' ' : '' ) . self::UPGRADE_MENU_SLUG;
break;
}
}
}
}
/**
* Enqueues admin styles for the "Upgrade Jetpack" menu item.
*
* The sidebar menu is visible on every admin page, so styles load globally.
* Only enqueues for free-plan sites on self-hosted installs.
*
* @return void
*/
public static function add_upgrade_menu_item_styles() {
if ( ! self::should_show_upgrade_menu() ) {
return;
}
$asset_file = dirname( __DIR__ ) . '/build/admin-ui-upgrade-menu.asset.php';
$asset = file_exists( $asset_file ) ? require $asset_file : array();
wp_enqueue_style(
'jetpack-admin-ui-upgrade-menu',
plugins_url( '../build/admin-ui-upgrade-menu.css', __FILE__ ),
$asset['dependencies'] ?? array(),
$asset['version'] ?? self::PACKAGE_VERSION
);
self::enqueue_upgrade_menu_tracks_script( $asset );
}
/**
* Enqueues the shared, token-only WPDS design-tokens stylesheet.
*
* Single entry point for any consumer that needs WPDS `var(--wpds-*)` values
* to resolve at runtime on a Jetpack admin page. Registers the handle on
* first use (idempotent) and enqueues it; the caller is responsible for
* scoping the call to the right page(s). Since admin-ui is a dependency of
* the Jetpack plugin and the modernized packages, both the plugin's
* legacy/wrap_ui gate and this package's own dashboards call through here,
* so the handle has a single owner and there is no duplicated enqueue logic.
*
* @return void
*/
public static function enqueue_design_tokens() {
self::register_design_tokens_style();
wp_enqueue_style( self::DESIGN_TOKENS_HANDLE );
}
/**
* Registers the shared, token-only WPDS design-tokens stylesheet.
*
* The stylesheet only defines `:root{--wpds-*}` custom properties (no
* component or class styles), giving every Jetpack admin page a single
* runtime source for design tokens. It is safe to call repeatedly:
* wp_register_style() is a no-op once the handle is registered.
*
* @return void
*/
private static function register_design_tokens_style() {
if ( wp_style_is( self::DESIGN_TOKENS_HANDLE, 'registered' ) ) {
return;
}
$asset_file = dirname( __DIR__ ) . '/build/design-tokens.asset.php';
$asset = file_exists( $asset_file ) ? require $asset_file : array();
wp_register_style(
self::DESIGN_TOKENS_HANDLE,
plugins_url( '../build/design-tokens.css', __FILE__ ),
$asset['dependencies'] ?? array(),
$asset['version'] ?? self::PACKAGE_VERSION
);
}
/**
* Enqueues the design tokens on the pages registered through this class.
*
* This is the admin_enqueue_scripts callback for the modernized Jetpack
* dashboards. Scoped to self::$page_hooks so the tokens load wherever a
* modernized dashboard renders, regardless of plan or connection state; the
* actual enqueue is delegated to the reusable enqueue_design_tokens() API.
*
* @param string $hook_suffix The current admin page's hook suffix.
* @return void
*/
public static function maybe_enqueue_design_tokens( $hook_suffix ) {
if ( ! in_array( $hook_suffix, self::$page_hooks, true ) ) {
return;
}
self::enqueue_design_tokens();
}
/**
* Enqueues Tracks for the upgrade submenu item.
*
* @param array $asset Parsed contents of admin-ui-upgrade-menu.asset.php.
* @return void
*/
private static function enqueue_upgrade_menu_tracks_script( $asset ) {
if ( ! class_exists( '\Automattic\Jetpack\Tracking' ) ) {
return;
}
Tracking::register_tracks_functions_scripts( true );
wp_enqueue_script(
'jetpack-admin-ui-upgrade-menu-tracking',
plugins_url( '../build/admin-ui-upgrade-menu-tracking.js', __FILE__ ),
$asset['dependencies'] ?? array(),
$asset['version'] ?? self::PACKAGE_VERSION,
true
);
$current_screen = get_current_screen();
$is_admin = current_user_can( 'jetpack_disconnect' );
$site_id = class_exists( 'Jetpack_Options' ) ? Jetpack_Options::get_option( 'id' ) : null;
$tracks_user_data = class_exists( 'Jetpack_Tracks_Client' ) ? Jetpack_Tracks_Client::get_connected_user_tracks_identity() : null;
wp_localize_script(
'jetpack-admin-ui-upgrade-menu-tracking',
'jetpackAdminUiUpgradeMenu',
array(
'menuItemClass' => self::UPGRADE_MENU_SLUG,
'tracksUserData' => $tracks_user_data,
'tracksEventData' => array(
'is_admin' => $is_admin,
'current_screen' => $current_screen ? $current_screen->id : false,
'blog_id' => $site_id,
),
)
);
}
}
@@ -0,0 +1,13 @@
/**
* Token-only WPDS design-tokens stylesheet.
*
* Re-exports the `:root{--wpds-*}` custom-property definitions from
* @wordpress/theme so they can be enqueued as a standalone WP style handle on
* every Jetpack admin page. The import is resolved and inlined by css-loader at
* build time (a local entry file is used rather than pointing the webpack entry
* straight at the package, which would get externalized to a non-existent
* `wp-theme/design-tokens.css` script handle).
*
* Keep this token-only: do not add component or class selectors here.
*/
@import "@wordpress/theme/design-tokens.css";
@@ -0,0 +1,70 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.3] - 2026-06-26
### Changed
- Internal updates.
## [0.5.2] - 2026-06-23
### Changed
- Update package dependencies. [#49831]
## [0.5.1] - 2026-06-22
### Changed
- Update package dependencies. [#49691] [#49757]
### Fixed
- Dequeue Help Center only in the block editor when the full unified experience is active, so Help Center stays available in block-editor-only mode. [#49750]
## [0.5.0] - 2026-06-15
### Changed
- Update package dependencies. [#49631]
### Fixed
- Agents Manager: Bootstrap hooks exactly once even if multiple versions of the class are shipped. [#49636]
- Agents Manager: drive the sidebar pre-render from the persisted open state (cached in a transient) instead of a path-scoped cookie, and only pre-render where the app is actually loaded, so closing the assistant on another domain no longer leaves a stale sidebar shell behind. [#49439]
## [0.4.0] - 2026-06-15
### Added
- Add a standalone AI chat button to the admin bar. [#49455]
- Persist the Agents Manager minimized and last-activity state via the open-state endpoint. [#49565]
## [0.3.2] - 2026-06-10
### Changed
- Update package dependencies. [#49273] [#49492]
## [0.3.1] - 2026-06-08
### Changed
- Internal updates.
## [0.3.0] - 2026-06-05
### Added
- Agents Manager: Add the jetpack-ai-jwt REST endpoint, moved from the My Jetpack package. [#49415]
## [0.2.1] - 2026-06-03
### Fixed
- Agents Manager: Include build folder when pushing changes to mirror repo. [#49383]
## [0.2.0] - 2026-06-03
### Added
- Agents Manager: Ensure sidebar preserves open state on load. [#49325]
## 0.1.0 - 2026-06-02
### Added
- Agents Manager: Allow overriding variant and sectionName through filters [#49283]
- Initial version, extracted from Jetpack MU WPCOM to its own package for external consumption. [#49202]
[0.5.3]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.3.2...v0.4.0
[0.3.2]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.2.1...v0.3.0
[0.2.1]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.1.0...v0.2.0
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => '20c99af888eb66732839');
@@ -0,0 +1 @@
(()=>{const e=["post-php","post-new-php","site-editor-php"],n=["agents-manager-sidebar-container","agents-manager-sidebar-container--sidebar-open"];function t(){const{classList:n}=document.body;return!e.some(e=>n.contains(e))||n.contains("is-fullscreen-mode")}function o(){document.body.classList.remove(...n)}if(function(){if(!document.body)return;const e=document.getElementById("adminmenu");if(!e)return;const n=document.getElementById("wpadminbar"),s=n?n.offsetHeight:32,i=window.innerHeight<e.offsetHeight+s+20,c=window.innerWidth<1200;(i||c||!t())&&o()}(),document.body){const e=new MutationObserver(()=>{n.some(e=>document.body.classList.contains(e))&&!document.querySelector(".agents-manager-chat")?t()||o():e.disconnect()});e.observe(document.body,{attributes:!0,attributeFilter:["class"]})}})();
@@ -0,0 +1,951 @@
<?php
/**
* Agents manager
*
* @package automattic/jetpack-agents-manager
*/
namespace Automattic\Jetpack\Agents_Manager;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Constants;
/**
* Class Agents_Manager
*/
class Agents_Manager {
/**
* The package version of the Agents Manager package.
*
* @var string
*/
const PACKAGE_VERSION = '0.5.3';
/**
* Help Center URL for disconnected variants.
*
* @var string
*/
private const HELP_CENTER_URL = 'https://wordpress.com/help?help-center=home';
/**
* Class instance.
*
* @var Agents_Manager
*/
private static $instance = null;
/**
* Agents_Manager constructor.
*/
private function __construct() {
add_action( 'rest_api_init', array( $this, 'register_rest_api' ) );
add_filter( 'calypso_preferences_update', array( $this, 'calypso_preferences_update' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
add_action( 'next_admin_init', array( $this, 'enqueue_scripts' ), 1001 );
add_filter( 'agents_manager_use_unified_experience', array( $this, 'should_use_unified_experience' ) );
Sidebar_Open_Preservation::init();
}
/**
* Check if the agents manager menu panel should be displayed.
*
* @return bool True if the menu panel should be displayed.
*/
public function should_display_menu_panel() {
return self::is_unified_experience();
}
/**
* Get the SVG icon markup for a given icon name.
*
* @param string $icon_name The name of the icon to retrieve.
* @return string The SVG markup.
*/
private function get_icon( $icon_name ) {
$icons = array(
'comment' => '<svg class="help-center-menu-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18 4H6c-1.1 0-2 .9-2 2v12.9c0 .6.5 1.1 1.1 1.1.3 0 .5-.1.8-.3L8.5 17H18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm.5 11c0 .3-.2.5-.5.5H7.9l-2.4 2.4V6c0-.3.2-.5.5-.5h12c.3 0 .5.2.5.5v9z" /></svg>',
'backup' => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.5 12h1.75l-2.5 3-2.5-3H4a8 8 0 113.134 6.35l.907-1.194A6.5 6.5 0 105.5 12zm9.53 1.97l-2.28-2.28V8.5a.75.75 0 00-1.5 0V12a.747.747 0 00.218.529l1.282-.84-1.28.842 2.5 2.5a.75.75 0 101.06-1.061z" /></svg>',
'page' => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z" /><path d="M17 4H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2ZM7 5.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5Z" /></svg>',
'video' => '<svg class="help-center-menu-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.7 3H5.3C4 3 3 4 3 5.3v13.4C3 20 4 21 5.3 21h13.4c1.3 0 2.3-1 2.3-2.3V5.3C21 4 20 3 18.7 3zm.8 15.7c0 .4-.4.8-.8.8H5.3c-.4 0-.8-.4-.8-.8V5.3c0-.4.4-.8.8-.8h13.4c.4 0 .8.4.8.8v13.4zM10 15l5-3-5-3v6z" /></svg>',
'rss' => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 10.2h-.8v1.5H5c1.9 0 3.8.8 5.1 2.1 1.4 1.4 2.1 3.2 2.1 5.1v.8h1.5V19c0-2.3-.9-4.5-2.6-6.2-1.6-1.6-3.8-2.6-6.1-2.6zm10.4-1.6C12.6 5.8 8.9 4.2 5 4.2h-.8v1.5H5c3.5 0 6.9 1.4 9.4 3.9s3.9 5.8 3.9 9.4v.8h1.5V19c0-3.9-1.6-7.6-4.4-10.4zM4 20h3v-3H4v3z" /></svg>',
);
return $icons[ $icon_name ] ?? '';
}
/**
* Add the agents manager menu panel to the admin bar.
*
* @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
*/
public function add_menu_panel( $wp_admin_bar ) {
// Add chat support group
$wp_admin_bar->add_group(
array(
'parent' => 'agents-manager',
'id' => 'agents-manager-menu-panel-chat',
'meta' => array(
'class' => 'ab-sub-secondary',
),
)
);
// Add chat support menu item
$wp_admin_bar->add_node(
array(
'parent' => 'agents-manager-menu-panel-chat',
'id' => 'agents-manager-chat-support',
'title' => $this->get_icon( 'comment' ) . '<span>' . __( 'Chat support', 'jetpack-agents-manager' ) . '</span>',
)
);
// Add chat history menu item
$wp_admin_bar->add_node(
array(
'parent' => 'agents-manager-menu-panel-chat',
'id' => 'agents-manager-chat-history',
'title' => $this->get_icon( 'backup' ) . '<span>' . __( 'Chat history', 'jetpack-agents-manager' ) . '</span>',
)
);
// Add links group
$wp_admin_bar->add_group(
array(
'parent' => 'agents-manager',
'id' => 'agents-manager-menu-panel-links',
'meta' => array(
'class' => 'ab-sub-secondary',
),
)
);
// Add support guides menu item
$wp_admin_bar->add_node(
array(
'parent' => 'agents-manager-menu-panel-links',
'id' => 'agents-manager-support-guides',
'title' => $this->get_icon( 'page' ) . '<span>' . __( 'Support guides', 'jetpack-agents-manager' ) . '</span>',
)
);
// Add courses menu item
$wp_admin_bar->add_node(
array(
'parent' => 'agents-manager-menu-panel-links',
'id' => 'agents-manager-courses',
'title' => $this->get_icon( 'video' ) . '<span>' . __( 'Courses', 'jetpack-agents-manager' ) . '</span>',
'href' => 'https://wordpress.com/support/courses/',
'meta' => array(
'target' => '_blank',
'rel' => 'noopener noreferrer',
),
)
);
// Add product updates menu item
$wp_admin_bar->add_node(
array(
'parent' => 'agents-manager-menu-panel-links',
'id' => 'agents-manager-product-updates',
'title' => $this->get_icon( 'rss' ) . '<span>' . __( 'Product updates', 'jetpack-agents-manager' ) . '</span>',
'href' => 'https://wordpress.com/blog/category/product-features/',
'meta' => array(
'target' => '_blank',
'rel' => 'noopener noreferrer',
),
)
);
}
/**
* Add the standalone AI chat button to the admin bar.
*
* @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
*/
public function add_ai_chat_button( $wp_admin_bar ) {
$wp_admin_bar->add_menu(
array(
'id' => 'agents-manager-ai-chat',
'parent' => 'top-secondary',
'title' => '<span title="' . esc_attr__( 'Ask AI', 'jetpack-agents-manager' ) . '"><svg class="ab-icon" role="img" aria-label="' . esc_attr__( 'Ask AI', 'jetpack-agents-manager' ) . '" width="24" height="24" viewBox="-45 -45 490 490" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M391.528 188.061L309.455 159.75C276.997 148.597 251.403 123.003 240.25 90.5451L211.939 8.47185C208.079 -2.82395 191.921 -2.82395 188.061 8.47185L159.75 90.5451C148.597 123.003 123.003 148.597 90.5451 159.75L8.47185 188.061C-2.82395 191.921 -2.82395 208.079 8.47185 211.939L90.5451 240.25C123.003 251.403 148.597 276.997 159.75 309.455L188.061 391.528C191.921 402.824 208.079 402.824 211.939 391.528L240.25 309.455C251.403 276.997 276.997 251.403 309.455 240.25L391.528 211.939C402.824 208.079 402.824 191.921 391.528 188.061ZM295.728 206.077L254.692 220.232C238.391 225.809 225.666 238.677 220.089 254.835L205.934 295.871C203.932 301.591 195.925 301.591 193.923 295.871L179.768 254.835C174.191 238.534 161.323 225.809 145.165 220.232L104.129 206.077C98.4093 204.075 98.4093 196.068 104.129 194.066L145.165 179.911C161.466 174.334 174.191 161.466 179.768 145.308L193.923 104.272C195.925 98.5523 203.932 98.5523 205.934 104.272L220.089 145.308C225.666 161.609 238.534 174.334 254.692 179.911L295.728 194.066C301.448 196.068 301.448 204.075 295.728 206.077Z" />
</svg></span>',
)
);
}
/**
* Enqueue Agents Manager scripts and add inline script data.
*/
public function enqueue_scripts() {
// Early return for P2 frontend - don't add admin bar or enqueue scripts.
$stylesheet = get_stylesheet();
$is_p2 = str_contains( $stylesheet, 'pub/p2' ) || function_exists( '\WPForTeams\is_wpforteams_site' ) && \WPForTeams\is_wpforteams_site( get_current_blog_id() );
if ( ! is_admin() && $is_p2 ) {
return;
}
// Determine which variant to load (null = don't load).
$variant = self::get_active_variant();
if ( null === $variant ) {
return;
}
$use_disconnected = str_contains( $variant, 'disconnected' );
$is_gutenberg = $this->is_block_editor();
// In Gutenberg, dequeue Help Center so we don't end up with two buttons — but only
// in the full unified experience, where Agents Manager takes over the Help Center.
// In block-editor-only mode (e.g. ?flags=unified-big-sky) Agents Manager replaces
// Big Sky's native UI and Help Center should remain available.
// Agents Manager fires at priority 101, after Help Center at 100, so HC is already enqueued.
if ( $is_gutenberg && self::is_unified_experience() ) {
wp_dequeue_script( 'help-center' );
wp_dequeue_style( 'help-center-style' );
}
// For non-Gutenberg, non-CIAB environments, add to admin bar.
// Gutenberg doesn't have an admin bar, so JS will handle UI insertion.
// CIAB hides the classic admin bar and uses its own Site Hub — the JS variant handles UI there.
$is_ciab = $this->is_ciab_environment();
if ( ! $is_gutenberg && ! $is_ciab ) {
add_action(
'admin_bar_menu',
function ( $wp_admin_bar ) use ( $use_disconnected ) {
// Remove the help-center menu item
$wp_admin_bar->remove_node( 'help-center' );
$menu_args = array(
'id' => 'agents-manager',
'title' => '<span title="' . __( 'Help Center', 'jetpack-agents-manager' ) . '"><svg id="agents-manager-icon" class="ab-icon" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-1 16v-2h2v2h-2zm2-3v-1.141A3.991 3.991 0 0016 10a4 4 0 00-8 0h2c0-1.103.897-2 2-2s2 .897 2 2-.897 2-2 2a1 1 0 00-1 1v2h2z" />
</svg></span>',
'parent' => 'top-secondary',
);
// For disconnected variants, link directly to help center instead of showing dropdown
if ( $use_disconnected ) {
$menu_args['href'] = self::HELP_CENTER_URL;
$menu_args['meta'] = array(
'target' => '_blank',
'rel' => 'noopener noreferrer',
);
} else {
// For full variants, show the dropdown menu panel
$menu_args['meta'] = array(
'html' => '<div id="agents-manager-masterbar" />',
'class' => 'menupop',
'target' => '_blank',
'rel' => 'noopener noreferrer',
);
}
// Add the main agents manager menu node
$wp_admin_bar->add_menu( $menu_args );
},
// Add the agents manager icon to the admin bar after the help center is added, so we can remove it.
100
);
// Initialize the agents manager menu panel (only for full variants, not disconnected)
if ( ! $use_disconnected ) {
add_action( 'admin_bar_menu', array( $this, 'add_menu_panel' ), 100 );
}
// Standalone AI chat button, shown only in the unified experience.
if ( ! $use_disconnected && self::is_unified_experience() ) {
add_action( 'admin_bar_menu', array( $this, 'add_ai_chat_button' ), 100 );
}
}
/**
* Filter to register agent provider modules for the Agents Manager.
*
* Plugins can hook into this filter to register script module IDs that export
* toolProvider and/or contextProvider. The Agents Manager JS will dynamically
* import these modules and merge their providers.
*
* @param array $providers Array of provider script module IDs.
*/
$agent_providers = apply_filters( 'agents_manager_agent_providers', array() );
$use_unified_experience = self::is_unified_experience();
/**
* Filter the default agent ID for the Agents Manager.
*
* Allows host applications (e.g., CIAB, WooCommerce AI) to specify a custom
* workflow agent instead of the default orchestrator. The value is passed to
* the frontend as `agentsManagerData.agentId` and consumed by `useAgentConfig()`.
*
* @param string|null $agent_id The agent ID to use, or null for default behavior.
*/
$agent_id = apply_filters( 'agents_manager_agent_id', null );
$this->enqueue_script( $variant );
$inline_data = array(
'agentProviders' => $agent_providers,
'useUnifiedExperience' => $use_unified_experience,
'isDevMode' => self::is_dev_mode(),
'sectionName' => apply_filters( 'agents_manager_section_name', $variant ),
'currentUser' => $this->get_current_user_data(),
'site' => $this->get_current_site(),
'helpCenterUrl' => self::HELP_CENTER_URL,
);
if ( $agent_id ) {
$inline_data['agentId'] = $agent_id;
}
/**
* Filter the data exposed to the Agents Manager frontend.
*
* @param array $inline_data Data encoded into `agentsManagerData`.
*/
$filtered = apply_filters( 'jetpack_ai_sidebar_agents_manager_data', $inline_data );
$inline_data = is_array( $filtered ) ? $filtered : $inline_data;
wp_add_inline_script(
'agents-manager',
'const agentsManagerData = ' . wp_json_encode(
$inline_data,
JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
) . ';',
'before'
);
}
/**
* The script variant active for this request, or null if none.
*
* Single source of truth for "is the Agents Manager app loaded on this
* request?". Used both to enqueue the app and to gate the server-side
* sidebar pre-render, so the pre-rendered shell can never appear on a page
* where the app won't mount to reconcile it.
*
* @return string|null The variant name, or null if scripts should not be loaded.
*/
public static function get_active_variant() {
/**
* Filter the script variant the Agents Manager loads for this request.
*
* @since 0.1.0
*
* @param string|null $variant The resolved variant, or null to not load.
*/
return apply_filters( 'agents_manager_variant', self::get_variant() );
}
/**
* Determine which script variant to load, or null if none should be loaded.
*
* Combines the gating logic (should we load at all?) with variant selection
* (which build to use?) into a single method so the two cannot get out of sync.
*
* @return string|null The variant name, or null if scripts should not be loaded.
*/
private static function get_variant() {
// CIAB: Load either the connected or disconnected variants if enabled.
if ( self::is_ciab_environment() && self::is_enabled() ) {
return self::is_jetpack_disconnected() ? 'ciab-disconnected' : 'ciab';
}
// Frontend: load disconnected variant for eligible logged-in editors.
if ( ! is_admin() ) {
if ( self::is_loading_on_frontend() && self::is_enabled() ) {
return 'wp-admin-disconnected';
}
return null;
}
// Apply wp-admin exclusions (WooCommerce, customizer, preview contexts).
if ( ! self::passes_admin_checks() ) {
return null;
}
if ( ! self::is_enabled() ) {
return null;
}
$disconnected = self::is_jetpack_disconnected();
if ( self::is_block_editor() ) {
return $disconnected ? 'gutenberg-disconnected' : 'gutenberg';
}
return $disconnected ? 'wp-admin-disconnected' : 'wp-admin';
}
/**
* Whether the unified experience — the Help Center takeover — is active.
*
* "Unified" here means Agents Manager takes over the Help Center, unifying Odie and
* Dolly (the orchestrator) into a single chat experience. This is distinct from
* block-editor-only enablement, which replaces Big Sky's native UI without taking
* over the Help Center.
*
* @return bool
*/
public static function is_unified_experience() {
/**
* Filter to determine if the user should see the unified chat experience.
*
* When true, Help Center will render UnifiedAIAgent instead of traditional UI.
* The filter is hooked by should_use_unified_experience() in this class.
*
* @param bool $use_unified_experience Whether to use unified experience. Default false.
*/
return (bool) apply_filters( 'agents_manager_use_unified_experience', false );
}
/**
* Returns true if the Agents Manager should be loaded in the current context.
*
* @return bool
*/
public static function is_enabled() {
// CIAB: Agents Manager is the default AI experience — enabled unless explicitly
// disabled via filter (e.g. for debugging or gradual rollout).
if ( self::is_ciab_environment() ) {
/**
* Filter whether Agents Manager is enabled in CIAB (Next Admin) environments.
*
* @param bool $enabled Whether Agents Manager should load. Default true.
*/
return apply_filters( 'agents_manager_enabled_in_ciab', true );
}
// Full unified experience: Agents Manager with support guides, Help Center takeover, etc.
if ( self::is_unified_experience() ) {
return true;
}
// Block editor only: Agents Manager replaces Big Sky's native UI. Hooked by Big Sky.
if ( self::is_block_editor() && apply_filters( 'agents_manager_enabled_in_block_editor', false ) ) {
return true;
}
return false;
}
/**
* Returns true if the current wp-admin context passes all exclusion checks.
*
* Excludes WooCommerce Admin home, customizer preview, Gutenberg asset requests,
* and preview query param contexts.
*
* @return bool
*/
private static function passes_admin_checks() {
// Don't load on WooCommerce Admin home page to avoid UI conflicts.
global $current_screen;
if ( $current_screen && $current_screen->id === 'woocommerce_page_wc-admin' ) {
return false;
}
// Don't load in customizer preview iframe.
if ( is_customize_preview() ) {
return false;
}
// Don't load during Gutenberg asset requests or preview contexts.
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
$is_preview = isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
$is_preview_overlay = isset( $_GET['preview_overlay'] );
if ( str_contains( $request_uri, 'wp-content/plugins/gutenberg-core' ) || $is_preview || $is_preview_overlay ) {
return false;
}
return true;
}
/**
* Enqueue Agents Manager script based on context.
*
* @param string $variant The variant of the asset file to get.
*/
private function enqueue_script( $variant ) {
$cache_key = 'agents-manager-asset-' . $variant . '.asset.json';
$asset_file = get_transient( $cache_key );
if ( ! $asset_file ) {
$asset_file = self::get_assets_json( 'widgets.wp.com/agents-manager/agents-manager-' . $variant . '.asset.json' );
if ( ! $asset_file ) {
return;
}
set_transient( $cache_key, $asset_file, HOUR_IN_SECONDS );
}
// When the request is dev mode, use a random cache buster as the version for easier debugging.
$version = self::is_dev_mode() ? wp_rand() : $asset_file['version'];
$script_dependencies = $asset_file['dependencies'] ?? array();
wp_enqueue_script(
'agents-manager',
'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . '.min.js',
$script_dependencies,
$version,
/**
* Filter the strategy to use when enqueuing the script.
*
* @param array|bool $args The arguments to pass to wp_enqueue_script. Default is true.
* @param string $handle The handle of the script.
*/
apply_filters( 'agents_manager_enqueue_script_strategy', true, 'agents-manager' )
);
if ( 'gutenberg-disconnected' !== $variant && 'ciab-disconnected' !== $variant ) {
wp_enqueue_style(
'agents-manager-style',
'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . ( is_rtl() ? '.rtl.css' : '.css' ),
array(),
$version
);
}
}
/**
* Get the asset via file-system on wpcom and via network on Atomic sites.
*
* @param string $filepath The URL to download the asset file from.
* @return array|null The asset file data or null on failure.
*/
private static function get_assets_json( $filepath ) {
$accessible_directly = file_exists( ABSPATH . $filepath );
if ( $accessible_directly ) {
$file_contents = file_get_contents( ABSPATH . $filepath );
if ( false === $file_contents ) {
return null;
}
return json_decode( $file_contents, true );
}
$request = wp_remote_get( 'https://' . $filepath );
if ( is_wp_error( $request ) ) {
return null;
}
$response_code = wp_remote_retrieve_response_code( $request );
if ( 200 !== $response_code ) {
return null;
}
$content_type = wp_remote_retrieve_header( $request, 'content-type' );
if ( is_string( $content_type ) && false === strpos( $content_type, 'json' ) ) {
return null;
}
$body = wp_remote_retrieve_body( $request );
if ( '' === $body ) {
return null;
}
$decoded = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return null;
}
return $decoded;
}
/**
* Update the calypso preferences.
*
* @param \stdClass $preferences The preferences.
*
* @return \stdClass The preferences.
*/
public function calypso_preferences_update( $preferences ) {
// Check if agents_manager_router_history exists and is a valid array structure
if ( ! isset( $preferences->agents_manager_router_history ) ||
! is_array( $preferences->agents_manager_router_history ) ) {
return $preferences;
}
$router_history = $preferences->agents_manager_router_history;
// Check if entries exist and is an array
if ( ! isset( $router_history['entries'] ) ||
! is_array( $router_history['entries'] ) ) {
return $preferences;
}
$entries = $router_history['entries'];
// Limit entries to 50 to prevent spamming entries in the router history.
if ( count( $entries ) > 50 ) {
// Keep only the last 49 entries and add the root entry at the beginning.
$entries = array_slice( $entries, -49 );
// Keep the start at root so the back button always works.
array_unshift(
$entries,
array(
'pathname' => '/',
'search' => '',
'hash' => '',
'key' => 'default',
'state' => null,
)
);
// Update the preferences object directly
$preferences->agents_manager_router_history['entries'] = $entries;
$preferences->agents_manager_router_history['index'] = 49;
}
return $preferences;
}
/**
* Creates instance.
*
* @return Agents_Manager
*/
public static function init() {
if ( did_action( 'jetpack_agents_manager_initialized' ) ) {
return self::get_instance();
}
self::$instance = new self();
/**
* Fires once the Agents Manager class has been instantiated.
*
* @since 0.5.0
*/
do_action( 'jetpack_agents_manager_initialized' );
return self::$instance;
}
/**
* Returns the instance of the Agents Manager class.
*
* @return Agents_Manager
*/
public static function get_instance() {
return self::$instance;
}
/**
* Returns whether the current request is coming from the A8C proxy.
*
* @return bool
*/
private static function is_proxied() {
// On Simple sites, use the wpcom function if available.
if ( function_exists( 'wpcom_is_proxied_request' ) ) {
return wpcom_is_proxied_request();
}
// On WoA/Garden sites, check server variable or constant.
return isset( $_SERVER['A8C_PROXIED_REQUEST'] )
? (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) )
: Constants::is_true( 'A8C_PROXIED_REQUEST' );
}
/**
* Enables "Development" features that should be accessible only for admins.
*/
private static function is_dev_mode() {
// Known local environments.
$domain = wp_parse_url( get_site_url(), PHP_URL_HOST );
if (
$domain === 'localhost' ||
'.jurassic.tube' === stristr( $domain, '.jurassic.tube' ) ||
'.jurassic.ninja' === stristr( $domain, '.jurassic.ninja' )
) {
return true;
}
// A8C development.
if ( self::is_proxied() ) {
return true;
}
if ( Constants::is_true( 'AT_PROXIED_REQUEST' ) && Constants::is_defined( 'ATOMIC_CLIENT_ID' ) ) {
switch ( Constants::get_constant( 'ATOMIC_CLIENT_ID' ) ) {
case 1:
case 2:
case 3: // Pressable
case 32:
case 118: // Commerce garden client (ciab)
return true;
}
}
return false;
}
/**
* Register the Agents Manager endpoints.
*/
public function register_rest_api() {
( new WP_REST_Agents_Manager_Persisted_Open_State() )->register_rest_route();
( new WP_REST_Jetpack_AI_JWT() )->register_rest_route();
}
/**
* Determine if user should see unified experience.
*
* @param bool $use_unified_experience Whether to use unified experience.
* @return bool
*/
public function should_use_unified_experience( $use_unified_experience = false ) {
// Early return for non-proxied/dev mode requests.
// This feature is currently only available to Automattic employees testing via proxy.
if ( ! self::is_dev_mode() ) {
return false;
}
$user_id = get_current_user_id();
if ( ! $user_id ) {
return false;
}
$is_simple_site = ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_simple();
if ( $is_simple_site ) {
// On Simple sites, evaluate locally.
// Check Automattician and opt-in setting.
$is_automattician = function_exists( '\is_automattician' ) && \is_automattician( $user_id );
if ( $is_automattician && $this->has_unified_chat_opt_in_enabled( $user_id ) ) {
return true;
}
}
// On WoA and Garden sites, delegate to wpcom via the /agents-manager/state endpoint.
// This avoids duplicating rollout logic and handles cases where
// wpcom-specific functions (like get_user_attribute) aren't available.
if ( $this->fetch_unified_experience_preference() ) {
return true;
}
// Default to false, for now.
// In the future: users with a big sky site (similar to https://github.a8c.com/Automattic/wpcom/pull/196449/files), a big-sky free trial or a paid plan.
return $use_unified_experience;
}
/**
* Check if user has enabled unified chat opt-in in their Automattician options.
*
* This checks the unified_ai_chat calypso preference set via the wpcom profile settings.
* Only used on Simple sites where get_user_attribute is available.
*
* @param int $user_id User ID.
*
* @return bool
*/
private function has_unified_chat_opt_in_enabled( $user_id ) {
if ( ! function_exists( '\get_user_attribute' ) ) {
return false;
}
$calypso_prefs = \get_user_attribute( $user_id, 'calypso_preferences' );
return ! empty( $calypso_prefs['unified_ai_chat'] );
}
/**
* Fetch unified experience preference from wpcom via Jetpack Connection.
*
* Used on Atomic sites to delegate the decision to wpcom, which has
* access to user attributes and can evaluate the rollout logic.
*
* Calls /agents-manager/state endpoint which is accessible via Jetpack user tokens.
*
* @return bool Whether user should see unified experience.
*/
private function fetch_unified_experience_preference() {
$user_id = get_current_user_id();
if ( ! $user_id ) {
return false;
}
// Check transient cache first (per-user cache).
$cache_key = 'unified-experience-' . $user_id;
$cached_result = get_transient( $cache_key );
if ( false !== $cached_result ) {
return (bool) $cached_result;
}
// Check if user is connected before making API call.
if ( ! ( new Connection_Manager() )->is_user_connected( $user_id ) ) {
return false;
}
// Call dedicated agents-manager/state endpoint.
$wpcom_request = \Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_user(
'/agents-manager/state?key=unified_ai_chat',
'2',
array( 'method' => 'GET' )
);
if ( is_wp_error( $wpcom_request ) ) {
// Cache failures too to avoid hammering the API.
set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
return false;
}
$response_code = wp_remote_retrieve_response_code( $wpcom_request );
if ( 200 !== $response_code ) {
set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
return false;
}
$body = wp_remote_retrieve_body( $wpcom_request );
$decoded_body = json_decode( $body, true );
// The response is { "unified_ai_chat": true/false } when using key param.
$result = is_array( $decoded_body ) && ! empty( $decoded_body['unified_ai_chat'] );
// Cache for 1 minute.
set_transient( $cache_key, $result ? 1 : 0, MINUTE_IN_SECONDS );
return $result;
}
/**
* Returns true if the current request is on the frontend and the user can edit posts.
*
* Mirrors Help_Center::is_loading_on_frontend().
*
* @return bool True if loading on the frontend for an eligible user.
*/
private static function is_loading_on_frontend() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
if ( isset( $_GET['na_site_preview'] ) || isset( $_GET['preview_overlay'] ) ) {
return false;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
if ( isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) ) ) {
return false;
}
$can_edit_posts = current_user_can( 'edit_posts' ) && is_user_member_of_blog();
return ! is_admin() && ! self::is_block_editor() && $can_edit_posts;
}
/**
* Returns true if the current screen is the block editor.
*
* @return bool True if the current screen is the block editor.
*/
private static function is_block_editor() {
if ( ! function_exists( 'get_current_screen' ) ) {
return false;
}
$current_screen = get_current_screen();
// The widgets screen has the block editor but no Gutenberg top bar.
return $current_screen && $current_screen->is_block_editor() && $current_screen->id !== 'widgets';
}
/**
* Check if current environment is CIAB (Commerce in a Box) / Next Admin.
*
* Uses the same detection method as Help Center: checks if next_admin_init has fired.
*
* @return bool True if CIAB/Next Admin environment.
*/
private static function is_ciab_environment() {
return (bool) did_action( 'next_admin_init' );
}
/**
* Returns true if the current user is NOT connected through Jetpack.
*
* Mirrors the logic from Help_Center::is_jetpack_disconnected().
*
* @return bool True if the site uses Jetpack but the current user is not connected.
*/
private static function is_jetpack_disconnected() {
$user_id = get_current_user_id();
$blog_id = get_current_blog_id();
if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
}
if ( true === apply_filters( 'is_jetpack_site', false, $blog_id ) ) {
return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
}
return false;
}
/**
* Get current user data for the agents manager.
*
* Mirrors the user data structure from Help Center's helpCenterData.
*
* @return array|null User data array or null if not logged in.
*/
private function get_current_user_data() {
$user_id = get_current_user_id();
if ( ! $user_id ) {
return null;
}
$user_data = get_userdata( $user_id );
if ( ! $user_data ) {
return null;
}
$user_email = $user_data->user_email;
// Use wpcom_get_avatar_url on Simple sites, fall back to get_avatar_url elsewhere.
if ( function_exists( 'wpcom_get_avatar_url' ) ) {
$avatar_url = wpcom_get_avatar_url( $user_email, 64, '', true )[0];
} else {
$avatar_url = get_avatar_url( $user_id );
}
return array(
'ID' => $user_id,
'username' => $user_data->user_login,
'display_name' => $user_data->display_name,
'avatar_URL' => $avatar_url,
'email' => $user_email,
);
}
/**
* Get current site data for the agents manager.
*
* Returns minimal site data needed by AgentsManager (ID and domain only).
* Uses jetpack_options['id'] on Atomic sites for the wpcom blog ID.
*
* @return array Site data with ID and domain.
*/
private function get_current_site() {
/*
* Atomic sites have the WP.com blog ID stored as a Jetpack option.
* This code deliberately doesn't use `Jetpack_Options::get_option`
* so it works even when Jetpack has not been loaded.
*/
$jetpack_options = get_option( 'jetpack_options' );
if ( is_array( $jetpack_options ) && isset( $jetpack_options['id'] ) ) {
$site_id = (int) $jetpack_options['id'];
} else {
$site_id = get_current_blog_id();
}
return array(
'ID' => $site_id,
'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
);
}
}
@@ -0,0 +1,204 @@
<?php
/**
* Open_State_Store file.
*
* @package automattic/jetpack-agents-manager
*/
namespace Automattic\Jetpack\Agents_Manager;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Status\Host;
/**
* Reads and writes the Agents Manager open state.
*
* The state is a global, per-user wpcom preference behind the
* `/agents-manager/state` endpoint. How the server reads it depends on the site:
*
* - wpcom Simple: the preference is local, so read `calypso_preferences` directly.
* - WoA / self-hosted: the preference is remote, so reads/writes go through this
* store's local REST route, which calls wpcom over the Jetpack Connection and
* caches the result in a per-user transient. Latency-sensitive readers (the
* server-side pre-render) use that transient to skip the round-trip.
*/
class Open_State_Store {
/**
* Transient key prefix for the cached per-user open state.
*
* @var string
*/
private const TRANSIENT_PREFIX = 'agents_manager_open_state_';
/**
* Default state values.
*
* @var array
*/
public const DEFAULTS = array(
'agents_manager_open' => false,
'agents_manager_docked' => false,
'agents_manager_minimized' => false,
'agents_manager_floating_position' => 'right',
'agents_manager_router_history' => null,
'agents_manager_last_activity' => null,
);
/**
* Fetch the open state from wpcom and refresh the cache.
*
* @return array|\WP_Error Normalized state, or WP_Error when the request fails.
*/
public static function fetch() {
$body = Client::wpcom_json_api_request_as_user(
'/agents-manager/state',
'2',
array( 'method' => 'GET' )
);
if ( is_wp_error( $body ) ) {
return $body;
}
$response = json_decode( wp_remote_retrieve_body( $body ), true );
$state = self::normalize( is_array( $response ) ? $response : array() );
self::cache( $state );
return $state;
}
/**
* Persist the open state to wpcom and refresh the cache.
*
* @param array $state Partial state to update (subset of DEFAULTS keys).
* @return array|\WP_Error Normalized state, or WP_Error when the request fails.
*/
public static function update( array $state ) {
$body = Client::wpcom_json_api_request_as_user(
'/agents-manager/state',
'2',
array( 'method' => 'POST' ),
array( 'state' => $state )
);
if ( is_wp_error( $body ) ) {
return $body;
}
$response = json_decode( wp_remote_retrieve_body( $body ), true );
if ( ! is_array( $response ) ) {
return new \WP_Error(
'invalid_response',
'Invalid response from WPCOM endpoint',
array( 'status' => 500 )
);
}
$normalized = self::normalize( $response );
self::cache( $normalized );
return $normalized;
}
/**
* Read the current user's open state from the fastest local source.
*
* For latency-sensitive callers like the server-side pre-render: Simple sites
* read `calypso_preferences` directly, everywhere else uses the cached
* transient (see the class docblock). Returns null when nothing is known yet,
* so callers can skip pre-rendering until the frontend sets the real state.
*
* @return array|null `{ agents_manager_open, agents_manager_docked }` or null.
*/
public static function get_cached() {
$user_id = get_current_user_id();
if ( ! $user_id ) {
return null;
}
// Simple sites have the preference locally, so read it directly (the
// transient is never primed there).
if ( ( new Host() )->is_wpcom_simple() && function_exists( '\get_user_attribute' ) ) {
$calypso_prefs = \get_user_attribute( $user_id, 'calypso_preferences' );
if ( ! is_array( $calypso_prefs ) ) {
return null;
}
return array(
'agents_manager_open' => (bool) ( $calypso_prefs['agents_manager_open'] ?? false ),
'agents_manager_docked' => (bool) ( $calypso_prefs['agents_manager_docked'] ?? false ),
);
}
$cached = get_transient( self::cache_key( $user_id ) );
return is_array( $cached ) ? $cached : null;
}
/**
* Normalize a raw endpoint response into the full state shape.
*
* @param array $response Raw decoded response.
* @return array Normalized state with all DEFAULTS keys present.
*/
private static function normalize( array $response ): array {
return array(
'agents_manager_open' => (bool) ( $response['agents_manager_open'] ?? self::DEFAULTS['agents_manager_open'] ),
'agents_manager_docked' => (bool) ( $response['agents_manager_docked'] ?? self::DEFAULTS['agents_manager_docked'] ),
'agents_manager_minimized' => (bool) ( $response['agents_manager_minimized'] ?? self::DEFAULTS['agents_manager_minimized'] ),
'agents_manager_floating_position' => $response['agents_manager_floating_position'] ?? self::DEFAULTS['agents_manager_floating_position'],
'agents_manager_router_history' => $response['agents_manager_router_history'] ?? self::DEFAULTS['agents_manager_router_history'],
'agents_manager_last_activity' => $response['agents_manager_last_activity'] ?? self::DEFAULTS['agents_manager_last_activity'],
);
}
/**
* Cache the open/docked bits in a per-user transient.
*
* Only used on the remote (WoA / self-hosted) path — it's what get_cached()
* reads there. Simple sites read `calypso_preferences` directly and skip this.
*
* @param array $state Normalized state.
*/
private static function cache( array $state ): void {
$user_id = get_current_user_id();
if ( ! $user_id ) {
return;
}
/**
* Filter how long the cached open state lives.
*
* It's refreshed on every read/write through this store, so the TTL mainly
* caps how long a value changed elsewhere (e.g. in Calypso) stays stale.
*
* @since 0.4.0
*
* @param int $ttl Cache lifetime in seconds.
*/
$ttl = (int) apply_filters( 'agents_manager_open_state_cache_ttl', WEEK_IN_SECONDS );
set_transient(
self::cache_key( $user_id ),
array(
'agents_manager_open' => (bool) ( $state['agents_manager_open'] ?? false ),
'agents_manager_docked' => (bool) ( $state['agents_manager_docked'] ?? false ),
),
$ttl
);
}
/**
* Build the per-user transient key.
*
* @param int $user_id User ID.
* @return string
*/
private static function cache_key( int $user_id ): string {
return self::TRANSIENT_PREFIX . $user_id;
}
}
@@ -0,0 +1,174 @@
<?php
/**
* Sidebar Open Preservation file.
*
* @package automattic/jetpack-agents-manager
*/
namespace Automattic\Jetpack\Agents_Manager;
/**
* Preserves the Agents Manager sidebar-open body classes across full wp-admin
* navigations so the next page load can pre-apply them server-side (avoiding a
* flicker before the React app boots).
*
* The open state comes from Open_State_Store::get_cached(), and the pre-render only
* runs when the Agents Manager app is actually loading on this request — so the
* pre-rendered shell is always reconciled by the app that mounts to manage it,
* never left orphaned.
*
* The server can know the persisted "open && docked" preference, but not the
* live viewport — and the docked layout only fits above a width breakpoint and
* when the admin menu fits vertically. Width is handled in CSS (a static media
* query). Height cannot be: the threshold is the *measured* #adminmenu height,
* which varies per page. So a tiny synchronous viewport-height gate is printed on
* `in_admin_header` (which fires after #adminmenu is in the DOM, before the
* content paints) to re-evaluate the real dock gate and strip the pre-rendered
* classes when the chat will actually float — pre-paint, so there is no flash.
*/
class Sidebar_Open_Preservation {
/**
* Class instance.
*
* @var Sidebar_Open_Preservation
*/
private static $instance;
/**
* Body class marking the docked sidebar shell.
*
* @var string
*/
private const SIDEBAR_CONTAINER_CLASS = 'agents-manager-sidebar-container';
/**
* Body class marking the sidebar as open.
*
* @var string
*/
private const SIDEBAR_OPEN_CLASS = 'agents-manager-sidebar-container--sidebar-open';
/**
* Creates instance.
*
* @return void
*/
public static function init() {
if ( self::$instance === null ) {
self::$instance = new self();
}
}
/**
* Sidebar_Open_Preservation constructor.
*/
public function __construct() {
// Run last so our class sits at the end of the `admin_body_class` list. Otherwise
// a later filter could append its class without a leading space and glue it onto
// ours, breaking the CSS selector that pre-opens the sidebar.
add_filter( 'admin_body_class', array( $this, 'add_preopen_body_classes' ), PHP_INT_MAX );
// Reconcile the pre-rendered shell against the live viewport before the
// page content paints. `in_admin_header` fires after #adminmenu is in the
// DOM, so the script can measure it the same way the React app does.
add_action( 'in_admin_header', array( $this, 'print_sidebar_docking_gate_script' ) );
}
/**
* Inject pre-open assistant classes in initial admin body markup.
*
* @param string $classes Existing admin body classes.
* @return string
*/
public function add_preopen_body_classes( string $classes ): string {
if ( ! $this->should_pre_render_docked_shell() ) {
return $classes;
}
$body_classes_with_sidebar_classes = implode(
' ',
array_filter(
array(
$classes,
self::SIDEBAR_CONTAINER_CLASS,
self::SIDEBAR_OPEN_CLASS,
)
)
);
return ' ' . $body_classes_with_sidebar_classes . ' ';
}
/**
* Print the synchronous sidebar-docking reconciliation script.
*
* Only emitted when the docked shell was pre-rendered. Because we optimistically
* inject the docked sidebar body classes, this script reconciles the gates that
* the React hook applies and removes those classes so the chat floats instead.
*
* The script lives in src/js/sidebar-docking-gate.js and is inlined (not
* referenced via `src`) on purpose: it must run render-blocking before paint,
* and a same- or cross-origin fetch would add latency to that blocking window.
* Reading the bundled file and printing it inline keeps it a real, lintable JS
* file with zero request cost.
*
* @return void
*/
public function print_sidebar_docking_gate_script() {
if ( ! $this->should_pre_render_docked_shell() ) {
return;
}
global $wp_filesystem;
if ( empty( $wp_filesystem ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}
$script_path = __DIR__ . '/../build/sidebar-docking-gate.js';
if ( empty( $wp_filesystem ) || ! $wp_filesystem->exists( $script_path ) ) {
return;
}
$script = $wp_filesystem->get_contents( $script_path );
if ( ! is_string( $script ) || '' === $script ) {
return;
}
wp_print_inline_script_tag( $script );
}
/**
* Whether the docked-open shell should be pre-rendered on this request.
*
* True only when the app is loading (so the shell will be reconciled by the
* app that mounts to manage it) and the cached state is both open and docked
* — the only state that reshapes the admin layout. A cold session (no cache),
* a closed sidebar, or a floating (undocked) chat all pre-render nothing.
*
* @return bool
*/
private function should_pre_render_docked_shell(): bool {
if ( ! $this->should_preserve_sidebar_open_state() ) {
return false;
}
$state = Open_State_Store::get_cached();
return $state && true === $state['agents_manager_open'] && true === $state['agents_manager_docked'];
}
/**
* Whether sidebar open preservation should run for this request.
*
* Gated on the same decision that loads the app (its active variant), so the
* pre-rendered shell only appears where the app will mount to reconcile it.
*
* @return bool
*/
private function should_preserve_sidebar_open_state(): bool {
return null !== Agents_Manager::get_active_variant();
}
}
@@ -0,0 +1,88 @@
<?php
/**
* WP_REST_Agents_Manager_Persisted_Open_State file.
*
* @package automattic/jetpack-agents-manager
*/
namespace Automattic\Jetpack\Agents_Manager;
/**
* Class WP_REST_Agents_Manager_Persisted_Open_State.
*/
class WP_REST_Agents_Manager_Persisted_Open_State extends \WP_REST_Controller {
/**
* WP_REST_Agents_Manager_Persisted_Open_State constructor.
*/
public function __construct() {
$this->namespace = 'agents-manager';
$this->rest_base = '/open-state';
}
/**
* Register available routes.
*/
public function register_rest_route() {
register_rest_route(
$this->namespace,
$this->rest_base,
array(
// Get the open state.
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_state' ),
'permission_callback' => 'is_user_logged_in',
),
// Set the open state
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'set_state' ),
'permission_callback' => 'is_user_logged_in',
),
)
);
}
/**
* Get Agents Manager state from user preferences.
*
* Delegates to Open_State_Store, which fetches the dedicated
* wpcom/v2/agents-manager/state endpoint and refreshes the local cache.
*/
public function get_state() {
$state = Open_State_Store::fetch();
if ( is_wp_error( $state ) ) {
return $state;
}
return rest_ensure_response( $state );
}
/**
* Set Agents Manager state in user preferences.
*
* Delegates to Open_State_Store, which updates the dedicated
* wpcom/v2/agents-manager/state endpoint and refreshes the local cache.
*
* @param \WP_REST_Request $request The request sent to the API.
*/
public function set_state( \WP_REST_Request $request ) {
$state = array();
foreach ( array_keys( Open_State_Store::DEFAULTS ) as $key ) {
if ( $request->has_param( $key ) ) {
$state[ $key ] = $request[ $key ];
}
}
$result = Open_State_Store::update( $state );
if ( is_wp_error( $result ) ) {
return $result;
}
return rest_ensure_response( $result );
}
}
@@ -0,0 +1,114 @@
<?php
/**
* WP_REST_Jetpack_AI_JWT file.
*
* @package automattic/jetpack-agents-manager
*/
namespace Automattic\Jetpack\Agents_Manager;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Jetpack_Options;
/**
* Class WP_REST_Jetpack_AI_JWT.
*/
class WP_REST_Jetpack_AI_JWT extends \WP_REST_Controller {
/**
* WP_REST_Jetpack_AI_JWT constructor.
*/
public function __construct() {
$this->namespace = 'jetpack/v4';
$this->rest_base = '/jetpack-ai-jwt';
}
/**
* Register available routes.
*/
public function register_rest_route() {
/*
* Check if the `jetpack/v4/jetpack-ai-jwt` endpoint is registered
* by the Jetpack plugin to avoid registering it again.
* In case it's not registered, register it
* to make it available for Jetpack products that depend on it.
*/
if ( $this->is_rest_endpoint_registered() ) {
return;
}
register_rest_route(
$this->namespace,
$this->rest_base,
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'get_jwt' ),
'permission_callback' => array( $this, 'permission_callback' ),
),
)
);
}
/**
* Check if this REST endpoint is already registered.
*
* @return bool True if the endpoint is registered, false otherwise.
*/
private function is_rest_endpoint_registered() {
$server = rest_get_server();
$routes = $server->get_routes();
$full_endpoint = '/' . trim( $this->namespace, '/' ) . $this->rest_base;
return isset( $routes[ $full_endpoint ] );
}
/**
* Permission callback for the JWT endpoint.
*
* @return bool
*/
public function permission_callback() {
return ( new Connection_Manager( 'jetpack' ) )->is_user_connected() && current_user_can( 'edit_posts' );
}
/**
* Ask WPCOM for a JWT token to use for OpenAI completion.
*/
public function get_jwt() {
$blog_id = Jetpack_Options::get_option( 'id' );
$response = Client::wpcom_json_api_request_as_user(
"/sites/$blog_id/jetpack-openai-query/jwt",
'2',
array(
'method' => 'POST',
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
),
wp_json_encode( array(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
$json = json_decode( wp_remote_retrieve_body( $response ) );
if ( ! isset( $json->token ) ) {
return new \WP_Error(
'no-token',
'No token returned from WPCOM',
array( 'status' => 500 )
);
}
return rest_ensure_response(
array(
'token' => $json->token,
'blog_id' => $blog_id,
)
);
}
}
@@ -0,0 +1,101 @@
/**
* Agents Manager pre-paint sidebar-docking gate.
*
* The assistant floats instead of being docked in certain conditions. Because we
* optimistically inject the sidebar classes, we remove them when those conditions
* are met.
*
* IMPORTANT: Keep this logic in sync with
* `calypso/packages/agents-manager/src/hooks/use-agent-layout-manager/index.tsx`.
*/
const FULLSCREEN_GATED_BODY_CLASSES = [ 'post-php', 'post-new-php', 'site-editor-php' ];
const FULLSCREEN_BODY_CLASS = 'is-fullscreen-mode';
const DOCKED_SIDEBAR_BODY_CLASSES = [
'agents-manager-sidebar-container',
'agents-manager-sidebar-container--sidebar-open',
];
// The Calypso hook adds this element when it mounts; its presence is our hand-off signal.
const CHAT_PORTAL_CLASS = 'agents-manager-chat';
// Minimum viewport width for docking, mirroring the hook's `desktopMediaQuery`
// default of `(min-width: 1200px)`.
const DESKTOP_MIN_WIDTH = 1200;
/**
* Whether the fullscreen gate is open.
*
* @return {boolean} Whether the fullscreen gate is open.
*/
function isFullscreenGateOpen() {
const { classList } = document.body;
const isGated = FULLSCREEN_GATED_BODY_CLASSES.some( cls => classList.contains( cls ) );
return ! isGated || classList.contains( FULLSCREEN_BODY_CLASS );
}
/**
* Whether the docked shell is currently on the body.
*
* @return {boolean} Whether the docked shell is present.
*/
function isDockedShellPresent() {
return DOCKED_SIDEBAR_BODY_CLASSES.some( cls => document.body.classList.contains( cls ) );
}
/**
* Remove the docked shell so the assistant floats.
*
* @return {void}
*/
function removeDockedShell() {
document.body.classList.remove( ...DOCKED_SIDEBAR_BODY_CLASSES );
}
/**
* Run the sidebar docking gate evaluation.
*
* @return {void}
*/
function runSidebarDockingGate() {
if ( ! document.body ) {
return;
}
const adminMenu = document.getElementById( 'adminmenu' );
if ( ! adminMenu ) {
return;
}
// The docked layout pins the admin menu to the viewport; if the menu is taller
// than the room below the admin bar it would be clipped, so the chat floats instead.
const adminBar = document.getElementById( 'wpadminbar' );
const adminBarHeight = adminBar ? adminBar.offsetHeight : 32;
const tooShort = window.innerHeight < adminMenu.offsetHeight + adminBarHeight + 20;
const tooNarrow = window.innerWidth < DESKTOP_MIN_WIDTH;
if ( tooShort || tooNarrow || ! isFullscreenGateOpen() ) {
removeDockedShell();
}
}
runSidebarDockingGate();
// The gated screen classes (`post-php`, …) and `is-fullscreen-mode` can land on
// <body> after the first run, leaving a stale shell on a non-fullscreen editor. So
// re-check the fullscreen gate on class changes (cheap reads, no layout) and float
// the chat if it closed. Disconnect once the shell is gone (which also stops a
// re-trigger loop) or the app mounts and takes over docking.
if ( document.body ) {
const observer = new MutationObserver( () => {
if ( ! isDockedShellPresent() || document.querySelector( `.${ CHAT_PORTAL_CLASS }` ) ) {
observer.disconnect();
return;
}
if ( ! isFullscreenGateOpen() ) {
removeDockedShell();
}
} );
observer.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
}
@@ -0,0 +1,31 @@
<?php
/**
* Action Hooks for Jetpack Assets module.
*
* @package automattic/jetpack-assets
*/
// If WordPress's plugin API is available already, use it. If not,
// drop data into `$wp_filter` for `WP_Hook::build_preinitialized_hooks()`.
if ( function_exists( 'add_action' ) ) {
add_action( 'wp_default_scripts', array( Automattic\Jetpack\Assets::class, 'wp_default_scripts_hook' ) );
add_action( 'plugins_loaded', array( Automattic\Jetpack\Assets\Script_Data::class, 'configure' ), 1 );
add_action( 'plugins_loaded', array( Automattic\Jetpack\Assets\Shared_Stores_Assets::class, 'configure' ), 1 );
} else {
global $wp_filter;
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$wp_filter['wp_default_scripts'][10][] = array(
'accepted_args' => 1,
'function' => array( Automattic\Jetpack\Assets::class, 'wp_default_scripts_hook' ),
);
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$wp_filter['plugins_loaded'][1][] = array(
'accepted_args' => 0,
'function' => array( Automattic\Jetpack\Assets\Script_Data::class, 'configure' ),
);
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$wp_filter['plugins_loaded'][1][] = array(
'accepted_args' => 0,
'function' => array( Automattic\Jetpack\Assets\Shared_Stores_Assets::class, 'configure' ),
);
}
@@ -0,0 +1 @@
<?php return array('dependencies' => array('wp-i18n'), 'version' => '5ba5dddc04be2306aaf0');
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => '063e99e8f3d8166cfb17');
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.JetpackScriptDataModule=t():e.JetpackScriptDataModule=t()}(globalThis,()=>(()=>{"use strict";var e={729(e,t,r){r.d(t,{$8:()=>n.$8,IT:()=>n.IT,KS:()=>n.KS,L2:()=>n.L2,Sy:()=>n.Sy,au:()=>n.au,d_:()=>n.d_,e5:()=>n.e5,hT:()=>n.hT,lI:()=>n.lI,mH:()=>n.mH,oQ:()=>n.oQ,sV:()=>n.sV});var n=r(336)},336(e,t,r){function n(){return window.JetpackScriptData}function o(){return n()?.site}function i(e=""){return`${n()?.site.admin_url}${e}`}function a(e=""){return i(`admin.php?page=jetpack${e}`)}function u(e=""){return i(`admin.php?page=my-jetpack${e}`)}function p(){return n()?.site.plan?.features?.active??[]}function c(e){return-1!==p().indexOf(e)}function s(){return"wpcom"===n()?.site?.host}function f(){return"woa"===n()?.site?.host}function d(){return n()?.site?.is_wpcom_platform}function l(){return"unknown"===n()?.site?.host}function S(){return s()?"simple":f()?"woa":"jetpack"}function m(e){return n()?.user.current_user.capabilities[e]}r.d(t,{$8:()=>d,IT:()=>c,KS:()=>S,L2:()=>l,Sy:()=>s,au:()=>n,d_:()=>m,e5:()=>u,hT:()=>i,lI:()=>f,mH:()=>p,oQ:()=>a,sV:()=>o})}},t={};function r(n){var o=t[n];if(void 0!==o)return o.exports;var i=t[n]={exports:{}};return e[n](i,i.exports,r),i.exports}r.d=(e,t)=>{for(var n in t)r.o(t,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var n={};r.r(n),r.d(n,{currentUserCan:()=>o.d_,getActiveFeatures:()=>o.mH,getAdminUrl:()=>o.hT,getJetpackAdminPageUrl:()=>o.oQ,getMyJetpackUrl:()=>o.e5,getScriptData:()=>o.au,getSiteData:()=>o.sV,getSiteType:()=>o.KS,isJetpackSelfHostedSite:()=>o.L2,isSimpleSite:()=>o.Sy,isWoASite:()=>o.lI,isWpcomPlatformSite:()=>o.$8,siteHasFeature:()=>o.IT});var o=r(729);return n})());
@@ -0,0 +1 @@
<?php return array('dependencies' => array('jetpack-script-data', 'wp-api-fetch', 'wp-data', 'wp-polyfill'), 'version' => '09c0b5848292ccbc06d4');
File diff suppressed because one or more lines are too long
@@ -0,0 +1,763 @@
<?php
/**
* Jetpack Assets package.
*
* @package automattic/jetpack-assets
*/
namespace Automattic\Jetpack;
use Automattic\Jetpack\Assets\Semver;
use Automattic\Jetpack\Constants as Jetpack_Constants;
use InvalidArgumentException;
/**
* Class Assets
*/
class Assets {
/**
* Holds all the scripts handles that should be loaded in a deferred fashion.
*
* @var array
*/
private $defer_script_handles = array();
/**
* The singleton instance of this class.
*
* @var Assets
*/
protected static $instance;
/**
* The registered textdomain mappings.
*
* @var array `array( mapped_domain => array( string target_domain, string target_type, string semver, string path_prefix ) )`.
*/
private static $domain_map = array();
/**
* Constructor.
*
* Static-only class, so nothing here.
*/
private function __construct() {}
// ////////////////////
// region Async script loading
/**
* Get the singleton instance of the class.
*
* @return Assets
*/
public static function instance() {
if ( ! isset( self::$instance ) ) {
self::$instance = new Assets();
}
return self::$instance;
}
/**
* A public method for adding the async script.
*
* @deprecated Since 2.1.0, the `strategy` feature should be used instead, with the "defer" setting.
*
* @param string $script_handle Script handle.
*/
public static function add_async_script( $script_handle ) {
_deprecated_function( __METHOD__, '2.1.0' );
wp_script_add_data( $script_handle, 'strategy', 'defer' );
}
/**
* Add an async attribute to scripts that can be loaded deferred.
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script
*
* @deprecated Since 2.1.0, the `strategy` feature should be used instead.
*
* @param string $tag The <script> tag for the enqueued script.
* @param string $handle The script's registered handle.
*/
public function script_add_async( $tag, $handle ) {
_deprecated_function( __METHOD__, '2.1.0' );
if ( empty( $this->defer_script_handles ) ) {
return $tag;
}
if ( in_array( $handle, $this->defer_script_handles, true ) ) {
// phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript
return preg_replace( '/<script( [^>]*)? src=/i', '<script defer$1 src=', $tag );
}
return $tag;
}
/**
* A helper function that lets you enqueue scripts in an async fashion.
*
* @deprecated Since 2.1.0 - use the strategy feature instead.
*
* @param string $handle Name of the script. Should be unique.
* @param string $min_path Minimized script path.
* @param string $non_min_path Full Script path.
* @param array $deps Array of script dependencies.
* @param bool $ver The script version.
* @param bool $in_footer Should the script be included in the footer.
*/
public static function enqueue_async_script( $handle, $min_path, $non_min_path, $deps = array(), $ver = false, $in_footer = true ) {
_deprecated_function( __METHOD__, '2.1.0' );
wp_enqueue_script( $handle, self::get_file_url_for_environment( $min_path, $non_min_path ), $deps, $ver, $in_footer );
wp_script_add_data( $handle, 'strategy', 'defer' );
}
// endregion .
// ////////////////////
// region Utils
/**
* Given a minified path, and a non-minified path, will return
* a minified or non-minified file URL based on whether SCRIPT_DEBUG is set and truthy.
*
* If $package_path is provided, then the minified or non-minified file URL will be generated
* relative to the root package directory.
*
* Both `$min_base` and `$non_min_base` can be either full URLs, or are expected to be relative to the
* root Jetpack directory.
*
* @param string $min_path minified path.
* @param string $non_min_path non-minified path.
* @param string $package_path Optional. A full path to a file inside a package directory
* The URL will be relative to its directory. Default empty.
* Typically this is done by passing __FILE__ as the argument.
*
* @return string The URL to the file
* @since 1.0.3
* @since-jetpack 5.6.0
*/
public static function get_file_url_for_environment( $min_path, $non_min_path, $package_path = '' ) {
$path = ( Jetpack_Constants::is_defined( 'SCRIPT_DEBUG' ) && Jetpack_Constants::get_constant( 'SCRIPT_DEBUG' ) )
? $non_min_path
: $min_path;
/*
* If the path is actually a full URL, keep that.
* We look for a host value, since enqueues are sometimes without a scheme.
*/
$file_parts = wp_parse_url( $path );
if ( ! empty( $file_parts['host'] ) ) {
$url = $path;
} else {
$plugin_path = empty( $package_path ) ? Jetpack_Constants::get_constant( 'JETPACK__PLUGIN_FILE' ) : $package_path;
$url = plugins_url( $path, $plugin_path );
}
/**
* Filters the URL for a file passed through the get_file_url_for_environment function.
*
* @since 1.0.3
*
* @package assets
*
* @param string $url The URL to the file.
* @param string $min_path The minified path.
* @param string $non_min_path The non-minified path.
*/
return apply_filters( 'jetpack_get_file_for_environment', $url, $min_path, $non_min_path );
}
/**
* Passes an array of URLs to wp_resource_hints.
*
* @since 1.5.0
*
* @param string|array $urls URLs to hint.
* @param string $type One of the supported resource types: dns-prefetch (default), preconnect, prefetch, or prerender.
*/
public static function add_resource_hint( $urls, $type = 'dns-prefetch' ) {
add_filter(
'wp_resource_hints',
function ( $hints, $resource_type ) use ( $urls, $type ) {
if ( $resource_type === $type ) {
// Type casting to array required since the function accepts a single string.
foreach ( (array) $urls as $url ) {
$hints[] = $url;
}
}
return $hints;
},
10,
2
);
}
/**
* Serve a WordPress.com static resource via a randomized wp.com subdomain.
*
* @since 1.9.0
*
* @param string $url WordPress.com static resource URL.
*
* @return string $url
*/
public static function staticize_subdomain( $url ) {
// Extract hostname from URL.
$host = wp_parse_url( $url, PHP_URL_HOST );
// Explode hostname on '.'.
$exploded_host = explode( '.', $host );
// Retrieve the name and TLD.
if ( count( $exploded_host ) > 1 ) {
$name = $exploded_host[ count( $exploded_host ) - 2 ];
$tld = $exploded_host[ count( $exploded_host ) - 1 ];
// Rebuild domain excluding subdomains.
$domain = $name . '.' . $tld;
} else {
$domain = $host;
}
// Array of Automattic domains.
$domains_allowed = array( 'wordpress.com', 'wp.com' );
// Return $url if not an Automattic domain.
if ( ! in_array( $domain, $domains_allowed, true ) ) {
return $url;
}
if ( \is_ssl() ) {
return preg_replace( '|https?://[^/]++/|', 'https://s-ssl.wordpress.com/', $url );
}
/*
* Generate a random subdomain id by taking the modulus of the crc32 value of the URL.
* Valid values are 0, 1, and 2.
*/
$static_counter = abs( crc32( basename( $url ) ) % 3 );
return preg_replace( '|://[^/]+?/|', "://s$static_counter.wp.com/", $url );
}
/**
* Resolve '.' and '..' components in a path or URL.
*
* @since 1.12.0
* @param string $path Path or URL.
* @return string Normalized path or URL.
*/
public static function normalize_path( $path ) {
$parts = wp_parse_url( $path );
if ( ! isset( $parts['path'] ) ) {
return $path;
}
$ret = '';
$ret .= isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '';
if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
$ret .= $parts['user'] ?? '';
$ret .= isset( $parts['pass'] ) ? ':' . $parts['pass'] : '';
$ret .= '@';
}
$ret .= $parts['host'] ?? '';
$ret .= isset( $parts['port'] ) ? ':' . $parts['port'] : '';
$pp = explode( '/', $parts['path'] );
if ( '' === $pp[0] ) {
$ret .= '/';
array_shift( $pp );
}
$i = 0;
while ( $i < count( $pp ) ) { // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found
if ( '' === $pp[ $i ] || '.' === $pp[ $i ] || 0 === $i && '..' === $pp[ $i ] ) {
array_splice( $pp, $i, 1 );
} elseif ( '..' === $pp[ $i ] ) {
array_splice( $pp, --$i, 2 );
} else {
++$i;
}
}
$ret .= implode( '/', $pp );
$ret .= isset( $parts['query'] ) ? '?' . $parts['query'] : '';
$ret .= isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '';
return $ret;
}
// endregion .
// ////////////////////
// region Webpack-built script registration
/**
* Register a Webpack-built script.
*
* Our Webpack-built scripts tend to need a bunch of boilerplate:
* - A call to `Assets::get_file_url_for_environment()` for possible debugging.
* - A call to `wp_register_style()` for extracted CSS, possibly with detection of RTL.
* - Loading of dependencies and version provided by `@wordpress/dependency-extraction-webpack-plugin`.
* - Avoiding WPCom's broken minifier.
*
* This wrapper handles all of that.
*
* @since 1.12.0
* @since 2.1.0 Add a new `strategy` option to leverage WP >= 6.3 script strategy feature. The `async` option is deprecated.
* @param string $handle Name of the script. Should be unique across both scripts and styles.
* @param string $path Minimized script path.
* @param string $relative_to File that `$path` is relative to. Pass `__FILE__`.
* @param array $options Additional options:
* - `asset_path`: (string|null) `.asset.php` to load. Default is to base it on `$path`.
* - `async`: (bool) Set true to register the script as deferred, like `Assets::enqueue_async_script()`. Deprecated in favor of `strategy`.
* - `css_dependencies`: (string[]) Additional style dependencies to queue.
* - `css_path`: (string|null) `.css` to load. Default is to base it on `$path`.
* - `dependencies`: (string[]) Additional script dependencies to queue.
* - `enqueue`: (bool) Set true to enqueue the script immediately.
* - `in_footer`: (bool) Set true to register script for the footer.
* - `media`: (string) Media for the css file. Default 'all'.
* - `minify`: (bool|null) Set true to pass `minify=true` in the query string, or `null` to suppress the normal `minify=false`.
* - `nonmin_path`: (string) Non-minified script path.
* - `strategy`: (string) Specify a script strategy to use, eg. `defer` or `async`. Default is `""`.
* - `textdomain`: (string) Text domain for the script. Required if the script depends on wp-i18n.
* - `version`: (string) Override the version from the `asset_path` file.
* @phan-param array{asset_path?:?string,async?:bool,css_dependencies?:string[],css_path?:?string,dependencies?:string[],enqueue?:bool,in_footer?:bool,media?:string,minify?:?bool,nonmin_path?:string,strategy?:string,textdomain?:string,version?:string} $options
* @throws \InvalidArgumentException If arguments are invalid.
*/
public static function register_script( $handle, $path, $relative_to, array $options = array() ) {
if ( substr( $path, -3 ) !== '.js' ) {
throw new \InvalidArgumentException( '$path must end in ".js"' );
}
if ( isset( $options['async'] ) ) {
_deprecated_argument( __METHOD__, '2.1.0', 'The `async` option is deprecated in favor of `strategy`' );
}
$dir = dirname( $relative_to );
$base = substr( $path, 0, -3 );
$options += array(
'asset_path' => "$base.asset.php",
'async' => false,
'css_dependencies' => array(),
'css_path' => "$base.css",
'dependencies' => array(),
'enqueue' => false,
'in_footer' => false,
'media' => 'all',
'minify' => false,
'strategy' => '',
'textdomain' => null,
);
'@phan-var array{asset_path:?string,async:bool,css_dependencies:string[],css_path:?string,dependencies:string[],enqueue:bool,in_footer:bool,media:string,minify:?bool,nonmin_path?:string,strategy:string,textdomain:string,version?:string} $options'; // Phan gets confused by the array addition.
if ( is_string( $options['css_path'] ) && $options['css_path'] !== '' && substr( $options['css_path'], -4 ) !== '.css' ) {
throw new \InvalidArgumentException( '$options[\'css_path\'] must end in ".css"' );
}
if ( isset( $options['nonmin_path'] ) ) {
$url = self::get_file_url_for_environment( $path, $options['nonmin_path'], $relative_to );
} else {
$url = plugins_url( $path, $relative_to );
}
$url = self::normalize_path( $url );
if ( null !== $options['minify'] ) {
$url = add_query_arg( 'minify', $options['minify'] ? 'true' : 'false', $url );
}
if ( $options['asset_path'] && file_exists( "$dir/{$options['asset_path']}" ) ) {
$asset = require "$dir/{$options['asset_path']}"; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath
$options['dependencies'] = array_merge( $asset['dependencies'], $options['dependencies'] );
$options['css_dependencies'] = array_merge(
array_filter(
$asset['dependencies'],
function ( $d ) {
return wp_style_is( $d, 'registered' );
}
),
$options['css_dependencies']
);
$ver = $options['version'] ?? $asset['version'];
} else {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
$ver = $options['version'] ?? @filemtime( "$dir/$path" );
}
if ( $options['async'] && '' === $options['strategy'] ) { // Handle the deprecated `async` option
$options['strategy'] = 'defer';
}
wp_register_script(
$handle,
$url,
$options['dependencies'],
$ver,
array(
'in_footer' => $options['in_footer'],
'strategy' => $options['strategy'],
)
);
if ( $options['textdomain'] ) {
// phpcs:ignore Jetpack.Functions.I18n.DomainNotLiteral
wp_set_script_translations( $handle, $options['textdomain'] );
} elseif ( in_array( 'wp-i18n', $options['dependencies'], true ) ) {
_doing_it_wrong(
__METHOD__,
/* translators: %s is the script handle. */
esc_html( sprintf( __( 'Script "%s" depends on wp-i18n but does not specify "textdomain"', 'jetpack-assets' ), $handle ) ),
''
);
}
if ( is_string( $options['css_path'] ) && $options['css_path'] !== '' && file_exists( "$dir/{$options['css_path']}" ) ) {
$csspath = $options['css_path'];
if ( is_rtl() ) {
$rtlcsspath = substr( $csspath, 0, -4 ) . '.rtl.css';
if ( file_exists( "$dir/$rtlcsspath" ) ) {
$csspath = $rtlcsspath;
}
}
$url = self::normalize_path( plugins_url( $csspath, $relative_to ) );
if ( null !== $options['minify'] ) {
$url = add_query_arg( 'minify', $options['minify'] ? 'true' : 'false', $url );
}
wp_register_style( $handle, $url, $options['css_dependencies'], $ver, $options['media'] );
wp_script_add_data( $handle, 'Jetpack::Assets::hascss', true );
} else {
wp_script_add_data( $handle, 'Jetpack::Assets::hascss', false );
}
if ( $options['enqueue'] ) {
self::enqueue_script( $handle );
}
}
/**
* Enqueue a script registered with `Assets::register_script`.
*
* @since 1.12.0
* @param string $handle Name of the script. Should be unique across both scripts and styles.
*/
public static function enqueue_script( $handle ) {
wp_enqueue_script( $handle );
if ( wp_scripts()->get_data( $handle, 'Jetpack::Assets::hascss' ) ) {
wp_enqueue_style( $handle );
}
}
/**
* 'wp_default_scripts' action handler.
*
* This registers the `wp-jp-i18n-loader` script for use by Webpack bundles built with
* `@automattic/i18n-loader-webpack-plugin`.
*
* @since 1.14.0
* @param \WP_Scripts $wp_scripts WP_Scripts instance.
*/
public static function wp_default_scripts_hook( $wp_scripts ) {
$data = array(
'baseUrl' => false,
'locale' => determine_locale(),
'domainMap' => array(),
'domainPaths' => array(),
);
$lang_dir = Jetpack_Constants::get_constant( 'WP_LANG_DIR' );
$content_dir = Jetpack_Constants::get_constant( 'WP_CONTENT_DIR' );
$abspath = Jetpack_Constants::get_constant( 'ABSPATH' );
// Note: str_starts_with() is not used here, as wp-includes/compat.php may not be loaded at this point.
if ( strpos( $lang_dir, $content_dir ) === 0 ) {
$data['baseUrl'] = content_url( substr( trailingslashit( $lang_dir ), strlen( trailingslashit( $content_dir ) ) ) );
} elseif ( strpos( $lang_dir, $abspath ) === 0 ) {
$data['baseUrl'] = site_url( substr( trailingslashit( $lang_dir ), strlen( untrailingslashit( $abspath ) ) ) );
}
foreach ( self::$domain_map as $from => list( $to, $type, , $path ) ) {
$data['domainMap'][ $from ] = ( 'core' === $type ? '' : "{$type}/" ) . $to;
if ( '' !== $path ) {
$data['domainPaths'][ $from ] = trailingslashit( $path );
}
}
/**
* Filters the i18n state data for use by Webpack bundles built with
* `@automattic/i18n-loader-webpack-plugin`.
*
* @since 1.14.0
* @package assets
* @param array $data The state data to generate. Expected fields are:
* - `baseUrl`: (string|false) The URL to the languages directory. False if no URL could be determined.
* - `locale`: (string) The locale for the page.
* - `domainMap`: (string[]) A mapping from Composer package textdomains to the corresponding
* `plugins/textdomain` or `themes/textdomain` (or core `textdomain`, but that's unlikely).
* - `domainPaths`: (string[]) A mapping from Composer package textdomains to the corresponding package
* paths.
*/
$data = apply_filters( 'jetpack_i18n_state', $data );
// Can't use self::register_script(), this action is called too early.
if ( file_exists( __DIR__ . '/../build/i18n-loader.asset.php' ) ) {
$path = '../build/i18n-loader.js';
$asset = require __DIR__ . '/../build/i18n-loader.asset.php';
} else {
$path = 'js/i18n-loader.js';
$asset = array(
'dependencies' => array( 'wp-i18n' ),
'version' => filemtime( __DIR__ . "/$path" ),
);
}
$url = self::normalize_path( plugins_url( $path, __FILE__ ) );
$url = add_query_arg( 'minify', 'true', $url );
$handle = 'wp-jp-i18n-loader';
$wp_scripts->add( $handle, $url, $asset['dependencies'], $asset['version'] );
// Ensure the script is loaded in the footer and deferred.
$wp_scripts->add_data( $handle, 'group', 1 );
if ( ! is_array( $data ) ||
! isset( $data['baseUrl'] ) || ! ( is_string( $data['baseUrl'] ) || false === $data['baseUrl'] ) ||
! isset( $data['locale'] ) || ! is_string( $data['locale'] ) ||
! isset( $data['domainMap'] ) || ! is_array( $data['domainMap'] ) ||
! isset( $data['domainPaths'] ) || ! is_array( $data['domainPaths'] )
) {
$wp_scripts->add_inline_script( $handle, 'console.warn( "I18n state deleted by jetpack_i18n_state hook" );' );
} elseif ( ! $data['baseUrl'] ) {
$wp_scripts->add_inline_script( $handle, 'console.warn( "Failed to determine languages base URL. Is WP_LANG_DIR in the WordPress root?" );' );
} else {
$data['domainMap'] = (object) $data['domainMap']; // Ensure it becomes a json object.
$data['domainPaths'] = (object) $data['domainPaths']; // Ensure it becomes a json object.
$wp_scripts->add_inline_script( $handle, 'wp.jpI18nLoader.state = ' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';' );
}
// Deprecated state module: Depend on wp-i18n to ensure global `wp` exists and because anything needing this will need that too.
$wp_scripts->add( 'wp-jp-i18n-state', false, array( 'wp-deprecated', $handle ) );
$wp_scripts->add_inline_script( 'wp-jp-i18n-state', 'wp.deprecated( "wp-jp-i18n-state", { alternative: "wp-jp-i18n-loader" } );' );
$wp_scripts->add_inline_script( 'wp-jp-i18n-state', 'wp.jpI18nState = wp.jpI18nLoader.state;' );
}
// endregion .
// ////////////////////
// region Textdomain aliasing
/**
* Register a textdomain alias.
*
* Composer packages included in plugins will likely not use the textdomain of the plugin, while
* WordPress's i18n infrastructure will include the translations in the plugin's domain. This
* allows for mapping the package's domain to the plugin's.
*
* Since multiple plugins may use the same package, we include the package's version here so
* as to choose the most recent translations (which are most likely to match the package
* selected by jetpack-autoloader).
*
* @since 1.15.0
* @param string $from Domain to alias.
* @param string $to Domain to alias it to.
* @param string $totype What is the target of the alias: 'plugins', 'themes', or 'core'.
* @param string $ver Version of the `$from` domain.
* @param string $path Path to prepend when lazy-loading from JavaScript.
* @throws InvalidArgumentException If arguments are invalid.
*/
public static function alias_textdomain( $from, $to, $totype, $ver, $path = '' ) {
if ( ! in_array( $totype, array( 'plugins', 'themes', 'core' ), true ) ) {
throw new InvalidArgumentException( 'Type must be "plugins", "themes", or "core"' );
}
if (
did_action( 'wp_default_scripts' ) &&
// Don't complain during plugin activation.
! defined( 'WP_SANDBOX_SCRAPING' )
) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: 1: wp_default_scripts. 2: Name of the domain being aliased. */
esc_html__( 'Textdomain aliases should be registered before the %1$s hook. This notice was triggered by the %2$s domain.', 'jetpack-assets' ),
'<code>wp_default_scripts</code>',
'<code>' . esc_html( $from ) . '</code>'
),
''
);
}
if ( empty( self::$domain_map[ $from ] ) ) {
self::init_domain_map_hooks( $from, array() === self::$domain_map );
self::$domain_map[ $from ] = array( $to, $totype, $ver, $path );
} elseif ( Semver::compare( $ver, self::$domain_map[ $from ][2] ) > 0 ) {
self::$domain_map[ $from ] = array( $to, $totype, $ver, $path );
}
}
/**
* Register textdomain aliases from a mapping file.
*
* The mapping file is simply a PHP file that returns an array
* with the following properties:
* - 'domain': String, `$to`
* - 'type': String, `$totype`
* - 'packages': Array, mapping `$from` to `array( 'path' => $path, 'ver' => $ver )` (or to the string `$ver` for back compat).
*
* @since 1.15.0
* @param string $file Mapping file.
*/
public static function alias_textdomains_from_file( $file ) {
$data = require $file;
foreach ( $data['packages'] as $from => $fromdata ) {
if ( ! is_array( $fromdata ) ) {
$fromdata = array(
'path' => '',
'ver' => $fromdata,
);
}
self::alias_textdomain( $from, $data['domain'], $data['type'], $fromdata['ver'], $fromdata['path'] );
}
}
/**
* Register the hooks for textdomain aliasing.
*
* @param string $domain Domain to alias.
* @param bool $firstcall If this is the first call.
*/
private static function init_domain_map_hooks( $domain, $firstcall ) {
// If WordPress's plugin API is available already, use it. If not,
// drop data into `$wp_filter` for `WP_Hook::build_preinitialized_hooks()`.
if ( function_exists( 'add_filter' ) ) {
$add_filter = 'add_filter';
} else {
$add_filter = function ( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) {
global $wp_filter;
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$wp_filter[ $hook_name ][ $priority ][] = array(
'accepted_args' => $accepted_args,
'function' => $callback,
);
};
}
$add_filter( "gettext_{$domain}", array( self::class, 'filter_gettext' ), 10, 3 );
$add_filter( "ngettext_{$domain}", array( self::class, 'filter_ngettext' ), 10, 5 );
$add_filter( "gettext_with_context_{$domain}", array( self::class, 'filter_gettext_with_context' ), 10, 4 );
$add_filter( "ngettext_with_context_{$domain}", array( self::class, 'filter_ngettext_with_context' ), 10, 6 );
if ( $firstcall ) {
$add_filter( 'load_script_translation_file', array( self::class, 'filter_load_script_translation_file' ), 10, 3 );
}
}
/**
* Filter for `gettext`.
*
* @since 1.15.0
* @param string $translation Translated text.
* @param string $text Text to translate.
* @param string $domain Text domain.
* @return string Translated text.
*/
public static function filter_gettext( $translation, $text, $domain ) {
if ( $translation === $text ) {
// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
$newtext = __( $text, self::$domain_map[ $domain ][0] );
if ( $newtext !== $text ) {
return $newtext;
}
}
return $translation;
}
/**
* Filter for `ngettext`.
*
* @since 1.15.0
* @param string $translation Translated text.
* @param string $single The text to be used if the number is singular.
* @param string $plural The text to be used if the number is plural.
* @param int $number The number to compare against to use either the singular or plural form.
* @param string $domain Text domain.
* @return string Translated text.
*/
public static function filter_ngettext( $translation, $single, $plural, $number, $domain ) {
if ( $translation === $single || $translation === $plural ) {
// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
$translation = _n( $single, $plural, $number, self::$domain_map[ $domain ][0] );
}
return $translation;
}
/**
* Filter for `gettext_with_context`.
*
* @since 1.15.0
* @param string $translation Translated text.
* @param string $text Text to translate.
* @param string $context Context information for the translators.
* @param string $domain Text domain.
* @return string Translated text.
*/
public static function filter_gettext_with_context( $translation, $text, $context, $domain ) {
if ( $translation === $text ) {
// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
$translation = _x( $text, $context, self::$domain_map[ $domain ][0] );
}
return $translation;
}
/**
* Filter for `ngettext_with_context`.
*
* @since 1.15.0
* @param string $translation Translated text.
* @param string $single The text to be used if the number is singular.
* @param string $plural The text to be used if the number is plural.
* @param int $number The number to compare against to use either the singular or plural form.
* @param string $context Context information for the translators.
* @param string $domain Text domain.
* @return string Translated text.
*/
public static function filter_ngettext_with_context( $translation, $single, $plural, $number, $context, $domain ) {
if ( $translation === $single || $translation === $plural ) {
// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
$translation = _nx( $single, $plural, $number, $context, self::$domain_map[ $domain ][0] );
}
return $translation;
}
/**
* Filter for `load_script_translation_file`.
*
* @since 1.15.0
* @param string|false $file Path to the translation file to load. False if there isn't one.
* @param string $handle Name of the script to register a translation domain to.
* @param string $domain The text domain.
*/
public static function filter_load_script_translation_file( $file, $handle, $domain ) {
if ( false !== $file && isset( self::$domain_map[ $domain ] ) && ! is_readable( $file ) ) {
// Determine the part of the filename after the domain.
$suffix = basename( $file );
$l = strlen( $domain );
if ( substr( $suffix, 0, $l ) !== $domain || '-' !== $suffix[ $l ] ) {
return $file;
}
$suffix = substr( $suffix, $l );
$lang_dir = Jetpack_Constants::get_constant( 'WP_LANG_DIR' );
// Look for replacement files.
list( $newdomain, $type ) = self::$domain_map[ $domain ];
$newfile = $lang_dir . ( 'core' === $type ? '/' : "/{$type}/" ) . $newdomain . $suffix;
if ( is_readable( $newfile ) ) {
return $newfile;
}
}
return $file;
}
// endregion .
}
// Enable section folding in vim:
// vim: foldmarker=//\ region,//\ endregion foldmethod=marker
// .
@@ -0,0 +1,238 @@
<?php
/**
* Jetpack script data.
*
* @package automattic/jetpack-assets
*/
namespace Automattic\Jetpack\Assets;
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Host;
/**
* Class script data
*/
class Script_Data {
const SCRIPT_HANDLE = 'jetpack-script-data';
/**
* Whether the script data has been rendered.
*
* @var bool
*/
private static $did_render_script_data = false;
/**
* Configure.
*/
public static function configure() {
/**
* Ensure that assets are registered on wp_loaded,
* which is fired before *_enqueue_scripts actions.
* It means that when the dependent scripts are registered,
* the scripts here are already registered.
*/
add_action( 'wp_loaded', array( self::class, 'register_assets' ) );
/**
* Notes:
* 1. wp_print_scripts action is fired on both admin and public pages.
* On admin pages, it's fired before admin_enqueue_scripts action,
* which can be a problem if the consumer package uses admin_enqueue_scripts
* to hook into the script data. Thus, we prefer to use admin_print_scripts on admin pages.
* 2. We want to render the script data on print, instead of init or enqueue actions,
* so that the hook callbacks have enough time and information
* to decide whether to update the script data or not.
*/
$hook = is_admin() ? 'admin_print_scripts' : 'wp_print_scripts';
add_action( $hook, array( self::class, 'render_script_data' ), 1 );
add_action( 'enqueue_block_editor_assets', array( self::class, 'render_script_data' ), 1 );
}
/**
* Register assets.
*
* @access private
*/
public static function register_assets() {
Assets::register_script(
self::SCRIPT_HANDLE,
'../build/jetpack-script-data.js',
__FILE__,
array(
'in_footer' => true,
'textdomain' => 'jetpack-assets',
)
);
}
/**
* Render the script data using an inline script.
*
* @access private
*
* @return void
*/
public static function render_script_data() {
// In case of 'enqueue_block_editor_assets' action, this can be called multiple times.
if ( self::$did_render_script_data ) {
return;
}
self::$did_render_script_data = true;
$script_data = is_admin() || self::is_authenticated_rest_request()
? self::get_admin_script_data()
: self::get_public_script_data();
if ( ! empty( $script_data ) ) {
$script_data = wp_json_encode(
$script_data,
JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE
);
wp_add_inline_script(
self::SCRIPT_HANDLE,
sprintf( 'window.JetpackScriptData = %s;', $script_data ),
'before'
);
Assets::enqueue_script( self::SCRIPT_HANDLE );
}
}
/**
* Whether the current request is an authenticated REST API request.
*
* @return bool
*/
protected static function is_authenticated_rest_request() {
return wp_is_serving_rest_request() && current_user_can( 'read' );
}
/**
* Get the admin script data.
*
* @return array
*/
protected static function get_admin_script_data() {
global $wp_version;
$data = array(
'site' => array(
'admin_url' => esc_url_raw( admin_url() ),
'date_format' => get_option( 'date_format' ),
'icon' => self::get_site_icon(),
'is_multisite' => is_multisite(),
'host' => ( new Host() )->get_known_host_guess(),
'is_wpcom_platform' => ( new Host() )->is_wpcom_platform(),
'plan' => array(
// The properties here should be updated by the consumer package/plugin.
// It includes properties like 'product_slug', 'features', etc.
'product_slug' => '',
),
'rest_nonce' => wp_create_nonce( 'wp_rest' ),
'rest_root' => esc_url_raw( rest_url() ),
'suffix' => ( new Status() )->get_site_suffix(),
'title' => self::get_site_title(),
'wp_version' => $wp_version,
'wpcom' => array(
// This should contain the connected site details like blog_id, is_atomic etc.
'blog_id' => 0,
),
),
'user' => array(
'current_user' => self::get_current_user_data(),
),
);
/**
* Filter the admin script data.
*
* When using this filter, ensure that the data is added only if it is used by some script.
* This filter may be called on almost every admin page load. So, one should check if the data is needed/used on that page.
* For example, the social (publicize) data is used only on Social admin page, Jetpack settings page and the post editor.
* So, the social data should be added only on those pages.
*
* @since 2.3.0
*
* @param array $data The script data.
*/
return apply_filters( 'jetpack_admin_js_script_data', $data );
}
/**
* Get the public script data.
*
* @return array
*/
protected static function get_public_script_data() {
$data = array();
/**
* Filter the public script data.
*
* See the docs for `jetpack_admin_js_script_data` filter for more information.
*
* @since 2.3.0
*
* @param array $data The script data.
*/
return apply_filters( 'jetpack_public_js_script_data', $data );
}
/**
* Get the site title.
*
* @return string
*/
protected static function get_site_title() {
$title = get_bloginfo( 'name' );
return $title ? $title : esc_url_raw( ( get_site_url() ) );
}
/**
* Get the site icon.
*
* @return string
*/
protected static function get_site_icon() {
if ( ! has_site_icon() ) {
return '';
}
/**
* Filters the site icon using Photon.
*
* @see https://developer.wordpress.com/docs/photon/
*
* @param string $url The URL of the site icon.
* @param array|string $args An array of arguments, e.g. array( 'w' => '300', 'resize' => array( 123, 456 ) ), or in string form (w=123&h=456).
*/
return apply_filters( 'jetpack_photon_url', get_site_icon_url(), array( 'w' => 64 ) );
}
/**
* Get the current user data.
*
* @return array
*/
protected static function get_current_user_data() {
$current_user = wp_get_current_user();
return array(
'display_name' => $current_user->display_name,
'id' => $current_user->ID,
'capabilities' => array(
'manage_options' => current_user_can( 'manage_options' ),
'manage_modules' => current_user_can( 'jetpack_manage_modules' ),
),
);
}
}
@@ -0,0 +1,121 @@
<?php
/**
* Simple semver version handling.
*
* We use this instead of something like `composer/semver` to avoid
* plugins needing to include yet-another dependency package. The
* amount of code we need here is pretty small.
*
* We use this instead of PHP's `version_compare()` because that doesn't
* handle prerelease versions in the way anyone other than PHP devs would
* expect, and silently breaks on various unexpected input.
*
* @package automattic/jetpack-assets
*/
namespace Automattic\Jetpack\Assets;
use InvalidArgumentException;
/**
* Simple semver version handling.
*/
class Semver {
/**
* Parse a semver version.
*
* @param string $version Version.
* @return array With components:
* - major: (int) Major version.
* - minor: (int) Minor version.
* - patch: (int) Patch version.
* - version: (string) Major.minor.patch.
* - prerelease: (string|null) Pre-release string.
* - buildinfo: (string|null) Build metadata string.
* @throws InvalidArgumentException If the version number is not in a recognized format.
*/
public static function parse( $version ) {
// This is slightly looser than the official version from semver.org, in that leading zeros are allowed.
if ( ! preg_match( '/^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<prerelease>(?:[0-9a-zA-Z-]+)(?:\.(?:[0-9a-zA-Z-]+))*))?(?:\+(?P<buildinfo>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/', $version, $m ) ) {
throw new InvalidArgumentException( "Version number \"$version\" is not in a recognized format." );
}
$info = array(
'major' => (int) $m['major'],
'minor' => (int) $m['minor'],
'patch' => (int) $m['patch'],
'version' => sprintf( '%d.%d.%d', $m['major'], $m['minor'], $m['patch'] ),
'prerelease' => isset( $m['prerelease'] ) && '' !== $m['prerelease'] ? $m['prerelease'] : null,
'buildinfo' => isset( $m['buildinfo'] ) && '' !== $m['buildinfo'] ? $m['buildinfo'] : null,
);
if ( null !== $info['prerelease'] ) {
$sep = '';
$prerelease = '';
foreach ( explode( '.', $info['prerelease'] ) as $part ) {
if ( ctype_digit( $part ) ) {
$part = (int) $part;
}
$prerelease .= $sep . $part;
$sep = '.';
}
$info['prerelease'] = $prerelease;
}
return $info;
}
/**
* Compare two version numbers.
*
* @param string $a First version.
* @param string $b Second version.
* @return int Less than, equal to, or greater than 0 depending on whether `$a` is less than, equal to, or greater than `$b`.
* @throws InvalidArgumentException If the version numbers are not in a recognized format.
*/
public static function compare( $a, $b ) {
$aa = self::parse( $a );
$bb = self::parse( $b );
if ( $aa['major'] !== $bb['major'] ) {
return $aa['major'] - $bb['major'];
}
if ( $aa['minor'] !== $bb['minor'] ) {
return $aa['minor'] - $bb['minor'];
}
if ( $aa['patch'] !== $bb['patch'] ) {
return $aa['patch'] - $bb['patch'];
}
if ( null === $aa['prerelease'] ) {
return null === $bb['prerelease'] ? 0 : 1;
}
if ( null === $bb['prerelease'] ) {
return -1;
}
$aaa = explode( '.', $aa['prerelease'] );
$bbb = explode( '.', $bb['prerelease'] );
$al = count( $aaa );
$bl = count( $bbb );
for ( $i = 0; $i < $al && $i < $bl; $i++ ) {
$a = $aaa[ $i ];
$b = $bbb[ $i ];
if ( ctype_digit( $a ) ) {
if ( ctype_digit( $b ) ) {
if ( (int) $a !== (int) $b ) {
return (int) $a - (int) $b;
}
} else {
return -1;
}
} elseif ( ctype_digit( $b ) ) {
return 1;
} else {
$tmp = strcmp( $a, $b );
if ( 0 !== $tmp ) {
return $tmp;
}
}
}
return $al - $bl;
}
}
@@ -0,0 +1,41 @@
<?php
/**
* Jetpack shared stores assets.
*
* @package automattic/jetpack-assets
*/
namespace Automattic\Jetpack\Assets;
use Automattic\Jetpack\Assets;
/**
* Registers the externalized jetpack-shared-stores bundle so that consuming
* packages can declare it as a WordPress script dependency. Loading the bundle
* once ensures the contained data stores register exactly once per page.
*/
class Shared_Stores_Assets {
const SCRIPT_HANDLE = 'jetpack-shared-stores';
/**
* Configure.
*/
public static function configure() {
add_action( 'wp_loaded', array( self::class, 'register_assets' ) );
}
/**
* Register assets.
*/
public static function register_assets() {
Assets::register_script(
self::SCRIPT_HANDLE,
'../build/jetpack-shared-stores.js',
__FILE__,
array(
'in_footer' => true,
)
);
}
}
@@ -0,0 +1,76 @@
const i18n = require( '@wordpress/i18n' );
const { default: md5 } = require( 'md5-es' );
const locationMap = {
plugin: 'plugins/',
theme: 'themes/',
core: '',
};
const hasOwn = ( obj, prop ) => Object.prototype.hasOwnProperty.call( obj, prop );
module.exports = {
state: {
baseUrl: null,
locale: null,
domainMap: {},
domainPaths: {},
},
/**
* Download and register translations for a bundle.
*
* @param {string} path - Bundle path being fetched. May have a query part.
* @param {string} domain - Text domain to register into.
* @param {string} location - Location for the translation: 'plugin', 'theme', or 'core'.
* @return {Promise} Resolved when the translations are registered, or rejected with an `Error`.
*/
async downloadI18n( path, domain, location ) {
const state = this.state;
if ( ! state || typeof state.baseUrl !== 'string' ) {
throw new Error( 'wp.jpI18nLoader.state is not set' );
}
// "en_US" is the default, no translations are needed.
if ( state.locale === 'en_US' ) {
return;
}
// Check that fetch is available.
if ( typeof fetch === 'undefined' ) {
throw new Error( 'Fetch API is not available.' );
}
// Extract any query part and hash the script name like WordPress does.
const pathPrefix = hasOwn( state.domainPaths, domain ) ? state.domainPaths[ domain ] : '';
let hash, query;
const i = path.indexOf( '?' );
if ( i >= 0 ) {
hash = md5.hash( pathPrefix + path.substring( 0, i ) );
query = path.substring( i );
} else {
hash = md5.hash( pathPrefix + path );
query = '';
}
// Download.
const locationAndDomain = hasOwn( state.domainMap, domain )
? state.domainMap[ domain ]
: locationMap[ location ] + domain;
const res = await fetch(
// prettier-ignore
`${ state.baseUrl }${ locationAndDomain }-${ state.locale }-${ hash }.json${ query }`
);
if ( ! res.ok ) {
throw new Error( `HTTP request failed: ${ res.status } ${ res.statusText }` );
}
const data = await res.json();
// Extract the messages from the file and register them.
const localeData = hasOwn( data.locale_data, domain )
? data.locale_data[ domain ]
: data.locale_data.messages;
localeData[ '' ].domain = domain;
i18n.setLocaleData( localeData, domain );
},
};
@@ -0,0 +1 @@
export * from '@automattic/jetpack-script-data';
@@ -0,0 +1 @@
export * from '@automattic/jetpack-shared-stores';
@@ -0,0 +1,225 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.8] - 2026-06-15
### Changed
- Internal updates.
## [0.4.7] - 2026-06-08
### Changed
- Internal updates.
## [0.4.6] - 2026-06-05
### Changed
- Internal updates.
## [0.4.5] - 2026-05-19
### Changed
- Internal updates.
## [0.4.4] - 2026-05-04
### Changed
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
## [0.4.3] - 2026-02-23
### Changed
- Update dependencies.
## [0.4.2] - 2026-02-02
### Changed
- Update dependencies.
## [0.4.1] - 2025-12-08
### Fixed
- Ensure proper flags are used with `json_encode()`. [#46092]
## [0.4.0] - 2025-11-17
### Added
- General: Add a daily cleanup of expired transients. [#45920]
## [0.3.14] - 2025-10-20
### Changed
- Internal updates.
## [0.3.13] - 2025-09-08
### Changed
- Internal updates.
## [0.3.12] - 2025-08-04
### Added
- Add helper to clear all custom transients at the same time. [#44549]
## [0.3.11] - 2025-06-23
### Fixed
- General: Ensure the correct home page is checked when sites are in a subfolder. [#44007]
## [0.3.10] - 2025-04-28
### Changed
- Internal updates.
## [0.3.9] - 2025-03-21
### Changed
- Internal updates.
## [0.3.8] - 2025-03-12
### Changed
- Internal updates.
## [0.3.7] - 2025-03-05
### Changed
- Internal updates.
## [0.3.6] - 2025-02-24
### Changed
- Update dependencies.
## [0.3.5] - 2025-02-03
### Fixed
- Code: Remove extra params on function calls. [#41263]
## [0.3.4] - 2025-01-23
### Changed
- Internal updates.
## [0.3.3] - 2024-12-23
### Fixed
- General: Fixed not parsing error responses from WordPress.com properly. [#40660]
## [0.3.2] - 2024-11-28
### Fixed
- Cachable: Make the expiry overridable by child classes. [#40339]
## [0.3.1] - 2024-11-25
### Changed
- Updated dependencies. [#40286]
## [0.3.0] - 2024-11-14
### Removed
- General: Update minimum PHP version to 7.2. [#40147]
## [0.2.14] - 2024-11-04
### Added
- Enable test coverage. [#39961]
## [0.2.13] - 2024-10-29
### Changed
- Internal updates.
## [0.2.12] - 2024-09-23
### Changed
- Update dependencies.
## [0.2.11] - 2024-09-05
### Changed
- Update dependencies.
## [0.2.10] - 2024-09-05
### Changed
- Update dependencies.
## [0.2.9] - 2024-08-26
### Changed
- Updated package dependencies. [#39004]
## [0.2.8] - 2024-08-15
### Fixed
- Fix incorrect next-version tokens in php `@since` and/or `@deprecated` docs. [#38869]
## [0.2.7] - 2024-05-06
### Added
- Add missing package dependencies. [#37141]
## [0.2.6] - 2024-03-25
### Changed
- Internal updates.
## [0.2.5] - 2024-03-14
### Changed
- Internal updates.
## [0.2.4] - 2024-01-25
### Fixed
- Chage get_client() visibility so that older versions of boost do not break. [#35240]
## [0.2.3] - 2024-01-22
### Added
- Send current boost version with API requests to handle requests accordingly [#35132]
### Changed
- Jetpack Boost: Use ARRAY_A when decoding from JSON [#35062]
## [0.2.2] - 2023-11-24
### Changed
- Replaced usage of strpos() with str_contains(). [#34137]
- Replaced usage of substr() with str_starts_with() and str_ends_with(). [#34207]
## [0.2.1] - 2023-11-21
### Fixed
- Made abstract static methods in `Automattic\Jetpack\Boost_Core\Lib\Cacheable` abstract, instead of being implemented to always throw. [#34220]
## [0.2.0] - 2023-11-20
### Changed
- Updated required PHP version to >= 7.0. [#34192]
## [0.1.3] - 2023-09-19
- Minor internal updates.
## [0.1.2] - 2023-09-01
### Fixed
- Fix showing default error message and code when parsing cloud response. [#32685]
## [0.1.1] - 2023-08-28
### Changed
- Updated package dependencies. [#32605]
## 0.1.0 - 2023-06-06
### Added
- Introduce new package. [#31163]
[0.4.8]: https://github.com/Automattic/jetpack-boost-core/compare/v0.4.7...v0.4.8
[0.4.7]: https://github.com/Automattic/jetpack-boost-core/compare/v0.4.6...v0.4.7
[0.4.6]: https://github.com/Automattic/jetpack-boost-core/compare/v0.4.5...v0.4.6
[0.4.5]: https://github.com/Automattic/jetpack-boost-core/compare/v0.4.4...v0.4.5
[0.4.4]: https://github.com/Automattic/jetpack-boost-core/compare/v0.4.3...v0.4.4
[0.4.3]: https://github.com/Automattic/jetpack-boost-core/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/Automattic/jetpack-boost-core/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/Automattic/jetpack-boost-core/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.14...v0.4.0
[0.3.14]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.13...v0.3.14
[0.3.13]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.12...v0.3.13
[0.3.12]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.11...v0.3.12
[0.3.11]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.10...v0.3.11
[0.3.10]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.9...v0.3.10
[0.3.9]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.8...v0.3.9
[0.3.8]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.7...v0.3.8
[0.3.7]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.6...v0.3.7
[0.3.6]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/Automattic/jetpack-boost-core/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.14...v0.3.0
[0.2.14]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.13...v0.2.14
[0.2.13]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.12...v0.2.13
[0.2.12]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.11...v0.2.12
[0.2.11]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.10...v0.2.11
[0.2.10]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.9...v0.2.10
[0.2.9]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.8...v0.2.9
[0.2.8]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.7...v0.2.8
[0.2.7]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.6...v0.2.7
[0.2.6]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.5...v0.2.6
[0.2.5]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.4...v0.2.5
[0.2.4]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.3...v0.2.4
[0.2.3]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.2...v0.2.3
[0.2.2]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/Automattic/jetpack-boost-core/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/Automattic/jetpack-boost-core/compare/v0.1.3...v0.2.0
[0.1.3]: https://github.com/Automattic/jetpack-boost-core/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/Automattic/jetpack-boost-core/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/Automattic/jetpack-boost-core/compare/v0.1.0...v0.1.1
@@ -0,0 +1,34 @@
<?php
/**
* Boost API Client interface.
*
* @package automattic/jetpack-boost-core
*/
namespace Automattic\Jetpack\Boost_Core\Contracts;
/**
* An interface to build Boost API client.
*
* Communication with Boost back-end should be done through this interface.
*/
interface Boost_API_Client {
/**
* Submit a request to boost API and return response.
*
* @param string $path - Request path.
* @param mixed[] $payload - Request arguments.
* @return mixed
*/
public function post( $path, $payload = array() );
/**
* Make a get request to boost API and return response.
*
* @param string $path - Request path.
* @param mixed[] $query - Query parameters.
* @return mixed
*/
public function get( $path, $query = array() );
}
@@ -0,0 +1,112 @@
<?php
/**
* A helper class to help with Boost API interactions.
*
* @package automattic/jetpack-boost-core
*/
namespace Automattic\Jetpack\Boost_Core\Lib;
use Automattic\Jetpack\Boost_Core\Contracts\Boost_API_Client;
/**
* A class that handles the Boost API client.
*
* The communication to the backend is done using this class on top of the Boost_API_Client interface.
*/
class Boost_API {
/**
* The API client instance.
*
* @var Boost_API_Client
*/
private static $api_client;
/**
* Get the API client instance.
*
* @return Boost_API_Client
* @deprecated 3.1.1 Use get(), and post() directly instead.
*/
public static function get_client() {
return self::get_api_client();
}
/**
* Instantiate the API client.
*
* @return Boost_API_Client
*/
private static function get_api_client() {
if ( ! self::$api_client ) {
$class = apply_filters( 'jetpack_boost_api_client_class', WPCOM_Boost_API_Client::class );
self::$api_client = new $class();
}
return self::$api_client;
}
/**
* Make a get request to boost API and return response.
*
* @param string $path - Request path.
* @param mixed[] $query - Query parameters.
* @param mixed[] $args - Request arguments.
* @return array|\WP_Error
*/
public static function get( $path, $query = array(), $args = null ) {
// @phan-suppress-next-line PhanParamTooMany -- By default this is WPCOM_Boost_API_Client, which accepts an extra param.
return self::get_api_client()->get( $path, $query, self::merge_args( $args ) );
}
/**
* Submit a request to boost API and return response.
*
* @param string $path - Request path.
* @param mixed[] $payload - Request arguments.
* @param mixed[] $args - Request arguments.
* @return mixed
*/
public static function post( $path, $payload = array(), $args = null ) {
// @phan-suppress-next-line PhanParamTooMany -- By default this is WPCOM_Boost_API_Client, which accepts an extra param.
return self::get_api_client()->post( $path, $payload, self::merge_args( $args ) );
}
/**
* Merge the arguments with the defaults.
*
* @param mixed[] $args - Provided arguments.
* @return mixed[]
*/
private static function merge_args( $args ) {
if ( ! is_array( $args ) ) {
$args = wp_parse_args(
$args,
array(
'headers' => self::default_headers(),
)
);
} else {
$args['headers'] = wp_parse_args( $args['headers'] ?? array(), self::default_headers() );
}
return $args;
}
/**
* Get the default headers to include with each request.
*
* @return string[]
*/
public static function default_headers() {
$headers = array(
'Content-Type' => 'application/json; charset=utf-8',
);
if ( defined( 'JETPACK_BOOST_VERSION' ) ) {
$headers['X-Jetpack-Boost-Version'] = JETPACK_BOOST_VERSION;
}
return $headers;
}
}
@@ -0,0 +1,139 @@
<?php
/**
* Base abstract class for cacheable value objects.
*
* @package automattic/jetpack-boost-core
*/
namespace Automattic\Jetpack\Boost_Core\Lib;
/**
* Class Cacheable.
*/
abstract class Cacheable implements \JsonSerializable {
/**
* Default cache expiry.
* Can be overridden by child classes.
*/
protected const DEFAULT_EXPIRY = 300; // 5 minutes.
/**
* The ID of this object, if cached as a transient.
*
* @var string|null $cache_id Cache id.
*/
private $cache_id;
/**
* Store the object.
*
* @param int $expiry Expiry in seconds.
*
* @return mixed|void
*/
public function store( $expiry = null ) {
if ( null === $expiry ) {
$expiry = static::DEFAULT_EXPIRY;
}
if ( ! $this->cache_id ) {
$this->cache_id = $this->generate_cache_id();
}
Transient::set(
static::cache_prefix() . $this->cache_id,
$this->jsonSerialize(),
$expiry
);
return $this->cache_id;
}
/**
* Default implementation for generating cache key. Generates a random ASCII string.
*/
protected function generate_cache_id() {
return wp_generate_password( 20, false );
}
/**
* This is intended to be the reverse of JsonSerializable->jsonSerialize.
*
* @param array $data Serialized data to restore the object from.
*
* @throws \Exception Throw an exception to remind to implement the method in child classes.
*/
abstract public static function jsonUnserialize( $data );
/**
* Fetch an object with the given ID.
*
* @param string $id The object ID.
*
* @return null|mixed
*/
public static function get( $id ) {
$data = Transient::get( static::cache_prefix() . $id, false );
if ( ! $data ) {
return null;
}
$class = get_called_class();
$object = $class::jsonUnserialize( $data, $id );
if ( $object ) {
$object->set_cache_id( $id );
}
return $object;
}
/**
* Set cache id.
*
* This is here so we know how this object was loaded.
*
* @param string $cache_id Cache id.
*/
public function set_cache_id( $cache_id ) {
$this->cache_id = $cache_id;
}
/**
* Getter for the cache ID.
*
* @return string
*/
public function get_cache_id() {
return $this->cache_id;
}
/**
* Delete the cache entry.
*/
public function delete() {
$this->cache_id && static::delete_by_cache_id( $this->cache_id );
}
/**
* Delete all currently cached entries.
*/
public static function clear_cache() {
Transient::delete_by_prefix( static::cache_prefix() );
}
/**
* Delete the cache entry for the given cache id.
*
* @param string $cache_id The cache ID.
*/
public static function delete_by_cache_id( $cache_id ) {
Transient::delete( static::cache_prefix() . $cache_id );
}
/**
* Returns the cache prefix
*
* @throws \Exception Throw an exception to remind to implement the method in child classes.
*/
abstract protected static function cache_prefix();
}
@@ -0,0 +1,177 @@
<?php
/**
* Transients for Jetpack Boost.
*
* @package automattic/jetpack-boost-core
*/
namespace Automattic\Jetpack\Boost_Core\Lib;
/**
* Class Transient
*/
class Transient {
const OPTION_PREFIX = 'jb_transient_';
/**
* Get the key with prefix.
*
* @param string $key the key to be prefixed.
*/
public static function key( $key ) {
return static::OPTION_PREFIX . $key;
}
/**
* Updates a cache entry. Creates the cache entry if it doesn't exist.
*
* @param string $key Cache key name.
* @param mixed $value Cache value.
* @param int $expiry Cache expiration in seconds.
*
* @return void
*/
public static function set( $key, $value, $expiry = 0 ) {
if ( 0 === $expiry ) {
$expiry = YEAR_IN_SECONDS;
}
$data = array(
'expire' => time() + $expiry,
'data' => $value,
);
update_option( self::key( $key ), $data, false );
}
/**
* Gets a cache entry.
*
* @param string $key Cache key name.
* @param mixed $default Default value.
*
* @return mixed
*/
public static function get( $key, $default = null ) {
// Ensure everything's there.
$option = get_option( self::key( $key ), $default );
if ( $default === $option || ! isset( $option['expire'] ) || ! isset( $option['data'] )
) {
return $default;
}
// Maybe expire the result instead of returning it.
$expire = $option['expire'];
$data = $option['data'];
if ( false !== $expire && $expire < time() ) {
self::delete( $key );
return $default;
}
return $data;
}
/**
* Delete all expired transients.
*
* @return int The number of deleted transients.
*/
public static function delete_expired() {
global $wpdb;
//phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$option_names = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s",
self::OPTION_PREFIX . '%'
)
);
$count = 0;
foreach ( $option_names as $option_name ) {
$value = get_option( $option_name );
if ( isset( $value['expire'] ) && $value['expire'] < time() ) {
delete_option( $option_name );
++$count;
}
}
return $count;
}
/**
* Delete all `Transient` values with certain prefix from database.
*
* @param string $prefix Cache key prefix.
*/
public static function delete_by_prefix( $prefix ) {
global $wpdb;
/**
* The prefix used in option_name.
*/
$option_prefix = static::key( $prefix );
/**
* LIKE search pattern for the delete query.
*/
$prefix_search_pattern = $wpdb->esc_like( $option_prefix ) . '%';
//phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$option_names = $wpdb->get_col(
$wpdb->prepare(
"
SELECT option_name
FROM $wpdb->options
WHERE `option_name` LIKE %s
",
$prefix_search_pattern
)
);
// phpcs:enable
// Go through each option individually to ensure caches are handled properly.
foreach ( $option_names as $option_name ) {
delete_option( $option_name );
}
}
/**
* Delete all `Transient` values from the database.
*
* @return void
*/
public static function delete_bulk() {
global $wpdb;
$prefix_search_pattern = $wpdb->esc_like( self::OPTION_PREFIX ) . '%';
//phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"DELETE FROM $wpdb->options WHERE option_name LIKE %s",
$prefix_search_pattern
)
);
if (
function_exists( 'wp_cache_flush_group' ) &&
function_exists( 'wp_cache_supports' ) &&
wp_cache_supports( 'flush_group' )
) {
wp_cache_flush_group( 'options' );
}
}
/**
* Delete a cache entry.
*
* @param string $key Cache key name.
*
* @return void
*/
public static function delete( $key ) {
delete_option( self::key( $key ) );
}
}
@@ -0,0 +1,96 @@
<?php
/**
* Implement the URL normalizer.
*
* @package automattic/jetpack-boost-core
*/
namespace Automattic\Jetpack\Boost_Core\Lib;
/**
* Class Url
*/
class Url {
const PARAMS_TO_EXCLUDE = array( 'utm_campaign', 'utm_medium', 'utm_source', 'utm_content', 'fbclid', '_ga', 'jb-disable-modules' );
const PARAMS_TO_NORMALIZE = array( 's' => '' );
/**
* Normalize a URL - right now, just make sure it's absolute.
*
* @param string $url The URL.
*
* @return string
*/
public static function normalize( $url ) {
$url = self::normalize_query_args( $url );
if ( '/' === $url[0] ) {
$url = site_url( $url );
}
return rtrim( $url, '/' );
}
/**
* Returns the current URL.
*
* @return string
*/
public static function get_current_url() {
// Fallback to the site URL if we're unable to determine the URL from $_SERVER global.
$current_url = site_url();
if ( isset( $_SERVER ) && is_array( $_SERVER ) ) {
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitization happens at the end
$scheme = isset( $_SERVER['HTTPS'] ) && 'on' === $_SERVER['HTTPS'] ? 'https' : 'http';
$host = ! empty( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : null;
$path = ! empty( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
// Support for local plugin development and testing using ngrok.
if ( ! empty( $_SERVER['HTTP_X_ORIGINAL_HOST'] ) && str_contains( $_SERVER['HTTP_X_ORIGINAL_HOST'], 'ngrok.io' ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
$host = wp_unslash( $_SERVER['HTTP_X_ORIGINAL_HOST'] );
}
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( $host ) {
$current_url = esc_url_raw( sprintf( '%s://%s%s', $scheme, $host, $path ) );
}
}
return apply_filters( 'jetpack_boost_current_url', $current_url );
}
/**
* Remove and reorder query parameters.
*
* @param string $url URL.
*
* @return string
*/
protected static function normalize_query_args( $url ) {
$exclude_parameters = apply_filters(
'jetpack_boost_excluded_query_parameters',
self::PARAMS_TO_EXCLUDE
);
// Filter out certain parameters that we know don't constitute a different page.
$url = remove_query_arg( $exclude_parameters, $url );
// Extract the query string, sort it and replace it in the current URL.
$parsed_url = wp_parse_url( $url );
if ( ! empty( $parsed_url['query'] ) ) {
parse_str( $parsed_url['query'], $args );
foreach ( self::PARAMS_TO_NORMALIZE as $key => $value ) {
if ( isset( $args[ $key ] ) ) {
$args[ $key ] = $value;
}
}
ksort( $args );
$sorted_query = http_build_query( $args );
$url = str_replace( '?' . $parsed_url['query'], '?' . $sorted_query, $url );
}
return $url;
}
}
@@ -0,0 +1,158 @@
<?php
/**
* Utility class.
*
* @package automattic/jetpack-boost-core
*/
namespace Automattic\Jetpack\Boost_Core\Lib;
use Automattic\Jetpack\Connection\Client;
/**
* Class Utils
*/
class Utils {
/**
* Standardize error format.
*
* @param mixed $error Error.
*
* @return array
*/
public static function standardize_error( $error ) {
if ( is_wp_error( $error ) ) {
return array(
'name' => $error->get_error_code(),
'message' => $error->get_error_message(),
);
}
if ( is_string( $error ) ) {
return array(
'name' => 'Error',
'message' => $error,
);
}
if ( is_object( $error ) ) {
return array(
'name' => 'Error',
'message' => json_decode( wp_json_encode( $error, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), true ),
);
}
return $error;
}
/**
* Convert relative url to absolute.
*
* @param string $url The URL.
*
* @return string
*/
public static function force_url_to_absolute( $url ) {
if ( str_starts_with( $url, '/' ) ) {
return home_url( $url );
}
return $url;
}
/**
* Given a post type, look up its label (if available). Returns
* raw post type string if not found.
*
* @param string $post_type Post type to look up.
*
* @return string
*/
public static function get_post_type_label( $post_type ) {
$post_type_object = get_post_type_object( $post_type );
if ( ! $post_type_object ) {
return $post_type;
}
return $post_type_object->labels->name;
}
/**
* Given a taxonomy name, look up its label. Returns raw taxonomy name if
* not found.
*
* @param string $taxonomy_name Taxonomy to look up.
*
* @return string
*/
public static function get_taxonomy_label( $taxonomy_name ) {
$taxonomy = get_taxonomy( $taxonomy_name );
if ( ! $taxonomy ) {
return $taxonomy_name;
}
return $taxonomy->label;
}
/**
* Make a Jetpack-authenticated request to the WPCOM servers
*
* @param string $method Request method.
* @param string $endpoint Endpoint to contact.
* @param array $args Request args.
* @param array $body Request body.
*
* @return \WP_Error|array
*/
public static function send_wpcom_request( $method, $endpoint, $args = null, $body = null ) {
$default_args = array(
'method' => $method,
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
);
$response = Client::wpcom_json_api_request_as_blog(
$endpoint,
'2',
array_merge( $default_args, empty( $args ) ? array() : $args ),
empty( $body ) ? null : wp_json_encode( $body, JSON_UNESCAPED_SLASHES ),
'wpcom'
);
if ( is_wp_error( $response ) ) {
return $response;
}
// Check for HTTP errors.
$code = wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code ) {
$default_message = sprintf(
/* translators: %d is a numeric HTTP error code */
__( 'HTTP %d while communicating with WordPress.com', 'jetpack-boost-core' ),
$code
);
/*
* Normalize error responses from WordPress.com.
*
* When WordPress.com returns an error from Boost Cloud, the body contains
* statusCode and error. When it returns a WP_Error, it contains code and message.
*/
if ( isset( $data['statusCode'] ) && isset( $data['error'] ) ) {
$data_code = $data['statusCode'];
$data_message = $data['error'];
} elseif ( isset( $data['code'] ) && isset( $data['message'] ) ) {
$data_code = $data['code'];
$data_message = $data['message'];
}
$error_code = empty( $data_code ) ? 'http_error' : $data_code;
$message = empty( $data_message ) ? $default_message : $data_message;
return new \WP_Error( $error_code, $message );
}
return $data;
}
}
@@ -0,0 +1,67 @@
<?php
/**
* WPCOM Boost API Client interface.
*
* @package automattic/jetpack-boost-core
*/
namespace Automattic\Jetpack\Boost_Core\Lib;
use Automattic\Jetpack\Boost_Core\Contracts\Boost_API_Client;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* A class that handles the Boost API client.
*
* The communication to the backend is done using this class on top of the Boost_API_Client interface.
*/
class WPCOM_Boost_API_Client implements Boost_API_Client {
/**
* Submit a POST request to boost API and return response.
*
* @param string $path - Request path.
* @param mixed[] $payload - Request payload.
* @param mixed[] $args - Request arguments.
* @return mixed
*/
public function post( $path, $payload = array(), $args = null ) {
return Utils::send_wpcom_request(
'POST',
$this->get_api_path( $path ),
$args,
$payload
);
}
/**
* Make a get request to boost API and return response.
*
* @param string $path - Request path.
* @param mixed[] $query - Query parameters.
* @param mixed[] $args - Request arguments.
* @return mixed
*/
public function get( $path, $query = array(), $args = null ) {
return Utils::send_wpcom_request(
'GET',
add_query_arg( $query, $this->get_api_path( $path ) ),
$args
);
}
/**
* Get the API path for the given path.
*
* @param string $path - Request path.
* @return string
*/
private function get_api_path( $path ) {
$blog_id = (int) \Jetpack_Options::get_option( 'id' );
return sprintf( '/sites/%d/jetpack-boost/%s', $blog_id, $path );
}
}
@@ -0,0 +1,211 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.18] - 2026-06-15
### Changed
- Internal updates.
## [0.4.17] - 2026-06-08
### Changed
- Internal updates.
## [0.4.16] - 2026-06-05
### Changed
- Internal updates.
## [0.4.15] - 2026-05-19
### Changed
- Internal updates.
## [0.4.14] - 2026-05-04
### Changed
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
## [0.4.13] - 2026-04-10
### Changed
- Internal updates.
## [0.4.12] - 2025-11-17
### Changed
- Internal updates.
## [0.4.11] - 2025-10-20
### Changed
- Internal updates.
## [0.4.10] - 2025-07-21
### Changed
- Internal updates.
## [0.4.9] - 2025-05-15
### Changed
- Switch from deprecated `jetpack_boost_critical_css_environment_changed` action to `jetpack_boost_environment_changed`. [#43446]
## [0.4.8] - 2025-04-28
### Changed
- Internal updates.
## [0.4.7] - 2025-03-26
### Changed
- Internal updates.
## [0.4.6] - 2025-03-21
### Changed
- Internal updates.
## [0.4.5] - 2025-03-18
### Changed
- Internal updates.
## [0.4.4] - 2025-03-17
### Changed
- Internal updates.
## [0.4.3] - 2025-03-12
### Changed
- Internal updates.
## [0.4.2] - 2025-02-24
### Changed
- Internal updates.
## [0.4.1] - 2025-01-23
### Changed
- Internal updates.
## [0.4.0] - 2024-11-14
### Removed
- General: Update minimum PHP version to 7.2. [#40147]
## [0.3.14] - 2024-11-04
### Added
- Enable test coverage. [#39961]
## [0.3.13] - 2024-10-29
### Changed
- Internal updates.
## [0.3.12] - 2024-08-26
### Changed
- Updated package dependencies. [#39004]
## [0.3.11] - 2024-04-22
### Changed
- Internal updates.
## [0.3.10] - 2024-04-08
### Changed
- Bump project version. [#36072]
## [0.3.9] - 2024-03-27
### Added
- Use `jetpack_boost_critical_css_environment_changed` hook instead of `handle_environment_change` [#36519]
### Changed
- Speed Score: More accurately detect which modules are active when a speed score is requested. [#36534]
### Fixed
- Updated Jetpack_Boost_Modules placeholder class to match Boost interface [#36598]
## [0.3.8] - 2024-03-25
### Changed
- Internal updates.
## [0.3.7] - 2024-03-18
### Changed
- Internal updates.
## [0.3.6] - 2024-03-14
### Changed
- Internal updates.
## [0.3.5] - 2024-03-01
### Changed
- Add gereric Jetpack_Boost_Modules class for when Boost is uninstalled/not activated. [#36080]
## [0.3.4] - 2024-02-13
### Fixed
- Speed Score: Do not return no-boost score if no boost modules are active [#35327]
## [0.3.3] - 2024-01-22
### Added
- Send current boost version with API requests to handle requests accordingly [#35132]
### Changed
- Jetpack Boost: Use Arrays, not objects [#35062]
## [0.3.2] - 2024-01-15
### Changed
- Internal updates.
## [0.3.1] - 2023-12-14
### Changed
- Internal updates.
## [0.3.0] - 2023-11-20
### Changed
- Updated required PHP version to >= 7.0. [#34192]
## [0.2.2] - 2023-09-19
### Fixed
- Fixed deprecation notice in PHP 8.2. [#33079]
## [0.2.1] - 2023-08-28
### Added
- Add boost speed score history endpoint [#32016]
### Changed
- Updated package dependencies. [#32605]
- Updated package version [#32016]
## [0.2.0] - 2023-06-06
### Changed
- Moved boost core classes to boost-core package [#31163]
- Updated package dependencies. [#31163]
## 0.1.0 - 2023-05-29
### Added
- Add a new package for Boost Speed Score [#30914]
- Add a new argument to `Speed_Score` to identify where the request was made from (e.g. 'boost-plugin', 'jetpack-dashboard', etc). [#31012]
[0.4.18]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.17...v0.4.18
[0.4.17]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.16...v0.4.17
[0.4.16]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.15...v0.4.16
[0.4.15]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.14...v0.4.15
[0.4.14]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.13...v0.4.14
[0.4.13]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.12...v0.4.13
[0.4.12]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.11...v0.4.12
[0.4.11]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.10...v0.4.11
[0.4.10]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.9...v0.4.10
[0.4.9]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.8...v0.4.9
[0.4.8]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.7...v0.4.8
[0.4.7]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.6...v0.4.7
[0.4.6]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.5...v0.4.6
[0.4.5]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.4...v0.4.5
[0.4.4]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.3...v0.4.4
[0.4.3]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.14...v0.4.0
[0.3.14]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.13...v0.3.14
[0.3.13]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.12...v0.3.13
[0.3.12]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.11...v0.3.12
[0.3.11]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.10...v0.3.11
[0.3.10]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.9...v0.3.10
[0.3.9]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.8...v0.3.9
[0.3.8]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.7...v0.3.8
[0.3.7]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.6...v0.3.7
[0.3.6]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.2.2...v0.3.0
[0.2.2]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/Automattic/jetpack-boost-speed-score/compare/v0.1.0...v0.2.0
@@ -0,0 +1,56 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Jetpack Boost Active Modules
*
* Since the speed scores API will be used in the Jetpack plugin and in the My Jetpack, if Jetpack Boost
* is uninstalled, all we need is to pass along this placeholder class for the modules that essentially
* tells the API the user doesn't have any Boost modules active.
*
* @package automattic/jetpack/boost_speed_score
*/
namespace Automattic\Jetpack\Boost_Speed_Score;
/**
* Jetpack Boost Modules
*/
class Jetpack_Boost_Modules {
/**
* Holds the singleton instance of the class
*
* @var Jetpack_Boost_Modules
*/
private static $instance = false;
/**
* Singleton
*
* @static
* @return Jetpack_Boost_Modules
*/
public static function init() {
if ( ! self::$instance ) {
self::$instance = new Jetpack_Boost_Modules();
}
return self::$instance;
}
/**
* Returns status of all active boost modules that are also ready
*
* @return array - An empty array. The user will never have active modules when using the Boost Score API
*/
public function get_ready_active_optimization_modules() {
return array();
}
/**
* Returns whether or not the user has active modules
*
* @return false - The user will never have active modules when using the Boost Score API
*/
public function have_enabled_modules() {
return false;
}
}
@@ -0,0 +1,138 @@
<?php
/**
* Represents a request to generate an array of speed scores history.
*
* @package automattic/jetpack-boost-speed-score
*/
namespace Automattic\Jetpack\Boost_Speed_Score;
use Automattic\Jetpack\Boost_Core\Lib\Boost_API;
use Automattic\Jetpack\Boost_Core\Lib\Cacheable;
/**
* Class Speed_Score_Graph_History_Request
*/
class Speed_Score_Graph_History_Request extends Cacheable {
/**
* Algorithm to use when defining a hash for the cache.
*/
const CACHE_KEY_HASH_ALGO = 'md5';
/**
* The timestamp start windown in ms.
*
* @var int|float $start timestamp start windown in ms.
*/
private $start;
/**
* The timestamp end windown in ms.
*
* @var int|float $end timestamp end windown in ms.
*/
private $end;
/**
* Number of retries attempted.
*
* @var int $retry_count Number of times this Speed Score request has been retried.
*/
private $retry_count;
/**
* The error returned
*
* @var array $error Speed Scores error.
*/
private $error;
/**
* Used when there's an error.
*
* @var string $status Status.
*/
private $status;
/**
* Constructor.
*
* @param int|float $start timestamp start windown in ms.
* @param int|float $end timestamp end windown in ms.
* @param array $error Speed Scores error.
*/
public function __construct( $start, $end, $error ) {
$this->start = $start;
$this->end = $end;
$this->error = $error;
$this->retry_count = 0;
}
/**
* Convert this object to a plain array for JSON serialization.
*/
#[\ReturnTypeWillChange]
public function jsonSerialize() {
return array(
'start' => $this->start,
'end' => $this->end,
'error' => $this->error,
'retry_count' => $this->retry_count,
);
}
/**
* This is intended to be the reverse of JsonSerializable->jsonSerialize.
*
* @param mixed $data The data to turn into an object.
*
* @return Speed_Score_Graph_History_Request
*/
public static function jsonUnserialize( $data ) {
$object = new Speed_Score_Graph_History_Request(
$data['start'],
$data['end'],
$data['error']
);
if ( ! empty( $data['retry_count'] ) ) {
$object->retry_count = intval( $data['retry_count'] );
}
return $object;
}
/**
* Return the cache prefix.
*
* @return string
*/
protected static function cache_prefix() {
return 'jetpack_boost_speed_scores_graph_history_';
}
/**
* Send a Speed History request to the API.
*
* @return true|\WP_Error True on success, WP_Error on failure.
*/
public function execute() {
$response = Boost_API::get(
'speed-scores-history',
array(
'start' => $this->start,
'end' => $this->end,
)
);
if ( is_wp_error( $response ) ) {
$this->status = 'error';
$this->error = $response->get_error_message();
$this->store();
return $response;
}
return $response;
}
}
@@ -0,0 +1,148 @@
<?php
/**
* Speed score history after Jetpack Boost
*
* @package automattic/jetpack-boost-speed-score
*/
namespace Automattic\Jetpack\Boost_Speed_Score;
use Automattic\Jetpack\Boost_Core\Lib\Transient;
/**
* Class Speed_Score_History
*
* @package Automattic\Jetpack\Boost_Speed_Score\Lib
*/
class Speed_Score_History {
const OPTION_PREFIX = 'jetpack_boost_speed_score_history_';
const STALE_TRANSIENT_KEY = 'speed_score_stale_marker';
/**
* Limit the number of recent records to keep.
*/
const LIMIT = 20;
/**
* The list of history entries.
*
* @var array
*/
private $entries;
/**
* URL of the site the speed score belongs to.
*
* @var string
*/
private $url;
/**
* Speed_Score_History constructor.
*
* @param string $url URL of the site the speed scores belong to.
*/
public function __construct( $url ) {
$this->url = $url;
$this->entries = get_option( $this->get_option_name(), array() );
}
/**
* Determine the option_name used to store the speed score data in options table.
*
* @return string
*/
private function get_option_name() {
return static::OPTION_PREFIX . Speed_Score_Request::generate_cache_id_from_url( $this->url );
}
/**
* Get the number of history records currently stored.
*
* @return int
*/
public function count() {
return count( $this->entries );
}
/**
* Find the latest available speed score history record.
*
* @param int $offset Instead of receiving the last one, you can use offset to receive a slightly older speed score.
*
* @return array|null
*/
public function latest( $offset = 0 ) {
$index = $this->count() - ( $offset + 1 );
if ( $index >= 0 ) {
return $this->entries[ $index ];
}
return null;
}
/**
* Find the latest available speed scores.
*
* @param int $offset Instead of receiving the last one, you can use offset to receive a slightly older speed score.
*
* @return array|null
*/
public function latest_scores( $offset = 0 ) {
$index = $this->count() - ( $offset + 1 );
if ( $index >= 0 ) {
return $this->entries[ $index ]['scores'];
}
return null;
}
/**
* Get a timestamp that marks previous history stale.
*
* All speed score before this timestamp are considered stale.
*
* @return array
*/
public static function get_stale_timestamp() {
$last_stale_marker = Transient::get( static::STALE_TRANSIENT_KEY, 0 );
// Any score that is older than 24 hours or before the last stale marker is considered stale.
return max( $last_stale_marker, time() - DAY_IN_SECONDS );
}
/**
* Mark previous speed score as stale.
*
* If a there were significant changes to the site, we want to mark prior speed scores as stale.
*/
public static function mark_stale() {
Transient::set( static::STALE_TRANSIENT_KEY, time() );
}
/**
* Check if the last item in history is stale.
*/
public function is_stale() {
$last_entry = $this->latest();
if ( ! $last_entry ) {
return true;
}
return $last_entry['timestamp'] < self::get_stale_timestamp();
}
/**
* Record a new history entry for speed scores.
*
* @param array $entry The new entry to save.
*/
public function push( $entry ) {
$this->entries[] = $entry;
$this->entries = array_slice( $this->entries, - static::LIMIT );
update_option( $this->get_option_name(), $this->entries, false );
}
}
@@ -0,0 +1,328 @@
<?php
/**
* Represents a request to generate a pair of speed scores.
*
* @package automattic/jetpack-boost-speed-score
*/
namespace Automattic\Jetpack\Boost_Speed_Score;
use Automattic\Jetpack\Boost_Core\Lib\Boost_API;
use Automattic\Jetpack\Boost_Core\Lib\Cacheable;
use Automattic\Jetpack\Boost_Core\Lib\Url;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class Speed_Score_Request
*/
class Speed_Score_Request extends Cacheable {
/**
* Algorithm to use when defining a hash for the cache.
*/
const CACHE_KEY_HASH_ALGO = 'md5';
/**
* The URL to get the Speed Scores for.
*
* @var string $url Url to get the Speed Scores for.
*/
private $url;
/**
* Active Jetpack Boost modules.
*
* @var string[] $active_modules Active modules.
*/
private $active_modules;
/**
* When the Speed Scores request was created, in seconds since epoch.
*
* @var float $created Speed Scores request creation timestamp.
*/
private $created;
/**
* Current status of the Speed Score request.
*
* @var string $status Speed Scores request status.
*/
private $status;
/**
* Number of retries attempted.
*
* @var int $retry_count Number of times this Speed Score request has been retried.
*/
private $retry_count;
/**
* The error returned
*
* @var array $error Speed Scores error.
*/
private $error;
/**
* Where the Speed Scores request was made from.
*
* @var string $client A string passed to Speed_Score to identify where the request was made from.
*/
private $client;
/**
* Constructor.
*
* @param string $url The URL to get the Speed Scores for.
* @param array $active_modules Active modules.
* @param null $created When the Speed Scores request was created, in seconds since epoch.
* @param string $status Status of the Speed Scores request.
* @param null $error The Speed Scores error.
* @param string $client A string identifying where the request was made from.
*/
public function __construct( $url, $active_modules = array(), $created = null, $status = 'pending', $error = null, $client = null ) {
$this->set_cache_id( self::generate_cache_id_from_url( $url ) );
$this->url = $url;
$this->active_modules = $active_modules;
$this->created = $created === null ? microtime( true ) : $created;
$this->status = $status;
$this->error = $error;
$this->client = $client;
$this->retry_count = 0;
}
/**
* Generate the cache ID from the URL.
*
* @param string $url The URL to get the Speed Scores for.
*
* @return string
*/
public static function generate_cache_id_from_url( $url ) {
return hash( self::CACHE_KEY_HASH_ALGO, $url );
}
/**
* Get the list of active performance modules while this request was created.
*
* @return string[]
*/
public function get_active_modules() {
return $this->active_modules;
}
/**
* Convert this object to a plain array for JSON serialization.
*/
#[\ReturnTypeWillChange]
public function jsonSerialize() {
return array(
'id' => $this->get_cache_id(),
'url' => $this->url,
'active_modules' => $this->get_active_modules(),
'created' => $this->created,
'status' => $this->status,
'error' => $this->error,
'client' => $this->client,
'retry_count' => $this->retry_count,
);
}
/**
* This is intended to be the reverse of JsonSerializable->jsonSerialize.
*
* @param mixed $data The data to turn into an object.
*
* @return Speed_Score_Request
*/
public static function jsonUnserialize( $data ) {
$object = new Speed_Score_Request(
$data['url'],
$data['active_modules'],
$data['created'],
$data['status'],
$data['error'],
$data['client']
);
if ( ! empty( $data['id'] ) ) {
$object->set_cache_id( $data['id'] );
}
if ( ! empty( $data['retry_count'] ) ) {
$object->retry_count = intval( $data['retry_count'] );
}
return $object;
}
/**
* Return the cache prefix.
*
* @return string
*/
protected static function cache_prefix() {
return 'jetpack_boost_speed_scores_';
}
/**
* Send a Speed Scores request to the API.
*
* @return true|\WP_Error True on success, WP_Error on failure.
*/
public function execute() {
$response = Boost_API::post(
'speed-scores',
array(
'request_id' => $this->get_cache_id(),
'url' => Url::normalize( $this->url ),
'active_modules' => $this->active_modules,
'client' => $this->client,
)
);
if ( is_wp_error( $response ) ) {
$this->status = 'error';
$this->error = $response->get_error_message();
$this->store();
return $response;
}
return true;
}
/**
* Is this request pending?
*/
public function is_pending() {
return 'pending' === $this->status;
}
/**
* Did the request fail?
*/
public function is_error() {
return 'error' === $this->status;
}
/**
* Did the request succeed?
*/
public function is_success() {
return 'success' === $this->status;
}
/**
* Poll for updates to this Speed Scores request.
*
* @return true|\WP_Error True on success, WP_Error on failure.
*/
public function poll_update() {
$response = Boost_API::get(
sprintf(
'speed-scores/%s',
$this->get_cache_id()
)
);
if ( is_wp_error( $response ) ) {
// Special case: If the request is not found, restart it.
if ( 'not_found' === $response->get_error_code() ) {
return $this->restart();
}
return $response;
}
switch ( $response['status'] ) {
case 'pending':
// The initial job probably failed, dispatch again if so.
if ( $this->created <= strtotime( '-15 mins' ) ) {
return $this->restart();
}
break;
case 'error':
$this->status = 'error';
$this->error = $response['error'];
$this->store();
break;
case 'success':
$this->status = 'success';
$this->store();
$this->record_history( $response );
break;
default:
return new \WP_Error(
'invalid_response',
__(
'Invalid response from WPCOM API while polling for speed scores',
'jetpack-boost-speed-score'
),
$response
);
}
return true;
}
/**
* Restart this request; useful when WPCOM doesn't recognize the request or it times out.
*/
private function restart() {
// Enforce a maximum number of restarts.
if ( $this->retry_count > 2 ) {
$this->status = 'error';
$this->error = 'Maximum number of retries exceeded';
$this->store();
return new \WP_Error(
'error',
$this->error
);
}
$result = $this->execute();
if ( is_wp_error( $result ) ) {
return $result;
}
$this->created = time();
++$this->retry_count;
$this->store();
return true;
}
/**
* Save the speed score record to history.
*
* @param array $response Response from api.
*/
private function record_history( $response ) {
$history = new Speed_Score_History( $this->url );
$last_history = $history->latest();
$last_scores = $last_history ? $last_history['scores'] : null;
$last_theme = $last_history ? $last_history['theme'] : null;
$current_theme = wp_get_theme()->get( 'Name' );
// Only change if there is a difference from last score or the theme changed.
if ( $last_scores !== $response['scores'] || $current_theme !== $last_theme ) {
$history->push(
array(
'timestamp' => time(),
'scores' => $response['scores'],
'theme' => $current_theme,
)
);
}
}
}
@@ -0,0 +1,365 @@
<?php
/**
* Speed Score API endpoints.
*
* @package automattic/jetpack-boost-speed-score
*/
namespace Automattic\Jetpack\Boost_Speed_Score;
use Automattic\Jetpack\Boost_Core\Lib\Utils;
if ( ! defined( 'JETPACK_BOOST_REST_NAMESPACE' ) ) {
define( 'JETPACK_BOOST_REST_NAMESPACE', 'jetpack-boost/v1' );
}
// For use in situations where you want additional namespacing.
if ( ! defined( 'JETPACK_BOOST_REST_PREFIX' ) ) {
define( 'JETPACK_BOOST_REST_PREFIX', '' );
}
/**
* Class Speed_Score
*
* @phan-constructor-used-for-side-effects
*/
class Speed_Score {
const PACKAGE_VERSION = '0.4.18';
/**
* Array of module slugs that are currently active and can impact speed score.
*
* @var string[]
*/
protected $modules;
/**
* A string representing the client making the request (e.g. 'boost-plugin', 'jetpack-dashboard', etc).
*
* @var string
*/
protected $client;
/**
* Constructor.
*
* @param string[] $modules - Array of module slugs that are currently active and can impact speed score.
* @param string $client - A string representing the client making the request.
*/
public function __construct( $modules, $client ) {
/*
* Plugins using the old version of the package may pass an object instead of an array. Converting the
* object to an array keeps it backward compatible. We will lose the module slugs in case of an object,
* but it is better than a fatal error.
*/
if ( ! is_array( $modules ) ) {
$modules = array();
}
$this->modules = $modules;
$this->client = $client;
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
add_action( 'jetpack_boost_deactivate', array( $this, 'clear_speed_score_request_cache' ) );
/**
* Mark the speed score history as stale when the environment changes.
*
* @since 0.3.9 - This hook replaced `handle_environment_change` action.
* @since $$next-version - jetpack_boost_critical_css_environment_changed has been replaced by jetpack_boost_environment_changed.
*/
add_action( 'jetpack_boost_environment_changed', array( Speed_Score_History::class, 'mark_stale' ) );
/**
* The `handle_environment_change` action is replaced by `jetpack_boost_environment_changed` in Jetpack Boost.
* Keeping the `handle_environment_change` action for backward compatibility.
*/
add_action( 'handle_environment_change', array( Speed_Score_History::class, 'mark_stale' ) );
add_action( 'jetpack_boost_deactivate', array( Speed_Score_History::class, 'mark_stale' ) );
}
/**
* Register Speed Score related REST routes.
*/
public function register_rest_routes() {
register_rest_route(
JETPACK_BOOST_REST_NAMESPACE,
JETPACK_BOOST_REST_PREFIX . '/speed-scores',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'fetch_speed_score' ),
'permission_callback' => array( $this, 'can_access_speed_scores' ),
)
);
register_rest_route(
JETPACK_BOOST_REST_NAMESPACE,
JETPACK_BOOST_REST_PREFIX . '/speed-scores/refresh',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'dispatch_speed_score_request' ),
'permission_callback' => array( $this, 'can_access_speed_scores' ),
)
);
register_rest_route(
JETPACK_BOOST_REST_NAMESPACE,
JETPACK_BOOST_REST_PREFIX . '/speed-scores-history',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'dispatch_speed_score_graph_history_request' ),
'permission_callback' => array( $this, 'can_access_speed_scores' ),
'args' => array(
'start' => array(
'required' => true,
'type' => 'number',
),
'end' => array(
'required' => true,
'type' => 'number',
),
),
)
);
}
/**
* Verify and normalize the URL argument for a request.
*
* @param \WP_REST_Request $request The request object.
*
* @return string|\WP_Error An error to return or the target url.
*/
private function process_url_arg( $request ) {
$params = $request->get_json_params();
if ( ! isset( $params['url'] ) ) {
return new \WP_Error(
'invalid_parameter',
__(
'The url parameter is required',
'jetpack-boost-speed-score'
),
array( 'status' => 400 )
);
}
return Utils::force_url_to_absolute( $params['url'] );
}
/**
* Handler for POST /speed-scores/refresh.
*
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response|\WP_Error The response.
*/
public function dispatch_speed_score_request( $request ) {
$url = $this->process_url_arg( $request );
if ( is_wp_error( $url ) ) {
return $url;
}
// Create and store the Speed Score request.
$score_request = new Speed_Score_Request( $url, $this->modules, null, 'pending', null, $this->client );
$score_request->store( 1800 ); // Keep the request for 30 minutes even if no one access the results.
// Send the request.
$score_request->execute();
$score_request_no_boost = $this->maybe_dispatch_no_boost_score_request( $url );
return $this->prepare_speed_score_response( $url, $score_request, $score_request_no_boost );
}
/**
* Handler for POST /speed-scores-history.
*
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response|\WP_Error The response.
*/
public function dispatch_speed_score_graph_history_request( $request ) {
$score_history_request = new Speed_Score_Graph_History_Request( $request->get_param( 'start' ), $request->get_param( 'end' ), array() );
// Send the request.
return $score_history_request->execute();
}
/**
* Remove the string "jb-disable-module" from array of strings.
*
* This is intended to be used by the filter `jetpack_boost_excluded_query_parameters` to allow `jb-disable-module` url parameter during score requests.
*
* @param string[] $params List of parameters to be removed from url.
*
* @return string[] Revised list of parameters to remove from url.
*/
public function allow_jb_disable_module( $params ) {
$index = array_search( 'jb-disable-modules', $params, true );
unset( $params[ $index ] );
return $params;
}
/**
* Handler for POST /speed-scores.
*
* @param \WP_REST_Request $request The request object.
*
* @return \WP_REST_Response|\WP_Error The response.
*/
public function fetch_speed_score( $request ) {
$url = $this->process_url_arg( $request );
if ( is_wp_error( $url ) ) {
return $url;
}
// Poll update if there is an ongoing request for scores with boost disabled.
$url_no_boost = $this->get_boost_modules_disabled_url( $url );
$score_request_no_boost = $this->get_score_request_by_url( $url_no_boost );
if ( $score_request_no_boost && $score_request_no_boost->is_pending() ) {
$response = $score_request_no_boost->poll_update();
if ( is_wp_error( $response ) ) {
return $response;
}
}
// Poll update if there is an ongoing request for scores with boost enabled.
$score_request = $this->get_score_request_by_url( $url );
if ( $score_request && $score_request->is_pending() ) {
$response = $score_request->poll_update();
if ( is_wp_error( $response ) ) {
return $response;
}
}
// If this is a fresh install, there might not be any speed score history. In which case, we want to fetch the initial scores.
// While updating plugin from 1.2 -> 1.3, the history will be missing along with a non-pending score request due to data structure change.
$history = new Speed_Score_History( $url );
if ( null === $history->latest_scores() && ( empty( $score_request ) || ! $score_request->is_pending() ) ) {
return $this->dispatch_speed_score_request( $request );
}
return $this->prepare_speed_score_response( $url, $score_request, $score_request_no_boost );
}
/**
* If it is time to fetch the score without boost, fetch it.
*
* @param string $url Url of the site.
*
* @return Speed_Score_Request
*/
private function maybe_dispatch_no_boost_score_request( $url ) {
// Allow `jb-disable-module` URL param to fetch score without boost modules being active.
add_filter( 'jetpack_boost_excluded_query_parameters', array( $this, 'allow_jb_disable_module' ) );
$url_no_boost = $this->get_boost_modules_disabled_url( $url );
$history = new Speed_Score_History( $url_no_boost );
$score_request = $this->get_score_request_by_url( $url_no_boost );
if (
// If there isn't already a pending request.
( empty( $score_request ) || ! $score_request->is_pending() )
&& ! empty( $this->modules )
&& $history->is_stale()
) {
$score_request = new Speed_Score_Request( $url_no_boost, array(), null, 'pending', null, $this->client ); // Dispatch a new speed score request to measure score without boost.
$score_request->store( 3600 ); // Keep the request for 1 hour even if no one access the results. The value is persisted for 1 hour in wp.com from initial request.
// Send the request.
$score_request->execute();
}
remove_filter( 'jetpack_boost_excluded_query_parameters', array( $this, 'allow_jb_disable_module' ) );
return $score_request;
}
/**
* Get Speed_Score_Request instance by url.
*
* @param string $url Url to get the Speed_Score_Request instance for.
*
* @return Speed_Score_Request
*/
private function get_score_request_by_url( $url ) {
return Speed_Score_Request::get(
Speed_Score_Request::generate_cache_id_from_url( $url )
);
}
/**
* Add query parameters to the url that would disable all boost modules.
*
* @param string $url The original URL we are measuring for score.
*
* @return string
*/
private function get_boost_modules_disabled_url( $url ) {
return add_query_arg( 'jb-disable-modules', 'all', $url );
}
/**
* Can the user access speed scores?
*
* @return bool
*/
public function can_access_speed_scores() {
return current_user_can( 'manage_options' );
}
/**
* Clear speed score request cache on jetpack_boost_deactivate action.
*/
public function clear_speed_score_request_cache() {
Speed_Score_Request::clear_cache();
}
/**
* Prepare the speed score response.
*
* @param string $url URL of the speed is requested for.
* @param Speed_Score_Request $score_request Speed score request.
* @param Speed_Score_Request $score_request_no_boost Speed score request without boost enabled.
*
* @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response
*/
private function prepare_speed_score_response( $url, $score_request, $score_request_no_boost ) {
$history = new Speed_Score_History( $url );
$url_no_boost = $this->get_boost_modules_disabled_url( $url );
$history_no_boost = new Speed_Score_History( $url_no_boost );
$response = array();
if ( ( ! $score_request || $score_request->is_success() ) && ( ! $score_request_no_boost || $score_request_no_boost->is_success() ) ) {
$response['status'] = 'success';
$response['scores'] = array(
'current' => $history->latest_scores(),
'noBoost' => null,
);
// Only include noBoost scores if at least one module is enabled.
if ( $score_request && ! empty( $score_request->get_active_modules() ) ) {
$response['scores']['noBoost'] = $history_no_boost->latest_scores();
}
$response['scores']['isStale'] = $history->is_stale();
} elseif ( ( $score_request && $score_request->is_error() ) || ( $score_request_no_boost && $score_request_no_boost->is_error() ) ) {
// If either request ended up in error, we can just return the one with error so front-end can take action. The relevent url is available on the serialized object.
if ( $score_request->is_error() ) {
// Serialized version of score request contains the status property and error description if any.
$response = $score_request->jsonSerialize();
} else {
$response = $score_request_no_boost->jsonSerialize();
}
} else {
// If no request ended up in error/success as previous conditions dictate, it means that either of them are in pending state.
$response['status'] = 'pending';
}
return rest_ensure_response( $response );
}
}
@@ -0,0 +1,286 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.1.3] - 2026-05-19
### Changed
- Internal updates.
## [3.1.2] - 2026-05-04
### Changed
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
## [3.1.1] - 2025-06-19
### Changed
- Internal updates.
## [3.1.0] - 2025-03-26
### Added
- Adds Account Protection initialization [#40925]
## [3.0.1] - 2025-02-24
### Changed
- Update dependencies.
## [3.0.0] - 2024-11-14
### Removed
- General: Update minimum PHP version to 7.2. [#40147]
## [2.0.4] - 2024-06-24
### Changed
- Internal updates.
## [2.0.3] - 2024-06-03
### Removed
- Remove the Identity Crisis package dev dependency. [#37654]
## [2.0.2] - 2024-05-06
### Changed
- Internal updates.
## [2.0.1] - 2024-03-14
### Changed
- Internal updates.
## [2.0.0] - 2023-11-20
### Changed
- Updated required PHP version to >= 7.0. [#34192]
## [1.15.4] - 2023-09-19
- Minor internal updates.
## [1.15.3] - 2023-06-26
### Changed
- Blaze can now be loaded as a module, instead of relying on the Config package. [#31479]
## [1.15.2] - 2023-04-10
### Added
- Add Jetpack Autoloader package suggestion. [#29988]
## [1.15.1] - 2023-03-28
### Changed
- Minor internal updates.
## [1.15.0] - 2023-03-27
### Added
- Initialize yoast promo package in jetpack plugin [#29641]
## [1.14.0] - 2023-02-20
### Added
- Added the Import package. [#28824]
## [1.13.0] - 2023-01-02
### Added
- Blaze package: Add config initialization, initialization checks for loading. [#28077]
## [1.12.0] - 2022-12-12
### Added
- Config: add option to init stats-admin [#27565]
## [1.11.1] - 2022-11-22
### Changed
- Updated package dependencies. [#27043]
## [1.11.0] - 2022-10-11
### Changed
- Integrate Stats package in Jetpack plugin [#26640]
## [1.10.0] - 2022-09-27
### Added
- Social: Added the option to configure if the plan information should be refreshed as the package is enabled. [#26294]
## [1.9.6] - 2022-08-26
### Changed
- Call ensure_options_$feature methods before the initialization
## [1.9.5] - 2022-08-23
### Changed
- Initialize VideoPress admin UI from the package [#25692]
## [1.9.4] - 2022-08-09
### Changed
- Initialize VideoPress package from the Config pkg [#25385]
## [1.9.3] - 2022-07-26
### Changed
- Updated package dependencies. [#25158]
## [1.9.2] - 2022-06-29
- Updated package dependencies.
## [1.9.1] - 2022-06-21
### Changed
- Renaming master to trunk.
## [1.9.0] - 2022-05-18
### Added
- Configuration for waf package [#24153]
## [1.8.0] - 2022-04-26
### Added
- Added the publicize package to be configured via the config package.
### Changed
- Updated package dependencies.
## [1.7.2] - 2022-04-19
### Added
- Enable WordAds from Config class
## [1.7.1] - 2022-04-06
### Removed
- Removed tracking dependency.
## [1.7.0] - 2022-03-23
### Added
- Search: added search initialization
## [1.6.1] - 2022-02-09
### Added
- Allow sync package consumers to provide custom data settings.
## [1.6.0] - 2022-01-04
### Added
- Accept options for the IDC package.
### Changed
- Updated package textdomain from `jetpack` to `jetpack-config`.
## [1.5.4] - 2021-12-14
### Changed
- Updated package dependencies.
## [1.5.3] - 2021-10-13
### Changed
- Updated package dependencies.
## [1.5.2] - 2021-10-12
### Added
- Add support for the identity-crisis package
## [1.5.1] - 2021-09-28
### Changed
- Updated package dependencies.
## [1.5.0] - 2021-09-22
### Added
- Allow for enabling and initializing new Post_List package from Config package.
## [1.4.7] - 2021-08-31
### Changed
- updates annotations versions.
## [1.4.6] - 2021-05-25
### Changed
- Updated package dependencies.
## [1.4.5] - 2021-04-27
### Changed
- Updated package dependencies.
## [1.4.4] - 2021-03-30
### Added
- Composer alias for dev-master, to improve dependencies
### Changed
- Update package dependencies.
## [1.4.3] - 2021-01-19
- Add mirror-repo information to all current composer packages
- Monorepo: Reorganize all projects
## [1.4.2] - 2020-10-28
- Updated PHPCS: Packages and Debugger
- Config: remove tos and tracking features
- Config: add info about the package dependencies to the package docs
## [1.4.1] - 2020-09-15
- Config: remove tos and tracking features
## [1.4.0] - 2020-08-26
- Config: Remove composer dependencies
- Config: Add connection status check
## [1.3.0] - 2020-06-26
- Config: check for both JITM namespaces
## [1.2.0] - 2020-05-20
- Store the list of active plugins that uses connection in an option
- Implement pre-connection JITMs
- Connection Package: Handle disconnections gracefully
## [1.1.0] - 2020-01-23
- Moved JITM initialization to plugins_loaded.
## [1.0.1] - 2020-01-20
- Move connection manager related logic to after plugins_loaded.
## 1.0.0 - 2020-01-14
- Trying to add deterministic initialization.
[3.1.3]: https://github.com/Automattic/jetpack-config/compare/v3.1.2...v3.1.3
[3.1.2]: https://github.com/Automattic/jetpack-config/compare/v3.1.1...v3.1.2
[3.1.1]: https://github.com/Automattic/jetpack-config/compare/v3.1.0...v3.1.1
[3.1.0]: https://github.com/Automattic/jetpack-config/compare/v3.0.1...v3.1.0
[3.0.1]: https://github.com/Automattic/jetpack-config/compare/v3.0.0...v3.0.1
[3.0.0]: https://github.com/Automattic/jetpack-config/compare/v2.0.4...v3.0.0
[2.0.4]: https://github.com/Automattic/jetpack-config/compare/v2.0.3...v2.0.4
[2.0.3]: https://github.com/Automattic/jetpack-config/compare/v2.0.2...v2.0.3
[2.0.2]: https://github.com/Automattic/jetpack-config/compare/v2.0.1...v2.0.2
[2.0.1]: https://github.com/Automattic/jetpack-config/compare/v2.0.0...v2.0.1
[2.0.0]: https://github.com/Automattic/jetpack-config/compare/v1.15.4...v2.0.0
[1.15.4]: https://github.com/Automattic/jetpack-config/compare/v1.15.3...v1.15.4
[1.15.3]: https://github.com/Automattic/jetpack-config/compare/v1.15.2...v1.15.3
[1.15.2]: https://github.com/Automattic/jetpack-config/compare/v1.15.1...v1.15.2
[1.15.1]: https://github.com/Automattic/jetpack-config/compare/v1.15.0...v1.15.1
[1.15.0]: https://github.com/Automattic/jetpack-config/compare/v1.14.0...v1.15.0
[1.14.0]: https://github.com/Automattic/jetpack-config/compare/v1.13.0...v1.14.0
[1.13.0]: https://github.com/Automattic/jetpack-config/compare/v1.12.0...v1.13.0
[1.12.0]: https://github.com/Automattic/jetpack-config/compare/v1.11.1...v1.12.0
[1.11.1]: https://github.com/Automattic/jetpack-config/compare/v1.11.0...v1.11.1
[1.11.0]: https://github.com/Automattic/jetpack-config/compare/v1.10.0...v1.11.0
[1.10.0]: https://github.com/Automattic/jetpack-config/compare/v1.9.6...v1.10.0
[1.9.6]: https://github.com/Automattic/jetpack-config/compare/v1.9.5...v1.9.6
[1.9.5]: https://github.com/Automattic/jetpack-config/compare/v1.9.4...v1.9.5
[1.9.4]: https://github.com/Automattic/jetpack-config/compare/v1.9.3...v1.9.4
[1.9.3]: https://github.com/Automattic/jetpack-config/compare/v1.9.2...v1.9.3
[1.9.2]: https://github.com/Automattic/jetpack-config/compare/v1.9.1...v1.9.2
[1.9.1]: https://github.com/Automattic/jetpack-config/compare/v1.9.0...v1.9.1
[1.9.0]: https://github.com/Automattic/jetpack-config/compare/v1.8.0...v1.9.0
[1.8.0]: https://github.com/Automattic/jetpack-config/compare/v1.7.2...v1.8.0
[1.7.2]: https://github.com/Automattic/jetpack-config/compare/v1.7.1...v1.7.2
[1.7.1]: https://github.com/Automattic/jetpack-config/compare/v1.7.0...v1.7.1
[1.7.0]: https://github.com/Automattic/jetpack-config/compare/v1.6.1...v1.7.0
[1.6.1]: https://github.com/Automattic/jetpack-config/compare/v1.6.0...v1.6.1
[1.6.0]: https://github.com/Automattic/jetpack-config/compare/v1.5.4...v1.6.0
[1.5.4]: https://github.com/Automattic/jetpack-config/compare/v1.5.3...v1.5.4
[1.5.3]: https://github.com/Automattic/jetpack-config/compare/v1.5.2...v1.5.3
[1.5.2]: https://github.com/Automattic/jetpack-config/compare/v1.5.1...v1.5.2
[1.5.1]: https://github.com/Automattic/jetpack-config/compare/v1.5.0...v1.5.1
[1.5.0]: https://github.com/Automattic/jetpack-config/compare/v1.4.7...v1.5.0
[1.4.7]: https://github.com/Automattic/jetpack-config/compare/v1.4.6...v1.4.7
[1.4.6]: https://github.com/Automattic/jetpack-config/compare/v1.4.5...v1.4.6
[1.4.5]: https://github.com/Automattic/jetpack-config/compare/v1.4.4...v1.4.5
[1.4.4]: https://github.com/Automattic/jetpack-config/compare/v1.4.3...v1.4.4
[1.4.3]: https://github.com/Automattic/jetpack-config/compare/v1.4.2...v1.4.3
[1.4.2]: https://github.com/Automattic/jetpack-config/compare/v1.4.1...v1.4.2
[1.4.1]: https://github.com/Automattic/jetpack-config/compare/v1.4.0...v1.4.1
[1.4.0]: https://github.com/Automattic/jetpack-config/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/Automattic/jetpack-config/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/Automattic/jetpack-config/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/Automattic/jetpack-config/compare/v1.0.1...v1.1.0
[1.0.1]: https://github.com/Automattic/jetpack-config/compare/v1.0.0...v1.0.1
@@ -0,0 +1,458 @@
<?php
/**
* The base Jetpack configuration class file.
*
* @package automattic/jetpack-config
*/
namespace Automattic\Jetpack;
/*
* The Config package does not require the composer packages that
* contain the package classes shown below. The consumer plugin
* must require the corresponding packages to use these features.
*/
use Automattic\Jetpack\Account_Protection\Account_Protection as Jetpack_Account_Protection_Main;
use Automattic\Jetpack\Connection\Manager;
use Automattic\Jetpack\Connection\Plugin;
use Automattic\Jetpack\Import\Main as Import_Main;
use Automattic\Jetpack\JITMS\JITM as JITMS_JITM;
use Automattic\Jetpack\Post_List\Post_List;
use Automattic\Jetpack\Publicize\Publicize_Setup;
use Automattic\Jetpack\Search\Initializer as Jetpack_Search_Main;
use Automattic\Jetpack\Stats\Main as Stats_Main;
use Automattic\Jetpack\Stats_Admin\Main as Stats_Admin_Main;
use Automattic\Jetpack\Sync\Main as Sync_Main;
use Automattic\Jetpack\VideoPress\Initializer as VideoPress_Pkg_Initializer;
use Automattic\Jetpack\Waf\Waf_Initializer as Jetpack_Waf_Main;
/**
* The configuration class.
*/
class Config {
const FEATURE_ENSURED = 1;
const FEATURE_NOT_AVAILABLE = 0;
const FEATURE_ALREADY_ENSURED = -1;
/**
* The initial setting values.
*
* @var Array
*/
protected $config = array(
'jitm' => false,
'connection' => false,
'sync' => false,
'post_list' => false,
'identity_crisis' => false,
'search' => false,
'publicize' => false,
'account_protection' => false,
'waf' => false,
'videopress' => false,
'stats' => false,
'stats_admin' => false,
'yoast_promo' => false,
'import' => false,
);
/**
* Initialization options stored here.
*
* @var array
*/
protected $feature_options = array();
/**
* Creates the configuration class instance.
*/
public function __construct() {
/**
* Adding the config handler to run on priority 2 because the class itself is
* being constructed on priority 1.
*/
add_action( 'plugins_loaded', array( $this, 'on_plugins_loaded' ), 2 );
}
/**
* Require a feature to be initialized. It's up to the package consumer to actually add
* the package to their composer project. Declaring a requirement using this method
* instructs the class to initialize it.
*
* Run the options method (if exists) every time the method is called.
*
* @param String $feature the feature slug.
* @param array $options Additional options, optional.
*/
public function ensure( $feature, array $options = array() ) {
$this->config[ $feature ] = true;
$this->set_feature_options( $feature, $options );
$method_options = 'ensure_options_' . $feature;
if ( method_exists( $this, $method_options ) ) {
$this->{ $method_options }();
}
}
/**
* Runs on plugins_loaded hook priority with priority 2.
*
* @action plugins_loaded
*/
public function on_plugins_loaded() {
if ( $this->config['connection'] ) {
$this->ensure_class( 'Automattic\Jetpack\Connection\Manager' )
&& $this->ensure_feature( 'connection' );
}
if ( $this->config['sync'] ) {
$this->ensure_class( 'Automattic\Jetpack\Sync\Main' )
&& $this->ensure_feature( 'sync' );
}
if ( $this->config['jitm'] ) {
// Check for the JITM class in both namespaces. The namespace was changed in jetpack-jitm v1.6.
( $this->ensure_class( 'Automattic\Jetpack\JITMS\JITM', false )
|| $this->ensure_class( 'Automattic\Jetpack\JITM' ) )
&& $this->ensure_feature( 'jitm' );
}
if ( $this->config['post_list'] ) {
$this->ensure_class( 'Automattic\Jetpack\Post_List\Post_List' )
&& $this->ensure_feature( 'post_list' );
}
if ( $this->config['identity_crisis'] ) {
$this->ensure_class( 'Automattic\Jetpack\Identity_Crisis' )
&& $this->ensure_feature( 'identity_crisis' );
}
if ( $this->config['search'] ) {
$this->ensure_class( 'Automattic\Jetpack\Search\Initializer' )
&& $this->ensure_feature( 'search' );
}
if ( $this->config['publicize'] ) {
$this->ensure_class( 'Automattic\Jetpack\Publicize\Publicize_UI' ) && $this->ensure_class( 'Automattic\Jetpack\Publicize\Publicize' )
&& $this->ensure_feature( 'publicize' );
}
if ( $this->config['account_protection'] ) {
$this->ensure_class( 'Automattic\Jetpack\Account_Protection\Account_Protection' )
&& $this->ensure_feature( 'account_protection' );
}
if ( $this->config['waf'] ) {
$this->ensure_class( 'Automattic\Jetpack\Waf\Waf_Initializer' )
&& $this->ensure_feature( 'waf' );
}
if ( $this->config['videopress'] ) {
$this->ensure_class( 'Automattic\Jetpack\VideoPress\Initializer' ) && $this->ensure_feature( 'videopress' );
}
if ( $this->config['stats'] ) {
$this->ensure_class( 'Automattic\Jetpack\Stats\Main' ) && $this->ensure_feature( 'stats' );
}
if ( $this->config['stats_admin'] ) {
$this->ensure_class( 'Automattic\Jetpack\Stats_Admin\Main' ) && $this->ensure_feature( 'stats_admin' );
}
if ( $this->config['yoast_promo'] ) {
$this->ensure_class( 'Automattic\Jetpack\Yoast_Promo' ) && $this->ensure_feature( 'yoast_promo' );
}
if ( $this->config['import'] ) {
$this->ensure_class( 'Automattic\Jetpack\Import\Main' )
&& $this->ensure_feature( 'import' );
}
}
/**
* Returns true if the required class is available and alerts the user if it's not available
* in case the site is in debug mode.
*
* @param String $classname a fully qualified class name.
* @param Boolean $log_notice whether the E_USER_NOTICE should be generated if the class is not found.
*
* @return Boolean whether the class is available.
*/
protected function ensure_class( $classname, $log_notice = true ) {
$available = class_exists( $classname );
if ( $log_notice && ! $available && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
trigger_error( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
sprintf(
/* translators: %1$s is a PHP class name. */
esc_html__(
'Unable to load class %1$s. Please add the package that contains it using composer and make sure you are requiring the Jetpack autoloader',
'jetpack-config'
),
esc_html( $classname )
),
E_USER_NOTICE
);
}
return $available;
}
/**
* Ensures a feature is enabled, sets it up if it hasn't already been set up.
*
* @param String $feature slug of the feature.
* @return Integer either FEATURE_ENSURED, FEATURE_ALREADY_ENSURED or FEATURE_NOT_AVAILABLE constants.
*/
protected function ensure_feature( $feature ) {
$method = 'enable_' . $feature;
if ( ! method_exists( $this, $method ) ) {
return self::FEATURE_NOT_AVAILABLE;
}
if ( did_action( 'jetpack_feature_' . $feature . '_enabled' ) ) {
return self::FEATURE_ALREADY_ENSURED;
}
$this->{ $method }();
/**
* Fires when a specific Jetpack package feature is initalized using the Config package.
*
* @since 1.1.0
*/
do_action( 'jetpack_feature_' . $feature . '_enabled' );
return self::FEATURE_ENSURED;
}
/**
* Enables the JITM feature.
*/
protected function enable_jitm() {
if ( class_exists( 'Automattic\Jetpack\JITMS\JITM' ) ) {
JITMS_JITM::configure();
} else {
// Provides compatibility with jetpack-jitm <v1.6.
// @phan-suppress-next-line PhanUndeclaredClassMethod
JITM::configure();
}
return true;
}
/**
* Enables the Post_List feature.
*/
protected function enable_post_list() {
Post_List::configure();
return true;
}
/**
* Enables the Sync feature.
*/
protected function enable_sync() {
Sync_Main::configure();
return true;
}
/**
* Enables the Connection feature.
*/
protected function enable_connection() {
Manager::configure();
return true;
}
/**
* Enables the identity-crisis feature.
*/
protected function enable_identity_crisis() {
Identity_Crisis::init();
}
/**
* Enables the search feature.
*/
protected function enable_search() {
Jetpack_Search_Main::init();
}
/**
* Enables the Publicize feature.
*/
protected function enable_publicize() {
Publicize_Setup::configure();
return true;
}
/**
* Handles Publicize options.
*/
protected function ensure_options_publicize() {
$options = $this->get_feature_options( 'publicize' );
if ( ! empty( $options['force_refresh'] ) ) {
Publicize_Setup::$refresh_plan_info = true;
}
}
/**
* Enables Account Protection.
*/
protected function enable_account_protection() {
$account_protection = Jetpack_Account_Protection_Main::instance();
$account_protection->initialize();
return true;
}
/**
* Enables Waf.
*/
protected function enable_waf() {
Jetpack_Waf_Main::init();
return true;
}
/**
* Enables VideoPress.
*/
protected function enable_videopress() {
VideoPress_Pkg_Initializer::init();
return true;
}
/**
* Enables Stats.
*/
protected function enable_stats() {
Stats_Main::init();
return true;
}
/**
* Enables Stats Admin.
*/
protected function enable_stats_admin() {
Stats_Admin_Main::init();
return true;
}
/**
* Handles VideoPress options
*/
protected function ensure_options_videopress() {
$options = $this->get_feature_options( 'videopress' );
if ( ! empty( $options ) ) {
VideoPress_Pkg_Initializer::update_init_options( $options );
}
return true;
}
/**
* Enables Yoast Promo.
*/
protected function enable_yoast_promo() {
Yoast_Promo::init();
return true;
}
/**
* Enables the Import feature.
*/
protected function enable_import() {
Import_Main::configure();
return true;
}
/**
* Setup the Connection options.
*/
protected function ensure_options_connection() {
$options = $this->get_feature_options( 'connection' );
if ( ! empty( $options['slug'] ) ) {
// The `slug` and `name` are removed from the options because they need to be passed as arguments.
$slug = $options['slug'];
unset( $options['slug'] );
$name = $slug;
if ( ! empty( $options['name'] ) ) {
$name = $options['name'];
unset( $options['name'] );
}
( new Plugin( $slug ) )->add( $name, $options );
}
return true;
}
/**
* Setup the Identity Crisis options.
*
* @return bool
*/
protected function ensure_options_identity_crisis() {
$options = $this->get_feature_options( 'identity_crisis' );
if ( is_array( $options ) && count( $options ) ) {
add_filter(
'jetpack_idc_consumers',
function ( $consumers ) use ( $options ) {
$consumers[] = $options;
return $consumers;
}
);
}
return true;
}
/**
* Setup the Sync options.
*/
protected function ensure_options_sync() {
$options = $this->get_feature_options( 'sync' );
if ( method_exists( 'Automattic\Jetpack\Sync\Main', 'set_sync_data_options' ) ) {
Sync_Main::set_sync_data_options( $options );
}
return true;
}
/**
* Temporary save initialization options for a feature.
*
* @param string $feature The feature slug.
* @param array $options The options.
*
* @return bool
*/
protected function set_feature_options( $feature, array $options ) {
if ( $options ) {
$this->feature_options[ $feature ] = $options;
}
return true;
}
/**
* Get initialization options for a feature from the temporary storage.
*
* @param string $feature The feature slug.
*
* @return array
*/
protected function get_feature_options( $feature ) {
return empty( $this->feature_options[ $feature ] ) ? array() : $this->feature_options[ $feature ];
}
}
@@ -0,0 +1,23 @@
<?php
/**
* Action Hooks for Jetpack connection assets.
*
* @package automattic/jetpack-connection
*/
// If WordPress's plugin API is available already, use it. If not,
// drop data into `$wp_filter` for `WP_Hook::build_preinitialized_hooks()`.
if ( function_exists( 'add_action' ) ) {
add_action(
'plugins_loaded',
array( Automattic\Jetpack\Connection\Connection_Assets::class, 'configure' ),
1
);
} else {
global $wp_filter;
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$wp_filter['plugins_loaded'][1][] = array(
'accepted_args' => 0,
'function' => array( Automattic\Jetpack\Connection\Connection_Assets::class, 'configure' ),
);
}
@@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-i18n', 'wp-polyfill', 'wp-url'), 'version' => '985abd39a545d956cbb2');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
<svg width="380" height="183" viewBox="0 0 380 183" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.75" filter="url(#filter0_f_5953_11924)">
<path d="M360 -60C360 7.93102 304.931 63 237 63C169.069 63 114 7.93102 114 -60C114 -127.931 169.069 -183 237 -183C304.931 -183 360 -127.931 360 -60Z" fill="url(#paint0_linear_5953_11924)"/>
</g>
<defs>
<filter id="filter0_f_5953_11924" x="-6" y="-303" width="486" height="486" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_5953_11924"/>
</filter>
<linearGradient id="paint0_linear_5953_11924" x1="360" y1="-60" x2="114" y2="-60" gradientUnits="userSpaceOnUse">
<stop stop-color="#48FF50"/>
<stop offset="0.471846" stop-color="#108642"/>
<stop offset="1" stop-color="#2E38FA"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 990 B

@@ -0,0 +1 @@
<?php return array('dependencies' => array('jetpack-script-data', 'react', 'react-jsx-runtime', 'wp-a11y', 'wp-components', 'wp-data', 'wp-date', 'wp-element', 'wp-i18n', 'wp-polyfill', 'wp-primitives', 'wp-url'), 'version' => 'b16d63ffff51bb15c0b3');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => 'a3e26c4e5b7f15f38c40');
@@ -0,0 +1 @@
.jetpack-sso-admin-create-user-invite-message{width:550px}.jetpack-sso-admin-create-user-invite-message-link-sso{text-decoration:none}#createuser .form-field textarea{width:25em}#createuser .form-field [type=checkbox]{width:1rem}#custom_email_message_description{color:#646970;font-size:12px;max-width:25rem}
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",function(){const e=document.getElementById("send_user_notification"),d=document.getElementById("user_external_contractor"),t=document.getElementById("invite_user_wpcom"),n=document.getElementById("custom_email_message_block");t&&e&&n&&(t.addEventListener("change",function(){e.disabled=t.checked,t.checked?(e.checked=!1,d&&(d.disabled=!1),n.style.display="table"):(d&&(d.disabled=!0,d.checked=!1),n.style.display="none")}),t.checked&&(e.disabled=!0,e.checked=!1,n.style.display="table"),t.checked||(d&&(d.disabled=!0),n.style.display="none"))});
@@ -0,0 +1 @@
.jetpack-sso-admin-create-user-invite-message{width:550px}.jetpack-sso-admin-create-user-invite-message-link-sso{text-decoration:none}#createuser .form-field textarea{width:25em}#createuser .form-field [type=checkbox]{width:1rem}#custom_email_message_description{color:#646970;font-size:12px;max-width:25rem}
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => '0a84fe0bec6dbd6ec0ac');
@@ -0,0 +1 @@
#loginform{padding-bottom:92px;position:relative!important}.jetpack-sso-repositioned #loginform{padding-bottom:26px}#loginform :is(#jetpack-sso-wrap,#jetpack-sso-wrap *){box-sizing:border-box}#jetpack-sso-wrap__action,#jetpack-sso-wrap__user{display:none}.jetpack-sso-form-display :is(#jetpack-sso-wrap__action,#jetpack-sso-wrap__user){display:block}#jetpack-sso-wrap{bottom:20px;margin-left:-24px;margin-right:-24px;padding:0 24px;position:absolute;width:100%}.jetpack-sso-repositioned #jetpack-sso-wrap{bottom:auto;margin-left:0;margin-right:0;margin-top:16px;padding:0;position:relative}.jetpack-sso-form-display #jetpack-sso-wrap{bottom:auto;margin-left:0;margin-right:0;margin-top:0;padding:0;position:relative}#loginform #jetpack-sso-wrap p{color:#777;margin-bottom:16px}#jetpack-sso-wrap a{display:block;text-align:center;text-decoration:none;width:100%}#jetpack-sso-wrap .jetpack-sso-toggle.wpcom{display:none}.jetpack-sso-form-display #jetpack-sso-wrap .jetpack-sso-toggle.wpcom{display:block}.jetpack-sso-form-display #jetpack-sso-wrap .jetpack-sso-toggle.default,.jetpack-sso-form-display #loginform>:is(div,p){display:none}.jetpack-sso-form-display #loginform #jetpack-sso-wrap{display:block}.jetpack-sso-form-display #loginform{padding:26px 24px}.jetpack-sso-or{margin-bottom:16px;position:relative;text-align:center}.jetpack-sso-or:before{background:#dcdcde;content:"";height:1px;left:0;position:absolute;top:50%;width:100%}.jetpack-sso-or span{background:#fff;color:#777;padding:0 8px;position:relative;text-transform:uppercase}#jetpack-sso-wrap .button{align-items:center;display:flex;height:36px;justify-content:center;margin-bottom:16px;width:100%}#jetpack-sso-wrap .button .genericon-wordpress{font-size:24px;margin-right:4px}#jetpack-sso-wrap__user img{border-radius:50%;display:block;margin:0 auto 16px}#jetpack-sso-wrap__user h2{font-size:21px;font-weight:300;margin-bottom:16px;text-align:center}#jetpack-sso-wrap__user h2 span{font-weight:700}.jetpack-sso-wrap__reauth{margin-bottom:16px}.jetpack-sso-form-display #nav{display:none}.jetpack-sso-form-display #backtoblog{margin:24px 0 0}.jetpack-sso-clear:after{clear:both;content:"";display:table}
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",()=>{const e=document.querySelector("body"),t=document.querySelector(".jetpack-sso-toggle"),d=document.getElementById("user_login"),o=document.getElementById("user_pass"),s=document.getElementById("jetpack-sso-wrap"),n=document.getElementById("loginform"),c=document.createElement("div");c.className="jetpack-sso-clear",n.appendChild(c),c.appendChild(document.querySelector("p.forgetmenot")),c.appendChild(document.querySelector("p.submit")),n.appendChild(s),e.classList.add("jetpack-sso-repositioned"),t.addEventListener("click",t=>{t.preventDefault(),e.classList.toggle("jetpack-sso-form-display"),e.classList.contains("jetpack-sso-form-display")||(d.focus(),o.disabled=!1)})});
@@ -0,0 +1 @@
#loginform{padding-bottom:92px;position:relative!important}.jetpack-sso-repositioned #loginform{padding-bottom:26px}#loginform :is(#jetpack-sso-wrap,#jetpack-sso-wrap *){box-sizing:border-box}#jetpack-sso-wrap__action,#jetpack-sso-wrap__user{display:none}.jetpack-sso-form-display :is(#jetpack-sso-wrap__action,#jetpack-sso-wrap__user){display:block}#jetpack-sso-wrap{bottom:20px;margin-left:-24px;margin-right:-24px;padding:0 24px;position:absolute;width:100%}.jetpack-sso-repositioned #jetpack-sso-wrap{bottom:auto;margin-left:0;margin-right:0;margin-top:16px;padding:0;position:relative}.jetpack-sso-form-display #jetpack-sso-wrap{bottom:auto;margin-left:0;margin-right:0;margin-top:0;padding:0;position:relative}#loginform #jetpack-sso-wrap p{color:#777;margin-bottom:16px}#jetpack-sso-wrap a{display:block;text-align:center;text-decoration:none;width:100%}#jetpack-sso-wrap .jetpack-sso-toggle.wpcom{display:none}.jetpack-sso-form-display #jetpack-sso-wrap .jetpack-sso-toggle.wpcom{display:block}.jetpack-sso-form-display #jetpack-sso-wrap .jetpack-sso-toggle.default,.jetpack-sso-form-display #loginform>:is(div,p){display:none}.jetpack-sso-form-display #loginform #jetpack-sso-wrap{display:block}.jetpack-sso-form-display #loginform{padding:26px 24px}.jetpack-sso-or{margin-bottom:16px;position:relative;text-align:center}.jetpack-sso-or:before{background:#dcdcde;content:"";height:1px;position:absolute;right:0;top:50%;width:100%}.jetpack-sso-or span{background:#fff;color:#777;padding:0 8px;position:relative;text-transform:uppercase}#jetpack-sso-wrap .button{align-items:center;display:flex;height:36px;justify-content:center;margin-bottom:16px;width:100%}#jetpack-sso-wrap .button .genericon-wordpress{font-size:24px;margin-left:4px}#jetpack-sso-wrap__user img{border-radius:50%;display:block;margin:0 auto 16px}#jetpack-sso-wrap__user h2{font-size:21px;font-weight:300;margin-bottom:16px;text-align:center}#jetpack-sso-wrap__user h2 span{font-weight:700}.jetpack-sso-wrap__reauth{margin-bottom:16px}.jetpack-sso-form-display #nav{display:none}.jetpack-sso-form-display #backtoblog{margin:24px 0 0}.jetpack-sso-clear:after{clear:both;content:"";display:table}
@@ -0,0 +1 @@
<?php return array('dependencies' => array('wp-polyfill'), 'version' => '9b04da34a7196789f44c');
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",function(){function t(){this.querySelector(".jetpack-sso-invitation-tooltip").style.display="block"}function e(t){t.target.ownerDocument.activeElement!==t.target&&(this.querySelector(".jetpack-sso-invitation-tooltip").style.display="none")}document.querySelectorAll(".jetpack-sso-invitation-tooltip-icon:not(.sso-disconnected-user)").forEach(function(t){t.innerHTML+=" [?]";const e=document.createElement("span");e.classList.add("jetpack-sso-invitation-tooltip");const n=window.Jetpack_SSOTooltip.tooltipString;function o(){t.appendChild(e),e.style.display="block"}function i(){t.ownerDocument.activeElement!==t&&t.removeChild(e)}e.innerHTML+=n,t.addEventListener("mouseenter",o),t.addEventListener("focus",o),t.addEventListener("mouseleave",i),t.addEventListener("blur",i)}),document.querySelectorAll(".jetpack-sso-invitation-tooltip-icon:not(.jetpack-sso-status-column)").forEach(function(n){n.addEventListener("mouseenter",t),n.addEventListener("focus",t),n.addEventListener("mouseleave",e),n.addEventListener("blur",e)})});
@@ -0,0 +1 @@
<?php return array('dependencies' => array('wp-polyfill'), 'version' => '2a9e9767fedce3c596e7');
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",function(){document.querySelectorAll(".jetpack-connection-tooltip").forEach(function(o){o.textContent=window.jetpackConnectionTooltips.columnTooltip})});
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => 'a63290feb29ef2daf665');
@@ -0,0 +1 @@
!function(t,a){window.jpTracksAJAX=window.jpTracksAJAX||{};const o="dops:analytics"===localStorage.getItem("debug");window.jpTracksAJAX.record_ajax_event=function(n,c,e){"object"==typeof e&&null!==e&&window.jpTracksContext&&window.jpTracksContext.blog_id&&!e.blog_id&&(e.blog_id=window.jpTracksContext.blog_id);const r={tracksNonce:a.jpTracksAJAX_nonce,action:"jetpack_tracks",tracksEventType:c,tracksEventName:n,tracksEventProp:e||!1};return t.ajax({type:"POST",url:a.ajaxurl,data:r,success:function(t){o&&console.log("AJAX tracks event recorded: ",r,t)}})},t(document).ready(function(){t("body").on("click",".jptracks a, a.jptracks",function(a){const o=t(a.target),n=o.closest(".jptracks"),c=n.attr("data-jptracks-name");if(void 0===c)return;const e=n.attr("data-jptracks-prop")||!1,r=o.attr("href"),s=o.get(0).target;let i=null;r&&s&&"_self"!==s&&(i=window.open("",s),i.opener=null),a.preventDefault(),window.jpTracksAJAX.record_ajax_event(c,"click",e).always(function(){if(r&&!o.hasClass("thickbox")){if(i)return void(i.location=r);window.location=r}})})})}(jQuery,jpTracksAJAX);
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => 'a1795bfc061f496e47cf');
@@ -0,0 +1 @@
(()=>{var e={7961(e){let n;window._tkq=window._tkq||[];const t=console.error;const o={initialize:function(e,n){o.setUser(e,n),o.identifyUser()},mc:{bumpStat:function(e,n){const t=function(e,n){let t="";if("object"==typeof e)for(const n in e)t+="&x_"+encodeURIComponent(n)+"="+encodeURIComponent(e[n]);else t="&x_"+encodeURIComponent(e)+"="+encodeURIComponent(n);return t}(e,n);(new Image).src=document.location.protocol+"//pixel.wp.com/g.gif?v=wpcom-no-pv"+t+"&t="+Math.random()}},tracks:{recordEvent:function(e,n){n=n||{},0===e.indexOf("jetpack_")?window._tkq.push(["recordEvent",e,n]):t('- Event name must be prefixed by "jetpack_"')},recordPageView:function(e){o.tracks.recordEvent("jetpack_page_view",{path:e})}},setUser:function(e,t){n={ID:e,username:t}},identifyUser:function(){n&&window._tkq.push(["identifyUser",n.ID,n.username])},clearedIdentity:function(){window._tkq.push(["clearIdentity"])}};e.exports=o}},n={};var t=function t(o){var r=n[o];if(void 0!==r)return r.exports;var i=n[o]={exports:{}};return e[o](i,i.exports,t),i.exports}(7961);window.analytics=t})();
@@ -0,0 +1,187 @@
<?php
/**
* IXR_Client
*
* @package automattic/jetpack-connection
*
* @since 1.7.0
* @since-jetpack 1.5
* @since-jetpack 7.7 Moved to the jetpack-connection package.
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager;
/**
* Disable direct access.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
if ( ! class_exists( IXR_Client::class ) ) {
require_once ABSPATH . WPINC . '/class-IXR.php';
}
/**
* A Jetpack implementation of the WordPress core IXR client.
*/
class Jetpack_IXR_Client extends IXR_Client {
/**
* Jetpack args, used for the remote requests.
*
* @var array
*/
public $jetpack_args = null;
/**
* Remote Response Headers.
*
* @var array
*/
public $response_headers = null;
/**
* Holds the raw remote response from the latest call to query().
*
* @var null|array|WP_Error
*/
public $last_response = null;
/**
* Constructor.
* Initialize a new Jetpack IXR client instance.
*
* @param array $args Jetpack args, used for the remote requests.
* @param string|bool $path Path to perform the reuqest to.
* @param int $port Port number.
* @param int $timeout The connection timeout, in seconds.
*/
public function __construct( $args = array(), $path = false, $port = 80, $timeout = 15 ) {
$connection = new Manager();
$defaults = array(
'url' => $connection->xmlrpc_api_url(),
'user_id' => 0,
'headers' => array(),
);
$args = wp_parse_args( $args, $defaults );
$args['headers'] = array_merge( array( 'Content-Type' => 'text/xml' ), (array) $args['headers'] );
$this->jetpack_args = $args;
$this->IXR_Client( $args['url'], $path, $port, $timeout );
}
/**
* Perform the IXR request.
*
* @param mixed ...$args IXR method and args.
*
* @return bool True if request succeeded, false otherwise.
*/
public function query( ...$args ) {
$method = array_shift( $args );
$request = new IXR_Request( $method, $args );
$xml = trim( $request->getXml() );
$response = Client::remote_request( $this->jetpack_args, $xml );
// Store response headers.
$this->response_headers = wp_remote_retrieve_headers( $response );
$this->last_response = $response;
if ( is_array( $this->last_response ) && isset( $this->last_response['http_response'] ) ) {
// If the expected array response is received, format the data as plain arrays.
$this->last_response = $this->last_response['http_response']->to_array();
$this->last_response['headers'] = $this->last_response['headers']->getAll();
}
if ( is_wp_error( $response ) ) {
$this->error = new IXR_Error( -10520, sprintf( 'Jetpack: [%s] %s', $response->get_error_code(), $response->get_error_message() ) );
return false;
}
if ( ! $response ) {
$this->error = new IXR_Error( -10520, 'Jetpack: Unknown Error' );
return false;
}
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
$this->error = new IXR_Error( -32300, 'transport error - HTTP status code was not 200' );
return false;
}
$content = wp_remote_retrieve_body( $response );
// Now parse what we've got back.
$this->message = new IXR_Message( $content );
if ( ! $this->message->parse() ) {
// XML error.
$this->error = new IXR_Error( -32700, 'parse error. not well formed' );
return false;
}
// Is the message a fault?
if ( 'fault' === $this->message->messageType ) {
$this->error = new IXR_Error( $this->message->faultCode, $this->message->faultString );
return false;
}
// Message must be OK.
return true;
}
/**
* Retrieve the Jetpack error from the result of the last request.
*
* @param int $fault_code Fault code.
* @param string $fault_string Fault string.
* @return WP_Error Error object.
*/
public function get_jetpack_error( $fault_code = null, $fault_string = null ) {
if ( $fault_code === null ) {
$fault_code = $this->error->code;
}
if ( $fault_string === null ) {
$fault_string = $this->error->message;
}
if ( preg_match( '#jetpack:\s+\[(\w+)\]\s*(.*)?$#i', $fault_string, $match ) ) {
$code = $match[1];
$message = $match[2];
$status = $fault_code;
return new WP_Error( $code, $message, $status );
}
return new WP_Error( "IXR_{$fault_code}", $fault_string );
}
/**
* Retrieve a response header if set.
*
* @param string $name header name.
* @return string|bool Header value if set, false if not set.
*/
public function get_response_header( $name ) {
if ( isset( $this->response_headers[ $name ] ) ) {
return $this->response_headers[ $name ];
}
// case-insensitive header names: http://www.ietf.org/rfc/rfc2616.txt.
if ( isset( $this->response_headers[ strtolower( $name ) ] ) ) {
return $this->response_headers[ strtolower( $name ) ];
}
return false;
}
/**
* Retrieve the raw response for the last query() call.
*
* @return null|array|WP_Error
*/
public function get_last_response() {
return $this->last_response;
}
}
@@ -0,0 +1,78 @@
<?php
/**
* IXR_ClientMulticall
*
* @package automattic/jetpack-connection
*
* @since 1.7.0
* @since-jetpack 1.5
* @since-jetpack 7.7 Moved to the jetpack-connection package.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* A Jetpack implementation of the WordPress core IXR client, capable of multiple calls in a single request.
*/
class Jetpack_IXR_ClientMulticall extends Jetpack_IXR_Client {
/**
* Storage for the IXR calls.
*
* @var array
*/
public $calls = array();
/**
* Add a IXR call to the client.
* First argument is the method name.
* The rest of the arguments are the params specified to the method.
*
* @param string[] ...$args IXR args.
*/
public function addCall( ...$args ) {
$method_name = array_shift( $args );
$struct = array(
'methodName' => $method_name,
'params' => $args,
);
$this->calls[] = $struct;
}
/**
* Perform the IXR multicall request.
*
* @param string[] ...$args IXR args.
*
* @return bool True if request succeeded, false otherwise.
*/
public function query( ...$args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$this->calls = $this->sort_calls( $this->calls );
// Prepare multicall, then call the parent::query() method.
return parent::query( 'system.multicall', $this->calls );
}
/**
* Sort the IXR calls.
* Make sure syncs are always done first preserving relative order.
*
* @param array $calls Calls to sort.
* @return array Sorted calls.
*/
public function sort_calls( $calls ) {
$sync_calls = array();
$other_calls = array();
foreach ( $calls as $call ) {
if ( 'jetpack.syncContent' === $call['methodName'] ) {
$sync_calls[] = $call;
} else {
$other_calls[] = $call;
}
}
return array_merge( $sync_calls, $other_calls );
}
}
@@ -0,0 +1,726 @@
<?php
/**
* Legacy Jetpack_Options class.
*
* @package automattic/jetpack-connection
*/
use Automattic\Jetpack\Constants;
/**
* Class Jetpack_Options
*/
class Jetpack_Options {
/**
* An array that maps a grouped option type to an option name.
*
* @var array
*/
private static $grouped_options = array(
'compact' => 'jetpack_options',
'private' => 'jetpack_private_options',
);
/**
* Returns an array of option names for a given type.
*
* @param string $type The type of option to return. Defaults to 'compact'.
*
* @return array
*/
public static function get_option_names( $type = 'compact' ) {
switch ( $type ) {
case 'non-compact':
case 'non_compact':
return array(
'activated',
'active_modules',
'active_modules_initialized', // (bool) used to determine that all the default modules were activated, so we know how to act on a reconnection.
'allowed_xsite_search_ids', // (array) Array of WP.com blog ids that are allowed to search the content of this site
'available_modules',
'do_activate',
'log',
'slideshow_background_color',
'widget_twitter',
'wpcc_options',
'relatedposts',
'file_data',
'autoupdate_plugins', // (array) An array of plugin ids ( eg. jetpack/jetpack ) that should be autoupdated
'autoupdate_plugins_translations', // (array) An array of plugin ids ( eg. jetpack/jetpack ) that should be autoupdated translation files.
'autoupdate_themes', // (array) An array of theme ids ( eg. twentyfourteen ) that should be autoupdated
'autoupdate_themes_translations', // (array) An array of theme ids ( eg. twentyfourteen ) that should autoupdated translation files.
'autoupdate_core', // (bool) Whether or not to autoupdate core
'autoupdate_translations', // (bool) Whether or not to autoupdate all translations
'json_api_full_management', // (bool) Allow full management (eg. Activate, Upgrade plugins) of the site via the JSON API.
'sync_non_public_post_stati', // (bool) Allow synchronisation of posts and pages with non-public status.
'site_icon_url', // (string) url to the full site icon
'site_icon_id', // (int) Attachment id of the site icon file
'dismissed_manage_banner', // (bool) Dismiss Jetpack manage banner allows the user to dismiss the banner permanently
'unique_connection', // (array) A flag to determine a unique connection to wordpress.com two values "connected" and "disconnected" with values for how many times each has occurred
'unique_registrations', // (integer) A counter of how many times the site was registered
'protect_whitelist', // (array) IP Address for the Protect module to ignore
'sync_error_idc', // (bool|array) false or array containing the site's home and siteurl at time of IDC error
'sync_health_status', // (bool|array) An array of data relating to Jetpack's sync health.
'safe_mode_confirmed', // (bool) True if someone confirms that this site was correctly put into safe mode automatically after an identity crisis is discovered.
'migrate_for_idc', // (bool) True if someone confirms that this site should migrate stats and subscribers from its previous URL
'ab_connect_banner_green_bar', // (int) Version displayed of the A/B test for the green bar at the top of the connect banner.
'tos_agreed', // (bool) Whether or not the TOS for connection has been agreed upon.
'static_asset_cdn_files', // (array) An nested array of files that we can swap out for cdn versions.
'mapbox_api_key', // (string) Mapbox API Key, for use with Map block.
'mailchimp', // (string) Mailchimp keyring data, for mailchimp block.
'xmlrpc_errors', // (array) Keys are XML-RPC signature error codes. Values are truthy.
'dismissed_wizard_banner', // (int) (DEPRECATED) True if the Wizard banner has been dismissed.
);
case 'private':
return array(
'blog_token', // (string) The Client Secret/Blog Token of this site.
'user_token', // (string) The User Token of this site. (deprecated)
'user_tokens', // (array) User Tokens for each user of this site who has connected to jetpack.wordpress.com.
'purchase_token', // (string) Token for logged out user purchases.
'token_lock', // (string) Token lock in format `expiration_date|||site_url`.
);
case 'network':
return array(
'file_data', // (array) List of absolute paths to all Jetpack modules
);
}
return array(
'id', // (int) The Client ID/WP.com Blog ID of this site.
'publicize_connections', // (array) An array of Publicize connections from WordPress.com.
'master_user', // (int) The local User ID of the user who connected this site to jetpack.wordpress.com.
'version', // (string) Used during upgrade procedure to auto-activate new modules. version:time.
'old_version', // (string) Used to determine which modules are the most recently added. previous_version:time.
'fallback_no_verify_ssl_certs', // (int) Flag for determining if this host must skip SSL Certificate verification due to misconfigured SSL.
'time_diff', // (int) Offset between Jetpack server's clocks and this server's clocks. Jetpack Server Time = time() + (int) Jetpack_Options::get_option( 'time_diff' )
'public', // (int|bool) If we think this site is public or not (1, 0), false if we haven't yet tried to figure it out.
'videopress', // (array) VideoPress options array.
'is_network_site', // (int|bool) If we think this site is a network or a single blog (1, 0), false if we haven't yet tried to figue it out.
'social_links', // (array) The specified links for each social networking site.
'identity_crisis_whitelist', // (array) An array of options, each having an array of the values whitelisted for it.
'gplus_authors', // (array) The Google+ authorship information for connected users.
'last_heartbeat', // (int) The timestamp of the last heartbeat that fired.
'hide_jitm', // (array) A list of just in time messages that we should not show because they have been dismissed by the user.
'custom_css_4.7_migration', // (bool) Whether Custom CSS has scanned for and migrated any legacy CSS CPT entries to the new Core format.
'image_widget_migration', // (bool) Whether any legacy Image Widgets have been converted to the new Core widget.
'gallery_widget_migration', // (bool) Whether any legacy Gallery Widgets have been converted to the new Core widget.
'sso_first_login', // (bool) Is this the first time the user logins via SSO.
'dismissed_hints', // (array) Part of Plugin Search Hints. List of cards that have been dismissed.
'first_admin_view', // (bool) Set to true the first time the user views the admin. Usually after the initial connection.
'setup_wizard_questionnaire', // (array) (DEPRECATED) List of user choices from the setup wizard.
'setup_wizard_status', // (string) (DEPRECATED) Status of the setup wizard.
'licensing_error', // (string) Last error message occurred while attaching licenses that is yet to be surfaced to the user.
'recommendations_data', // (array) The user choice and other data for the recommendations.
'recommendations_step', // (string) The current step of the recommendations.
'recommendations_conditional', // (array) An array of action-based recommendations.
'licensing_activation_notice_dismiss', // (array) The `last_detached_count` and the `last_dismissed_time` for the user-license activation notice.
'has_seen_wc_connection_modal', // (bool) Whether the site has displayed the WooCommerce Connection modal
'partner_coupon', // (string) A Jetpack partner issued coupon to promote a sale together with Jetpack.
'partner_coupon_added', // (string) A date for when `partner_coupon` was added, so we can auto-purge after a certain time interval.
'dismissed_backup_review_restore', // (bool) Determines if the component review request is dismissed for successful restore requests.
'dismissed_backup_review_backups', // (bool) Determines if the component review request is dismissed for successful backup requests.
'identity_crisis_url_secret', // (array) The IDC URL secret and its expiration date.
'identity_crisis_ip_requester', // (array) The IDC IP address and its expiration date.
'dismissed_welcome_banner', // (bool) Determines if the welcome banner has been dismissed or not.
'recommendations_evaluation', // (object) Catalog of recommended modules with corresponding score following successful site evaluation in Welcome Banner.
'dismissed_recommendations', // (bool) Determines if the recommendations have been dismissed or not.
'recommendations_first_run', // (bool) Determines if the current recommendations are the initial default auto-loaded ones (without user input).
'historically_active_modules', // (array) List of installed plugins/enabled modules that have at one point in time been active and working
);
}
/**
* Is the option name valid?
*
* @param string $name The name of the option.
* @param string|null $group The name of the group that the option is in. Default to null, which will search non_compact.
*
* @return bool Is the option name valid?
*/
public static function is_valid( $name, $group = null ) {
if ( is_array( $name ) ) {
$compact_names = array();
foreach ( array_keys( self::$grouped_options ) as $_group ) {
$compact_names = array_merge( $compact_names, self::get_option_names( $_group ) );
}
$result = array_diff( $name, self::get_option_names( 'non_compact' ), $compact_names );
return empty( $result );
}
if ( $group === null || 'non_compact' === $group ) {
if ( in_array( $name, self::get_option_names( $group ), true ) ) {
return true;
}
}
foreach ( array_keys( self::$grouped_options ) as $_group ) {
if ( $group === null || $group === $_group ) {
if ( in_array( $name, self::get_option_names( $_group ), true ) ) {
return true;
}
}
}
return false;
}
/**
* Checks if an option must be saved for the whole network in WP Multisite
*
* @param string $option_name Option name. It must come _without_ `jetpack_%` prefix. The method will prefix the option name.
*
* @return bool
*/
public static function is_network_option( $option_name ) {
if ( ! is_multisite() ) {
return false;
}
return in_array( $option_name, self::get_option_names( 'network' ), true );
}
/**
* Filters the requested option.
* This is a wrapper around `get_option_from_database` so that we can filter the option.
*
* @param string $name Option name. It must come _without_ `jetpack_%` prefix. The method will prefix the option name.
* @param mixed $default (optional).
*
* @return mixed
*/
public static function get_option( $name, $default = false ) {
// Check if external storage should be used for this option
if ( self::should_use_external_storage( $name ) ) {
// Try external storage
if ( class_exists( 'Automattic\Jetpack\Connection\External_Storage' ) ) {
$external_value = \Automattic\Jetpack\Connection\External_Storage::get_value( $name );
if ( null !== $external_value ) {
return $external_value;
}
}
}
/**
* Filter Jetpack Options.
* Can be useful in environments when Jetpack is running with a different setup
*
* @since 1.7.0
*
* @param string $value The value from the database.
* @param string $name Option name, _without_ `jetpack_%` prefix.
* @return string $value, unless the filters modify it.
*/
return apply_filters( 'jetpack_options', self::get_option_from_database( $name, $default ), $name );
}
/**
* Returns the requested option. Looks in jetpack_options or jetpack_$name as appropriate.
*
* @param string $name Option name. It must come _without_ `jetpack_%` prefix. The method will prefix the option name.
* @param mixed $default (optional).
*
* @return mixed
*/
private static function get_option_from_database( $name, $default = false ) {
if ( self::is_valid( $name, 'non_compact' ) ) {
if ( self::is_network_option( $name ) ) {
return get_site_option( "jetpack_$name", $default );
}
return get_option( "jetpack_$name", $default );
}
foreach ( array_keys( self::$grouped_options ) as $group ) {
if ( self::is_valid( $name, $group ) ) {
return self::get_grouped_option( $group, $name, $default );
}
}
return $default;
}
/**
* Options that can be stored in external storage.
*
* @since 6.18.0
*
* @var array
*/
private static $external_storage_allowlist = array( 'blog_token', 'id', 'master_user', 'user_tokens' );
/**
* Determines if external storage should be used for a given option.
* Simple allowlist check with global killswitch.
*
* @since 6.17.0
*
* @param string $name Option name, _without_ `jetpack_%` prefix.
* @return bool True if external storage should be checked for this option.
*/
private static function should_use_external_storage( $name ) {
// Check allowlist and global killswitch
if ( ! in_array( $name, self::$external_storage_allowlist, true ) ||
( defined( 'JETPACK_EXTERNAL_STORAGE_DISABLED' ) && constant( 'JETPACK_EXTERNAL_STORAGE_DISABLED' ) ) ) {
return false;
}
return true;
}
/**
* Returns the requested option, and ensures it's autoloaded in the future.
* This does _not_ adjust the prefix in any way (does not prefix jetpack_%)
*
* @param string $name Option name.
* @param mixed $default (optional).
*
* @return mixed
*/
public static function get_option_and_ensure_autoload( $name, $default ) {
// In this function the name is not adjusted by prefixing jetpack_
// so if it has already prefixed, we'll replace it and then
// check if the option name is a network option or not.
$jetpack_name = preg_replace( '/^jetpack_/', '', $name, 1 );
$is_network_option = self::is_network_option( $jetpack_name );
$value = $is_network_option ? get_site_option( $name ) : get_option( $name );
if ( false === $value && false !== $default ) {
if ( $is_network_option ) {
add_site_option( $name, $default );
} else {
add_option( $name, $default );
}
$value = $default;
}
return $value;
}
/**
* Update grouped option
*
* @param string $group Options group.
* @param string $name Options name.
* @param mixed $value Options value.
*
* @return bool Success or failure.
*/
private static function update_grouped_option( $group, $name, $value ) {
$options = get_option( self::$grouped_options[ $group ] );
if ( ! is_array( $options ) ) {
$options = array();
}
$options[ $name ] = $value;
return update_option( self::$grouped_options[ $group ], $options );
}
/**
* Updates the single given option. Updates jetpack_options or jetpack_$name as appropriate.
*
* @param string $name Option name. It must come _without_ `jetpack_%` prefix. The method will prefix the option name.
* @param mixed $value Option value.
* @param bool|null $autoload If not compact option, allows specifying whether to autoload or not.
*
* @return bool Was the option successfully updated?
*/
public static function update_option( $name, $value, $autoload = null ) {
/**
* Fires before Jetpack updates a specific option.
*
* @since 1.1.2
* @since-jetpack 3.0.0
*
* @param string $name The name of the option being updated.
* @param mixed $value The new value of the option.
*/
do_action( 'pre_update_jetpack_option_' . $name, $name, $value );
if ( self::is_valid( $name, 'non_compact' ) ) {
if ( self::is_network_option( $name ) ) {
return update_site_option( "jetpack_$name", $value );
}
return update_option( "jetpack_$name", $value, $autoload );
}
foreach ( array_keys( self::$grouped_options ) as $group ) {
if ( self::is_valid( $name, $group ) ) {
return self::update_grouped_option( $group, $name, $value );
}
}
trigger_error( sprintf( 'Invalid Jetpack option name: %s', esc_html( $name ) ), E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- Don't want to change legacy behavior.
return false;
}
/**
* Updates the multiple given options. Updates jetpack_options and/or jetpack_$name as appropriate.
*
* @param array $array array( option name => option value, ... ).
*/
public static function update_options( $array ) {
$names = array_keys( $array );
foreach ( array_diff( $names, self::get_option_names(), self::get_option_names( 'non_compact' ), self::get_option_names( 'private' ) ) as $unknown_name ) {
trigger_error( sprintf( 'Invalid Jetpack option name: %s', esc_html( $unknown_name ) ), E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- Don't change legacy behavior.
unset( $array[ $unknown_name ] );
}
foreach ( $names as $name ) {
self::update_option( $name, $array[ $name ] );
}
}
/**
* Deletes the given option. May be passed multiple option names as an array.
* Updates jetpack_options and/or deletes jetpack_$name as appropriate.
*
* @param string|array $names Option names. They must come _without_ `jetpack_%` prefix. The method will prefix the option names.
*
* @return bool Was the option successfully deleted?
*/
public static function delete_option( $names ) {
$result = true;
$names = (array) $names;
if ( ! self::is_valid( $names ) ) {
// phpcs:disable -- This line triggers a handful of errors; ignoring to avoid changing legacy behavior.
trigger_error( sprintf( 'Invalid Jetpack option names: %s', print_r( $names, 1 ) ), E_USER_WARNING );
// phpcs:enable
return false;
}
foreach ( array_intersect( $names, self::get_option_names( 'non_compact' ) ) as $name ) {
if ( self::is_network_option( $name ) ) {
$result = delete_site_option( "jetpack_$name" );
} else {
$result = delete_option( "jetpack_$name" );
}
}
foreach ( array_keys( self::$grouped_options ) as $group ) {
if ( ! self::delete_grouped_option( $group, $names ) ) {
$result = false;
}
}
return $result;
}
/**
* Get group option.
*
* @param string $group Option group name.
* @param string $name Option name.
* @param mixed $default Default option value.
*
* @return mixed Option.
*/
private static function get_grouped_option( $group, $name, $default ) {
$options = get_option( self::$grouped_options[ $group ] );
if ( is_array( $options ) && isset( $options[ $name ] ) ) {
return $options[ $name ];
}
return $default;
}
/**
* Delete grouped option.
*
* @param string $group Option group name.
* @param array $names Option names.
*
* @return bool Success or failure.
*/
private static function delete_grouped_option( $group, $names ) {
$options = get_option( self::$grouped_options[ $group ], array() );
$to_delete = array_intersect( $names, self::get_option_names( $group ), array_keys( $options ) );
if ( $to_delete ) {
foreach ( $to_delete as $name ) {
unset( $options[ $name ] );
}
return update_option( self::$grouped_options[ $group ], $options );
}
return true;
}
/*
* Raw option methods allow Jetpack to get / update / delete options via direct DB queries, including options
* that are not created by the Jetpack plugin. This is helpful only in rare cases when we need to bypass
* cache and filters.
*/
/**
* Deletes an option via $wpdb query.
*
* @param string $name Option name.
*
* @return bool Is the option deleted?
*/
public static function delete_raw_option( $name ) {
if ( self::bypass_raw_option( $name ) ) {
return delete_option( $name );
}
global $wpdb;
$result = $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->options WHERE option_name = %s", $name ) );
return $result;
}
/**
* Updates an option via $wpdb query.
*
* @param string $name Option name.
* @param mixed $value Option value.
* @param bool $autoload Specifying whether to autoload or not.
*
* @return bool Is the option updated?
*/
public static function update_raw_option( $name, $value, $autoload = false ) {
if ( self::bypass_raw_option( $name ) ) {
return update_option( $name, $value, $autoload );
}
global $wpdb;
$autoload_value = $autoload ? 'yes' : 'no';
$old_value = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1",
$name
)
);
if ( $old_value === $value ) {
return false;
}
$serialized_value = maybe_serialize( $value );
// below we used "insert ignore" to at least suppress the resulting error.
$updated_num = $wpdb->query(
$wpdb->prepare(
"UPDATE $wpdb->options SET option_value = %s WHERE option_name = %s",
$serialized_value,
$name
)
);
// Try inserting the option if the value doesn't exits.
if ( ! $updated_num ) {
$updated_num = $wpdb->query(
$wpdb->prepare(
"INSERT IGNORE INTO $wpdb->options ( option_name, option_value, autoload ) VALUES ( %s, %s, %s )",
$name,
$serialized_value,
$autoload_value
)
);
}
return (bool) $updated_num;
}
/**
* Gets an option via $wpdb query.
*
* @since 1.1.2
* @since-jetpack 5.4.0
*
* @param string $name Option name.
* @param mixed $default Default option value if option is not found.
*
* @return mixed Option value, or null if option is not found and default is not specified.
*/
public static function get_raw_option( $name, $default = null ) {
if ( self::bypass_raw_option( $name ) ) {
return get_option( $name, $default );
}
global $wpdb;
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1",
$name
)
);
$value = maybe_unserialize( $value );
if ( null === $value && null !== $default ) {
return $default;
}
return $value;
}
/**
* This function checks for a constant that, if present, will disable direct DB queries Jetpack uses to manage certain options and force Jetpack to always use Options API instead.
* Options can be selectively managed via a blocklist by filtering option names via the jetpack_disabled_raw_option filter.
*
* @param string $name Option name.
*
* @return bool
*/
public static function bypass_raw_option( $name ) {
if ( Constants::get_constant( 'JETPACK_DISABLE_RAW_OPTIONS' ) ) {
return true;
}
/**
* Allows to disable particular raw options.
*
* @since 1.1.2
* @since-jetpack 5.5.0
*
* @param array $disabled_raw_options An array of option names that you can selectively blocklist from being managed via direct database queries.
*/
$disabled_raw_options = apply_filters( 'jetpack_disabled_raw_options', array() );
return isset( $disabled_raw_options[ $name ] );
}
/**
* Gets all known options that are used by Jetpack and managed by Jetpack_Options.
*
* @since 1.1.2
* @since-jetpack 5.4.0
*
* @param boolean $strip_unsafe_options If true, and by default, will strip out options necessary for the connection to WordPress.com.
* @return array An array of all options managed via the Jetpack_Options class.
*/
public static function get_all_jetpack_options( $strip_unsafe_options = true ) {
$jetpack_options = self::get_option_names();
$jetpack_options_non_compat = self::get_option_names( 'non_compact' );
$jetpack_options_private = self::get_option_names( 'private' );
$all_jp_options = array_merge( $jetpack_options, $jetpack_options_non_compat, $jetpack_options_private );
if ( $strip_unsafe_options ) {
// Flag some Jetpack options as unsafe.
$unsafe_options = array(
'id', // (int) The Client ID/WP.com Blog ID of this site.
'master_user', // (int) The local User ID of the user who connected this site to jetpack.wordpress.com.
'version', // (string) Used during upgrade procedure to auto-activate new modules. version:time
// non_compact.
'activated',
// private.
'register',
'blog_token', // (string) The Client Secret/Blog Token of this site.
'user_token', // (string) The User Token of this site. (deprecated)
'user_tokens',
);
// Remove the unsafe Jetpack options.
foreach ( $unsafe_options as $unsafe_option ) {
$key = array_search( $unsafe_option, $all_jp_options, true );
if ( false !== $key ) {
unset( $all_jp_options[ $key ] );
}
}
}
return $all_jp_options;
}
/**
* Get all options that are not managed by the Jetpack_Options class that are used by Jetpack.
*
* @since 1.1.2
* @since-jetpack 5.4.0
*
* @return array
*/
public static function get_all_wp_options() {
// A manual build of the wp options.
return array(
'sharing-options',
'disabled_likes',
'disabled_reblogs',
'jetpack_comments_likes_enabled',
'stats_options',
'stats_dashboard_widget',
'safecss_preview_rev',
'safecss_rev',
'safecss_revision_migrated',
'nova_menu_order',
'jetpack_portfolio',
'jetpack_portfolio_posts_per_page',
'jetpack_testimonial',
'jetpack_testimonial_posts_per_page',
'sharedaddy_disable_resources',
'sharing-options',
'sharing-services',
'site_icon_temp_data',
'featured-content',
'site_logo',
'jetpack_dismissed_notices',
'jetpack-twitter-cards-site-tag',
'jetpack-sitemap-state',
'jetpack_sitemap_post_types',
'jetpack_sitemap_location',
'jetpack_protect_key',
'jetpack_protect_blocked_attempts',
'jetpack_protect_activating',
'jetpack_active_plan',
'jetpack_activation_source',
'jetpack_site_products',
'jetpack_sso_match_by_email',
'jetpack_sso_require_two_step',
'jetpack_sso_remove_login_form',
'jetpack_last_connect_url_check',
'jetpack_excluded_extensions',
);
}
/**
* Gets all options that can be safely reset by CLI.
*
* @since 1.1.2
* @since-jetpack 5.4.0
*
* @return array array Associative array containing jp_options which are managed by the Jetpack_Options class and wp_options which are not.
*/
public static function get_options_for_reset() {
$all_jp_options = self::get_all_jetpack_options();
$wp_options = self::get_all_wp_options();
$options = array(
'jp_options' => $all_jp_options,
'wp_options' => $wp_options,
);
return $options;
}
/**
* Delete all known options
*
* @since 1.1.2
* @since-jetpack 5.4.0
*
* @return void
*/
public static function delete_all_known_options() {
// Delete all compact options.
foreach ( (array) self::$grouped_options as $option_name ) {
delete_option( $option_name );
}
// Delete all non-compact Jetpack options.
foreach ( (array) self::get_option_names( 'non-compact' ) as $option_name ) {
self::delete_option( $option_name );
}
// Delete all options that can be reset via CLI, that aren't Jetpack options.
foreach ( (array) self::get_all_wp_options() as $option_name ) {
delete_option( $option_name );
}
}
}
@@ -0,0 +1,412 @@
<?php
/**
* The Jetpack Connection signature class file.
*
* @package automattic/jetpack-connection
*/
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
/**
* The Jetpack Connection signature class that is used to sign requests.
*/
class Jetpack_Signature {
/**
* Token part of the access token.
*
* @access public
* @var string
*/
public $token;
/**
* Access token secret.
*
* @access public
* @var string
*/
public $secret;
/**
* Timezone difference (in seconds).
*
* @access public
* @var int
*/
public $time_diff;
/**
* The current request URL.
*
* @access public
* @var string
*/
public $current_request_url;
/**
* Constructor.
*
* @param string $access_token Access token.
* @param int $time_diff Timezone difference (in seconds).
*/
public function __construct( $access_token, $time_diff = 0 ) {
$secret = explode( '.', $access_token );
if ( 2 !== count( $secret ) ) {
return;
}
$this->token = $secret[0];
$this->secret = $secret[1];
$this->time_diff = $time_diff;
}
/**
* Sign the current request.
*
* @todo Implement a proper nonce verification.
*
* @param array $override Optional arguments to override the ones from the current request.
* @return string|WP_Error Request signature, or a WP_Error on failure.
*/
public function sign_current_request( $override = array() ) {
if ( isset( $override['scheme'] ) ) {
$scheme = $override['scheme'];
if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
return new WP_Error( 'invalid_scheme', 'Invalid URL scheme' );
}
} elseif ( is_ssl() ) {
$scheme = 'https';
} else {
$scheme = 'http';
}
$port = $this->get_current_request_port();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidatedNotSanitized -- Sniff misses the esc_url_raw wrapper.
$this->current_request_url = esc_url_raw( wp_unslash( "{$scheme}://{$_SERVER['HTTP_HOST']}:{$port}" . ( $_SERVER['REQUEST_URI'] ?? '' ) ) );
if ( array_key_exists( 'body', $override ) && ! empty( $override['body'] ) ) {
$body = $override['body'];
} elseif ( isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( $_SERVER['REQUEST_METHOD'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
$body = $GLOBALS['HTTP_RAW_POST_DATA'] ?? null;
// Convert the $_POST to the body, if the body was empty. This is how arrays are hashed
// and encoded on the Jetpack side.
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Used to generate a cryptographic signature of the post data. Not actually using any of it here.
if ( empty( $body ) && is_array( $_POST ) && $_POST !== array() ) {
$body = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- We need all of $_POST in order to generate a cryptographic signature of the post data.
}
}
} elseif ( isset( $_SERVER['REQUEST_METHOD'] ) && 'PUT' === strtoupper( $_SERVER['REQUEST_METHOD'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
// This is a little strange-looking, but there doesn't seem to be another way to get the PUT body.
$raw_put_data = file_get_contents( 'php://input' );
parse_str( $raw_put_data, $body );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$put_data = json_decode( $raw_put_data, true );
if ( is_array( $put_data ) && $put_data !== array() ) {
$body = $put_data;
}
}
} else {
$body = null;
}
if ( empty( $body ) ) {
$body = null;
}
$a = array();
foreach ( array( 'token', 'timestamp', 'nonce', 'body-hash' ) as $parameter ) {
if ( isset( $override[ $parameter ] ) ) {
$a[ $parameter ] = $override[ $parameter ];
} else {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$a[ $parameter ] = isset( $_GET[ $parameter ] ) ? filter_var( wp_unslash( $_GET[ $parameter ] ) ) : '';
}
}
$method = $override['method'] ?? ( isset( $_SERVER['REQUEST_METHOD'] ) ? filter_var( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : null );
return $this->sign_request( $a['token'], $a['timestamp'], $a['nonce'], $a['body-hash'], $method, $this->current_request_url, $body, true );
}
/**
* Sign a specified request.
*
* @todo Having body_hash v. body-hash is annoying. Refactor to accept an array?
* @todo Use wp_json_encode() instead of json_encode()?
*
* @param string $token Request token.
* @param int $timestamp Timestamp of the request.
* @param string $nonce Request nonce.
* @param string $body_hash Request body hash.
* @param string $method Request method.
* @param string $url Request URL.
* @param mixed $body Request body.
* @param bool $verify_body_hash Whether to verify the body hash against the body.
* @return string|WP_Error Request signature, or a WP_Error on failure.
*/
public function sign_request( $token = '', $timestamp = 0, $nonce = '', $body_hash = '', $method = '', $url = '', $body = null, $verify_body_hash = true ) {
if ( ! $this->secret ) {
return new WP_Error( 'invalid_secret', 'Invalid secret' );
}
if ( ! $this->token ) {
return new WP_Error( 'invalid_token', 'Invalid token' );
}
list( $token ) = explode( '.', $token );
$signature_details = compact( 'token', 'timestamp', 'nonce', 'body_hash', 'method', 'url' );
if ( ! str_starts_with( $token, "$this->token:" ) ) {
return new WP_Error( 'token_mismatch', 'Incorrect token', compact( 'signature_details' ) );
}
// If we got an array at this point, let's encode it, so we can see what it looks like as a string.
if ( is_array( $body ) ) {
if ( $body !== array() ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
$body = json_encode(
$body,
0 // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.ZeroFound -- No `json_encode()` flags because this needs to match whatever is calculating the hash on the other end.
);
} else {
$body = '';
}
}
$required_parameters = array( 'token', 'timestamp', 'nonce', 'method', 'url' );
if ( $body !== null ) {
$required_parameters[] = 'body_hash';
if ( ! is_string( $body ) ) {
return new WP_Error( 'invalid_body', 'Body is malformed.', compact( 'signature_details' ) );
}
}
foreach ( $required_parameters as $required ) {
if ( ! is_scalar( $$required ) ) {
return new WP_Error( 'invalid_signature', sprintf( 'The required "%s" parameter is malformed.', str_replace( '_', '-', $required ) ), compact( 'signature_details' ) );
}
if ( ! strlen( $$required ) ) {
return new WP_Error( 'invalid_signature', sprintf( 'The required "%s" parameter is missing.', str_replace( '_', '-', $required ) ), compact( 'signature_details' ) );
}
}
if ( empty( $body ) ) {
if ( $body_hash ) {
return new WP_Error( 'invalid_body_hash', 'Invalid body hash for empty body.', compact( 'signature_details' ) );
}
} else {
$connection = new Connection_Manager();
if ( $verify_body_hash && $connection->sha1_base64( $body ) !== $body_hash ) {
return new WP_Error( 'invalid_body_hash', 'The body hash does not match.', compact( 'signature_details' ) );
}
}
$parsed = wp_parse_url( $url );
if ( ! isset( $parsed['host'] ) ) {
return new WP_Error( 'invalid_signature', sprintf( 'The required "%s" parameter is malformed.', 'url' ), compact( 'signature_details' ) );
}
if ( ! empty( $parsed['port'] ) ) {
$port = $parsed['port'];
} elseif ( 'http' === $parsed['scheme'] ) {
$port = 80;
} elseif ( 'https' === $parsed['scheme'] ) {
$port = 443;
} else {
return new WP_Error( 'unknown_scheme_port', "The scheme's port is unknown", compact( 'signature_details' ) );
}
if ( ! ctype_digit( "$timestamp" ) || 10 < strlen( (string) $timestamp ) ) { // If Jetpack is around in 275 years, you can blame mdawaffe for the bug.
return new WP_Error( 'invalid_signature', sprintf( 'The required "%s" parameter is malformed.', 'timestamp' ), compact( 'signature_details' ) );
}
$local_time = $timestamp - $this->time_diff;
if ( $local_time < time() - 600 || $local_time > time() + 300 ) {
return new WP_Error( 'invalid_signature', 'The timestamp is too old.', compact( 'signature_details' ) );
}
if ( 12 < strlen( $nonce ) || preg_match( '/[^a-zA-Z0-9]/', $nonce ) ) {
return new WP_Error( 'invalid_signature', sprintf( 'The required "%s" parameter is malformed.', 'nonce' ), compact( 'signature_details' ) );
}
$normalized_request_pieces = array(
$token,
$timestamp,
$nonce,
$body_hash,
strtoupper( $method ),
strtolower( $parsed['host'] ),
$port,
empty( $parsed['path'] ) ? '' : $parsed['path'],
// Normalized Query String.
);
$normalized_request_pieces = array_merge( $normalized_request_pieces, $this->normalized_query_parameters( $parsed['query'] ?? '' ) );
$flat_normalized_request_pieces = array();
foreach ( $normalized_request_pieces as $piece ) {
if ( is_array( $piece ) ) {
foreach ( $piece as $subpiece ) {
$flat_normalized_request_pieces[] = $subpiece;
}
} else {
$flat_normalized_request_pieces[] = $piece;
}
}
$normalized_request_pieces = $flat_normalized_request_pieces;
$normalized_request_string = implode( "\n", $normalized_request_pieces ) . "\n";
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return base64_encode( hash_hmac( 'sha1', $normalized_request_string, $this->secret, true ) );
}
/**
* Retrieve and normalize the parameters from a query string.
*
* @param string $query_string Query string.
* @return array Normalized query string parameters.
*/
public function normalized_query_parameters( $query_string ) {
parse_str( $query_string, $array );
unset( $array['signature'] );
$names = array_keys( $array );
$values = array_values( $array );
$names = array_map( array( $this, 'encode_3986' ), $names );
$values = array_map( array( $this, 'encode_3986' ), $values );
$pairs = array_map( array( $this, 'join_with_equal_sign' ), $names, $values );
sort( $pairs );
return $pairs;
}
/**
* Encodes a string or array of strings according to RFC 3986.
*
* @param string|array $string_or_array String or array to encode.
* @return string|array URL-encoded string or array.
*/
public function encode_3986( $string_or_array ) {
if ( is_array( $string_or_array ) ) {
return array_map( array( $this, 'encode_3986' ), $string_or_array );
}
return rawurlencode( $string_or_array );
}
/**
* Concatenates a parameter name and a parameter value with an equals sign between them.
*
* @param string $name Parameter name.
* @param string|array $value Parameter value.
* @return string|array A string pair (e.g. `name=value`) or an array of string pairs.
*/
public function join_with_equal_sign( $name, $value ) {
if ( is_array( $value ) ) {
return $this->join_array_with_equal_sign( $name, $value );
}
return "{$name}={$value}";
}
/**
* Helper function for join_with_equal_sign for handling arrayed values.
* Explicitly supports nested arrays.
*
* @param string $name Parameter name.
* @param array $value Parameter value.
* @return array An array of string pairs (e.g. `[ name[example]=value ]`).
*/
private function join_array_with_equal_sign( $name, $value ) {
$result = array();
foreach ( $value as $value_key => $value_value ) {
$joined_value = $this->join_with_equal_sign( $name . '[' . $value_key . ']', $value_value );
if ( is_array( $joined_value ) ) {
foreach ( array_values( $joined_value ) as $individual_joined_value ) {
$result[] = $individual_joined_value;
}
} elseif ( is_string( $joined_value ) ) {
$result[] = $joined_value;
}
}
sort( $result );
return $result;
}
/**
* Gets the port that should be considered to sign the current request.
*
* It will analyze the current request, as well as some Jetpack constants, to return the string
* to be concatenated in the URL representing the port of the current request.
*
* @since 1.8.4
*
* @return string The port to be used in the signature
*/
public function get_current_request_port() {
$host_port = isset( $_SERVER['HTTP_X_FORWARDED_PORT'] ) ? $this->sanitize_host_post( $_SERVER['HTTP_X_FORWARDED_PORT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
if ( '' === $host_port && isset( $_SERVER['SERVER_PORT'] ) ) {
$host_port = $this->sanitize_host_post( $_SERVER['SERVER_PORT'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
}
/**
* Note: This port logic is tested in the Jetpack_Cxn_Tests->test__server_port_value() test.
* Please update the test if any changes are made in this logic.
*/
if ( is_ssl() ) {
// 443: Standard Port
// 80: Assume we're behind a proxy without X-Forwarded-Port. Hardcoding "80" here means most sites
// with SSL termination proxies (self-served, Cloudflare, etc.) don't need to fiddle with
// the JETPACK_SIGNATURE__HTTPS_PORT constant. The code also implies we can't talk to a
// site at https://example.com:80/ (which would be a strange configuration).
// JETPACK_SIGNATURE__HTTPS_PORT: Set this constant in wp-config.php to the back end webserver's port
// if the site is behind a proxy running on port 443 without
// X-Forwarded-Port and the back end's port is *not* 80. It's better,
// though, to configure the proxy to send X-Forwarded-Port.
$https_port = defined( 'JETPACK_SIGNATURE__HTTPS_PORT' ) ? $this->sanitize_host_post( JETPACK_SIGNATURE__HTTPS_PORT ) : '443';
$port = in_array( $host_port, array( '443', '80', $https_port ), true ) ? '' : $host_port;
} else {
// 80: Standard Port
// JETPACK_SIGNATURE__HTTPS_PORT: Set this constant in wp-config.php to the back end webserver's port
// if the site is behind a proxy running on port 80 without
// X-Forwarded-Port. It's better, though, to configure the proxy to
// send X-Forwarded-Port.
$http_port = defined( 'JETPACK_SIGNATURE__HTTP_PORT' ) ? $this->sanitize_host_post( JETPACK_SIGNATURE__HTTP_PORT ) : '80';
$port = in_array( $host_port, array( '80', $http_port ), true ) ? '' : $host_port;
}
return (string) $port;
}
/**
* Sanitizes a variable checking if it's a valid port number, which can be an integer or a numeric string
*
* @since 1.8.4
*
* @param mixed $port_number Variable representing a port number.
* @return string Always a string with a valid port number, or an empty string if input is invalid
*/
public function sanitize_host_post( $port_number ) {
if ( ! is_int( $port_number ) && ! is_string( $port_number ) ) {
return '';
}
if ( is_string( $port_number ) && ! ctype_digit( $port_number ) ) {
return '';
}
if ( 0 >= (int) $port_number || 65535 < $port_number ) {
return '';
}
return (string) $port_number;
}
}
@@ -0,0 +1,230 @@
<?php
/**
* Legacy Jetpack Tracks Client
*
* @package automattic/jetpack-tracking
*/
use Automattic\Jetpack\Connection\Manager;
/**
* Jetpack_Tracks_Client
*
* Send Tracks events on behalf of a user
*
* Example Usage:
```php
require( dirname(__FILE__).'path/to/tracks/class-jetpack-tracks-client.php' );
$result = Jetpack_Tracks_Client::record_event( array(
'_en' => $event_name, // required
'_ui' => $user_id, // required unless _ul is provided
'_ul' => $user_login, // required unless _ui is provided
// Optional, but recommended
'_ts' => $ts_in_ms, // Default: now
'_via_ip' => $client_ip, // we use it for geo, etc.
// Possibly useful to set some context for the event
'_via_ua' => $client_user_agent,
'_via_url' => $client_url,
'_via_ref' => $client_referrer,
// For user-targeted tests
'abtest_name' => $abtest_name,
'abtest_variation' => $abtest_variation,
// Your application-specific properties
'custom_property' => $some_value,
) );
if ( is_wp_error( $result ) ) {
// Handle the error in your app
}
```
*/
class Jetpack_Tracks_Client {
const PIXEL = 'https://pixel.wp.com/t.gif';
const BROWSER_TYPE = 'php-agent';
const USER_AGENT_SLUG = 'tracks-client';
const VERSION = '0.3';
/**
* Stores the Terms of Service Object Reference.
*
* @var null
*/
private static $terms_of_service = null;
/**
* Record an event.
*
* @param mixed $event Event object to send to Tracks. An array will be cast to object. Required.
* Properties are included directly in the pixel query string after light validation.
* @return mixed True on success, WP_Error on failure
*/
public static function record_event( $event ) {
if ( ! self::$terms_of_service ) {
self::$terms_of_service = new \Automattic\Jetpack\Terms_Of_Service();
}
// Don't track users who have opted out or not agreed to our TOS, or are not running an active Jetpack.
if ( ! self::$terms_of_service->has_agreed() || ! empty( $_COOKIE['tk_opt-out'] ) ) {
return false;
}
if ( ! $event instanceof Jetpack_Tracks_Event ) {
$event = new Jetpack_Tracks_Event( $event );
}
if ( is_wp_error( $event ) ) {
return $event;
}
$pixel = $event->build_pixel_url();
if ( ! $pixel ) {
return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 );
}
return self::record_pixel( $pixel );
}
/**
* Synchronously request the pixel.
*
* @param string $pixel The wp.com tracking pixel.
* @return array|bool|WP_Error True if successful. wp_remote_get response or WP_Error if not.
*/
public static function record_pixel( $pixel ) {
// Add the Request Timestamp and URL terminator just before the HTTP request.
$pixel .= '&_rt=' . self::build_timestamp() . '&_=_';
$response = wp_remote_get(
$pixel,
array(
'blocking' => true, // The default, but being explicit here :).
'timeout' => 1,
'redirection' => 2,
'httpversion' => '1.1',
'user-agent' => self::get_user_agent(),
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$code = $response['response']['code'] ?? 0;
if ( 200 !== $code ) {
return new WP_Error( 'request_failed', 'Tracks pixel request failed', $code );
}
return true;
}
/**
* Get the user agent.
*
* @return string The user agent.
*/
public static function get_user_agent() {
return self::USER_AGENT_SLUG . '-v' . self::VERSION;
}
/**
* Build an event and return its tracking URL
*
* @deprecated Call the `build_pixel_url` method on a Jetpack_Tracks_Event object instead.
* @param array $event Event keys and values.
* @return string URL of a tracking pixel.
*/
public static function build_pixel_url( $event ) {
$_event = new Jetpack_Tracks_Event( $event );
return $_event->build_pixel_url();
}
/**
* Validate input for a tracks event.
*
* @deprecated Instantiate a Jetpack_Tracks_Event object instead
* @param array $event Event keys and values.
* @return mixed Validated keys and values or WP_Error on failure
*/
private static function validate_and_sanitize( $event ) {
$_event = new Jetpack_Tracks_Event( $event );
if ( is_wp_error( $_event ) ) {
return $_event;
}
return get_object_vars( $_event );
}
/**
* Builds a timestamp.
*
* Milliseconds since 1970-01-01.
*
* @return string
*/
public static function build_timestamp() {
$ts = round( microtime( true ) * 1000 );
return number_format( $ts, 0, '', '' );
}
/**
* Grabs the user's anon id from cookies, or generates and sets a new one
*
* @return string An anon id for the user
*/
public static function get_anon_id() {
static $anon_id = null;
if ( ! isset( $anon_id ) ) {
// Did the browser send us a cookie?
if ( isset( $_COOKIE['tk_ai'] ) && preg_match( '#^[a-z]+:[A-Za-z0-9+/=]{24}$#', $_COOKIE['tk_ai'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
$anon_id = $_COOKIE['tk_ai']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
} else {
$binary = '';
// Generate a new anonId and try to save it in the browser's cookies.
// Note that base64-encoding an 18 character string generates a 24-character anon id.
for ( $i = 0; $i < 18; ++$i ) {
$binary .= chr( wp_rand( 0, 255 ) );
}
$anon_id = 'jetpack:' . base64_encode( $binary ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
if ( ! headers_sent()
&& ! ( defined( 'REST_REQUEST' ) && REST_REQUEST )
&& ! ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
) {
setcookie( 'tk_ai', $anon_id, 0, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), false ); // phpcs:ignore Jetpack.Functions.SetCookie -- This is a random value and should be fine.
}
}
}
return $anon_id;
}
/**
* Gets the WordPress.com user's Tracks identity, if connected.
*
* @return array|bool
*/
public static function get_connected_user_tracks_identity() {
$user_data = ( new Manager() )->get_connected_user_data();
if ( ! $user_data ) {
return false;
}
return array(
'blogid' => Jetpack_Options::get_option( 'id', 0 ),
'email' => $user_data['email'],
'userid' => $user_data['ID'],
'username' => $user_data['login'],
'user_locale' => $user_data['user_locale'],
);
}
}
@@ -0,0 +1,188 @@
<?php
/**
* Class Jetpack_Tracks_Event. Legacy.
*
* @package automattic/jetpack-sync
*/
/*
* Example Usage:
```php
require_once( dirname(__FILE__) . 'path/to/tracks/class-jetpack-tracks-event.php' );
$event = new Jetpack_Tracks_Event( array(
'_en' => $event_name, // required
'_ui' => $user_id, // required unless _ul is provided
'_ul' => $user_login, // required unless _ui is provided
// Optional, but recommended
'_via_ip' => $client_ip, // for geo, etc.
// Possibly useful to set some context for the event
'_via_ua' => $client_user_agent,
'_via_url' => $client_url,
'_via_ref' => $client_referrer,
// For user-targeted tests
'abtest_name' => $abtest_name,
'abtest_variation' => $abtest_variation,
// Your application-specific properties
'custom_property' => $some_value,
) );
if ( is_wp_error( $event->error ) ) {
// Handle the error in your app
}
$bump_and_redirect_pixel = $event->build_signed_pixel_url();
```
*/
/**
* Class Jetpack_Tracks_Event
*/
#[AllowDynamicProperties]
class Jetpack_Tracks_Event {
const EVENT_NAME_REGEX = '/^(([a-z0-9]+)_){2}([a-z0-9_]+)$/';
const PROP_NAME_REGEX = '/^[a-z_][a-z0-9_]*$/';
/**
* Tracks Event Error.
*
* @var mixed Error.
*/
public $error;
/**
* Jetpack_Tracks_Event constructor.
*
* @param object $event Tracks event.
*/
public function __construct( $event ) {
$_event = self::validate_and_sanitize( $event );
if ( is_wp_error( $_event ) ) {
$this->error = $_event;
return;
}
foreach ( $_event as $key => $value ) {
$this->{$key} = $value;
}
}
/**
* Record a track event.
*/
public function record() {
return Jetpack_Tracks_Client::record_event( $this );
}
/**
* Annotate the event with all relevant info.
*
* @param mixed $event Object or (flat) array.
* @return mixed The transformed event array or WP_Error on failure.
*/
public static function validate_and_sanitize( $event ) {
$event = (object) $event;
// Required.
if ( ! $event->_en ) {
return new WP_Error( 'invalid_event', 'A valid event must be specified via `_en`', 400 );
}
// delete non-routable addresses otherwise geoip will discard the record entirely.
if ( property_exists( $event, '_via_ip' ) && preg_match( '/^192\.168|^10\./', $event->_via_ip ) ) {
unset( $event->_via_ip );
}
$validated = array(
'browser_type' => Jetpack_Tracks_Client::BROWSER_TYPE,
'_aua' => Jetpack_Tracks_Client::get_user_agent(),
);
$_event = (object) array_merge( (array) $event, $validated );
// If you want to block property names, do it here.
// Make sure we have an event timestamp.
if ( ! isset( $_event->_ts ) ) {
$_event->_ts = Jetpack_Tracks_Client::build_timestamp();
}
return $_event;
}
/**
* Build a pixel URL that will send a Tracks event when fired.
* On error, returns an empty string ('').
*
* @return string A pixel URL or empty string ('') if there were invalid args.
*/
public function build_pixel_url() {
if ( $this->error ) {
return '';
}
$args = get_object_vars( $this );
// Request Timestamp and URL Terminator must be added just before the HTTP request or not at all.
unset( $args['_rt'] );
unset( $args['_'] );
$validated = self::validate_and_sanitize( $args );
if ( is_wp_error( $validated ) ) {
return '';
}
return Jetpack_Tracks_Client::PIXEL . '?' . http_build_query( $validated );
}
/**
* Validate the event name.
*
* @param string $name Event name.
* @return false|int
*/
public static function event_name_is_valid( $name ) {
return preg_match( self::EVENT_NAME_REGEX, $name );
}
/**
* Validates prop name
*
* @param string $name Property name.
*
* @return false|int Truthy value.
*/
public static function prop_name_is_valid( $name ) {
return preg_match( self::PROP_NAME_REGEX, $name );
}
/**
* Scrutinize event name.
*
* @param object $event Tracks event.
*/
public static function scrutinize_event_names( $event ) {
if ( ! self::event_name_is_valid( $event->_en ) ) {
return;
}
$whitelisted_key_names = array(
'anonId',
'Browser_Type',
);
foreach ( array_keys( (array) $event ) as $key ) {
if ( in_array( $key, $whitelisted_key_names, true ) ) {
continue;
}
if ( ! self::prop_name_is_valid( $key ) ) {
return;
}
}
}
}
@@ -0,0 +1,845 @@
<?php
/**
* Jetpack XMLRPC Server.
*
* @package automattic/jetpack-connection
*/
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Connection\Secrets;
use Automattic\Jetpack\Connection\Tokens;
use Automattic\Jetpack\Connection\Urls;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Roles;
/**
* Just a sack of functions. Not actually an IXR_Server
*/
class Jetpack_XMLRPC_Server {
/**
* The current error object
*
* @var \WP_Error
*/
public $error = null;
/**
* The current user
*
* @var \WP_User
*/
public $user = null;
/**
* The connection manager object.
*
* @var Automattic\Jetpack\Connection\Manager
*/
private $connection;
/**
* Creates a new XMLRPC server object.
*/
public function __construct() {
$this->connection = new Connection_Manager();
}
/**
* Whitelist of the XML-RPC methods available to the Jetpack Server. If the
* user is not authenticated (->login()) then the methods are never added,
* so they will get a "does not exist" error.
*
* @param array $core_methods Core XMLRPC methods.
*/
public function xmlrpc_methods( $core_methods ) {
$jetpack_methods = array(
'jetpack.verifyAction' => array( $this, 'verify_action' ),
'jetpack.idcUrlValidation' => array( $this, 'validate_urls_for_idc_mitigation' ),
'jetpack.unlinkUser' => array( $this, 'unlink_user' ),
'jetpack.testConnection' => array( $this, 'test_connection' ),
);
$jetpack_methods = array_merge( $jetpack_methods, $this->provision_xmlrpc_methods() );
$this->user = $this->login();
if ( $this->user ) {
$jetpack_methods = array_merge(
$jetpack_methods,
array(
'jetpack.testAPIUserCode' => array( $this, 'test_api_user_code' ),
)
);
if ( isset( $core_methods['metaWeblog.editPost'] ) ) {
$jetpack_methods['metaWeblog.newMediaObject'] = $core_methods['metaWeblog.newMediaObject'];
$jetpack_methods['jetpack.updateAttachmentParent'] = array( $this, 'update_attachment_parent' );
}
/**
* Filters the XML-RPC methods available to Jetpack for authenticated users.
*
* @since 1.7.0
* @since-jetpack 1.1.0
*
* @param array $jetpack_methods XML-RPC methods available to the Jetpack Server.
* @param array $core_methods Available core XML-RPC methods.
* @param \WP_User $user Information about the user authenticated in the request.
*/
$jetpack_methods = apply_filters( 'jetpack_xmlrpc_methods', $jetpack_methods, $core_methods, $this->user );
}
/**
* Filters the XML-RPC methods available to Jetpack for requests signed both with a blog token or a user token.
*
* @since 1.7.0
* @since 1.9.5 Introduced the $user parameter.
* @since-jetpack 3.0.0
*
* @param array $jetpack_methods XML-RPC methods available to the Jetpack Server.
* @param array $core_methods Available core XML-RPC methods.
* @param \WP_User|bool $user Information about the user authenticated in the request. False if authenticated with blog token.
*/
return apply_filters( 'jetpack_xmlrpc_unauthenticated_methods', $jetpack_methods, $core_methods, $this->user );
}
/**
* Whitelist of the bootstrap XML-RPC methods
*/
public function bootstrap_xmlrpc_methods() {
return array(
'jetpack.remoteAuthorize' => array( $this, 'remote_authorize' ),
'jetpack.remoteRegister' => array( $this, 'remote_register' ),
);
}
/**
* Additional method needed for authorization calls.
*/
public function authorize_xmlrpc_methods() {
return array(
'jetpack.remoteAuthorize' => array( $this, 'remote_authorize' ),
'jetpack.remoteRegister' => array( $this, 'remote_already_registered' ),
);
}
/**
* Remote provisioning methods.
*/
public function provision_xmlrpc_methods() {
return array(
'jetpack.remoteRegister' => array( $this, 'remote_register' ),
'jetpack.remoteProvision' => array( $this, 'remote_provision' ),
'jetpack.remoteConnect' => array( $this, 'remote_connect' ),
'jetpack.getUser' => array( $this, 'get_user' ),
);
}
/**
* Used to verify whether a local user exists and what role they have.
*
* @param int|string|array $request One of:
* int|string The local User's ID, username, or email address.
* array A request array containing:
* 0: int|string The local User's ID, username, or email address.
*
* @return array|\IXR_Error Information about the user, or error if no such user found:
* roles: string[] The user's rols.
* login: string The user's username.
* email_hash string[] The MD5 hash of the user's normalized email address.
* caps string[] The user's capabilities.
* allcaps string[] The user's granular capabilities, merged from role capabilities.
* token_key string The Token Key of the user's Jetpack token. Empty string if none.
*/
public function get_user( $request ) {
$user_id = is_array( $request ) ? $request[0] : $request;
if ( ! $user_id ) {
return $this->error(
new \WP_Error(
'invalid_user',
__( 'Invalid user identifier.', 'jetpack-connection' ),
400
),
'get_user'
);
}
$user = $this->get_user_by_anything( $user_id );
if ( ! $user ) {
return $this->error(
new \WP_Error(
'user_unknown',
__( 'User not found.', 'jetpack-connection' ),
404
),
'get_user'
);
}
$user_token = ( new Tokens() )->get_access_token( $user->ID );
if ( $user_token ) {
list( $user_token_key ) = explode( '.', $user_token->secret );
if ( $user_token_key === $user_token->secret ) {
$user_token_key = '';
}
} else {
$user_token_key = '';
}
return array(
'id' => $user->ID,
'login' => $user->user_login,
'email_hash' => md5( strtolower( trim( $user->user_email ) ) ),
'roles' => $user->roles,
'caps' => $user->caps,
'allcaps' => $user->allcaps,
'token_key' => $user_token_key,
);
}
/**
* Remote authorization XMLRPC method handler.
*
* @param array $request the request.
*/
public function remote_authorize( $request ) {
$user = get_user_by( 'id', $request['state'] );
/**
* Happens on various request handling events in the Jetpack XMLRPC server.
* The action combines several types of events:
* - remote_authorize
* - remote_provision
* - get_user.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param String $action the action name, i.e., 'remote_authorize'.
* @param String $stage the execution stage, can be 'begin', 'success', 'error', etc.
* @param array $parameters extra parameters from the event.
* @param WP_User $user the acting user.
*/
do_action( 'jetpack_xmlrpc_server_event', 'remote_authorize', 'begin', array(), $user );
foreach ( array( 'secret', 'state', 'redirect_uri', 'code' ) as $required ) {
if ( ! isset( $request[ $required ] ) || empty( $request[ $required ] ) ) {
return $this->error(
new \WP_Error( 'missing_parameter', 'One or more parameters is missing from the request.', 400 ),
'remote_authorize'
);
}
}
if ( ! $user ) {
return $this->error( new \WP_Error( 'user_unknown', 'User not found.', 404 ), 'remote_authorize' );
}
if ( $this->connection->has_connected_owner() && $this->connection->is_user_connected( $request['state'] ) ) {
return $this->error( new \WP_Error( 'already_connected', 'User already connected.', 400 ), 'remote_authorize' );
}
$verified = $this->verify_action( array( 'authorize', $request['secret'], $request['state'] ) );
if ( is_a( $verified, 'IXR_Error' ) ) {
return $this->error( $verified, 'remote_authorize' );
}
wp_set_current_user( $request['state'] );
$result = $this->connection->authorize( $request );
if ( is_wp_error( $result ) ) {
return $this->error( $result, 'remote_authorize' );
}
// This action is documented in class.jetpack-xmlrpc-server.php.
do_action( 'jetpack_xmlrpc_server_event', 'remote_authorize', 'success' );
return array(
'result' => $result,
);
}
/**
* This XML-RPC method is called from the /jpphp/provision endpoint on WPCOM in order to
* register this site so that a plan can be provisioned.
*
* @param array|ArrayAccess $request An array containing at minimum nonce and local_user keys.
*
* @return \WP_Error|array
*/
public function remote_register( $request ) {
// This action is documented in class.jetpack-xmlrpc-server.php.
do_action( 'jetpack_xmlrpc_server_event', 'remote_register', 'begin', array() );
$user = $this->fetch_and_verify_local_user( $request );
if ( ! $user ) {
return $this->error(
new WP_Error( 'input_error', __( 'Valid user is required', 'jetpack-connection' ), 400 ),
'remote_register'
);
}
if ( is_wp_error( $user ) || is_a( $user, 'IXR_Error' ) ) {
return $this->error( $user, 'remote_register' );
}
if ( empty( $request['nonce'] ) ) {
return $this->error(
new \WP_Error(
'nonce_missing',
__( 'The required "nonce" parameter is missing.', 'jetpack-connection' ),
400
),
'remote_register'
);
}
$nonce = sanitize_text_field( $request['nonce'] );
unset( $request['nonce'] );
$api_url = $this->connection->api_url( 'partner_provision_nonce_check' );
// @phan-suppress-next-line PhanAccessMethodInternal -- Phan is correct, but the usage is intentional.
$response = Client::_wp_remote_request(
esc_url_raw( add_query_arg( 'nonce', $nonce, $api_url ) ),
array( 'method' => 'GET' ),
true
);
if (
200 !== wp_remote_retrieve_response_code( $response ) ||
'OK' !== trim( wp_remote_retrieve_body( $response ) )
) {
return $this->error(
new \WP_Error(
'invalid_nonce',
__( 'There was an issue validating this request.', 'jetpack-connection' ),
400
),
'remote_register'
);
}
if ( ! Jetpack_Options::get_option( 'id' ) || ! ( new Tokens() )->get_access_token() || ! empty( $request['force'] ) ) {
wp_set_current_user( $user->ID );
// This code mostly copied from Jetpack::admin_page_load.
if ( isset( $request['from'] ) ) {
$this->connection->add_register_request_param( 'from', (string) $request['from'] );
}
$registered = $this->connection->try_registration();
if ( is_wp_error( $registered ) ) {
return $this->error( $registered, 'remote_register' );
} elseif ( ! $registered ) {
return $this->error(
new \WP_Error(
'registration_error',
__( 'There was an unspecified error registering the site', 'jetpack-connection' ),
400
),
'remote_register'
);
}
}
// This action is documented in class.jetpack-xmlrpc-server.php.
do_action( 'jetpack_xmlrpc_server_event', 'remote_register', 'success' );
return array(
'client_id' => Jetpack_Options::get_option( 'id' ),
);
}
/**
* This is a substitute for remote_register() when the blog is already registered which returns an error code
* signifying that state.
* This is an unauthorized call and we should not be responding with any data other than the error code.
*
* @return \IXR_Error
*/
public function remote_already_registered() {
return $this->error(
new \WP_Error( 'already_registered', __( 'Blog is already registered', 'jetpack-connection' ), 400 ),
'remote_register'
);
}
/**
* This XML-RPC method is called from the /jpphp/provision endpoint on WPCOM in order to
* register this site so that a plan can be provisioned.
*
* @param array|ArrayAccess $request An array containing at minimum a nonce key and a local_username key.
*
* @return \WP_Error|array
*/
public function remote_provision( $request ) {
$user = $this->fetch_and_verify_local_user( $request );
if ( ! $user ) {
return $this->error(
new WP_Error( 'input_error', __( 'Valid user is required', 'jetpack-connection' ), 400 ),
'remote_provision'
);
}
if ( is_wp_error( $user ) || is_a( $user, 'IXR_Error' ) ) {
return $this->error( $user, 'remote_provision' );
}
$site_icon = get_site_icon_url();
/**
* Filters the Redirect URI returned by the remote_register XMLRPC method
*
* @param string $redirect_uri The Redirect URI
*
* @since 1.9.7
*/
$redirect_uri = apply_filters( 'jetpack_xmlrpc_remote_register_redirect_uri', admin_url() );
// Generate secrets.
$roles = new Roles();
$role = $roles->translate_user_to_role( $user );
$secrets = ( new Secrets() )->generate( 'authorize', $user->ID );
$response = array(
'jp_version' => Constants::get_constant( 'JETPACK__VERSION' ),
'redirect_uri' => $redirect_uri,
'user_id' => $user->ID,
'user_email' => $user->user_email,
'user_login' => $user->user_login,
'scope' => $this->connection->sign_role( $role, $user->ID ),
'secret' => $secrets['secret_1'],
'is_active' => $this->connection->has_connected_owner(),
);
if ( $site_icon ) {
$response['site_icon'] = $site_icon;
}
/**
* Filters the response of the remote_provision XMLRPC method
*
* @param array $response The response.
* @param array $request An array containing at minimum a nonce key and a local_username key.
* @param \WP_User $user The local authenticated user.
*
* @since 1.9.7
*/
$response = apply_filters( 'jetpack_remote_xmlrpc_provision_response', $response, $request, $user );
return $response;
}
/**
* Given an array containing a local user identifier and a nonce, will attempt to fetch and set
* an access token for the given user.
*
* @param array|ArrayAccess $request An array containing local_user and nonce keys at minimum.
* @param \IXR_Client $ixr_client The client object, optional.
* @return mixed
*/
public function remote_connect( $request, $ixr_client = false ) {
if ( $this->connection->has_connected_owner() ) {
return $this->error(
new WP_Error(
'already_connected',
__( 'Jetpack is already connected.', 'jetpack-connection' ),
400
),
'remote_connect'
);
}
$user = $this->fetch_and_verify_local_user( $request );
if ( ! $user || is_wp_error( $user ) || is_a( $user, 'IXR_Error' ) ) {
return $this->error(
new WP_Error(
'input_error',
__( 'Valid user is required.', 'jetpack-connection' ),
400
),
'remote_connect'
);
}
if ( empty( $request['nonce'] ) ) {
return $this->error(
new WP_Error(
'input_error',
__( 'A non-empty nonce must be supplied.', 'jetpack-connection' ),
400
),
'remote_connect'
);
}
if ( ! $ixr_client ) {
$ixr_client = new Jetpack_IXR_Client();
}
// TODO: move this query into the Tokens class?
$ixr_client->query(
'jetpack.getUserAccessToken',
array(
'nonce' => sanitize_text_field( $request['nonce'] ),
'external_user_id' => $user->ID,
)
);
$token = $ixr_client->isError() ? false : $ixr_client->getResponse();
if ( empty( $token ) ) {
return $this->error(
new WP_Error(
'token_fetch_failed',
__( 'Failed to fetch user token from WordPress.com.', 'jetpack-connection' ),
400
),
'remote_connect'
);
}
$token = sanitize_text_field( $token );
( new Tokens() )->update_user_token( $user->ID, sprintf( '%s.%d', $token, $user->ID ), true );
/**
* Hook fired at the end of the jetpack.remoteConnect XML-RPC callback
*
* @since 1.9.7
*/
do_action( 'jetpack_remote_connect_end' );
return $this->connection->has_connected_owner();
}
/**
* Getter for the local user to act as.
*
* @param array $request the current request data.
* @return WP_User|IXR_Error|false IXR_Error if the request is missing a local_user field, WP_User object on success, or false on failure to find a user.
*/
private function fetch_and_verify_local_user( $request ) {
if ( empty( $request['local_user'] ) ) {
return $this->error(
new \WP_Error(
'local_user_missing',
__( 'The required "local_user" parameter is missing.', 'jetpack-connection' ),
400
),
'remote_provision'
);
}
// Local user is used to look up by login, email or ID.
$local_user_info = $request['local_user'];
return $this->get_user_by_anything( $local_user_info );
}
/**
* Gets the user object by its data.
*
* @param string $user_id can be any identifying user data.
* @return WP_User|false WP_User object on success, false on failure.
*/
private function get_user_by_anything( $user_id ) {
$user = get_user_by( 'login', $user_id );
if ( ! $user ) {
$user = get_user_by( 'email', $user_id );
}
if ( ! $user ) {
$user = get_user_by( 'ID', $user_id );
}
return $user;
}
/**
* Possible error_codes:
*
* - verify_secret_1_missing
* - verify_secret_1_malformed
* - verify_secrets_missing: verification secrets are not found in database
* - verify_secrets_incomplete: verification secrets are only partially found in database
* - verify_secrets_expired: verification secrets have expired
* - verify_secrets_mismatch: stored secret_1 does not match secret_1 sent by Jetpack.WordPress.com
* - state_missing: required parameter of state not found
* - state_malformed: state is not a digit
* - invalid_state: state in request does not match the stored state
*
* The 'authorize' and 'register' actions have additional error codes
*
* state_missing: a state ( user id ) was not supplied
* state_malformed: state is not the correct data type
* invalid_state: supplied state does not match the stored state
*
* @param array $params action An array of 3 parameters:
* [0]: string action. Possible values are `authorize`, `publicize` and `register`.
* [1]: string secret_1.
* [2]: int state.
* @return \IXR_Error|string IXR_Error on failure, secret_2 on success.
*/
public function verify_action( $params ) {
$action = $params[0] ?? '';
$verify_secret = $params[1] ?? '';
$state = $params[2] ?? '';
$result = ( new Secrets() )->verify( $action, $verify_secret, $state );
if ( is_wp_error( $result ) ) {
return $this->error( $result );
}
return $result;
}
/**
* Wrapper for wp_authenticate( $username, $password );
*
* @return \WP_User|bool
*/
public function login() {
$this->connection->require_jetpack_authentication();
$user = wp_authenticate( 'username', 'password' );
if ( is_wp_error( $user ) ) {
if ( 'authentication_failed' === $user->get_error_code() ) { // Generic error could mean most anything.
$this->error = new \WP_Error( 'invalid_request', 'Invalid Request', 403 );
} else {
$this->error = $user;
}
return false;
} elseif ( ! $user ) { // Shouldn't happen.
$this->error = new \WP_Error( 'invalid_request', 'Invalid Request', 403 );
return false;
}
wp_set_current_user( $user->ID );
return $user;
}
/**
* Returns the current error as an \IXR_Error
*
* @param \WP_Error|\IXR_Error $error The error object, optional.
* @param string $event_name The event name.
* @param \WP_User $user The user object.
* @return bool|\IXR_Error
*/
public function error( $error = null, $event_name = null, $user = null ) {
if ( null !== $event_name ) {
// This action is documented in class.jetpack-xmlrpc-server.php.
do_action( 'jetpack_xmlrpc_server_event', $event_name, 'fail', $error, $user );
}
if ( $error !== null ) {
$this->error = $error;
}
if ( is_wp_error( $this->error ) ) {
$code = $this->error->get_error_data();
if ( ! $code ) {
$code = -10520;
}
$message = sprintf( 'Jetpack: [%s] %s', $this->error->get_error_code(), $this->error->get_error_message() );
if ( ! class_exists( \IXR_Error::class ) ) {
require_once ABSPATH . WPINC . '/class-IXR.php';
}
return new \IXR_Error( $code, $message );
} elseif ( is_a( $this->error, 'IXR_Error' ) ) {
return $this->error;
}
return false;
}
/* API Methods */
/**
* Just authenticates with the given Jetpack credentials.
*
* @return string A success string. The Jetpack plugin filters it and make it return the Jetpack plugin version.
*/
public function test_connection() {
/**
* Filters the successful response of the XMLRPC test_connection method
*
* @param string $response The response string.
*/
return apply_filters( 'jetpack_xmlrpc_test_connection_response', 'success' );
}
/**
* Test the API user code.
*
* @param array $args arguments identifying the test site.
*/
public function test_api_user_code( $args ) {
$client_id = (int) $args[0];
$user_id = (int) $args[1];
$nonce = (string) $args[2];
$verify = (string) $args[3];
if ( ! $client_id || ! $user_id || ! strlen( $nonce ) || 32 !== strlen( $verify ) ) {
return false;
}
$user = get_user_by( 'id', $user_id );
if ( ! $user || is_wp_error( $user ) ) {
return false;
}
/* phpcs:ignore
debugging
error_log( "CLIENT: $client_id" );
error_log( "USER: $user_id" );
error_log( "NONCE: $nonce" );
error_log( "VERIFY: $verify" );
*/
$jetpack_token = ( new Tokens() )->get_access_token( $user_id );
$api_user_code = get_user_meta( $user_id, "jetpack_json_api_$client_id", true );
if ( ! $api_user_code ) {
return false;
}
$hmac = hash_hmac(
'md5',
json_encode( // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
(object) array(
'client_id' => $client_id,
'user_id' => $user_id,
'nonce' => $nonce,
'code' => (string) $api_user_code,
),
0 // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.ZeroFound -- No `json_encode()` flags because this needs to match whatever is calculating the hash on the other end.
),
$jetpack_token->secret
);
if ( ! hash_equals( $hmac, $verify ) ) {
return false;
}
return $user_id;
}
/**
* Unlink a user from WordPress.com
*
* When the request is done without any parameter, this XMLRPC callback gets an empty array as input.
*
* If $user_id is not provided, it will try to disconnect the current logged in user. This will fail if called by the Master User.
*
* If $user_id is is provided, it will try to disconnect the informed user, even if it's the Master User.
*
* @param mixed $user_id The user ID to disconnect from this site.
*/
public function unlink_user( $user_id = array() ) {
$user_id = (int) $user_id;
if ( $user_id < 1 ) {
$user_id = null;
}
/**
* Fired when we want to log an event to the Jetpack event log.
*
* @since 1.7.0
* @since-jetpack 7.7.0
*
* @param string $code Unique name for the event.
* @param string $data Optional data about the event.
*/
do_action( 'jetpack_event_log', 'unlink' );
return $this->connection->disconnect_user(
$user_id,
(bool) $user_id
);
}
/**
* Returns the home URL, site URL, and URL secret for the current site which can be used on the WPCOM side for
* IDC mitigation to decide whether sync should be allowed if the home and siteurl values differ between WPCOM
* and the remote Jetpack site.
*
* @since 1.56.0 Additional data may be added via filter `jetpack_connection_validate_urls_for_idc_mitigation_response`.
*
* @return array
*/
public function validate_urls_for_idc_mitigation() {
$response = array(
'home' => Urls::home_url(),
'siteurl' => Urls::site_url(),
);
/**
* Allows modifying the response.
*
* @since 1.56.0
*
* @param array $response
*/
return apply_filters( 'jetpack_connection_validate_urls_for_idc_mitigation_response', $response );
}
/**
* Updates the attachment parent object.
*
* @param array $args attachment and parent identifiers.
*/
public function update_attachment_parent( $args ) {
$attachment_id = (int) $args[0];
$parent_id = (int) $args[1];
return wp_update_post(
array(
'ID' => $attachment_id,
'post_parent' => $parent_id,
)
);
}
/**
* Deprecated: This method is no longer part of the Connection package and now lives on the Jetpack plugin.
*
* Disconnect this blog from the connected wordpress.com account
*
* @deprecated since 1.25.0
* @see Jetpack_XMLRPC_Methods::disconnect_blog() in the Jetpack plugin
*
* @return boolean
*/
public function disconnect_blog() {
_deprecated_function( __METHOD__, '1.25.0', 'Jetpack_XMLRPC_Methods::disconnect_blog()' );
if ( class_exists( 'Jetpack_XMLRPC_Methods' ) ) {
return Jetpack_XMLRPC_Methods::disconnect_blog();
}
return false;
}
/**
* Deprecated: This method is no longer part of the Connection package and now lives on the Jetpack plugin.
*
* Serve a JSON API request.
*
* @deprecated since 1.25.0
* @see Jetpack_XMLRPC_Methods::json_api() in the Jetpack plugin
*
* @param array $args request arguments.
*/
public function json_api( $args = array() ) {
_deprecated_function( __METHOD__, '1.25.0', 'Jetpack_XMLRPC_Methods::json_api()' );
if ( class_exists( 'Jetpack_XMLRPC_Methods' ) ) {
return Jetpack_XMLRPC_Methods::json_api( $args );
}
return array();
}
}
@@ -0,0 +1,223 @@
<?php
/**
* Jetpack Connection Abilities Registration.
*
* Registers Jetpack Connection abilities with the WordPress Abilities API so
* AI agents can inspect the site's connection state through the standard
* `wp-abilities/v1` REST surface.
*
* @package automattic/jetpack-connection
*/
// @phan-file-suppress PhanUndeclaredFunction, PhanUndeclaredClassMethod @phan-suppress-current-line UnusedSuppression -- Abilities API added in WP 6.9; suppressions needed for older-WP compatibility runs.
namespace Automattic\Jetpack\Connection\Abilities;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Connection\Package_Version;
use Automattic\Jetpack\WP_Abilities\Registrar;
use Jetpack_Options;
/**
* Registers Jetpack Connection abilities with the WordPress Abilities API.
*
* Exposes a single read-only ability for site-level connection state so AI
* agents can answer "is this site registered?" without having to
* reverse-engineer Jetpack_Options keys.
*
* Writes (registering a new site, disconnecting a user, transferring
* ownership) are deliberately deferred to a follow-up PR.
*/
class Connection_Abilities extends Registrar {
const CATEGORY_SLUG = 'jetpack';
/**
* {@inheritDoc}
*/
public static function get_category_slug(): string {
return self::CATEGORY_SLUG;
}
/**
* {@inheritDoc}
*
* The `jetpack` ability-category is shared with other Jetpack registrars
* (e.g. the Modules_Abilities class in the Jetpack plugin). Only the first
* registration wins, so the English source string is kept byte-identical
* across registrars to keep the visible category text consistent
* regardless of load order.
*/
public static function get_category_definition(): array {
return array(
// "Jetpack" is a product name and should not be translated.
'label' => 'Jetpack',
'description' => __( 'Abilities provided by Jetpack.', 'jetpack-connection' ),
);
}
/**
* {@inheritDoc}
*/
public static function get_abilities(): array {
return array(
'jetpack/get-connection-status' => self::spec_get_connection_status(),
);
}
/*
---------------------------------------------------------------------
* Ability specs
* ---------------------------------------------------------------------
*/
/**
* Spec: jetpack/get-connection-status.
*/
private static function spec_get_connection_status(): array {
return array(
'label' => __( 'Get Jetpack connection status', 'jetpack-connection' ),
'description' => __(
'Return the site-level Jetpack connection state in one zero-argument call. Shape: { site_registered, user_connected, master_user, blog_id, registration_url, connection_version }. `site_registered` is true when the site has a blog id and a blog token. `user_connected` is true when at least one user has linked their WordPress.com account. `master_user` is the local user id of the connection owner (the user who registered the site), or null if there is no owner. `blog_id` is the WordPress.com site id, or null when the site has not been registered. `registration_url` is the wp-admin URL the site owner should visit to register the site when `site_registered` is false; null once the site is registered. `connection_version` is the running Jetpack Connection package version. Read-only and idempotent — safe to poll.',
'jetpack-connection'
),
'input_schema' => array(
'type' => 'object',
'properties' => new \stdClass(),
'additionalProperties' => false,
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'site_registered' => array( 'type' => 'boolean' ),
'user_connected' => array( 'type' => 'boolean' ),
'master_user' => array( 'type' => array( 'integer', 'null' ) ),
'blog_id' => array( 'type' => array( 'integer', 'null' ) ),
'registration_url' => array( 'type' => array( 'string', 'null' ) ),
'connection_version' => array( 'type' => 'string' ),
),
),
'execute_callback' => array( __CLASS__, 'get_connection_status' ),
'permission_callback' => array( __CLASS__, 'can_view_connection' ),
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
'mcp' => array(
'public' => true,
'type' => 'tool', // default is already "tool", but can be explicit.
),
),
);
}
/*
---------------------------------------------------------------------
* Permission callbacks
* ---------------------------------------------------------------------
*/
/**
* Permission check: mirrors the capability used by the Jetpack admin page.
*
* Connection state is not sensitive in itself (the same data is exposed
* on the Jetpack admin page and through several existing REST endpoints),
* but subscribers and contributors have no legitimate need to inspect it.
* Gating on `jetpack_admin_page` aligns with how {@see Modules_Abilities}
* scopes its read.
*
* @return bool
*/
public static function can_view_connection(): bool {
return current_user_can( 'jetpack_admin_page' );
}
/*
---------------------------------------------------------------------
* Execute callbacks
* ---------------------------------------------------------------------
*/
/**
* Execute: get-connection-status.
*
* @param array|null $input Ignored zero-arg ability.
* @return array
*/
public static function get_connection_status( $input = null ) {
unset( $input );
$manager = self::get_manager();
$site_registered = (bool) $manager->is_connected();
$user_connected = (bool) $manager->has_connected_user();
$master_user_raw = Jetpack_Options::get_option( 'master_user' );
$master_user = is_numeric( $master_user_raw ) && (int) $master_user_raw > 0 ? (int) $master_user_raw : null;
$blog_id_raw = Jetpack_Options::get_option( 'id' );
$blog_id = is_numeric( $blog_id_raw ) && (int) $blog_id_raw > 0 ? (int) $blog_id_raw : null;
return array(
'site_registered' => $site_registered,
'user_connected' => $user_connected,
'master_user' => $master_user,
'blog_id' => $blog_id,
'registration_url' => $site_registered ? null : self::registration_url(),
'connection_version' => Package_Version::PACKAGE_VERSION,
);
}
/*
---------------------------------------------------------------------
* Helpers
* ---------------------------------------------------------------------
*/
/**
* Return a Connection_Manager instance. Filterable for tests so they can
* inject a partial mock without having to seed Jetpack_Options + tokens.
*
* @return Connection_Manager
*/
protected static function get_manager(): Connection_Manager {
/**
* Filters the Connection_Manager instance used by the Connection abilities.
*
* Tests inject a partial mock here; production callers should leave
* the default. The filter callback receives the package-default
* instance and must return a Connection_Manager non-Manager
* returns are discarded.
*
* @since 8.4.0
*
* @param Connection_Manager $manager The default instance.
*/
$instance = apply_filters( 'jetpack_connection_abilities_manager', new Connection_Manager() );
return $instance instanceof Connection_Manager ? $instance : new Connection_Manager();
}
/**
* Build the wp-admin URL the site owner should visit to register the
* site to WordPress.com. We deliberately return a stable admin URL (no
* secret generation, no XML-RPC roundtrip) so this read stays side-effect
* free and cheap to poll. The destination page handles the actual
* registration handshake from there.
*
* WP 7.0+ ships a core "Connectors" screen at `wp-admin/options-connectors.php`
* with a Jetpack card registered by {@see Jetpack_Connector}. We probe
* for the file directly (rather than a `class_exists()` on the registry)
* because the file is what actually serves the URL if it isn't on
* disk, the redirect 404s regardless of which classes have loaded.
*
* @return string
*/
private static function registration_url(): string {
if ( defined( 'ABSPATH' ) && file_exists( ABSPATH . 'wp-admin/options-connectors.php' ) ) {
return admin_url( 'options-connectors.php' );
}
return admin_url( 'admin.php?page=jetpack' );
}
}
@@ -0,0 +1,281 @@
<?php
/**
* Authorize_Json_Api handler class.
* Used to handle connections via JSON API.
* Ported from the Jetpack class.
*
* @since 2.7.6 Ported from the Jetpack class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Status\Host;
use Jetpack_Options;
/**
* Authorize_Json_Api handler class.
*/
class Authorize_Json_Api {
/**
* Verified data for JSON authorization request
*
* @since 2.7.6
*
* @var array
*/
public $json_api_authorization_request = array();
/**
* Verifies the request by checking the signature
*
* @since jetpack-4.6.0 Method was updated to use `$_REQUEST` instead of `$_GET` and `$_POST`. Method also updated to allow
* passing in an `$environment` argument that overrides `$_REQUEST`. This was useful for integrating with SSO.
* @since 2.7.6 Ported from Jetpack to the Connection package.
*
* @param null|array $environment Value to override $_REQUEST.
*
* @return void
*/
public function verify_json_api_authorization_request( $environment = null ) {
$environment = $environment === null
? $_REQUEST // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verification handled later in function and request data are 1) used to verify a cryptographic signature of the request data and 2) sanitized later in function.
: $environment;
if ( ! isset( $environment['token'] ) ) {
wp_die( esc_html__( 'You must connect your Jetpack plugin to WordPress.com to use this feature.', 'jetpack-connection' ) );
}
list( $env_token,, $env_user_id ) = explode( ':', $environment['token'] );
$token = ( new Tokens() )->get_access_token( (int) $env_user_id, $env_token );
if ( ! $token || empty( $token->secret ) ) {
wp_die( esc_html__( 'You must connect your Jetpack plugin to WordPress.com to use this feature.', 'jetpack-connection' ) );
}
$die_error = __( 'Someone may be trying to trick you into giving them access to your site. Or it could be you just encountered a bug :). Either way, please close this window.', 'jetpack-connection' );
// Host has encoded the request URL, probably as a result of a bad http => https redirect.
if (
preg_match( '/https?%3A%2F%2F/i', esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) ) > 0 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- no site changes, we're erroring out.
) {
/**
* Jetpack authorisation request Error.
*
* @since jetpack-7.5.0
*/
do_action( 'jetpack_verify_api_authorization_request_error_double_encode' );
$die_error = sprintf(
/* translators: %s is a URL */
__( 'Your site is incorrectly double-encoding redirects from http to https. This is preventing Jetpack from authenticating your connection. Please visit our <a href="%s">support page</a> for details about how to resolve this.', 'jetpack-connection' ),
esc_url( Redirect::get_url( 'jetpack-support-double-encoding' ) )
);
}
$jetpack_signature = new \Jetpack_Signature( $token->secret, (int) Jetpack_Options::get_option( 'time_diff' ) );
if ( isset( $environment['jetpack_json_api_original_query'] ) ) {
$signature = $jetpack_signature->sign_request(
$environment['token'],
$environment['timestamp'],
$environment['nonce'],
'',
'GET',
$environment['jetpack_json_api_original_query'],
null,
true
);
} else {
$signature = $jetpack_signature->sign_current_request(
array(
'body' => null,
'method' => 'GET',
)
);
}
if ( ! $signature ) {
wp_die(
wp_kses(
$die_error,
array(
'a' => array(
'href' => array(),
),
)
)
);
} elseif ( is_wp_error( $signature ) ) {
wp_die(
wp_kses(
$die_error,
array(
'a' => array(
'href' => array(),
),
)
)
);
} elseif ( ! hash_equals( $signature, $environment['signature'] ) ) {
if ( is_ssl() ) {
// If we signed an HTTP request on the Jetpack Servers, but got redirected to HTTPS by the local blog, check the HTTP signature as well.
$signature = $jetpack_signature->sign_current_request(
array(
'scheme' => 'http',
'body' => null,
'method' => 'GET',
)
);
if ( ! $signature || is_wp_error( $signature ) || ! hash_equals( $signature, $environment['signature'] ) ) {
wp_die(
wp_kses(
$die_error,
array(
'a' => array(
'href' => array(),
),
)
)
);
}
} else {
wp_die(
wp_kses(
$die_error,
array(
'a' => array(
'href' => array(),
),
)
)
);
}
}
$timestamp = (int) $environment['timestamp'];
$nonce = stripslashes( (string) $environment['nonce'] );
if ( ! ( new Nonce_Handler() )->add( $timestamp, $nonce ) ) {
// De-nonce the nonce, at least for 5 minutes.
// We have to reuse this nonce at least once (used the first time when the initial request is made, used a second time when the login form is POSTed).
$old_nonce_time = get_option( "jetpack_nonce_{$timestamp}_{$nonce}" );
if ( $old_nonce_time < time() - 300 ) {
wp_die( esc_html__( 'The authorization process expired. Please go back and try again.', 'jetpack-connection' ) );
}
}
$data = json_decode(
base64_decode( stripslashes( $environment['data'] ) ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
);
$data_filters = array(
'state' => 'opaque',
'client_id' => 'int',
'client_title' => 'string',
'client_image' => 'url',
);
foreach ( $data_filters as $key => $sanitation ) {
if ( ! isset( $data->$key ) ) {
wp_die(
wp_kses(
$die_error,
array(
'a' => array(
'href' => array(),
),
)
)
);
}
switch ( $sanitation ) {
case 'int':
$this->json_api_authorization_request[ $key ] = (int) $data->$key;
break;
case 'opaque':
$this->json_api_authorization_request[ $key ] = (string) $data->$key;
break;
case 'string':
$this->json_api_authorization_request[ $key ] = wp_kses( (string) $data->$key, array() );
break;
case 'url':
$this->json_api_authorization_request[ $key ] = esc_url_raw( (string) $data->$key );
break;
}
}
if ( empty( $this->json_api_authorization_request['client_id'] ) ) {
wp_die(
wp_kses(
$die_error,
array(
'a' => array(
'href' => array(),
),
)
)
);
}
}
/**
* Add the Access Code details to the public-api.wordpress.com redirect.
*
* @since 2.7.6 Ported from Jetpack to the Connection package.
*
* @param string $redirect_to URL.
* @param string $original_redirect_to URL.
* @param \WP_User $user WP_User for the redirect.
*
* @return string
*/
public function add_token_to_login_redirect_json_api_authorization( $redirect_to, $original_redirect_to, $user ) {
return add_query_arg(
urlencode_deep(
array(
'jetpack-code' => get_user_meta(
$user->ID,
'jetpack_json_api_' . $this->json_api_authorization_request['client_id'],
true
),
'jetpack-user-id' => (int) $user->ID,
'jetpack-state' => $this->json_api_authorization_request['state'],
)
),
$redirect_to
);
}
/**
* If someone logs in to approve API access, store the Access Code in usermeta.
*
* @since 2.7.6 Ported from Jetpack to the Connection package.
*
* @param string $user_login Unused.
* @param \WP_User $user User logged in.
*
* @return void
*/
public function store_json_api_authorization_token( $user_login, $user ) {
add_filter( 'login_redirect', array( $this, 'add_token_to_login_redirect_json_api_authorization' ), 10, 3 );
add_filter( 'allowed_redirect_hosts', array( Host::class, 'allow_wpcom_public_api_domain' ) );
$token = wp_generate_password( 32, false );
update_user_meta( $user->ID, 'jetpack_json_api_' . $this->json_api_authorization_request['client_id'], $token );
}
/**
* HTML for the JSON API authorization notice.
*
* @since 2.7.6 Ported from Jetpack to the Connection package.
*
* @return string
*/
public function login_message_json_api_authorization() {
return '<p class="message">' . sprintf(
/* translators: Name/image of the client requesting authorization */
esc_html__( '%s wants to access your sites data. Log in to authorize that access.', 'jetpack-connection' ),
'<strong>' . esc_html( $this->json_api_authorization_request['client_title'] ) . '</strong>'
) . '<img src="' . esc_url( $this->json_api_authorization_request['client_image'] ) . '" /></p>';
}
}
@@ -0,0 +1,513 @@
<?php
/**
* The Connection Client class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Constants;
use WP_Error;
// `wp_remote_request` returns an array with a particular format.
'@phan-type _WP_Remote_Response_Array = array{headers:\WpOrg\Requests\Utility\CaseInsensitiveDictionary,body:string,response:array{code:int,message:string},cookies:\WP_HTTP_Cookie[],filename:?string,http_response:WP_HTTP_Requests_Response}';
/**
* The Client class that is used to connect to WordPress.com Jetpack API.
*/
class Client {
const WPCOM_JSON_API_VERSION = '1.1';
/**
* Makes an authorized remote request using Jetpack_Signature
*
* @param array $args the arguments for the remote request.
* @param array|string|null $body the request body.
* @return array|WP_Error WP HTTP response on success
* @phan-return _WP_Remote_Response_Array|WP_Error
*/
public static function remote_request( $args, $body = null ) {
if ( isset( $args['url'] ) ) {
/**
* Filters the remote request url.
*
* @since 1.30.12
*
* @param string The remote request url.
*/
$args['url'] = apply_filters( 'jetpack_remote_request_url', $args['url'] );
}
$result = self::build_signed_request( $args, $body );
if ( is_wp_error( $result ) ) {
return $result;
}
$response = self::_wp_remote_request( $result['url'], $result['request'] );
Error_Handler::get_instance()->check_api_response_for_errors(
$response,
$result['auth'],
empty( $args['url'] ) ? '' : $args['url'],
empty( $args['method'] ) ? 'POST' : $args['method'],
'rest'
);
/**
* Fired when the remote request response has been received.
*
* @since 1.30.8
*
* @param array|WP_Error The HTTP response.
*/
do_action( 'jetpack_received_remote_request_response', $response );
return $response;
}
/**
* Adds authorization signature to a remote request using Jetpack_Signature
*
* @param array $args the arguments for the remote request.
* @param array|string|null $body the request body.
* @return WP_Error|array{url:string,request:array,auth:array} {
* An array containing URL and request items.
*
* @type string $url The request URL.
* @type array $request Request arguments.
* @type array $auth Authorization data.
* }
*/
public static function build_signed_request( $args, $body = null ) {
add_filter(
'jetpack_constant_default_value',
__NAMESPACE__ . '\Utils::jetpack_api_constant_filter',
10,
2
);
$defaults = array(
'url' => '',
'user_id' => 0,
'blog_id' => 0,
'auth_location' => Constants::get_constant( 'JETPACK_CLIENT__AUTH_LOCATION' ),
'method' => 'POST',
'format' => 'json',
'timeout' => 10,
'redirection' => 0,
'headers' => array(),
'stream' => false,
'filename' => null,
'sslverify' => true,
);
$args = wp_parse_args( $args, $defaults );
$args['blog_id'] = (int) $args['blog_id'];
if ( 'header' !== $args['auth_location'] ) {
$args['auth_location'] = 'query_string';
}
$token = ( new Tokens() )->get_access_token( $args['user_id'] );
if ( ! $token ) {
return new WP_Error( 'missing_token' );
}
$method = strtoupper( $args['method'] );
$timeout = (int) $args['timeout'];
$redirection = $args['redirection'];
$stream = $args['stream'];
$filename = $args['filename'];
$sslverify = $args['sslverify'];
$request = compact( 'method', 'body', 'timeout', 'redirection', 'stream', 'filename', 'sslverify' );
@list( $token_key, $secret ) = explode( '.', $token->secret ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( ! $secret ) {
return new WP_Error( 'malformed_token' );
}
$token_key = sprintf(
'%s:%d:%d',
$token_key,
Constants::get_constant( 'JETPACK__API_VERSION' ),
$token->external_user_id
);
$time_diff = (int) \Jetpack_Options::get_option( 'time_diff' );
$jetpack_signature = new \Jetpack_Signature( $token->secret, $time_diff );
$timestamp = time() + $time_diff;
if ( function_exists( 'wp_generate_password' ) ) {
$nonce = wp_generate_password( 10, false );
} else {
$nonce = substr( sha1( (string) wp_rand( 0, 1000000 ) ), 0, 10 );
}
// Kind of annoying. Maybe refactor Jetpack_Signature to handle body-hashing.
if ( $body === null ) {
$body_hash = '';
} else {
// Allow arrays to be used in passing data.
$body_to_hash = $body;
if ( $args['format'] === 'jsonl' ) {
parse_str( $body, $body_to_hash );
}
if ( is_array( $body_to_hash ) ) {
// We cast this to a new variable, because the array form of $body needs to be
// maintained so it can be passed into the request later on in the code.
if ( array() !== $body_to_hash ) {
$body_to_hash = wp_json_encode(
self::_stringify_data( $body_to_hash ),
0 // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.ZeroFound -- No `json_encode()` flags because this needs to match whatever is calculating the hash on the other end.
);
} else {
$body_to_hash = '';
}
}
if ( ! is_string( $body_to_hash ) ) {
return new WP_Error( 'invalid_body', 'Body is malformed.' );
}
$body_hash = base64_encode( sha1( $body_to_hash, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}
$auth = array(
'token' => $token_key,
'timestamp' => $timestamp,
'nonce' => $nonce,
'body-hash' => $body_hash,
);
if ( false !== strpos( $args['url'], 'xmlrpc.php' ) ) {
$url_args = array(
'for' => 'jetpack',
'wpcom_blog_id' => \Jetpack_Options::get_option( 'id' ),
);
} else {
$url_args = array();
}
if ( 'header' !== $args['auth_location'] ) {
$url_args += $auth;
}
$url = add_query_arg( urlencode_deep( $url_args ), $args['url'] );
$signature = $jetpack_signature->sign_request( $token_key, $timestamp, $nonce, $body_hash, $method, $url, $body, false );
if ( is_wp_error( $signature ) ) {
return $signature;
}
// Send an Authorization header so various caches/proxies do the right thing.
$auth['signature'] = $signature;
$auth['version'] = Constants::get_constant( 'JETPACK__VERSION' );
$header_pieces = array();
foreach ( $auth as $key => $value ) {
$header_pieces[] = sprintf( '%s="%s"', $key, $value );
}
$request['headers'] = array_merge(
$args['headers'],
array(
'Authorization' => 'X_JETPACK ' . implode( ' ', $header_pieces ),
)
);
if ( 'header' !== $args['auth_location'] ) {
$url = add_query_arg( 'signature', rawurlencode( $signature ), $url );
}
return compact( 'url', 'request', 'auth' );
}
/**
* Wrapper for wp_remote_request(). Turns off SSL verification for certain SSL errors.
* This is lame, but many, many, many hosts have misconfigured SSL.
*
* When Jetpack is registered, the jetpack_fallback_no_verify_ssl_certs option is set to the current time if:
* 1. a certificate error is found AND
* 2. not verifying the certificate works around the problem.
*
* The option is checked on each request.
*
* @internal
*
* @param string $url the request URL.
* @param array $args request arguments.
* @param boolean $set_fallback whether to allow flagging this request to use a fallback certficate override.
* @return array|WP_Error WP HTTP response on success
* @phan-return _WP_Remote_Response_Array|WP_Error
*/
public static function _wp_remote_request( $url, $args, $set_fallback = false ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
$fallback = \Jetpack_Options::get_option( 'fallback_no_verify_ssl_certs' );
if ( false === $fallback ) {
\Jetpack_Options::update_option( 'fallback_no_verify_ssl_certs', 0 );
}
/**
* SSL verification (`sslverify`) for the JetpackClient remote request
* defaults to off, use this filter to force it on.
*
* Return `true` to ENABLE SSL verification, return `false`
* to DISABLE SSL verification.
*
* @since 1.7.0
* @since-jetpack 3.6.0
*
* @param bool Whether to force `sslverify` or not.
*/
if ( apply_filters( 'jetpack_client_verify_ssl_certs', false ) ) {
return wp_remote_request( $url, $args );
}
if ( (int) $fallback ) {
// We're flagged to fallback.
$args['sslverify'] = false;
}
$response = wp_remote_request( $url, $args );
if (
! $set_fallback // We're not allowed to set the flag on this request, so whatever happens happens.
||
isset( $args['sslverify'] ) && ! $args['sslverify'] // No verification - no point in doing it again.
||
! is_wp_error( $response ) // Let it ride.
) {
self::set_time_diff( $response, $set_fallback );
return $response;
}
// At this point, we're not flagged to fallback and we are allowed to set the flag on this request.
$message = $response->get_error_message();
// Is it an SSL Certificate verification error?
if (
false === strpos( $message, '14090086' ) // OpenSSL SSL3 certificate error.
&&
false === strpos( $message, '1407E086' ) // OpenSSL SSL2 certificate error.
&&
false === strpos( $message, 'error setting certificate verify locations' ) // cURL CA bundle not found.
&&
false === strpos( $message, 'Peer certificate cannot be authenticated with' ) // cURL CURLE_SSL_CACERT: CA bundle found, but not helpful
// Different versions of curl have different error messages
// this string should catch them all.
&&
false === strpos( $message, 'Problem with the SSL CA cert' ) // cURL CURLE_SSL_CACERT_BADFILE: probably access rights.
) {
// No, it is not.
return $response;
}
// Redo the request without SSL certificate verification.
$args['sslverify'] = false;
$response = wp_remote_request( $url, $args );
if ( ! is_wp_error( $response ) ) {
// The request went through this time, flag for future fallbacks.
\Jetpack_Options::update_option( 'fallback_no_verify_ssl_certs', time() );
self::set_time_diff( $response, $set_fallback );
}
return $response;
}
/**
* Sets the time difference for correct signature computation.
*
* @param array|WP_Error $response Response array from `wp_remote_request`, or WP_Error on error.
* @param bool $force_set whether to force setting the time difference.
* @phan-param _WP_Remote_Response_Array|WP_Error $response
*/
public static function set_time_diff( &$response, $force_set = false ) {
$code = wp_remote_retrieve_response_code( $response );
// Only trust the Date header on some responses.
if ( 200 != $code && 304 != $code && 400 != $code && 401 != $code ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual
return;
}
$date = wp_remote_retrieve_header( $response, 'date' );
if ( ! $date ) {
return;
}
$time = (int) strtotime( $date );
if ( 0 >= $time ) {
return;
}
$time_diff = $time - time();
if ( $force_set ) { // During register.
\Jetpack_Options::update_option( 'time_diff', $time_diff );
} else { // Otherwise.
$old_diff = \Jetpack_Options::get_option( 'time_diff' );
if ( false === $old_diff || abs( $time_diff - (int) $old_diff ) > 10 ) {
\Jetpack_Options::update_option( 'time_diff', $time_diff );
}
}
}
/**
* Validate and build arguments for a WordPress.com REST API request.
*
* @param string $path REST API path.
* @param string $version REST API version. Default is `2`.
* @param array $args Arguments to {@see WP_Http}. Default is `array()`.
* @param string $base_api_path REST API root. Default is `wpcom`.
*
* @return array Validated arguments.
*/
public static function validate_args_for_wpcom_json_api_request(
$path,
$version = '2',
$args = array(),
$base_api_path = 'wpcom'
) {
$base_api_path = trim( $base_api_path, '/' );
$version = ltrim( $version, 'v' );
$path = ltrim( $path, '/' );
$filtered_args = array_intersect_key(
$args,
array(
'headers' => 'array',
'method' => 'string',
'format' => 'string',
'timeout' => 'int',
'redirection' => 'int',
'stream' => 'boolean',
'filename' => 'string',
'sslverify' => 'boolean',
)
);
// Use GET by default whereas `remote_request` uses POST.
$request_method = isset( $filtered_args['method'] ) ? strtoupper( $filtered_args['method'] ) : 'GET';
$url = sprintf(
'%s/%s/v%s/%s',
Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
$base_api_path,
$version,
$path
);
$validated_args = array_merge(
$filtered_args,
array(
'url' => $url,
'method' => $request_method,
)
);
return $validated_args;
}
/**
* Queries the WordPress.com REST API with a user token.
*
* @param string $path REST API path.
* @param string $version REST API version. Default is `2`.
* @param array $args Arguments to {@see WP_Http}. Default is `array()`.
* @param null|string|array $body Body passed to {@see WP_Http}. Default is `null`.
* @param string $base_api_path REST API root. Default is `wpcom`.
*
* @return array|WP_Error $response Response data, else {@see WP_Error} on failure.
* @phan-return _WP_Remote_Response_Array|WP_Error
*/
public static function wpcom_json_api_request_as_user(
$path,
$version = '2',
$args = array(),
$body = null,
$base_api_path = 'wpcom'
) {
$args = self::validate_args_for_wpcom_json_api_request( $path, $version, $args, $base_api_path );
$args['user_id'] = get_current_user_id();
if ( isset( $body ) && ! isset( $args['headers'] ) && in_array( $args['method'], array( 'POST', 'PUT', 'PATCH' ), true ) ) {
$args['headers'] = array( 'Content-Type' => 'application/json' );
}
if ( isset( $body ) && ! is_string( $body ) ) {
$body = wp_json_encode( $body, JSON_UNESCAPED_SLASHES );
}
return self::remote_request( $args, $body );
}
/**
* Query the WordPress.com REST API using the blog token
*
* @param string $path The API endpoint relative path.
* @param string $version The API version.
* @param array $args Request arguments.
* @param array|string|null $body Request body.
* @param string $base_api_path (optional) the API base path override, defaults to 'rest'.
* @return array|WP_Error $response Data.
* @phan-return _WP_Remote_Response_Array|WP_Error
*/
public static function wpcom_json_api_request_as_blog(
$path,
$version = self::WPCOM_JSON_API_VERSION,
$args = array(),
$body = null,
$base_api_path = 'rest'
) {
$validated_args = self::validate_args_for_wpcom_json_api_request( $path, $version, $args, $base_api_path );
$validated_args['blog_id'] = (int) \Jetpack_Options::get_option( 'id' );
// For Simple sites get the response directly without any HTTP requests.
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
add_filter( 'is_jetpack_authorized_for_site', '__return_true' );
require_lib( 'wpcom-api-direct' );
return \WPCOM_API_Direct::do_request( $validated_args, $body );
}
return self::remote_request( $validated_args, $body );
}
/**
* Takes an array or similar structure and recursively turns all values into strings. This is used to
* make sure that body hashes are made ith the string version, which is what will be seen after a
* server pulls up the data in the $_POST array.
*
* @param mixed $data the data that needs to be stringified.
*
* @return array|string
*/
public static function _stringify_data( $data ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
// Booleans are special, lets just makes them and explicit 1/0 instead of the 0 being an empty string.
if ( is_bool( $data ) ) {
return $data ? '1' : '0';
}
// Cast objects into arrays.
if ( is_object( $data ) ) {
$data = (array) $data;
}
// Non arrays at this point should be just converted to strings.
if ( ! is_array( $data ) ) {
return (string) $data;
}
foreach ( $data as &$value ) {
$value = self::_stringify_data( $value );
}
return $data;
}
}
@@ -0,0 +1,44 @@
<?php
/**
* Connection_Assets.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Assets;
/**
* Connection_Assets class.
*/
class Connection_Assets {
/**
* Initialize the class.
*/
public static function configure() {
add_action( 'wp_loaded', array( __CLASS__, 'register_assets' ) );
add_filter( 'jetpack_admin_js_script_data', array( Initial_State::class, 'set_connection_script_data' ), 10, 1 );
}
/**
* Register assets.
*
* NOTICE: Please think twice before including Connection scripts in the frontend.
* Those scripts are intended to be used in WP admin area.
*/
public static function register_assets() {
Assets::register_script(
'jetpack-connection',
'../dist/jetpack-connection.js',
__FILE__,
array(
'in_footer' => true,
'textdomain' => 'jetpack-connection',
)
);
}
}
@@ -0,0 +1,241 @@
<?php
/**
* Admin connection notices.
*
* @package automattic/jetpack-admin-ui
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Tracking;
/**
* Admin connection notices.
*
* @phan-constructor-used-for-side-effects
*/
class Connection_Notice {
/**
* Whether the class has been initialized.
*
* @var bool
*/
private static $is_initialized = false;
/**
* The constructor.
*/
public function __construct() {
if ( ! static::$is_initialized ) {
add_action( 'current_screen', array( $this, 'initialize_notices' ) );
static::$is_initialized = true;
}
}
/**
* Initialize the notices if needed.
*
* @param \WP_Screen $screen WP Core's screen object.
*
* @return void
*/
public function initialize_notices( $screen ) {
if ( in_array(
$screen->id,
array(
'jetpack_page_akismet-key-config',
'admin_page_jetpack_modules',
),
true
) ) {
return;
}
/*
* phpcs:disable WordPress.Security.NonceVerification.Recommended
*
* This function is firing within wp-admin and checks (below) if it is in the midst of a deletion on the users
* page. Nonce will be already checked by WordPress, so we do not need to check ourselves.
*/
if ( isset( $screen->base ) && 'users' === $screen->base
&& isset( $_REQUEST['action'] ) && 'delete' === $_REQUEST['action']
) {
add_action( 'admin_notices', array( $this, 'delete_user_update_connection_owner_notice' ) );
}
}
/**
* This is an entire admin notice dedicated to messaging and handling of the case where a user is trying to delete
* the connection owner.
*/
public function delete_user_update_connection_owner_notice() {
// Get connection owner or bail.
$connection_manager = new Manager();
$connection_owner_id = $connection_manager->get_connection_owner_id();
if ( ! $connection_owner_id ) {
return;
}
$connection_owner_userdata = get_userdata( $connection_owner_id );
// Bail if we're not trying to delete connection owner.
$user_ids_to_delete = array();
if ( isset( $_REQUEST['users'] ) ) {
$user_ids_to_delete = array_map( 'sanitize_text_field', wp_unslash( $_REQUEST['users'] ) );
} elseif ( isset( $_REQUEST['user'] ) ) {
$user_ids_to_delete[] = sanitize_text_field( wp_unslash( $_REQUEST['user'] ) );
}
// phpcs:enable
$user_ids_to_delete = array_map( 'absint', $user_ids_to_delete );
$deleting_connection_owner = in_array( $connection_owner_id, (array) $user_ids_to_delete, true );
if ( ! $deleting_connection_owner ) {
return;
}
// Bail if they're trying to delete themselves to avoid confusion.
if ( get_current_user_id() === $connection_owner_id ) {
return;
}
$tracking = new Tracking();
// Track it!
if ( method_exists( $tracking, 'record_user_event' ) ) {
$tracking->record_user_event( 'delete_connection_owner_notice_view' );
}
$connected_admins = $connection_manager->get_connected_users( 'jetpack_disconnect' );
$user = is_a( $connection_owner_userdata, 'WP_User' ) ? esc_html( $connection_owner_userdata->data->user_login ) : '';
echo "<div class='notice notice-warning' id='jetpack-notice-switch-connection-owner'>";
echo '<h2>' . esc_html__( 'Important notice about your Jetpack connection:', 'jetpack-connection' ) . '</h2>';
echo '<p>' . sprintf(
/* translators: WordPress User, if available. */
esc_html__( 'Warning! You are about to delete the Jetpack connection owner (%s) for this site, which may cause some of your Jetpack features to stop working.', 'jetpack-connection' ),
esc_html( $user )
) . '</p>';
if ( ! empty( $connected_admins ) && count( $connected_admins ) > 1 ) {
echo '<form id="jp-switch-connection-owner" action="" method="post">';
echo "<label for='owner'>" . esc_html__( 'You can choose to transfer connection ownership to one of these already-connected admins:', 'jetpack-connection' ) . ' </label>';
$connected_admin_ids = array_map(
function ( $connected_admin ) {
return $connected_admin->ID;
},
$connected_admins
);
wp_dropdown_users(
array(
'name' => 'owner',
'include' => array_diff( $connected_admin_ids, array( $connection_owner_id ) ),
'show' => 'display_name_with_login',
)
);
echo '<p>';
submit_button( esc_html__( 'Set new connection owner', 'jetpack-connection' ), 'primary', 'jp-switch-connection-owner-submit', false );
echo '</p>';
echo "<div id='jp-switch-user-results'></div>";
echo '</form>';
?>
<script type="text/javascript">
( function() {
const switchOwnerButton = document.getElementById('jp-switch-connection-owner');
if ( ! switchOwnerButton ) {
return;
}
switchOwnerButton.addEventListener( 'submit', function ( e ) {
e.preventDefault();
const submitBtn = document.getElementById('jp-switch-connection-owner-submit');
submitBtn.disabled = true;
const results = document.getElementById('jp-switch-user-results');
results.innerHTML = '';
results.classList.remove( 'error-message' );
const handleAPIError = ( message ) => {
submitBtn.disabled = false;
results.classList.add( 'error-message' );
results.innerHTML = message || "<?php esc_html_e( 'Something went wrong. Please try again.', 'jetpack-connection' ); ?>";
}
fetch(
<?php echo wp_json_encode( esc_url_raw( get_rest_url() . 'jetpack/v4/connection/owner' ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>,
{
method: 'POST',
headers: {
'X-WP-Nonce': <?php echo wp_json_encode( wp_create_nonce( 'wp_rest' ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>,
},
body: new URLSearchParams( new FormData( this ) ),
}
)
.then( response => response.json() )
.then( data => {
if ( data.hasOwnProperty( 'code' ) && data.code === 'success' ) {
// Owner successfully changed.
results.innerHTML = <?php echo wp_json_encode( esc_html__( 'Success!', 'jetpack-connection' ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>;
setTimeout(function () {
document.getElementById( 'jetpack-notice-switch-connection-owner' ).style.display = 'none';
}, 1000);
return;
}
handleAPIError( data?.message );
} )
.catch( () => handleAPIError() );
});
} )();
</script>
<?php
} else {
echo '<p>' . esc_html__( 'Every Jetpack site needs at least one connected admin for the features to work properly. Please connect to your WordPress.com account via the button below. Once you connect, you may refresh this page to see an option to change the connection owner.', 'jetpack-connection' ) . '</p>';
$connect_url = $connection_manager->get_authorization_url();
$connect_url = add_query_arg( 'from', 'delete_connection_owner_notice', $connect_url );
echo "<a href='" . esc_url( $connect_url ) . "' target='_blank' rel='noopener noreferrer' class='button-primary'>" . esc_html__( 'Connect to WordPress.com', 'jetpack-connection' ) . '</a>';
}
echo '<p>';
printf(
wp_kses(
/* translators: URL to Jetpack support doc regarding the primary user. */
__( "<a href='%s' target='_blank' rel='noopener noreferrer'>Learn more</a> about the connection owner and what will break if you do not have one.", 'jetpack-connection' ),
array(
'a' => array(
'href' => true,
'target' => true,
'rel' => true,
),
)
),
esc_url( Redirect::get_url( 'jetpack-support-primary-user' ) )
);
echo '</p>';
echo '<p>';
printf(
wp_kses(
/* translators: URL to contact Jetpack support. */
__( 'As always, feel free to <a href="%s" target="_blank" rel="noopener noreferrer">contact our support team</a> if you have any questions.', 'jetpack-connection' ),
array(
'a' => array(
'href' => true,
'target' => true,
'rel' => true,
),
)
),
esc_url( Redirect::get_url( 'jetpack-contact-support' ) )
);
echo '</p>';
echo '</div>';
}
}
@@ -0,0 +1,320 @@
<?php
/**
* External Storage utilities for Jetpack Connection.
*
* Provides centralized logic for external storage implementations
* across different environments (WoA, VIP, other).
*
* Usage Example:
*
* // 1. Create a storage provider class implementing the interface:
* class My_Storage_Provider implements Storage_Provider_Interface {
* public function is_available() { return true; }
* public function should_handle( $option_name ) {
* return in_array( $option_name, array( 'blog_token', 'id' ), true );
* }
* public function get( $option_name ) {
* // Return value from your external storage or null
* }
* public function get_environment_id() { return 'my_env'; }
* }
*
* // 2. Register the provider:
* if ( class_exists( 'Automattic\Jetpack\Connection\External_Storage' ) ) {
* \Automattic\Jetpack\Connection\External_Storage::register_provider( new My_Storage_Provider() );
* }
*
* // 3. External storage is now automatically used by Jetpack_Options::get_option()
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* External Storage utilities class.
*
* @since 6.18.0
*/
class External_Storage {
/**
* Registered storage provider.
*
* @since 6.18.0
*
* @var Storage_Provider_Interface|null
*/
private static $provider = null;
/**
* Whether the init action has already fired.
*
* @since 8.3.0
*
* @var bool
*/
private static $init_fired = false;
/**
* Static cache to prevent logging same event multiple times in single request.
*
* @since 7.0.0
*
* @var array
*/
private static $logged_events = array();
/**
* Maximum delay threshold for empty state reporting (in seconds).
* This also determines the transient expiry for tracking first empty state.
* Provider custom thresholds must not exceed this value.
*
* @since 7.0.0
*/
private const EMPTY_STATE_TRANSIENT_EXPIRY = 15 * MINUTE_IN_SECONDS;
/**
* Register a storage provider for external storage.
*
* @since 6.18.0
*
* @param Storage_Provider_Interface $provider Storage provider implementing the interface.
* @return bool True if provider was registered successfully, false otherwise.
*/
public static function register_provider( Storage_Provider_Interface $provider ) {
self::$provider = $provider;
/**
* Fires after an external storage provider is registered.
*
* This allows dependent systems (like the connection status cache in Manager)
* to invalidate state that may have been computed before the provider was available.
*
* @since 8.3.0
*
* @param Storage_Provider_Interface $provider The registered storage provider.
*/
do_action( 'jetpack_external_storage_provider_registered', $provider );
return true;
}
/**
* Get value from external storage provider.
*
* Returns null if no provider is registered or if the provider can't provide the value (triggers database fallback).
*
* @since 6.18.0
*
* @param string $key The key to retrieve.
* @return mixed The value from external storage, or null for database fallback.
*/
public static function get_value( $key ) {
if ( ! self::$init_fired ) {
self::$init_fired = true;
/**
* Fires before the first external storage read.
*
* Use this hook to register your storage provider via
* External_Storage::register_provider(). This fires after the connection
* package classes are loaded but before any connection status checks read
* from external storage.
*
* Useful for mu-plugins that load before the plugin providing External_Storage,
* since add_action() does not require the action or any classes to exist at
* hook-registration time.
*
* @since 8.3.0
*/
do_action( 'jetpack_external_storage_init' );
}
$provider = self::$provider;
// Check if we have a registered provider
if ( null === $provider ) {
return null; // No provider registered, use database
}
$environment = $provider->get_environment_id();
// Check if provider is available in current environment
if ( ! $provider->is_available() ) {
self::log_event( 'unavailable', $key, 'External storage not available', $environment );
return null;
}
// Check if provider should handle this option
if ( ! $provider->should_handle( $key ) ) {
return null;
}
// Try to get value from the provider
try {
$value = $provider->get( $key );
// Check if we got a valid value
if ( null !== $value && false !== $value && '' !== $value && 0 !== $value ) {
return $value;
}
// Empty value - log it
self::log_event( 'empty', $key, '', $environment );
} catch ( \Exception $e ) {
// Provider threw an exception
self::log_event( 'error', $key, $e->getMessage(), $environment );
}
// Provider couldn't provide value, return null for database fallback
return null;
}
/**
* Log events if WP_DEBUG is enabled and delegate to provider for error reporting.
* Includes rate limiting to prevent log spam from noisy events.
*
* Storage providers can optionally implement handle_error_event() method to receive
* notifications about storage errors and empty states for their own error reporting.
*
* @since 6.18.0
*
* @param string $event_type The event type (error, empty, unavailable).
* @param string $key The key that triggered the event.
* @param string $details Additional details about the event.
* @param string $environment The environment identifier (atomic, vip, etc.).
*/
public static function log_event( $event_type, $key, $details = '', $environment = 'unknown' ) {
// Only process 'error' and 'empty' events for provider error reporting
if ( 'error' !== $event_type && 'empty' !== $event_type ) {
// For non-reportable events, just do debug logging with rate limiting
if ( self::should_log_event( $key, $event_type ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
sprintf(
'Jetpack External Storage %s: %s in %s%s',
$event_type,
$key,
$environment,
$details ? ' - ' . $details : ''
)
);
}
return;
}
// For 'empty' events, check delay mechanism first to avoid false positives
// during sync between external storage and the database.
// This is checked BEFORE rate limiting so we don't block legitimate reports.
if ( 'empty' === $event_type && ! self::should_report_empty_state( $key ) ) {
return;
}
// Apply rate limiting only for events that will trigger provider notification
if ( ! self::should_log_event( $key, $event_type ) ) {
return;
}
// Local debug logging (only when WP_DEBUG is enabled)
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
sprintf(
'Jetpack External Storage %s: %s in %s%s',
$event_type,
$key,
$environment,
$details ? ' - ' . $details : ''
)
);
}
// Delegate to provider if it implements error handling
if ( null !== self::$provider && method_exists( self::$provider, 'handle_error_event' ) ) {
// @phan-suppress-next-line PhanUndeclaredMethod -- Optional method, checked via method_exists()
self::$provider->handle_error_event( $event_type, $key, $details, $environment );
}
}
/**
* Determine if we should report an empty state based on delay mechanism.
*
* This prevents false positives during storage sync delays. On first encounter
* of empty state, sets a transient. On subsequent encounters after the delay
* threshold, allows reporting (indicating likely disconnection, not sync delay).
*
* Providers can customize the delay threshold by implementing get_empty_state_delay_threshold().
*
* @since 6.18.0
*
* @param string $key The key that was empty.
* @return bool True if we should report this empty state, false otherwise.
*/
private static function should_report_empty_state( $key ) {
$delay_key = 'jetpack_external_storage_empty_delay_' . $key;
$first_empty_time = get_transient( $delay_key );
if ( false === $first_empty_time ) {
// First time encountering empty state - set delay transient and don't report yet
set_transient( $delay_key, time(), self::EMPTY_STATE_TRANSIENT_EXPIRY );
return false;
}
// Default delay threshold (5 minutes)
$delay_threshold = 5 * MINUTE_IN_SECONDS;
// Allow provider to customize delay threshold
// A threshold of 0 is valid for providers where external storage is written first
if ( null !== self::$provider && method_exists( self::$provider, 'get_empty_state_delay_threshold' ) ) {
// @phan-suppress-next-line PhanUndeclaredMethod -- Optional method, checked via method_exists()
$custom_threshold = self::$provider->get_empty_state_delay_threshold();
if ( is_int( $custom_threshold ) && $custom_threshold >= 0 && $custom_threshold <= self::EMPTY_STATE_TRANSIENT_EXPIRY ) {
$delay_threshold = $custom_threshold;
}
}
if ( ( time() - $first_empty_time ) >= $delay_threshold ) {
// Delay threshold passed - likely disconnection, report it
delete_transient( $delay_key );
return true;
}
return false;
}
/**
* Determine if an event should be logged based on rate limiting rules.
*
* This prevents log spam from noisy events by applying a simple one-hour
* rate limit per key and event type combination. Also uses a static cache
* to prevent duplicate logs within the same request.
*
* @since 6.18.0
*
* @param string $key The key that triggered the event.
* @param string $event_type The event type (error, empty, unavailable).
* @return bool True if the event should be logged, false if rate limited.
*/
private static function should_log_event( $key, $event_type = '' ) {
// Combine event type and key for unique tracking
$event_cache_key = $event_type . '_' . $key;
// Check static cache first (prevents multiple logs in same request)
if ( isset( self::$logged_events[ $event_cache_key ] ) ) {
return false;
}
$rate_limit_key = 'jetpack_ext_storage_rate_limit_' . $event_cache_key;
// Check if we're still within the rate limit period
if ( get_transient( $rate_limit_key ) ) {
return false;
}
// Mark as logged in both caches
self::$logged_events[ $event_cache_key ] = true;
set_transient( $rate_limit_key, true, HOUR_IN_SECONDS );
return true;
}
}
@@ -0,0 +1,310 @@
<?php
/**
* Jetpack Heartbeat package.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack;
use Automattic\Jetpack\Connection\Rest_Authentication;
use Automattic\Jetpack\Connection\REST_Connector;
use Jetpack_Options;
use WP_CLI;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;
/**
* Heartbeat sends a batch of stats to wp.com once a day
*/
class Heartbeat {
/**
* Holds the singleton instance of this class
*
* @since 1.0.0
* @since-jetpack 2.3.3
* @var Heartbeat
*/
private static $instance = false;
/**
* Cronjob identifier
*
* @var string
*/
private $cron_name = 'jetpack_v2_heartbeat';
/**
* Singleton
*
* @since 1.0.0
* @since-jetpack 2.3.3
* @static
* @return Heartbeat
*/
public static function init() {
if ( ! self::$instance ) {
self::$instance = new Heartbeat();
}
return self::$instance;
}
/**
* Constructor for singleton
*
* @since 1.0.0
* @since-jetpack 2.3.3
*/
private function __construct() {
// Schedule the task.
add_action( $this->cron_name, array( $this, 'cron_exec' ) );
if ( ! wp_next_scheduled( $this->cron_name ) ) {
// Deal with the old pre-3.0 weekly one.
$timestamp = wp_next_scheduled( 'jetpack_heartbeat' );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, 'jetpack_heartbeat' );
}
wp_schedule_event( time(), 'daily', $this->cron_name );
}
add_filter( 'jetpack_xmlrpc_unauthenticated_methods', array( __CLASS__, 'jetpack_xmlrpc_methods' ) );
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'jetpack-heartbeat', array( $this, 'cli_callback' ) );
}
add_action( 'rest_api_init', array( $this, 'initialize_rest_api' ) );
}
/**
* Method that gets executed on the wp-cron call
*
* @since 1.0.0
* @since-jetpack 2.3.3
* @global string $wp_version
*/
public function cron_exec() {
$a8c_mc_stats = new A8c_Mc_Stats();
/*
* This should run daily. Figuring in for variances in
* WP_CRON, don't let it run more than every 23 hours at most.
*
* i.e. if it ran less than 23 hours ago, fail out.
*/
$last = (int) Jetpack_Options::get_option( 'last_heartbeat' );
if ( $last && ( $last + DAY_IN_SECONDS - HOUR_IN_SECONDS > time() ) ) {
return;
}
/*
* Check for an identity crisis
*
* If one exists:
* - Bump stat for ID crisis
* - Email site admin about potential ID crisis
*/
// Coming Soon!
foreach ( self::generate_stats_array( 'v2-' ) as $key => $value ) {
if ( is_array( $value ) ) {
foreach ( $value as $v ) {
$a8c_mc_stats->add( $key, (string) $v );
}
} else {
$a8c_mc_stats->add( $key, (string) $value );
}
}
Jetpack_Options::update_option( 'last_heartbeat', time() );
$a8c_mc_stats->do_server_side_stats();
/**
* Fires when we synchronize all registered options on heartbeat.
*
* @since 3.3.0
*/
do_action( 'jetpack_heartbeat' );
}
/**
* Generates heartbeat stats data.
*
* @param string $prefix Prefix to add before stats identifier.
*
* @return array The stats array.
*/
public static function generate_stats_array( $prefix = '' ) {
/**
* This filter is used to build the array of stats that are bumped once a day by Jetpack Heartbeat.
*
* Filter the array and add key => value pairs where
* * key is the stat group name
* * value is the stat name.
*
* Example:
* add_filter( 'jetpack_heartbeat_stats_array', function( $stats ) {
* $stats['is-https'] = is_ssl() ? 'https' : 'http';
* });
*
* This will bump the stats for the 'is-https/https' or 'is-https/http' stat.
*
* @param array $stats The stats to be filtered.
* @param string $prefix The prefix that will automatically be added at the begining at each stat group name.
*/
$stats = apply_filters( 'jetpack_heartbeat_stats_array', array(), $prefix );
$return = array();
// Apply prefix to stats.
foreach ( $stats as $stat => $value ) {
$return[ "$prefix$stat" ] = $value;
}
return $return;
}
/**
* Registers jetpack.getHeartbeatData xmlrpc method
*
* @param array $methods The list of methods to be filtered.
* @return array $methods
*/
public static function jetpack_xmlrpc_methods( $methods ) {
$methods['jetpack.getHeartbeatData'] = array( __CLASS__, 'xmlrpc_data_response' );
return $methods;
}
/**
* Handles the response for the jetpack.getHeartbeatData xmlrpc method
*
* @param array $params The parameters received in the request.
* @return array $params all the stats that heartbeat handles.
*/
public static function xmlrpc_data_response( $params = array() ) {
// The WordPress XML-RPC server sets a default param of array()
// if no argument is passed on the request and the method handlers get this array in $params.
// generate_stats_array() needs a string as first argument.
$params = empty( $params ) ? '' : $params;
return self::generate_stats_array( $params );
}
/**
* Clear scheduled events
*
* @return void
*/
public function deactivate() {
// Deal with the old pre-3.0 weekly one.
$timestamp = wp_next_scheduled( 'jetpack_heartbeat' );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, 'jetpack_heartbeat' );
}
$timestamp = wp_next_scheduled( $this->cron_name );
wp_unschedule_event( $timestamp, $this->cron_name );
}
/**
* Interact with the Heartbeat
*
* ## OPTIONS
*
* inspect (default): Gets the list of data that is going to be sent in the heartbeat and the date/time of the last heartbeat
*
* @param array $args Arguments passed via CLI.
*
* @return void
*/
public function cli_callback( $args ) {
$allowed_args = array(
'inspect',
);
if ( isset( $args[0] ) && ! in_array( $args[0], $allowed_args, true ) ) {
/* translators: %s is a command like "prompt" */
WP_CLI::error( sprintf( __( '%s is not a valid command.', 'jetpack-connection' ), $args[0] ) );
}
$stats = self::generate_stats_array();
$formatted_stats = array();
foreach ( $stats as $stat_name => $bin ) {
$formatted_stats[] = array(
'Stat name' => $stat_name,
'Bin' => $bin,
);
}
WP_CLI\Utils\format_items( 'table', $formatted_stats, array( 'Stat name', 'Bin' ) );
$last_heartbeat = Jetpack_Options::get_option( 'last_heartbeat' );
if ( $last_heartbeat ) {
$last_date = gmdate( 'Y-m-d H:i:s', $last_heartbeat );
/* translators: %s is the full datetime of the last heart beat e.g. 2020-01-01 12:21:23 */
WP_CLI::line( sprintf( __( 'Last heartbeat sent at: %s', 'jetpack-connection' ), $last_date ) );
}
}
/**
* Initialize the heartbeat REST API.
*
* @return void
*/
public function initialize_rest_api() {
register_rest_route(
'jetpack/v4',
'/heartbeat/data',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'rest_heartbeat_data' ),
'permission_callback' => array( $this, 'rest_heartbeat_data_permission_check' ),
'args' => array(
'prefix' => array(
'description' => __( 'Prefix to add before the stats identifiers.', 'jetpack-connection' ),
'type' => 'string',
),
),
)
);
}
/**
* Endpoint to retrieve the heartbeat data.
*
* @param WP_REST_Request $request The request data.
*
* @since 2.7.0
*
* @return array
*/
public function rest_heartbeat_data( WP_REST_Request $request ) {
return static::generate_stats_array( $request->get_param( 'prefix' ) );
}
/**
* Check permissions for the `get_heartbeat_data` endpoint.
*
* @return true|WP_Error
*/
public function rest_heartbeat_data_permission_check() {
if ( current_user_can( 'jetpack_connect' ) ) {
return true;
}
return Rest_Authentication::is_signed_with_blog_token()
? true
: new WP_Error( 'invalid_permission_heartbeat_data', REST_Connector::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
}

Some files were not shown because too many files have changed in this diff Show More