diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 4b49ebfdcb..68f13c2ac1 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -696,6 +696,24 @@ function rest_send_allow_header( $response, $server, $request ) { return $response; } +/** + * Recursively computes the intersection of arrays using keys for comparison. + * + * @param array $array1 The array with master keys to check. + * @param array $array2 An array to compare keys against. + * + * @return array An associative array containing all the entries of array1 which have keys that are present in all arguments. + */ +function _rest_array_intersect_key_recursive( $array1, $array2 ) { + $array1 = array_intersect_key( $array1, $array2 ); + foreach ( $array1 as $key => $value ) { + if ( is_array( $value ) && is_array( $array2[ $key ] ) ) { + $array1[ $key ] = _rest_array_intersect_key_recursive( $value, $array2[ $key ] ); + } + } + return $array1; +} + /** * Filter the API response to include only a white-listed set of response object fields. * @@ -723,15 +741,27 @@ function rest_filter_response_fields( $response, $server, $request ) { // Trim off outside whitespace from the comma delimited list. $fields = array_map( 'trim', $fields ); - $fields_as_keyed = array_combine( $fields, array_fill( 0, count( $fields ), true ) ); + // Create nested array of accepted field hierarchy. + $fields_as_keyed = array(); + foreach ( $fields as $field ) { + $parts = explode( '.', $field ); + $ref = &$fields_as_keyed; + while ( count( $parts ) > 1 ) { + $next = array_shift( $parts ); + $ref[ $next ] = array(); + $ref = &$ref[ $next ]; + } + $last = array_shift( $parts ); + $ref[ $last ] = true; + } if ( wp_is_numeric_array( $data ) ) { $new_data = array(); foreach ( $data as $item ) { - $new_data[] = array_intersect_key( $item, $fields_as_keyed ); + $new_data[] = _rest_array_intersect_key_recursive( $item, $fields_as_keyed ); } } else { - $new_data = array_intersect_key( $data, $fields_as_keyed ); + $new_data = _rest_array_intersect_key_recursive( $data, $fields_as_keyed ); } $response->set_data( $new_data ); @@ -739,6 +769,41 @@ function rest_filter_response_fields( $response, $server, $request ) { return $response; } +/** + * Given an array of fields to include in a response, some of which may be + * `nested.fields`, determine whether the provided field should be included + * in the response body. + * + * If a parent field is passed in, the presence of any nested field within + * that parent will cause the method to return `true`. For example "title" + * will return true if any of `title`, `title.raw` or `title.rendered` is + * provided. + * + * @since 5.3.0 + * + * @param string $field A field to test for inclusion in the response body. + * @param array $fields An array of string fields supported by the endpoint. + * @return bool Whether to include the field or not. + */ +function rest_is_field_included( $field, $fields ) { + if ( in_array( $field, $fields, true ) ) { + return true; + } + foreach ( $fields as $accepted_field ) { + // Check to see if $field is the parent of any item in $fields. + // A field "parent" should be accepted if "parent.child" is accepted. + if ( strpos( $accepted_field, "$field." ) === 0 ) { + return true; + } + // Conversely, if "parent" is accepted, all "parent.child" fields should + // also be accepted. + if ( strpos( $field, "$accepted_field." ) === 0 ) { + return true; + } + } + return false; +} + /** * Adds the REST API URL to the WP RSD endpoint. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php index 7adc5ed616..1bc38124e9 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php @@ -562,7 +562,25 @@ abstract class WP_REST_Controller { if ( in_array( 'id', $fields, true ) ) { $requested_fields[] = 'id'; } - return array_intersect( $fields, $requested_fields ); + // Return the list of all requested fields which appear in the schema. + return array_reduce( + $requested_fields, + function( $response_fields, $field ) use ( $fields ) { + if ( in_array( $field, $fields, true ) ) { + $response_fields[] = $field; + return $response_fields; + } + // Check for nested fields if $field is not a direct match. + $nested_fields = explode( '.', $field ); + // A nested field is included so long as its top-level property is + // present in the schema. + if ( in_array( $nested_fields[0], $fields, true ) ) { + $response_fields[] = $field; + } + return $response_fields; + }, + array() + ); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index e53ff4b910..883e9b31fe 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -1439,15 +1439,15 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { // Base fields for every post. $data = array(); - if ( in_array( 'id', $fields, true ) ) { + if ( rest_is_field_included( 'id', $fields ) ) { $data['id'] = $post->ID; } - if ( in_array( 'date', $fields, true ) ) { + if ( rest_is_field_included( 'date', $fields ) ) { $data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date ); } - if ( in_array( 'date_gmt', $fields, true ) ) { + if ( rest_is_field_included( 'date_gmt', $fields ) ) { // For drafts, `post_date_gmt` may not be set, indicating that the // date of the draft should be updated each time it is saved (see // #38883). In this case, shim the value based on the `post_date` @@ -1460,7 +1460,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { $data['date_gmt'] = $this->prepare_date_response( $post_date_gmt ); } - if ( in_array( 'guid', $fields, true ) ) { + if ( rest_is_field_included( 'guid', $fields ) ) { $data['guid'] = array( /** This filter is documented in wp-includes/post-template.php */ 'rendered' => apply_filters( 'get_the_guid', $post->guid, $post->ID ), @@ -1468,11 +1468,11 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { ); } - if ( in_array( 'modified', $fields, true ) ) { + if ( rest_is_field_included( 'modified', $fields ) ) { $data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified ); } - if ( in_array( 'modified_gmt', $fields, true ) ) { + if ( rest_is_field_included( 'modified_gmt', $fields ) ) { // For drafts, `post_modified_gmt` may not be set (see // `post_date_gmt` comments above). In this case, shim the value // based on the `post_modified` field with the site's timezone @@ -1485,33 +1485,36 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { $data['modified_gmt'] = $this->prepare_date_response( $post_modified_gmt ); } - if ( in_array( 'password', $fields, true ) ) { + if ( rest_is_field_included( 'password', $fields ) ) { $data['password'] = $post->post_password; } - if ( in_array( 'slug', $fields, true ) ) { + if ( rest_is_field_included( 'slug', $fields ) ) { $data['slug'] = $post->post_name; } - if ( in_array( 'status', $fields, true ) ) { + if ( rest_is_field_included( 'status', $fields ) ) { $data['status'] = $post->post_status; } - if ( in_array( 'type', $fields, true ) ) { + if ( rest_is_field_included( 'type', $fields ) ) { $data['type'] = $post->post_type; } - if ( in_array( 'link', $fields, true ) ) { + if ( rest_is_field_included( 'link', $fields ) ) { $data['link'] = get_permalink( $post->ID ); } - if ( in_array( 'title', $fields, true ) ) { + if ( rest_is_field_included( 'title', $fields ) ) { + $data['title'] = array(); + } + if ( rest_is_field_included( 'title.raw', $fields ) ) { + $data['title']['raw'] = $post->post_title; + } + if ( rest_is_field_included( 'title.rendered', $fields ) ) { add_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); - $data['title'] = array( - 'raw' => $post->post_title, - 'rendered' => get_the_title( $post->ID ), - ); + $data['title']['rendered'] = get_the_title( $post->ID ); remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); } @@ -1525,17 +1528,24 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { $has_password_filter = true; } - if ( in_array( 'content', $fields, true ) ) { - $data['content'] = array( - 'raw' => $post->post_content, - /** This filter is documented in wp-includes/post-template.php */ - 'rendered' => post_password_required( $post ) ? '' : apply_filters( 'the_content', $post->post_content ), - 'protected' => (bool) $post->post_password, - 'block_version' => block_version( $post->post_content ), - ); + if ( rest_is_field_included( 'content', $fields ) ) { + $data['content'] = array(); + } + if ( rest_is_field_included( 'content.raw', $fields ) ) { + $data['content']['raw'] = $post->post_content; + } + if ( rest_is_field_included( 'content.rendered', $fields ) ) { + /** This filter is documented in wp-includes/post-template.php */ + $data['content']['rendered'] = post_password_required( $post ) ? '' : apply_filters( 'the_content', $post->post_content ); + } + if ( rest_is_field_included( 'content.protected', $fields ) ) { + $data['content']['protected'] = (bool) $post->post_password; + } + if ( rest_is_field_included( 'content.block_version', $fields ) ) { + $data['content']['block_version'] = block_version( $post->post_content ); } - if ( in_array( 'excerpt', $fields, true ) ) { + if ( rest_is_field_included( 'excerpt', $fields ) ) { /** This filter is documented in wp-includes/post-template.php */ $excerpt = apply_filters( 'the_excerpt', apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ) ); $data['excerpt'] = array( @@ -1550,35 +1560,35 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { remove_filter( 'post_password_required', '__return_false' ); } - if ( in_array( 'author', $fields, true ) ) { + if ( rest_is_field_included( 'author', $fields ) ) { $data['author'] = (int) $post->post_author; } - if ( in_array( 'featured_media', $fields, true ) ) { + if ( rest_is_field_included( 'featured_media', $fields ) ) { $data['featured_media'] = (int) get_post_thumbnail_id( $post->ID ); } - if ( in_array( 'parent', $fields, true ) ) { + if ( rest_is_field_included( 'parent', $fields ) ) { $data['parent'] = (int) $post->post_parent; } - if ( in_array( 'menu_order', $fields, true ) ) { + if ( rest_is_field_included( 'menu_order', $fields ) ) { $data['menu_order'] = (int) $post->menu_order; } - if ( in_array( 'comment_status', $fields, true ) ) { + if ( rest_is_field_included( 'comment_status', $fields ) ) { $data['comment_status'] = $post->comment_status; } - if ( in_array( 'ping_status', $fields, true ) ) { + if ( rest_is_field_included( 'ping_status', $fields ) ) { $data['ping_status'] = $post->ping_status; } - if ( in_array( 'sticky', $fields, true ) ) { + if ( rest_is_field_included( 'sticky', $fields ) ) { $data['sticky'] = is_sticky( $post->ID ); } - if ( in_array( 'template', $fields, true ) ) { + if ( rest_is_field_included( 'template', $fields ) ) { $template = get_page_template_slug( $post->ID ); if ( $template ) { $data['template'] = $template; @@ -1587,7 +1597,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { } } - if ( in_array( 'format', $fields, true ) ) { + if ( rest_is_field_included( 'format', $fields ) ) { $data['format'] = get_post_format( $post->ID ); // Fill in blank post format. @@ -1596,7 +1606,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { } } - if ( in_array( 'meta', $fields, true ) ) { + if ( rest_is_field_included( 'meta', $fields ) ) { $data['meta'] = $this->meta->get_value( $post->ID, $request ); } @@ -1605,7 +1615,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { foreach ( $taxonomies as $taxonomy ) { $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; - if ( in_array( $base, $fields, true ) ) { + if ( rest_is_field_included( $base, $fields ) ) { $terms = get_the_terms( $post, $taxonomy->name ); $data[ $base ] = $terms ? array_values( wp_list_pluck( $terms, 'term_id' ) ) : array(); } @@ -1613,8 +1623,8 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { $post_type_obj = get_post_type_object( $post->post_type ); if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) { - $permalink_template_requested = in_array( 'permalink_template', $fields, true ); - $generated_slug_requested = in_array( 'generated_slug', $fields, true ); + $permalink_template_requested = rest_is_field_included( 'permalink_template', $fields ); + $generated_slug_requested = rest_is_field_included( 'generated_slug', $fields ); if ( $permalink_template_requested || $generated_slug_requested ) { if ( ! function_exists( 'get_sample_permalink' ) ) { diff --git a/tests/phpunit/tests/rest-api.php b/tests/phpunit/tests/rest-api.php index c81efd67b6..512aa61b6d 100644 --- a/tests/phpunit/tests/rest-api.php +++ b/tests/phpunit/tests/rest-api.php @@ -519,6 +519,71 @@ class Tests_REST_API extends WP_UnitTestCase { ); } + /** + * Ensure that nested fields may be whitelisted with request['_fields']. + * + * @ticket 42094 + */ + public function test_rest_filter_response_fields_nested_field_filter() { + $response = new WP_REST_Response(); + + $response->set_data( + array( + 'a' => 0, + 'b' => array( + '1' => 1, + '2' => 2, + ), + 'c' => 3, + 'd' => array( + '4' => 4, + '5' => 5, + ), + ) + ); + $request = array( + '_fields' => 'b.1,c,d.5', + ); + + $response = rest_filter_response_fields( $response, null, $request ); + $this->assertEquals( + array( + 'b' => array( + '1' => 1, + ), + 'c' => 3, + 'd' => array( + '5' => 5, + ), + ), + $response->get_data() + ); + } + + /** + * @ticket 42094 + */ + public function test_rest_is_field_included() { + $fields = array( + 'id', + 'title', + 'content.raw', + 'custom.property', + ); + + $this->assertTrue( rest_is_field_included( 'id', $fields ) ); + $this->assertTrue( rest_is_field_included( 'title', $fields ) ); + $this->assertTrue( rest_is_field_included( 'title.raw', $fields ) ); + $this->assertTrue( rest_is_field_included( 'title.rendered', $fields ) ); + $this->assertTrue( rest_is_field_included( 'content', $fields ) ); + $this->assertTrue( rest_is_field_included( 'content.raw', $fields ) ); + $this->assertTrue( rest_is_field_included( 'custom.property', $fields ) ); + $this->assertFalse( rest_is_field_included( 'content.rendered', $fields ) ); + $this->assertFalse( rest_is_field_included( 'type', $fields ) ); + $this->assertFalse( rest_is_field_included( 'meta', $fields ) ); + $this->assertFalse( rest_is_field_included( 'meta.value', $fields ) ); + } + /** * The get_rest_url function should return a URL consistently terminated with a "/", * whether the blog is configured with pretty permalink support or not. diff --git a/tests/phpunit/tests/rest-api/rest-controller.php b/tests/phpunit/tests/rest-api/rest-controller.php index f130abc62d..91e1d0c5b7 100644 --- a/tests/phpunit/tests/rest-api/rest-controller.php +++ b/tests/phpunit/tests/rest-api/rest-controller.php @@ -232,7 +232,7 @@ class WP_Test_REST_Controller extends WP_Test_REST_TestCase { public function data_get_fields_for_response() { return array( array( - 'somestring,someinteger', + 'somestring,someinteger,someinvalidkey', array( 'somestring', 'someinteger', diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index ba174c448e..95e8ee11c2 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -1689,6 +1689,76 @@ class WP_Test_REST_Posts_Controller extends WP_Test_REST_Post_Type_Controller_Te ); } + /** + * @ticket 42094 + */ + public function test_prepare_item_filters_content_when_needed() { + $filter_count = 0; + $filter_content = function() use ( &$filter_count ) { + $filter_count++; + return '

Filtered content.

'; + }; + add_filter( 'the_content', $filter_content ); + + wp_set_current_user( self::$editor_id ); + $endpoint = new WP_REST_Posts_Controller( 'post' ); + $request = new WP_REST_REQUEST( 'GET', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + + $request->set_param( 'context', 'edit' ); + $request->set_param( '_fields', 'content.rendered' ); + + $post = get_post( self::$post_id ); + $response = $endpoint->prepare_item_for_response( $post, $request ); + + remove_filter( 'the_content', $filter_content ); + + $this->assertEquals( + array( + 'id' => self::$post_id, + 'content' => array( + 'rendered' => '

Filtered content.

', + ), + ), + $response->get_data() + ); + $this->assertSame( 1, $filter_count ); + } + + /** + * @ticket 42094 + */ + public function test_prepare_item_skips_content_filter_if_not_needed() { + $filter_count = 0; + $filter_content = function() use ( &$filter_count ) { + $filter_count++; + return '

Filtered content.

'; + }; + add_filter( 'the_content', $filter_content ); + + wp_set_current_user( self::$editor_id ); + $endpoint = new WP_REST_Posts_Controller( 'post' ); + $request = new WP_REST_REQUEST( 'GET', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + + $request->set_param( 'context', 'edit' ); + $request->set_param( '_fields', 'content.raw' ); + + $post = get_post( self::$post_id ); + $response = $endpoint->prepare_item_for_response( $post, $request ); + + remove_filter( 'the_content', $filter_content ); + + $this->assertEquals( + array( + 'id' => $post->ID, + 'content' => array( + 'raw' => $post->post_content, + ), + ), + $response->get_data() + ); + $this->assertSame( 0, $filter_count ); + } + public function test_create_item() { wp_set_current_user( self::$editor_id );