Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Connection: Add REST support for jsonAPI endpoints #39432

Open
wants to merge 41 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
3a60d13
Connection: REST access for jsonAPI endpoints.
sergeymitr Sep 18, 2024
9b85565
Make endpoints provide their own REST route.
sergeymitr Sep 18, 2024
36368f0
Fix routes for name and slug requests.
sergeymitr Sep 18, 2024
61b021e
Merge branch 'trunk' into add/json-api-direct-access
sergeymitr Sep 23, 2024
8bfbadb
Add blog/user token verification.
sergeymitr Sep 23, 2024
6357e97
Add the version prefix to the endpoints.
sergeymitr Sep 23, 2024
9b5e5ae
Authenticate WP user if user token is provided, fix a few smaller aut…
sergeymitr Sep 24, 2024
20974a0
Merge branch 'trunk' into add/json-api-direct-access
sergeymitr Sep 25, 2024
52fe990
Fix a phpdoc typo.
sergeymitr Sep 25, 2024
549e826
Activate REST for the '/plugins' endpoint.
sergeymitr Sep 25, 2024
a3cc273
Improve REST authentication.
sergeymitr Sep 26, 2024
0d757d3
Merge branch 'trunk' into add/json-api-direct-access
sergeymitr Oct 11, 2024
4aaa656
Improve custom permission checking.
sergeymitr Oct 11, 2024
40eec5f
Add missing phpdoc.
sergeymitr Oct 11, 2024
b4836df
Merge branch 'trunk' into add/json-api-direct-access
sergeymitr Oct 16, 2024
ab97174
Implement response signature.
sergeymitr Oct 17, 2024
9ba813f
Merge branch 'trunk' into add/json-api-direct-access
sergeymitr Oct 24, 2024
bb92f7a
Make endpoints provide minimum the Jetpack version.
sergeymitr Oct 25, 2024
3db7335
Revert Post endpoints changes, we'll focus on the Plugins one for now.
sergeymitr Oct 25, 2024
9b3202f
Don't create REST route on WPCOM.
sergeymitr Oct 30, 2024
2e1ae27
Remove excessive REST authentication call.
sergeymitr Oct 30, 2024
8abca0d
Remove excessive admin.php loading.
sergeymitr Oct 31, 2024
e97dd90
Remove excessive fallback to blog token.
sergeymitr Nov 4, 2024
79fdce8
Improve comments for permission check callbacks.
sergeymitr Nov 4, 2024
22e01d7
Copy some code from XML-RPC's 'serve'.
sergeymitr Nov 7, 2024
01a8fbc
Handle locale.
sergeymitr Nov 13, 2024
a0ec6ce
Merge branch 'trunk' into add/json-api-direct-access
sergeymitr Nov 14, 2024
541cbe3
Merge branch 'trunk' into add/json-api-direct-access
sergeymitr Nov 19, 2024
8013673
Properly wrap REST JSON API errors.
sergeymitr Nov 19, 2024
b2b467a
Properly wrap REST response.
sergeymitr Nov 20, 2024
b9faed5
Merge branch 'trunk' into add/json-api-direct-access
sergeymitr Nov 21, 2024
eb68e2c
Fix phan error.
sergeymitr Nov 21, 2024
d2a9835
Merge branch 'trunk' into add/json-api-direct-access
sergeymitr Dec 3, 2024
bbb226a
Fix the empty output response.
sergeymitr Dec 3, 2024
debee21
Revert accidental debugging code commit.
sergeymitr Dec 3, 2024
95e1279
Merge branch 'trunk' into add/json-api-direct-access
sergeymitr Dec 10, 2024
2b2ff8c
Proper response wrapping and error handling.
sergeymitr Dec 13, 2024
a4806b7
Merge branch 'trunk' into add/json-api-direct-access
sergeymitr Dec 13, 2024
9d6f78a
Revert changes to the 'output()' method.
sergeymitr Dec 13, 2024
9739eaa
Mix some reverting issues.
sergeymitr Dec 13, 2024
4850259
Fix a type mismatch.
sergeymitr Dec 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add the 'is_signed_with_user_token()' method for REST authentication.
13 changes: 13 additions & 0 deletions projects/packages/connection/src/class-rest-authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
4 changes: 2 additions & 2 deletions projects/plugins/jetpack/.phan/baseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -171,7 +171,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'],
Expand Down
4 changes: 4 additions & 0 deletions projects/plugins/jetpack/changelog/add-json-api-direct-access
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: other

Add REST support for jsonAPI endpoints.
27 changes: 3 additions & 24 deletions projects/plugins/jetpack/class-jetpack-xmlrpc-methods.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );

Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions projects/plugins/jetpack/class.jetpack.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );
}
}

Expand Down Expand Up @@ -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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking that maybe it would make sense to shuffle things a bit so that we make this method consistent with json_api here.

require_once JETPACK__PLUGIN_DIR . 'class.json-api.php';
WPCOM_JSON_API::init( $method, $url, $post_body ); // And we register the routes via the api

// 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.
fgiannar marked this conversation as resolved.
Show resolved Hide resolved

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.
*
Expand Down
198 changes: 198 additions & 0 deletions projects/plugins/jetpack/class.json-api-endpoints.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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.
*
Expand All @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should never happen on WPCOM :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, it feels like this logic would be better suited to be part of the api, aka WPCOM_JSON_API vs the endpoint definitions. Is there a reason you chose to add it here?

Copy link
Contributor Author

@sergeymitr sergeymitr Oct 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I added the "not WPCOM" condition: 9b3202f

Is there a reason you chose to add it here?

The REST endpoint belongs to the endpoint object, so that seems logical to me to let the endpoint object initialize it.
That way the whole logic is encapsulated within a single class, simplifying the code.

}
}

/**
Expand Down Expand Up @@ -2618,6 +2649,173 @@ 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 = wp_json_encode( WPCOM_JSON_API::wrap_http_envelope( $status_code, $response, 'application/json' ) );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we need to json encode the response independently of the http_envelope param. Otherwise, we get a PHP Warning locally: PHP Warning: Array to string conversion in class.json-api-endpoints.php on line 2740 and a Fatal on WPCOM: Fatal error: Uncaught Error: Object of class stdClass could not be converted to string

}

$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() {
fgiannar marked this conversation as resolved.
Show resolved Hide resolved
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;
fgiannar marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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
*
Expand Down
Loading
Loading