installed plugin Jetpack Protect version 1.0.2

This commit is contained in:
2022-07-28 18:42:13 +00:00
committed by Gitium
parent d55c4af45c
commit a3483bf62f
286 changed files with 64090 additions and 0 deletions

View File

@ -0,0 +1,488 @@
<?php
/**
* The Connection Client class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Constants;
/**
* 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 $body the request body.
* @return array|WP_Error WP HTTP response on success
*/
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 ( ! $result || is_wp_error( $result ) ) {
return $result;
}
$response = self::_wp_remote_request( $result['url'], $result['request'] );
/**
* 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 $body the request body.
* @return WP_Error|array {
* An array containing URL and request items.
*
* @type String $url The request URL.
* @type array $request Request arguments.
* }
*/
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',
'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 ( empty( $token ) || empty( $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( 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 ( is_array( $body ) ) {
// 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 ( count( $body ) > 0 ) {
$body_to_hash = wp_json_encode( self::_stringify_data( $body ) );
} 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 ( ! $signature || 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 ' . join( ' ', $header_pieces ),
)
);
if ( 'header' !== $args['auth_location'] ) {
$url = add_query_arg( 'signature', rawurlencode( $signature ), $url );
}
return compact( 'url', 'request' );
}
/**
* 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
*/
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 HTTP_Response $response the response object.
* @param Boolean $force_set whether to force setting the time difference.
*/
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|WP_Error $response Response data, else {@see WP_Error} on failure.
*/
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',
'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 string $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.
*/
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 );
}
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 String $body Request body.
* @param String $base_api_path (optional) the API base path override, defaults to 'rest'.
* @return array|WP_Error $response Data.
*/
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 array|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;
}
}

View File

@ -0,0 +1,690 @@
<?php
/**
* The Jetpack Connection error class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* The Jetpack Connection Errors that handles errors
*
* This class handles the following workflow:
*
* 1. A XML-RCP request with an invalid signature triggers a error
* 2. Applies a gate to only process each error code once an hour to avoid overflow
* 3. It stores the error on the database, but we don't know yet if this is a valid error, because
* we can't confirm it came from WP.com.
* 4. It encrypts the error details and send it to thw wp.com server
* 5. wp.com checks it and, if valid, sends a new request back to this site using the verify_xml_rpc_error REST endpoint
* 6. This endpoint add this error to the Verified errors in the database
* 7. Triggers a workflow depending on the error (display user an error message, do some self healing, etc.)
*
* Errors are stored in the database as options in the following format:
*
* [
* $error_code => [
* $user_id => [
* $error_details
* ]
* ]
* ]
*
* For each error code we store a maximum of 5 errors for 5 different user ids.
*
* An user ID can be
* * 0 for blog tokens
* * positive integer for user tokens
* * 'invalid' for malformed tokens
*
* @since 1.14.2
*/
class Error_Handler {
/**
* The name of the option that stores the errors
*
* @since 1.14.2
*
* @var string
*/
const STORED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_errors';
/**
* The name of the option that stores the errors
*
* @since 1.14.2
*
* @var string
*/
const STORED_VERIFIED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_verified_errors';
/**
* The prefix of the transient that controls the gate for each error code
*
* @since 1.14.2
*
* @var string
*/
const ERROR_REPORTING_GATE = 'jetpack_connection_error_reporting_gate_';
/**
* Time in seconds a test should live in the database before being discarded
*
* @since 1.14.2
*/
const ERROR_LIFE_TIME = DAY_IN_SECONDS;
/**
* The error code for event tracking purposes.
* If there are many, only the first error code will be tracked.
*
* @var string
*/
private $error_code;
/**
* List of known errors. Only error codes in this list will be handled
*
* @since 1.14.2
*
* @var array
*/
public $known_errors = array(
'malformed_token',
'malformed_user_id',
'unknown_user',
'no_user_tokens',
'empty_master_user_option',
'no_token_for_user',
'token_malformed',
'user_id_mismatch',
'no_possible_tokens',
'no_valid_user_token',
'no_valid_blog_token',
'unknown_token',
'could_not_sign',
'invalid_scheme',
'invalid_secret',
'invalid_token',
'token_mismatch',
'invalid_body',
'invalid_signature',
'invalid_body_hash',
'invalid_nonce',
'signature_mismatch',
);
/**
* Holds the instance of this singleton class
*
* @since 1.14.2
*
* @var Error_Handler $instance
*/
public static $instance = null;
/**
* Initialize instance, hookds and load verified errors handlers
*
* @since 1.14.2
*/
private function __construct() {
defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
$this->handle_verified_errors();
// If the site gets reconnected, clear errors.
add_action( 'jetpack_site_registered', array( $this, 'delete_all_errors' ) );
add_action( 'jetpack_get_site_data_success', array( $this, 'delete_all_errors' ) );
add_filter( 'jetpack_connection_disconnect_site_wpcom', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
add_filter( 'jetpack_connection_delete_all_tokens', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
add_action( 'jetpack_unlinked_user', array( $this, 'delete_all_errors' ) );
add_action( 'jetpack_updated_user_token', array( $this, 'delete_all_errors' ) );
}
/**
* Gets the list of verified errors and act upon them
*
* @since 1.14.2
*
* @return void
*/
public function handle_verified_errors() {
$verified_errors = $this->get_verified_errors();
foreach ( array_keys( $verified_errors ) as $error_code ) {
switch ( $error_code ) {
case 'malformed_token':
case 'token_malformed':
case 'no_possible_tokens':
case 'no_valid_user_token':
case 'no_valid_blog_token':
case 'unknown_token':
case 'could_not_sign':
case 'invalid_token':
case 'token_mismatch':
case 'invalid_signature':
case 'signature_mismatch':
case 'no_user_tokens':
case 'no_token_for_user':
add_action( 'admin_notices', array( $this, 'generic_admin_notice_error' ) );
add_action( 'react_connection_errors_initial_state', array( $this, 'jetpack_react_dashboard_error' ) );
$this->error_code = $error_code;
// Since we are only generically handling errors, we don't need to trigger error messages for each one of them.
break 2;
}
}
}
/**
* Gets the instance of this singleton class
*
* @since 1.14.2
*
* @return Error_Handler $instance
*/
public static function get_instance() {
if ( self::$instance === null ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Keep track of a connection error that was encountered
*
* @since 1.14.2
*
* @param \WP_Error $error the error object.
* @param boolean $force Force the report, even if should_report_error is false.
* @return void
*/
public function report_error( \WP_Error $error, $force = false ) {
if ( in_array( $error->get_error_code(), $this->known_errors, true ) && $this->should_report_error( $error ) || $force ) {
$stored_error = $this->store_error( $error );
if ( $stored_error ) {
$this->send_error_to_wpcom( $stored_error );
}
}
}
/**
* Checks the status of the gate
*
* This protects the site (and WPCOM) against over loads.
*
* @since 1.14.2
*
* @param \WP_Error $error the error object.
* @return boolean $should_report True if gate is open and the error should be reported.
*/
public function should_report_error( \WP_Error $error ) {
if ( defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG ) {
return true;
}
/**
* Whether to bypass the gate for XML-RPC error handling
*
* By default, we only process XML-RPC errors once an hour for each error code.
* This is done to avoid overflows. If you need to disable this gate, you can set this variable to true.
*
* This filter is useful for unit testing
*
* @since 1.14.2
*
* @param boolean $bypass_gate whether to bypass the gate. Default is false, do not bypass.
*/
$bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
if ( true === $bypass_gate ) {
return true;
}
$transient = self::ERROR_REPORTING_GATE . $error->get_error_code();
if ( get_transient( $transient ) ) {
return false;
}
set_transient( $transient, true, HOUR_IN_SECONDS );
return true;
}
/**
* Stores the error in the database so we know there is an issue and can inform the user
*
* @since 1.14.2
*
* @param \WP_Error $error the error object.
* @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
*/
public function store_error( \WP_Error $error ) {
$stored_errors = $this->get_stored_errors();
$error_array = $this->wp_error_to_array( $error );
$error_code = $error->get_error_code();
$user_id = $error_array['user_id'];
if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
$stored_errors[ $error_code ] = array();
}
$stored_errors[ $error_code ][ $user_id ] = $error_array;
// Let's store a maximum of 5 different user ids for each error code.
if ( count( $stored_errors[ $error_code ] ) > 5 ) {
// array_shift will destroy keys here because they are numeric, so manually remove first item.
$keys = array_keys( $stored_errors[ $error_code ] );
unset( $stored_errors[ $error_code ][ $keys[0] ] );
}
if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
return $error_array;
}
return false;
}
/**
* Converts a WP_Error object in the array representation we store in the database
*
* @since 1.14.2
*
* @param \WP_Error $error the error object.
* @return boolean|array False if error is invalid or the error array
*/
public function wp_error_to_array( \WP_Error $error ) {
$data = $error->get_error_data();
if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
return false;
}
$data = $data['signature_details'];
if ( ! isset( $data['token'] ) || empty( $data['token'] ) ) {
return false;
}
$user_id = $this->get_user_id_from_token( $data['token'] );
$error_array = array(
'error_code' => $error->get_error_code(),
'user_id' => $user_id,
'error_message' => $error->get_error_message(),
'error_data' => $data,
'timestamp' => time(),
'nonce' => wp_generate_password( 10, false ),
);
return $error_array;
}
/**
* Sends the error to WP.com to be verified
*
* @since 1.14.2
*
* @param array $error_array The array representation of the error as it is stored in the database.
* @return bool
*/
public function send_error_to_wpcom( $error_array ) {
$blog_id = \Jetpack_Options::get_option( 'id' );
$encrypted_data = $this->encrypt_data_to_wpcom( $error_array );
if ( false === $encrypted_data ) {
return false;
}
$args = array(
'body' => array(
'error_data' => $encrypted_data,
),
);
// send encrypted data to WP.com Public-API v2.
wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
return true;
}
/**
* Encrypt data to be sent over to WP.com
*
* @since 1.14.2
*
* @param array|string $data the data to be encoded.
* @return boolean|string The encoded string on success, false on failure
*/
public function encrypt_data_to_wpcom( $data ) {
try {
// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
} catch ( \SodiumException $e ) {
// error encrypting data.
return false;
}
return $encrypted_data;
}
/**
* Extracts the user ID from a token
*
* @since 1.14.2
*
* @param string $token the token used to make the xml-rpc request.
* @return string $the user id or `invalid` if user id not present.
*/
public function get_user_id_from_token( $token ) {
$parsed_token = explode( ':', wp_unslash( $token ) );
if ( isset( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
$user_id = $parsed_token[2];
} else {
$user_id = 'invalid';
}
return $user_id;
}
/**
* Gets the reported errors stored in the database
*
* @since 1.14.2
*
* @return array $errors
*/
public function get_stored_errors() {
$stored_errors = get_option( self::STORED_ERRORS_OPTION );
if ( ! is_array( $stored_errors ) ) {
$stored_errors = array();
}
$stored_errors = $this->garbage_collector( $stored_errors );
return $stored_errors;
}
/**
* Gets the verified errors stored in the database
*
* @since 1.14.2
*
* @return array $errors
*/
public function get_verified_errors() {
$verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
if ( ! is_array( $verified_errors ) ) {
$verified_errors = array();
}
$verified_errors = $this->garbage_collector( $verified_errors );
return $verified_errors;
}
/**
* Removes expired errors from the array
*
* This method is called by get_stored_errors and get_verified errors and filters their result
* Whenever a new error is stored to the database or verified, this will be triggered and the
* expired error will be permantently removed from the database
*
* @since 1.14.2
*
* @param array $errors array of errors as stored in the database.
* @return array
*/
private function garbage_collector( $errors ) {
foreach ( $errors as $error_code => $users ) {
foreach ( $users as $user_id => $error ) {
if ( self::ERROR_LIFE_TIME < time() - (int) $error['timestamp'] ) {
unset( $errors[ $error_code ][ $user_id ] );
}
}
}
// Clear empty error codes.
$errors = array_filter(
$errors,
function ( $user_errors ) {
return ! empty( $user_errors );
}
);
return $errors;
}
/**
* Delete all stored and verified errors from the database
*
* @since 1.14.2
*
* @return void
*/
public function delete_all_errors() {
$this->delete_stored_errors();
$this->delete_verified_errors();
}
/**
* Delete all stored and verified errors from the database and returns unfiltered value
*
* This is used to hook into a couple of filters that expect true to not short circuit the disconnection flow
*
* @since 8.9.0
*
* @param mixed $check The input sent by the filter.
* @return boolean
*/
public function delete_all_errors_and_return_unfiltered_value( $check ) {
$this->delete_all_errors();
return $check;
}
/**
* Delete the reported errors stored in the database
*
* @since 1.14.2
*
* @return boolean True, if option is successfully deleted. False on failure.
*/
public function delete_stored_errors() {
return delete_option( self::STORED_ERRORS_OPTION );
}
/**
* Delete the verified errors stored in the database
*
* @since 1.14.2
*
* @return boolean True, if option is successfully deleted. False on failure.
*/
public function delete_verified_errors() {
return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
}
/**
* Gets an error based on the nonce
*
* Receives a nonce and finds the related error.
*
* @since 1.14.2
*
* @param string $nonce The nonce created for the error we want to get.
* @return null|array Returns the error array representation or null if error not found.
*/
public function get_error_by_nonce( $nonce ) {
$errors = $this->get_stored_errors();
foreach ( $errors as $user_group ) {
foreach ( $user_group as $error ) {
if ( $error['nonce'] === $nonce ) {
return $error;
}
}
}
return null;
}
/**
* Adds an error to the verified error list
*
* @since 1.14.2
*
* @param array $error The error array, as it was saved in the unverified errors list.
* @return void
*/
public function verify_error( $error ) {
$verified_errors = $this->get_verified_errors();
$error_code = $error['error_code'];
$user_id = $error['user_id'];
if ( ! isset( $verified_errors[ $error_code ] ) ) {
$verified_errors[ $error_code ] = array();
}
$verified_errors[ $error_code ][ $user_id ] = $error;
update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
}
/**
* Register REST API end point for error hanlding.
*
* @since 1.14.2
*
* @return void
*/
public function register_verify_error_endpoint() {
register_rest_route(
'jetpack/v4',
'/verify_xmlrpc_error',
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'verify_xml_rpc_error' ),
'permission_callback' => '__return_true',
'args' => array(
'nonce' => array(
'required' => true,
'type' => 'string',
),
),
)
);
}
/**
* Handles verification that a xml rpc error is legit and came from WordPres.com
*
* @since 1.14.2
*
* @param \WP_REST_Request $request The request sent to the WP REST API.
*
* @return boolean
*/
public function verify_xml_rpc_error( \WP_REST_Request $request ) {
$error = $this->get_error_by_nonce( $request['nonce'] );
if ( $error ) {
$this->verify_error( $error );
return new \WP_REST_Response( true, 200 );
}
return new \WP_REST_Response( false, 200 );
}
/**
* Prints a generic error notice for all connection errors
*
* @since 8.9.0
*
* @return void
*/
public function generic_admin_notice_error() {
// do not add admin notice to the jetpack dashboard.
global $pagenow;
if ( 'admin.php' === $pagenow || isset( $_GET['page'] ) && 'jetpack' === $_GET['page'] ) { // phpcs:ignore
return;
}
if ( ! current_user_can( 'jetpack_connect' ) ) {
return;
}
/**
* Filters the message to be displayed in the admin notices area when there's a xmlrpc error.
*
* By default we don't display any errors.
*
* Return an empty value to disable the message.
*
* @since 8.9.0
*
* @param string $message The error message.
* @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
*/
$message = apply_filters( 'jetpack_connection_error_notice_message', '', $this->get_verified_errors() );
/**
* Fires inside the admin_notices hook just before displaying the error message for a broken connection.
*
* If you want to disable the default message from being displayed, return an emtpy value in the jetpack_connection_error_notice_message filter.
*
* @since 8.9.0
*
* @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
*/
do_action( 'jetpack_connection_error_notice', $this->get_verified_errors() );
if ( empty( $message ) ) {
return;
}
?>
<div class="notice notice-error is-dismissible jetpack-message jp-connect" style="display:block !important;">
<p><?php echo esc_html( $message ); ?></p>
</div>
<?php
}
/**
* Adds the error message to the Jetpack React Dashboard
*
* @since 8.9.0
*
* @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
* @return array
*/
public function jetpack_react_dashboard_error( $errors ) {
$errors[] = array(
'code' => 'xmlrpc_error',
'message' => __( 'Your connection with WordPress.com seems to be broken. If you\'re experiencing issues, please try reconnecting.', 'jetpack-connection' ),
'action' => 'reconnect',
'data' => array( 'api_error_code' => $this->error_code ),
);
return $errors;
}
}

View File

@ -0,0 +1,254 @@
<?php
/**
* Jetpack Heartbeat package.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack;
use Jetpack_Options;
use WP_CLI;
/**
* 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' ) );
}
}
/**
* 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 ) );
}
}
}

View File

@ -0,0 +1,57 @@
<?php
/**
* The React initial state.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Status;
/**
* The React initial state.
*/
class Initial_State {
/**
* Whether the initial state was already rendered
*
* @var boolean
*/
private static $rendered = false;
/**
* Get the initial state data.
*
* @return array
*/
private static function get_data() {
global $wp_version;
return array(
'apiRoot' => esc_url_raw( rest_url() ),
'apiNonce' => wp_create_nonce( 'wp_rest' ),
'registrationNonce' => wp_create_nonce( 'jetpack-registration-nonce' ),
'connectionStatus' => REST_Connector::connection_status( false ),
'userConnectionData' => REST_Connector::get_user_connection_data( false ),
'connectedPlugins' => REST_Connector::get_connection_plugins( false ),
'wpVersion' => $wp_version,
'siteSuffix' => ( new Status() )->get_site_suffix(),
);
}
/**
* Render the initial state into a JavaScript variable.
*
* @return string
*/
public static function render() {
if ( self::$rendered ) {
return null;
}
self::$rendered = true;
return 'var JP_CONNECTION_INITIAL_STATE=JSON.parse(decodeURIComponent("' . rawurlencode( wp_json_encode( self::get_data() ) ) . '"));';
}
}

View File

@ -0,0 +1,213 @@
<?php
/**
* The nonce handler.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* The nonce handler.
*/
class Nonce_Handler {
/**
* How long the scheduled cleanup can run (in seconds).
* Can be modified using the filter `jetpack_connection_nonce_scheduled_cleanup_limit`.
*/
const SCHEDULED_CLEANUP_TIME_LIMIT = 5;
/**
* How many nonces should be removed per batch during the `clean_all()` run.
*/
const CLEAN_ALL_LIMIT_PER_BATCH = 1000;
/**
* Nonce lifetime in seconds.
*/
const LIFETIME = HOUR_IN_SECONDS;
/**
* The nonces used during the request are stored here to keep them valid.
* The property is static to keep the nonces accessible between the `Nonce_Handler` instances.
*
* @var array
*/
private static $nonces_used_this_request = array();
/**
* The database object.
*
* @var \wpdb
*/
private $db;
/**
* Initializing the object.
*/
public function __construct() {
global $wpdb;
$this->db = $wpdb;
}
/**
* Scheduling the WP-cron cleanup event.
*/
public function init_schedule() {
add_action( 'jetpack_clean_nonces', array( __CLASS__, 'clean_scheduled' ) );
if ( ! wp_next_scheduled( 'jetpack_clean_nonces' ) ) {
wp_schedule_event( time(), 'hourly', 'jetpack_clean_nonces' );
}
}
/**
* Reschedule the WP-cron cleanup event to make it start sooner.
*/
public function reschedule() {
wp_clear_scheduled_hook( 'jetpack_clean_nonces' );
wp_schedule_event( time(), 'hourly', 'jetpack_clean_nonces' );
}
/**
* Adds a used nonce to a list of known nonces.
*
* @param int $timestamp the current request timestamp.
* @param string $nonce the nonce value.
*
* @return bool whether the nonce is unique or not.
*/
public function add( $timestamp, $nonce ) {
if ( isset( static::$nonces_used_this_request[ "$timestamp:$nonce" ] ) ) {
return static::$nonces_used_this_request[ "$timestamp:$nonce" ];
}
// This should always have gone through Jetpack_Signature::sign_request() first to check $timestamp and $nonce.
$timestamp = (int) $timestamp;
$nonce = esc_sql( $nonce );
// Raw query so we can avoid races: add_option will also update.
$show_errors = $this->db->hide_errors();
// Running `try...finally` to make sure that we re-enable errors in case of an exception.
try {
$old_nonce = $this->db->get_row(
$this->db->prepare( "SELECT 1 FROM `{$this->db->options}` WHERE option_name = %s", "jetpack_nonce_{$timestamp}_{$nonce}" )
);
if ( $old_nonce === null ) {
$return = (bool) $this->db->query(
$this->db->prepare(
"INSERT INTO `{$this->db->options}` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, %s)",
"jetpack_nonce_{$timestamp}_{$nonce}",
time(),
'no'
)
);
} else {
$return = false;
}
} finally {
$this->db->show_errors( $show_errors );
}
static::$nonces_used_this_request[ "$timestamp:$nonce" ] = $return;
return $return;
}
/**
* Removing all existing nonces, or at least as many as possible.
* Capped at 20 seconds to avoid breaking the site.
*
* @param int $cutoff_timestamp All nonces added before this timestamp will be removed.
* @param int $time_limit How long the cleanup can run (in seconds).
*
* @return true
*/
public function clean_all( $cutoff_timestamp = PHP_INT_MAX, $time_limit = 20 ) {
// phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
for ( $end_time = time() + $time_limit; time() < $end_time; ) {
$result = $this->delete( static::CLEAN_ALL_LIMIT_PER_BATCH, $cutoff_timestamp );
if ( ! $result ) {
break;
}
}
return true;
}
/**
* Scheduled clean up of the expired nonces.
*/
public static function clean_scheduled() {
/**
* Adjust the time limit for the scheduled cleanup.
*
* @since 9.5.0
*
* @param int $time_limit How long the cleanup can run (in seconds).
*/
$time_limit = apply_filters( 'jetpack_connection_nonce_cleanup_runtime_limit', static::SCHEDULED_CLEANUP_TIME_LIMIT );
( new static() )->clean_all( time() - static::LIFETIME, $time_limit );
}
/**
* Delete the nonces.
*
* @param int $limit How many nonces to delete.
* @param null|int $cutoff_timestamp All nonces added before this timestamp will be removed.
*
* @return int|false Number of removed nonces, or `false` if nothing to remove (or in case of a database error).
*/
public function delete( $limit = 10, $cutoff_timestamp = null ) {
global $wpdb;
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_id FROM `{$wpdb->options}`"
. " WHERE `option_name` >= 'jetpack_nonce_' AND `option_name` < %s"
. ' LIMIT %d',
'jetpack_nonce_' . $cutoff_timestamp,
$limit
)
);
if ( ! is_array( $ids ) ) {
// There's an error and we can't proceed.
return false;
}
// Removing zeroes in case AUTO_INCREMENT of the options table is broken, and all ID's are zeroes.
$ids = array_filter( $ids );
if ( ! count( $ids ) ) {
// There's nothing to remove.
return false;
}
$ids_fill = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$args = $ids;
$args[] = 'jetpack_nonce_%';
// The Code Sniffer is unable to understand what's going on...
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
return $wpdb->query( $wpdb->prepare( "DELETE FROM `{$wpdb->options}` WHERE `option_id` IN ( {$ids_fill} ) AND option_name LIKE %s", $args ) );
}
/**
* Clean the cached nonces valid during the current request, therefore making them invalid.
*
* @return bool
*/
public static function invalidate_request_nonces() {
static::$nonces_used_this_request = array();
return true;
}
}

View File

@ -0,0 +1,112 @@
<?php
/**
* The Package_Version_Tracker class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* The Package_Version_Tracker class.
*/
class Package_Version_Tracker {
const PACKAGE_VERSION_OPTION = 'jetpack_package_versions';
/**
* The cache key for storing a failed request to update remote package versions.
* The caching logic is that when a failed request occurs, we cache it temporarily
* with a set expiration time.
* Only after the key has expired, we'll be able to repeat a remote request.
* This also implies that the cached value is redundant, however we chose the datetime
* of the failed request to avoid using booleans.
*/
const CACHED_FAILED_REQUEST_KEY = 'jetpack_failed_update_remote_package_versions';
/**
* The min time difference in seconds for attempting to
* update remote tracked package versions after a failed remote request.
*/
const CACHED_FAILED_REQUEST_EXPIRATION = 1 * HOUR_IN_SECONDS;
/**
* Uses the jetpack_package_versions filter to obtain the package versions from packages that need
* version tracking. If the package versions have changed, updates the option and notifies WPCOM.
*/
public function maybe_update_package_versions() {
/**
* Obtains the package versions.
*
* @since 1.30.2
*
* @param array An associative array of Jetpack package slugs and their corresponding versions as key/value pairs.
*/
$filter_versions = apply_filters( 'jetpack_package_versions', array() );
if ( ! is_array( $filter_versions ) ) {
return;
}
$option_versions = get_option( self::PACKAGE_VERSION_OPTION, array() );
foreach ( $filter_versions as $package => $version ) {
if ( ! is_string( $package ) || ! is_string( $version ) ) {
unset( $filter_versions[ $package ] );
}
}
if ( ! is_array( $option_versions )
|| count( array_diff_assoc( $filter_versions, $option_versions ) )
|| count( array_diff_assoc( $option_versions, $filter_versions ) )
) {
$this->update_package_versions_option( $filter_versions );
}
}
/**
* Updates the package versions:
* - Sends the updated package versions to wpcom.
* - Updates the 'jetpack_package_versions' option.
*
* @param array $package_versions The package versions.
*/
protected function update_package_versions_option( $package_versions ) {
$connection = new Manager();
if ( ! $connection->is_connected() ) {
return;
}
$site_id = \Jetpack_Options::get_option( 'id' );
$last_failed_attempt_within_hour = get_transient( self::CACHED_FAILED_REQUEST_KEY );
if ( $last_failed_attempt_within_hour ) {
return;
}
$body = wp_json_encode(
array(
'package_versions' => $package_versions,
)
);
$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/sites/%d/jetpack-package-versions', $site_id ),
'2',
array(
'headers' => array( 'content-type' => 'application/json' ),
'method' => 'POST',
),
$body,
'wpcom'
);
if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
update_option( self::PACKAGE_VERSION_OPTION, $package_versions );
} else {
set_transient( self::CACHED_FAILED_REQUEST_KEY, time(), self::CACHED_FAILED_REQUEST_EXPIRATION );
}
}
}

View File

@ -0,0 +1,30 @@
<?php
/**
* The Package_Version class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* The Package_Version class.
*/
class Package_Version {
const PACKAGE_VERSION = '1.41.7';
const PACKAGE_SLUG = 'connection';
/**
* Adds the package slug and version to the package version tracker's data.
*
* @param array $package_versions The package version array.
*
* @return array The packge version array.
*/
public static function send_package_version_to_tracker( $package_versions ) {
$package_versions[ self::PACKAGE_SLUG ] = self::PACKAGE_VERSION;
return $package_versions;
}
}

View File

@ -0,0 +1,269 @@
<?php
/**
* Storage for plugin connection information.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use WP_Error;
/**
* The class serves a single purpose - to store the data which plugins use the connection, along with some auxiliary information.
*/
class Plugin_Storage {
const ACTIVE_PLUGINS_OPTION_NAME = 'jetpack_connection_active_plugins';
/**
* Options where disabled plugins were stored
*
* @deprecated since 1.39.0.
* @var string
*/
const PLUGINS_DISABLED_OPTION_NAME = 'jetpack_connection_disabled_plugins';
/**
* Whether this class was configured for the first time or not.
*
* @var boolean
*/
private static $configured = false;
/**
* Refresh list of connected plugins upon intialization.
*
* @var boolean
*/
private static $refresh_connected_plugins = false;
/**
* Connected plugins.
*
* @var array
*/
private static $plugins = array();
/**
* The blog ID the storage is setup for.
* The data will be refreshed if the blog ID changes.
* Used for the multisite networks.
*
* @var int
*/
private static $current_blog_id = null;
/**
* Add or update the plugin information in the storage.
*
* @param string $slug Plugin slug.
* @param array $args Plugin arguments, optional.
*
* @return bool
*/
public static function upsert( $slug, array $args = array() ) {
self::$plugins[ $slug ] = $args;
// if plugin is not in the list of active plugins, refresh the list.
if ( ! array_key_exists( $slug, (array) get_option( self::ACTIVE_PLUGINS_OPTION_NAME, array() ) ) ) {
self::$refresh_connected_plugins = true;
}
return true;
}
/**
* Retrieve the plugin information by slug.
* WARNING: the method cannot be called until Plugin_Storage::configure is called, which happens on plugins_loaded
* Even if you don't use Jetpack Config, it may be introduced later by other plugins,
* so please make sure not to run the method too early in the code.
*
* @param string $slug The plugin slug.
*
* @return array|null|WP_Error
*/
public static function get_one( $slug ) {
$plugins = self::get_all();
if ( $plugins instanceof WP_Error ) {
return $plugins;
}
return empty( $plugins[ $slug ] ) ? null : $plugins[ $slug ];
}
/**
* Retrieve info for all plugins that use the connection.
* WARNING: the method cannot be called until Plugin_Storage::configure is called, which happens on plugins_loaded
* Even if you don't use Jetpack Config, it may be introduced later by other plugins,
* so please make sure not to run the method too early in the code.
*
* @since 1.39.0 deprecated the $connected_only argument.
*
* @param null $deprecated null plugins that were explicitly disconnected. Deprecated, there's no such a thing as disconnecting only specific plugins anymore.
*
* @return array|WP_Error
*/
public static function get_all( $deprecated = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$maybe_error = self::ensure_configured();
if ( $maybe_error instanceof WP_Error ) {
return $maybe_error;
}
return self::$plugins;
}
/**
* Remove the plugin connection info from Jetpack.
* WARNING: the method cannot be called until Plugin_Storage::configure is called, which happens on plugins_loaded
* Even if you don't use Jetpack Config, it may be introduced later by other plugins,
* so please make sure not to run the method too early in the code.
*
* @param string $slug The plugin slug.
*
* @return bool|WP_Error
*/
public static function delete( $slug ) {
$maybe_error = self::ensure_configured();
if ( $maybe_error instanceof WP_Error ) {
return $maybe_error;
}
if ( array_key_exists( $slug, self::$plugins ) ) {
unset( self::$plugins[ $slug ] );
}
return true;
}
/**
* The method makes sure that `Jetpack\Config` has finished, and it's now safe to retrieve the list of plugins.
*
* @return bool|WP_Error
*/
private static function ensure_configured() {
if ( ! self::$configured ) {
return new WP_Error( 'too_early', __( 'You cannot call this method until Jetpack Config is configured', 'jetpack-connection' ) );
}
if ( is_multisite() && get_current_blog_id() !== self::$current_blog_id ) {
self::$plugins = (array) get_option( self::ACTIVE_PLUGINS_OPTION_NAME, array() );
self::$current_blog_id = get_current_blog_id();
}
return true;
}
/**
* Called once to configure this class after plugins_loaded.
*
* @return void
*/
public static function configure() {
if ( self::$configured ) {
return;
}
if ( is_multisite() ) {
self::$current_blog_id = get_current_blog_id();
}
// If a plugin was activated or deactivated.
// self::$plugins is populated in Config::ensure_options_connection().
$number_of_plugins_differ = count( self::$plugins ) !== count( (array) get_option( self::ACTIVE_PLUGINS_OPTION_NAME, array() ) );
if ( $number_of_plugins_differ || true === self::$refresh_connected_plugins ) {
self::update_active_plugins_option();
}
self::$configured = true;
}
/**
* Updates the active plugins option with current list of active plugins.
*
* @return void
*/
public static function update_active_plugins_option() {
// Note: Since this options is synced to wpcom, if you change its structure, you have to update the sanitizer at wpcom side.
update_option( self::ACTIVE_PLUGINS_OPTION_NAME, self::$plugins );
if ( ! class_exists( 'Automattic\Jetpack\Sync\Settings' ) || ! \Automattic\Jetpack\Sync\Settings::is_sync_enabled() ) {
self::update_active_plugins_wpcom_no_sync_fallback();
}
}
/**
* Add the plugin to the set of disconnected ones.
*
* @deprecated since 1.39.0.
*
* @param string $slug Plugin slug.
*
* @return bool
*/
public static function disable_plugin( $slug ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return true;
}
/**
* Remove the plugin from the set of disconnected ones.
*
* @deprecated since 1.39.0.
*
* @param string $slug Plugin slug.
*
* @return bool
*/
public static function enable_plugin( $slug ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return true;
}
/**
* Get all plugins that were disconnected by user.
*
* @deprecated since 1.39.0.
*
* @return array
*/
public static function get_all_disabled_plugins() { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
return array();
}
/**
* Update active plugins option with current list of active plugins on WPCOM.
* This is a fallback to ensure this option is always up to date on WPCOM in case
* Sync is not present or disabled.
*
* @since 1.34.0
*/
private static function update_active_plugins_wpcom_no_sync_fallback() {
$connection = new Manager();
if ( ! $connection->is_connected() ) {
return;
}
$site_id = \Jetpack_Options::get_option( 'id' );
$body = wp_json_encode(
array(
'active_connected_plugins' => self::$plugins,
)
);
Client::wpcom_json_api_request_as_blog(
sprintf( '/sites/%d/jetpack-active-connected-plugins', $site_id ),
'2',
array(
'headers' => array( 'content-type' => 'application/json' ),
'method' => 'POST',
),
$body,
'wpcom'
);
}
}

View File

@ -0,0 +1,122 @@
<?php
/**
* Plugin connection management class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* Plugin connection management class.
* The class represents a single plugin that uses Jetpack connection.
* Its functionality has been pretty simplistic so far: add to the storage (`Plugin_Storage`), remove it from there,
* and determine whether it's the last active connection. As the component grows, there'll be more functionality added.
*/
class Plugin {
/**
* List of the keys allowed as arguments
*
* @var array
*/
private $arguments_whitelist = array(
'url_info',
);
/**
* Plugin slug.
*
* @var string
*/
private $slug;
/**
* Initialize the plugin manager.
*
* @param string $slug Plugin slug.
*/
public function __construct( $slug ) {
$this->slug = $slug;
}
/**
* Get the plugin slug.
*
* @return string
*/
public function get_slug() {
return $this->slug;
}
/**
* Add the plugin connection info into Jetpack.
*
* @param string $name Plugin name, required.
* @param array $args Plugin arguments, optional.
*
* @return $this
* @see $this->arguments_whitelist
*/
public function add( $name, array $args = array() ) {
$args = compact( 'name' ) + array_intersect_key( $args, array_flip( $this->arguments_whitelist ) );
Plugin_Storage::upsert( $this->slug, $args );
return $this;
}
/**
* Remove the plugin connection info from Jetpack.
*
* @return $this
*/
public function remove() {
Plugin_Storage::delete( $this->slug );
return $this;
}
/**
* Determine if this plugin connection is the only one active at the moment, if any.
*
* @return bool
*/
public function is_only() {
$plugins = Plugin_Storage::get_all( true );
return ! $plugins || ( array_key_exists( $this->slug, $plugins ) && 1 === count( $plugins ) );
}
/**
* Add the plugin to the set of disconnected ones.
*
* @deprecated since 1.39.0.
*
* @return bool
*/
public function disable() {
return true;
}
/**
* Remove the plugin from the set of disconnected ones.
*
* @deprecated since 1.39.0.
*
* @return bool
*/
public function enable() {
return true;
}
/**
* Whether this plugin is allowed to use the connection.
*
* @deprecated since $next-version$$
* @return bool
*/
public function is_enabled() {
return true;
}
}

View File

@ -0,0 +1,220 @@
<?php
/**
* The Jetpack Connection Rest Authentication file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* The Jetpack Connection Rest Authentication class.
*/
class Rest_Authentication {
/**
* The rest authentication status.
*
* @since 1.17.0
* @var boolean
*/
private $rest_authentication_status = null;
/**
* The rest authentication type.
* Can be either 'user' or 'blog' depending on whether the request
* is signed with a user or a blog token.
*
* @since 1.29.0
* @var string
*/
private $rest_authentication_type = null;
/**
* The Manager object.
*
* @since 1.17.0
* @var Object
*/
private $connection_manager = null;
/**
* Holds the singleton instance of this class
*
* @since 1.17.0
* @var Object
*/
private static $instance = false;
/**
* Flag used to avoid determine_current_user filter to enter an infinite loop
*
* @since 1.26.0
* @var boolean
*/
private $doing_determine_current_user_filter = false;
/**
* The constructor.
*/
private function __construct() {
$this->connection_manager = new Manager();
}
/**
* Controls the single instance of this class.
*
* @static
*/
public static function init() {
if ( ! self::$instance ) {
self::$instance = new self();
add_filter( 'determine_current_user', array( self::$instance, 'wp_rest_authenticate' ) );
add_filter( 'rest_authentication_errors', array( self::$instance, 'wp_rest_authentication_errors' ) );
}
return self::$instance;
}
/**
* Authenticates requests from Jetpack server to WP REST API endpoints.
* Uses the existing XMLRPC request signing implementation.
*
* @param int|bool $user User ID if one has been determined, false otherwise.
*
* @return int|null The user id or null if the request was authenticated via blog token, or not authenticated at all.
*/
public function wp_rest_authenticate( $user ) {
if ( $this->doing_determine_current_user_filter ) {
return $user;
}
$this->doing_determine_current_user_filter = true;
try {
if ( ! empty( $user ) ) {
// Another authentication method is in effect.
return $user;
}
add_filter(
'jetpack_constant_default_value',
__NAMESPACE__ . '\Utils::jetpack_api_constant_filter',
10,
2
);
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['_for'] ) || 'jetpack' !== $_GET['_for'] ) {
// Nothing to do for this authentication method.
return null;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['token'] ) && ! isset( $_GET['signature'] ) ) {
// Nothing to do for this authentication method.
return null;
}
if ( ! isset( $_SERVER['REQUEST_METHOD'] ) ) {
$this->rest_authentication_status = new \WP_Error(
'rest_invalid_request',
__( 'The request method is missing.', 'jetpack-connection' ),
array( 'status' => 400 )
);
return null;
}
// Only support specific request parameters that have been tested and
// are known to work with signature verification. A different method
// can be passed to the WP REST API via the '?_method=' parameter if
// needed.
if ( 'GET' !== $_SERVER['REQUEST_METHOD'] && 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
$this->rest_authentication_status = new \WP_Error(
'rest_invalid_request',
__( 'This request method is not supported.', 'jetpack-connection' ),
array( 'status' => 400 )
);
return null;
}
if ( 'POST' !== $_SERVER['REQUEST_METHOD'] && ! empty( file_get_contents( 'php://input' ) ) ) {
$this->rest_authentication_status = new \WP_Error(
'rest_invalid_request',
__( 'This request method does not support body parameters.', 'jetpack-connection' ),
array( 'status' => 400 )
);
return null;
}
$verified = $this->connection_manager->verify_xml_rpc_signature();
if (
$verified &&
isset( $verified['type'] ) &&
'blog' === $verified['type']
) {
// Site-level authentication successful.
$this->rest_authentication_status = true;
$this->rest_authentication_type = 'blog';
return null;
}
if (
$verified &&
isset( $verified['type'] ) &&
'user' === $verified['type'] &&
! empty( $verified['user_id'] )
) {
// User-level authentication successful.
$this->rest_authentication_status = true;
$this->rest_authentication_type = 'user';
return $verified['user_id'];
}
// Something else went wrong. Probably a signature error.
$this->rest_authentication_status = new \WP_Error(
'rest_invalid_signature',
__( 'The request is not signed correctly.', 'jetpack-connection' ),
array( 'status' => 400 )
);
return null;
} finally {
$this->doing_determine_current_user_filter = false;
}
}
/**
* Report authentication status to the WP REST API.
*
* @param WP_Error|mixed $value Error from another authentication handler, null if we should handle it, or another value if not.
* @return WP_Error|boolean|null {@see WP_JSON_Server::check_authentication}
*/
public function wp_rest_authentication_errors( $value ) {
if ( null !== $value ) {
return $value;
}
return $this->rest_authentication_status;
}
/**
* Resets the saved authentication state in between testing requests.
*/
public function reset_saved_auth_state() {
$this->rest_authentication_status = null;
$this->connection_manager->reset_saved_auth_state();
}
/**
* Whether the request was signed with a blog token.
*
* @since 1.29.0
*
* @return bool True if the request was signed with a valid blog token, false otherwise.
*/
public static function is_signed_with_blog_token() {
$instance = self::init();
return true === $instance->rest_authentication_status && 'blog' === $instance->rest_authentication_type;
}
}

View File

@ -0,0 +1,851 @@
<?php
/**
* Sets up the Connection REST API endpoints.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Status;
use Jetpack_XMLRPC_Server;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Registers the REST routes for Connections.
*/
class REST_Connector {
/**
* The Connection Manager.
*
* @var Manager
*/
private $connection;
/**
* This property stores the localized "Insufficient Permissions" error message.
*
* @var string Generic error message when user is not allowed to perform an action.
*/
private static $user_permissions_error_msg;
const JETPACK__DEBUGGER_PUBLIC_KEY = "\r\n" . '-----BEGIN PUBLIC KEY-----' . "\r\n"
. 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm+uLLVoxGCY71LS6KFc6' . "\r\n"
. '1UnF6QGBAsi5XF8ty9kR3/voqfOkpW+gRerM2Kyjy6DPCOmzhZj7BFGtxSV2ZoMX' . "\r\n"
. '9ZwWxzXhl/Q/6k8jg8BoY1QL6L2K76icXJu80b+RDIqvOfJruaAeBg1Q9NyeYqLY' . "\r\n"
. 'lEVzN2vIwcFYl+MrP/g6Bc2co7Jcbli+tpNIxg4Z+Hnhbs7OJ3STQLmEryLpAxQO' . "\r\n"
. 'q8cbhQkMx+FyQhxzSwtXYI/ClCUmTnzcKk7SgGvEjoKGAmngILiVuEJ4bm7Q1yok' . "\r\n"
. 'xl9+wcfW6JAituNhml9dlHCWnn9D3+j8pxStHihKy2gVMwiFRjLEeD8K/7JVGkb/' . "\r\n"
. 'EwIDAQAB' . "\r\n"
. '-----END PUBLIC KEY-----' . "\r\n";
/**
* Constructor.
*
* @param Manager $connection The Connection Manager.
*/
public function __construct( Manager $connection ) {
$this->connection = $connection;
self::$user_permissions_error_msg = esc_html__(
'You do not have the correct user permissions to perform this action.
Please contact your site admin if you think this is a mistake.',
'jetpack-connection'
);
$jp_version = Constants::get_constant( 'JETPACK__VERSION' );
if ( ! $this->connection->has_connected_owner() ) {
// Register a site.
register_rest_route(
'jetpack/v4',
'/verify_registration',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'verify_registration' ),
'permission_callback' => '__return_true',
)
);
}
// Authorize a remote user.
register_rest_route(
'jetpack/v4',
'/remote_authorize',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => __CLASS__ . '::remote_authorize',
'permission_callback' => '__return_true',
)
);
// Get current connection status of Jetpack.
register_rest_route(
'jetpack/v4',
'/connection',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => __CLASS__ . '::connection_status',
'permission_callback' => '__return_true',
)
);
// Disconnect site.
register_rest_route(
'jetpack/v4',
'/connection',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => __CLASS__ . '::disconnect_site',
'permission_callback' => __CLASS__ . '::disconnect_site_permission_check',
'args' => array(
'isActive' => array(
'description' => __( 'Set to false will trigger the site to disconnect.', 'jetpack-connection' ),
'validate_callback' => function ( $value ) {
if ( false !== $value ) {
return new WP_Error(
'rest_invalid_param',
__( 'The isActive argument should be set to false.', 'jetpack-connection' ),
array( 'status' => 400 )
);
}
return true;
},
'required' => true,
),
),
)
);
// We are only registering this route if Jetpack-the-plugin is not active or it's version is ge 10.0-alpha.
// The reason for doing so is to avoid conflicts between the Connection package and
// older versions of Jetpack, registering the same route twice.
if ( empty( $jp_version ) || version_compare( $jp_version, '10.0-alpha', '>=' ) ) {
// Get current user connection data.
register_rest_route(
'jetpack/v4',
'/connection/data',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => __CLASS__ . '::get_user_connection_data',
'permission_callback' => __CLASS__ . '::user_connection_data_permission_check',
)
);
}
// Get list of plugins that use the Jetpack connection.
register_rest_route(
'jetpack/v4',
'/connection/plugins',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( __CLASS__, 'get_connection_plugins' ),
'permission_callback' => __CLASS__ . '::connection_plugins_permission_check',
)
);
// Full or partial reconnect in case of connection issues.
register_rest_route(
'jetpack/v4',
'/connection/reconnect',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'connection_reconnect' ),
'permission_callback' => __CLASS__ . '::jetpack_reconnect_permission_check',
)
);
// Register the site (get `blog_token`).
register_rest_route(
'jetpack/v4',
'/connection/register',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'connection_register' ),
'permission_callback' => __CLASS__ . '::jetpack_register_permission_check',
'args' => array(
'from' => array(
'description' => __( 'Indicates where the registration action was triggered for tracking/segmentation purposes', 'jetpack-connection' ),
'type' => 'string',
),
'registration_nonce' => array(
'description' => __( 'The registration nonce', 'jetpack-connection' ),
'type' => 'string',
'required' => true,
),
'redirect_uri' => array(
'description' => __( 'URI of the admin page where the user should be redirected after connection flow', 'jetpack-connection' ),
'type' => 'string',
),
'plugin_slug' => array(
'description' => __( 'Indicates from what plugin the request is coming from', 'jetpack-connection' ),
'type' => 'string',
),
),
)
);
// Get authorization URL.
register_rest_route(
'jetpack/v4',
'/connection/authorize_url',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'connection_authorize_url' ),
'permission_callback' => __CLASS__ . '::user_connection_data_permission_check',
'args' => array(
'redirect_uri' => array(
'description' => __( 'URI of the admin page where the user should be redirected after connection flow', 'jetpack-connection' ),
'type' => 'string',
),
),
)
);
register_rest_route(
'jetpack/v4',
'/user-token',
array(
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( static::class, 'update_user_token' ),
'permission_callback' => array( static::class, 'update_user_token_permission_check' ),
'args' => array(
'user_token' => array(
'description' => __( 'New user token', 'jetpack-connection' ),
'type' => 'string',
'required' => true,
),
'is_connection_owner' => array(
'description' => __( 'Is connection owner', 'jetpack-connection' ),
'type' => 'boolean',
),
),
),
)
);
// Set the connection owner.
register_rest_route(
'jetpack/v4',
'/connection/owner',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( static::class, 'set_connection_owner' ),
'permission_callback' => array( static::class, 'set_connection_owner_permission_check' ),
'args' => array(
'owner' => array(
'description' => __( 'New owner', 'jetpack-connection' ),
'type' => 'integer',
'required' => true,
),
),
)
);
}
/**
* Handles verification that a site is registered.
*
* @since 1.7.0
* @since-jetpack 5.4.0
*
* @param WP_REST_Request $request The request sent to the WP REST API.
*
* @return string|WP_Error
*/
public function verify_registration( WP_REST_Request $request ) {
$registration_data = array( $request['secret_1'], $request['state'] );
return $this->connection->handle_registration( $registration_data );
}
/**
* Handles verification that a site is registered
*
* @since 1.7.0
* @since-jetpack 5.4.0
*
* @param WP_REST_Request $request The request sent to the WP REST API.
*
* @return array|wp-error
*/
public static function remote_authorize( $request ) {
$xmlrpc_server = new Jetpack_XMLRPC_Server();
$result = $xmlrpc_server->remote_authorize( $request );
if ( is_a( $result, 'IXR_Error' ) ) {
$result = new WP_Error( $result->code, $result->message );
}
return $result;
}
/**
* Get connection status for this Jetpack site.
*
* @since 1.7.0
* @since-jetpack 4.3.0
*
* @param bool $rest_response Should we return a rest response or a simple array. Default to rest response.
*
* @return WP_REST_Response|array Connection information.
*/
public static function connection_status( $rest_response = true ) {
$status = new Status();
$connection = new Manager();
$connection_status = array(
'isActive' => $connection->is_active(), // TODO deprecate this.
'isStaging' => $status->is_staging_site(),
'isRegistered' => $connection->is_connected(),
'isUserConnected' => $connection->is_user_connected(),
'hasConnectedOwner' => $connection->has_connected_owner(),
'offlineMode' => array(
'isActive' => $status->is_offline_mode(),
'constant' => defined( 'JETPACK_DEV_DEBUG' ) && JETPACK_DEV_DEBUG,
'url' => $status->is_local_site(),
/** This filter is documented in packages/status/src/class-status.php */
'filter' => ( apply_filters( 'jetpack_development_mode', false ) || apply_filters( 'jetpack_offline_mode', false ) ), // jetpack_development_mode is deprecated.
'wpLocalConstant' => defined( 'WP_LOCAL_DEV' ) && WP_LOCAL_DEV,
),
'isPublic' => '1' == get_option( 'blog_public' ), // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
);
/**
* Filters the connection status data.
*
* @since 1.25.0
*
* @param array An array containing the connection status data.
*/
$connection_status = apply_filters( 'jetpack_connection_status', $connection_status );
if ( $rest_response ) {
return rest_ensure_response(
$connection_status
);
} else {
return $connection_status;
}
}
/**
* Get plugins connected to the Jetpack.
*
* @param bool $rest_response Should we return a rest response or a simple array. Default to rest response.
*
* @since 1.13.1
* @since 1.38.0 Added $rest_response param.
*
* @return WP_REST_Response|WP_Error Response or error object, depending on the request result.
*/
public static function get_connection_plugins( $rest_response = true ) {
$plugins = ( new Manager() )->get_connected_plugins();
if ( is_wp_error( $plugins ) ) {
return $plugins;
}
array_walk(
$plugins,
function ( &$data, $slug ) {
$data['slug'] = $slug;
}
);
if ( $rest_response ) {
return rest_ensure_response( array_values( $plugins ) );
}
return array_values( $plugins );
}
/**
* Verify that user can view Jetpack admin page and can activate plugins.
*
* @since 1.15.0
*
* @return bool|WP_Error Whether user has the capability 'activate_plugins'.
*/
public static function activate_plugins_permission_check() {
if ( current_user_can( 'activate_plugins' ) ) {
return true;
}
return new WP_Error( 'invalid_user_permission_activate_plugins', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
/**
* Permission check for the connection_plugins endpoint
*
* @return bool|WP_Error
*/
public static function connection_plugins_permission_check() {
if ( true === static::activate_plugins_permission_check() ) {
return true;
}
if ( true === static::is_request_signed_by_jetpack_debugger() ) {
return true;
}
return new WP_Error( 'invalid_user_permission_activate_plugins', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
/**
* Permission check for the disconnect site endpoint.
*
* @since 1.30.1
*
* @return bool|WP_Error True if user is able to disconnect the site.
*/
public static function disconnect_site_permission_check() {
if ( current_user_can( 'jetpack_disconnect' ) ) {
return true;
}
return new WP_Error(
'invalid_user_permission_jetpack_disconnect',
self::get_user_permissions_error_msg(),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Get miscellaneous user data related to the connection. Similar data available in old "My Jetpack".
* Information about the master/primary user.
* Information about the current user.
*
* @param bool $rest_response Should we return a rest response or a simple array. Default to rest response.
*
* @since 1.30.1
*
* @return \WP_REST_Response|array
*/
public static function get_user_connection_data( $rest_response = true ) {
$connection = new Manager();
$current_user = wp_get_current_user();
$connection_owner = $connection->get_connection_owner();
$owner_display_name = false === $connection_owner ? null : $connection_owner->display_name;
$is_user_connected = $connection->is_user_connected();
$is_master_user = false === $connection_owner ? false : ( $current_user->ID === $connection_owner->ID );
$wpcom_user_data = $connection->get_connected_user_data();
// Add connected user gravatar to the returned wpcom_user_data.
// Probably we shouldn't do this when $wpcom_user_data is false, but we have been since 2016 so
// clients probably expect that by now.
if ( false === $wpcom_user_data ) {
$wpcom_user_data = array();
}
$wpcom_user_data['avatar'] = ( ! empty( $wpcom_user_data['email'] ) ?
get_avatar_url(
$wpcom_user_data['email'],
array(
'size' => 64,
'default' => 'mysteryman',
)
)
: false );
$current_user_connection_data = array(
'isConnected' => $is_user_connected,
'isMaster' => $is_master_user,
'username' => $current_user->user_login,
'id' => $current_user->ID,
'wpcomUser' => $wpcom_user_data,
'gravatar' => get_avatar_url( $current_user->ID, 64, 'mm', '', array( 'force_display' => true ) ),
'permissions' => array(
'connect' => current_user_can( 'jetpack_connect' ),
'connect_user' => current_user_can( 'jetpack_connect_user' ),
'disconnect' => current_user_can( 'jetpack_disconnect' ),
),
);
/**
* Filters the current user connection data.
*
* @since 1.30.1
*
* @param array An array containing the current user connection data.
*/
$current_user_connection_data = apply_filters( 'jetpack_current_user_connection_data', $current_user_connection_data );
$response = array(
'currentUser' => $current_user_connection_data,
'connectionOwner' => $owner_display_name,
);
if ( $rest_response ) {
return rest_ensure_response( $response );
}
return $response;
}
/**
* Permission check for the connection/data endpoint
*
* @return bool|WP_Error
*/
public static function user_connection_data_permission_check() {
if ( current_user_can( 'jetpack_connect_user' ) ) {
return true;
}
return new WP_Error(
'invalid_user_permission_user_connection_data',
self::get_user_permissions_error_msg(),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Verifies if the request was signed with the Jetpack Debugger key
*
* @param string|null $pub_key The public key used to verify the signature. Default is the Jetpack Debugger key. This is used for testing purposes.
*
* @return bool
*/
public static function is_request_signed_by_jetpack_debugger( $pub_key = null ) {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['signature'], $_GET['timestamp'], $_GET['url'], $_GET['rest_route'] ) ) {
return false;
}
// signature timestamp must be within 5min of current time.
if ( abs( time() - (int) $_GET['timestamp'] ) > 300 ) {
return false;
}
$signature = base64_decode( filter_var( wp_unslash( $_GET['signature'] ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$signature_data = wp_json_encode(
array(
'rest_route' => filter_var( wp_unslash( $_GET['rest_route'] ) ),
'timestamp' => (int) $_GET['timestamp'],
'url' => filter_var( wp_unslash( $_GET['url'] ) ),
)
);
if (
! function_exists( 'openssl_verify' )
|| 1 !== openssl_verify(
$signature_data,
$signature,
$pub_key ? $pub_key : static::JETPACK__DEBUGGER_PUBLIC_KEY
)
) {
return false;
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return true;
}
/**
* Verify that user is allowed to disconnect Jetpack.
*
* @since 1.15.0
*
* @return bool|WP_Error Whether user has the capability 'jetpack_disconnect'.
*/
public static function jetpack_reconnect_permission_check() {
if ( current_user_can( 'jetpack_reconnect' ) ) {
return true;
}
return new WP_Error( 'invalid_user_permission_jetpack_disconnect', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
/**
* Returns generic error message when user is not allowed to perform an action.
*
* @return string The error message.
*/
public static function get_user_permissions_error_msg() {
return self::$user_permissions_error_msg;
}
/**
* The endpoint tried to partially or fully reconnect the website to WP.com.
*
* @since 1.15.0
*
* @return \WP_REST_Response|WP_Error
*/
public function connection_reconnect() {
$response = array();
$next = null;
$result = $this->connection->restore();
if ( is_wp_error( $result ) ) {
$response = $result;
} elseif ( is_string( $result ) ) {
$next = $result;
} else {
$next = true === $result ? 'completed' : 'failed';
}
switch ( $next ) {
case 'authorize':
$response['status'] = 'in_progress';
$response['authorizeUrl'] = $this->connection->get_authorization_url();
break;
case 'completed':
$response['status'] = 'completed';
/**
* Action fired when reconnection has completed successfully.
*
* @since 1.18.1
*/
do_action( 'jetpack_reconnection_completed' );
break;
case 'failed':
$response = new WP_Error( 'Reconnect failed' );
break;
}
return rest_ensure_response( $response );
}
/**
* Verify that user is allowed to connect Jetpack.
*
* @since 1.26.0
*
* @return bool|WP_Error Whether user has the capability 'jetpack_connect'.
*/
public static function jetpack_register_permission_check() {
if ( current_user_can( 'jetpack_connect' ) ) {
return true;
}
return new WP_Error( 'invalid_user_permission_jetpack_connect', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
/**
* The endpoint tried to partially or fully reconnect the website to WP.com.
*
* @since 1.7.0
* @since-jetpack 7.7.0
*
* @param \WP_REST_Request $request The request sent to the WP REST API.
*
* @return \WP_REST_Response|WP_Error
*/
public function connection_register( $request ) {
if ( ! wp_verify_nonce( $request->get_param( 'registration_nonce' ), 'jetpack-registration-nonce' ) ) {
return new WP_Error( 'invalid_nonce', __( 'Unable to verify your request.', 'jetpack-connection' ), array( 'status' => 403 ) );
}
if ( isset( $request['from'] ) ) {
$this->connection->add_register_request_param( 'from', (string) $request['from'] );
}
if ( ! empty( $request['plugin_slug'] ) ) {
// If `plugin_slug` matches a plugin using the connection, let's inform the plugin that is establishing the connection.
$connected_plugin = Plugin_Storage::get_one( (string) $request['plugin_slug'] );
if ( ! is_wp_error( $connected_plugin ) && ! empty( $connected_plugin ) ) {
$this->connection->set_plugin_instance( new Plugin( (string) $request['plugin_slug'] ) );
}
}
$result = $this->connection->try_registration();
if ( is_wp_error( $result ) ) {
return $result;
}
$redirect_uri = $request->get_param( 'redirect_uri' ) ? admin_url( $request->get_param( 'redirect_uri' ) ) : null;
if ( class_exists( 'Jetpack' ) ) {
$authorize_url = \Jetpack::build_authorize_url( $redirect_uri );
} else {
$authorize_url = $this->connection->get_authorization_url( null, $redirect_uri );
}
/**
* Filters the response of jetpack/v4/connection/register endpoint
*
* @param array $response Array response
* @since 1.27.0
*/
$response_body = apply_filters(
'jetpack_register_site_rest_response',
array()
);
// We manipulate the alternate URLs after the filter is applied, so they can not be overwritten.
$response_body['authorizeUrl'] = $authorize_url;
if ( ! empty( $response_body['alternateAuthorizeUrl'] ) ) {
$response_body['alternateAuthorizeUrl'] = Redirect::get_url( $response_body['alternateAuthorizeUrl'] );
}
return rest_ensure_response( $response_body );
}
/**
* Get the authorization URL.
*
* @since 1.27.0
*
* @param \WP_REST_Request $request The request sent to the WP REST API.
*
* @return \WP_REST_Response|WP_Error
*/
public function connection_authorize_url( $request ) {
$redirect_uri = $request->get_param( 'redirect_uri' ) ? admin_url( $request->get_param( 'redirect_uri' ) ) : null;
$authorize_url = $this->connection->get_authorization_url( null, $redirect_uri );
return rest_ensure_response(
array(
'authorizeUrl' => $authorize_url,
)
);
}
/**
* The endpoint tried to partially or fully reconnect the website to WP.com.
*
* @since 1.29.0
*
* @param \WP_REST_Request $request The request sent to the WP REST API.
*
* @return \WP_REST_Response|WP_Error
*/
public static function update_user_token( $request ) {
$token_parts = explode( '.', $request['user_token'] );
if ( count( $token_parts ) !== 3 || ! (int) $token_parts[2] || ! ctype_digit( $token_parts[2] ) ) {
return new WP_Error( 'invalid_argument_user_token', esc_html__( 'Invalid user token is provided', 'jetpack-connection' ) );
}
$user_id = (int) $token_parts[2];
if ( false === get_userdata( $user_id ) ) {
return new WP_Error( 'invalid_argument_user_id', esc_html__( 'Invalid user id is provided', 'jetpack-connection' ) );
}
$connection = new Manager();
if ( ! $connection->is_connected() ) {
return new WP_Error( 'site_not_connected', esc_html__( 'Site is not connected', 'jetpack-connection' ) );
}
$is_connection_owner = isset( $request['is_connection_owner'] )
? (bool) $request['is_connection_owner']
: ( new Manager() )->get_connection_owner_id() === $user_id;
( new Tokens() )->update_user_token( $user_id, $request['user_token'], $is_connection_owner );
/**
* Fires when the user token gets successfully replaced.
*
* @since 1.29.0
*
* @param int $user_id User ID.
* @param string $token New user token.
*/
do_action( 'jetpack_updated_user_token', $user_id, $request['user_token'] );
return rest_ensure_response(
array(
'success' => true,
)
);
}
/**
* Disconnects Jetpack from the WordPress.com Servers
*
* @since 1.30.1
*
* @return bool|WP_Error True if Jetpack successfully disconnected.
*/
public static function disconnect_site() {
$connection = new Manager();
if ( $connection->is_connected() ) {
$connection->disconnect_site();
return rest_ensure_response( array( 'code' => 'success' ) );
}
return new WP_Error(
'disconnect_failed',
esc_html__( 'Failed to disconnect the site as it appears already disconnected.', 'jetpack-connection' ),
array( 'status' => 400 )
);
}
/**
* Verify that the API client is allowed to replace user token.
*
* @since 1.29.0
*
* @return bool|WP_Error.
*/
public static function update_user_token_permission_check() {
return Rest_Authentication::is_signed_with_blog_token()
? true
: new WP_Error( 'invalid_permission_update_user_token', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
/**
* Change the connection owner.
*
* @since 1.29.0
*
* @param WP_REST_Request $request The request sent to the WP REST API.
*
* @return \WP_REST_Response|WP_Error
*/
public static function set_connection_owner( $request ) {
$new_owner_id = $request['owner'];
$owner_set = ( new Manager() )->update_connection_owner( $new_owner_id );
if ( is_wp_error( $owner_set ) ) {
return $owner_set;
}
return rest_ensure_response(
array(
'code' => 'success',
)
);
}
/**
* Check that user has permission to change the master user.
*
* @since 1.7.0
* @since-jetpack 6.2.0
* @since-jetpack 7.7.0 Update so that any user with jetpack_disconnect privs can set owner.
*
* @return bool|WP_Error True if user is able to change master user.
*/
public static function set_connection_owner_permission_check() {
if ( current_user_can( 'jetpack_disconnect' ) ) {
return true;
}
return new WP_Error( 'invalid_user_permission_set_connection_owner', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
}

View File

@ -0,0 +1,281 @@
<?php
/**
* The Jetpack Connection Secrets class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Jetpack_Options;
use WP_Error;
/**
* The Jetpack Connection Secrets class that is used to manage secrets.
*/
class Secrets {
const SECRETS_MISSING = 'secrets_missing';
const SECRETS_EXPIRED = 'secrets_expired';
const LEGACY_SECRETS_OPTION_NAME = 'jetpack_secrets';
/**
* Deletes all connection secrets from the local Jetpack site.
*/
public function delete_all() {
Jetpack_Options::delete_raw_option( 'jetpack_secrets' );
}
/**
* Runs the wp_generate_password function with the required parameters. This is the
* default implementation of the secret callable, can be overridden using the
* jetpack_connection_secret_generator filter.
*
* @return String $secret value.
*/
private function secret_callable_method() {
$secret = wp_generate_password( 32, false );
// Some sites may hook into the random_password filter and make the password shorter, let's make sure our secret has the required length.
$attempts = 1;
$secret_length = strlen( $secret );
while ( $secret_length < 32 && $attempts < 32 ) {
$attempts++;
$secret .= wp_generate_password( 32, false );
$secret_length = strlen( $secret );
}
return (string) substr( $secret, 0, 32 );
}
/**
* Generates two secret tokens and the end of life timestamp for them.
*
* @param String $action The action name.
* @param Integer|bool $user_id The user identifier. Defaults to `false`.
* @param Integer $exp Expiration time in seconds.
*/
public function generate( $action, $user_id = false, $exp = 600 ) {
if ( false === $user_id ) {
$user_id = get_current_user_id();
}
$callable = apply_filters( 'jetpack_connection_secret_generator', array( get_called_class(), 'secret_callable_method' ) );
$secrets = Jetpack_Options::get_raw_option(
self::LEGACY_SECRETS_OPTION_NAME,
array()
);
$secret_name = 'jetpack_' . $action . '_' . $user_id;
if (
isset( $secrets[ $secret_name ] ) &&
$secrets[ $secret_name ]['exp'] > time()
) {
return $secrets[ $secret_name ];
}
$secret_value = array(
'secret_1' => call_user_func( $callable ),
'secret_2' => call_user_func( $callable ),
'exp' => time() + $exp,
);
$secrets[ $secret_name ] = $secret_value;
$res = Jetpack_Options::update_raw_option( self::LEGACY_SECRETS_OPTION_NAME, $secrets );
return $res ? $secrets[ $secret_name ] : false;
}
/**
* Returns two secret tokens and the end of life timestamp for them.
*
* @param String $action The action name.
* @param Integer $user_id The user identifier.
* @return string|array an array of secrets or an error string.
*/
public function get( $action, $user_id ) {
$secret_name = 'jetpack_' . $action . '_' . $user_id;
$secrets = Jetpack_Options::get_raw_option(
self::LEGACY_SECRETS_OPTION_NAME,
array()
);
if ( ! isset( $secrets[ $secret_name ] ) ) {
return self::SECRETS_MISSING;
}
if ( $secrets[ $secret_name ]['exp'] < time() ) {
$this->delete( $action, $user_id );
return self::SECRETS_EXPIRED;
}
return $secrets[ $secret_name ];
}
/**
* Deletes secret tokens in case they, for example, have expired.
*
* @param String $action The action name.
* @param Integer $user_id The user identifier.
*/
public function delete( $action, $user_id ) {
$secret_name = 'jetpack_' . $action . '_' . $user_id;
$secrets = Jetpack_Options::get_raw_option(
self::LEGACY_SECRETS_OPTION_NAME,
array()
);
if ( isset( $secrets[ $secret_name ] ) ) {
unset( $secrets[ $secret_name ] );
Jetpack_Options::update_raw_option( self::LEGACY_SECRETS_OPTION_NAME, $secrets );
}
}
/**
* Verify a Previously Generated Secret.
*
* @param string $action The type of secret to verify.
* @param string $secret_1 The secret string to compare to what is stored.
* @param int $user_id The user ID of the owner of the secret.
* @return WP_Error|string WP_Error on failure, secret_2 on success.
*/
public function verify( $action, $secret_1, $user_id ) {
$allowed_actions = array( 'register', 'authorize', 'publicize' );
if ( ! in_array( $action, $allowed_actions, true ) ) {
return new WP_Error( 'unknown_verification_action', 'Unknown Verification Action', 400 );
}
$user = get_user_by( 'id', $user_id );
/**
* We've begun verifying the previously generated secret.
*
* @since 1.7.0
* @since-jetpack 7.5.0
*
* @param string $action The type of secret to verify.
* @param \WP_User $user The user object.
*/
do_action( 'jetpack_verify_secrets_begin', $action, $user );
$return_error = function ( WP_Error $error ) use ( $action, $user ) {
/**
* Verifying of the previously generated secret has failed.
*
* @since 1.7.0
* @since-jetpack 7.5.0
*
* @param string $action The type of secret to verify.
* @param \WP_User $user The user object.
* @param WP_Error $error The error object.
*/
do_action( 'jetpack_verify_secrets_fail', $action, $user, $error );
return $error;
};
$stored_secrets = $this->get( $action, $user_id );
$this->delete( $action, $user_id );
$error = null;
if ( empty( $secret_1 ) ) {
$error = $return_error(
new WP_Error(
'verify_secret_1_missing',
/* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
sprintf( __( 'The required "%s" parameter is missing.', 'jetpack-connection' ), 'secret_1' ),
400
)
);
} elseif ( ! is_string( $secret_1 ) ) {
$error = $return_error(
new WP_Error(
'verify_secret_1_malformed',
/* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
sprintf( __( 'The required "%s" parameter is malformed.', 'jetpack-connection' ), 'secret_1' ),
400
)
);
} elseif ( empty( $user_id ) ) {
// $user_id is passed around during registration as "state".
$error = $return_error(
new WP_Error(
'state_missing',
/* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
sprintf( __( 'The required "%s" parameter is missing.', 'jetpack-connection' ), 'state' ),
400
)
);
} elseif ( ! ctype_digit( (string) $user_id ) ) {
$error = $return_error(
new WP_Error(
'state_malformed',
/* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
sprintf( __( 'The required "%s" parameter is malformed.', 'jetpack-connection' ), 'state' ),
400
)
);
} elseif ( self::SECRETS_MISSING === $stored_secrets ) {
$error = $return_error(
new WP_Error(
'verify_secrets_missing',
__( 'Verification secrets not found', 'jetpack-connection' ),
400
)
);
} elseif ( self::SECRETS_EXPIRED === $stored_secrets ) {
$error = $return_error(
new WP_Error(
'verify_secrets_expired',
__( 'Verification took too long', 'jetpack-connection' ),
400
)
);
} elseif ( ! $stored_secrets ) {
$error = $return_error(
new WP_Error(
'verify_secrets_empty',
__( 'Verification secrets are empty', 'jetpack-connection' ),
400
)
);
} elseif ( is_wp_error( $stored_secrets ) ) {
$stored_secrets->add_data( 400 );
$error = $return_error( $stored_secrets );
} elseif ( empty( $stored_secrets['secret_1'] ) || empty( $stored_secrets['secret_2'] ) || empty( $stored_secrets['exp'] ) ) {
$error = $return_error(
new WP_Error(
'verify_secrets_incomplete',
__( 'Verification secrets are incomplete', 'jetpack-connection' ),
400
)
);
} elseif ( ! hash_equals( $secret_1, $stored_secrets['secret_1'] ) ) {
$error = $return_error(
new WP_Error(
'verify_secrets_mismatch',
__( 'Secret mismatch', 'jetpack-connection' ),
400
)
);
}
// Something went wrong during the checks, returning the error.
if ( ! empty( $error ) ) {
return $error;
}
/**
* We've succeeded at verifying the previously generated secret.
*
* @since 1.7.0
* @since-jetpack 7.5.0
*
* @param string $action The type of secret to verify.
* @param \WP_User $user The user object.
*/
do_action( 'jetpack_verify_secrets_success', $action, $user );
return $stored_secrets['secret_2'];
}
}

View File

@ -0,0 +1,244 @@
<?php
/**
* The Server_Sandbox class.
*
* This feature is only useful for Automattic developers.
* It configures Jetpack to talk to staging/sandbox servers
* on WordPress.com instead of production servers.
*
* @package automattic/jetpack-sandbox
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Constants;
/**
* The Server_Sandbox class.
*/
class Server_Sandbox {
/**
* Sets up the action hooks for the server sandbox.
*/
public function init() {
if ( did_action( 'jetpack_server_sandbox_init' ) ) {
return;
}
add_action( 'requests-requests.before_request', array( $this, 'server_sandbox' ), 10, 4 );
add_action( 'admin_bar_menu', array( $this, 'admin_bar_add_sandbox_item' ), 999 );
/**
* Fires when the server sandbox is initialized. This action is used to ensure that
* the server sandbox action hooks are set up only once.
*
* @since 1.30.7
*/
do_action( 'jetpack_server_sandbox_init' );
}
/**
* Returns the new url and host values.
*
* @param string $sandbox Sandbox domain.
* @param string $url URL of request about to be made.
* @param array $headers Headers of request about to be made.
* @param string $data The body of request about to be made.
* @param string $method The method of request about to be made.
*
* @return array [ 'url' => new URL, 'host' => new Host, 'new_signature => New signature if url was changed ]
*/
public function server_sandbox_request_parameters( $sandbox, $url, $headers, $data = null, $method = 'GET' ) {
$host = '';
$new_signature = '';
if ( ! is_string( $sandbox ) || ! is_string( $url ) ) {
return array(
'url' => $url,
'host' => $host,
'new_signature' => $new_signature,
);
}
$url_host = wp_parse_url( $url, PHP_URL_HOST );
switch ( $url_host ) {
case 'public-api.wordpress.com':
case 'jetpack.wordpress.com':
case 'jetpack.com':
case 'dashboard.wordpress.com':
$host = isset( $headers['Host'] ) ? $headers['Host'] : $url_host;
$original_url = $url;
$url = preg_replace(
'@^(https?://)' . preg_quote( $url_host, '@' ) . '(?=[/?#].*|$)@',
'${1}' . $sandbox,
$url,
1
);
/**
* Whether to add the X Debug query parameter to the request made to the Sandbox
*
* @since 1.36.0
*
* @param bool $add_parameter Whether to add the parameter to the request or not. Default is to false.
* @param string $url The URL of the request being made.
* @param string $host The host of the request being made.
*/
if ( apply_filters( 'jetpack_sandbox_add_profile_parameter', false, $url, $host ) ) {
$url = add_query_arg( 'XDEBUG_PROFILE', 1, $url );
// URL has been modified since the signature was created. We'll need a new one.
$original_url = add_query_arg( 'XDEBUG_PROFILE', 1, $original_url );
$new_signature = $this->get_new_signature( $original_url, $headers, $data, $method );
}
}
return compact( 'url', 'host', 'new_signature' );
}
/**
* Gets a new signature for the request
*
* @param string $url The new URL to be signed.
* @param array $headers The headers of the request about to be made.
* @param string $data The body of request about to be made.
* @param string $method The method of the request about to be made.
* @return string|null
*/
private function get_new_signature( $url, $headers, $data, $method ) {
if ( ! empty( $headers['Authorization'] ) ) {
$a_headers = $this->extract_authorization_headers( $headers );
if ( ! empty( $a_headers ) ) {
$token_details = explode( ':', $a_headers['token'] );
if ( count( $token_details ) === 3 ) {
$user_id = $token_details[2];
$token = ( new Tokens() )->get_access_token( $user_id );
$time_diff = (int) \Jetpack_Options::get_option( 'time_diff' );
$jetpack_signature = new \Jetpack_Signature( $token->secret, $time_diff );
$signature = $jetpack_signature->sign_request(
$a_headers['token'],
$a_headers['timestamp'],
$a_headers['nonce'],
$a_headers['body-hash'],
$method,
$url,
$data,
false
);
if ( $signature && ! is_wp_error( $signature ) ) {
return $signature;
} elseif ( is_wp_error( $signature ) ) {
$this->log_new_signature_error( $signature->get_error_message() );
}
} else {
$this->log_new_signature_error( 'Malformed token on Authorization Header' );
}
} else {
$this->log_new_signature_error( 'Error extracting Authorization Header' );
}
} else {
$this->log_new_signature_error( 'Empty Authorization Header' );
}
}
/**
* Logs error if the attempt to create a new signature fails
*
* @param string $message The error message.
* @return void
*/
private function log_new_signature_error( $message ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( sprintf( "SANDBOXING: Error re-signing the request. '%s'", $message ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
}
/**
* Extract the values in the Authorization header into an array
*
* @param array $headers The headers of the request about to be made.
* @return array|null
*/
public function extract_authorization_headers( $headers ) {
if ( ! empty( $headers['Authorization'] ) && is_string( $headers['Authorization'] ) ) {
$header = str_replace( 'X_JETPACK ', '', $headers['Authorization'] );
$vars = explode( ' ', $header );
$result = array();
foreach ( $vars as $var ) {
$elements = explode( '"', $var );
if ( count( $elements ) === 3 ) {
$result[ substr( $elements[0], 0, -1 ) ] = $elements[1];
}
}
return $result;
}
}
/**
* Modifies parameters of request in order to send the request to the
* server specified by `JETPACK__SANDBOX_DOMAIN`.
*
* Attached to the `requests-requests.before_request` filter.
*
* @param string $url URL of request about to be made.
* @param array $headers Headers of request about to be made.
* @param array|string $data Data of request about to be made.
* @param string $type Type of request about to be made.
* @return void
*/
public function server_sandbox( &$url, &$headers, &$data = null, &$type = null ) {
if ( ! Constants::get_constant( 'JETPACK__SANDBOX_DOMAIN' ) ) {
return;
}
$original_url = $url;
$request_parameters = $this->server_sandbox_request_parameters( Constants::get_constant( 'JETPACK__SANDBOX_DOMAIN' ), $url, $headers, $data, $type );
$url = $request_parameters['url'];
if ( $request_parameters['host'] ) {
$headers['Host'] = $request_parameters['host'];
if ( $request_parameters['new_signature'] ) {
$headers['Authorization'] = preg_replace( '/signature=\"[^\"]+\"/', 'signature="' . $request_parameters['new_signature'] . '"', $headers['Authorization'] );
}
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( sprintf( "SANDBOXING via '%s': '%s'", Constants::get_constant( 'JETPACK__SANDBOX_DOMAIN' ), $original_url ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
}
}
/**
* Adds a "Jetpack API Sandboxed" item to the admin bar if the JETPACK__SANDBOX_DOMAIN
* constant is set.
*
* Attached to the `admin_bar_menu` action.
*
* @param WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
*/
public function admin_bar_add_sandbox_item( $wp_admin_bar ) {
if ( ! Constants::get_constant( 'JETPACK__SANDBOX_DOMAIN' ) ) {
return;
}
$node = array(
'id' => 'jetpack-connection-api-sandbox',
'title' => 'Jetpack API Sandboxed',
'meta' => array(
'title' => 'Sandboxing via ' . Constants::get_constant( 'JETPACK__SANDBOX_DOMAIN' ),
),
);
$wp_admin_bar->add_menu( $node );
}
}

View File

@ -0,0 +1,112 @@
<?php
/**
* A Terms of Service class for Jetpack.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack;
/**
* Class Terms_Of_Service
*
* Helper class that is responsible for the state of agreement of the terms of service.
*/
class Terms_Of_Service {
/**
* Jetpack option name where the terms of service state is stored.
*
* @var string
*/
const OPTION_NAME = 'tos_agreed';
/**
* Allow the site to agree to the terms of service.
*/
public function agree() {
$this->set_agree();
/**
* Acton fired when the master user has agreed to the terms of service.
*
* @since 1.0.4
* @since-jetpack 7.9.0
*/
do_action( 'jetpack_agreed_to_terms_of_service' );
}
/**
* Allow the site to reject to the terms of service.
*/
public function reject() {
$this->set_reject();
/**
* Acton fired when the master user has revoked their agreement to the terms of service.
*
* @since 1.0.4
* @since-jetpack 7.9.1
*/
do_action( 'jetpack_reject_terms_of_service' );
}
/**
* Returns whether the master user has agreed to the terms of service.
*
* The following conditions have to be met in order to agree to the terms of service.
* 1. The master user has gone though the connect flow.
* 2. The site is not in dev mode.
* 3. The master user of the site is still connected (deprecated @since 1.4.0).
*
* @return bool
*/
public function has_agreed() {
if ( $this->is_offline_mode() ) {
return false;
}
/**
* Before 1.4.0 we used to also check if the master user of the site is connected
* by calling the Connection related `is_active` method.
* As of 1.4.0 we have removed this check in order to resolve the
* circular dependencies it was introducing to composer packages.
*
* @since 1.4.0
*/
return $this->get_raw_has_agreed();
}
/**
* Abstracted for testing purposes.
* Tells us if the site is in dev mode.
*
* @return bool
*/
protected function is_offline_mode() {
return ( new Status() )->is_offline_mode();
}
/**
* Gets just the Jetpack Option that contains the terms of service state.
* Abstracted for testing purposes.
*
* @return bool
*/
protected function get_raw_has_agreed() {
return \Jetpack_Options::get_option( self::OPTION_NAME, false );
}
/**
* Sets the correct Jetpack Option to mark the that the site has agreed to the terms of service.
* Abstracted for testing purposes.
*/
protected function set_agree() {
\Jetpack_Options::update_option( self::OPTION_NAME, true );
}
/**
* Sets the correct Jetpack Option to mark that the site has rejected the terms of service.
* Abstracted for testing purposes.
*/
protected function set_reject() {
\Jetpack_Options::update_option( self::OPTION_NAME, false );
}
}

View File

@ -0,0 +1,691 @@
<?php
/**
* The Jetpack Connection Tokens class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Roles;
use DateInterval;
use DateTime;
use Exception;
use Jetpack_Options;
use WP_Error;
/**
* The Jetpack Connection Tokens class that manages tokens.
*/
class Tokens {
const MAGIC_NORMAL_TOKEN_KEY = ';normal;';
/**
* Datetime format.
*/
const DATE_FORMAT_ATOM = 'Y-m-d\TH:i:sP';
/**
* Deletes all connection tokens and transients from the local Jetpack site.
*/
public function delete_all() {
Jetpack_Options::delete_option(
array(
'blog_token',
'user_token',
'user_tokens',
)
);
$this->remove_lock();
}
/**
* Perform the API request to validate the blog and user tokens.
*
* @param int|null $user_id ID of the user we need to validate token for. Current user's ID by default.
*
* @return array|false|WP_Error The API response: `array( 'blog_token_is_healthy' => true|false, 'user_token_is_healthy' => true|false )`.
*/
public function validate( $user_id = null ) {
$blog_id = Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error( 'site_not_registered', 'Site not registered.' );
}
$url = sprintf(
'%s/%s/v%s/%s',
Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
'wpcom',
'2',
'sites/' . $blog_id . '/jetpack-token-health'
);
$user_token = $this->get_access_token( $user_id ? $user_id : get_current_user_id() );
$blog_token = $this->get_access_token();
// Cannot validate non-existent tokens.
if ( false === $user_token || false === $blog_token ) {
return false;
}
$method = 'POST';
$body = array(
'user_token' => $this->get_signed_token( $user_token ),
'blog_token' => $this->get_signed_token( $blog_token ),
);
$response = Client::_wp_remote_request( $url, compact( 'body', 'method' ) );
if ( is_wp_error( $response ) || ! wp_remote_retrieve_body( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return $body ? $body : false;
}
/**
* Perform the API request to validate only the blog.
*
* @return bool|WP_Error Boolean with the test result. WP_Error if test cannot be performed.
*/
public function validate_blog_token() {
$blog_id = Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error( 'site_not_registered', 'Site not registered.' );
}
$url = sprintf(
'%s/%s/v%s/%s',
Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
'wpcom',
'2',
'sites/' . $blog_id . '/jetpack-token-health/blog'
);
$method = 'GET';
$response = Client::remote_request( compact( 'url', 'method' ) );
if ( is_wp_error( $response ) || ! wp_remote_retrieve_body( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return is_array( $body ) && isset( $body['is_healthy'] ) && true === $body['is_healthy'];
}
/**
* Obtains the auth token.
*
* @param array $data The request data.
* @param string $token_api_url The URL of the Jetpack "token" API.
* @return object|WP_Error Returns the auth token on success.
* Returns a WP_Error on failure.
*/
public function get( $data, $token_api_url ) {
$roles = new Roles();
$role = $roles->translate_current_user_to_role();
if ( ! $role ) {
return new WP_Error( 'role', __( 'An administrator for this blog must set up the Jetpack connection.', 'jetpack-connection' ) );
}
$client_secret = $this->get_access_token();
if ( ! $client_secret ) {
return new WP_Error( 'client_secret', __( 'You need to register your Jetpack before connecting it.', 'jetpack-connection' ) );
}
/**
* Filter the URL of the first time the user gets redirected back to your site for connection
* data processing.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param string $redirect_url Defaults to the site admin URL.
*/
$processing_url = apply_filters( 'jetpack_token_processing_url', admin_url( 'admin.php' ) );
$redirect = isset( $data['redirect'] ) ? esc_url_raw( (string) $data['redirect'] ) : '';
/**
* Filter the URL to redirect the user back to when the authentication process
* is complete.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param string $redirect_url Defaults to the site URL.
*/
$redirect = apply_filters( 'jetpack_token_redirect_url', $redirect );
$redirect_uri = ( 'calypso' === $data['auth_type'] )
? $data['redirect_uri']
: add_query_arg(
array(
'handler' => 'jetpack-connection-webhooks',
'action' => 'authorize',
'_wpnonce' => wp_create_nonce( "jetpack-authorize_{$role}_{$redirect}" ),
'redirect' => $redirect ? rawurlencode( $redirect ) : false,
),
esc_url( $processing_url )
);
/**
* Filters the token request data.
*
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param array $request_data request data.
*/
$body = apply_filters(
'jetpack_token_request_body',
array(
'client_id' => Jetpack_Options::get_option( 'id' ),
'client_secret' => $client_secret->secret,
'grant_type' => 'authorization_code',
'code' => $data['code'],
'redirect_uri' => $redirect_uri,
)
);
$args = array(
'method' => 'POST',
'body' => $body,
'headers' => array(
'Accept' => 'application/json',
),
);
add_filter( 'http_request_timeout', array( $this, 'return_30' ), PHP_INT_MAX - 1 );
$response = Client::_wp_remote_request( $token_api_url, $args );
remove_filter( 'http_request_timeout', array( $this, 'return_30' ), PHP_INT_MAX - 1 );
if ( is_wp_error( $response ) ) {
return new WP_Error( 'token_http_request_failed', $response->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $response );
$entity = wp_remote_retrieve_body( $response );
if ( $entity ) {
$json = json_decode( $entity );
} else {
$json = false;
}
if ( 200 !== $code || ! empty( $json->error ) ) {
if ( empty( $json->error ) ) {
return new WP_Error( 'unknown', '', $code );
}
/* translators: Error description string. */
$error_description = isset( $json->error_description ) ? sprintf( __( 'Error Details: %s', 'jetpack-connection' ), (string) $json->error_description ) : '';
return new WP_Error( (string) $json->error, $error_description, $code );
}
if ( empty( $json->access_token ) || ! is_scalar( $json->access_token ) ) {
return new WP_Error( 'access_token', '', $code );
}
if ( empty( $json->token_type ) || 'X_JETPACK' !== strtoupper( $json->token_type ) ) {
return new WP_Error( 'token_type', '', $code );
}
if ( empty( $json->scope ) ) {
return new WP_Error( 'scope', 'No Scope', $code );
}
// TODO: get rid of the error silencer.
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@list( $role, $hmac ) = explode( ':', $json->scope );
if ( empty( $role ) || empty( $hmac ) ) {
return new WP_Error( 'scope', 'Malformed Scope', $code );
}
if ( $this->sign_role( $role ) !== $json->scope ) {
return new WP_Error( 'scope', 'Invalid Scope', $code );
}
$cap = $roles->translate_role_to_cap( $role );
if ( ! $cap ) {
return new WP_Error( 'scope', 'No Cap', $code );
}
if ( ! current_user_can( $cap ) ) {
return new WP_Error( 'scope', 'current_user_cannot', $code );
}
return (string) $json->access_token;
}
/**
* Enters a user token into the user_tokens option
*
* @param int $user_id The user id.
* @param string $token The user token.
* @param bool $is_master_user Whether the user is the master user.
* @return bool
*/
public function update_user_token( $user_id, $token, $is_master_user ) {
// Not designed for concurrent updates.
$user_tokens = $this->get_user_tokens();
if ( ! is_array( $user_tokens ) ) {
$user_tokens = array();
}
$user_tokens[ $user_id ] = $token;
if ( $is_master_user ) {
$master_user = $user_id;
$options = compact( 'user_tokens', 'master_user' );
} else {
$options = compact( 'user_tokens' );
}
return Jetpack_Options::update_options( $options );
}
/**
* Sign a user role with the master access token.
* If not specified, will default to the current user.
*
* @access public
*
* @param string $role User role.
* @param int $user_id ID of the user.
* @return string Signed user role.
*/
public function sign_role( $role, $user_id = null ) {
if ( empty( $user_id ) ) {
$user_id = (int) get_current_user_id();
}
if ( ! $user_id ) {
return false;
}
$token = $this->get_access_token();
if ( ! $token || is_wp_error( $token ) ) {
return false;
}
return $role . ':' . hash_hmac( 'md5', "{$role}|{$user_id}", $token->secret );
}
/**
* Increases the request timeout value to 30 seconds.
*
* @return int Returns 30.
*/
public function return_30() {
return 30;
}
/**
* Gets the requested token.
*
* Tokens are one of two types:
* 1. Blog Tokens: These are the "main" tokens. Each site typically has one Blog Token,
* though some sites can have multiple "Special" Blog Tokens (see below). These tokens
* are not associated with a user account. They represent the site's connection with
* the Jetpack servers.
* 2. User Tokens: These are "sub-"tokens. Each connected user account has one User Token.
*
* All tokens look like "{$token_key}.{$private}". $token_key is a public ID for the
* token, and $private is a secret that should never be displayed anywhere or sent
* over the network; it's used only for signing things.
*
* Blog Tokens can be "Normal" or "Special".
* * Normal: The result of a normal connection flow. They look like
* "{$random_string_1}.{$random_string_2}"
* That is, $token_key and $private are both random strings.
* Sites only have one Normal Blog Token. Normal Tokens are found in either
* Jetpack_Options::get_option( 'blog_token' ) (usual) or the JETPACK_BLOG_TOKEN
* constant (rare).
* * Special: A connection token for sites that have gone through an alternative
* connection flow. They look like:
* ";{$special_id}{$special_version};{$wpcom_blog_id};.{$random_string}"
* That is, $private is a random string and $token_key has a special structure with
* lots of semicolons.
* Most sites have zero Special Blog Tokens. Special tokens are only found in the
* JETPACK_BLOG_TOKEN constant.
*
* In particular, note that Normal Blog Tokens never start with ";" and that
* Special Blog Tokens always do.
*
* When searching for a matching Blog Tokens, Blog Tokens are examined in the following
* order:
* 1. Defined Special Blog Tokens (via the JETPACK_BLOG_TOKEN constant)
* 2. Stored Normal Tokens (via Jetpack_Options::get_option( 'blog_token' ))
* 3. Defined Normal Tokens (via the JETPACK_BLOG_TOKEN constant)
*
* @param int|false $user_id false: Return the Blog Token. int: Return that user's User Token.
* @param string|false $token_key If provided, check that the token matches the provided input.
* @param bool|true $suppress_errors If true, return a falsy value when the token isn't found; When false, return a descriptive WP_Error when the token isn't found.
*
* @return object|false|WP_Error
*/
public function get_access_token( $user_id = false, $token_key = false, $suppress_errors = true ) {
if ( $this->is_locked() ) {
$this->delete_all();
return false;
}
$possible_special_tokens = array();
$possible_normal_tokens = array();
$user_tokens = $this->get_user_tokens();
if ( $user_id ) {
if ( ! $user_tokens ) {
return $suppress_errors ? false : new WP_Error( 'no_user_tokens', __( 'No user tokens found', 'jetpack-connection' ) );
}
if ( true === $user_id ) { // connection owner.
$user_id = Jetpack_Options::get_option( 'master_user' );
if ( ! $user_id ) {
return $suppress_errors ? false : new WP_Error( 'empty_master_user_option', __( 'No primary user defined', 'jetpack-connection' ) );
}
}
if ( ! isset( $user_tokens[ $user_id ] ) || ! $user_tokens[ $user_id ] ) {
// translators: %s is the user ID.
return $suppress_errors ? false : new WP_Error( 'no_token_for_user', sprintf( __( 'No token for user %d', 'jetpack-connection' ), $user_id ) );
}
$user_token_chunks = explode( '.', $user_tokens[ $user_id ] );
if ( empty( $user_token_chunks[1] ) || empty( $user_token_chunks[2] ) ) {
// translators: %s is the user ID.
return $suppress_errors ? false : new WP_Error( 'token_malformed', sprintf( __( 'Token for user %d is malformed', 'jetpack-connection' ), $user_id ) );
}
if ( $user_token_chunks[2] !== (string) $user_id ) {
// translators: %1$d is the ID of the requested user. %2$d is the user ID found in the token.
return $suppress_errors ? false : new WP_Error( 'user_id_mismatch', sprintf( __( 'Requesting user_id %1$d does not match token user_id %2$d', 'jetpack-connection' ), $user_id, $user_token_chunks[2] ) );
}
$possible_normal_tokens[] = "{$user_token_chunks[0]}.{$user_token_chunks[1]}";
} else {
$stored_blog_token = Jetpack_Options::get_option( 'blog_token' );
if ( $stored_blog_token ) {
$possible_normal_tokens[] = $stored_blog_token;
}
$defined_tokens_string = Constants::get_constant( 'JETPACK_BLOG_TOKEN' );
if ( $defined_tokens_string ) {
$defined_tokens = explode( ',', $defined_tokens_string );
foreach ( $defined_tokens as $defined_token ) {
if ( ';' === $defined_token[0] ) {
$possible_special_tokens[] = $defined_token;
} else {
$possible_normal_tokens[] = $defined_token;
}
}
}
}
if ( self::MAGIC_NORMAL_TOKEN_KEY === $token_key ) {
$possible_tokens = $possible_normal_tokens;
} else {
$possible_tokens = array_merge( $possible_special_tokens, $possible_normal_tokens );
}
if ( ! $possible_tokens ) {
// If no user tokens were found, it would have failed earlier, so this is about blog token.
return $suppress_errors ? false : new WP_Error( 'no_possible_tokens', __( 'No blog token found', 'jetpack-connection' ) );
}
$valid_token = false;
if ( false === $token_key ) {
// Use first token.
$valid_token = $possible_tokens[0];
} elseif ( self::MAGIC_NORMAL_TOKEN_KEY === $token_key ) {
// Use first normal token.
$valid_token = $possible_tokens[0]; // $possible_tokens only contains normal tokens because of earlier check.
} else {
// Use the token matching $token_key or false if none.
// Ensure we check the full key.
$token_check = rtrim( $token_key, '.' ) . '.';
foreach ( $possible_tokens as $possible_token ) {
if ( hash_equals( substr( $possible_token, 0, strlen( $token_check ) ), $token_check ) ) {
$valid_token = $possible_token;
break;
}
}
}
if ( ! $valid_token ) {
if ( $user_id ) {
// translators: %d is the user ID.
return $suppress_errors ? false : new WP_Error( 'no_valid_user_token', sprintf( __( 'Invalid token for user %d', 'jetpack-connection' ), $user_id ) );
} else {
return $suppress_errors ? false : new WP_Error( 'no_valid_blog_token', __( 'Invalid blog token', 'jetpack-connection' ) );
}
}
return (object) array(
'secret' => $valid_token,
'external_user_id' => (int) $user_id,
);
}
/**
* Updates the blog token to a new value.
*
* @access public
*
* @param string $token the new blog token value.
* @return Boolean Whether updating the blog token was successful.
*/
public function update_blog_token( $token ) {
return Jetpack_Options::update_option( 'blog_token', $token );
}
/**
* Unlinks the current user from the linked WordPress.com user.
*
* @access public
* @static
*
* @todo Refactor to properly load the XMLRPC client independently.
*
* @param Integer $user_id the user identifier.
* @param bool $can_overwrite_primary_user Allow for the primary user to be disconnected.
* @return Boolean Whether the disconnection of the user was successful.
*/
public function disconnect_user( $user_id, $can_overwrite_primary_user = false ) {
$tokens = $this->get_user_tokens();
if ( ! $tokens ) {
return false;
}
if ( Jetpack_Options::get_option( 'master_user' ) === $user_id && ! $can_overwrite_primary_user ) {
return false;
}
if ( ! isset( $tokens[ $user_id ] ) ) {
return false;
}
unset( $tokens[ $user_id ] );
$this->update_user_tokens( $tokens );
return true;
}
/**
* Returns an array of user_id's that have user tokens for communicating with wpcom.
* Able to select by specific capability.
*
* @deprecated 1.30.0
* @see Manager::get_connected_users
*
* @param string $capability The capability of the user.
* @param int|null $limit How many connected users to get before returning.
* @return array Array of WP_User objects if found.
*/
public function get_connected_users( $capability = 'any', $limit = null ) {
_deprecated_function( __METHOD__, '1.30.0' );
return ( new Manager( 'jetpack' ) )->get_connected_users( $capability, $limit );
}
/**
* Fetches a signed token.
*
* @param object $token the token.
* @return WP_Error|string a signed token
*/
public function get_signed_token( $token ) {
if ( ! isset( $token->secret ) || empty( $token->secret ) ) {
return new WP_Error( 'invalid_token' );
}
list( $token_key, $token_secret ) = explode( '.', $token->secret );
$token_key = sprintf(
'%s:%d:%d',
$token_key,
Constants::get_constant( 'JETPACK__API_VERSION' ),
$token->external_user_id
);
$timestamp = time();
if ( function_exists( 'wp_generate_password' ) ) {
$nonce = wp_generate_password( 10, false );
} else {
$nonce = substr( sha1( wp_rand( 0, 1000000 ) ), 0, 10 );
}
$normalized_request_string = join(
"\n",
array(
$token_key,
$timestamp,
$nonce,
)
) . "\n";
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$signature = base64_encode( hash_hmac( 'sha1', $normalized_request_string, $token_secret, true ) );
$auth = array(
'token' => $token_key,
'timestamp' => $timestamp,
'nonce' => $nonce,
'signature' => $signature,
);
$header_pieces = array();
foreach ( $auth as $key => $value ) {
$header_pieces[] = sprintf( '%s="%s"', $key, $value );
}
return join( ' ', $header_pieces );
}
/**
* Gets the list of user tokens
*
* @since 1.30.0
*
* @return bool|array An array of user tokens where keys are user IDs and values are the tokens. False if no user token is found.
*/
public function get_user_tokens() {
return Jetpack_Options::get_option( 'user_tokens' );
}
/**
* Updates the option that stores the user tokens
*
* @since 1.30.0
*
* @param array $tokens An array of user tokens where keys are user IDs and values are the tokens.
* @return bool Was the option successfully updated?
*
* @todo add validate the input.
*/
public function update_user_tokens( $tokens ) {
return Jetpack_Options::update_option( 'user_tokens', $tokens );
}
/**
* Lock the tokens to the current site URL.
*
* @param int $timespan How long the tokens should be locked, in seconds.
*
* @return bool
*/
public function set_lock( $timespan = HOUR_IN_SECONDS ) {
try {
$expires = ( new DateTime() )->add( DateInterval::createFromDateString( (int) $timespan . ' seconds' ) );
} catch ( Exception $e ) {
return false;
}
if ( false === $expires ) {
return false;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return Jetpack_Options::update_option( 'token_lock', $expires->format( static::DATE_FORMAT_ATOM ) . '|||' . base64_encode( Urls::site_url() ) );
}
/**
* Remove the site lock from tokens.
*
* @return bool
*/
public function remove_lock() {
Jetpack_Options::delete_option( 'token_lock' );
return true;
}
/**
* Check if the domain is locked, remove the lock if needed.
* Possible scenarios:
* - lock expired, site URL matches the lock URL: remove the lock, return false.
* - lock not expired, site URL matches the lock URL: return false.
* - site URL does not match the lock URL (expiration date is ignored): return true, do not remove the lock.
*
* @return bool
*/
public function is_locked() {
$the_lock = Jetpack_Options::get_option( 'token_lock' );
if ( ! $the_lock ) {
// Not locked.
return false;
}
$the_lock = explode( '|||', $the_lock, 2 );
if ( count( $the_lock ) !== 2 ) {
// Something's wrong with the lock.
$this->remove_lock();
return false;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$locked_site_url = base64_decode( $the_lock[1] );
$expires = $the_lock[0];
$expiration_date = DateTime::createFromFormat( static::DATE_FORMAT_ATOM, $expires );
if ( false === $expiration_date || ! $locked_site_url ) {
// Something's wrong with the lock.
$this->remove_lock();
return false;
}
if ( Urls::site_url() === $locked_site_url ) {
if ( new DateTime() > $expiration_date ) {
// Site lock expired.
// Site URL matches, removing the lock.
$this->remove_lock();
}
return false;
}
// Site URL doesn't match.
return true;
}
}

View File

@ -0,0 +1,321 @@
<?php
/**
* Nosara Tracks for Jetpack
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack;
/**
* The Tracking class, used to record events in wpcom
*/
class Tracking {
/**
* The assets version.
*
* @since 1.13.1
* @deprecated since 1.40.1
*
* @var string Assets version.
*/
const ASSETS_VERSION = '1.0.0';
/**
* Slug of the product that we are tracking.
*
* @var string
*/
private $product_name;
/**
* Connection manager object.
*
* @var Object
*/
private $connection;
/**
* Creates the Tracking object.
*
* @param String $product_name the slug of the product that we are tracking.
* @param Automattic\Jetpack\Connection\Manager $connection the connection manager object.
*/
public function __construct( $product_name = 'jetpack', $connection = null ) {
$this->product_name = $product_name;
$this->connection = $connection;
if ( $this->connection === null ) {
// TODO We should always pass a Connection.
$this->connection = new Connection\Manager();
}
if ( ! did_action( 'jetpack_set_tracks_ajax_hook' ) ) {
add_action( 'wp_ajax_jetpack_tracks', array( $this, 'ajax_tracks' ) );
/**
* Fires when the Tracking::ajax_tracks() callback has been hooked to the
* wp_ajax_jetpack_tracks action. This action is used to ensure that
* the callback is hooked only once.
*
* @since 1.13.11
*/
do_action( 'jetpack_set_tracks_ajax_hook' );
}
}
/**
* Universal method for for all tracking events triggered via the JavaScript client.
*
* @access public
*/
public function ajax_tracks() {
// Check for nonce.
if (
empty( $_REQUEST['tracksNonce'] )
|| ! wp_verify_nonce( $_REQUEST['tracksNonce'], 'jp-tracks-ajax-nonce' ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- WP core doesn't pre-sanitize nonces either.
) {
wp_send_json_error(
__( 'You arent authorized to do that.', 'jetpack-connection' ),
403
);
}
if ( ! isset( $_REQUEST['tracksEventName'] ) || ! isset( $_REQUEST['tracksEventType'] ) ) {
wp_send_json_error(
__( 'No valid event name or type.', 'jetpack-connection' ),
403
);
}
$tracks_data = array();
if ( 'click' === $_REQUEST['tracksEventType'] && isset( $_REQUEST['tracksEventProp'] ) ) {
if ( is_array( $_REQUEST['tracksEventProp'] ) ) {
$tracks_data = array_map( 'filter_var', wp_unslash( $_REQUEST['tracksEventProp'] ) );
} else {
$tracks_data = array( 'clicked' => filter_var( wp_unslash( $_REQUEST['tracksEventProp'] ) ) );
}
}
$this->record_user_event( filter_var( wp_unslash( $_REQUEST['tracksEventName'] ) ), $tracks_data, null, false );
wp_send_json_success();
}
/**
* Register script necessary for tracking.
*
* @param boolean $enqueue Also enqueue? defaults to false.
*/
public static function register_tracks_functions_scripts( $enqueue = false ) {
// Register jp-tracks as it is a dependency.
wp_register_script(
'jp-tracks',
'//stats.wp.com/w.js',
array(),
gmdate( 'YW' ),
true
);
Assets::register_script(
'jp-tracks-functions',
'../dist/tracks-callables.js',
__FILE__,
array(
'dependencies' => array( 'jp-tracks' ),
'enqueue' => $enqueue,
'in_footer' => true,
'nonmin_path' => 'js/tracks-callables.js',
)
);
}
/**
* Enqueue script necessary for tracking.
*/
public function enqueue_tracks_scripts() {
Assets::register_script(
'jptracks',
'../dist/tracks-ajax.js',
__FILE__,
array(
'dependencies' => array( 'jquery' ),
'enqueue' => true,
'in_footer' => true,
'nonmin_path' => 'js/tracks-ajax.js',
)
);
wp_localize_script(
'jptracks',
'jpTracksAJAX',
array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'jpTracksAJAX_nonce' => wp_create_nonce( 'jp-tracks-ajax-nonce' ),
)
);
}
/**
* Send an event in Tracks.
*
* @param string $event_type Type of the event.
* @param array $data Data to send with the event.
* @param mixed $user Username, user_id, or WP_user object.
* @param bool $use_product_prefix Whether to use the object's product name as a prefix to the event type. If
* set to false, the prefix will be 'jetpack_'.
*/
public function record_user_event( $event_type, $data = array(), $user = null, $use_product_prefix = true ) {
if ( ! $user ) {
$user = wp_get_current_user();
}
$site_url = get_option( 'siteurl' );
$data['_via_ua'] = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
$data['_via_ip'] = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$data['_lg'] = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '';
$data['blog_url'] = $site_url;
$data['blog_id'] = \Jetpack_Options::get_option( 'id' );
// Top level events should not be namespaced.
if ( '_aliasUser' !== $event_type ) {
$prefix = $use_product_prefix ? $this->product_name : 'jetpack';
$event_type = $prefix . '_' . $event_type;
}
$data['jetpack_version'] = defined( 'JETPACK__VERSION' ) ? JETPACK__VERSION : '0';
return $this->tracks_record_event( $user, $event_type, $data );
}
/**
* Record an event in Tracks - this is the preferred way to record events from PHP.
*
* @param mixed $user username, user_id, or WP_user object.
* @param string $event_name The name of the event.
* @param array $properties Custom properties to send with the event.
* @param int $event_timestamp_millis The time in millis since 1970-01-01 00:00:00 when the event occurred.
*
* @return bool true for success | \WP_Error if the event pixel could not be fired
*/
public function tracks_record_event( $user, $event_name, $properties = array(), $event_timestamp_millis = false ) {
// We don't want to track user events during unit tests/CI runs.
if ( $user instanceof \WP_User && 'wptests_capabilities' === $user->cap_key ) {
return false;
}
$terms_of_service = new Terms_Of_Service();
$status = new Status();
// Don't track users who have not agreed to our TOS.
if ( ! $this->should_enable_tracking( $terms_of_service, $status ) ) {
return false;
}
$event_obj = $this->tracks_build_event_obj( $user, $event_name, $properties, $event_timestamp_millis );
if ( is_wp_error( $event_obj->error ) ) {
return $event_obj->error;
}
return $event_obj->record();
}
/**
* Determines whether tracking should be enabled.
*
* @param Automattic\Jetpack\Terms_Of_Service $terms_of_service A Terms_Of_Service object.
* @param Automattic\Jetpack\Status $status A Status object.
*
* @return boolean True if tracking should be enabled, else false.
*/
public function should_enable_tracking( $terms_of_service, $status ) {
if ( $status->is_offline_mode() ) {
return false;
}
return $terms_of_service->has_agreed() || $this->connection->is_user_connected();
}
/**
* Procedurally build a Tracks Event Object.
* NOTE: Use this only when the simpler Automattic\Jetpack\Tracking->jetpack_tracks_record_event() function won't work for you.
*
* @param WP_user $user WP_user object.
* @param string $event_name The name of the event.
* @param array $properties Custom properties to send with the event.
* @param int $event_timestamp_millis The time in millis since 1970-01-01 00:00:00 when the event occurred.
*
* @return \Jetpack_Tracks_Event|\WP_Error
*/
private function tracks_build_event_obj( $user, $event_name, $properties = array(), $event_timestamp_millis = false ) {
$identity = $this->tracks_get_identity( $user->ID );
$properties['user_lang'] = $user->get( 'WPLANG' );
$blog_details = array(
'blog_lang' => isset( $properties['blog_lang'] ) ? $properties['blog_lang'] : get_bloginfo( 'language' ),
);
$timestamp = ( false !== $event_timestamp_millis ) ? $event_timestamp_millis : round( microtime( true ) * 1000 );
$timestamp_string = is_string( $timestamp ) ? $timestamp : number_format( $timestamp, 0, '', '' );
return new \Jetpack_Tracks_Event(
array_merge(
$blog_details,
(array) $properties,
$identity,
array(
'_en' => $event_name,
'_ts' => $timestamp_string,
)
)
);
}
/**
* Get the identity to send to tracks.
*
* @param int $user_id The user id of the local user.
*
* @return array $identity
*/
public function tracks_get_identity( $user_id ) {
// Meta is set, and user is still connected. Use WPCOM ID.
$wpcom_id = get_user_meta( $user_id, 'jetpack_tracks_wpcom_id', true );
if ( $wpcom_id && $this->connection->is_user_connected( $user_id ) ) {
return array(
'_ut' => 'wpcom:user_id',
'_ui' => $wpcom_id,
);
}
// User is connected, but no meta is set yet. Use WPCOM ID and set meta.
if ( $this->connection->is_user_connected( $user_id ) ) {
$wpcom_user_data = $this->connection->get_connected_user_data( $user_id );
update_user_meta( $user_id, 'jetpack_tracks_wpcom_id', $wpcom_user_data['ID'] );
return array(
'_ut' => 'wpcom:user_id',
'_ui' => $wpcom_user_data['ID'],
);
}
// User isn't linked at all. Fall back to anonymous ID.
$anon_id = get_user_meta( $user_id, 'jetpack_tracks_anon_id', true );
if ( ! $anon_id ) {
$anon_id = \Jetpack_Tracks_Client::get_anon_id();
add_user_meta( $user_id, 'jetpack_tracks_anon_id', $anon_id, false );
}
if ( ! isset( $_COOKIE['tk_ai'] ) && ! headers_sent() ) {
setcookie( 'tk_ai', $anon_id, 0, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), false ); // phpcs:ignore Jetpack.Functions.SetCookie -- This is a random string and should be fine.
}
return array(
'_ut' => 'anon',
'_ui' => $anon_id,
);
}
}

View File

@ -0,0 +1,187 @@
<?php
/**
* The Jetpack Connection package Urls class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Constants;
/**
* Provides Url methods for the Connection package.
*/
class Urls {
const HTTPS_CHECK_OPTION_PREFIX = 'jetpack_sync_https_history_';
const HTTPS_CHECK_HISTORY = 5;
/**
* Return URL from option or PHP constant.
*
* @param string $option_name (e.g. 'home').
*
* @return mixed|null URL.
*/
public static function get_raw_url( $option_name ) {
$value = null;
$constant = ( 'home' === $option_name )
? 'WP_HOME'
: 'WP_SITEURL';
// Since we disregard the constant for multisites in ms-default-filters.php,
// let's also use the db value if this is a multisite.
if ( ! is_multisite() && Constants::is_defined( $constant ) ) {
$value = Constants::get_constant( $constant );
} else {
// Let's get the option from the database so that we can bypass filters. This will help
// ensure that we get more uniform values.
$value = \Jetpack_Options::get_raw_option( $option_name );
}
return $value;
}
/**
* Normalize domains by removing www unless declared in the site's option.
*
* @param string $option Option value from the site.
* @param callable $url_function Function retrieving the URL to normalize.
* @return mixed|string URL.
*/
public static function normalize_www_in_url( $option, $url_function ) {
$url = wp_parse_url( call_user_func( $url_function ) );
$option_url = wp_parse_url( get_option( $option ) );
if ( ! $option_url || ! $url ) {
return $url;
}
if ( "www.{$option_url[ 'host' ]}" === $url['host'] ) {
// remove www if not present in option URL.
$url['host'] = $option_url['host'];
}
if ( "www.{$url[ 'host' ]}" === $option_url['host'] ) {
// add www if present in option URL.
$url['host'] = $option_url['host'];
}
$normalized_url = "{$url['scheme']}://{$url['host']}";
if ( isset( $url['path'] ) ) {
$normalized_url .= "{$url['path']}";
}
if ( isset( $url['query'] ) ) {
$normalized_url .= "?{$url['query']}";
}
return $normalized_url;
}
/**
* Return URL with a normalized protocol.
*
* @param callable $callable Function to retrieve URL option.
* @param string $new_value URL Protocol to set URLs to.
* @return string Normalized URL.
*/
public static function get_protocol_normalized_url( $callable, $new_value ) {
$option_key = self::HTTPS_CHECK_OPTION_PREFIX . $callable;
$parsed_url = wp_parse_url( $new_value );
if ( ! $parsed_url ) {
return $new_value;
}
if ( array_key_exists( 'scheme', $parsed_url ) ) {
$scheme = $parsed_url['scheme'];
} else {
$scheme = '';
}
$scheme_history = get_option( $option_key, array() );
$scheme_history[] = $scheme;
// Limit length to self::HTTPS_CHECK_HISTORY.
$scheme_history = array_slice( $scheme_history, ( self::HTTPS_CHECK_HISTORY * -1 ) );
update_option( $option_key, $scheme_history );
$forced_scheme = in_array( 'https', $scheme_history, true ) ? 'https' : 'http';
return set_url_scheme( $new_value, $forced_scheme );
}
/**
* Helper function that is used when getting home or siteurl values. Decides
* whether to get the raw or filtered value.
*
* @param string $url_type URL to get, home or siteurl.
* @return string
*/
public static function get_raw_or_filtered_url( $url_type ) {
$url_function = ( 'home' === $url_type )
? 'home_url'
: 'site_url';
if (
! Constants::is_defined( 'JETPACK_SYNC_USE_RAW_URL' ) ||
Constants::get_constant( 'JETPACK_SYNC_USE_RAW_URL' )
) {
$scheme = is_ssl() ? 'https' : 'http';
$url = self::get_raw_url( $url_type );
$url = set_url_scheme( $url, $scheme );
} else {
$url = self::normalize_www_in_url( $url_type, $url_function );
}
return self::get_protocol_normalized_url( $url_function, $url );
}
/**
* Return the escaped home_url.
*
* @return string
*/
public static function home_url() {
$url = self::get_raw_or_filtered_url( 'home' );
/**
* Allows overriding of the home_url value that is synced back to WordPress.com.
*
* @since 1.7.0
* @since-jetpack 5.2.0
*
* @param string $home_url
*/
return esc_url_raw( apply_filters( 'jetpack_sync_home_url', $url ) );
}
/**
* Return the escaped siteurl.
*
* @return string
*/
public static function site_url() {
$url = self::get_raw_or_filtered_url( 'siteurl' );
/**
* Allows overriding of the site_url value that is synced back to WordPress.com.
*
* @since 1.7.0
* @since-jetpack 5.2.0
*
* @param string $site_url
*/
return esc_url_raw( apply_filters( 'jetpack_sync_site_url', $url ) );
}
/**
* Return main site URL with a normalized protocol.
*
* @return string
*/
public static function main_network_site_url() {
return self::get_protocol_normalized_url( 'main_network_site_url', network_site_url() );
}
}

View File

@ -0,0 +1,87 @@
<?php
/**
* The Jetpack Connection package Utils class file.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Tracking;
/**
* Provides utility methods for the Connection package.
*/
class Utils {
const DEFAULT_JETPACK__API_VERSION = 1;
const DEFAULT_JETPACK__API_BASE = 'https://jetpack.wordpress.com/jetpack.';
const DEFAULT_JETPACK__WPCOM_JSON_API_BASE = 'https://public-api.wordpress.com';
/**
* Enters a user token into the user_tokens option
*
* @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Tokens->update_user_token() instead.
*
* @param int $user_id The user id.
* @param string $token The user token.
* @param bool $is_master_user Whether the user is the master user.
* @return bool
*/
public static function update_user_token( $user_id, $token, $is_master_user ) {
_deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Tokens->update_user_token' );
return ( new Tokens() )->update_user_token( $user_id, $token, $is_master_user );
}
/**
* Filters the value of the api constant.
*
* @param String $constant_value The constant value.
* @param String $constant_name The constant name.
* @return mixed | null
*/
public static function jetpack_api_constant_filter( $constant_value, $constant_name ) {
if ( $constant_value !== null ) {
// If the constant value was already set elsewhere, use that value.
return $constant_value;
}
if ( defined( "self::DEFAULT_$constant_name" ) ) {
return constant( "self::DEFAULT_$constant_name" );
}
return null;
}
/**
* Add a filter to initialize default values of the constants.
*/
public static function init_default_constants() {
add_filter(
'jetpack_constant_default_value',
array( __CLASS__, 'jetpack_api_constant_filter' ),
10,
2
);
}
/**
* Filters the registration request body to include tracking properties.
*
* @param array $properties Already prepared tracking properties.
* @return array amended properties.
*/
public static function filter_register_request_body( $properties ) {
$tracking = new Tracking();
$tracks_identity = $tracking->tracks_get_identity( get_current_user_id() );
return array_merge(
$properties,
array(
'_ui' => $tracks_identity['_ui'],
'_ut' => $tracks_identity['_ut'],
)
);
}
}

View File

@ -0,0 +1,215 @@
<?php
/**
* Connection Webhooks class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\CookieState;
use Automattic\Jetpack\Roles;
use Automattic\Jetpack\Status\Host;
use Automattic\Jetpack\Tracking;
use Jetpack_Options;
/**
* Connection Webhooks class.
*/
class Webhooks {
/**
* The Connection Manager object.
*
* @var Manager
*/
private $connection;
/**
* Webhooks constructor.
*
* @param Manager $connection The Connection Manager object.
*/
public function __construct( $connection ) {
$this->connection = $connection;
}
/**
* Initialize the webhooks.
*
* @param Manager $connection The Connection Manager object.
*/
public static function init( $connection ) {
$webhooks = new static( $connection );
add_action( 'init', array( $webhooks, 'controller' ) );
add_action( 'load-toplevel_page_jetpack', array( $webhooks, 'fallback_jetpack_controller' ) );
}
/**
* Jetpack plugin used to trigger this webhooks in Jetpack::admin_page_load()
*
* The Jetpack toplevel menu is still accessible for stand-alone plugins, and while there's no content for that page, there are still
* actions from Calypso and WPCOM that reach that route regardless of the site having the Jetpack plugin or not. That's why we are still handling it here.
*/
public function fallback_jetpack_controller() {
$this->controller( true );
}
/**
* The "controller" decides which handler we need to run.
*
* @param bool $force Do not check if it's a webhook request and just run the controller.
*/
public function controller( $force = false ) {
if ( ! $force ) {
// The nonce is verified in specific handlers.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['handler'] ) || 'jetpack-connection-webhooks' !== $_GET['handler'] ) {
return;
}
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['connect_url_redirect'] ) ) {
$this->handle_connect_url_redirect();
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['action'] ) ) {
return;
}
// The nonce is verified in specific handlers.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
switch ( $_GET['action'] ) {
case 'authorize':
$this->handle_authorize();
$this->do_exit();
break;
case 'authorize_redirect':
$this->handle_authorize_redirect();
$this->do_exit();
break;
// Class Jetpack::admin_page_load() still handles other cases.
}
}
/**
* Perform the authorization action.
*/
public function handle_authorize() {
if ( $this->connection->is_connected() && $this->connection->is_user_connected() ) {
$redirect_url = apply_filters( 'jetpack_client_authorize_already_authorized_url', admin_url() );
wp_safe_redirect( $redirect_url );
return;
}
do_action( 'jetpack_client_authorize_processing' );
$data = stripslashes_deep( $_GET );
$data['auth_type'] = 'client';
$roles = new Roles();
$role = $roles->translate_current_user_to_role();
$redirect = isset( $data['redirect'] ) ? esc_url_raw( (string) $data['redirect'] ) : '';
check_admin_referer( "jetpack-authorize_{$role}_{$redirect}" );
$tracking = new Tracking();
$result = $this->connection->authorize( $data );
if ( is_wp_error( $result ) ) {
do_action( 'jetpack_client_authorize_error', $result );
$tracking->record_user_event(
'jpc_client_authorize_fail',
array(
'error_code' => $result->get_error_code(),
'error_message' => $result->get_error_message(),
)
);
} else {
/**
* Fires after the Jetpack client is authorized to communicate with WordPress.com.
*
* @param int Jetpack Blog ID.
*
* @since 1.7.0
* @since-jetpack 4.2.0
*/
do_action( 'jetpack_client_authorized', Jetpack_Options::get_option( 'id' ) );
$tracking->record_user_event( 'jpc_client_authorize_success' );
}
$fallback_redirect = apply_filters( 'jetpack_client_authorize_fallback_url', admin_url() );
$redirect = wp_validate_redirect( $redirect ) ? $redirect : $fallback_redirect;
wp_safe_redirect( $redirect );
}
/**
* The authorhize_redirect webhook handler
*/
public function handle_authorize_redirect() {
$authorize_redirect_handler = new Webhooks\Authorize_Redirect( $this->connection );
$authorize_redirect_handler->handle();
}
/**
* The `exit` is wrapped into a method so we could mock it.
*/
protected function do_exit() {
exit;
}
/**
* Handle the `connect_url_redirect` action,
* which is usually called to repeat an attempt for user to authorize the connection.
*
* @return void
*/
public function handle_connect_url_redirect() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
$from = ! empty( $_GET['from'] ) ? sanitize_text_field( wp_unslash( $_GET['from'] ) ) : 'iframe';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- no site changes, sanitization happens in get_authorization_url()
$redirect = ! empty( $_GET['redirect_after_auth'] ) ? wp_unslash( $_GET['redirect_after_auth'] ) : false;
add_filter( 'allowed_redirect_hosts', array( Host::class, 'allow_wpcom_environments' ) );
if ( ! $this->connection->is_user_connected() ) {
if ( ! $this->connection->is_connected() ) {
$this->connection->register();
}
$connect_url = add_query_arg( 'from', $from, $this->connection->get_authorization_url( null, $redirect ) );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
if ( isset( $_GET['notes_iframe'] ) ) {
$connect_url .= '&notes_iframe';
}
wp_safe_redirect( $connect_url );
$this->do_exit();
} else {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
if ( ! isset( $_GET['calypso_env'] ) ) {
( new CookieState() )->state( 'message', 'already_authorized' );
wp_safe_redirect( $redirect );
$this->do_exit();
} else {
$connect_url = add_query_arg(
array(
'from' => $from,
'already_authorized' => true,
),
$this->connection->get_authorization_url()
);
wp_safe_redirect( $connect_url );
$this->do_exit();
}
}
}
}

View File

@ -0,0 +1,105 @@
<?php
/**
* XMLRPC Async Call class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Jetpack_IXR_ClientMulticall;
/**
* Make XMLRPC async calls to WordPress.com
*
* This class allows you to enqueue XMLRPC calls that will be grouped and sent
* at once in a multi-call request at shutdown.
*
* Usage:
*
* XMLRPC_Async_Call::add_call( 'methodName', get_current_user_id(), $arg1, $arg2, etc... )
*
* See XMLRPC_Async_Call::add_call for details
*/
class XMLRPC_Async_Call {
/**
* Hold the IXR Clients that will be dispatched at shutdown
*
* Clients are stored in the following schema:
* [
* $blog_id => [
* $user_id => [
* arrat of Jetpack_IXR_ClientMulticall
* ]
* ]
* ]
*
* @var array
*/
public static $clients = array();
/**
* Adds a new XMLRPC call to the queue to be processed on shutdown
*
* @param string $method The XML-RPC method.
* @param integer $user_id The user ID used to make the request (will use this user's token); Use 0 for the blog token.
* @param mixed ...$args This function accepts any number of additional arguments, that will be passed to the call.
* @return void
*/
public static function add_call( $method, $user_id = 0, ...$args ) {
global $blog_id;
$client_blog_id = is_multisite() ? $blog_id : 0;
if ( ! isset( self::$clients[ $client_blog_id ] ) ) {
self::$clients[ $client_blog_id ] = array();
}
if ( ! isset( self::$clients[ $client_blog_id ][ $user_id ] ) ) {
self::$clients[ $client_blog_id ][ $user_id ] = new Jetpack_IXR_ClientMulticall( array( 'user_id' => $user_id ) );
}
if ( function_exists( 'ignore_user_abort' ) ) {
ignore_user_abort( true );
}
array_unshift( $args, $method );
call_user_func_array( array( self::$clients[ $client_blog_id ][ $user_id ], 'addCall' ), $args );
if ( false === has_action( 'shutdown', array( 'Automattic\Jetpack\Connection\XMLRPC_Async_Call', 'do_calls' ) ) ) {
add_action( 'shutdown', array( 'Automattic\Jetpack\Connection\XMLRPC_Async_Call', 'do_calls' ) );
}
}
/**
* Trigger the calls at shutdown
*
* @return void
*/
public static function do_calls() {
foreach ( self::$clients as $client_blog_id => $blog_clients ) {
if ( $client_blog_id > 0 ) {
$switch_success = switch_to_blog( $client_blog_id, true );
if ( ! $switch_success ) {
continue;
}
}
foreach ( $blog_clients as $client ) {
if ( empty( $client->calls ) ) {
continue;
}
flush();
$client->query();
}
if ( $client_blog_id > 0 ) {
restore_current_blog();
}
}
}
}

View File

@ -0,0 +1,83 @@
<?php
/**
* Sets up the Connection XML-RPC methods.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* Registers the XML-RPC methods for Connections.
*/
class XMLRPC_Connector {
/**
* The Connection Manager.
*
* @var Manager
*/
private $connection;
/**
* Constructor.
*
* @param Manager $connection The Connection Manager.
*/
public function __construct( Manager $connection ) {
$this->connection = $connection;
// Adding the filter late to avoid being overwritten by Jetpack's XMLRPC server.
add_filter( 'xmlrpc_methods', array( $this, 'xmlrpc_methods' ), 20 );
}
/**
* Attached to the `xmlrpc_methods` filter.
*
* @param array $methods The already registered XML-RPC methods.
* @return array
*/
public function xmlrpc_methods( $methods ) {
return array_merge(
$methods,
array(
'jetpack.verifyRegistration' => array( $this, 'verify_registration' ),
)
);
}
/**
* Handles verification that a site is registered.
*
* @param array $registration_data The data sent by the XML-RPC client:
* [ $secret_1, $user_id ].
*
* @return string|IXR_Error
*/
public function verify_registration( $registration_data ) {
return $this->output( $this->connection->handle_registration( $registration_data ) );
}
/**
* Normalizes output for XML-RPC.
*
* @param mixed $data The data to output.
*/
private function output( $data ) {
if ( is_wp_error( $data ) ) {
$code = $data->get_error_data();
if ( ! $code ) {
$code = -10520;
}
if ( ! class_exists( \IXR_Error::class ) ) {
require_once ABSPATH . WPINC . '/class-IXR.php';
}
return new \IXR_Error(
$code,
sprintf( 'Jetpack: [%s] %s', $data->get_error_code(), $data->get_error_message() )
);
}
return $data;
}
}

View File

@ -0,0 +1,17 @@
<?php
/**
* The Jetpack Connection Interface file.
* No longer used.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* This interface is no longer used and is now deprecated.
*
* @deprecated since jetpack 7.8
*/
interface Manager_Interface {
}

View File

@ -0,0 +1,63 @@
/* global jpTracksAJAX */
( function ( $, jpTracksAJAX ) {
window.jpTracksAJAX = window.jpTracksAJAX || {};
const debugSet = localStorage.getItem( 'debug' ) === 'dops:analytics';
window.jpTracksAJAX.record_ajax_event = function ( eventName, eventType, eventProp ) {
const data = {
tracksNonce: jpTracksAJAX.jpTracksAJAX_nonce,
action: 'jetpack_tracks',
tracksEventType: eventType,
tracksEventName: eventName,
tracksEventProp: eventProp || false,
};
return $.ajax( {
type: 'POST',
url: jpTracksAJAX.ajaxurl,
data: data,
success: function ( response ) {
if ( debugSet ) {
// eslint-disable-next-line
console.log( 'AJAX tracks event recorded: ', data, response );
}
},
} );
};
$( document ).ready( function () {
$( 'body' ).on( 'click', '.jptracks a, a.jptracks', function ( event ) {
const $this = $( event.target );
// We know that the jptracks element is either this, or its ancestor
const $jptracks = $this.closest( '.jptracks' );
// We need an event name at least
const eventName = $jptracks.attr( 'data-jptracks-name' );
if ( undefined === eventName ) {
return;
}
const eventProp = $jptracks.attr( 'data-jptracks-prop' ) || false;
const url = $this.attr( 'href' );
const target = $this.get( 0 ).target;
let newTabWindow = null;
if ( url && target && '_self' !== target ) {
newTabWindow = window.open( '', target );
newTabWindow.opener = null;
}
event.preventDefault();
window.jpTracksAJAX.record_ajax_event( eventName, 'click', eventProp ).always( function () {
// Continue on to whatever url they were trying to get to.
if ( url && ! $this.hasClass( 'thickbox' ) ) {
if ( newTabWindow ) {
newTabWindow.location = url;
return;
}
window.location = url;
}
} );
} );
} );
} )( jQuery, jpTracksAJAX );

View File

@ -0,0 +1,94 @@
/**
* This was abstracted from wp-calypso's analytics lib: https://github.com/Automattic/wp-calypso/blob/trunk/client/lib/analytics/README.md
* Some stuff was removed like GA tracking and other things not necessary for Jetpack tracking.
*
* This library should only be used and loaded if the Jetpack site is connected.
*/
// Load tracking scripts
window._tkq = window._tkq || [];
let _user;
const debug = console.error; // eslint-disable-line no-console
/**
* Build a query string.
*
* @param {string|object} group - Stat group, or object mapping groups to names.
* @param {string} [name] - Stat name, when `group` is a string.
* @returns {string} Query string fragment.
*/
function buildQuerystring( group, name ) {
let uriComponent = '';
if ( 'object' === typeof group ) {
for ( const key in group ) {
uriComponent += '&x_' + encodeURIComponent( key ) + '=' + encodeURIComponent( group[ key ] );
}
} else {
uriComponent = '&x_' + encodeURIComponent( group ) + '=' + encodeURIComponent( name );
}
return uriComponent;
}
const analytics = {
initialize: function ( userId, username ) {
analytics.setUser( userId, username );
analytics.identifyUser();
},
mc: {
bumpStat: function ( group, name ) {
const uriComponent = buildQuerystring( group, name ); // prints debug info
new Image().src =
document.location.protocol +
'//pixel.wp.com/g.gif?v=wpcom-no-pv' +
uriComponent +
'&t=' +
Math.random();
},
},
tracks: {
recordEvent: function ( eventName, eventProperties ) {
eventProperties = eventProperties || {};
if ( eventName.indexOf( 'jetpack_' ) !== 0 ) {
debug( '- Event name must be prefixed by "jetpack_"' );
return;
}
window._tkq.push( [ 'recordEvent', eventName, eventProperties ] );
},
recordPageView: function ( urlPath ) {
analytics.tracks.recordEvent( 'jetpack_page_view', {
path: urlPath,
} );
},
},
setUser: function ( userId, username ) {
_user = { ID: userId, username: username };
},
identifyUser: function () {
// Don't identify the user if we don't have one
if ( _user ) {
window._tkq.push( [ 'identifyUser', _user.ID, _user.username ] );
}
},
clearedIdentity: function () {
window._tkq.push( [ 'clearIdentity' ] );
},
};
if ( typeof module !== 'undefined' ) {
// Bundled by Webpack.
module.exports = analytics;
} else {
// Direct load.
window.analytics = analytics;
}

View File

@ -0,0 +1,197 @@
<?php
/**
* Authorize_Redirect Webhook handler class.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection\Webhooks;
use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Tracking;
use GP_Locales;
use Jetpack_Network;
/**
* Authorize_Redirect Webhook handler class.
*/
class Authorize_Redirect {
/**
* Constructs the object
*
* @param Automattic\Jetpack\Connection\Manager $connection The Connection Manager object.
*/
public function __construct( $connection ) {
$this->connection = $connection;
}
/**
* Handle the webhook
*
* This method implements what's in Jetpack::admin_page_load when the Jetpack plugin is not present
*/
public function handle() {
add_filter(
'allowed_redirect_hosts',
function ( $domains ) {
$domains[] = 'jetpack.com';
$domains[] = 'jetpack.wordpress.com';
$domains[] = 'wordpress.com';
// Calypso envs.
$domains[] = 'http://calypso.localhost:3000/';
$domains[] = 'https://wpcalypso.wordpress.com/';
$domains[] = 'https://horizon.wordpress.com/';
return array_unique( $domains );
}
);
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$dest_url = empty( $_GET['dest_url'] ) ? null : esc_url_raw( wp_unslash( $_GET['dest_url'] ) );
if ( ! $dest_url || ( 0 === stripos( $dest_url, 'https://jetpack.com/' ) && 0 === stripos( $dest_url, 'https://wordpress.com/' ) ) ) {
// The destination URL is missing or invalid, nothing to do here.
exit;
}
if ( $this->connection->is_connected() && $this->connection->is_user_connected() ) {
// The user is either already connected, or finished the connection process.
wp_safe_redirect( $dest_url );
exit;
} elseif ( ! empty( $_GET['done'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// The user decided not to proceed with setting up the connection.
wp_safe_redirect( Admin_Menu::get_top_level_menu_item_url() );
exit;
}
$redirect_args = array(
'page' => 'jetpack',
'action' => 'authorize_redirect',
'dest_url' => rawurlencode( $dest_url ),
'done' => '1',
);
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! empty( $_GET['from'] ) && 'jetpack_site_only_checkout' === $_GET['from'] ) {
$redirect_args['from'] = 'jetpack_site_only_checkout';
}
wp_safe_redirect( $this->build_authorize_url( add_query_arg( $redirect_args, admin_url( 'admin.php' ) ) ) );
exit;
}
/**
* Create the Jetpack authorization URL. Copied from Jetpack class.
*
* @param bool|string $redirect URL to redirect to.
*
* @todo Update default value for redirect since the called function expects a string.
*
* @return mixed|void
*/
public function build_authorize_url( $redirect = false ) {
add_filter( 'jetpack_connect_request_body', array( __CLASS__, 'filter_connect_request_body' ) );
add_filter( 'jetpack_connect_redirect_url', array( __CLASS__, 'filter_connect_redirect_url' ) );
$url = $this->connection->get_authorization_url( wp_get_current_user(), $redirect );
remove_filter( 'jetpack_connect_request_body', array( __CLASS__, 'filter_connect_request_body' ) );
remove_filter( 'jetpack_connect_redirect_url', array( __CLASS__, 'filter_connect_redirect_url' ) );
/**
* This filter is documented in plugins/jetpack/class-jetpack.php
*/
return apply_filters( 'jetpack_build_authorize_url', $url );
}
/**
* Filters the redirection URL that is used for connect requests. The redirect
* URL should return the user back to the Jetpack console.
* Copied from Jetpack class.
*
* @param String $redirect the default redirect URL used by the package.
* @return String the modified URL.
*/
public static function filter_connect_redirect_url( $redirect ) {
$jetpack_admin_page = esc_url_raw( admin_url( 'admin.php?page=jetpack' ) );
$redirect = $redirect
? wp_validate_redirect( esc_url_raw( $redirect ), $jetpack_admin_page )
: $jetpack_admin_page;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_REQUEST['is_multisite'] ) ) {
$redirect = Jetpack_Network::init()->get_url( 'network_admin_page' );
}
return $redirect;
}
/**
* Filters the connection URL parameter array.
* Copied from Jetpack class.
*
* @param array $args default URL parameters used by the package.
* @return array the modified URL arguments array.
*/
public static function filter_connect_request_body( $args ) {
if (
Constants::is_defined( 'JETPACK__GLOTPRESS_LOCALES_PATH' )
&& include_once Constants::get_constant( 'JETPACK__GLOTPRESS_LOCALES_PATH' )
) {
$gp_locale = GP_Locales::by_field( 'wp_locale', get_locale() );
$args['locale'] = isset( $gp_locale ) && isset( $gp_locale->slug )
? $gp_locale->slug
: '';
}
$tracking = new Tracking();
$tracks_identity = $tracking->tracks_get_identity( $args['state'] );
$args = array_merge(
$args,
array(
'_ui' => $tracks_identity['_ui'],
'_ut' => $tracks_identity['_ut'],
)
);
$calypso_env = self::get_calypso_env();
if ( ! empty( $calypso_env ) ) {
$args['calypso_env'] = $calypso_env;
}
return $args;
}
/**
* Return Calypso environment value; used for developing Jetpack and pairing
* it with different Calypso enrionments, such as localhost.
* Copied from Jetpack class.
*
* @since 1.37.1
*
* @return string Calypso environment
*/
public static function get_calypso_env() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['calypso_env'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return sanitize_key( $_GET['calypso_env'] );
}
if ( getenv( 'CALYPSO_ENV' ) ) {
return sanitize_key( getenv( 'CALYPSO_ENV' ) );
}
if ( defined( 'CALYPSO_ENV' ) && CALYPSO_ENV ) {
return sanitize_key( CALYPSO_ENV );
}
return '';
}
}