updated plugin Jetpack Protect version 5.0.0

This commit is contained in:
2026-06-03 21:29:05 +00:00
committed by Gitium
parent 0490bb3940
commit af21e84842
399 changed files with 17749 additions and 5470 deletions

View File

@ -164,7 +164,10 @@ class Client {
// We cast this to a new variable, because the array form of $body needs to be
// maintained so it can be passed into the request later on in the code.
if ( array() !== $body_to_hash ) {
$body_to_hash = wp_json_encode( self::_stringify_data( $body_to_hash ) );
$body_to_hash = wp_json_encode(
self::_stringify_data( $body_to_hash ),
0 // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.ZeroFound -- No `json_encode()` flags because this needs to match whatever is calculating the hash on the other end.
);
} else {
$body_to_hash = '';
}
@ -417,7 +420,7 @@ class Client {
* @param string $path REST API path.
* @param string $version REST API version. Default is `2`.
* @param array $args Arguments to {@see WP_Http}. Default is `array()`.
* @param null|string|array $body Body passed to {@see WP_Http}. Default is `null`.
* @param null|string|array $body Body passed to {@see WP_Http}. Default is `null`.
* @param string $base_api_path REST API root. Default is `wpcom`.
*
* @return array|WP_Error $response Response data, else {@see WP_Error} on failure.
@ -438,7 +441,7 @@ class Client {
}
if ( isset( $body ) && ! is_string( $body ) ) {
$body = wp_json_encode( $body );
$body = wp_json_encode( $body, JSON_UNESCAPED_SLASHES );
}
return self::remote_request( $args, $body );

View File

@ -12,6 +12,8 @@ use Automattic\Jetpack\Tracking;
/**
* Admin connection notices.
*
* @phan-constructor-used-for-side-effects
*/
class Connection_Notice {
@ -167,11 +169,11 @@ class Connection_Notice {
}
fetch(
<?php echo wp_json_encode( esc_url_raw( get_rest_url() . 'jetpack/v4/connection/owner' ), JSON_HEX_TAG | JSON_HEX_AMP ); ?>,
<?php echo wp_json_encode( esc_url_raw( get_rest_url() . 'jetpack/v4/connection/owner' ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>,
{
method: 'POST',
headers: {
'X-WP-Nonce': <?php echo wp_json_encode( wp_create_nonce( 'wp_rest' ), JSON_HEX_TAG | JSON_HEX_AMP ); ?>,
'X-WP-Nonce': <?php echo wp_json_encode( wp_create_nonce( 'wp_rest' ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>,
},
body: new URLSearchParams( new FormData( this ) ),
}
@ -180,7 +182,7 @@ class Connection_Notice {
.then( data => {
if ( data.hasOwnProperty( 'code' ) && data.code === 'success' ) {
// Owner successfully changed.
results.innerHTML = <?php echo wp_json_encode( esc_html__( 'Success!', 'jetpack-connection' ), JSON_HEX_TAG | JSON_HEX_AMP ); ?>;
results.innerHTML = <?php echo wp_json_encode( esc_html__( 'Success!', 'jetpack-connection' ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>;
setTimeout(function () {
document.getElementById( 'jetpack-notice-switch-connection-owner' ).style.display = 'none';
}, 1000);

View File

@ -10,17 +10,20 @@ namespace Automattic\Jetpack\Connection;
/**
* The Jetpack Connection Errors that handles errors
*
* This class handles the following workflow:
* This class handles the following workflow for incoming XML-RPC and REST API requests:
*
* 1. A XML-RCP request with an invalid signature triggers a error
* 1. An incoming XML-RPC or REST API request with an invalid signature triggers an 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
* 4. It encrypts the error details and sends it to the 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
* 6. This endpoint adds 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.)
*
* Note: This class only handles authentication/signature errors from incoming requests to this site.
* Outgoing request signing issues (when this site makes requests to WP.com) are not handled here.
*
* Errors are stored in the database as options in the following format:
*
* [
@ -33,11 +36,26 @@ namespace Automattic\Jetpack\Connection;
*
* For each error code we store a maximum of 5 errors for 5 different user ids.
*
* An user ID can be
* A user ID can be:
* * 0 for blog tokens
* * positive integer for user tokens
* * 'invalid' for malformed tokens
*
* Example error structure:
* [
* 'invalid_token' => [
* '123' => [
* 'error_code' => 'invalid_token',
* 'user_id' => '123',
* 'error_message' => 'The token is invalid',
* 'error_data' => ['action' => 'reconnect'],
* 'timestamp' => 1234567890,
* 'nonce' => 'abc123def',
* 'error_type' => 'xmlrpc'
* ]
* ]
* ]
*
* @since 1.14.2
*/
class Error_Handler {
@ -76,14 +94,6 @@ class Error_Handler {
*/
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
*
@ -127,7 +137,16 @@ class Error_Handler {
public static $instance = null;
/**
* Initialize instance, hookds and load verified errors handlers
* Cached displayable errors to avoid duplicate processing
*
* @since 6.13.10
*
* @var array|null
*/
private $cached_displayable_errors = null;
/**
* Initialize instance, hooks and load verified errors handlers
*
* @since 1.14.2
*/
@ -149,38 +168,213 @@ class Error_Handler {
}
/**
* Gets the list of verified errors and act upon them
* Gets displayable errors with predefined structure and optional filtering.
*
* @since 1.14.2
* This method returns a hierarchical array of errors (error_code => user_id => error_details)
* that can be safely displayed in My Jetpack and other UI components. It includes
* predefined error messages and actions, with optional filtering for specific sites.
* Only processes a limited set of error codes that are meant to be displayed to users.
*
* @return void
* @since 6.13.10
*
* @return array Array of displayable errors with hierarchical structure.
* Example:
* [
* 'invalid_token' => [
* '123' => [
* 'error_code' => 'invalid_token',
* 'user_id' => '123',
* 'error_message' => 'Your connection with WordPress.com seems to be broken...',
* 'error_data' => ['action' => 'reconnect'],
* 'timestamp' => 1234567890,
* 'nonce' => 'abc123def',
* 'error_type' => 'xmlrpc'
* ]
* ]
* ]
*/
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':
case 'invalid_connection_owner':
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;
public function get_displayable_errors() {
// Check if we have cached result AND no filters are applied
if ( $this->cached_displayable_errors !== null && ! $this->has_external_filters() ) {
return $this->cached_displayable_errors;
}
// Since we are only generically handling errors, we don't need to trigger error messages for each one of them.
break 2;
$verified_errors = $this->get_verified_errors();
$displayable_errors = array();
// Only process error codes that are meant to be displayed to users
$displayable_error_codes = array(
'malformed_token',
'token_malformed',
'no_possible_tokens',
'no_valid_user_token',
'no_valid_blog_token',
'unknown_token',
'could_not_sign',
'invalid_token',
'token_mismatch',
'invalid_signature',
'signature_mismatch',
'no_user_tokens',
'no_token_for_user',
'invalid_connection_owner',
);
foreach ( $verified_errors as $error_code => $users ) {
// Skip error codes that are not meant to be displayed
if ( ! in_array( $error_code, $displayable_error_codes, true ) ) {
continue;
}
$displayable_errors[ $error_code ] = array();
foreach ( $users as $user_id => $error ) {
// Override other error messages with default display message
$displayable_errors[ $error_code ][ $user_id ] = array_merge(
$error,
array(
'error_message' => __( "Your connection with WordPress.com seems to be broken. If you're experiencing issues, please try reconnecting.", 'jetpack-connection' ),
)
);
}
}
/**
* Filter displayable connection errors to allow customization of error messages and actions.
*
* This filter allows sites to customize how connection errors are displayed,
* including modifying error messages, actions, and data. Access to this filter
* is controlled by should_allow_error_filtering().
*
* @since 6.12.0
*
* @param array $displayable_errors Array of displayable errors with hierarchical structure.
* @param array $verified_errors Array of raw verified errors from the database.
*/
if ( $this->should_allow_error_filtering() ) {
$displayable_errors = apply_filters( 'jetpack_connection_get_verified_errors', $displayable_errors, $verified_errors );
}
// Only cache if no external filters are applied
if ( ! $this->has_external_filters() ) {
$this->cached_displayable_errors = $displayable_errors;
}
return $displayable_errors;
}
/**
* Sets up hooks for displaying verified errors on admin pages.
*
* This method is hooked into 'admin_init'. It retrieves displayable errors
* and, if any exist, sets up the necessary action and filter hooks to display
* them in admin notices and the React dashboard.
*
* @since 1.14.2
*/
public function handle_verified_errors() {
$displayable_errors = $this->get_displayable_errors();
// If there are any displayable errors, set up the hooks for displaying them in React dashboard and admin notices.
if ( ! empty( $displayable_errors ) ) {
add_action( 'admin_notices', array( $this, 'generic_admin_notice_error' ) );
add_filter( 'react_connection_errors_initial_state', array( $this, 'jetpack_react_dashboard_error' ), 10, 1 );
}
}
/**
* Determines whether error filtering should be allowed.
*
* This method controls access to the jetpack_connection_displayable_errors filter.
* Currently, only WoA sites are allowed to use this filter.
*
* @since 6.13.10
*
* @return bool True if error filtering should be allowed, false otherwise.
*/
protected function should_allow_error_filtering() {
$host = new \Automattic\Jetpack\Status\Host();
if ( $host->is_woa_site() || $host->is_vip_site() || $host->is_newspack_site() ) {
return true;
}
return false;
}
/**
* Provides displayable connection errors for the React dashboard in a flat array format.
*
* This method transforms the hierarchical displayable_errors structure into the flat format
* expected by the React dashboard. It's used as a filter for 'react_connection_errors_initial_state'.
* Returns only the first error to avoid overwhelming the user with multiple error messages.
*
* @since 8.9.0
*
* @param array $errors Existing errors from other filters (unused but required for filter signature).
* @return array Array containing only the first displayable error for the React dashboard.
* Example:
* [
* [
* 'code' => 'connection_error',
* 'message' => 'Your connection with WordPress.com seems to be broken...',
* 'action' => 'reconnect',
* 'data' => [
* 'api_error_code' => 'invalid_token',
* 'action' => 'reconnect'
* ]
* ]
* ]
*/
public function jetpack_react_dashboard_error( $errors ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$displayable_errors = $this->get_displayable_errors();
// Get the first error only
$first_error_code = array_key_first( $displayable_errors );
if ( ! $first_error_code ) {
return array(); // No errors
}
$first_user_errors = $displayable_errors[ $first_error_code ];
if ( ! is_array( $first_user_errors ) || empty( $first_user_errors ) ) {
return array(); // Invalid error structure
}
$first_error = reset( $first_user_errors );
// Validate error structure
if ( ! is_array( $first_error ) || ! isset( $first_error['error_message'] ) ) {
return array(); // Invalid error structure
}
// Determine the action - use the one from error_data if available, otherwise default to 'reconnect'
$action = 'reconnect'; // Default action for connection errors
if ( isset( $first_error['error_data']['action'] ) && is_string( $first_error['error_data']['action'] ) ) {
$action = $first_error['error_data']['action'];
}
// Safely merge error data, ensuring we don't overwrite critical fields
$error_data = isset( $first_error['error_data'] ) && is_array( $first_error['error_data'] ) ? $first_error['error_data'] : array();
// Build the data array with safe merging
$dashboard_data = array( 'api_error_code' => $first_error_code );
// Add error_data fields, but be careful not to overwrite api_error_code
foreach ( $error_data as $key => $value ) {
if ( 'api_error_code' !== $key ) {
$dashboard_data[ $key ] = $value;
}
}
$dashboard_error = array(
array(
'code' => 'connection_error',
'message' => $first_error['error_message'],
'action' => $action,
'data' => $dashboard_data,
),
);
return $dashboard_error;
}
/**
@ -208,7 +402,7 @@ class Error_Handler {
* @since 1.14.2
*/
public function report_error( \WP_Error $error, $force = false, $skip_wpcom_verification = false ) {
if ( in_array( $error->get_error_code(), $this->known_errors, true ) && $this->should_report_error( $error ) || $force ) {
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 ) {
$skip_wpcom_verification ? $this->verify_error( $stored_error ) : $this->send_error_to_wpcom( $stored_error );
@ -227,7 +421,7 @@ class Error_Handler {
* @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 ) {
if ( defined( '\\JETPACK_DEV_DEBUG' ) && constant( '\\JETPACK_DEV_DEBUG' ) ) {
return true;
}
@ -294,6 +488,106 @@ class Error_Handler {
return false;
}
/**
* Builds action error data for generic JavaScript components.
*
* This helper method creates standardized error_data arrays that work with the generic
* JavaScript error handling components. External plugins (like wpcomsh) can use this
* to ensure their error structures are compatible.
*
* @since 6.16.0
*
* @param array $args Action configuration arguments - only non-empty values will be included.
* @return array Standardized error_data array for JavaScript components.
*/
public function build_action_error_data( array $args = array() ) {
// Set default values for variants
$args = wp_parse_args(
$args,
array(
'action_variant' => 'primary',
'secondary_action_variant' => 'secondary',
)
);
// Start with core data
$error_data = array(
'blog_id' => \Jetpack_Options::get_option( 'id' ),
);
// Validate variant values
$valid_variants = array( 'primary', 'secondary' );
if ( ! in_array( $args['action_variant'], $valid_variants, true ) ) {
$args['action_variant'] = 'primary';
}
if ( ! in_array( $args['secondary_action_variant'], $valid_variants, true ) ) {
$args['secondary_action_variant'] = 'secondary';
}
// Merge extra_data first, then regular args (so args take precedence)
if ( ! empty( $args['extra_data'] ) && is_array( $args['extra_data'] ) ) {
$error_data = array_merge( $error_data, $args['extra_data'] );
unset( $args['extra_data'] ); // Remove from args to avoid duplication
}
// Filter out empty values and merge with error_data
$filtered_args = array_filter(
$args,
function ( $value ) {
return ! empty( $value );
}
);
return array_merge( $error_data, $filtered_args );
}
/**
* Builds a standardized error array for the connection error system.
*
* This method creates a consistent error array structure that can be used
* by both internal error handling and external plugins/customizations.
*
* @since 1.14.2
*
* @param string $error_code The error code identifier.
* @param string $error_message The human-readable error message.
* @param array $error_data Additional error data (optional).
* @param string $user_id The user ID associated with the error (optional).
* @param string $error_type The type of error (optional).
* @return array|false The standardized error array or false on failure.
* Example successful return:
* [
* 'error_code' => 'invalid_token',
* 'user_id' => '123',
* 'error_message' => 'The token is invalid',
* 'error_data' => ['action' => 'reconnect'],
* 'timestamp' => 1234567890,
* 'nonce' => 'abc123def',
* 'error_type' => 'xmlrpc'
* ]
*/
public function build_error_array( string $error_code, string $error_message, array $error_data = array(), $user_id = '0', string $error_type = '' ) {
// Validate required parameters
if ( empty( $error_code ) || empty( $error_message ) ) {
return false;
}
// Validate user_id is a string or integer
if ( ! is_string( $user_id ) && ! is_int( $user_id ) ) {
return false;
}
return array(
'error_code' => $error_code,
'user_id' => $user_id,
'error_message' => $error_message,
'error_data' => $error_data,
'timestamp' => time(),
'nonce' => wp_generate_password( 10, false ),
'error_type' => $error_type,
);
}
/**
* Converts a WP_Error object in the array representation we store in the database
*
@ -318,17 +612,13 @@ class Error_Handler {
$user_id = $this->get_user_id_from_token( $signature_details['token'] );
$error_array = array(
'error_code' => $error->get_error_code(),
'user_id' => $user_id,
'error_message' => $error->get_error_message(),
'error_data' => $signature_details,
'timestamp' => time(),
'nonce' => wp_generate_password( 10, false ),
'error_type' => empty( $data['error_type'] ) ? '' : $data['error_type'],
return $this->build_error_array(
$error->get_error_code(),
$error->get_error_message(),
$signature_details,
$user_id,
empty( $data['error_type'] ) ? '' : $data['error_type']
);
return $error_array;
}
/**
@ -373,7 +663,7 @@ class Error_Handler {
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 ) ) );
$encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data, JSON_UNESCAPED_SLASHES ), 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 ) {
@ -427,14 +717,18 @@ class Error_Handler {
}
/**
* Gets the verified errors stored in the database
* Gets the verified errors stored in the database.
*
* This method retrieves only the errors that are actually stored in the database,
* without applying any filters that might inject additional errors. This is used
* internally by methods that need to modify and store the verified errors back
* to the database to prevent accidentally persisting filtered/injected errors.
*
* @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 ) ) {
@ -451,7 +745,7 @@ class Error_Handler {
*
* 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
* expired error will be permanently removed from the database
*
* @since 1.14.2
*
@ -461,7 +755,7 @@ class Error_Handler {
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'] ) {
if ( empty( $error['timestamp'] ) || self::ERROR_LIFE_TIME < time() - (int) $error['timestamp'] ) {
unset( $errors[ $error_code ][ $user_id ] );
}
}
@ -486,6 +780,9 @@ class Error_Handler {
public function delete_all_errors() {
$this->delete_stored_errors();
$this->delete_verified_errors();
// Invalidate cache since we deleted all errors
$this->invalidate_displayable_errors_cache();
}
/**
@ -527,6 +824,9 @@ class Error_Handler {
delete_option( static::STORED_VERIFIED_ERRORS_OPTION );
}
}
// Invalidate cache since we may have deleted verified errors
$this->invalidate_displayable_errors_cache();
}
/**
@ -609,10 +909,13 @@ class Error_Handler {
$verified_errors[ $error_code ][ $user_id ] = $error;
update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
// Invalidate cache since we added a new verified error
$this->invalidate_displayable_errors_cache();
}
/**
* Register REST API end point for error hanlding.
* Register REST API end point for error handling.
*
* @since 1.14.2
*
@ -686,7 +989,7 @@ class Error_Handler {
* @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() );
$message = apply_filters( 'jetpack_connection_error_notice_message', '', $this->get_displayable_errors() );
/**
* Fires inside the admin_notices hook just before displaying the error message for a broken connection.
@ -697,7 +1000,7 @@ class Error_Handler {
*
* @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() );
do_action( 'jetpack_connection_error_notice', $this->get_displayable_errors() );
if ( empty( $message ) ) {
return;
@ -714,24 +1017,6 @@ class Error_Handler {
);
}
/**
* 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' => 'connection_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;
}
/**
* Check REST API response for errors, and report them to WP.com if needed.
*
@ -777,4 +1062,27 @@ class Error_Handler {
$this->report_error( $error, false, true );
}
/**
* Determines whether external filters are applied to the get_displayable_errors method.
*
* @since 6.13.10
*
* @return bool True if external filters are applied, false otherwise.
*/
private function has_external_filters() {
return has_filter( 'jetpack_connection_get_verified_errors' ) &&
$this->should_allow_error_filtering();
}
/**
* Invalidates the cached displayable errors
*
* @since 6.13.10
*
* @return void
*/
private function invalidate_displayable_errors_cache() {
$this->cached_displayable_errors = null;
}
}

View File

@ -0,0 +1,280 @@
<?php
/**
* External Storage utilities for Jetpack Connection.
*
* Provides centralized logic for external storage implementations
* across different environments (WoA, VIP, other).
*
* Usage Example:
*
* // 1. Create a storage provider class implementing the interface:
* class My_Storage_Provider implements Storage_Provider_Interface {
* public function is_available() { return true; }
* public function should_handle( $option_name ) {
* return in_array( $option_name, array( 'blog_token', 'id' ), true );
* }
* public function get( $option_name ) {
* // Return value from your external storage or null
* }
* public function get_environment_id() { return 'my_env'; }
* }
*
* // 2. Register the provider:
* if ( class_exists( 'Automattic\Jetpack\Connection\External_Storage' ) ) {
* \Automattic\Jetpack\Connection\External_Storage::register_provider( new My_Storage_Provider() );
* }
*
* // 3. External storage is now automatically used by Jetpack_Options::get_option()
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
require_once __DIR__ . '/interface-storage-provider.php';
/**
* External Storage utilities class.
*
* @since 6.18.0
*/
class External_Storage {
/**
* Registered storage provider.
*
* @since 6.18.0
*
* @var Storage_Provider_Interface|null
*/
private static $provider = null;
/**
* Static cache to prevent logging same event multiple times in single request.
*
* @since 7.0.0
*
* @var array
*/
private static $logged_events = array();
/**
* Maximum delay threshold for empty state reporting (in seconds).
* This also determines the transient expiry for tracking first empty state.
* Provider custom thresholds must not exceed this value.
*
* @since 7.0.0
*/
private const EMPTY_STATE_TRANSIENT_EXPIRY = 15 * MINUTE_IN_SECONDS;
/**
* Register a storage provider for external storage.
*
* @since 6.18.0
*
* @param Storage_Provider_Interface $provider Storage provider implementing the interface.
* @return bool True if provider was registered successfully, false otherwise.
*/
public static function register_provider( Storage_Provider_Interface $provider ) {
self::$provider = $provider;
return true;
}
/**
* Get value from external storage provider.
*
* Returns null if no provider is registered or if the provider can't provide the value (triggers database fallback).
*
* @since 6.18.0
*
* @param string $key The key to retrieve.
* @return mixed The value from external storage, or null for database fallback.
*/
public static function get_value( $key ) {
$provider = self::$provider;
// Check if we have a registered provider
if ( null === $provider ) {
return null; // No provider registered, use database
}
$environment = $provider->get_environment_id();
// Check if provider is available in current environment
if ( ! $provider->is_available() ) {
self::log_event( 'unavailable', $key, 'External storage not available', $environment );
return null;
}
// Check if provider should handle this option
if ( ! $provider->should_handle( $key ) ) {
return null;
}
// Try to get value from the provider
try {
$value = $provider->get( $key );
// Check if we got a valid value
if ( null !== $value && false !== $value && '' !== $value && 0 !== $value ) {
return $value;
}
// Empty value - log it
self::log_event( 'empty', $key, '', $environment );
} catch ( \Exception $e ) {
// Provider threw an exception
self::log_event( 'error', $key, $e->getMessage(), $environment );
}
// Provider couldn't provide value, return null for database fallback
return null;
}
/**
* Log events if WP_DEBUG is enabled and delegate to provider for error reporting.
* Includes rate limiting to prevent log spam from noisy events.
*
* Storage providers can optionally implement handle_error_event() method to receive
* notifications about storage errors and empty states for their own error reporting.
*
* @since 6.18.0
*
* @param string $event_type The event type (error, empty, unavailable).
* @param string $key The key that triggered the event.
* @param string $details Additional details about the event.
* @param string $environment The environment identifier (atomic, vip, etc.).
*/
public static function log_event( $event_type, $key, $details = '', $environment = 'unknown' ) {
// Only process 'error' and 'empty' events for provider error reporting
if ( 'error' !== $event_type && 'empty' !== $event_type ) {
// For non-reportable events, just do debug logging with rate limiting
if ( self::should_log_event( $key, $event_type ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
sprintf(
'Jetpack External Storage %s: %s in %s%s',
$event_type,
$key,
$environment,
$details ? ' - ' . $details : ''
)
);
}
return;
}
// For 'empty' events, check delay mechanism first to avoid false positives
// during sync between external storage and the database.
// This is checked BEFORE rate limiting so we don't block legitimate reports.
if ( 'empty' === $event_type && ! self::should_report_empty_state( $key ) ) {
return;
}
// Apply rate limiting only for events that will trigger provider notification
if ( ! self::should_log_event( $key, $event_type ) ) {
return;
}
// Local debug logging (only when WP_DEBUG is enabled)
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
sprintf(
'Jetpack External Storage %s: %s in %s%s',
$event_type,
$key,
$environment,
$details ? ' - ' . $details : ''
)
);
}
// Delegate to provider if it implements error handling
if ( null !== self::$provider && method_exists( self::$provider, 'handle_error_event' ) ) {
// @phan-suppress-next-line PhanUndeclaredMethod -- Optional method, checked via method_exists()
self::$provider->handle_error_event( $event_type, $key, $details, $environment );
}
}
/**
* Determine if we should report an empty state based on delay mechanism.
*
* This prevents false positives during storage sync delays. On first encounter
* of empty state, sets a transient. On subsequent encounters after the delay
* threshold, allows reporting (indicating likely disconnection, not sync delay).
*
* Providers can customize the delay threshold by implementing get_empty_state_delay_threshold().
*
* @since 6.18.0
*
* @param string $key The key that was empty.
* @return bool True if we should report this empty state, false otherwise.
*/
private static function should_report_empty_state( $key ) {
$delay_key = 'jetpack_external_storage_empty_delay_' . $key;
$first_empty_time = get_transient( $delay_key );
if ( false === $first_empty_time ) {
// First time encountering empty state - set delay transient and don't report yet
set_transient( $delay_key, time(), self::EMPTY_STATE_TRANSIENT_EXPIRY );
return false;
}
// Default delay threshold (5 minutes)
$delay_threshold = 5 * MINUTE_IN_SECONDS;
// Allow provider to customize delay threshold
// A threshold of 0 is valid for providers where external storage is written first
if ( null !== self::$provider && method_exists( self::$provider, 'get_empty_state_delay_threshold' ) ) {
// @phan-suppress-next-line PhanUndeclaredMethod -- Optional method, checked via method_exists()
$custom_threshold = self::$provider->get_empty_state_delay_threshold();
if ( is_int( $custom_threshold ) && $custom_threshold >= 0 && $custom_threshold <= self::EMPTY_STATE_TRANSIENT_EXPIRY ) {
$delay_threshold = $custom_threshold;
}
}
if ( ( time() - $first_empty_time ) >= $delay_threshold ) {
// Delay threshold passed - likely disconnection, report it
delete_transient( $delay_key );
return true;
}
return false;
}
/**
* Determine if an event should be logged based on rate limiting rules.
*
* This prevents log spam from noisy events by applying a simple one-hour
* rate limit per key and event type combination. Also uses a static cache
* to prevent duplicate logs within the same request.
*
* @since 6.18.0
*
* @param string $key The key that triggered the event.
* @param string $event_type The event type (error, empty, unavailable).
* @return bool True if the event should be logged, false if rate limited.
*/
private static function should_log_event( $key, $event_type = '' ) {
// Combine event type and key for unique tracking
$event_cache_key = $event_type . '_' . $key;
// Check static cache first (prevents multiple logs in same request)
if ( isset( self::$logged_events[ $event_cache_key ] ) ) {
return false;
}
$rate_limit_key = 'jetpack_ext_storage_rate_limit_' . $event_cache_key;
// Check if we're still within the rate limit period
if ( get_transient( $rate_limit_key ) ) {
return false;
}
// Mark as logged in both caches
self::$logged_events[ $event_cache_key ] = true;
set_transient( $rate_limit_key, true, HOUR_IN_SECONDS );
return true;
}
}

View File

@ -33,7 +33,7 @@ class Initial_State {
'connectedPlugins' => REST_Connector::get_connection_plugins( false ),
'wpVersion' => $wp_version,
'siteSuffix' => $status->get_site_suffix(),
'connectionErrors' => Error_Handler::get_instance()->get_verified_errors(),
'connectionErrors' => Error_Handler::get_instance()->get_displayable_errors(),
'isOfflineMode' => $status->is_offline_mode(),
'calypsoEnv' => ( new Status\Host() )->get_calypso_env(),
);
@ -57,7 +57,7 @@ class Initial_State {
* @return string
*/
public static function render() {
return 'var JP_CONNECTION_INITIAL_STATE; typeof JP_CONNECTION_INITIAL_STATE === "object" || (JP_CONNECTION_INITIAL_STATE = JSON.parse(decodeURIComponent("' . rawurlencode( wp_json_encode( self::get_data() ) ) . '")));';
return 'var JP_CONNECTION_INITIAL_STATE; typeof JP_CONNECTION_INITIAL_STATE === "object" || (JP_CONNECTION_INITIAL_STATE = ' . wp_json_encode( self::get_data(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ');';
}
/**

View File

@ -10,6 +10,7 @@ namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\A8c_Mc_Stats;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Heartbeat;
use Automattic\Jetpack\Identity_Crisis;
use Automattic\Jetpack\Partner;
use Automattic\Jetpack\Roles;
use Automattic\Jetpack\Status;
@ -31,7 +32,7 @@ class Manager {
/**
* A copy of the raw POST data for signature verification purposes.
*
* @var String
* @var string
*/
protected $raw_post_data;
@ -87,6 +88,14 @@ class Manager {
*/
private static $is_connected = null;
/**
* Memoized user ID of the connection owner.
* If undefined or invalid, set to 0.
*
* @var null|int
*/
private static $connection_owner_id = null;
/**
* Tracks whether connection status invalidation hooks have been added.
*
@ -156,6 +165,13 @@ class Manager {
add_action( 'deleted_user', array( $manager, 'disconnect_user_force' ), 9, 1 );
add_action( 'remove_user_from_blog', array( $manager, 'disconnect_user_force' ), 9, 1 );
// Add hooks for cleaning up account mismatch transients
$user_account_status = new User_Account_Status();
add_action( 'delete_user', array( $user_account_status, 'clean_account_mismatch_transients' ), 9, 1 );
add_action( 'remove_user_from_blog', array( $user_account_status, 'clean_account_mismatch_transients' ), 9, 1 );
add_action( 'user_register', array( $user_account_status, 'clean_account_mismatch_transients' ), 9, 1 );
add_action( 'profile_update', array( $user_account_status, 'clean_account_mismatch_transients' ), 9, 1 );
$manager->add_connection_status_invalidation_hooks();
// Set up package version hook.
@ -173,6 +189,9 @@ class Manager {
// Initial Partner management.
Partner::init();
// WP 7.0+ Connectors screen card.
Wpcom_Connector::init();
}
/**
@ -191,6 +210,7 @@ class Manager {
add_action( 'pre_update_jetpack_option_blog_token', array( $this, 'reset_connection_status' ) );
add_action( 'pre_update_jetpack_option_user_token', array( $this, 'reset_connection_status' ) );
add_action( 'pre_update_jetpack_option_user_tokens', array( $this, 'reset_connection_status' ) );
add_action( 'pre_update_jetpack_option_master_user', array( $this, 'reset_connection_status' ) );
// phpcs:ignore WPCUT.SwitchBlog.SwitchBlog -- wpcom flags **every** use of switch_blog, apparently expecting valid instances to ignore or suppress the sniff.
add_action( 'switch_blog', array( $this, 'reset_connection_status' ) );
@ -262,7 +282,7 @@ class Manager {
// Hack to preserve $HTTP_RAW_POST_DATA.
add_filter( 'xmlrpc_methods', array( $this, 'xmlrpc_methods' ) );
} elseif ( $has_connected_owner && ! $is_signed ) {
} elseif ( $has_connected_owner ) {
// The jetpack.authorize method should be available for unauthenticated users on a site with an
// active Jetpack connection, so that additional users can link their account.
$callback = array( $this->xmlrpc_server, 'authorize_xmlrpc_methods' );
@ -362,10 +382,10 @@ class Manager {
/**
* Authenticates XML-RPC and other requests from the Jetpack Server
*
* @param WP_User|Mixed $user user object if authenticated.
* @param String $username username.
* @param String $password password string.
* @return WP_User|Mixed authenticated user or error.
* @param WP_User|mixed $user user object if authenticated.
* @param string $username username.
* @param string $password password string.
* @return WP_User|mixed authenticated user or error.
*/
public function authenticate_jetpack( $user, $username, $password ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( is_a( $user, '\\WP_User' ) ) {
@ -435,6 +455,13 @@ class Manager {
return false;
}
// Skip XML-RPC signature verification for OAuth authorization flow.
// OAuth uses GET requests without body-hash and has its own
// signature verification in Authorize_Json_Api class.
if ( isset( $_GET['action'] ) && $_GET['action'] === 'jetpack_json_api_authorization' ) {
return false;
}
$signature_details = array(
'token' => isset( $_GET['token'] ) ? wp_unslash( $_GET['token'] ) : '',
'timestamp' => isset( $_GET['timestamp'] ) ? wp_unslash( $_GET['timestamp'] ) : '',
@ -476,7 +503,7 @@ class Manager {
$user_id = (int) $user_id;
$user = new \WP_User( $user_id );
if ( ! $user || ! $user->exists() ) {
if ( ! $user->exists() ) {
return new \WP_Error(
'unknown_user',
sprintf( 'User %d does not exist', $user_id ),
@ -599,7 +626,7 @@ class Manager {
*
* @deprecated 1.25.0
* @see Manager::has_connected_owner
* @return Boolean is the site connected?
* @return bool is the site connected?
*/
public function is_active() {
return (bool) $this->get_tokens()->get_access_token( true );
@ -644,8 +671,7 @@ class Manager {
$has_blog_id = (bool) \Jetpack_Options::get_option( 'id' );
if ( $has_blog_id ) {
$has_blog_token = (bool) $this->get_tokens()->get_access_token();
self::$is_connected = ( $has_blog_id && $has_blog_token );
self::$is_connected = (bool) $this->get_tokens()->get_access_token();
} else {
// Short-circuit, no need to check for tokens if there's no blog ID.
self::$is_connected = false;
@ -661,7 +687,8 @@ class Manager {
* @since 5.0.0
*/
public function reset_connection_status() {
self::$is_connected = null;
self::$is_connected = null;
self::$connection_owner_id = null;
}
/**
@ -805,8 +832,14 @@ class Manager {
* @return bool|int Returns the ID of the connection owner or False if no connection owner found.
*/
public function get_connection_owner_id() {
$owner = $this->get_connection_owner();
return $owner instanceof \WP_User ? $owner->ID : false;
// Check if the memoized value is available.
if ( null === self::$connection_owner_id ) {
$owner = $this->get_connection_owner();
self::$connection_owner_id = $owner instanceof \WP_User ? $owner->ID : 0;
}
// If the ID is set to 0, there's no valid connection owner.
return self::$connection_owner_id > 0 ? self::$connection_owner_id : false;
}
/**
@ -814,7 +847,7 @@ class Manager {
*
* @todo Refactor to properly load the XMLRPC client independently.
*
* @param Integer $user_id the user identifier.
* @param int|null $user_id the user identifier.
* @return bool|array An array with the WPCOM user data on success, false otherwise.
*/
public function get_connected_user_data( $user_id = null ) {
@ -856,9 +889,7 @@ class Manager {
* @return WP_User|false False if no connection owner found.
*/
public function get_connection_owner() {
$user_id = \Jetpack_Options::get_option( 'master_user' );
if ( ! $user_id ) {
return false;
}
@ -898,8 +929,8 @@ class Manager {
* Returns true if the provided user is the Jetpack connection owner.
* If user ID is not specified, the current user will be used.
*
* @param Integer|Boolean $user_id the user identifier. False for current user.
* @return Boolean True the user the connection owner, false otherwise.
* @param int|bool $user_id the user identifier. False for current user.
* @return bool True the user the connection owner, false otherwise.
*/
public function is_connection_owner( $user_id = false ) {
if ( ! $user_id ) {
@ -915,9 +946,9 @@ class Manager {
*
* @access public
*
* @param Integer $user_id (optional) the user identifier, defaults to current user.
* @param String $redirect_url the URL to redirect the user to for processing, defaults to
* admin_url().
* @param int|null $user_id (optional) the user identifier, defaults to current user.
* @param string|null $redirect_url the URL to redirect the user to for processing, defaults to
* admin_url().
* @return WP_Error only in case of a failed user lookup.
*/
public function connect_user( $user_id = null, $redirect_url = null ) {
@ -994,10 +1025,10 @@ class Manager {
*
* @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.
* @param bool $force_disconnect_locally Disconnect user locally even if we were unable to disconnect them from WP.com.
* @return Boolean Whether the disconnection of the user was successful.
* @param int|null $user_id the user identifier.
* @param bool $can_overwrite_primary_user Allow for the primary user to be disconnected.
* @param bool $force_disconnect_locally Disconnect user locally even if we were unable to disconnect them from WP.com.
* @return bool Whether the disconnection of the user was successful.
*/
public function disconnect_user( $user_id = null, $can_overwrite_primary_user = false, $force_disconnect_locally = false ) {
$user_id = empty( $user_id ) ? get_current_user_id() : (int) $user_id;
@ -1017,6 +1048,10 @@ class Manager {
$is_disconnected_locally = false;
if ( $is_disconnected_from_wpcom || $force_disconnect_locally ) {
// Get the WordPress.com email before disconnecting the user
$wpcom_user_data = $this->get_connected_user_data( $user_id );
$wpcom_email = isset( $wpcom_user_data['email'] ) ? $wpcom_user_data['email'] : null;
// Disconnect the user locally.
$is_disconnected_locally = $this->get_tokens()->disconnect_user( $user_id );
@ -1025,6 +1060,12 @@ class Manager {
$transient_key = "jetpack_connected_user_data_$user_id";
delete_transient( $transient_key );
// Clean up account mismatch transients for this user
if ( $wpcom_email ) {
$user_account_status = new User_Account_Status();
$user_account_status->clean_account_mismatch_transients( $wpcom_email );
}
/**
* Fires after the current user has been unlinked from WordPress.com.
*
@ -1037,6 +1078,9 @@ class Manager {
if ( $is_primary_user ) {
Jetpack_Options::delete_option( 'master_user' );
// Clear the memoized connection owner ID since it changed
self::$connection_owner_id = null;
}
}
}
@ -1070,7 +1114,7 @@ class Manager {
*
* @since 1.29.0
*
* @param Integer $new_owner_id The ID of the user to become the connection owner.
* @param int $new_owner_id The ID of the user to become the connection owner.
*
* @return true|WP_Error True if owner successfully changed, WP_Error otherwise.
*/
@ -1110,6 +1154,9 @@ class Manager {
// This will ensure consistency with WPCOM.
\Jetpack_Options::update_option( 'master_user', $new_owner_id );
// Clear the memoized connection owner ID since it changed
self::$connection_owner_id = null;
// Track it.
( new Tracking() )->record_user_event( 'set_connection_owner_success' );
@ -1127,9 +1174,9 @@ class Manager {
*
* @since 1.29.0
*
* @param Integer $new_owner_id The ID of the user to become the connection owner.
* @param int $new_owner_id The ID of the user to become the connection owner.
*
* @return Boolean Whether the ownership transfer was successful.
* @return bool Whether the ownership transfer was successful.
*/
public function update_connection_owner_wpcom( $new_owner_id ) {
// Notify WPCOM about the connection owner change.
@ -1154,8 +1201,8 @@ class Manager {
/**
* Returns the requested Jetpack API URL.
*
* @param String $relative_url the relative API path.
* @return String API URL.
* @param string $relative_url the relative API path.
* @return string API URL.
*/
public function api_url( $relative_url ) {
$api_base = Constants::get_constant( 'JETPACK__API_BASE' );
@ -1167,10 +1214,10 @@ class Manager {
* @since 1.7.0
* @since-jetpack 8.0.0
*
* @param String $url the generated URL.
* @param String $relative_url the relative URL that was passed as an argument.
* @param String $api_base the API base string that is being used.
* @param String $api_version the API version string that is being used.
* @param string $url the generated URL.
* @param string $relative_url the relative URL that was passed as an argument.
* @param string $api_base the API base string that is being used.
* @param string $api_version the API version string that is being used.
*/
return apply_filters(
'jetpack_api_url',
@ -1184,7 +1231,7 @@ class Manager {
/**
* Returns the Jetpack XMLRPC WordPress.com API endpoint URL.
*
* @return String XMLRPC API URL.
* @return string XMLRPC API URL.
*/
public function xmlrpc_api_url() {
$base = preg_replace(
@ -1200,7 +1247,7 @@ class Manager {
* remain public because the call to action comes from the current site, not from
* WordPress.com.
*
* @param String $api_endpoint (optional) an API endpoint to use, defaults to 'register'.
* @param string $api_endpoint (optional) an API endpoint to use, defaults to 'register'.
* @return true|WP_Error The error object.
*/
public function register( $api_endpoint = 'register' ) {
@ -1456,7 +1503,7 @@ class Manager {
* @since 1.7.0
* @since-jetpack 2.6.0
*
* @param Mixed $response the response object, or the error object.
* @param mixed $response the response object, or the error object.
* @return string|WP_Error A JSON object on success or WP_Error on failures
**/
protected function validate_remote_register_response( $response ) {
@ -1635,7 +1682,7 @@ class Manager {
*
* @since 1.7.0
* @since-jetpack 5.4.0
* @param Integer $min_timeout the minimum timeout value.
* @param int $min_timeout the minimum timeout value.
**/
public function set_min_time_limit( $min_timeout ) {
$timeout = $this->get_max_execution_time();
@ -1711,16 +1758,16 @@ class Manager {
* @return array $amended arguments.
*/
public static function apply_activation_source_to_args( $args ) {
list( $activation_source_name, $activation_source_keyword ) = get_option( 'jetpack_activation_source' );
$activation_source = get_option( 'jetpack_activation_source' );
if ( $activation_source_name ) {
if ( ! empty( $activation_source[0] ) ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.urlencode_urlencode
$args['_as'] = urlencode( $activation_source_name );
$args['_as'] = urlencode( $activation_source[0] );
}
if ( $activation_source_keyword ) {
if ( ! empty( $activation_source[1] ) ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.urlencode_urlencode
$args['_ak'] = urlencode( $activation_source_keyword );
$args['_ak'] = urlencode( $activation_source[1] );
}
return $args;
@ -1729,9 +1776,9 @@ class Manager {
/**
* Generates two secret tokens and the end of life timestamp for them.
*
* @param String $action The action name.
* @param Integer $user_id The user identifier.
* @param Integer $exp Expiration time in seconds.
* @param string $action The action name.
* @param int|bool $user_id The user identifier.
* @param int $exp Expiration time in seconds.
*/
public function generate_secrets( $action, $user_id = false, $exp = 600 ) {
return ( new Secrets() )->generate( $action, $user_id, $exp );
@ -1742,8 +1789,8 @@ class Manager {
*
* @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Secrets->get() instead.
*
* @param String $action The action name.
* @param Integer $user_id The user identifier.
* @param string $action The action name.
* @param int $user_id The user identifier.
* @return string|array an array of secrets or an error string.
*/
public function get_secrets( $action, $user_id ) {
@ -1756,8 +1803,8 @@ class Manager {
*
* @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Secrets->delete() instead.
*
* @param String $action The action name.
* @param Integer $user_id The user identifier.
* @param string $action The action name.
* @param int $user_id The user identifier.
*/
public function delete_secrets( $action, $user_id ) {
_deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Secrets->delete' );
@ -1799,6 +1846,9 @@ class Manager {
)
);
// Clear the memoized connection owner ID since it changed
self::$connection_owner_id = null;
( new Secrets() )->delete_all();
$this->get_tokens()->delete_all();
@ -2711,6 +2761,8 @@ class Manager {
/**
* If the site-level connection is active, add the list of plugins using connection to the heartbeat (except Jetpack itself)
*
* @since 6.11.0 Add the list of Jetpack package versions to the heartbeat.
*
* @param array $stats The Heartbeat stats array.
* @return array $stats
*/
@ -2727,6 +2779,11 @@ class Manager {
$stats[ $stats_group ][] = $plugin_slug;
}
}
$stats['jetpack_package_versions'] = apply_filters( 'jetpack_package_versions', array() );
$stats['identitycrisis'] = Identity_Crisis::check_identity_crisis() ? 'yes' : 'no';
return $stats;
}

View File

@ -90,6 +90,8 @@ class Nonce_Handler {
// Raw query so we can avoid races: add_option will also update.
$show_errors = $this->db->hide_errors();
$return = false;
// Running `try...finally` to make sure that we re-enable errors in case of an exception.
try {
$old_nonce = $this->db->get_row(
@ -105,8 +107,6 @@ class Nonce_Handler {
'no'
)
);
} else {
$return = false;
}
} finally {
$this->db->show_errors( $show_errors );

View File

@ -7,6 +7,8 @@
namespace Automattic\Jetpack\Connection;
use Jetpack_Options;
/**
* The Package_Version_Tracker class.
*/
@ -50,6 +52,12 @@ class Package_Version_Tracker {
return;
}
// Only attempt to update the option on POST requests.
// This will prevent the option from being updated multiple times due to concurrent requests.
if ( ! ( isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD'] ) ) {
return;
}
// The version check is being rate limited.
if ( $this->is_rate_limiting() ) {
return;
@ -92,6 +100,12 @@ class Package_Version_Tracker {
protected function update_package_versions_option( $package_versions ) {
if ( ! $this->is_sync_enabled() ) {
$this->update_package_versions_via_remote_request( $package_versions );
// Remove the checksum for package versions, so it gets recalculated when sync gets activated.
$jetpack_callables_sync_checksum = Jetpack_Options::get_raw_option( 'jetpack_callables_sync_checksum' );
if ( isset( $jetpack_callables_sync_checksum['jetpack_package_versions'] ) ) {
unset( $jetpack_callables_sync_checksum['jetpack_package_versions'] );
Jetpack_Options::update_raw_option( 'jetpack_callables_sync_checksum', $jetpack_callables_sync_checksum );
}
return;
}
@ -138,7 +152,8 @@ class Package_Version_Tracker {
$body = wp_json_encode(
array(
'package_versions' => $package_versions,
)
),
JSON_UNESCAPED_SLASHES
);
$response = Client::wpcom_json_api_request_as_blog(

View File

@ -12,7 +12,7 @@ namespace Automattic\Jetpack\Connection;
*/
class Package_Version {
const PACKAGE_VERSION = '6.8.1';
const PACKAGE_VERSION = '8.2.2';
const PACKAGE_SLUG = 'connection';

View File

@ -243,7 +243,8 @@ class Plugin_Storage {
$body = wp_json_encode(
array(
'active_connected_plugins' => self::$plugins,
)
),
JSON_UNESCAPED_SLASHES
);
Client::wpcom_json_api_request_as_blog(

View File

@ -95,6 +95,15 @@ class Plugin {
public function is_only() {
$plugins = Plugin_Storage::get_all();
if ( is_wp_error( $plugins ) ) {
if ( 'too_early' === $plugins->get_error_code() ) {
_doing_it_wrong( __METHOD__, esc_html( $plugins->get_error_code() . ': ' . $plugins->get_error_message() ), '6.16.1' );
} else {
wp_trigger_error( __METHOD__, $plugins->get_error_code() . ': ' . $plugins->get_error_message() );
}
return false;
}
return ! $plugins || ( array_key_exists( $this->slug, $plugins ) && 1 === count( $plugins ) );
}
}

View File

@ -19,6 +19,8 @@ use WP_REST_Server;
/**
* Registers the REST routes for Connections.
*
* @phan-constructor-used-for-side-effects
*/
class REST_Connector {
/**
@ -169,7 +171,7 @@ class REST_Connector {
// Disconnect/unlink user from WordPress.com servers.
// this endpoint is set to override the older endpoint that was previously in the Jetpack plugin
// Override is here in case an older version of the Jetpack plugin is installed alongside an updated standalone
// Override is here in case an older version of the Jetpack plugin is installed alongside an updated standalone.
register_rest_route(
'jetpack/v4',
'/connection/user',
@ -178,7 +180,7 @@ class REST_Connector {
'callback' => __CLASS__ . '::unlink_user',
'permission_callback' => __CLASS__ . '::unlink_user_permission_callback',
),
true // override other implementations
true // override other implementations.
);
// We are only registering this route if Jetpack-the-plugin is not active or it's version is ge 10.0-alpha.
@ -261,34 +263,6 @@ class REST_Connector {
)
);
// Provider-specific authorization URL endpoint
register_rest_route(
'jetpack/v4',
'/connection/authorize_url/(?P<provider>[a-zA-Z]+)',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'connection_authorize_url_provider' ),
'permission_callback' => __CLASS__ . '::user_connection_data_permission_check',
'args' => array(
'provider' => array(
'description' => __( 'Authentication provider (google, github, apple, link)', 'jetpack-connection' ),
'type' => 'string',
'required' => true,
'enum' => array( 'google', 'github', 'apple', 'link' ),
),
'redirect_uri' => array(
'description' => __( 'URI of the admin page where the user should be redirected after connection flow', 'jetpack-connection' ),
'type' => 'string',
),
'email_address' => array(
'description' => __( 'Email address for magic link authentication', 'jetpack-connection' ),
'type' => 'string',
'format' => 'email',
),
),
)
);
register_rest_route(
'jetpack/v4',
'/user-token',
@ -502,8 +476,9 @@ class REST_Connector {
'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.
'filter' => apply_filters( 'jetpack_offline_mode', false ),
'wpLocalConstant' => defined( 'WP_LOCAL_DEV' ) && WP_LOCAL_DEV,
'option' => (bool) get_option( 'jetpack_offline_mode' ),
),
'isPublic' => '1' == get_option( 'blog_public' ), // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
);
@ -670,15 +645,22 @@ class REST_Connector {
)
: false );
// Check for possible account errors between the local user and WPCOM account.
$possible_errors = array();
if ( $is_user_connected && ! empty( $wpcom_user_data['email'] ) ) {
$user_account_status = new \Automattic\Jetpack\Connection\User_Account_Status();
$possible_errors = $user_account_status->check_account_errors( $current_user->user_email, $wpcom_user_data['email'] );
}
$current_user_connection_data = array(
'isConnected' => $is_user_connected,
'isMaster' => $is_master_user,
'username' => $current_user->user_login,
'id' => $current_user->ID,
'blogId' => $blog_id,
'wpcomUser' => $wpcom_user_data,
'gravatar' => get_avatar_url( $current_user->ID ),
'permissions' => array(
'isConnected' => $is_user_connected,
'isMaster' => $is_master_user,
'username' => $current_user->user_login,
'id' => $current_user->ID,
'blogId' => $blog_id,
'wpcomUser' => $wpcom_user_data,
'gravatar' => get_avatar_url( $current_user->ID ),
'permissions' => array(
'connect' => current_user_can( 'jetpack_connect' ),
'connect_user' => current_user_can( 'jetpack_connect_user' ),
// This is a mapped capability
@ -687,6 +669,7 @@ class REST_Connector {
'disconnect' => current_user_can( 'jetpack_disconnect' ),
'manage_options' => current_user_can( 'manage_options' ),
),
'possibleAccountErrors' => $possible_errors,
);
/**
@ -711,67 +694,6 @@ class REST_Connector {
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'] ) || ! isset( $_GET['timestamp'] ) || ! isset( $_GET['url'] ) || ! isset( $_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.
*
@ -900,7 +822,7 @@ class REST_Connector {
array()
);
// We manipulate the alternate URLs after the filter is applied, so they can not be overwritten.
// We manipulate the alternate URLs after the filter is applied, so they cannot be overwritten.
$response_body['authorizeUrl'] = $authorize_url;
if ( ! empty( $response_body['alternateAuthorizeUrl'] ) ) {
$response_body['alternateAuthorizeUrl'] = Redirect::get_url( $response_body['alternateAuthorizeUrl'] );
@ -1140,51 +1062,64 @@ class REST_Connector {
}
/**
* Provider-specific authorization URL endpoint
* Permission check for the connection/data endpoint
*
* @param WP_REST_Request $request The request sent to the WP REST API.
*
* @return \WP_REST_Response|WP_Error
* @return bool|WP_Error
*/
public function connection_authorize_url_provider( $request ) {
$provider = $request['provider'];
$redirect_uri = $request['redirect_uri'] ?? '';
// Validate magic link parameters if provider is 'link'
if ( 'link' === $provider ) {
if ( empty( $request['email_address'] ) ) {
return new WP_Error(
'missing_email',
__( 'Email address is required for magic link authentication.', 'jetpack-connection' ),
array( 'status' => 400 )
);
}
// Sanitize email address
$email = sanitize_email( $request['email_address'] );
if ( ! is_email( $email ) ) {
return new WP_Error(
'invalid_email',
__( 'Invalid email address format.', 'jetpack-connection' ),
array( 'status' => 400 )
);
}
public static function user_connection_data_permission_check() {
if ( current_user_can( 'jetpack_connect_user' ) ) {
return true;
}
$authorize_url = ( new Authorize_Redirect( $this->connection ) )->build_authorize_url(
$redirect_uri,
false,
false,
$provider,
array(
'email_address' => $email ?? '',
)
);
return rest_ensure_response(
array(
'authorizeUrl' => $authorize_url,
)
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'] ) || ! isset( $_GET['timestamp'] ) || ! isset( $_GET['url'] ) || ! isset( $_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'] ) ),
),
0 // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.ZeroFound -- No `json_encode()` flags because this needs to match whatever is calculating the hash on the other end.
);
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;
}
}

View File

@ -93,6 +93,7 @@ class Secrets {
* @param String $action The action name.
* @param Integer $user_id The user identifier.
* @return string|array an array of secrets or an error string.
* @phan-return string|array{secret_1:string,secret_2:string,exp:int}
*/
public function get( $action, $user_id ) {
$secret_name = 'jetpack_' . $action . '_' . $user_id;

View File

@ -17,6 +17,8 @@ namespace Automattic\Jetpack\Connection;
*
* @see https://github.com/Automattic/jetpack/pull/23597
* @see \Automattic\Jetpack\Connection\Tokens::is_locked()
*
* @phan-constructor-used-for-side-effects
*/
class Tokens_Locks {

View File

@ -549,7 +549,7 @@ class Tokens {
if ( function_exists( 'wp_generate_password' ) ) {
$nonce = wp_generate_password( 10, false );
} else {
$nonce = substr( sha1( wp_rand( 0, 1000000 ) ), 0, 10 );
$nonce = substr( sha1( (string) wp_rand( 0, 1000000 ) ), 0, 10 );
}
$normalized_request_string = implode(
@ -618,10 +618,6 @@ class Tokens {
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() ) );
}

View File

@ -76,14 +76,16 @@ class Tracking {
) {
wp_send_json_error(
__( 'You arent authorized to do that.', 'jetpack-connection' ),
403
403,
JSON_UNESCAPED_SLASHES
);
}
if ( ! isset( $_REQUEST['tracksEventName'] ) || ! isset( $_REQUEST['tracksEventType'] ) ) {
wp_send_json_error(
__( 'No valid event name or type.', 'jetpack-connection' ),
403
403,
JSON_UNESCAPED_SLASHES
);
}
@ -98,7 +100,7 @@ class Tracking {
$this->record_user_event( filter_var( wp_unslash( $_REQUEST['tracksEventName'] ) ), $tracks_data, null, false );
wp_send_json_success();
wp_send_json_success( null, null, JSON_UNESCAPED_SLASHES );
}
/**

View File

@ -189,4 +189,28 @@ class Urls {
public static function main_network_site_url() {
return self::get_protocol_normalized_url( 'main_network_site_url', network_site_url() );
}
/**
* Maybe add the origin_site_id query parameter to a URL.
*
* The parameter is only added for users who are members of the current blog.
*
* @since 7.1.0
*
* @param string $url The URL to add the query param to.
* @return string The URL with the origin_site_id query parameter maybe added.
*/
public static function maybe_add_origin_site_id( $url ) {
$site_id = Manager::get_site_id();
if ( is_wp_error( $site_id ) ) {
return $url;
}
// Add query param to URL only for users who can access wp-admin.
if ( ! is_user_member_of_blog() ) {
return $url;
}
return add_query_arg( 'origin_site_id', $site_id, $url );
}
}

View File

@ -0,0 +1,116 @@
<?php
/**
* Class to check for account errors in the Jetpack Connection.
*
* @package automattic/jetpack-connection
* @since 6.11.0
*/
namespace Automattic\Jetpack\Connection;
/**
* Class User_Account_Status
*/
class User_Account_Status {
/**
* Check for possible account errors between the local user and WPCOM account.
*
* @since 6.11.0
*
* @param string $current_user_email The email of the current WordPress user.
* @param string $wpcom_user_email The email of the connected WordPress.com account.
*
* @return array An array of possible account errors, empty if no errors.
*/
public function check_account_errors( $current_user_email, $wpcom_user_email ) {
$errors = array();
// Check for email mismatch error.
$has_mismatch = $this->possible_account_mismatch( $current_user_email, $wpcom_user_email );
if ( $has_mismatch ) {
$errors['mismatch'] = array(
'type' => 'mismatch',
'message' => __( 'Your WordPress.com email is also used by another user account. This wont affect functionality but may cause confusion about which user account is connected.', 'jetpack-connection' ),
'details' => array(
'site_email' => $current_user_email,
'wpcom_email' => $wpcom_user_email,
),
);
}
/**
* Filters the account errors.
*
* @since 6.11.0
*
* @param array $errors The array of account errors.
* @param string $current_user_email The email of the current WordPress user.
* @param string $wpcom_user_email The email of the connected WordPress.com account.
*/
return apply_filters( 'jetpack_connection_account_errors', $errors, $current_user_email, $wpcom_user_email );
}
/**
* Check if there is a possible account mismatch between the local user and WPCOM account.
*
* @since 6.11.0
*
* @param string $current_user_email The email of the current WordPress user.
* @param string $wpcom_user_email The email of the connected WordPress.com account.
*
* @return bool Whether there is a possible account mismatch.
*/
public function possible_account_mismatch( $current_user_email, $wpcom_user_email ) {
// If emails are the same or there's no WPCOM email, there's no mismatch.
if ( $current_user_email === $wpcom_user_email || ! $wpcom_user_email ) {
return false;
}
// Generate transient key with both wpcom email and user ID if available.
$transient_key = 'jetpack_account_mismatch_';
$transient_key .= md5( $wpcom_user_email );
$cached_result = get_transient( $transient_key );
if ( false !== $cached_result ) {
return (bool) $cached_result;
}
// Check if there's a WordPress user with the WPCOM email .
$wpcom_email_user = get_user_by( 'email', $wpcom_user_email );
$mismatch_exists = false !== $wpcom_email_user;
// Store the result in a transient for 24 hours.
set_transient( $transient_key, $mismatch_exists, DAY_IN_SECONDS );
return $mismatch_exists;
}
/**
* Clears account mismatch transients for a user when they update their email or are deleted.
*
* @since 6.11.0
*
* @param string|int $user_id_or_email User ID or email address.
* @return void
*/
public function clean_account_mismatch_transients( $user_id_or_email ) {
$email = null;
if ( is_numeric( $user_id_or_email ) ) {
$user = get_userdata( $user_id_or_email );
if ( $user && isset( $user->user_email ) ) {
$email = $user->user_email;
}
} else {
$email = $user_id_or_email;
}
if ( ! $email || ! is_email( $email ) ) {
return;
}
$transient_key = 'jetpack_account_mismatch_' . md5( $email );
delete_transient( $transient_key );
}
}

View File

@ -176,6 +176,8 @@ class Webhooks {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
$from = ! empty( $_GET['from'] ) ? sanitize_text_field( wp_unslash( $_GET['from'] ) ) : 'iframe';
$skip_pricing = filter_input( INPUT_GET, 'skip_pricing', FILTER_VALIDATE_BOOLEAN );
// 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;
@ -188,6 +190,10 @@ class Webhooks {
$connect_url = add_query_arg( 'from', $from, $this->connection->get_authorization_url( null, $redirect ) );
if ( $skip_pricing ) {
$connect_url = add_query_arg( 'skip_pricing', '1', $connect_url );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
if ( isset( $_GET['notes_iframe'] ) ) {
$connect_url .= '&notes_iframe';

View File

@ -11,6 +11,8 @@ use IXR_Error;
/**
* Registers the XML-RPC methods for Connections.
*
* @phan-constructor-used-for-side-effects
*/
class XMLRPC_Connector {
/**

View File

@ -0,0 +1,437 @@
<?php
/**
* WordPress.com connector card for the WP core Connectors screen.
*
* Registers a connector in the WP 7.0+ Connectors registry and enqueues
* a script module that provides a custom render function with connection
* details (owner, connected plugins, disconnect).
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
use Automattic\Jetpack\Modules;
use Automattic\Jetpack\Status\Host;
/**
* WordPress.com connector card handler.
*
* @since 8.2.0
*/
class Wpcom_Connector {
/**
* Whether the connector has been initialized.
*
* @var bool
*/
private static $initialized = false;
/**
* Script module identifier.
*
* @var string
*/
const MODULE_ID = '@automattic/jetpack-connection-connectors';
/**
* Screen ID assigned by WordPress to the Gutenberg plugin's connectors submenu page.
*
* @var string
*/
const GUTENBERG_CONNECTORS_SCREEN_ID = 'settings_page_options-connectors-wp-admin';
/**
* Page slug registered by the Gutenberg plugin for the connectors submenu page.
*
* @var string
*/
const GUTENBERG_CONNECTORS_PAGE_SLUG = 'options-connectors-wp-admin';
/**
* Initialize the connector.
*/
public static function init() {
if ( static::$initialized ) {
return;
}
static::$initialized = true;
add_action( 'wp_connectors_init', array( static::class, 'register_connector' ), 20 );
add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_script_module' ) );
add_action( 'jetpack_client_authorize_error', array( static::class, 'store_auth_error' ) );
}
/**
* Register WordPress.com as a connector in the WP core Connectors screen.
*
* The wp_connectors_init action is available in WordPress 7.0+.
* On older versions this action never fires, so the hook is safely a no-op.
*
* @since 8.2.0
*
* @param \WP_Connector_Registry $registry Connector registry instance.
*/
public static function register_connector( $registry ) {
// @phan-suppress-previous-line PhanUndeclaredTypeParameter -- WP 7.0+ class.
$registry->register( // @phan-suppress-current-line PhanUndeclaredClassMethod -- WP 7.0+ class.
'wordpress_com',
array(
'name' => 'WordPress.com',
'description' => __( 'Enhanced functionality with Jetpack and WooCommerce.', 'jetpack-connection' ),
'type' => 'cloud_service',
'logo_url' => plugins_url( 'images/wpcom-logo.svg', __FILE__ ),
'authentication' => array(
'method' => 'none',
),
)
);
}
/**
* Enqueue the connectors card script module on the Settings > Connectors page.
*
* @since 8.2.0
*/
public static function enqueue_script_module() {
$screen = get_current_screen();
if ( ! $screen || ! static::is_connectors_screen( $screen ) ) {
return;
}
if ( ! class_exists( 'WP_Connector_Registry' ) ) {
return;
}
$css_path = __DIR__ . '/css/connectors-card.css';
wp_enqueue_style(
'wpcom-connector-card',
plugins_url( 'css/connectors-card.css', __FILE__ ),
array(),
(string) @filemtime( $css_path ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fallback to empty string if file is missing.
);
wp_register_script_module(
static::MODULE_ID,
plugins_url( 'js/connectors-card.js', __FILE__ ),
array(
array(
'id' => '@wordpress/connectors',
'import' => 'static',
),
)
);
wp_enqueue_script_module( static::MODULE_ID );
add_filter(
'script_module_data_' . static::MODULE_ID,
array( static::class, 'get_connector_data' )
);
}
/**
* Build the data passed to the script module via the script_module_data_ filter.
*
* @since 8.2.0
*
* @param array $data Existing script module data.
* @return array Filtered script module data.
*/
public static function get_connector_data( $data ) {
$manager = new Manager();
$is_registered = $manager->is_connected();
$is_connected = $is_registered && $manager->has_connected_owner();
$data['isConnected'] = $is_connected;
$data['isRegistered'] = $is_registered;
$data['apiRoot'] = esc_url_raw( rest_url() );
$data['apiNonce'] = wp_create_nonce( 'wp_rest' );
$data['redirectUri'] = static::get_connectors_page_path();
$data['connectorName'] = 'WordPress.com';
$data['connectorDescription'] = __( 'Enhanced functionality with Jetpack and WooCommerce.', 'jetpack-connection' );
$data['connectorLogoUrl'] = plugins_url( 'images/wpcom-logo.svg', __FILE__ );
if ( $is_registered ) {
$data['connectedPlugins'] = static::get_connected_plugins_data( $manager );
$data['siteDetails'] = array(
'blogId' => (int) \Jetpack_Options::get_option( 'id' ),
'siteUrl' => site_url(),
'homeUrl' => home_url(),
);
if ( in_array( 'jetpack', array_column( $data['connectedPlugins'], 'slug' ), true ) ) {
$data['ssoStatus'] = ( new Modules() )->is_active( 'sso', false );
}
}
if ( $is_connected ) {
$data['currentUser'] = static::get_current_user_data( $manager );
$data['connectionOwner'] = static::get_connection_owner_data( $manager );
}
$host = new Host();
$data['isWoaSite'] = $host->is_woa_site();
$data['isVipSite'] = $host->is_vip_site();
$auth_error = static::consume_auth_error();
if ( $auth_error ) {
$data['authError'] = $auth_error;
}
return $data;
}
/**
* Get the current (logged-in) user's connection details.
*
* @param Manager $manager Connection manager instance.
* @return array|null Current user data or null if not connected.
*/
private static function get_current_user_data( $manager ) {
$user_id = get_current_user_id();
if ( ! $user_id || ! $manager->is_user_connected( $user_id ) ) {
return null;
}
$user = get_userdata( $user_id );
$user_info = static::resolve_user_fields( $user, $manager->get_connected_user_data( $user_id ) );
$is_owner = $manager->is_connection_owner( $user_id );
$has_other_connected_users = false;
if ( $is_owner ) {
$connected_users = $manager->get_connected_users( 'any', 2 );
$has_other_connected_users = count( $connected_users ) > 1;
}
return array_merge(
$user_info,
array(
'isOwner' => $is_owner,
'hasOtherConnectedUsers' => $has_other_connected_users,
)
);
}
/**
* Get the connection owner details for the script module.
*
* @param Manager $manager Connection manager instance.
* @return array|null Owner data or null if unavailable.
*/
private static function get_connection_owner_data( $manager ) {
$owner = $manager->get_connection_owner();
if ( false === $owner ) {
return null;
}
$fields = static::resolve_user_fields( $owner, $manager->get_connected_user_data( $owner->ID ) );
$fields['localLogin'] = $owner->user_login;
return $fields;
}
/**
* Merge local WP user fields with WordPress.com user data.
*
* WPCOM values take precedence when available. Returns the common
* user shape used by both currentUser and connectionOwner.
*
* @param \WP_User|false $wp_user Local WordPress user object (false if unavailable).
* @param array|false $wpcom_user_data WPCOM user data from the connection manager.
* @return array User data with displayName, login, email, and avatar.
*/
private static function resolve_user_fields( $wp_user, $wpcom_user_data ) {
$display_name = $wp_user ? $wp_user->display_name : '';
$login = $wp_user ? $wp_user->user_login : '';
$email = $wp_user ? $wp_user->user_email : '';
if ( is_array( $wpcom_user_data ) ) {
if ( ! empty( $wpcom_user_data['display_name'] ) ) {
$display_name = $wpcom_user_data['display_name'];
}
if ( ! empty( $wpcom_user_data['login'] ) ) {
$login = $wpcom_user_data['login'];
}
if ( ! empty( $wpcom_user_data['email'] ) ) {
$email = $wpcom_user_data['email'];
}
}
$user_id = $wp_user ? $wp_user->ID : 0;
return array(
'displayName' => $display_name,
'login' => $login,
'email' => $email,
'avatar' => $user_id
? get_avatar_url(
$user_id,
array(
'size' => 48,
'default' => 'mysteryman',
)
)
: '',
);
}
/**
* Check whether the given screen is the Connectors settings page.
*
* Handles both WP 7.0 core (`options-connectors`) and the Gutenberg
* plugin (`settings_page_options-connectors-wp-admin`).
*
* @param \WP_Screen $screen Current admin screen.
* @return bool
*/
private static function is_connectors_screen( $screen ) {
return 'options-connectors' === $screen->id
|| static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id;
}
/**
* Return the admin-relative path for the Connectors page.
*
* WP 7.0 core uses the standalone `options-connectors.php` file while
* the Gutenberg plugin registers a submenu page under options-general.php
* with slug `options-connectors-wp-admin`. Both set parent_file to
* `options-general.php` for menu highlighting, so we distinguish them by
* checking the actual script filename being served.
*
* Note: for the Gutenberg case we use the registered page slug directly,
* not `$screen->id`. WordPress auto-prefixes screen IDs for submenu pages
* (e.g. `settings_page_options-connectors-wp-admin`), so using `$screen->id`
* as the `page=` parameter produces an invalid URL.
*
* The result is suitable for the `redirect_uri` parameter accepted by the
* `jetpack/v4/connection/register` REST endpoint (which wraps it in `admin_url()`).
*
* @return string Admin-relative path, e.g. 'options-connectors.php'.
*/
private static function get_connectors_page_path() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only compared against a hardcoded string.
$script = isset( $_SERVER['SCRIPT_NAME'] ) ? wp_basename( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) : '';
if ( 'options-connectors.php' === $script ) {
return 'options-connectors.php';
}
// Gutenberg plugin registers the page under options-general.php.
$screen = get_current_screen();
if ( $screen && static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id ) {
return 'options-general.php?page=' . static::GUTENBERG_CONNECTORS_PAGE_SLUG;
}
return 'options-connectors.php';
}
/**
* Store an authorization error in a short-lived transient.
*
* Hooked to `jetpack_client_authorize_error` which fires when
* the auth webhook fails. The transient is read on the next
* Connectors page load so the JS card can display the error.
*
* @since 8.2.0
*
* @param \WP_Error $error Authorization error.
*/
public static function store_auth_error( $error ) {
if ( is_wp_error( $error ) ) {
$user_id = get_current_user_id();
if ( $user_id ) {
set_transient(
'wpcom_connector_auth_error_' . $user_id,
$error->get_error_message(),
60
);
}
}
}
/**
* Read and delete a stored authorization error for the current user.
*
* @return string|false Error message or false if none.
*/
private static function consume_auth_error() {
$user_id = get_current_user_id();
if ( ! $user_id ) {
return false;
}
$key = 'wpcom_connector_auth_error_' . $user_id;
$error = get_transient( $key );
if ( false !== $error ) {
delete_transient( $key );
}
return $error;
}
/**
* Get connected plugins data for the script module.
*
* @param Manager $manager Connection manager instance.
* @return array List of connected plugin data.
*/
private static function get_connected_plugins_data( $manager ) {
$plugins = $manager->get_connected_plugins();
if ( is_wp_error( $plugins ) || ! is_array( $plugins ) ) {
return array();
}
$result = array();
foreach ( $plugins as $slug => $plugin_data ) {
$name = $plugin_data['name'] ?? $slug;
$entry = array(
'name' => $name,
'slug' => $slug,
);
$logo_url = static::get_plugin_logo_url( $slug );
if ( $logo_url ) {
$entry['logoUrl'] = $logo_url;
}
$result[] = $entry;
}
return $result;
}
/**
* Map a plugin slug to a brand logo URL.
*
* Jetpack-family plugins get the Jetpack mark, WooCommerce-family
* plugins get the Woo mark, and Automattic for Agencies gets the
* Automattic mark. Unknown slugs return null (the JS falls back
* to a generic dashicon).
*
* @param string $slug Plugin slug.
* @return string|null Logo URL or null.
*/
private static function get_plugin_logo_url( $slug ) {
if ( str_starts_with( $slug, 'jetpack' ) ) {
return plugins_url( 'images/jetpack-icon.svg', __FILE__ ); // str_starts_with() is polyfilled by WP since 5.9; this code only runs on WP 7.0+.
}
if ( str_starts_with( $slug, 'woocommerce' ) || str_starts_with( $slug, 'woo' ) ) {
return plugins_url( 'images/woo-icon.svg', __FILE__ );
}
if ( str_starts_with( $slug, 'automattic' ) ) {
return plugins_url( 'images/automattic-icon.svg', __FILE__ );
}
return null;
}
}

View File

@ -0,0 +1,160 @@
/**
* Styles for the WordPress.com connector card on the
* WP core Settings > Connectors page (WP 7.0+).
*
* Enqueued by Wpcom_Connector::enqueue_script_module().
* Uses CSS logical properties for RTL support.
*
* ConnectorItem renders inside .components-item which has
* 20px padding (12px on <=480px screens). The divider uses
* negative inline margins to span the full card width.
*/
.wpcom-connector__description-padded {
padding-inline-end: 40px;
}
.wpcom-connector__expanded {
margin-inline: -20px;
margin-block-start: 4px;
border-block-start: 1px solid #ddd;
padding-block-start: 20px;
padding-inline: 20px;
}
.wpcom-connector__status-badge {
padding-block: 4px;
padding-inline: 12px;
border-radius: 2px;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.wpcom-connector__status-badge--connected {
color: #345b37;
background-color: #eff8f0;
}
.wpcom-connector__status-badge--site-connected {
color: #1e4c5e;
background-color: #e9f0f5;
}
.wpcom-connector__section {
padding-block-end: 20px;
}
.wpcom-connector__owner-avatar {
border-radius: 50%;
}
.wpcom-connector__plugin-icon {
inline-size: 20px;
block-size: 20px;
flex-shrink: 0;
}
.wpcom-connector__plugin-icon--fallback {
font-size: 20px;
}
.wpcom-connector__divider {
border: none;
border-block-start: 1px solid #ddd;
margin: 0;
}
.wpcom-connector__details-link:focus:not(:active) {
box-shadow: none;
outline: none;
}
.wpcom-connector__disconnect-site {
margin-inline-start: auto;
}
.wpcom-connector__modal.components-modal__frame,
.wpcom-connector__modal {
max-inline-size: 480px;
}
.wpcom-connector__details-modal {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 16px;
row-gap: 10px;
align-items: baseline;
}
.wpcom-connector__details-label {
white-space: nowrap;
}
.wpcom-connector__details-value {
overflow-wrap: break-word;
min-inline-size: 0;
text-align: end;
}
.wpcom-connector__connect-prompt-text {
flex: 1;
min-inline-size: 0;
}
.wpcom-connector__inline-action {
margin-inline-start: auto;
white-space: nowrap;
}
@media (max-width: 480px) {
.wpcom-connector__expanded {
margin-inline: -12px;
padding-block-start: 12px;
padding-inline: 12px;
}
.wpcom-connector__section.components-h-stack,
.wpcom-connector__section > .components-h-stack {
flex-wrap: wrap;
}
.wpcom-connector__connect-prompt-text {
min-inline-size: 100%;
}
.wpcom-connector__inline-action {
margin-inline-start: 0;
}
.wpcom-connector__details-modal {
grid-template-columns: 1fr;
row-gap: 4px;
}
.wpcom-connector__details-label:not(:first-child) {
margin-block-start: 8px;
}
.wpcom-connector__details-value {
text-align: start;
}
}
.wpcom-connector__error {
color: #cc1818;
font-size: 13px;
margin-block-start: 8px;
padding-block: 8px;
padding-inline: 12px;
border-inline-start: 4px solid #cc1818;
background-color: #fcecec;
border-radius: 2px;
align-items: center;
}
.wpcom-connector__error .components-button {
color: #cc1818;
flex-shrink: 0;
}

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" clip-rule="evenodd" fill="none" fill-rule="evenodd" viewBox="0 0 47 43">
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.3164 20.9589C39.3164 13.2129 33.656 6.30469 23.4214 6.30469C13.1867 6.30469 7.58624 13.2129 7.58624 20.9589V21.9206C7.58624 29.6677 13.1867 36.6942 23.4214 36.6942C33.656 36.6942 39.3164 29.6677 39.3164 21.9206V20.9589ZM23.4214 43C9.21187 43 0 32.7913 0 22.1604V20.8407C0 10.0285 9.21187 0 23.4214 0C37.6919 0 46.9038 10.0285 46.9038 20.8407V22.1604C46.9038 32.7913 37.6919 43 23.4214 43Z" fill="#00A3E0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.4548 14.1897C29.752 15.0401 30.1158 16.8077 29.2739 18.1343L22.712 28.4682C21.8691 29.7959 20.1348 30.1799 18.8397 29.3295C17.5446 28.477 17.1765 26.7137 18.0205 25.386L24.5824 15.0522C25.4253 13.7256 27.1597 13.3404 28.4548 14.1897Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 887 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="16" fill="#069E08"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.3295 3.17557V18.6565H7.36005L15.3295 3.17557ZM16.9478 28.8244V13.313H24.9478L16.9478 28.8244Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 279 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill="#873EFF" d="M10 0C15.5228 0 20 4.47715 20 10C20 15.5228 15.5228 20 10 20C4.47715 20 0 15.5228 0 10C0 4.47715 4.47715 0 10 0ZM3.96289 6.48242C3.18274 6.48255 2.78715 6.84526 2.78711 7.51562C2.78716 8.186 3.20472 8.5711 3.96289 8.5713H4.80957V12.583C4.80963 13.7152 5.5685 14.3857 6.67871 14.3857C7.56882 14.3856 8.2837 13.9465 8.8223 12.9355L10.0205 10.6924V12.5947C10.0207 13.7156 10.7461 14.3857 11.8672 14.3857C12.7463 14.3856 13.3951 14.0014 14.0215 12.9355L16.7803 8.2744C17.3848 7.25229 16.9565 6.4827 15.627 6.48242C14.9124 6.48242 14.4499 6.71367 14.0322 7.49414L12.1309 11.0664V7.88965C12.1309 6.94438 11.68 6.48252 10.8447 6.48242C10.1852 6.48242 9.6577 6.76825 9.251 7.55957L7.45898 11.0664V7.92285C7.45898 6.91172 7.04118 6.48258 6.03027 6.48242H3.96289Z"/>
</svg>

After

Width:  |  Height:  |  Size: 849 B

View File

@ -0,0 +1,3 @@
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<path fill="#3858E9" d="M36,0C16.1,0,0,16.1,0,36c0,19.9,16.1,36,36,36c19.9,0,36-16.2,36-36C72,16.1,55.8,0,36,0z M3.6,36 c0-4.7,1-9.1,2.8-13.2l15.4,42.3C11.1,59.9,3.6,48.8,3.6,36z M36,68.4c-3.2,0-6.2-0.5-9.1-1.3l9.7-28.2l9.9,27.3 c0.1,0.2,0.1,0.3,0.2,0.4C43.4,67.7,39.8,68.4,36,68.4z M40.5,20.8c1.9-0.1,3.7-0.3,3.7-0.3c1.7-0.2,1.5-2.8-0.2-2.7 c0,0-5.2,0.4-8.6,0.4c-3.2,0-8.5-0.4-8.5-0.4c-1.7-0.1-2,2.6-0.2,2.7c0,0,1.7,0.2,3.4,0.3l5,13.8L28,55.9L16.2,20.8 c2-0.1,3.7-0.3,3.7-0.3c1.7-0.2,1.5-2.8-0.2-2.7c0,0-5.2,0.4-8.6,0.4c-0.6,0-1.3,0-2.1,0C14.7,9.4,24.7,3.6,36,3.6 c8.4,0,16.1,3.2,21.9,8.5c-0.1,0-0.3,0-0.4,0c-3.2,0-5.4,2.8-5.4,5.7c0,2.7,1.5,4.9,3.2,7.6c1.2,2.2,2.7,4.9,2.7,8.9 c0,2.8-0.8,6.3-2.5,10.5l-3.2,10.8L40.5,20.8z M52.3,64l9.9-28.6c1.8-4.6,2.5-8.3,2.5-11.6c0-1.2-0.1-2.3-0.2-3.3 c2.5,4.6,4,9.9,4,15.5C68.4,47.9,61.9,58.4,52.3,64z"/>
</svg>

After

Width:  |  Height:  |  Size: 910 B

View File

@ -0,0 +1,784 @@
/**
* Script module that registers a WordPress.com connector card on the
* WP core Settings > Connectors page (WP 7.0+).
*
* Loaded via wp_enqueue_script_module() with `@wordpress/connectors`
* as a static dependency. Uses classic-script globals for element,
* i18n, and components which are always loaded on admin pages.
*
* Name, description, and logo are provided by the PHP registration
* in register_connector() and merged automatically by the store.
* This module adds the render function and passes connection-specific
* data (owner, plugins, disconnect) via script module data.
*
* The render prop naming differs between WP core (name, logo) and
* the Gutenberg plugin (label, icon), so the card accepts both.
*
* @see Wpcom_Connector::enqueue_script_module()
*/
// eslint-disable-next-line import/no-unresolved -- resolved via WP import map at runtime.
const connectors = await import( '@wordpress/connectors' );
const registerConnector =
connectors.__experimentalRegisterConnector || connectors.registerConnector;
const ConnectorItem = connectors.__experimentalConnectorItem || connectors.ConnectorItem;
const { createElement, useState } = window.wp.element;
const { __ } = window.wp.i18n;
const { Button, Modal } = window.wp.components;
const HStack = window.wp.components.__experimentalHStack || window.wp.components.HStack;
const VStack = window.wp.components.__experimentalVStack || window.wp.components.VStack;
const Text = window.wp.components.__experimentalText || window.wp.components.Text;
const MODULE_ID = '@automattic/jetpack-connection-connectors';
const dataEl = document.getElementById( `wp-script-module-data-${ MODULE_ID }` );
const data = JSON.parse( dataEl?.textContent ?? '{}' );
const initialIsConnected = Boolean( data.isConnected );
const initialIsRegistered = Boolean( data.isRegistered );
const apiRoot = data.apiRoot || '';
const apiNonce = data.apiNonce || '';
const redirectUri = data.redirectUri || '';
const currentUser = data.currentUser || null;
const connectionOwner = data.connectionOwner || null;
const connectedPlugins = data.connectedPlugins || [];
const siteDetails = data.siteDetails || null;
const isWoaSite = Boolean( data.isWoaSite );
const isVipSite = Boolean( data.isVipSite );
const isManagedPlatformSite = isWoaSite || isVipSite;
const CONNECTOR_LOGO = data.connectorLogoUrl
? createElement( 'img', { src: data.connectorLogoUrl, alt: '', width: 36, height: 36 } )
: null;
const ssoStatus = data.ssoStatus ?? null;
/**
* Start the Jetpack connection flow: register the site (if needed),
* then redirect to WordPress.com for user authorization.
*
* Mirrors the flow in useConnection / handleRegisterSite from
* the `@automattic/jetpack-connection` JS package.
*
* @param {boolean} siteRegistered - Whether the site is already registered.
* @return {Promise<void>} Resolves after redirect begins.
*/
async function startConnectionFlow( siteRegistered ) {
if ( siteRegistered ) {
const params = new URLSearchParams();
if ( redirectUri ) {
params.set( 'redirect_uri', redirectUri );
}
const qs = params.toString();
const authRes = await window.fetch(
apiRoot + 'jetpack/v4/connection/authorize_url' + ( qs ? '?' + qs : '' ),
{ headers: { 'X-WP-Nonce': apiNonce } }
);
if ( ! authRes.ok ) {
const errBody = await authRes.json().catch( () => null );
throw new Error(
errBody?.message || __( 'Failed to retrieve authorization URL.', 'jetpack-connection' )
);
}
const authData = await authRes.json();
const authorizeUrl = authData?.authorizeUrl || authData;
if ( typeof authorizeUrl !== 'string' || ! authorizeUrl ) {
throw new Error( 'No authorization URL received' );
}
window.location.href = addSkipPricing( authorizeUrl );
return;
}
const body = { from: 'wpcom-connector' };
if ( redirectUri ) {
body.redirect_uri = redirectUri;
}
const response = await window.fetch( apiRoot + 'jetpack/v4/connection/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': apiNonce,
},
body: JSON.stringify( body ),
} );
if ( ! response.ok ) {
const errBody = await response.json().catch( () => null );
throw new Error( errBody?.message || __( 'Site registration failed.', 'jetpack-connection' ) );
}
const result = await response.json();
if ( ! result.authorizeUrl ) {
throw new Error( 'No authorization URL received' );
}
window.location.href = addSkipPricing( result.authorizeUrl );
}
/**
* Append skip_pricing to a Calypso authorize URL so that the post-auth
* redirect honours redirect_after_auth instead of sending the user to
* the Calypso plans page.
*
* TEMPORARY: Remove once Calypso recognises `from=wpcom-connector`
* natively and redirects to redirectAfterAuth for this flow.
*
* @param {string} url - Calypso authorize URL.
* @return {string} URL with skip_pricing appended.
*/
function addSkipPricing( url ) {
try {
const parsed = new URL( url );
parsed.searchParams.set( 'skip_pricing', 'true' );
return parsed.toString();
} catch {
return url;
}
}
/* ── Small presentational components ────────────────────────────── */
/**
* Inline error notice with an optional dismiss button.
*
* @param {object} props - Component props.
* @param {string} props.message - Error message text.
* @param {Function|null} props.onDismiss - Callback to clear the error; omit for non-dismissible.
* @return {object} React element.
*/
function ErrorNotice( { message, onDismiss = null } ) {
return createElement(
HStack,
{ spacing: 2, className: 'wpcom-connector__error', role: 'alert' },
createElement( Text, { size: 13 }, message ),
onDismiss
? createElement(
Button,
{
variant: 'link',
size: 'small',
onClick: onDismiss,
'aria-label': __( 'Dismiss error', 'jetpack-connection' ),
},
__( 'Dismiss', 'jetpack-connection' )
)
: null
);
}
/**
* Status badge with a BEM modifier for different connection states.
*
* @param {object} props - Component props.
* @param {string} props.label - Badge text.
* @param {string} props.modifier - BEM modifier suffix (e.g. 'connected', 'site-connected').
* @return {object} React element.
*/
function StatusBadge( { label, modifier = 'connected' } ) {
const cls = 'wpcom-connector__status-badge wpcom-connector__status-badge--' + modifier;
return createElement( 'span', { className: cls }, label );
}
/**
* A labelled user row with avatar, display name, and login.
*
* @param {object} props - Component props.
* @param {string} props.title - Section heading (uppercase label).
* @param {object|null} props.user - User data object with displayName, login, avatar.
* @param {string|null} props.subtitle - Override for the default login/email line.
* @param {object|null} props.actionSlot - Optional element rendered at the end of the user row.
* @return {object|null} React element or null.
*/
function UserSection( { title, user, subtitle = null, actionSlot = null } ) {
if ( ! user ) {
return null;
}
const defaultSubtitle = user.email
? '@' + user.login + ' (' + user.email + ')'
: '@' + user.login;
return createElement(
VStack,
{ spacing: 3, className: 'wpcom-connector__section' },
createElement(
Text,
{
variant: 'muted',
size: 11,
upperCase: true,
weight: 500,
},
title
),
createElement(
HStack,
null,
createElement(
HStack,
{ spacing: 3, expanded: false, alignment: 'center' },
user.avatar
? createElement( 'img', {
src: user.avatar,
alt: '',
width: 36,
height: 36,
className: 'wpcom-connector__owner-avatar',
} )
: null,
createElement(
VStack,
{ spacing: 0 },
createElement( Text, { weight: 600, size: 13 }, user.displayName ),
createElement( Text, { variant: 'muted', size: 12 }, subtitle || defaultSubtitle )
)
),
actionSlot
)
);
}
/**
* Connected plugins section displayed in the expanded card.
*
* @return {object|null} React element or null.
*/
function ConnectedPluginsSection() {
if ( ! connectedPlugins.length ) {
return null;
}
return createElement(
VStack,
{ spacing: 3, className: 'wpcom-connector__section' },
createElement(
Text,
{
variant: 'muted',
size: 11,
upperCase: true,
weight: 500,
},
__( 'Connected plugins', 'jetpack-connection' )
),
createElement(
HStack,
{ spacing: 4, wrap: true, justify: 'flex-start' },
...connectedPlugins.map( plugin =>
createElement(
HStack,
{ key: plugin.slug, spacing: 2, expanded: false, alignment: 'center' },
plugin.logoUrl
? createElement( 'img', {
src: plugin.logoUrl,
alt: '',
className: 'wpcom-connector__plugin-icon',
} )
: createElement( 'span', {
className:
'dashicons dashicons-admin-plugins wpcom-connector__plugin-icon wpcom-connector__plugin-icon--fallback',
} ),
createElement( Text, { size: 13 }, plugin.name )
)
)
)
);
}
/**
* Prompt shown to admins whose user account is not yet linked.
*
* @param {object} props - Component props.
* @param {Function} props.onConnect - Callback to start the authorization flow.
* @param {boolean} props.isConnecting - Whether a connection attempt is in progress.
* @param {boolean} props.isDisconnecting - Whether a disconnect is in progress (disables button).
* @return {object} React element.
*/
function ConnectPrompt( { onConnect, isConnecting, isDisconnecting } ) {
return createElement(
HStack,
{ spacing: 3, className: 'wpcom-connector__section' },
createElement(
'div',
{ className: 'wpcom-connector__connect-prompt-text' },
createElement(
Text,
{ size: 13 },
__(
'Your site is registered with WordPress.com. Connect your user account to unlock full functionality.',
'jetpack-connection'
)
)
),
createElement(
Button,
{
variant: 'secondary',
size: 'small',
onClick: onConnect,
isBusy: isConnecting,
disabled: isConnecting || isDisconnecting,
className: 'wpcom-connector__inline-action',
},
isConnecting
? __( 'Connecting…', 'jetpack-connection' )
: __( 'Connect account', 'jetpack-connection' )
)
);
}
/* ── Modal components ───────────────────────────────────────────── */
/**
* Destructive confirmation dialog (disconnect site or unlink owner).
*
* @param {object} props - Component props.
* @param {string} props.title - Modal heading.
* @param {string} props.message - Body text explaining consequences.
* @param {Function} props.onConfirm - Called when the user confirms.
* @param {Function} props.onCancel - Called when the user cancels or closes.
* @return {object} React element.
*/
function ConfirmationModal( { title, message, onConfirm, onCancel } ) {
return createElement(
Modal,
{ title, onRequestClose: onCancel, size: 'small' },
createElement(
VStack,
{ spacing: 5 },
createElement( Text, { size: 13 }, message ),
createElement(
HStack,
{ spacing: 3, justify: 'flex-end' },
createElement(
Button,
{ variant: 'tertiary', size: 'compact', onClick: onCancel },
__( 'Cancel', 'jetpack-connection' )
),
createElement(
Button,
{
variant: 'primary',
isDestructive: true,
size: 'compact',
onClick: onConfirm,
},
__( 'Disconnect', 'jetpack-connection' )
)
)
)
);
}
/**
* Read-only modal showing blog ID, site URL, home URL, and SSO status.
*
* @param {object} props - Component props.
* @param {Function} props.onClose - Called when the modal is dismissed.
* @return {object} React element.
*/
function SiteDetailsModal( { onClose } ) {
const row = ( label, value ) => [
createElement(
Text,
{ key: label, variant: 'muted', size: 12, className: 'wpcom-connector__details-label' },
label
),
createElement(
Text,
{ key: label + '-value', size: 13, className: 'wpcom-connector__details-value' },
value
),
];
return createElement(
Modal,
{
className: 'wpcom-connector__modal',
title: __( 'Connection details', 'jetpack-connection' ),
onRequestClose: onClose,
size: 'small',
},
createElement(
'div',
{ className: 'wpcom-connector__details-modal' },
...row( __( 'Blog ID', 'jetpack-connection' ), String( siteDetails.blogId ) ),
...row( __( 'Site URL', 'jetpack-connection' ), siteDetails.siteUrl ),
...row( __( 'Home URL', 'jetpack-connection' ), siteDetails.homeUrl ),
...( ssoStatus !== null
? row(
__( 'WordPress.com login (SSO)', 'jetpack-connection' ),
ssoStatus
? __( 'Enabled', 'jetpack-connection' )
: __( 'Not enabled', 'jetpack-connection' )
)
: [] )
)
);
}
/* ── Expanded details panel ─────────────────────────────────────── */
/**
* Expanded content shown when the user clicks "Details".
*
* @param {object} props - Component props.
* @param {boolean} props.isConnecting - Whether a user connection is in progress.
* @param {Function} props.onConnect - Callback to start user authorization flow.
* @return {object} React element.
*/
function ExpandedDetails( { isConnecting = false, onConnect = null } ) {
const [ isDisconnecting, setIsDisconnecting ] = useState( false );
const [ isUnlinking, setIsUnlinking ] = useState( false );
const [ showDetailsModal, setShowDetailsModal ] = useState( false );
const [ pendingConfirm, setPendingConfirm ] = useState( null );
const [ actionError, setActionError ] = useState( null );
const executeDisconnect = async () => {
setPendingConfirm( null );
setIsDisconnecting( true );
setActionError( null );
try {
const response = await window.fetch( apiRoot + 'jetpack/v4/connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': apiNonce,
},
body: JSON.stringify( { isActive: false } ),
} );
if ( response.ok ) {
window.location.reload();
return;
}
const errBody = await response.json().catch( () => null );
setActionError(
errBody?.message ||
__( 'Failed to disconnect the site. Please try again.', 'jetpack-connection' )
);
} catch {
setActionError(
__( 'Failed to disconnect the site. Please try again.', 'jetpack-connection' )
);
} finally {
setIsDisconnecting( false );
}
};
const handleDisconnect = () => {
setPendingConfirm( {
title: __( 'Disconnect site', 'jetpack-connection' ),
message: __(
'Are you sure you want to disconnect from WordPress.com? This will affect all plugins using this connection.',
'jetpack-connection'
),
onConfirm: executeDisconnect,
} );
};
const executeUnlinkUser = async () => {
setPendingConfirm( null );
setIsUnlinking( true );
setActionError( null );
try {
const body = { linked: false, force: true };
if ( currentUser?.isOwner ) {
body[ 'disconnect-all-users' ] = true;
}
const response = await window.fetch( apiRoot + 'jetpack/v4/connection/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': apiNonce,
},
body: JSON.stringify( body ),
} );
if ( response.ok ) {
window.location.reload();
return;
}
const errBody = await response.json().catch( () => null );
setActionError(
errBody?.message ||
__( 'Failed to disconnect the account. Please try again.', 'jetpack-connection' )
);
} catch {
setActionError(
__( 'Failed to disconnect the account. Please try again.', 'jetpack-connection' )
);
} finally {
setIsUnlinking( false );
}
};
const handleUnlinkUser = () => {
const message =
currentUser?.isOwner && currentUser?.hasOtherConnectedUsers
? __(
'Disconnecting the owner account will remove the WordPress.com account connection for all users on this site. The site will remain connected to WordPress.com.',
'jetpack-connection'
)
: __(
'Are you sure you want to disconnect your WordPress.com account? The site will remain connected to WordPress.com.',
'jetpack-connection'
);
setPendingConfirm( {
title: __( 'Disconnect user account', 'jetpack-connection' ),
message,
onConfirm: executeUnlinkUser,
} );
};
return createElement(
VStack,
{ spacing: 5 },
// Current user info + unlink action (only when the viewing admin is linked).
currentUser
? createElement( UserSection, {
title: currentUser.isOwner
? __( 'Connected as owner', 'jetpack-connection' )
: __( 'Connected as', 'jetpack-connection' ),
user: currentUser,
actionSlot:
isManagedPlatformSite && currentUser.isOwner
? null
: createElement(
Button,
{
variant: 'link',
isDestructive: true,
isBusy: isUnlinking,
disabled: isUnlinking || isDisconnecting,
onClick: handleUnlinkUser,
className: 'wpcom-connector__inline-action',
},
__( 'Disconnect account', 'jetpack-connection' )
),
} )
: null,
// Connect prompt (only when the viewing admin is NOT linked).
! currentUser && onConnect
? createElement( ConnectPrompt, {
onConnect,
isConnecting,
isDisconnecting,
} )
: null,
// Connection owner (shown to non-owners and unlinked admins).
connectionOwner && ! currentUser?.isOwner
? createElement( UserSection, {
title: __( 'Connection owner', 'jetpack-connection' ),
user: connectionOwner,
subtitle: connectionOwner.localLogin
? '@' +
connectionOwner.login +
' ( ' +
__( 'local username:', 'jetpack-connection' ) +
' ' +
connectionOwner.localLogin +
' )'
: null,
} )
: null,
createElement( ConnectedPluginsSection ),
actionError
? createElement( ErrorNotice, {
message: actionError,
onDismiss: () => setActionError( null ),
} )
: null,
// Footer: connection details link + disconnect site button.
createElement( 'hr', { className: 'wpcom-connector__divider' } ),
createElement(
HStack,
{ spacing: 3, alignment: 'center' },
siteDetails
? createElement(
Button,
{
variant: 'link',
size: 'compact',
onClick: () => setShowDetailsModal( true ),
className: 'wpcom-connector__details-link',
},
__( 'Connection details', 'jetpack-connection' )
)
: null,
isManagedPlatformSite
? null
: createElement(
Button,
{
variant: 'secondary',
isDestructive: true,
size: 'compact',
isBusy: isDisconnecting,
disabled: isDisconnecting || isUnlinking,
onClick: handleDisconnect,
className: 'wpcom-connector__disconnect-site',
},
__( 'Disconnect site', 'jetpack-connection' )
)
),
// Modals (rendered but visually hidden until triggered).
showDetailsModal && siteDetails
? createElement( SiteDetailsModal, {
onClose: () => setShowDetailsModal( false ),
} )
: null,
pendingConfirm
? createElement( ConfirmationModal, {
title: pendingConfirm.title,
message: pendingConfirm.message,
onConfirm: pendingConfirm.onConfirm,
onCancel: () => setPendingConfirm( null ),
} )
: null
);
}
/* ── Main card component ────────────────────────────────────────── */
/**
* Render callback for the WordPress.com connector card.
*
* Props vary between WordPress core (name, description, logo)
* and the Gutenberg plugin (label, description, icon).
*
* @param {object} props - Connector render props.
* @param {string} props.name - Connector name (core).
* @param {string} props.label - Connector label (Gutenberg).
* @param {string} props.description - Connector description.
* @param {object} props.logo - Logo element (core).
* @param {object} props.icon - Icon element (Gutenberg).
* @return {object} React element.
*/
function WpcomConnectorCard( { name, label, description, logo, icon } ) {
const connectorName = name || label;
const connectorLogo = logo || icon || CONNECTOR_LOGO;
const [ isExpanded, setIsExpanded ] = useState( false );
const isConnected = initialIsConnected;
const isSiteRegistered = initialIsRegistered;
const [ isConnecting, setIsConnecting ] = useState( false );
const [ connectError, setConnectError ] = useState( data.authError || null );
const handleConnect = async () => {
setIsConnecting( true );
setConnectError( null );
try {
await startConnectionFlow( isSiteRegistered );
} catch ( err ) {
setConnectError(
err?.message || __( 'Connection failed. Please try again.', 'jetpack-connection' )
);
setIsConnecting( false );
}
};
let actionArea;
let expandedContent = null;
if ( isConnected || isSiteRegistered ) {
// Site is registered with WordPress.com (with or without a connected owner).
const badgeProps = isConnected
? { label: __( 'Connected', 'jetpack-connection' ) }
: {
label: __( 'Site connected', 'jetpack-connection' ),
modifier: 'site-connected',
};
actionArea = createElement(
HStack,
{ spacing: 3, expanded: false },
createElement( StatusBadge, badgeProps ),
createElement(
Button,
{
variant: 'secondary',
size: 'compact',
onClick: () => setIsExpanded( ! isExpanded ),
'aria-expanded': isExpanded,
},
isExpanded ? __( 'Close', 'jetpack-connection' ) : __( 'Details', 'jetpack-connection' )
)
);
if ( isExpanded ) {
const needsUserConnection = ! currentUser;
expandedContent = createElement(
'div',
{ className: 'wpcom-connector__expanded' },
createElement( ExpandedDetails, {
isConnecting: needsUserConnection ? isConnecting : false,
onConnect: needsUserConnection ? handleConnect : null,
} )
);
}
} else {
// Not connected at all — show a simple connect button.
actionArea = createElement(
Button,
{
variant: 'secondary',
size: 'compact',
onClick: handleConnect,
isBusy: isConnecting,
disabled: isConnecting,
},
isConnecting
? __( 'Connecting…', 'jetpack-connection' )
: __( 'Connect', 'jetpack-connection' )
);
}
const showBadge = isConnected || isSiteRegistered;
const styledDescription = showBadge
? description
: createElement( 'span', { className: 'wpcom-connector__description-padded' }, description );
return createElement(
ConnectorItem,
{
// ConnectorItem uses name/logo (core) or label/icon (Gutenberg).
logo: connectorLogo,
icon: connectorLogo,
name: connectorName,
label: connectorName,
description: styledDescription,
actionArea,
},
expandedContent,
connectError
? createElement( ErrorNotice, {
message: connectError,
onDismiss: () => setConnectError( null ),
} )
: null
);
}
registerConnector( 'wordpress_com', {
name: data.connectorName ?? 'WordPress.com',
label: data.connectorName ?? 'WordPress.com',
description:
data.connectorDescription ??
__( 'Enhanced functionality with Jetpack and WooCommerce.', 'jetpack-connection' ),
logoUrl: data.connectorLogoUrl ?? '',
render: WpcomConnectorCard,
} );

View File

@ -1,30 +0,0 @@
#wpadminbar #wp-admin-bar-jetpack-idc {
margin-right: 5px;
.jp-idc-admin-bar {
border-radius: 2px;
font-weight: 500;
font-size: 14px;
line-height: 20px;
color: #EFEFF0;
padding: 6px 8px;
}
&.hide {
display: none;
}
.dashicons {
font-family: 'dashicons';
margin-top: -6px;
&:before {
font-size: 18px;
}
}
.ab-item {
padding: 0;
background: #E68B28;
}
}

View File

@ -1,6 +1,5 @@
import { IDCScreen } from '@automattic/jetpack-idc';
import * as WPElement from '@wordpress/element';
import React from 'react';
import './admin-bar.scss';
import './style.scss';

View File

@ -1,9 +0,0 @@
#jp-identity-crisis-container .jp-idc__idc-screen {
margin-top: 40px;
margin-bottom: 40px;
}
#jp-identity-crisis-container.notice {
background: none;
border: none;
}

View File

@ -7,6 +7,7 @@
namespace Automattic\Jetpack;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Connection\Urls;
use Automattic\Jetpack\IdentityCrisis\Exception;
@ -28,6 +29,16 @@ class Identity_Crisis {
*/
const PERSISTENT_BLOG_ID_OPTION_NAME = 'jetpack_persistent_blog_id';
/**
* Initial delay for IDC validation in seconds (1 hour).
*/
const IDC_VALIDATION_INITIAL_DELAY = 3600;
/**
* Maximum delay for IDC validation in seconds (30 days).
*/
const IDC_VALIDATION_MAX_DELAY = 2592000;
/**
* Instance of the object.
*
@ -322,10 +333,9 @@ class Identity_Crisis {
);
if ( in_array( $error_code, $allowed_idc_error_codes, true ) ) {
Jetpack_Options::update_option(
'sync_error_idc',
self::get_sync_error_idc_option( $response )
);
// This is a defensive fallback.
$new_idc_data = self::get_idc_option_with_preserved_timing( $response );
Jetpack_Options::update_option( 'sync_error_idc', $new_idc_data );
}
return true;
@ -334,6 +344,102 @@ class Identity_Crisis {
return false;
}
/**
* Gets IDC option data with timing preserved from existing option if appropriate.
*
* This is a defensive fallback for edge cases where IDC errors are repeatedly detected
* even though the site should be in IDC mode. However, edge cases can cause the option
* to be deleted, triggering new IDC detections.
*
* @param array $response The IDC error response from WordPress.com.
*
* @return array The IDC option data, with timing preserved if the wpcom URLs match.
*/
private static function get_idc_option_with_preserved_timing( $response ) {
// Get existing IDC option to check if this is the same error.
$existing_idc = Jetpack_Options::get_option( 'sync_error_idc' );
// Get the new error data with fresh timing values.
$new_idc_data = self::get_sync_error_idc_option( $response );
// If an existing IDC exists and the wpcom URLs match, preserve the backoff delay.
if ( is_array( $existing_idc ) && self::has_same_wpcom_urls( $existing_idc, $new_idc_data ) ) {
// Same wpcom URLs - preserve the backoff delay.
// Note: last_checked is already set to time() by get_sync_error_idc_option(),
// which is correct - we want to record that we just saw this error again.
$preserved_delay = self::get_valid_delay_from_existing_idc( $existing_idc );
if ( $preserved_delay !== null ) {
$new_idc_data['next_check_delay'] = $preserved_delay;
}
}
// else: Different wpcom URLs or first time - use fresh timing from get_sync_error_idc_option().
return $new_idc_data;
}
/**
* Extracts and validates the next_check_delay from an existing IDC option.
*
* @param array $existing_idc The existing IDC option data.
*
* @return int|null The validated delay in seconds, or null if invalid.
*/
private static function get_valid_delay_from_existing_idc( $existing_idc ) {
if ( ! isset( $existing_idc['next_check_delay'] ) ) {
return null;
}
$delay = $existing_idc['next_check_delay'];
// Validate the delay is numeric and within acceptable bounds.
if (
! is_numeric( $delay ) ||
$delay < self::IDC_VALIDATION_INITIAL_DELAY ||
$delay > self::IDC_VALIDATION_MAX_DELAY
) {
return null;
}
return (int) $delay;
}
/**
* Determines if two IDC error arrays have the same wpcom URLs.
*
* The wpcom URLs are stored in reversed form in the database, but the jetpack_options
* filter un-reverses them when retrieved. This method normalizes both sets of URLs
* to reversed form before comparing.
*
* @param array $idc1 First IDC error data (from get_option, may be un-reversed).
* @param array $idc2 Second IDC error data (from get_sync_error_idc_option, reversed).
*
* @return bool True if they have the same wpcom URLs.
*/
private static function has_same_wpcom_urls( $idc1, $idc2 ) {
// Both must have wpcom_home and wpcom_siteurl to be comparable.
if (
! isset( $idc1['wpcom_home'] ) ||
! isset( $idc1['wpcom_siteurl'] ) ||
! isset( $idc2['wpcom_home'] ) ||
! isset( $idc2['wpcom_siteurl'] ) ||
! isset( $idc1['reversed_url'] ) ||
! isset( $idc2['reversed_url'] )
) {
return false;
}
// The existing IDC data has been un-reversed by the jetpack_options filter when
// retrieved via Jetpack_Options::get_option(), so the wpcom URLs are in normal format.
// The new data from get_sync_error_idc_option() has reversed URLs.
// Reverse the existing URLs to match the format of the new data for comparison.
$existing_wpcom_home = strrev( $idc1['wpcom_home'] );
$existing_wpcom_siteurl = strrev( $idc1['wpcom_siteurl'] );
// Compare the reversed URLs.
return $existing_wpcom_home === $idc2['wpcom_home']
&& $existing_wpcom_siteurl === $idc2['wpcom_siteurl'];
}
/**
* Clears all IDC specific options. This method is used on disconnect and reconnect.
*
@ -374,6 +480,18 @@ class Identity_Crisis {
// Is the site opted in and does the stored sync_error_idc option match what we now generate?
$sync_error = Jetpack_Options::get_option( 'sync_error_idc' );
if ( $sync_error && self::should_handle_idc() ) {
// Ensure backward compatibility: add validation timing fields if missing.
// Also ensure $sync_error is an array (could be a scalar from older code).
if ( ! is_array( $sync_error ) ) {
$sync_error = array();
}
if ( ! isset( $sync_error['last_checked'] ) ) {
$sync_error['last_checked'] = 0;
}
if ( ! isset( $sync_error['next_check_delay'] ) ) {
$sync_error['next_check_delay'] = self::IDC_VALIDATION_INITIAL_DELAY;
}
$local_options = self::get_sync_error_idc_option();
// Ensure all values are set.
@ -389,6 +507,17 @@ class Identity_Crisis {
Jetpack_Options::update_option( 'migrate_for_idc', true );
} elseif ( $sync_error['home'] === $local_options['home'] && $sync_error['siteurl'] === $local_options['siteurl'] ) {
$is_valid = true;
// Check if it's time to validate the IDC with a remote call to WordPress.com.
if ( self::should_remote_validate_idc( $sync_error ) ) {
// Perform remote validation.
if ( self::remote_validate_idc( $sync_error ) ) {
// IDC was cleared remotely. The option is already deleted by
// remote_validate_idc(), so return false immediately to
// avoid double deletion and allow the filter to run.
return (bool) apply_filters( 'jetpack_sync_error_idc_validation', false );
}
}
}
}
}
@ -429,6 +558,188 @@ class Identity_Crisis {
return $sync_error;
}
/**
* Checks if enough time has passed to validate the IDC with a remote call.
*
* Uses progressive delay: starts at 1 hour, doubles each time (1h, 2h, 4h, 8h, 16h...),
* and stops checking once the maximum delay of 30 days is reached.
*
* @param array $sync_error The stored sync_error_idc option.
* @return bool True if validation should be performed, false otherwise.
*/
public static function should_remote_validate_idc( $sync_error ) {
// Respect the user's decision to stay in safe mode.
// If safe mode is confirmed, don't attempt validation.
if ( self::safe_mode_is_confirmed() ) {
return false;
}
// If a validation is already in progress or recently completed, don't trigger another.
if ( get_transient( 'jetpack_idc_validation_lock' ) ) {
return false;
}
// If delay is not set or invalid, validate immediately to bring into new system.
if ( empty( $sync_error['next_check_delay'] ) ) {
return true;
}
// If delay has reached or exceeded the maximum, stop validating.
if ( $sync_error['next_check_delay'] >= self::IDC_VALIDATION_MAX_DELAY ) {
return false;
}
// Check if enough time has passed since the last check.
$time_since_last_check = time() - ( $sync_error['last_checked'] ?? 0 );
return $time_since_last_check >= $sync_error['next_check_delay'];
}
/**
* Validates the stored IDC by making a remote call to WordPress.com.
*
* Makes a lightweight API call to check if WordPress.com still detects an IDC.
* If no IDC is detected in the response, the local IDC option is cleared.
* If an IDC is still detected, the option is refreshed with the latest URL data from
* WordPress.com and the validation timestamps are updated with progressive backoff.
* If the request fails (network error, timeout, etc.), timing is updated to prevent
* immediate retries but the delay interval is not increased.
*
* @param array $sync_error The stored sync_error_idc option with timing fields.
* @return bool True if IDC was cleared, false otherwise.
*/
public static function remote_validate_idc( $sync_error ) {
// Prevent recursive calls that could cause infinite loops within the same request.
static $is_validating = false;
if ( $is_validating ) {
return false;
}
$blog_id = Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
// Site not registered - IDC state is invalid without a connection to WordPress.com.
// Clear the IDC since there's nothing to validate against, and the site can't
// restore the blog_id while in IDC anyway (connection flow is blocked).
Jetpack_Options::delete_option( 'sync_error_idc' );
return true; // Return true to indicate IDC was cleared.
}
// Use a transient lock to prevent concurrent validations across multiple requests.
// Lock for the full backoff duration to prevent retries during the delay window.
$lock_key = 'jetpack_idc_validation_lock';
$lock_duration = $sync_error['next_check_delay'] ?? self::IDC_VALIDATION_INITIAL_DELAY;
if ( get_transient( $lock_key ) ) {
return false;
}
// Set the lock and verify it was set successfully.
// If the write fails, bail immediately to prevent request floods.
if ( ! set_transient( $lock_key, true, $lock_duration ) ) {
return false; // Bail - can't prevent concurrent requests.
}
$is_validating = true;
// Update last_checked before making the API call.
// This prevents retries even if the API call hangs, times out, or response handling fails.
$sync_error['last_checked'] = time();
// Note: update_option may return false if value unchanged, which is OK.
// We only bail if we can't verify the option exists with correct timestamp.
Jetpack_Options::update_option( 'sync_error_idc', $sync_error );
// Verify the critical timing field was persisted.
// This protects against caching/DB issues that would cause request floods.
$verified_option = Jetpack_Options::get_option( 'sync_error_idc' );
if (
! is_array( $verified_option ) ||
empty( $verified_option['last_checked'] ) ||
(int) $verified_option['last_checked'] !== (int) $sync_error['last_checked']
) {
// Option is missing, corrupted, or has incorrect timestamp - BAIL to prevent retries.
delete_transient( $lock_key );
$is_validating = false;
return false;
}
// Build API path with current URLs as query params.
// We must explicitly include URLs because add_idc_query_args_to_url() skips
// adding them when the site is in IDC (to prevent sync). For revalidation,
// we need WordPress.com to compare current URLs against what it has stored.
// We use the jetpack-token-health/blog endpoint which performs IDC detection
// and returns idc_detected in the response when URLs don't match.
$api_path = sprintf(
'sites/%d/jetpack-token-health/blog?home=%s&siteurl=%s&idc=1&idc_validation=1',
$blog_id,
rawurlencode( Urls::home_url() ),
rawurlencode( Urls::site_url() )
);
// Make an API call to WordPress.com to check token health and IDC status.
// The response will include 'idc_detected' if URLs still mismatch.
$response = Client::wpcom_json_api_request_as_blog(
$api_path,
'2',
array( 'method' => 'GET' ),
null,
'wpcom'
);
// Parse response body - will be null/false if request failed or JSON is invalid.
$body = null;
if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
$body = json_decode( wp_remote_retrieve_body( $response ), true );
}
// Check for success: valid response with no idc_detected means IDC is resolved.
if ( is_array( $body ) && empty( $body['idc_detected'] ) ) {
Jetpack_Options::delete_option( 'sync_error_idc' );
delete_transient( $lock_key );
$is_validating = false;
return true;
}
// IDC still exists or request failed - update timing.
$idc_detected = is_array( $body ) && ! empty( $body['idc_detected'] ) && is_array( $body['idc_detected'] );
if ( $idc_detected ) {
// Valid idc_detected response - refresh data and apply exponential backoff.
// @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- $body is verified as array in $idc_detected check
$fresh_idc_data = self::get_sync_error_idc_option( $body['idc_detected'] );
$fresh_idc_data['last_checked'] = time();
$fresh_idc_data['next_check_delay'] = min(
( $sync_error['next_check_delay'] ?? self::IDC_VALIDATION_INITIAL_DELAY ) * 2,
self::IDC_VALIDATION_MAX_DELAY
);
Jetpack_Options::update_option( 'sync_error_idc', $fresh_idc_data );
self::invalidate_idc_option_cache();
} else {
// Network error, invalid JSON, or non-200 - just update last_checked without backoff.
$sync_error['last_checked'] = time();
Jetpack_Options::update_option( 'sync_error_idc', $sync_error );
self::invalidate_idc_option_cache();
}
delete_transient( $lock_key );
$is_validating = false;
return false;
}
/**
* Invalidate the cache for the sync_error_idc option.
*
* This ensures that subsequent requests read fresh data from the database
* rather than stale cached values, which is critical for preventing request floods.
*
* Note: This directly calls wp_cache_delete with the 'jetpack_options' cache group,
* which couples this code to the internal caching implementation of Jetpack_Options.
* If Jetpack_Options changes its caching strategy, this method will need to be updated.
*
* @return void
*/
private static function invalidate_idc_option_cache() {
wp_cache_delete( 'sync_error_idc', 'jetpack_options' );
}
/**
* Normalizes a url by doing three things:
* - Strips protocol
@ -505,6 +816,12 @@ class Identity_Crisis {
$returned_values = self::reverse_wpcom_urls_for_idc( $returned_values );
}
// Add validation timing fields.
// Set last_checked to current time so remote validation doesn't trigger immediately.
// This ensures the first validation happens after the initial delay period.
$returned_values['last_checked'] = time();
$returned_values['next_check_delay'] = self::IDC_VALIDATION_INITIAL_DELAY;
return $returned_values;
}
@ -667,6 +984,12 @@ class Identity_Crisis {
* phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag -- The exception is being caught, false positive.
*/
public static function add_secret_to_url_validation_response( array $response ) {
// Only checking the database option to limit the effect.
if ( get_option( 'jetpack_offline_mode' ) ) {
$response['offline_mode'] = '1';
return $response;
}
try {
$secret = new URL_Secret();

View File

@ -89,7 +89,7 @@ class UI {
* @return string
*/
private static function get_initial_state() {
return 'var JP_IDENTITY_CRISIS__INITIAL_STATE=JSON.parse(decodeURIComponent("' . rawurlencode( wp_json_encode( static::get_initial_state_data() ) ) . '"));';
return 'var JP_IDENTITY_CRISIS__INITIAL_STATE=' . wp_json_encode( static::get_initial_state_data(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';';
}
/**

View File

@ -0,0 +1,129 @@
<?php
/**
* Storage Provider Interface for External Storage.
*
* Defines the contract that all external storage providers must implement
* to be compatible with the Jetpack Connection External Storage system.
*
* @package automattic/jetpack-connection
*/
namespace Automattic\Jetpack\Connection;
/**
* Interface for external storage providers.
*
* All storage providers must implement this interface to ensure
* compatibility with the External_Storage system.
*
* ## Required Methods
*
* - `is_available()` - Check if storage backend is accessible
* - `should_handle( $option_name )` - Determine if provider handles the option
* - `get( $option_name )` - Retrieve value from external storage
* - `get_environment_id()` - Return environment identifier for logging
*
* ## Optional Methods
*
* Providers may implement these additional methods for enhanced functionality:
*
* ### handle_error_event( string $event_type, string $key, string $details, string $environment )
*
* Called when External_Storage detects an error or empty state. Implement this
* to report errors to your host's monitoring system.
*
* - `$event_type` - 'error' or 'empty'
* - `$key` - The option key that triggered the event
* - `$details` - Additional error details (error message, etc.)
* - `$environment` - Environment identifier from get_environment_id()
*
* Example:
*
* public function handle_error_event( $event_type, $key, $details, $environment ) {
* // Report to your monitoring system
* wp_remote_post(
* 'https://your-api/errors',
* array(
* 'body' => array(
* 'event_type' => $event_type,
* 'key' => $key,
* 'details' => $details,
* 'environment' => $environment,
* ),
* )
* );
* }
*
* ### get_empty_state_delay_threshold()
*
* Customize the delay before reporting empty states. Returns delay in seconds.
* Default (when not implemented) is 5 minutes (300 seconds). Use this if your
* storage system has different sync times.
*
* - Return `0` if external storage is the source of truth (written first)
* - Return higher values for slower sync systems
* - Maximum allowed value is 15 minutes (900 seconds); values above this are ignored
*
* Example:
*
* public function get_empty_state_delay_threshold() {
* return 90; // 90 seconds for fast-syncing storage
* // return 0; // No delay - external storage is written first
* }
*
* @since 6.18.0
*/
interface Storage_Provider_Interface {
/**
* Check if the storage provider is available in the current environment.
*
* This method should return true if the storage backend is accessible
* and ready to handle requests, false otherwise.
*
* @since 6.18.0
*
* @return bool True if storage is available, false otherwise.
*/
public function is_available();
/**
* Determine if this provider should handle the given option.
*
* This method allows providers to selectively handle certain options
* based on their configuration, environment, or other criteria.
*
* @since 6.18.0
*
* @param string $option_name The name of the option to check.
* @return bool True if this provider should handle the option, false otherwise.
*/
public function should_handle( $option_name );
/**
* Retrieve a value from external storage.
*
* This method should return the value from external storage, or null
* if the value is not found or cannot be retrieved.
*
* @since 6.18.0
*
* @param string $option_name The name of the option to retrieve.
* @return mixed The option value, or null if not found/available.
* @throws \Exception If there's an error retrieving the value.
*/
public function get( $option_name );
/**
* Get the environment identifier for this provider.
*
* This method should return a unique identifier for the environment
* or storage type (e.g., 'atomic', 'vip', 'kubernetes', etc.).
* Used for logging and debugging purposes.
*
* @since 6.18.0
*
* @return string The environment identifier.
*/
public function get_environment_id();
}

View File

@ -14,7 +14,9 @@ use Automattic\Jetpack\Modules;
use WP_Error;
/**
* Force users to use two factor authentication.
* Force users to use two-factor authentication.
*
* @phan-constructor-used-for-side-effects
*/
class Force_2FA {
/**

View File

@ -7,6 +7,7 @@
namespace Automattic\Jetpack\Connection\SSO;
use Automattic\Jetpack\Connection\SSO;
use Automattic\Jetpack\Constants;
use Jetpack_IXR_Client;
@ -211,6 +212,15 @@ class Helpers {
}
}
foreach ( array( SSO::get_broker_url(), SSO::get_broker_auth_url() ) as $broker_url ) {
if ( $broker_url ) {
$broker_parts = wp_parse_url( $broker_url );
if ( $broker_parts && ! empty( $broker_parts['host'] ) ) {
$hosts[] = $broker_parts['host'];
}
}
}
return array_unique( $hosts );
}

View File

@ -71,7 +71,7 @@ class Notices {
}
/**
* Error message that is displayed when the current site is in an identity crisis and SSO can not be used.
* Error message that is displayed when the current site is in an identity crisis and SSO cannot be used.
*
* @since jetpack-4.3.2
*
@ -182,7 +182,7 @@ class Notices {
}
/**
* Message displayed when the user can not be found after approving the SSO process on WordPress.com
* Message displayed when the user cannot be found after approving the SSO process on WordPress.com
*
* @param string $message Error message.
*
@ -211,38 +211,7 @@ class Notices {
}
/**
* Error message that is displayed when the current site is in an identity crisis and SSO can not be used.
*
* @since jetpack-4.4.0
* @deprecated since 2.10.0
*
* @param string $message Error message.
*
* @return string
*/
public static function sso_not_allowed_in_staging( $message ) {
_deprecated_function( __FUNCTION__, '2.10.0', 'sso_not_allowed_in_safe_mode' );
$error = __(
'Logging in with WordPress.com is disabled for sites that are in staging mode.',
'jetpack-connection'
);
/**
* Filters the disallowed notice for staging sites attempting SSO.
*
* @module sso
*
* @since jetpack-10.5.0
*
* @param string $error Error text.
*/
$error = apply_filters_deprecated( 'jetpack_sso_disallowed_staging_notice', array( $error ), '2.9.1', 'jetpack_sso_disallowed_safe_mode_notice' );
$message .= sprintf( '<p class="message">%s</p>', esc_html( $error ) );
return $message;
}
/**
* Error message that is displayed when the current site is in an identity crisis and SSO can not be used.
* Error message that is displayed when the current site is in an identity crisis and SSO cannot be used.
*
* @since 2.10.0
*

View File

@ -13,6 +13,7 @@ use Automattic\Jetpack\Connection\SSO\Helpers;
use Automattic\Jetpack\Connection\SSO\Notices;
use Automattic\Jetpack\Connection\SSO\User_Admin;
use Automattic\Jetpack\Connection\Webhooks\Authorize_Redirect;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Roles;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Host;
@ -40,6 +41,24 @@ class SSO {
*/
public static $instance = null;
/**
* Stores the WP_User being authenticated via SSO so the
* attach_session_information callback can tag the session.
*
* @var WP_User|null
*/
private static $sso_user_for_2fa = null;
/**
* Cookie name for the SSO broker authorization signal.
*
* Set when WP.com signals that a broker should be used for SSO. The cookie
* value is the SSO nonce, tying the signal to a specific authentication flow.
*
* @var string
*/
const BROKER_COOKIE = 'jetpack_sso_broker';
/**
* Automattic\Jetpack\Connection\SSO constructor.
*/
@ -275,7 +294,7 @@ class SSO {
* $_GET['jetpack-sso-default-form'] is used to provide a fallback in case JavaScript is not enabled.
*
* The default_to_sso_login() method allows us to dynamically decide whether we show the SSO login form or not.
* The SSO module uses the method to display the default login form if we can not find a user to log in via SSO.
* The SSO module uses the method to display the default login form if we cannot find a user to log in via SSO.
* But, the method could be filtered by a site admin to always show the default login form if that is preferred.
*/
if ( empty( $_GET['jetpack-sso-show-default-form'] ) && Helpers::show_sso_login() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
@ -559,6 +578,17 @@ class SSO {
true
);
// Persist the WordPress.com referrer signal so it survives the SSO button
// click, which changes the HTTP Referer to the site's own login page.
// Uses the live-only check to avoid a self-reinforcing cookie loop.
if ( self::is_live_referrer_wpcom() ) {
setcookie( 'jetpack_sso_wpcom_referrer', '1', time() + ( 10 * MINUTE_IN_SECONDS ), COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
$_COOKIE['jetpack_sso_wpcom_referrer'] = '1';
} elseif ( ! empty( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) {
setcookie( 'jetpack_sso_wpcom_referrer', ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
unset( $_COOKIE['jetpack_sso_wpcom_referrer'] );
}
if ( ! empty( $_GET['redirect_to'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// If we have something to redirect to.
$url = esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
@ -724,6 +754,30 @@ class SSO {
true
);
}
if ( isset( $_COOKIE[ self::BROKER_COOKIE ] ) ) {
setcookie(
self::BROKER_COOKIE,
' ',
time() - YEAR_IN_SECONDS,
COOKIEPATH,
COOKIE_DOMAIN,
is_ssl(),
true
);
}
if ( isset( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) {
setcookie(
'jetpack_sso_wpcom_referrer',
' ',
time() - YEAR_IN_SECONDS,
COOKIEPATH,
COOKIE_DOMAIN,
is_ssl(),
true
);
}
}
/**
@ -755,22 +809,178 @@ class SSO {
return new WP_Error( $xml->getErrorCode(), $xml->getErrorMessage() );
}
$nonce = sanitize_key( $xml->getResponse() );
$response = $xml->getResponse();
// The response may be a plain nonce string (default) or an associative
// array containing 'nonce' and a 'use_sso_broker' signal for sites that
// use an external SSO broker (e.g. CIAB stores via the MSD).
if ( is_array( $response ) ) {
if ( empty( $response['nonce'] ) ) {
return new WP_Error( 'invalid_response', __( 'Invalid nonce response from WordPress.com.', 'jetpack-connection' ) );
}
$nonce = sanitize_key( $response['nonce'] );
$use_broker = ! empty( $response['use_sso_broker'] );
} else {
$nonce = sanitize_key( $response );
$use_broker = false;
}
$cookie_expiry = time() + ( 10 * MINUTE_IN_SECONDS );
setcookie(
'jetpack_sso_nonce',
$nonce,
time() + ( 10 * MINUTE_IN_SECONDS ),
$cookie_expiry,
COOKIEPATH,
COOKIE_DOMAIN,
is_ssl(),
true
);
// Ensure this request can use the nonce immediately after setcookie().
$_COOKIE['jetpack_sso_nonce'] = $nonce;
if ( $use_broker ) {
setcookie(
self::BROKER_COOKIE,
$nonce,
$cookie_expiry,
COOKIEPATH,
COOKIE_DOMAIN,
is_ssl(),
true
);
// Mirror the broker signal in-memory for this request.
$_COOKIE[ self::BROKER_COOKIE ] = $nonce;
} else {
setcookie( self::BROKER_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
unset( $_COOKIE[ self::BROKER_COOKIE ] );
}
}
return $nonce;
}
/**
* Validates a broker URL string.
*
* @param string $url The URL to validate.
* @return string|false The URL if valid HTTPS with a host, or false.
*/
private static function validate_broker_url( $url ) {
if ( empty( $url ) || ! is_string( $url ) ) {
return false;
}
$sanitized = esc_url_raw( $url );
$url_parts = wp_parse_url( $sanitized );
if ( $url_parts && 'https' === ( $url_parts['scheme'] ?? '' ) && ! empty( $url_parts['host'] ) ) {
return $sanitized;
}
return false;
}
/**
* Checks whether WP.com has authorized broker mode for the current SSO flow.
*
* The broker cookie is set during the nonce request when WP.com signals
* that broker SSO should be used. Its value matches the SSO nonce to tie
* the authorization to a specific flow.
*
* @return bool True if WP.com authorized broker mode for this nonce.
*/
private static function is_broker_authorized() {
$broker_signal = ! empty( $_COOKIE[ self::BROKER_COOKIE ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
? sanitize_key( wp_unslash( $_COOKIE[ self::BROKER_COOKIE ] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
: false;
$current_nonce = ! empty( $_COOKIE['jetpack_sso_nonce'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
? sanitize_key( wp_unslash( $_COOKIE['jetpack_sso_nonce'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
: false;
return $broker_signal && $current_nonce && $broker_signal === $current_nonce;
}
/**
* Checks whether the current request's referrer is a WordPress.com domain.
*
* Used to skip the broker URL when the user navigated from Calypso or
* another WordPress.com interface, so they stay within the expected
* wordpress.com SSO flow.
*
* @return bool True if the referrer is a WordPress.com domain.
*/
private static function is_referrer_wpcom() {
// Check the cookie persisted by save_cookies() on the initial login page
// load. The live HTTP Referer changes to the site's own wp-login.php when
// the user clicks the SSO button, so the cookie carries the original signal.
if ( ! empty( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return true;
}
return self::is_live_referrer_wpcom();
}
/**
* Checks the live HTTP Referer header against WordPress.com domains.
*
* Unlike is_referrer_wpcom(), this does NOT consult the persisted cookie,
* so it is safe to call from save_cookies() without creating a
* self-reinforcing loop.
*
* @return bool True if the live referrer is a WordPress.com domain.
*/
private static function is_live_referrer_wpcom() {
$referer = wp_get_raw_referer();
if ( ! $referer ) {
return false;
}
$wpcom_hosts = array(
'wordpress.com',
'horizon.wordpress.com',
'wpcalypso.wordpress.com',
);
$referer_host = wp_parse_url( $referer, PHP_URL_HOST );
return $referer_host && in_array( $referer_host, $wpcom_hosts, true );
}
/**
* Retrieves the SSO broker URL if authorized by WP.com and defined by the MU plugin.
*
* The broker URL is read from the JETPACK_SSO_BROKER_URL constant, which
* is expected to be defined by a garden MU plugin (e.g. for CIAB stores).
* It is only used when WP.com has signaled broker mode via the nonce response.
*
* @return string|false The broker URL, or false if not available.
*/
public static function get_broker_url() {
if ( ! self::is_broker_authorized() ) {
return false;
}
$url = Constants::get_constant( 'JETPACK_SSO_BROKER_URL' );
return $url ? self::validate_broker_url( $url ) : false;
}
/**
* Retrieves the SSO broker authorization URL if authorized by WP.com.
*
* For broker sites, this URL replaces the Jetpack authorization endpoint
* for establishing user connections. Read from the JETPACK_SSO_BROKER_AUTH_URL
* constant defined by the garden MU plugin.
*
* @return string|false The broker authorization URL, or false if not available.
*/
public static function get_broker_auth_url() {
if ( ! self::is_broker_authorized() ) {
return false;
}
$url = Constants::get_constant( 'JETPACK_SSO_BROKER_AUTH_URL' );
return $url ? self::validate_broker_url( $url ) : false;
}
/**
* The function that actually handles the login!
*/
@ -823,7 +1033,7 @@ class SSO {
}
$user_found_with = '';
if ( empty( $user ) && isset( $user_data->external_user_id ) ) {
if ( isset( $user_data->external_user_id ) ) {
$user_found_with = 'external_user_id';
$user = get_user_by( 'id', (int) $user_data->external_user_id );
if ( $user ) {
@ -918,25 +1128,56 @@ class SSO {
// Cache the user's details, so we can present it back to them on their user screen.
update_user_meta( $user->ID, 'wpcom_user_data', $user_data );
/*
* Two-Factor plugin 0.15.0+ unconditionally hooks wp_login at PHP_INT_MAX,
* which destroys the auth session and prompts for local 2FA — even for SSO
* logins that already completed 2FA on WordPress.com.
*
* When WP.com confirms the user has 2FA active, remove Two-Factor's wp_login
* hook so SSO can complete without a redundant local 2FA prompt.
*
* When WP.com 2FA is NOT active, the hook stays and Two-Factor can enforce
* local 2FA as a safety net.
*
* @see https://github.com/WordPress/two-factor/issues/811
*/
/**
* Filter whether to accept WordPress.com 2FA in place of a local
* Two-Factor prompt during SSO login.
*
* Return false to always require the local Two-Factor prompt,
* even when the user has completed 2FA on WordPress.com.
*
* @since 8.1.0
* @module sso
*
* @param bool $accept Whether to accept WP.com 2FA. Default true.
* @param object $user_data WordPress.com user data from SSO validation.
* @param WP_User $user The local WordPress user.
*/
$accept_wpcom_2fa = apply_filters( 'jetpack_sso_accept_wpcom_2fa', true, $user_data, $user );
if (
! empty( $user_data->two_step_enabled )
&& class_exists( 'Two_Factor_Core' )
&& $accept_wpcom_2fa
) {
self::$sso_user_for_2fa = $user;
add_filter( 'attach_session_information', array( static::class, 'add_two_factor_session_meta' ), 10, 2 );
remove_action( 'wp_login', array( 'Two_Factor_Core', 'wp_login' ), PHP_INT_MAX );
}
add_filter( 'auth_cookie_expiration', array( Helpers::class, 'extend_auth_cookie_expiration_for_sso' ) );
wp_set_auth_cookie( $user->ID, true );
remove_filter( 'auth_cookie_expiration', array( Helpers::class, 'extend_auth_cookie_expiration_for_sso' ) );
remove_filter( 'attach_session_information', array( static::class, 'add_two_factor_session_meta' ), 10 );
/** This filter is documented in core/src/wp-includes/user.php */
do_action( 'wp_login', $user->user_login, $user );
wp_set_current_user( $user->ID );
$_request_redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$redirect_to = user_can( $user, 'edit_posts' ) ? admin_url() : self::profile_page_url();
// If we have a saved redirect to request in a cookie.
if ( ! empty( $_COOKIE['jetpack_sso_redirect_to'] ) ) {
// Set that as the requested redirect to.
$redirect_to = esc_url_raw( wp_unslash( $_COOKIE['jetpack_sso_redirect_to'] ) );
$_request_redirect_to = $redirect_to;
}
$json_api_auth_environment = Helpers::get_json_api_auth_environment();
$is_json_api_auth = ! empty( $json_api_auth_environment );
@ -952,12 +1193,40 @@ class SSO {
)
);
$_request_redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$redirect_to = user_can( $user, 'edit_posts' ) ? admin_url() : self::profile_page_url();
// If we have a saved redirect to request in a cookie.
if ( ! empty( $_COOKIE['jetpack_sso_redirect_to'] ) ) {
// Set that as the requested redirect to.
$redirect_to = esc_url_raw( wp_unslash( $_COOKIE['jetpack_sso_redirect_to'] ) );
$_request_redirect_to = $redirect_to;
}
if ( $is_json_api_auth ) {
$authorize_json_api = new Authorize_Json_Api();
$authorize_json_api->verify_json_api_authorization_request( $json_api_auth_environment );
$authorize_json_api->store_json_api_authorization_token( $user->user_login, $user );
} elseif ( ! $is_user_connected ) {
$broker_auth_url = self::get_broker_auth_url();
if ( $broker_auth_url ) {
add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
wp_safe_redirect(
add_query_arg(
array(
'action' => 'jetpack-sso',
'site_id' => Manager::get_site_id( true ),
'redirect_to' => $redirect_to,
'request_redirect_to' => $_request_redirect_to,
'broker-sso-auth-redirect' => '1',
),
$broker_auth_url
)
);
exit( 0 );
}
wp_safe_redirect(
add_query_arg(
array(
@ -1086,10 +1355,30 @@ class SSO {
}
/**
* Build WordPress.com SSO URL with appropriate query parameters.
* Returns the base URL for SSO authentication.
*
* If a broker URL is available (authorized by WP.com and defined by the
* garden MU plugin), that URL is used unless the user navigated from a
* WordPress.com domain. Otherwise falls back to the default WordPress.com
* login URL.
*
* @return string The base SSO URL.
*/
public static function get_sso_base_url() {
$broker_url = self::get_broker_url();
if ( $broker_url && ! self::is_referrer_wpcom() ) {
return $broker_url;
}
return 'https://wordpress.com/wp-login.php';
}
/**
* Build SSO URL with appropriate query parameters.
*
* The base URL can be WordPress.com or an authorized broker URL.
*
* @param array $args Optional query parameters.
* @return string|WP_Error WordPress.com SSO URL
* @return string|WP_Error Redirect URL for SSO authentication.
*/
public function build_sso_url( $args = array() ) {
$sso_nonce = ! empty( $args['sso_nonce'] ) ? $args['sso_nonce'] : self::request_initial_nonce();
@ -1106,16 +1395,15 @@ class SSO {
return $sso_nonce;
}
return add_query_arg( $args, 'https://wordpress.com/wp-login.php' );
return add_query_arg( $args, self::get_sso_base_url() );
}
/**
* Build WordPress.com SSO URL with appropriate query parameters,
* including the parameters necessary to force the user to reauthenticate
* on WordPress.com.
* Build SSO URL with appropriate query parameters, including the
* parameters necessary to force the user to reauthenticate.
*
* @param array $args Optional query parameters.
* @return string|WP_Error WordPress.com SSO URL
* @return string|WP_Error Redirect URL for SSO authentication.
*/
public function build_reauth_and_sso_url( $args = array() ) {
$sso_nonce = ! empty( $args['sso_nonce'] ) ? $args['sso_nonce'] : self::request_initial_nonce();
@ -1145,7 +1433,7 @@ class SSO {
return $args['sso_nonce'];
}
return add_query_arg( $args, 'https://wordpress.com/wp-login.php' );
return add_query_arg( $args, self::get_sso_base_url() );
}
/**
@ -1188,7 +1476,7 @@ class SSO {
$redirect_after_auth = apply_filters( 'login_redirect', $redirect_to, $request_redirect_to, wp_get_current_user() );
/**
* Since we are passing this redirect to WordPress.com and therefore can not use wp_safe_redirect(),
* Since we are passing this redirect to WordPress.com and therefore cannot use wp_safe_redirect(),
* let's sanitize it here to make sure it's safe. If the redirect is not safe, then use admin_url().
*/
$redirect_after_auth = wp_sanitize_redirect( $redirect_after_auth );
@ -1273,4 +1561,19 @@ class SSO {
public function get_user_data( $user_id ) {
return get_user_meta( $user_id, 'wpcom_user_data', true );
}
/**
* Marks a session as two-factor-authenticated when SSO handled 2FA via WP.com.
*
* @param array $session Session information array.
* @param int $user_id User ID for the session being created.
* @return array Modified session information.
*/
public static function add_two_factor_session_meta( $session, $user_id ) {
if ( self::$sso_user_for_2fa && self::$sso_user_for_2fa->ID === $user_id ) {
$session['two-factor-login'] = time();
self::$sso_user_for_2fa = null;
}
return $session;
}
}

View File

@ -19,8 +19,14 @@ use WP_Error;
use WP_User;
use WP_User_Query;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Jetpack sso user admin class.
*
* @phan-constructor-used-for-side-effects
*/
class User_Admin extends Base_Admin {
/**
@ -789,7 +795,7 @@ class User_Admin extends Base_Admin {
type="checkbox"
id="user_external_contractor"
>
<?php esc_html_e( 'This user is a contractor, freelancer, consultant, or agency.', 'jetpack-connection' ); ?>
<?php esc_html_e( 'Mark as external collaborator', 'jetpack-connection' ); ?>
</label>
</fieldset>
</td>
@ -975,18 +981,6 @@ class User_Admin extends Base_Admin {
return $errors;
}
/**
* Deprecated method. Adds a column in the user admin table to display user connection status and actions.
*
* @param array $columns User list table columns.
* @return array
* @deprecated 6.5.0
*/
public function jetpack_user_connected_th( $columns ) {
_deprecated_function( __METHOD__, 'package-6.5.0' );
return $columns;
}
/**
* Executed when our WP_User_Query instance is set, and we don't have cached invites.
* This function uses the user emails and the 'are-users-invited' endpoint to build the cache.
@ -1079,8 +1073,12 @@ class User_Admin extends Base_Admin {
* @return false|string returns the user invite code if the user is invited, false otherwise.
*/
private static function has_pending_wpcom_invite( $user_id ) {
$user = get_user_by( 'id', $user_id );
if ( ! $user instanceof \WP_User ) {
return false;
}
$blog_id = Manager::get_site_id( true );
$user = get_user_by( 'id', $user_id );
$cached_invite = self::get_pending_cached_wpcom_invite( $user->user_email );
if ( $cached_invite ) {

View File

@ -10,7 +10,7 @@
width: 25em;
}
#createuser .form-field [type=checkbox] {
#createuser .form-field [type="checkbox"] {
width: 1rem;
}

View File

@ -1,4 +1,5 @@
#loginform {
/* We set !important because sometimes static is added inline */
position: relative !important;
padding-bottom: 92px;
@ -52,7 +53,7 @@
}
#loginform #jetpack-sso-wrap p {
color: #777777;
color: #777;
margin-bottom: 16px;
}
@ -77,8 +78,8 @@
}
.jetpack-sso-form-display #loginform>p,
.jetpack-sso-form-display #loginform>div {
.jetpack-sso-form-display #loginform > p,
.jetpack-sso-form-display #loginform > div {
display: none;
}
@ -96,9 +97,9 @@
text-align: center;
}
.jetpack-sso-or:before {
.jetpack-sso-or::before {
background: #dcdcde;
content: '';
content: "";
height: 1px;
position: absolute;
left: 0;
@ -111,7 +112,7 @@
color: #777;
position: relative;
padding: 0 8px;
text-transform: uppercase
text-transform: uppercase;
}
#jetpack-sso-wrap .button {
@ -142,7 +143,7 @@
}
#jetpack-sso-wrap__user h2 span {
font-weight: bold;
font-weight: 700;
}
.jetpack-sso-wrap__reauth {
@ -157,7 +158,7 @@
margin: 24px 0 0;
}
.jetpack-sso-clear:after {
.jetpack-sso-clear::after {
content: "";
display: table;
clear: both;

View File

@ -106,54 +106,18 @@ class Authorize_Redirect {
* @param bool|string $redirect URL to redirect to.
* @param bool|string $from If not false, adds 'from=$from' param to the connect URL.
* @param bool $raw If true, URL will not be escaped.
* @param string|null $provider The authentication provider (google, github, apple, link).
* @param array|null $provider_args Additional provider-specific arguments.
*
* @todo Update default value for redirect since the called function expects a string.
*
* @return mixed|void
*/
public function build_authorize_url( $redirect = false, $from = false, $raw = false, $provider = null, $provider_args = null ) {
public function build_authorize_url( $redirect = false, $from = false, $raw = 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, $from, $raw );
// If a provider is specified, modify the URL to use the provider-specific endpoint
if ( $provider && in_array( $provider, array( 'google', 'github', 'apple', 'link' ), true ) ) {
// Parse the URL to modify it safely
$url_parts = wp_parse_url( $url );
if ( ! empty( $url_parts['host'] ) && ! empty( $url_parts['path'] ) ) {
// Build the new URL using wordpress.com as the host
$url_parts['host'] = 'wordpress.com';
$url_parts['path'] = '/log-in/jetpack/' . $provider;
// Preserve the query parameters
$query_params = array();
if ( ! empty( $url_parts['query'] ) ) {
parse_str( $url_parts['query'], $query_params );
}
// Add magic link specific parameters if provider is 'link'
if ( 'link' === $provider && is_array( $provider_args ) && ! empty( $provider_args['email_address'] ) ) {
$query_params['email_address'] = $provider_args['email_address'];
// Add flag to trigger magic link flow
$query_params['auto_trigger'] = '1';
}
// URL encode all parameter values
$query_params = array_map( 'rawurlencode', $query_params );
// Rebuild the URL
$url = 'https://' . $url_parts['host'] . $url_parts['path'];
if ( ! empty( $query_params ) ) {
$url = add_query_arg( $query_params, $url );
}
}
}
remove_filter( 'jetpack_connect_request_body', array( __CLASS__, 'filter_connect_request_body' ) );
remove_filter( 'jetpack_connect_redirect_url', array( __CLASS__, 'filter_connect_redirect_url' ) );
@ -162,14 +126,11 @@ class Authorize_Redirect {
*
* @since jetpack-8.9.0
* @since 2.7.6 Added $raw parameter.
* @since 6.8.0 Added $provider and $provider_args parameters.
*
* @param string $url Connection URL.
* @param bool $raw If true, URL will not be escaped.
* @param string|null $provider The authentication provider if specified.
* @param array|null $provider_args Additional provider-specific arguments.
*/
return apply_filters( 'jetpack_build_authorize_url', $url, $raw, $provider, $provider_args );
return apply_filters( 'jetpack_build_authorize_url', $url, $raw );
}
/**