diff --git a/projects/packages/connection/changelog/add-json-api-direct-access b/projects/packages/connection/changelog/add-json-api-direct-access new file mode 100644 index 0000000000000..b36404becd080 --- /dev/null +++ b/projects/packages/connection/changelog/add-json-api-direct-access @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add the 'is_signed_with_user_token()' method for REST authentication. diff --git a/projects/packages/connection/src/class-rest-authentication.php b/projects/packages/connection/src/class-rest-authentication.php index 196a0991536ea..d9784cd2c7420 100644 --- a/projects/packages/connection/src/class-rest-authentication.php +++ b/projects/packages/connection/src/class-rest-authentication.php @@ -219,4 +219,17 @@ public static function is_signed_with_blog_token() { return true === $instance->rest_authentication_status && 'blog' === $instance->rest_authentication_type; } + + /** + * Whether the request was signed with a user token. + * + * @since $$next-version$$ + * + * @return bool True if the request was signed with a valid user token, false otherwise. + */ + public static function is_signed_with_user_token() { + $instance = self::init(); + + return true === $instance->rest_authentication_status && 'user' === $instance->rest_authentication_type; + } } diff --git a/projects/plugins/jetpack/.phan/baseline.php b/projects/plugins/jetpack/.phan/baseline.php index 60c2077e00526..2b0c79cf619fd 100644 --- a/projects/plugins/jetpack/.phan/baseline.php +++ b/projects/plugins/jetpack/.phan/baseline.php @@ -56,10 +56,10 @@ // PhanTypeMismatchArgumentInternalReal : 7 occurrences // PhanCommentAbstractOnInheritedMethod : 6 occurrences // PhanDeprecatedClass : 5 occurrences + // PhanImpossibleCondition : 5 occurrences // PhanNonClassMethodCall : 5 occurrences // PhanTypeMismatchDimAssignment : 5 occurrences // PhanAccessMethodInternal : 4 occurrences - // PhanImpossibleCondition : 4 occurrences // PhanTypeInvalidLeftOperandOfAdd : 4 occurrences // PhanTypeInvalidLeftOperandOfBitwiseOp : 4 occurrences // PhanTypeInvalidRightOperandOfBitwiseOp : 4 occurrences @@ -169,7 +169,7 @@ 'class.jetpack-post-images.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeMismatchArgument', 'PhanTypeMismatchDefault', 'PhanTypeMismatchReturnProbablyReal'], 'class.jetpack-twitter-cards.php' => ['PhanPluginSimplifyExpressionBool', 'PhanRedundantCondition', 'PhanTypeArraySuspiciousNullable', 'PhanTypeMismatchArgument', 'PhanTypePossiblyInvalidDimOffset'], 'class.jetpack.php' => ['PhanAccessMethodInternal', 'PhanDeprecatedFunction', 'PhanNoopNew', 'PhanPluginDuplicateConditionalNullCoalescing', 'PhanPossiblyUndeclaredVariable', 'PhanRedundantConditionInLoop', 'PhanTypeArraySuspiciousNullable', 'PhanTypeExpectedObjectPropAccess', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentNullableInternal', 'PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchDefault', 'PhanTypeMismatchPropertyDefault', 'PhanTypeMismatchReturn', 'PhanTypeMismatchReturnProbablyReal'], - 'class.json-api-endpoints.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanPluginSimplifyExpressionBool', 'PhanPossiblyUndeclaredVariable', 'PhanRedundantCondition', 'PhanTypeArraySuspiciousNullable', 'PhanTypeComparisonToArray', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentInternal', 'PhanTypeMismatchArgumentNullable', 'PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchReturnProbablyReal', 'PhanUndeclaredProperty'], + 'class.json-api-endpoints.php' => ['PhanImpossibleCondition', 'PhanPluginDuplicateConditionalNullCoalescing', 'PhanPluginSimplifyExpressionBool', 'PhanPossiblyUndeclaredVariable', 'PhanRedundantCondition', 'PhanTypeArraySuspiciousNullable', 'PhanTypeComparisonToArray', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentInternal', 'PhanTypeMismatchArgumentNullable', 'PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchReturnProbablyReal', 'PhanUndeclaredProperty'], 'class.json-api.php' => ['PhanPluginDuplicateSwitchCaseLooseEquality', 'PhanPluginSimplifyExpressionBool', 'PhanPossiblyUndeclaredVariable', 'PhanRedundantCondition', 'PhanTypeArraySuspicious', 'PhanTypeArraySuspiciousNullable', 'PhanTypeMismatchArgumentNullable', 'PhanTypeMismatchProperty', 'PhanTypeMismatchPropertyDefault', 'PhanTypeMismatchReturn', 'PhanTypeMismatchReturnProbablyReal'], 'enhanced-open-graph.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeArraySuspiciousNullable'], 'extensions/blocks/ai-chat/ai-chat.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeMismatchArgument'], diff --git a/projects/plugins/jetpack/changelog/add-json-api-direct-access b/projects/plugins/jetpack/changelog/add-json-api-direct-access new file mode 100644 index 0000000000000..95c8f04a09752 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-json-api-direct-access @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Add REST support for jsonAPI endpoints. diff --git a/projects/plugins/jetpack/class-jetpack-xmlrpc-methods.php b/projects/plugins/jetpack/class-jetpack-xmlrpc-methods.php index 02041eb7593f1..4dd2b1f07c45d 100644 --- a/projects/plugins/jetpack/class-jetpack-xmlrpc-methods.php +++ b/projects/plugins/jetpack/class-jetpack-xmlrpc-methods.php @@ -137,27 +137,6 @@ public static function json_api( $args = array() ) { } } - if ( 'en' !== $locale ) { - // .org mo files are named slightly different from .com, and all we have is this the locale -- try to guess them. - $new_locale = $locale; - if ( str_contains( $locale, '-' ) ) { - $locale_pieces = explode( '-', $locale ); - $new_locale = $locale_pieces[0]; - $new_locale .= ( ! empty( $locale_pieces[1] ) ) ? '_' . strtoupper( $locale_pieces[1] ) : ''; - } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found - // .com might pass 'fr' because thats what our language files are named as, where core seems - // to do fr_FR - so try that if we don't think we can load the file. - if ( ! file_exists( WP_LANG_DIR . '/' . $locale . '.mo' ) ) { - $new_locale = $locale . '_' . strtoupper( $locale ); - } - } - - if ( file_exists( WP_LANG_DIR . '/' . $new_locale . '.mo' ) ) { - unload_textdomain( 'default' ); - load_textdomain( 'default', WP_LANG_DIR . '/' . $new_locale . '.mo' ); - } - } - $old_user = wp_get_current_user(); wp_set_current_user( $user_id ); @@ -176,12 +155,12 @@ public static function json_api( $args = array() ) { define( 'REST_API_REQUEST', true ); define( 'WPCOM_JSON_API__BASE', 'public-api.wordpress.com/rest/v1' ); - // needed? - require_once ABSPATH . 'wp-admin/includes/admin.php'; - require_once JETPACK__PLUGIN_DIR . 'class.json-api.php'; $api = WPCOM_JSON_API::init( $method, $url, $post_body ); $api->token_details['user'] = $user_details; + + $api->init_locale( $locale ); + require_once JETPACK__PLUGIN_DIR . 'class.json-api-endpoints.php'; $display_errors = ini_set( 'display_errors', 0 ); // phpcs:ignore WordPress.PHP.IniSet diff --git a/projects/plugins/jetpack/class.jetpack.php b/projects/plugins/jetpack/class.jetpack.php index f4a5f489405f8..1df67ea6c792f 100644 --- a/projects/plugins/jetpack/class.jetpack.php +++ b/projects/plugins/jetpack/class.jetpack.php @@ -855,8 +855,11 @@ function ( $methods ) { if ( $is_connection_ready ) { require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.jetpack-iframe-embed.php'; add_action( 'init', array( 'Jetpack_Iframe_Embed', 'init' ), 9, 0 ); + require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.jetpack-keyring-service-helper.php'; add_action( 'init', array( 'Jetpack_Keyring_Service_Helper', 'init' ), 9, 0 ); + + add_action( 'rest_api_init', array( $this, 'maybe_initialize_rest_jsonapi' ) ); } } @@ -6119,6 +6122,21 @@ public function run_initialize_tracking_action() { do_action( 'jetpack_initialize_tracking' ); } + /** + * Initialize REST jsonAPI if needed. + * + * @return void + */ + public function maybe_initialize_rest_jsonapi() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! empty( $_GET['jsonapi'] ) && ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) ) { + require_once ABSPATH . 'wp-admin/includes/admin.php'; // JSON API relies on WP functionality not autoloaded in REST. + + define( 'WPCOM_JSON_API__BASE', 'public-api.wordpress.com/rest/v1' ); + require_once JETPACK__PLUGIN_DIR . 'class.json-api-endpoints.php'; + } + } + /** * Run plugin post-activation actions if we need to. * diff --git a/projects/plugins/jetpack/class.json-api-endpoints.php b/projects/plugins/jetpack/class.json-api-endpoints.php index 794bd049d6545..a6ecd90b02a21 100644 --- a/projects/plugins/jetpack/class.json-api-endpoints.php +++ b/projects/plugins/jetpack/class.json-api-endpoints.php @@ -6,6 +6,9 @@ */ use Automattic\Jetpack\Connection\Client; +use Automattic\Jetpack\Connection\Manager; +use Automattic\Jetpack\Connection\Rest_Authentication; +use Automattic\Jetpack\Connection\Tokens; use Automattic\Jetpack\Status; require_once __DIR__ . '/json-api-config.php'; @@ -124,6 +127,20 @@ abstract class WPCOM_JSON_API_Endpoint { */ public $path_labels = array(); + /** + * The REST endpoint if available. + * + * @var string + */ + public $rest_route; + + /** + * Jetpack Version in which REST support was introduced. + * + * @var string + */ + public $rest_min_jp_version; + /** * Accepted query parameters * @@ -277,6 +294,11 @@ abstract class WPCOM_JSON_API_Endpoint { */ public $allow_fallback_to_jetpack_blog_token = false; + /** + * REST namespace. + */ + const REST_NAMESPACE = 'jetpack/rest'; + /** * Constructor. * @@ -300,6 +322,8 @@ public function __construct( $args ) { 'new_version' => WPCOM_JSON_API__CURRENT_VERSION, 'jp_disabled' => false, 'path_labels' => array(), + 'rest_route' => null, + 'rest_min_jp_version' => null, 'request_format' => array(), 'response_format' => array(), 'query_parameters' => array(), @@ -339,6 +363,9 @@ public function __construct( $args ) { $this->deprecated = $args['deprecated']; $this->new_version = $args['new_version']; + $this->rest_route = $args['rest_route']; + $this->rest_min_jp_version = $args['rest_min_jp_version']; + // Ensure max version is not less than min version. if ( version_compare( $this->min_version, $this->max_version, '>' ) ) { $this->max_version = $this->min_version; @@ -387,6 +414,10 @@ public function __construct( $args ) { $this->example_response = $args['example_response']; $this->api->add( $this ); + + if ( ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) && $this->rest_route && ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) ) { + $this->create_rest_route_for_endpoint(); + } } /** @@ -2623,6 +2654,175 @@ public function get_amp_cache_origins( $siteurl ) { ); } + /** + * Register a REST route for this jsonAPI endpoint. + * + * @return void + * @throws Exception The exception if something goes wrong. + */ + public function create_rest_route_for_endpoint() { + register_rest_route( + static::REST_NAMESPACE, + $this->build_rest_route(), + array( + 'methods' => $this->method, + 'callback' => array( $this, 'rest_callback' ), + 'permission_callback' => array( $this, 'rest_permission_callback' ), + ) + ); + } + + /** + * Handle the rest call. + * + * @param WP_REST_Request $request The request object. + * + * @return mixed|WP_Error + */ + public function rest_callback( WP_REST_Request $request ) { + // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed -- Making sure random warnings don't break JSON. + ini_set( 'display_errors', false ); + + $blog_id = Jetpack_Options::get_option( 'id' ); + + $this->api->initialize(); + $this->api->endpoint = $this; + + $locale = $request->get_param( 'language' ); + if ( $locale ) { + $this->api->init_locale( $locale ); + } + + if ( $this->in_testing && ! WPCOM_JSON_API__DEBUG ) { + return new WP_Error( 'endpoint_not_available' ); + } + + $token_data = ( new Manager() )->verify_xml_rpc_signature(); + if ( ! $token_data || empty( $token_data['token_key'] ) || ! array_key_exists( 'user_id', $token_data ) ) { + return new WP_Error( 'response_signature_error' ); + } + + $token = ( new Tokens() )->get_access_token( $token_data['user_id'], $token_data['token_key'] ); + if ( is_wp_error( $token ) ) { + return $token; + } + if ( ! $token ) { + return new WP_Error( 'response_signature_error' ); + } + + /** This action is documented in class.json-api.php */ + do_action( 'wpcom_json_api_output', $this->stat ); + + $response = call_user_func_array( + array( $this, 'callback' ), + array_values( array( $this->path, $blog_id ) + $request->get_url_params() ) + ); + + if ( ! $response && ! is_array( $response ) ) { + // Dealing with empty non-array response. Phan is wrong about it being an "impossible condition". + $response = new WP_Error( 'empty_response', 'Endpoint response is empty', 500 ); + } + + $status_code = 200; + + if ( is_wp_error( $response ) ) { + $status_code = 500; + + if ( $response->get_error_data() && is_scalar( $response->get_error_data() ) + && (string) (int) $response->get_error_data() === (string) $response->get_error_data() + ) { + $status_code = (int) $response->get_error_data(); + } + + $response = WPCOM_JSON_API::serializable_error( $response ); + } + + if ( $request->get_param( 'http_envelope' ) ) { + $response = WPCOM_JSON_API::wrap_http_envelope( $status_code, $response, 'application/json' ); + } + + $response = wp_json_encode( $response ); + + $nonce = wp_generate_password( 10, false ); + $hmac = hash_hmac( 'sha1', $nonce . $response, $token->secret ); + + return array( + $response, + (string) $nonce, + (string) $hmac, + ); + } + + /** + * The REST endpoint should only be available for requests signed with a valid blog or user token. + * Declaring it "final" so individual endpoints couldn't remove this requirement. + * + * If you need to add custom permissions to individual endpoints, you can override method `rest_permission_callback_custom()`. + * + * @see self::rest_permission_callback_custom() + * + * @return true|WP_Error + */ + final public function rest_permission_callback() { + $manager = new Manager( 'jetpack' ); + if ( ! $manager->is_connected() ) { + return new WP_Error( 'site_not_connected' ); + } + + if ( ( $this->allow_jetpack_site_auth && Rest_Authentication::is_signed_with_blog_token() ) || ( get_current_user_id() && Rest_Authentication::is_signed_with_user_token() ) ) { + $custom_permission_result = $this->rest_permission_callback_custom(); + + // Successful custom permission check. + if ( $custom_permission_result === true ) { + return true; + } + + // Custom permission check errored, returning the error. + if ( is_wp_error( $custom_permission_result ) ) { + return $custom_permission_result; + } + + // Custom permission check failed, but didn't return a specific error. Proceed to returning the generic error. + } + + $message = esc_html__( + 'You do not have the correct user permissions to perform this action. Please contact your site admin if you think this is a mistake.', + 'jetpack' + ); + return new WP_Error( 'rest_api_invalid_permission', $message, array( 'status' => rest_authorization_required_code() ) ); + } + + /** + * You can override this method in individual endpoints to add custom permission checks. + * This will run on top of `rest_permission_callback()`. + * + * @see self::rest_permission_callback() + * + * @return true|WP_Error + */ + public function rest_permission_callback_custom() { + return true; + } + + /** + * Build the REST endpoint URL. + * + * @return string + */ + public function build_rest_route() { + $version_prefix = $this->max_version ? 'v' . $this->max_version : ''; + return $version_prefix . $this->rest_route; + } + + /** + * Get Jetpack Version where support for the endpoint was introduced. + * + * @return string + */ + public function get_rest_min_jp_version() { + return $this->rest_min_jp_version; + } + /** * Return endpoint response * diff --git a/projects/plugins/jetpack/class.json-api.php b/projects/plugins/jetpack/class.json-api.php index 6b9a805c6ea4a..85b0b78d32a16 100644 --- a/projects/plugins/jetpack/class.json-api.php +++ b/projects/plugins/jetpack/class.json-api.php @@ -693,25 +693,8 @@ public function output( $status_code, $response = null, $content_type = 'applica $response = $this->filter_fields( $response ); if ( isset( $this->query['http_envelope'] ) && self::is_truthy( $this->query['http_envelope'] ) ) { - $headers = array( - array( - 'name' => 'Content-Type', - 'value' => $content_type, - ), - ); - - foreach ( $extra as $key => $value ) { - $headers[] = array( - 'name' => $key, - 'value' => $value, - ); - } + $response = static::wrap_http_envelope( $status_code, $response, $content_type, $extra ); - $response = array( - 'code' => (int) $status_code, - 'headers' => $headers, - 'body' => $response, - ); $status_code = 200; $content_type = 'application/json'; } @@ -743,6 +726,40 @@ public function output( $status_code, $response = null, $content_type = 'applica return $content_type; } + /** + * Wrap JSON API response into an HTTP 200 one. + * + * @param int $status_code HTTP status code. + * @param mixed $response Response body. + * @param string $content_type Content type. + * @param array|null $extra Extra data. + * + * @return array + */ + public static function wrap_http_envelope( $status_code, $response, $content_type, $extra = null ) { + $headers = array( + array( + 'name' => 'Content-Type', + 'value' => $content_type, + ), + ); + + if ( is_array( $extra ) ) { + foreach ( $extra as $key => $value ) { + $headers[] = array( + 'name' => $key, + 'value' => $value, + ); + } + } + + return array( + 'code' => (int) $status_code, + 'headers' => $headers, + 'body' => $response, + ); + } + /** * Serialize an error. * @@ -1271,4 +1288,32 @@ public function finish_request() { return fastcgi_finish_request(); } } + + /** + * Initialize the locale if different from 'en'. + * + * @param string $locale The locale to initialize. + */ + public function init_locale( $locale ) { + if ( 'en' !== $locale ) { + // .org mo files are named slightly different from .com, and all we have is this the locale -- try to guess them. + $new_locale = $locale; + if ( str_contains( $locale, '-' ) ) { + $locale_pieces = explode( '-', $locale ); + $new_locale = $locale_pieces[0]; + $new_locale .= ( ! empty( $locale_pieces[1] ) ) ? '_' . strtoupper( $locale_pieces[1] ) : ''; + } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found + // .com might pass 'fr' because thats what our language files are named as, where core seems + // to do fr_FR - so try that if we don't think we can load the file. + if ( ! file_exists( WP_LANG_DIR . '/' . $locale . '.mo' ) ) { + $new_locale = $locale . '_' . strtoupper( $locale ); + } + } + + if ( file_exists( WP_LANG_DIR . '/' . $new_locale . '.mo' ) ) { + unload_textdomain( 'default' ); + load_textdomain( 'default', WP_LANG_DIR . '/' . $new_locale . '.mo' ); + } + } + } } diff --git a/projects/plugins/jetpack/json-endpoints/jetpack/class.jetpack-json-api-plugins-list-endpoint.php b/projects/plugins/jetpack/json-endpoints/jetpack/class.jetpack-json-api-plugins-list-endpoint.php index 99b7c37f6d72a..59f8e4f110b7d 100644 --- a/projects/plugins/jetpack/json-endpoints/jetpack/class.jetpack-json-api-plugins-list-endpoint.php +++ b/projects/plugins/jetpack/json-endpoints/jetpack/class.jetpack-json-api-plugins-list-endpoint.php @@ -5,6 +5,8 @@ 'description' => 'Get installed Plugins on your blog', 'method' => 'GET', 'path' => '/sites/%s/plugins', + 'rest_route' => '/plugins', + 'rest_min_jp_version' => '14.0-a.7', 'stat' => 'plugins', 'min_version' => '1', 'max_version' => '1.1',