From 6be14bbaa73c2d6a9620747d8f46fd9f0b49d714 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Tue, 27 Aug 2024 08:17:46 +0400 Subject: [PATCH] Core Data: Resolve entity collection user permissions (#64504) Co-authored-by: Mamaduka Co-authored-by: TimothyBJacobs Co-authored-by: swissspidy Co-authored-by: youknowriad Co-authored-by: tyxla Co-authored-by: spacedmonkey --- backport-changelog/6.7/7139.md | 3 + .../class-gutenberg-rest-server.php | 169 ++++++++++++++++++ lib/compat/wordpress-6.7/rest-api.php | 10 ++ lib/load.php | 1 + packages/core-data/src/resolvers.js | 50 +++++- packages/core-data/src/test/resolvers.js | 1 + packages/core-data/src/utils/index.js | 2 +- .../core-data/src/utils/user-permissions.js | 10 +- phpunit/class-gutenberg-rest-server-test.php | 88 +++++++++ 9 files changed, 322 insertions(+), 12 deletions(-) create mode 100644 backport-changelog/6.7/7139.md create mode 100644 lib/compat/wordpress-6.7/class-gutenberg-rest-server.php create mode 100644 phpunit/class-gutenberg-rest-server-test.php diff --git a/backport-changelog/6.7/7139.md b/backport-changelog/6.7/7139.md new file mode 100644 index 0000000000000..9023695102a91 --- /dev/null +++ b/backport-changelog/6.7/7139.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7139 + +* https://github.com/WordPress/gutenberg/pull/64504 diff --git a/lib/compat/wordpress-6.7/class-gutenberg-rest-server.php b/lib/compat/wordpress-6.7/class-gutenberg-rest-server.php new file mode 100644 index 0000000000000..8374e8dc1fa23 --- /dev/null +++ b/lib/compat/wordpress-6.7/class-gutenberg-rest-server.php @@ -0,0 +1,169 @@ +get_data(); + $links = static::get_compact_response_links( $response ); + + if ( ! empty( $links ) ) { + // Convert links to part of the data. + $data['_links'] = $links; + } + + if ( $embed ) { + $this->embed_cache = array(); + // Determine if this is a numeric array. + if ( wp_is_numeric_array( $data ) ) { + foreach ( $data as $key => $item ) { + $data[ $key ] = $this->embed_links( $item, $embed ); + } + } else { + $data = $this->embed_links( $data, $embed ); + } + $this->embed_cache = array(); + } + + return $data; + } + + /** + * Retrieves links from a response. + * + * Extracts the links from a response into a structured hash, suitable for + * direct output. + * + * @since 4.4.0 + * @since 6.7.0 The `targetHints` property to the `self` link object was added. + * + * @param WP_REST_Response $response Response to extract links from. + * @return array Map of link relation to list of link hashes. + */ + public static function get_response_links( $response ) { + $links = $response->get_links(); + + if ( empty( $links ) ) { + return array(); + } + + $server = rest_get_server(); + + // Convert links to part of the data. + $data = array(); + foreach ( $links as $rel => $items ) { + $data[ $rel ] = array(); + + foreach ( $items as $item ) { + $attributes = $item['attributes']; + $attributes['href'] = $item['href']; + + if ( 'self' !== $rel ) { + $data[ $rel ][] = $attributes; + continue; + } + + // Prefer targetHints that were specifically designated by the developer. + if ( isset( $attributes['targetHints']['allow'] ) ) { + $data[ $rel ][] = $attributes; + continue; + } + + $request = WP_REST_Request::from_url( $item['href'] ); + if ( ! $request ) { + $data[ $rel ][] = $attributes; + continue; + } + + $match = $server->match_request_to_handler( $request ); + if ( ! is_wp_error( $match ) ) { + $response = new WP_REST_Response(); + $response->set_matched_route( $match[0] ); + $response->set_matched_handler( $match[1] ); + $headers = rest_send_allow_header( $response, $server, $request )->get_headers(); + + foreach ( $headers as $name => $value ) { + $name = WP_REST_Request::canonicalize_header_name( $name ); + $attributes['targetHints'][ $name ] = array_map( 'trim', explode( ',', $value ) ); + } + } + + $data[ $rel ][] = $attributes; + } + } + + return $data; + } + + /** + * Retrieves the CURIEs (compact URIs) used for relations. + * + * Extracts the links from a response into a structured hash, suitable for + * direct output. + * + * @since 4.5.0 + * + * @param WP_REST_Response $response Response to extract links from. + * @return array Map of link relation to list of link hashes. + */ + // @core-merge: Do not merge. The method is copied here to fix the inheritance issue. + public static function get_compact_response_links( $response ) { + $links = static::get_response_links( $response ); + + if ( empty( $links ) ) { + return array(); + } + + $curies = $response->get_curies(); + $used_curies = array(); + + foreach ( $links as $rel => $items ) { + + // Convert $rel URIs to their compact versions if they exist. + foreach ( $curies as $curie ) { + $href_prefix = substr( $curie['href'], 0, strpos( $curie['href'], '{rel}' ) ); + if ( ! str_starts_with( $rel, $href_prefix ) ) { + continue; + } + + // Relation now changes from '$uri' to '$curie:$relation'. + $rel_regex = str_replace( '\{rel\}', '(.+)', preg_quote( $curie['href'], '!' ) ); + preg_match( '!' . $rel_regex . '!', $rel, $matches ); + if ( $matches ) { + $new_rel = $curie['name'] . ':' . $matches[1]; + $used_curies[ $curie['name'] ] = $curie; + $links[ $new_rel ] = $items; + unset( $links[ $rel ] ); + break; + } + } + } + + // Push the curies onto the start of the links array. + if ( $used_curies ) { + $links['curies'] = array_values( $used_curies ); + } + + return $links; + } +} diff --git a/lib/compat/wordpress-6.7/rest-api.php b/lib/compat/wordpress-6.7/rest-api.php index 081c22c810291..f2f5d72104527 100644 --- a/lib/compat/wordpress-6.7/rest-api.php +++ b/lib/compat/wordpress-6.7/rest-api.php @@ -98,3 +98,13 @@ function gutenberg_register_wp_rest_templates_controller_plugin_field() { ); } add_action( 'rest_api_init', 'gutenberg_register_wp_rest_templates_controller_plugin_field' ); + +/** + * Overrides the default 'WP_REST_Server' class. + * + * @return string The name of the custom server class. + */ +function gutenberg_override_default_rest_server() { + return 'Gutenberg_REST_Server'; +} +add_filter( 'wp_rest_server_class', 'gutenberg_override_default_rest_server', 1 ); diff --git a/lib/load.php b/lib/load.php index b501f0abd1c97..2f2c168a5fc29 100644 --- a/lib/load.php +++ b/lib/load.php @@ -42,6 +42,7 @@ function gutenberg_is_experiment_enabled( $name ) { // WordPress 6.7 compat. require __DIR__ . '/compat/wordpress-6.7/class-gutenberg-rest-templates-controller-6-7.php'; + require __DIR__ . '/compat/wordpress-6.7/class-gutenberg-rest-server.php'; require __DIR__ . '/compat/wordpress-6.7/rest-api.php'; // Plugin specific code. diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index c28f018508e38..2cec1997c0efd 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -19,7 +19,7 @@ import { forwardResolver, getNormalizedCommaSeparable, getUserPermissionCacheKey, - getUserPermissionsFromResponse, + getUserPermissionsFromAllowHeader, ALLOWED_RESOURCE_ACTIONS, } from './utils'; import { getSyncProvider } from './sync'; @@ -173,7 +173,9 @@ export const getEntityRecord = const response = await apiFetch( { path, parse: false } ); const record = await response.json(); - const permissions = getUserPermissionsFromResponse( response ); + const permissions = getUserPermissionsFromAllowHeader( + response.headers?.get( 'allow' ) + ); registry.batch( () => { dispatch.receiveEntityRecords( kind, name, record, query ); @@ -299,19 +301,52 @@ export const getEntityRecords = meta ); - // When requesting all fields, the list of results can be used to - // resolve the `getEntityRecord` selector in addition to `getEntityRecords`. + // When requesting all fields, the list of results can be used to resolve + // the `getEntityRecord` and `canUser` selectors in addition to `getEntityRecords`. // See https://github.com/WordPress/gutenberg/pull/26575 + // See https://github.com/WordPress/gutenberg/pull/64504 if ( ! query?._fields && ! query.context ) { const key = entityConfig.key || DEFAULT_ENTITY_KEY; const resolutionsArgs = records .filter( ( record ) => record?.[ key ] ) .map( ( record ) => [ kind, name, record[ key ] ] ); + const targetHints = records + .filter( ( record ) => record?.[ key ] ) + .map( ( record ) => ( { + id: record[ key ], + permissions: getUserPermissionsFromAllowHeader( + record?._links?.self?.[ 0 ].targetHints.allow + ), + } ) ); + + const canUserResolutionsArgs = []; + for ( const targetHint of targetHints ) { + for ( const action of ALLOWED_RESOURCE_ACTIONS ) { + canUserResolutionsArgs.push( [ + action, + { kind, name, id: targetHint.id }, + ] ); + + dispatch.receiveUserPermission( + getUserPermissionCacheKey( action, { + kind, + name, + id: targetHint.id, + } ), + targetHint.permissions[ action ] + ); + } + } + dispatch.finishResolutions( 'getEntityRecord', resolutionsArgs ); + dispatch.finishResolutions( + 'canUser', + canUserResolutionsArgs + ); } dispatch.__unstableReleaseStoreLock( lock ); @@ -440,7 +475,12 @@ export const canUser = return; } - const permissions = getUserPermissionsFromResponse( response ); + // Optional chaining operator is used here because the API requests don't + // return the expected result in the React native version. Instead, API requests + // only return the result, without including response properties like the headers. + const permissions = getUserPermissionsFromAllowHeader( + response.headers?.get( 'allow' ) + ); registry.batch( () => { for ( const action of ALLOWED_RESOURCE_ACTIONS ) { const key = getUserPermissionCacheKey( action, resource, id ); diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index fa0d53d79aa7d..240d6b1e1a29c 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -219,6 +219,7 @@ describe( 'getEntityRecords', () => { const finishResolutions = jest.fn(); const dispatch = Object.assign( jest.fn(), { receiveEntityRecords: jest.fn(), + receiveUserPermission: jest.fn(), __unstableAcquireStoreLock: jest.fn(), __unstableReleaseStoreLock: jest.fn(), finishResolutions, diff --git a/packages/core-data/src/utils/index.js b/packages/core-data/src/utils/index.js index bb4b554443502..189635647779e 100644 --- a/packages/core-data/src/utils/index.js +++ b/packages/core-data/src/utils/index.js @@ -11,6 +11,6 @@ export { default as getNestedValue } from './get-nested-value'; export { default as isNumericID } from './is-numeric-id'; export { getUserPermissionCacheKey, - getUserPermissionsFromResponse, + getUserPermissionsFromAllowHeader, ALLOWED_RESOURCE_ACTIONS, } from './user-permissions'; diff --git a/packages/core-data/src/utils/user-permissions.js b/packages/core-data/src/utils/user-permissions.js index a81c83f9e5af5..f1f02b5f5ba70 100644 --- a/packages/core-data/src/utils/user-permissions.js +++ b/packages/core-data/src/utils/user-permissions.js @@ -5,13 +5,11 @@ export const ALLOWED_RESOURCE_ACTIONS = [ 'delete', ]; -export function getUserPermissionsFromResponse( response ) { +export function getUserPermissionsFromAllowHeader( allowedMethods ) { const permissions = {}; - - // Optional chaining operator is used here because the API requests don't - // return the expected result in the React native version. Instead, API requests - // only return the result, without including response properties like the headers. - const allowedMethods = response.headers?.get( 'allow' ) || ''; + if ( ! allowedMethods ) { + return permissions; + } const methods = { create: 'POST', diff --git a/phpunit/class-gutenberg-rest-server-test.php b/phpunit/class-gutenberg-rest-server-test.php new file mode 100644 index 0000000000000..943d5b34b999e --- /dev/null +++ b/phpunit/class-gutenberg-rest-server-test.php @@ -0,0 +1,88 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + + self::$post_id = $factory->post->create(); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + wp_delete_post( self::$post_id ); + } + + public function set_up() { + parent::set_up(); + add_filter( + 'wp_rest_server_class', + function () { + return 'Gutenberg_REST_Server'; + } + ); + } + + public function test_populates_target_hints_for_administrator() { + wp_set_current_user( self::$admin_id ); + $response = rest_do_request( '/wp/v2/posts' ); + $post = $response->get_data()[0]; + + $link = $post['_links']['self'][0]; + $this->assertArrayHasKey( 'targetHints', $link ); + $this->assertArrayHasKey( 'allow', $link['targetHints'] ); + $this->assertSame( array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ), $link['targetHints']['allow'] ); + } + + public function test_populates_target_hints_for_logged_out_user() { + $response = rest_do_request( '/wp/v2/posts' ); + $post = $response->get_data()[0]; + + $link = $post['_links']['self'][0]; + $this->assertArrayHasKey( 'targetHints', $link ); + $this->assertArrayHasKey( 'allow', $link['targetHints'] ); + $this->assertSame( array( 'GET' ), $link['targetHints']['allow'] ); + } + + public function test_does_not_error_on_invalid_urls() { + $response = new WP_REST_Response(); + $response->add_link( 'self', 'this is not a real URL' ); + + $links = rest_get_server()::get_response_links( $response ); + $this->assertArrayNotHasKey( 'targetHints', $links['self'][0] ); + } + + public function test_does_not_error_on_bad_rest_api_routes() { + $response = new WP_REST_Response(); + $response->add_link( 'self', rest_url( '/this/is/not/a/real/route' ) ); + + $links = rest_get_server()::get_response_links( $response ); + $this->assertArrayNotHasKey( 'targetHints', $links['self'][0] ); + } + + public function test_prefers_developer_defined_target_hints() { + $response = new WP_REST_Response(); + $response->add_link( + 'self', + '/wp/v2/posts/' . self::$post_id, + array( + 'targetHints' => array( + 'allow' => array( 'GET', 'PUT' ), + ), + ) + ); + + $links = rest_get_server()::get_response_links( $response ); + $link = $links['self'][0]; + $this->assertArrayHasKey( 'targetHints', $link ); + $this->assertArrayHasKey( 'allow', $link['targetHints'] ); + $this->assertSame( array( 'GET', 'PUT' ), $link['targetHints']['allow'] ); + } +}