wp/wp-includes/rest-api/class-wp-rest-server.php
changeset 16 a86126ab1dd4
parent 9 177826044cd9
child 18 be944660c56a
--- a/wp/wp-includes/rest-api/class-wp-rest-server.php	Tue Oct 22 16:11:46 2019 +0200
+++ b/wp/wp-includes/rest-api/class-wp-rest-server.php	Tue Dec 15 13:49:49 2020 +0100
@@ -79,6 +79,14 @@
 	protected $route_options = array();
 
 	/**
+	 * Caches embedded requests.
+	 *
+	 * @since 5.4.0
+	 * @var array
+	 */
+	protected $embed_cache = array();
+
+	/**
 	 * Instantiates the REST server.
 	 *
 	 * @since 4.4.0
@@ -129,8 +137,8 @@
 		 *
 		 * @since 4.4.0
 		 *
-		 * @param WP_Error|null|bool WP_Error if authentication error, null if authentication
-		 *                              method wasn't used, true if authentication succeeded.
+		 * @param WP_Error|null|true $errors WP_Error if authentication error, null if authentication
+		 *                                   method wasn't used, true if authentication succeeded.
 		 */
 		return apply_filters( 'rest_authentication_errors', null );
 	}
@@ -217,7 +225,7 @@
 	 *
 	 * @param string $path Optional. The request route. If not set, `$_SERVER['PATH_INFO']` will be used.
 	 *                     Default null.
-	 * @return false|null Null if not served and a HEAD request, false otherwise.
+	 * @return null|false Null if not served and a HEAD request, false otherwise.
 	 */
 	public function serve_request( $path = null ) {
 		$content_type = isset( $_GET['_jsonp'] ) ? 'application/javascript' : 'application/json';
@@ -235,8 +243,42 @@
 		 * https://miki.it/blog/2014/7/8/abusing-jsonp-with-rosetta-flash/
 		 */
 		$this->send_header( 'X-Content-Type-Options', 'nosniff' );
-		$this->send_header( 'Access-Control-Expose-Headers', 'X-WP-Total, X-WP-TotalPages' );
-		$this->send_header( 'Access-Control-Allow-Headers', 'Authorization, Content-Type' );
+		$expose_headers = array( 'X-WP-Total', 'X-WP-TotalPages', 'Link' );
+
+		/**
+		 * Filters the list of response headers that are exposed to CORS requests.
+		 *
+		 * @since 5.5.0
+		 *
+		 * @param string[] $expose_headers The list of headers to expose.
+		 */
+		$expose_headers = apply_filters( 'rest_exposed_cors_headers', $expose_headers );
+
+		$this->send_header( 'Access-Control-Expose-Headers', implode( ', ', $expose_headers ) );
+
+		$allow_headers = array(
+			'Authorization',
+			'X-WP-Nonce',
+			'Content-Disposition',
+			'Content-MD5',
+			'Content-Type',
+		);
+
+		/**
+		 * Filters the list of request headers that are allowed for CORS requests.
+		 *
+		 * The allowed headers are passed to the browser to specify which
+		 * headers can be passed to the REST API. By default, we allow the
+		 * Content-* headers needed to upload files to the media endpoints.
+		 * As well as the Authorization and Nonce headers for allowing authentication.
+		 *
+		 * @since 5.5.0
+		 *
+		 * @param string[] $allow_headers The list of headers to allow.
+		 */
+		$allow_headers = apply_filters( 'rest_allowed_cors_headers', $allow_headers );
+
+		$this->send_header( 'Access-Control-Allow-Headers', implode( ', ', $allow_headers ) );
 
 		/**
 		 * Send nocache headers on authenticated requests.
@@ -260,7 +302,8 @@
 		 * Filters whether the REST API is enabled.
 		 *
 		 * @since 4.4.0
-		 * @deprecated 4.7.0 Use the rest_authentication_errors filter to restrict access to the API
+		 * @deprecated 4.7.0 Use the {@see 'rest_authentication_errors'} filter to
+		 *                   restrict access to the API.
 		 *
 		 * @param bool $rest_enabled Whether the REST API is enabled. Default true.
 		 */
@@ -269,7 +312,11 @@
 			array( true ),
 			'4.7.0',
 			'rest_authentication_errors',
-			__( 'The REST API can no longer be completely disabled, the rest_authentication_errors filter can be used to restrict access to the API, instead.' )
+			sprintf(
+				/* translators: %s: rest_authentication_errors */
+				__( 'The REST API can no longer be completely disabled, the %s filter can be used to restrict access to the API, instead.' ),
+				'rest_authentication_errors'
+			)
 		);
 
 		/**
@@ -310,7 +357,7 @@
 		$request->set_body_params( wp_unslash( $_POST ) );
 		$request->set_file_params( $_FILES );
 		$request->set_headers( $this->get_headers( wp_unslash( $_SERVER ) ) );
-		$request->set_body( $this->get_raw_data() );
+		$request->set_body( self::get_raw_data() );
 
 		/*
 		 * HTTP method override for clients that can't use PUT/PATCH/DELETE. First, we check
@@ -385,7 +432,8 @@
 			}
 
 			// Embed links inside the request.
-			$result = $this->response_to_data( $result, isset( $_GET['_embed'] ) );
+			$embed  = isset( $_GET['_embed'] ) ? rest_parse_embed_param( $_GET['_embed'] ) : false;
+			$result = $this->response_to_data( $result, $embed );
 
 			/**
 			 * Filters the API response.
@@ -401,13 +449,24 @@
 			 */
 			$result = apply_filters( 'rest_pre_echo_response', $result, $this, $request );
 
+			// The 204 response shouldn't have a body.
+			if ( 204 === $code || null === $result ) {
+				return null;
+			}
+
 			$result = wp_json_encode( $result );
 
 			$json_error_message = $this->get_json_last_error();
+
 			if ( $json_error_message ) {
-				$json_error_obj = new WP_Error( 'rest_encode_error', $json_error_message, array( 'status' => 500 ) );
-				$result         = $this->error_to_response( $json_error_obj );
-				$result         = wp_json_encode( $result->data[0] );
+				$json_error_obj = new WP_Error(
+					'rest_encode_error',
+					$json_error_message,
+					array( 'status' => 500 )
+				);
+
+				$result = $this->error_to_response( $json_error_obj );
+				$result = wp_json_encode( $result->data[0] );
 			}
 
 			if ( $jsonp_callback ) {
@@ -418,6 +477,7 @@
 				echo $result;
 			}
 		}
+
 		return null;
 	}
 
@@ -425,31 +485,37 @@
 	 * Converts a response to data to send.
 	 *
 	 * @since 4.4.0
+	 * @since 5.4.0 The $embed parameter can now contain a list of link relations to include.
 	 *
 	 * @param WP_REST_Response $response Response object.
-	 * @param bool             $embed    Whether links should be embedded.
+	 * @param bool|string[]    $embed    Whether to embed all links, a filtered list of link relations, or no links.
 	 * @return array {
 	 *     Data with sub-requests embedded.
 	 *
-	 *     @type array [$_links]    Links.
-	 *     @type array [$_embedded] Embeddeds.
+	 *     @type array $_links    Links.
+	 *     @type array $_embedded Embeddeds.
 	 * }
 	 */
 	public function response_to_data( $response, $embed ) {
 		$data  = $response->get_data();
-		$links = $this->get_compact_response_links( $response );
+		$links = self::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 ) ) {
-				$data = array_map( array( $this, 'embed_links' ), $data );
+				foreach ( $data as $key => $item ) {
+					$data[ $key ] = $this->embed_links( $item, $embed );
+				}
 			} else {
-				$data = $this->embed_links( $data );
+				$data = $this->embed_links( $data, $embed );
 			}
+			$this->embed_cache = array();
 		}
 
 		return $data;
@@ -468,6 +534,7 @@
 	 */
 	public static function get_response_links( $response ) {
 		$links = $response->get_links();
+
 		if ( empty( $links ) ) {
 			return array();
 		}
@@ -542,16 +609,18 @@
 	 * Embeds the links from the data into the request.
 	 *
 	 * @since 4.4.0
+	 * @since 5.4.0 The $embed parameter can now contain a list of link relations to include.
 	 *
-	 * @param array $data Data from the request.
+	 * @param array         $data  Data from the request.
+	 * @param bool|string[] $embed Whether to embed all links or a filtered list of link relations.
 	 * @return array {
 	 *     Data with sub-requests embedded.
 	 *
-	 *     @type array [$_links]    Links.
-	 *     @type array [$_embedded] Embeddeds.
+	 *     @type array $_links    Links.
+	 *     @type array $_embedded Embeddeds.
 	 * }
 	 */
-	protected function embed_links( $data ) {
+	protected function embed_links( $data, $embed = true ) {
 		if ( empty( $data['_links'] ) ) {
 			return $data;
 		}
@@ -559,8 +628,9 @@
 		$embedded = array();
 
 		foreach ( $data['_links'] as $rel => $links ) {
-			// Ignore links to self, for obvious reasons.
-			if ( 'self' === $rel ) {
+			// If a list of relations was specified, and the link relation
+			// is not in the list of allowed relations, don't process the link.
+			if ( is_array( $embed ) && ! in_array( $rel, $embed, true ) ) {
 				continue;
 			}
 
@@ -574,24 +644,28 @@
 					continue;
 				}
 
-				// Run through our internal routing and serve.
-				$request = WP_REST_Request::from_url( $item['href'] );
-				if ( ! $request ) {
-					$embeds[] = array();
-					continue;
+				if ( ! array_key_exists( $item['href'], $this->embed_cache ) ) {
+					// Run through our internal routing and serve.
+					$request = WP_REST_Request::from_url( $item['href'] );
+					if ( ! $request ) {
+						$embeds[] = array();
+						continue;
+					}
+
+					// Embedded resources get passed context=embed.
+					if ( empty( $request['context'] ) ) {
+						$request['context'] = 'embed';
+					}
+
+					$response = $this->dispatch( $request );
+
+					/** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
+					$response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this, $request );
+
+					$this->embed_cache[ $item['href'] ] = $this->response_to_data( $response, false );
 				}
 
-				// Embedded resources get passed context=embed.
-				if ( empty( $request['context'] ) ) {
-					$request['context'] = 'embed';
-				}
-
-				$response = $this->dispatch( $request );
-
-				/** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
-				$response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this, $request );
-
-				$embeds[] = $this->response_to_data( $response, false );
+				$embeds[] = $this->embed_cache[ $item['href'] ];
 			}
 
 			// Determine if any real links were found.
@@ -705,11 +779,18 @@
 	 * used as the delimiter with preg_match()
 	 *
 	 * @since 4.4.0
+	 * @since 5.4.0 Add $namespace parameter.
 	 *
+	 * @param string $namespace Optionally, only return routes in the given namespace.
 	 * @return array `'/path/regex' => array( $callback, $bitmask )` or
 	 *               `'/path/regex' => array( array( $callback, $bitmask ), ...)`.
 	 */
-	public function get_routes() {
+	public function get_routes( $namespace = '' ) {
+		$endpoints = $this->endpoints;
+
+		if ( $namespace ) {
+			$endpoints = wp_list_filter( $endpoints, array( 'namespace' => $namespace ) );
+		}
 
 		/**
 		 * Filters the array of available endpoints.
@@ -721,7 +802,7 @@
 		 *                         `'/path/regex' => array( $callback, $bitmask )` or
 		 *                         `'/path/regex' => array( array( $callback, $bitmask ).
 		 */
-		$endpoints = apply_filters( 'rest_endpoints', $this->endpoints );
+		$endpoints = apply_filters( 'rest_endpoints', $endpoints );
 
 		// Normalise the endpoints.
 		$defaults = array(
@@ -780,7 +861,7 @@
 	 *
 	 * @since 4.4.0
 	 *
-	 * @return array List of registered namespaces.
+	 * @return string[] List of registered namespaces.
 	 */
 	public function get_namespaces() {
 		return array_keys( $this->namespaces );
@@ -833,7 +914,21 @@
 		$method = $request->get_method();
 		$path   = $request->get_route();
 
-		foreach ( $this->get_routes() as $route => $handlers ) {
+		$with_namespace = array();
+
+		foreach ( $this->get_namespaces() as $namespace ) {
+			if ( 0 === strpos( trailingslashit( ltrim( $path, '/' ) ), $namespace ) ) {
+				$with_namespace[] = $this->get_routes( $namespace );
+			}
+		}
+
+		if ( $with_namespace ) {
+			$routes = array_merge( ...$with_namespace );
+		} else {
+			$routes = $this->get_routes();
+		}
+
+		foreach ( $routes as $route => $handlers ) {
 			$match = preg_match( '@^' . $route . '$@i', $path, $matches );
 
 			if ( ! $match ) {
@@ -841,6 +936,7 @@
 			}
 
 			$args = array();
+
 			foreach ( $matches as $param => $value ) {
 				if ( ! is_int( $param ) ) {
 					$args[ $param ] = $value;
@@ -861,7 +957,11 @@
 				}
 
 				if ( ! is_callable( $callback ) ) {
-					$response = new WP_Error( 'rest_invalid_handler', __( 'The handler for the route is invalid' ), array( 'status' => 500 ) );
+					$response = new WP_Error(
+						'rest_invalid_handler',
+						__( 'The handler for the route is invalid' ),
+						array( 'status' => 500 )
+					);
 				}
 
 				if ( ! is_wp_error( $response ) ) {
@@ -904,9 +1004,9 @@
 				 *
 				 * @since 4.7.0
 				 *
-				 * @param WP_HTTP_Response|WP_Error $response Result to send to the client. Usually a WP_REST_Response or WP_Error.
-				 * @param array                     $handler  Route handler used for the request.
-				 * @param WP_REST_Request           $request  Request used to generate the response.
+				 * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client. Usually a WP_REST_Response or WP_Error.
+				 * @param array                                            $handler  Route handler used for the request.
+				 * @param WP_REST_Request                                  $request  Request used to generate the response.
 				 */
 				$response = apply_filters( 'rest_request_before_callbacks', $response, $handler, $request );
 
@@ -918,7 +1018,11 @@
 						if ( is_wp_error( $permission ) ) {
 							$response = $permission;
 						} elseif ( false === $permission || null === $permission ) {
-							$response = new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to do that.' ), array( 'status' => rest_authorization_required_code() ) );
+							$response = new WP_Error(
+								'rest_forbidden',
+								__( 'Sorry, you are not allowed to do that.' ),
+								array( 'status' => rest_authorization_required_code() )
+							);
 						}
 					}
 				}
@@ -963,9 +1067,9 @@
 				 *
 				 * @since 4.7.0
 				 *
-				 * @param WP_HTTP_Response|WP_Error $response Result to send to the client. Usually a WP_REST_Response or WP_Error.
-				 * @param array                     $handler  Route handler used for the request.
-				 * @param WP_REST_Request           $request  Request used to generate the response.
+				 * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client. Usually a WP_REST_Response or WP_Error.
+				 * @param array                                            $handler  Route handler used for the request.
+				 * @param WP_REST_Request                                  $request  Request used to generate the response.
 				 */
 				$response = apply_filters( 'rest_request_after_callbacks', $response, $handler, $request );
 
@@ -982,7 +1086,13 @@
 			}
 		}
 
-		return $this->error_to_response( new WP_Error( 'rest_no_route', __( 'No route was found matching the URL and request method' ), array( 'status' => 404 ) ) );
+		return $this->error_to_response(
+			new WP_Error(
+				'rest_no_route',
+				__( 'No route was found matching the URL and request method' ),
+				array( 'status' => 404 )
+			)
+		);
 	}
 
 	/**
@@ -996,14 +1106,9 @@
 	 * @return bool|string Boolean false or string error message.
 	 */
 	protected function get_json_last_error() {
-		// See https://core.trac.wordpress.org/ticket/27799.
-		if ( ! function_exists( 'json_last_error' ) ) {
-			return false;
-		}
-
 		$last_error_code = json_last_error();
 
-		if ( ( defined( 'JSON_ERROR_NONE' ) && JSON_ERROR_NONE === $last_error_code ) || empty( $last_error_code ) ) {
+		if ( JSON_ERROR_NONE === $last_error_code || empty( $last_error_code ) ) {
 			return false;
 		}
 
@@ -1022,7 +1127,7 @@
 	 *
 	 *     @type string $context Context.
 	 * }
-	 * @return array Index entity
+	 * @return WP_REST_Response The API root index data.
 	 */
 	public function get_index( $request ) {
 		// General site data.
@@ -1069,7 +1174,11 @@
 		$namespace = $request['namespace'];
 
 		if ( ! isset( $this->namespaces[ $namespace ] ) ) {
-			return new WP_Error( 'rest_invalid_namespace', __( 'The specified namespace could not be found.' ), array( 'status' => 404 ) );
+			return new WP_Error(
+				'rest_invalid_namespace',
+				__( 'The specified namespace could not be found.' ),
+				array( 'status' => 404 )
+			);
 		}
 
 		$routes    = $this->namespaces[ $namespace ];
@@ -1105,7 +1214,7 @@
 	 *
 	 * @param array  $routes  Routes to get data for.
 	 * @param string $context Optional. Context for data. Accepts 'view' or 'help'. Default 'view'.
-	 * @return array Route data to expose in indexes.
+	 * @return array[] Route data to expose in indexes, keyed by route.
 	 */
 	public function get_data_for_routes( $routes, $context = 'view' ) {
 		$available = array();
@@ -1136,8 +1245,8 @@
 		 *
 		 * @since 4.4.0
 		 *
-		 * @param array $available Map of route to route data.
-		 * @param array $routes    Internal route data as an associative array.
+		 * @param array[] $available Route data to expose in indexes, keyed by route.
+		 * @param array   $routes    Internal route data as an associative array.
 		 */
 		return apply_filters( 'rest_route_data', $available, $routes );
 	}
@@ -1186,6 +1295,7 @@
 
 			if ( isset( $callback['args'] ) ) {
 				$endpoint_data['args'] = array();
+
 				foreach ( $callback['args'] as $key => $opts ) {
 					$arg_data = array(
 						'required' => ! empty( $opts['required'] ),
@@ -1214,7 +1324,11 @@
 			// For non-variable routes, generate links.
 			if ( strpos( $route, '{' ) === false ) {
 				$data['_links'] = array(
-					'self' => rest_url( $route ),
+					'self' => array(
+						array(
+							'href' => rest_url( $route ),
+						),
+					),
 				);
 			}
 		}
@@ -1279,19 +1393,7 @@
 	 * @param string $key Header key.
 	 */
 	public function remove_header( $key ) {
-		if ( function_exists( 'header_remove' ) ) {
-			// In PHP 5.3+ there is a way to remove an already set header.
-			header_remove( $key );
-		} else {
-			// In PHP 5.2, send an empty header, but only as a last resort to
-			// override a header already sent.
-			foreach ( headers_list() as $header ) {
-				if ( 0 === stripos( $header, "$key:" ) ) {
-					$this->send_header( $key, '' );
-					break;
-				}
-			}
-		}
+		header_remove( $key );
 	}
 
 	/**
@@ -1304,17 +1406,16 @@
 	 * @return string Raw request data.
 	 */
 	public static function get_raw_data() {
+		// phpcs:disable PHPCompatibility.Variables.RemovedPredefinedGlobalVariables.http_raw_post_dataDeprecatedRemoved
 		global $HTTP_RAW_POST_DATA;
 
-		/*
-		 * A bug in PHP < 5.2.2 makes $HTTP_RAW_POST_DATA not set by default,
-		 * but we can do it ourself.
-		 */
+		// $HTTP_RAW_POST_DATA was deprecated in PHP 5.6 and removed in PHP 7.0.
 		if ( ! isset( $HTTP_RAW_POST_DATA ) ) {
 			$HTTP_RAW_POST_DATA = file_get_contents( 'php://input' );
 		}
 
 		return $HTTP_RAW_POST_DATA;
+		// phpcs:enable
 	}
 
 	/**
@@ -1338,6 +1439,12 @@
 		foreach ( $server as $key => $value ) {
 			if ( strpos( $key, 'HTTP_' ) === 0 ) {
 				$headers[ substr( $key, 5 ) ] = $value;
+			} elseif ( 'REDIRECT_HTTP_AUTHORIZATION' === $key && empty( $server['HTTP_AUTHORIZATION'] ) ) {
+				/*
+				 * In some server configurations, the authorization header is passed in this alternate location.
+				 * Since it would not be passed in in both places we do not check for both headers and resolve.
+				 */
+				$headers['AUTHORIZATION'] = $value;
 			} elseif ( isset( $additional[ $key ] ) ) {
 				$headers[ $key ] = $value;
 			}