summaryrefslogtreecommitdiffstats
path: root/wp-includes/rest-api
diff options
context:
space:
mode:
Diffstat (limited to 'wp-includes/rest-api')
-rw-r--r--wp-includes/rest-api/class-wp-rest-request.php1063
-rw-r--r--wp-includes/rest-api/class-wp-rest-response.php293
-rw-r--r--wp-includes/rest-api/class-wp-rest-server.php1877
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php848
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php1455
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php497
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-block-directory-controller.php328
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-block-pattern-categories-controller.php162
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-block-patterns-controller.php298
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-block-renderer-controller.php224
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-block-types-controller.php792
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-blocks-controller.php100
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php1915
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-controller.php681
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-edit-site-export-controller.php94
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php708
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-global-styles-revisions-controller.php474
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-menu-items-controller.php1022
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-menu-locations-controller.php304
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php577
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-navigation-fallback-controller.php191
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php410
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php1004
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-post-statuses-controller.php373
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-post-types-controller.php430
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php3173
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php857
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-search-controller.php408
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php342
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-sidebars-controller.php509
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-site-health-controller.php407
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-taxonomies-controller.php452
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php276
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-template-revisions-controller.php297
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php999
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php1211
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-themes-controller.php667
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php668
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php1614
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-widget-types-controller.php674
-rw-r--r--wp-includes/rest-api/endpoints/class-wp-rest-widgets-controller.php876
-rw-r--r--wp-includes/rest-api/fields/class-wp-rest-comment-meta-fields.php51
-rw-r--r--wp-includes/rest-api/fields/class-wp-rest-meta-fields.php625
-rw-r--r--wp-includes/rest-api/fields/class-wp-rest-post-meta-fields.php72
-rw-r--r--wp-includes/rest-api/fields/class-wp-rest-term-meta-fields.php70
-rw-r--r--wp-includes/rest-api/fields/class-wp-rest-user-meta-fields.php51
-rw-r--r--wp-includes/rest-api/search/class-wp-rest-post-format-search-handler.php128
-rw-r--r--wp-includes/rest-api/search/class-wp-rest-post-search-handler.php205
-rw-r--r--wp-includes/rest-api/search/class-wp-rest-search-handler.php100
-rw-r--r--wp-includes/rest-api/search/class-wp-rest-term-search-handler.php169
50 files changed, 31021 insertions, 0 deletions
diff --git a/wp-includes/rest-api/class-wp-rest-request.php b/wp-includes/rest-api/class-wp-rest-request.php
new file mode 100644
index 0000000..c671fd3
--- /dev/null
+++ b/wp-includes/rest-api/class-wp-rest-request.php
@@ -0,0 +1,1063 @@
+<?php
+/**
+ * REST API: WP_REST_Request class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.4.0
+ */
+
+/**
+ * Core class used to implement a REST request object.
+ *
+ * Contains data from the request, to be passed to the callback.
+ *
+ * Note: This implements ArrayAccess, and acts as an array of parameters when
+ * used in that manner. It does not use ArrayObject (as we cannot rely on SPL),
+ * so be aware it may have non-array behavior in some cases.
+ *
+ * Note: When using features provided by ArrayAccess, be aware that WordPress deliberately
+ * does not distinguish between arguments of the same name for different request methods.
+ * For instance, in a request with `GET id=1` and `POST id=2`, `$request['id']` will equal
+ * 2 (`POST`) not 1 (`GET`). For more precision between request methods, use
+ * WP_REST_Request::get_body_params(), WP_REST_Request::get_url_params(), etc.
+ *
+ * @since 4.4.0
+ *
+ * @link https://www.php.net/manual/en/class.arrayaccess.php
+ */
+#[AllowDynamicProperties]
+class WP_REST_Request implements ArrayAccess {
+
+ /**
+ * HTTP method.
+ *
+ * @since 4.4.0
+ * @var string
+ */
+ protected $method = '';
+
+ /**
+ * Parameters passed to the request.
+ *
+ * These typically come from the `$_GET`, `$_POST` and `$_FILES`
+ * superglobals when being created from the global scope.
+ *
+ * @since 4.4.0
+ * @var array Contains GET, POST and FILES keys mapping to arrays of data.
+ */
+ protected $params;
+
+ /**
+ * HTTP headers for the request.
+ *
+ * @since 4.4.0
+ * @var array Map of key to value. Key is always lowercase, as per HTTP specification.
+ */
+ protected $headers = array();
+
+ /**
+ * Body data.
+ *
+ * @since 4.4.0
+ * @var string Binary data from the request.
+ */
+ protected $body = null;
+
+ /**
+ * Route matched for the request.
+ *
+ * @since 4.4.0
+ * @var string
+ */
+ protected $route;
+
+ /**
+ * Attributes (options) for the route that was matched.
+ *
+ * This is the options array used when the route was registered, typically
+ * containing the callback as well as the valid methods for the route.
+ *
+ * @since 4.4.0
+ * @var array Attributes for the request.
+ */
+ protected $attributes = array();
+
+ /**
+ * Used to determine if the JSON data has been parsed yet.
+ *
+ * Allows lazy-parsing of JSON data where possible.
+ *
+ * @since 4.4.0
+ * @var bool
+ */
+ protected $parsed_json = false;
+
+ /**
+ * Used to determine if the body data has been parsed yet.
+ *
+ * @since 4.4.0
+ * @var bool
+ */
+ protected $parsed_body = false;
+
+ /**
+ * Constructor.
+ *
+ * @since 4.4.0
+ *
+ * @param string $method Optional. Request method. Default empty.
+ * @param string $route Optional. Request route. Default empty.
+ * @param array $attributes Optional. Request attributes. Default empty array.
+ */
+ public function __construct( $method = '', $route = '', $attributes = array() ) {
+ $this->params = array(
+ 'URL' => array(),
+ 'GET' => array(),
+ 'POST' => array(),
+ 'FILES' => array(),
+
+ // See parse_json_params.
+ 'JSON' => null,
+
+ 'defaults' => array(),
+ );
+
+ $this->set_method( $method );
+ $this->set_route( $route );
+ $this->set_attributes( $attributes );
+ }
+
+ /**
+ * Retrieves the HTTP method for the request.
+ *
+ * @since 4.4.0
+ *
+ * @return string HTTP method.
+ */
+ public function get_method() {
+ return $this->method;
+ }
+
+ /**
+ * Sets HTTP method for the request.
+ *
+ * @since 4.4.0
+ *
+ * @param string $method HTTP method.
+ */
+ public function set_method( $method ) {
+ $this->method = strtoupper( $method );
+ }
+
+ /**
+ * Retrieves all headers from the request.
+ *
+ * @since 4.4.0
+ *
+ * @return array Map of key to value. Key is always lowercase, as per HTTP specification.
+ */
+ public function get_headers() {
+ return $this->headers;
+ }
+
+ /**
+ * Canonicalizes the header name.
+ *
+ * Ensures that header names are always treated the same regardless of
+ * source. Header names are always case insensitive.
+ *
+ * Note that we treat `-` (dashes) and `_` (underscores) as the same
+ * character, as per header parsing rules in both Apache and nginx.
+ *
+ * @link https://stackoverflow.com/q/18185366
+ * @link https://www.nginx.com/resources/wiki/start/topics/tutorials/config_pitfalls/#missing-disappearing-http-headers
+ * @link https://nginx.org/en/docs/http/ngx_http_core_module.html#underscores_in_headers
+ *
+ * @since 4.4.0
+ *
+ * @param string $key Header name.
+ * @return string Canonicalized name.
+ */
+ public static function canonicalize_header_name( $key ) {
+ $key = strtolower( $key );
+ $key = str_replace( '-', '_', $key );
+
+ return $key;
+ }
+
+ /**
+ * Retrieves the given header from the request.
+ *
+ * If the header has multiple values, they will be concatenated with a comma
+ * as per the HTTP specification. Be aware that some non-compliant headers
+ * (notably cookie headers) cannot be joined this way.
+ *
+ * @since 4.4.0
+ *
+ * @param string $key Header name, will be canonicalized to lowercase.
+ * @return string|null String value if set, null otherwise.
+ */
+ public function get_header( $key ) {
+ $key = $this->canonicalize_header_name( $key );
+
+ if ( ! isset( $this->headers[ $key ] ) ) {
+ return null;
+ }
+
+ return implode( ',', $this->headers[ $key ] );
+ }
+
+ /**
+ * Retrieves header values from the request.
+ *
+ * @since 4.4.0
+ *
+ * @param string $key Header name, will be canonicalized to lowercase.
+ * @return array|null List of string values if set, null otherwise.
+ */
+ public function get_header_as_array( $key ) {
+ $key = $this->canonicalize_header_name( $key );
+
+ if ( ! isset( $this->headers[ $key ] ) ) {
+ return null;
+ }
+
+ return $this->headers[ $key ];
+ }
+
+ /**
+ * Sets the header on request.
+ *
+ * @since 4.4.0
+ *
+ * @param string $key Header name.
+ * @param string $value Header value, or list of values.
+ */
+ public function set_header( $key, $value ) {
+ $key = $this->canonicalize_header_name( $key );
+ $value = (array) $value;
+
+ $this->headers[ $key ] = $value;
+ }
+
+ /**
+ * Appends a header value for the given header.
+ *
+ * @since 4.4.0
+ *
+ * @param string $key Header name.
+ * @param string $value Header value, or list of values.
+ */
+ public function add_header( $key, $value ) {
+ $key = $this->canonicalize_header_name( $key );
+ $value = (array) $value;
+
+ if ( ! isset( $this->headers[ $key ] ) ) {
+ $this->headers[ $key ] = array();
+ }
+
+ $this->headers[ $key ] = array_merge( $this->headers[ $key ], $value );
+ }
+
+ /**
+ * Removes all values for a header.
+ *
+ * @since 4.4.0
+ *
+ * @param string $key Header name.
+ */
+ public function remove_header( $key ) {
+ $key = $this->canonicalize_header_name( $key );
+ unset( $this->headers[ $key ] );
+ }
+
+ /**
+ * Sets headers on the request.
+ *
+ * @since 4.4.0
+ *
+ * @param array $headers Map of header name to value.
+ * @param bool $override If true, replace the request's headers. Otherwise, merge with existing.
+ */
+ public function set_headers( $headers, $override = true ) {
+ if ( true === $override ) {
+ $this->headers = array();
+ }
+
+ foreach ( $headers as $key => $value ) {
+ $this->set_header( $key, $value );
+ }
+ }
+
+ /**
+ * Retrieves the Content-Type of the request.
+ *
+ * @since 4.4.0
+ *
+ * @return array|null Map containing 'value' and 'parameters' keys
+ * or null when no valid Content-Type header was
+ * available.
+ */
+ public function get_content_type() {
+ $value = $this->get_header( 'Content-Type' );
+ if ( empty( $value ) ) {
+ return null;
+ }
+
+ $parameters = '';
+ if ( strpos( $value, ';' ) ) {
+ list( $value, $parameters ) = explode( ';', $value, 2 );
+ }
+
+ $value = strtolower( $value );
+ if ( ! str_contains( $value, '/' ) ) {
+ return null;
+ }
+
+ // Parse type and subtype out.
+ list( $type, $subtype ) = explode( '/', $value, 2 );
+
+ $data = compact( 'value', 'type', 'subtype', 'parameters' );
+ $data = array_map( 'trim', $data );
+
+ return $data;
+ }
+
+ /**
+ * Checks if the request has specified a JSON Content-Type.
+ *
+ * @since 5.6.0
+ *
+ * @return bool True if the Content-Type header is JSON.
+ */
+ public function is_json_content_type() {
+ $content_type = $this->get_content_type();
+
+ return isset( $content_type['value'] ) && wp_is_json_media_type( $content_type['value'] );
+ }
+
+ /**
+ * Retrieves the parameter priority order.
+ *
+ * Used when checking parameters in WP_REST_Request::get_param().
+ *
+ * @since 4.4.0
+ *
+ * @return string[] Array of types to check, in order of priority.
+ */
+ protected function get_parameter_order() {
+ $order = array();
+
+ if ( $this->is_json_content_type() ) {
+ $order[] = 'JSON';
+ }
+
+ $this->parse_json_params();
+
+ // Ensure we parse the body data.
+ $body = $this->get_body();
+
+ if ( 'POST' !== $this->method && ! empty( $body ) ) {
+ $this->parse_body_params();
+ }
+
+ $accepts_body_data = array( 'POST', 'PUT', 'PATCH', 'DELETE' );
+ if ( in_array( $this->method, $accepts_body_data, true ) ) {
+ $order[] = 'POST';
+ }
+
+ $order[] = 'GET';
+ $order[] = 'URL';
+ $order[] = 'defaults';
+
+ /**
+ * Filters the parameter priority order for a REST API request.
+ *
+ * The order affects which parameters are checked when using WP_REST_Request::get_param()
+ * and family. This acts similarly to PHP's `request_order` setting.
+ *
+ * @since 4.4.0
+ *
+ * @param string[] $order Array of types to check, in order of priority.
+ * @param WP_REST_Request $request The request object.
+ */
+ return apply_filters( 'rest_request_parameter_order', $order, $this );
+ }
+
+ /**
+ * Retrieves a parameter from the request.
+ *
+ * @since 4.4.0
+ *
+ * @param string $key Parameter name.
+ * @return mixed|null Value if set, null otherwise.
+ */
+ public function get_param( $key ) {
+ $order = $this->get_parameter_order();
+
+ foreach ( $order as $type ) {
+ // Determine if we have the parameter for this type.
+ if ( isset( $this->params[ $type ][ $key ] ) ) {
+ return $this->params[ $type ][ $key ];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Checks if a parameter exists in the request.
+ *
+ * This allows distinguishing between an omitted parameter,
+ * and a parameter specifically set to null.
+ *
+ * @since 5.3.0
+ *
+ * @param string $key Parameter name.
+ * @return bool True if a param exists for the given key.
+ */
+ public function has_param( $key ) {
+ $order = $this->get_parameter_order();
+
+ foreach ( $order as $type ) {
+ if ( is_array( $this->params[ $type ] ) && array_key_exists( $key, $this->params[ $type ] ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Sets a parameter on the request.
+ *
+ * If the given parameter key exists in any parameter type an update will take place,
+ * otherwise a new param will be created in the first parameter type (respecting
+ * get_parameter_order()).
+ *
+ * @since 4.4.0
+ *
+ * @param string $key Parameter name.
+ * @param mixed $value Parameter value.
+ */
+ public function set_param( $key, $value ) {
+ $order = $this->get_parameter_order();
+ $found_key = false;
+
+ foreach ( $order as $type ) {
+ if ( 'defaults' !== $type && is_array( $this->params[ $type ] ) && array_key_exists( $key, $this->params[ $type ] ) ) {
+ $this->params[ $type ][ $key ] = $value;
+ $found_key = true;
+ }
+ }
+
+ if ( ! $found_key ) {
+ $this->params[ $order[0] ][ $key ] = $value;
+ }
+ }
+
+ /**
+ * Retrieves merged parameters from the request.
+ *
+ * The equivalent of get_param(), but returns all parameters for the request.
+ * Handles merging all the available values into a single array.
+ *
+ * @since 4.4.0
+ *
+ * @return array Map of key to value.
+ */
+ public function get_params() {
+ $order = $this->get_parameter_order();
+ $order = array_reverse( $order, true );
+
+ $params = array();
+ foreach ( $order as $type ) {
+ /*
+ * array_merge() / the "+" operator will mess up
+ * numeric keys, so instead do a manual foreach.
+ */
+ foreach ( (array) $this->params[ $type ] as $key => $value ) {
+ $params[ $key ] = $value;
+ }
+ }
+
+ return $params;
+ }
+
+ /**
+ * Retrieves parameters from the route itself.
+ *
+ * These are parsed from the URL using the regex.
+ *
+ * @since 4.4.0
+ *
+ * @return array Parameter map of key to value.
+ */
+ public function get_url_params() {
+ return $this->params['URL'];
+ }
+
+ /**
+ * Sets parameters from the route.
+ *
+ * Typically, this is set after parsing the URL.
+ *
+ * @since 4.4.0
+ *
+ * @param array $params Parameter map of key to value.
+ */
+ public function set_url_params( $params ) {
+ $this->params['URL'] = $params;
+ }
+
+ /**
+ * Retrieves parameters from the query string.
+ *
+ * These are the parameters you'd typically find in `$_GET`.
+ *
+ * @since 4.4.0
+ *
+ * @return array Parameter map of key to value
+ */
+ public function get_query_params() {
+ return $this->params['GET'];
+ }
+
+ /**
+ * Sets parameters from the query string.
+ *
+ * Typically, this is set from `$_GET`.
+ *
+ * @since 4.4.0
+ *
+ * @param array $params Parameter map of key to value.
+ */
+ public function set_query_params( $params ) {
+ $this->params['GET'] = $params;
+ }
+
+ /**
+ * Retrieves parameters from the body.
+ *
+ * These are the parameters you'd typically find in `$_POST`.
+ *
+ * @since 4.4.0
+ *
+ * @return array Parameter map of key to value.
+ */
+ public function get_body_params() {
+ return $this->params['POST'];
+ }
+
+ /**
+ * Sets parameters from the body.
+ *
+ * Typically, this is set from `$_POST`.
+ *
+ * @since 4.4.0
+ *
+ * @param array $params Parameter map of key to value.
+ */
+ public function set_body_params( $params ) {
+ $this->params['POST'] = $params;
+ }
+
+ /**
+ * Retrieves multipart file parameters from the body.
+ *
+ * These are the parameters you'd typically find in `$_FILES`.
+ *
+ * @since 4.4.0
+ *
+ * @return array Parameter map of key to value
+ */
+ public function get_file_params() {
+ return $this->params['FILES'];
+ }
+
+ /**
+ * Sets multipart file parameters from the body.
+ *
+ * Typically, this is set from `$_FILES`.
+ *
+ * @since 4.4.0
+ *
+ * @param array $params Parameter map of key to value.
+ */
+ public function set_file_params( $params ) {
+ $this->params['FILES'] = $params;
+ }
+
+ /**
+ * Retrieves the default parameters.
+ *
+ * These are the parameters set in the route registration.
+ *
+ * @since 4.4.0
+ *
+ * @return array Parameter map of key to value
+ */
+ public function get_default_params() {
+ return $this->params['defaults'];
+ }
+
+ /**
+ * Sets default parameters.
+ *
+ * These are the parameters set in the route registration.
+ *
+ * @since 4.4.0
+ *
+ * @param array $params Parameter map of key to value.
+ */
+ public function set_default_params( $params ) {
+ $this->params['defaults'] = $params;
+ }
+
+ /**
+ * Retrieves the request body content.
+ *
+ * @since 4.4.0
+ *
+ * @return string Binary data from the request body.
+ */
+ public function get_body() {
+ return $this->body;
+ }
+
+ /**
+ * Sets body content.
+ *
+ * @since 4.4.0
+ *
+ * @param string $data Binary data from the request body.
+ */
+ public function set_body( $data ) {
+ $this->body = $data;
+
+ // Enable lazy parsing.
+ $this->parsed_json = false;
+ $this->parsed_body = false;
+ $this->params['JSON'] = null;
+ }
+
+ /**
+ * Retrieves the parameters from a JSON-formatted body.
+ *
+ * @since 4.4.0
+ *
+ * @return array Parameter map of key to value.
+ */
+ public function get_json_params() {
+ // Ensure the parameters have been parsed out.
+ $this->parse_json_params();
+
+ return $this->params['JSON'];
+ }
+
+ /**
+ * Parses the JSON parameters.
+ *
+ * Avoids parsing the JSON data until we need to access it.
+ *
+ * @since 4.4.0
+ * @since 4.7.0 Returns error instance if value cannot be decoded.
+ * @return true|WP_Error True if the JSON data was passed or no JSON data was provided, WP_Error if invalid JSON was passed.
+ */
+ protected function parse_json_params() {
+ if ( $this->parsed_json ) {
+ return true;
+ }
+
+ $this->parsed_json = true;
+
+ // Check that we actually got JSON.
+ if ( ! $this->is_json_content_type() ) {
+ return true;
+ }
+
+ $body = $this->get_body();
+ if ( empty( $body ) ) {
+ return true;
+ }
+
+ $params = json_decode( $body, true );
+
+ /*
+ * Check for a parsing error.
+ */
+ if ( null === $params && JSON_ERROR_NONE !== json_last_error() ) {
+ // Ensure subsequent calls receive error instance.
+ $this->parsed_json = false;
+
+ $error_data = array(
+ 'status' => WP_Http::BAD_REQUEST,
+ 'json_error_code' => json_last_error(),
+ 'json_error_message' => json_last_error_msg(),
+ );
+
+ return new WP_Error( 'rest_invalid_json', __( 'Invalid JSON body passed.' ), $error_data );
+ }
+
+ $this->params['JSON'] = $params;
+
+ return true;
+ }
+
+ /**
+ * Parses the request body parameters.
+ *
+ * Parses out URL-encoded bodies for request methods that aren't supported
+ * natively by PHP. In PHP 5.x, only POST has these parsed automatically.
+ *
+ * @since 4.4.0
+ */
+ protected function parse_body_params() {
+ if ( $this->parsed_body ) {
+ return;
+ }
+
+ $this->parsed_body = true;
+
+ /*
+ * Check that we got URL-encoded. Treat a missing Content-Type as
+ * URL-encoded for maximum compatibility.
+ */
+ $content_type = $this->get_content_type();
+
+ if ( ! empty( $content_type ) && 'application/x-www-form-urlencoded' !== $content_type['value'] ) {
+ return;
+ }
+
+ parse_str( $this->get_body(), $params );
+
+ /*
+ * Add to the POST parameters stored internally. If a user has already
+ * set these manually (via `set_body_params`), don't override them.
+ */
+ $this->params['POST'] = array_merge( $params, $this->params['POST'] );
+ }
+
+ /**
+ * Retrieves the route that matched the request.
+ *
+ * @since 4.4.0
+ *
+ * @return string Route matching regex.
+ */
+ public function get_route() {
+ return $this->route;
+ }
+
+ /**
+ * Sets the route that matched the request.
+ *
+ * @since 4.4.0
+ *
+ * @param string $route Route matching regex.
+ */
+ public function set_route( $route ) {
+ $this->route = $route;
+ }
+
+ /**
+ * Retrieves the attributes for the request.
+ *
+ * These are the options for the route that was matched.
+ *
+ * @since 4.4.0
+ *
+ * @return array Attributes for the request.
+ */
+ public function get_attributes() {
+ return $this->attributes;
+ }
+
+ /**
+ * Sets the attributes for the request.
+ *
+ * @since 4.4.0
+ *
+ * @param array $attributes Attributes for the request.
+ */
+ public function set_attributes( $attributes ) {
+ $this->attributes = $attributes;
+ }
+
+ /**
+ * Sanitizes (where possible) the params on the request.
+ *
+ * This is primarily based off the sanitize_callback param on each registered
+ * argument.
+ *
+ * @since 4.4.0
+ *
+ * @return true|WP_Error True if parameters were sanitized, WP_Error if an error occurred during sanitization.
+ */
+ public function sanitize_params() {
+ $attributes = $this->get_attributes();
+
+ // No arguments set, skip sanitizing.
+ if ( empty( $attributes['args'] ) ) {
+ return true;
+ }
+
+ $order = $this->get_parameter_order();
+
+ $invalid_params = array();
+ $invalid_details = array();
+
+ foreach ( $order as $type ) {
+ if ( empty( $this->params[ $type ] ) ) {
+ continue;
+ }
+
+ foreach ( $this->params[ $type ] as $key => $value ) {
+ if ( ! isset( $attributes['args'][ $key ] ) ) {
+ continue;
+ }
+
+ $param_args = $attributes['args'][ $key ];
+
+ // If the arg has a type but no sanitize_callback attribute, default to rest_parse_request_arg.
+ if ( ! array_key_exists( 'sanitize_callback', $param_args ) && ! empty( $param_args['type'] ) ) {
+ $param_args['sanitize_callback'] = 'rest_parse_request_arg';
+ }
+ // If there's still no sanitize_callback, nothing to do here.
+ if ( empty( $param_args['sanitize_callback'] ) ) {
+ continue;
+ }
+
+ /** @var mixed|WP_Error $sanitized_value */
+ $sanitized_value = call_user_func( $param_args['sanitize_callback'], $value, $this, $key );
+
+ if ( is_wp_error( $sanitized_value ) ) {
+ $invalid_params[ $key ] = implode( ' ', $sanitized_value->get_error_messages() );
+ $invalid_details[ $key ] = rest_convert_error_to_response( $sanitized_value )->get_data();
+ } else {
+ $this->params[ $type ][ $key ] = $sanitized_value;
+ }
+ }
+ }
+
+ if ( $invalid_params ) {
+ return new WP_Error(
+ 'rest_invalid_param',
+ /* translators: %s: List of invalid parameters. */
+ sprintf( __( 'Invalid parameter(s): %s' ), implode( ', ', array_keys( $invalid_params ) ) ),
+ array(
+ 'status' => 400,
+ 'params' => $invalid_params,
+ 'details' => $invalid_details,
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks whether this request is valid according to its attributes.
+ *
+ * @since 4.4.0
+ *
+ * @return true|WP_Error True if there are no parameters to validate or if all pass validation,
+ * WP_Error if required parameters are missing.
+ */
+ public function has_valid_params() {
+ // If JSON data was passed, check for errors.
+ $json_error = $this->parse_json_params();
+ if ( is_wp_error( $json_error ) ) {
+ return $json_error;
+ }
+
+ $attributes = $this->get_attributes();
+ $required = array();
+
+ $args = empty( $attributes['args'] ) ? array() : $attributes['args'];
+
+ foreach ( $args as $key => $arg ) {
+ $param = $this->get_param( $key );
+ if ( isset( $arg['required'] ) && true === $arg['required'] && null === $param ) {
+ $required[] = $key;
+ }
+ }
+
+ if ( ! empty( $required ) ) {
+ return new WP_Error(
+ 'rest_missing_callback_param',
+ /* translators: %s: List of required parameters. */
+ sprintf( __( 'Missing parameter(s): %s' ), implode( ', ', $required ) ),
+ array(
+ 'status' => 400,
+ 'params' => $required,
+ )
+ );
+ }
+
+ /*
+ * Check the validation callbacks for each registered arg.
+ *
+ * This is done after required checking as required checking is cheaper.
+ */
+ $invalid_params = array();
+ $invalid_details = array();
+
+ foreach ( $args as $key => $arg ) {
+
+ $param = $this->get_param( $key );
+
+ if ( null !== $param && ! empty( $arg['validate_callback'] ) ) {
+ /** @var bool|\WP_Error $valid_check */
+ $valid_check = call_user_func( $arg['validate_callback'], $param, $this, $key );
+
+ if ( false === $valid_check ) {
+ $invalid_params[ $key ] = __( 'Invalid parameter.' );
+ }
+
+ if ( is_wp_error( $valid_check ) ) {
+ $invalid_params[ $key ] = implode( ' ', $valid_check->get_error_messages() );
+ $invalid_details[ $key ] = rest_convert_error_to_response( $valid_check )->get_data();
+ }
+ }
+ }
+
+ if ( $invalid_params ) {
+ return new WP_Error(
+ 'rest_invalid_param',
+ /* translators: %s: List of invalid parameters. */
+ sprintf( __( 'Invalid parameter(s): %s' ), implode( ', ', array_keys( $invalid_params ) ) ),
+ array(
+ 'status' => 400,
+ 'params' => $invalid_params,
+ 'details' => $invalid_details,
+ )
+ );
+ }
+
+ if ( isset( $attributes['validate_callback'] ) ) {
+ $valid_check = call_user_func( $attributes['validate_callback'], $this );
+
+ if ( is_wp_error( $valid_check ) ) {
+ return $valid_check;
+ }
+
+ if ( false === $valid_check ) {
+ // A WP_Error instance is preferred, but false is supported for parity with the per-arg validate_callback.
+ return new WP_Error( 'rest_invalid_params', __( 'Invalid parameters.' ), array( 'status' => 400 ) );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if a parameter is set.
+ *
+ * @since 4.4.0
+ *
+ * @param string $offset Parameter name.
+ * @return bool Whether the parameter is set.
+ */
+ #[ReturnTypeWillChange]
+ public function offsetExists( $offset ) {
+ $order = $this->get_parameter_order();
+
+ foreach ( $order as $type ) {
+ if ( isset( $this->params[ $type ][ $offset ] ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Retrieves a parameter from the request.
+ *
+ * @since 4.4.0
+ *
+ * @param string $offset Parameter name.
+ * @return mixed|null Value if set, null otherwise.
+ */
+ #[ReturnTypeWillChange]
+ public function offsetGet( $offset ) {
+ return $this->get_param( $offset );
+ }
+
+ /**
+ * Sets a parameter on the request.
+ *
+ * @since 4.4.0
+ *
+ * @param string $offset Parameter name.
+ * @param mixed $value Parameter value.
+ */
+ #[ReturnTypeWillChange]
+ public function offsetSet( $offset, $value ) {
+ $this->set_param( $offset, $value );
+ }
+
+ /**
+ * Removes a parameter from the request.
+ *
+ * @since 4.4.0
+ *
+ * @param string $offset Parameter name.
+ */
+ #[ReturnTypeWillChange]
+ public function offsetUnset( $offset ) {
+ $order = $this->get_parameter_order();
+
+ // Remove the offset from every group.
+ foreach ( $order as $type ) {
+ unset( $this->params[ $type ][ $offset ] );
+ }
+ }
+
+ /**
+ * Retrieves a WP_REST_Request object from a full URL.
+ *
+ * @since 4.5.0
+ *
+ * @param string $url URL with protocol, domain, path and query args.
+ * @return WP_REST_Request|false WP_REST_Request object on success, false on failure.
+ */
+ public static function from_url( $url ) {
+ $bits = parse_url( $url );
+ $query_params = array();
+
+ if ( ! empty( $bits['query'] ) ) {
+ wp_parse_str( $bits['query'], $query_params );
+ }
+
+ $api_root = rest_url();
+ if ( get_option( 'permalink_structure' ) && str_starts_with( $url, $api_root ) ) {
+ // Pretty permalinks on, and URL is under the API root.
+ $api_url_part = substr( $url, strlen( untrailingslashit( $api_root ) ) );
+ $route = parse_url( $api_url_part, PHP_URL_PATH );
+ } elseif ( ! empty( $query_params['rest_route'] ) ) {
+ // ?rest_route=... set directly.
+ $route = $query_params['rest_route'];
+ unset( $query_params['rest_route'] );
+ }
+
+ $request = false;
+ if ( ! empty( $route ) ) {
+ $request = new WP_REST_Request( 'GET', $route );
+ $request->set_query_params( $query_params );
+ }
+
+ /**
+ * Filters the REST API request generated from a URL.
+ *
+ * @since 4.5.0
+ *
+ * @param WP_REST_Request|false $request Generated request object, or false if URL
+ * could not be parsed.
+ * @param string $url URL the request was generated from.
+ */
+ return apply_filters( 'rest_request_from_url', $request, $url );
+ }
+}
diff --git a/wp-includes/rest-api/class-wp-rest-response.php b/wp-includes/rest-api/class-wp-rest-response.php
new file mode 100644
index 0000000..c6ea11b
--- /dev/null
+++ b/wp-includes/rest-api/class-wp-rest-response.php
@@ -0,0 +1,293 @@
+<?php
+/**
+ * REST API: WP_REST_Response class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.4.0
+ */
+
+/**
+ * Core class used to implement a REST response object.
+ *
+ * @since 4.4.0
+ *
+ * @see WP_HTTP_Response
+ */
+class WP_REST_Response extends WP_HTTP_Response {
+
+ /**
+ * Links related to the response.
+ *
+ * @since 4.4.0
+ * @var array
+ */
+ protected $links = array();
+
+ /**
+ * The route that was to create the response.
+ *
+ * @since 4.4.0
+ * @var string
+ */
+ protected $matched_route = '';
+
+ /**
+ * The handler that was used to create the response.
+ *
+ * @since 4.4.0
+ * @var null|array
+ */
+ protected $matched_handler = null;
+
+ /**
+ * Adds a link to the response.
+ *
+ * @internal The $rel parameter is first, as this looks nicer when sending multiple.
+ *
+ * @since 4.4.0
+ *
+ * @link https://tools.ietf.org/html/rfc5988
+ * @link https://www.iana.org/assignments/link-relations/link-relations.xml
+ *
+ * @param string $rel Link relation. Either an IANA registered type,
+ * or an absolute URL.
+ * @param string $href Target URI for the link.
+ * @param array $attributes Optional. Link parameters to send along with the URL. Default empty array.
+ */
+ public function add_link( $rel, $href, $attributes = array() ) {
+ if ( empty( $this->links[ $rel ] ) ) {
+ $this->links[ $rel ] = array();
+ }
+
+ if ( isset( $attributes['href'] ) ) {
+ // Remove the href attribute, as it's used for the main URL.
+ unset( $attributes['href'] );
+ }
+
+ $this->links[ $rel ][] = array(
+ 'href' => $href,
+ 'attributes' => $attributes,
+ );
+ }
+
+ /**
+ * Removes a link from the response.
+ *
+ * @since 4.4.0
+ *
+ * @param string $rel Link relation. Either an IANA registered type, or an absolute URL.
+ * @param string $href Optional. Only remove links for the relation matching the given href.
+ * Default null.
+ */
+ public function remove_link( $rel, $href = null ) {
+ if ( ! isset( $this->links[ $rel ] ) ) {
+ return;
+ }
+
+ if ( $href ) {
+ $this->links[ $rel ] = wp_list_filter( $this->links[ $rel ], array( 'href' => $href ), 'NOT' );
+ } else {
+ $this->links[ $rel ] = array();
+ }
+
+ if ( ! $this->links[ $rel ] ) {
+ unset( $this->links[ $rel ] );
+ }
+ }
+
+ /**
+ * Adds multiple links to the response.
+ *
+ * Link data should be an associative array with link relation as the key.
+ * The value can either be an associative array of link attributes
+ * (including `href` with the URL for the response), or a list of these
+ * associative arrays.
+ *
+ * @since 4.4.0
+ *
+ * @param array $links Map of link relation to list of links.
+ */
+ public function add_links( $links ) {
+ foreach ( $links as $rel => $set ) {
+ // If it's a single link, wrap with an array for consistent handling.
+ if ( isset( $set['href'] ) ) {
+ $set = array( $set );
+ }
+
+ foreach ( $set as $attributes ) {
+ $this->add_link( $rel, $attributes['href'], $attributes );
+ }
+ }
+ }
+
+ /**
+ * Retrieves links for the response.
+ *
+ * @since 4.4.0
+ *
+ * @return array List of links.
+ */
+ public function get_links() {
+ return $this->links;
+ }
+
+ /**
+ * Sets a single link header.
+ *
+ * @internal The $rel parameter is first, as this looks nicer when sending multiple.
+ *
+ * @since 4.4.0
+ *
+ * @link https://tools.ietf.org/html/rfc5988
+ * @link https://www.iana.org/assignments/link-relations/link-relations.xml
+ *
+ * @param string $rel Link relation. Either an IANA registered type, or an absolute URL.
+ * @param string $link Target IRI for the link.
+ * @param array $other Optional. Other parameters to send, as an associative array.
+ * Default empty array.
+ */
+ public function link_header( $rel, $link, $other = array() ) {
+ $header = '<' . $link . '>; rel="' . $rel . '"';
+
+ foreach ( $other as $key => $value ) {
+ if ( 'title' === $key ) {
+ $value = '"' . $value . '"';
+ }
+
+ $header .= '; ' . $key . '=' . $value;
+ }
+ $this->header( 'Link', $header, false );
+ }
+
+ /**
+ * Retrieves the route that was used.
+ *
+ * @since 4.4.0
+ *
+ * @return string The matched route.
+ */
+ public function get_matched_route() {
+ return $this->matched_route;
+ }
+
+ /**
+ * Sets the route (regex for path) that caused the response.
+ *
+ * @since 4.4.0
+ *
+ * @param string $route Route name.
+ */
+ public function set_matched_route( $route ) {
+ $this->matched_route = $route;
+ }
+
+ /**
+ * Retrieves the handler that was used to generate the response.
+ *
+ * @since 4.4.0
+ *
+ * @return null|array The handler that was used to create the response.
+ */
+ public function get_matched_handler() {
+ return $this->matched_handler;
+ }
+
+ /**
+ * Sets the handler that was responsible for generating the response.
+ *
+ * @since 4.4.0
+ *
+ * @param array $handler The matched handler.
+ */
+ public function set_matched_handler( $handler ) {
+ $this->matched_handler = $handler;
+ }
+
+ /**
+ * Checks if the response is an error, i.e. >= 400 response code.
+ *
+ * @since 4.4.0
+ *
+ * @return bool Whether the response is an error.
+ */
+ public function is_error() {
+ return $this->get_status() >= 400;
+ }
+
+ /**
+ * Retrieves a WP_Error object from the response.
+ *
+ * @since 4.4.0
+ *
+ * @return WP_Error|null WP_Error or null on not an errored response.
+ */
+ public function as_error() {
+ if ( ! $this->is_error() ) {
+ return null;
+ }
+
+ $error = new WP_Error();
+
+ if ( is_array( $this->get_data() ) ) {
+ $data = $this->get_data();
+ $error->add( $data['code'], $data['message'], $data['data'] );
+
+ if ( ! empty( $data['additional_errors'] ) ) {
+ foreach ( $data['additional_errors'] as $err ) {
+ $error->add( $err['code'], $err['message'], $err['data'] );
+ }
+ }
+ } else {
+ $error->add( $this->get_status(), '', array( 'status' => $this->get_status() ) );
+ }
+
+ return $error;
+ }
+
+ /**
+ * Retrieves the CURIEs (compact URIs) used for relations.
+ *
+ * @since 4.5.0
+ *
+ * @return array Compact URIs.
+ */
+ public function get_curies() {
+ $curies = array(
+ array(
+ 'name' => 'wp',
+ 'href' => 'https://api.w.org/{rel}',
+ 'templated' => true,
+ ),
+ );
+
+ /**
+ * Filters extra CURIEs available on REST API responses.
+ *
+ * CURIEs allow a shortened version of URI relations. This allows a more
+ * usable form for custom relations than using the full URI. These work
+ * similarly to how XML namespaces work.
+ *
+ * Registered CURIES need to specify a name and URI template. This will
+ * automatically transform URI relations into their shortened version.
+ * The shortened relation follows the format `{name}:{rel}`. `{rel}` in
+ * the URI template will be replaced with the `{rel}` part of the
+ * shortened relation.
+ *
+ * For example, a CURIE with name `example` and URI template
+ * `http://w.org/{rel}` would transform a `http://w.org/term` relation
+ * into `example:term`.
+ *
+ * Well-behaved clients should expand and normalize these back to their
+ * full URI relation, however some naive clients may not resolve these
+ * correctly, so adding new CURIEs may break backward compatibility.
+ *
+ * @since 4.5.0
+ *
+ * @param array $additional Additional CURIEs to register with the REST API.
+ */
+ $additional = apply_filters( 'rest_response_link_curies', array() );
+
+ return array_merge( $curies, $additional );
+ }
+}
diff --git a/wp-includes/rest-api/class-wp-rest-server.php b/wp-includes/rest-api/class-wp-rest-server.php
new file mode 100644
index 0000000..a1bc4b9
--- /dev/null
+++ b/wp-includes/rest-api/class-wp-rest-server.php
@@ -0,0 +1,1877 @@
+<?php
+/**
+ * REST API: WP_REST_Server class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.4.0
+ */
+
+/**
+ * Core class used to implement the WordPress REST API server.
+ *
+ * @since 4.4.0
+ */
+#[AllowDynamicProperties]
+class WP_REST_Server {
+
+ /**
+ * Alias for GET transport method.
+ *
+ * @since 4.4.0
+ * @var string
+ */
+ const READABLE = 'GET';
+
+ /**
+ * Alias for POST transport method.
+ *
+ * @since 4.4.0
+ * @var string
+ */
+ const CREATABLE = 'POST';
+
+ /**
+ * Alias for POST, PUT, PATCH transport methods together.
+ *
+ * @since 4.4.0
+ * @var string
+ */
+ const EDITABLE = 'POST, PUT, PATCH';
+
+ /**
+ * Alias for DELETE transport method.
+ *
+ * @since 4.4.0
+ * @var string
+ */
+ const DELETABLE = 'DELETE';
+
+ /**
+ * Alias for GET, POST, PUT, PATCH & DELETE transport methods together.
+ *
+ * @since 4.4.0
+ * @var string
+ */
+ const ALLMETHODS = 'GET, POST, PUT, PATCH, DELETE';
+
+ /**
+ * Namespaces registered to the server.
+ *
+ * @since 4.4.0
+ * @var array
+ */
+ protected $namespaces = array();
+
+ /**
+ * Endpoints registered to the server.
+ *
+ * @since 4.4.0
+ * @var array
+ */
+ protected $endpoints = array();
+
+ /**
+ * Options defined for the routes.
+ *
+ * @since 4.4.0
+ * @var array
+ */
+ 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
+ */
+ public function __construct() {
+ $this->endpoints = array(
+ // Meta endpoints.
+ '/' => array(
+ 'callback' => array( $this, 'get_index' ),
+ 'methods' => 'GET',
+ 'args' => array(
+ 'context' => array(
+ 'default' => 'view',
+ ),
+ ),
+ ),
+ '/batch/v1' => array(
+ 'callback' => array( $this, 'serve_batch_request_v1' ),
+ 'methods' => 'POST',
+ 'args' => array(
+ 'validation' => array(
+ 'type' => 'string',
+ 'enum' => array( 'require-all-validate', 'normal' ),
+ 'default' => 'normal',
+ ),
+ 'requests' => array(
+ 'required' => true,
+ 'type' => 'array',
+ 'maxItems' => $this->get_max_batch_size(),
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'method' => array(
+ 'type' => 'string',
+ 'enum' => array( 'POST', 'PUT', 'PATCH', 'DELETE' ),
+ 'default' => 'POST',
+ ),
+ 'path' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'body' => array(
+ 'type' => 'object',
+ 'properties' => array(),
+ 'additionalProperties' => true,
+ ),
+ 'headers' => array(
+ 'type' => 'object',
+ 'properties' => array(),
+ 'additionalProperties' => array(
+ 'type' => array( 'string', 'array' ),
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+
+ /**
+ * Checks the authentication headers if supplied.
+ *
+ * @since 4.4.0
+ *
+ * @return WP_Error|null|true WP_Error indicates unsuccessful login, null indicates successful
+ * or no authentication provided
+ */
+ public function check_authentication() {
+ /**
+ * Filters REST API authentication errors.
+ *
+ * This is used to pass a WP_Error from an authentication method back to
+ * the API.
+ *
+ * Authentication methods should check first if they're being used, as
+ * multiple authentication methods can be enabled on a site (cookies,
+ * HTTP basic auth, OAuth). If the authentication method hooked in is
+ * not actually being attempted, null should be returned to indicate
+ * another authentication method should check instead. Similarly,
+ * callbacks should ensure the value is `null` before checking for
+ * errors.
+ *
+ * A WP_Error instance can be returned if an error occurs, and this should
+ * match the format used by API methods internally (that is, the `status`
+ * data should be used). A callback can return `true` to indicate that
+ * the authentication method was used, and it succeeded.
+ *
+ * @since 4.4.0
+ *
+ * @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 );
+ }
+
+ /**
+ * Converts an error to a response object.
+ *
+ * This iterates over all error codes and messages to change it into a flat
+ * array. This enables simpler client behavior, as it is represented as a
+ * list in JSON rather than an object/map.
+ *
+ * @since 4.4.0
+ * @since 5.7.0 Converted to a wrapper of {@see rest_convert_error_to_response()}.
+ *
+ * @param WP_Error $error WP_Error instance.
+ * @return WP_REST_Response List of associative arrays with code and message keys.
+ */
+ protected function error_to_response( $error ) {
+ return rest_convert_error_to_response( $error );
+ }
+
+ /**
+ * Retrieves an appropriate error representation in JSON.
+ *
+ * Note: This should only be used in WP_REST_Server::serve_request(), as it
+ * cannot handle WP_Error internally. All callbacks and other internal methods
+ * should instead return a WP_Error with the data set to an array that includes
+ * a 'status' key, with the value being the HTTP status to send.
+ *
+ * @since 4.4.0
+ *
+ * @param string $code WP_Error-style code.
+ * @param string $message Human-readable message.
+ * @param int $status Optional. HTTP status code to send. Default null.
+ * @return string JSON representation of the error
+ */
+ protected function json_error( $code, $message, $status = null ) {
+ if ( $status ) {
+ $this->set_status( $status );
+ }
+
+ $error = compact( 'code', 'message' );
+
+ return wp_json_encode( $error );
+ }
+
+ /**
+ * Gets the encoding options passed to {@see wp_json_encode}.
+ *
+ * @since 6.1.0
+ *
+ * @param \WP_REST_Request $request The current request object.
+ *
+ * @return int The JSON encode options.
+ */
+ protected function get_json_encode_options( WP_REST_Request $request ) {
+ $options = 0;
+
+ if ( $request->has_param( '_pretty' ) ) {
+ $options |= JSON_PRETTY_PRINT;
+ }
+
+ /**
+ * Filters the JSON encoding options used to send the REST API response.
+ *
+ * @since 6.1.0
+ *
+ * @param int $options JSON encoding options {@see json_encode()}.
+ * @param WP_REST_Request $request Current request object.
+ */
+ return apply_filters( 'rest_json_encode_options', $options, $request );
+ }
+
+ /**
+ * Handles serving a REST API request.
+ *
+ * Matches the current server URI to a route and runs the first matching
+ * callback then outputs a JSON representation of the returned value.
+ *
+ * @since 4.4.0
+ *
+ * @see WP_REST_Server::dispatch()
+ *
+ * @global WP_User $current_user The currently authenticated user.
+ *
+ * @param string $path Optional. The request route. If not set, `$_SERVER['PATH_INFO']` will be used.
+ * Default null.
+ * @return null|false Null if not served and a HEAD request, false otherwise.
+ */
+ public function serve_request( $path = null ) {
+ /* @var WP_User|null $current_user */
+ global $current_user;
+
+ if ( $current_user instanceof WP_User && ! $current_user->exists() ) {
+ /*
+ * If there is no current user authenticated via other means, clear
+ * the cached lack of user, so that an authenticate check can set it
+ * properly.
+ *
+ * This is done because for authentications such as Application
+ * Passwords, we don't want it to be accepted unless the current HTTP
+ * request is a REST API request, which can't always be identified early
+ * enough in evaluation.
+ */
+ $current_user = null;
+ }
+
+ /**
+ * Filters whether JSONP is enabled for the REST API.
+ *
+ * @since 4.4.0
+ *
+ * @param bool $jsonp_enabled Whether JSONP is enabled. Default true.
+ */
+ $jsonp_enabled = apply_filters( 'rest_jsonp_enabled', true );
+
+ $jsonp_callback = false;
+ if ( isset( $_GET['_jsonp'] ) ) {
+ $jsonp_callback = $_GET['_jsonp'];
+ }
+
+ $content_type = ( $jsonp_callback && $jsonp_enabled ) ? 'application/javascript' : 'application/json';
+ $this->send_header( 'Content-Type', $content_type . '; charset=' . get_option( 'blog_charset' ) );
+ $this->send_header( 'X-Robots-Tag', 'noindex' );
+
+ $api_root = get_rest_url();
+ if ( ! empty( $api_root ) ) {
+ $this->send_header( 'Link', '<' . sanitize_url( $api_root ) . '>; rel="https://api.w.org/"' );
+ }
+
+ /*
+ * Mitigate possible JSONP Flash attacks.
+ *
+ * https://miki.it/blog/2014/7/8/abusing-jsonp-with-rosetta-flash/
+ */
+ $this->send_header( 'X-Content-Type-Options', 'nosniff' );
+
+ /**
+ * Filters whether the REST API is enabled.
+ *
+ * @since 4.4.0
+ * @deprecated 4.7.0 Use the {@see 'rest_authentication_errors'} filter to
+ * restrict access to the REST API.
+ *
+ * @param bool $rest_enabled Whether the REST API is enabled. Default true.
+ */
+ apply_filters_deprecated(
+ 'rest_enabled',
+ array( true ),
+ '4.7.0',
+ 'rest_authentication_errors',
+ 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'
+ )
+ );
+
+ if ( $jsonp_callback ) {
+ if ( ! $jsonp_enabled ) {
+ echo $this->json_error( 'rest_callback_disabled', __( 'JSONP support is disabled on this site.' ), 400 );
+ return false;
+ }
+
+ if ( ! wp_check_jsonp_callback( $jsonp_callback ) ) {
+ echo $this->json_error( 'rest_callback_invalid', __( 'Invalid JSONP callback function.' ), 400 );
+ return false;
+ }
+ }
+
+ if ( empty( $path ) ) {
+ if ( isset( $_SERVER['PATH_INFO'] ) ) {
+ $path = $_SERVER['PATH_INFO'];
+ } else {
+ $path = '/';
+ }
+ }
+
+ $request = new WP_REST_Request( $_SERVER['REQUEST_METHOD'], $path );
+
+ $request->set_query_params( wp_unslash( $_GET ) );
+ $request->set_body_params( wp_unslash( $_POST ) );
+ $request->set_file_params( $_FILES );
+ $request->set_headers( $this->get_headers( wp_unslash( $_SERVER ) ) );
+ $request->set_body( self::get_raw_data() );
+
+ /*
+ * HTTP method override for clients that can't use PUT/PATCH/DELETE. First, we check
+ * $_GET['_method']. If that is not set, we check for the HTTP_X_HTTP_METHOD_OVERRIDE
+ * header.
+ */
+ $method_overridden = false;
+ if ( isset( $_GET['_method'] ) ) {
+ $request->set_method( $_GET['_method'] );
+ } elseif ( isset( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) ) {
+ $request->set_method( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] );
+ $method_overridden = true;
+ }
+
+ $expose_headers = array( 'X-WP-Total', 'X-WP-TotalPages', 'Link' );
+
+ /**
+ * Filters the list of response headers that are exposed to REST API CORS requests.
+ *
+ * @since 5.5.0
+ * @since 6.3.0 The `$request` parameter was added.
+ *
+ * @param string[] $expose_headers The list of response headers to expose.
+ * @param WP_REST_Request $request The request in context.
+ */
+ $expose_headers = apply_filters( 'rest_exposed_cors_headers', $expose_headers, $request );
+
+ $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 REST API 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
+ * @since 6.3.0 The `$request` parameter was added.
+ *
+ * @param string[] $allow_headers The list of request headers to allow.
+ * @param WP_REST_Request $request The request in context.
+ */
+ $allow_headers = apply_filters( 'rest_allowed_cors_headers', $allow_headers, $request );
+
+ $this->send_header( 'Access-Control-Allow-Headers', implode( ', ', $allow_headers ) );
+
+ $result = $this->check_authentication();
+
+ if ( ! is_wp_error( $result ) ) {
+ $result = $this->dispatch( $request );
+ }
+
+ // Normalize to either WP_Error or WP_REST_Response...
+ $result = rest_ensure_response( $result );
+
+ // ...then convert WP_Error across.
+ if ( is_wp_error( $result ) ) {
+ $result = $this->error_to_response( $result );
+ }
+
+ /**
+ * Filters the REST API response.
+ *
+ * Allows modification of the response before returning.
+ *
+ * @since 4.4.0
+ * @since 4.5.0 Applied to embedded responses.
+ *
+ * @param WP_HTTP_Response $result Result to send to the client. Usually a `WP_REST_Response`.
+ * @param WP_REST_Server $server Server instance.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ $result = apply_filters( 'rest_post_dispatch', rest_ensure_response( $result ), $this, $request );
+
+ // Wrap the response in an envelope if asked for.
+ if ( isset( $_GET['_envelope'] ) ) {
+ $embed = isset( $_GET['_embed'] ) ? rest_parse_embed_param( $_GET['_embed'] ) : false;
+ $result = $this->envelope_response( $result, $embed );
+ }
+
+ // Send extra data from response objects.
+ $headers = $result->get_headers();
+ $this->send_headers( $headers );
+
+ $code = $result->get_status();
+ $this->set_status( $code );
+
+ /**
+ * Filters whether to send nocache headers on a REST API request.
+ *
+ * @since 4.4.0
+ * @since 6.3.2 Moved the block to catch the filter added on rest_cookie_check_errors() from rest-api.php
+ *
+ * @param bool $rest_send_nocache_headers Whether to send no-cache headers.
+ */
+ $send_no_cache_headers = apply_filters( 'rest_send_nocache_headers', is_user_logged_in() );
+
+ // send no cache headers if the $send_no_cache_headers is true
+ // OR if the HTTP_X_HTTP_METHOD_OVERRIDE is used but resulted a 4x response code.
+ if ( $send_no_cache_headers || ( true === $method_overridden && strpos( $code, '4' ) === 0 ) ) {
+ foreach ( wp_get_nocache_headers() as $header => $header_value ) {
+ if ( empty( $header_value ) ) {
+ $this->remove_header( $header );
+ } else {
+ $this->send_header( $header, $header_value );
+ }
+ }
+ }
+
+ /**
+ * Filters whether the REST API request has already been served.
+ *
+ * Allow sending the request manually - by returning true, the API result
+ * will not be sent to the client.
+ *
+ * @since 4.4.0
+ *
+ * @param bool $served Whether the request has already been served.
+ * Default false.
+ * @param WP_HTTP_Response $result Result to send to the client. Usually a `WP_REST_Response`.
+ * @param WP_REST_Request $request Request used to generate the response.
+ * @param WP_REST_Server $server Server instance.
+ */
+ $served = apply_filters( 'rest_pre_serve_request', false, $result, $request, $this );
+
+ if ( ! $served ) {
+ if ( 'HEAD' === $request->get_method() ) {
+ return null;
+ }
+
+ // Embed links inside the request.
+ $embed = isset( $_GET['_embed'] ) ? rest_parse_embed_param( $_GET['_embed'] ) : false;
+ $result = $this->response_to_data( $result, $embed );
+
+ /**
+ * Filters the REST API response.
+ *
+ * Allows modification of the response data after inserting
+ * embedded data (if any) and before echoing the response data.
+ *
+ * @since 4.8.1
+ *
+ * @param array $result Response data to send to the client.
+ * @param WP_REST_Server $server Server instance.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ $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, $this->get_json_encode_options( $request ) );
+
+ $json_error_message = $this->get_json_last_error();
+
+ if ( $json_error_message ) {
+ $this->set_status( 500 );
+ $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, $this->get_json_encode_options( $request ) );
+ }
+
+ if ( $jsonp_callback ) {
+ // Prepend '/**/' to mitigate possible JSONP Flash attacks.
+ // https://miki.it/blog/2014/7/8/abusing-jsonp-with-rosetta-flash/
+ echo '/**/' . $jsonp_callback . '(' . $result . ')';
+ } else {
+ echo $result;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * 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|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 Embedded objects.
+ * }
+ */
+ public function response_to_data( $response, $embed ) {
+ $data = $response->get_data();
+ $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 ) ) {
+ 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
+ *
+ * @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();
+ }
+
+ // 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'];
+ $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.
+ */
+ public static function get_compact_response_links( $response ) {
+ $links = self::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;
+ }
+
+ /**
+ * 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 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 Embedded objects.
+ * }
+ */
+ protected function embed_links( $data, $embed = true ) {
+ if ( empty( $data['_links'] ) ) {
+ return $data;
+ }
+
+ $embedded = array();
+
+ foreach ( $data['_links'] as $rel => $links ) {
+ /*
+ * 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;
+ }
+
+ $embeds = array();
+
+ foreach ( $links as $item ) {
+ // Determine if the link is embeddable.
+ if ( empty( $item['embeddable'] ) ) {
+ // Ensure we keep the same order.
+ $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 );
+ }
+
+ $embeds[] = $this->embed_cache[ $item['href'] ];
+ }
+
+ // Determine if any real links were found.
+ $has_links = count( array_filter( $embeds ) );
+
+ if ( $has_links ) {
+ $embedded[ $rel ] = $embeds;
+ }
+ }
+
+ if ( ! empty( $embedded ) ) {
+ $data['_embedded'] = $embedded;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Wraps the response in an envelope.
+ *
+ * The enveloping technique is used to work around browser/client
+ * compatibility issues. Essentially, it converts the full HTTP response to
+ * data instead.
+ *
+ * @since 4.4.0
+ * @since 6.0.0 The `$embed` parameter can now contain a list of link relations to include.
+ *
+ * @param WP_REST_Response $response Response object.
+ * @param bool|string[] $embed Whether to embed all links, a filtered list of link relations, or no links.
+ * @return WP_REST_Response New response with wrapped data
+ */
+ public function envelope_response( $response, $embed ) {
+ $envelope = array(
+ 'body' => $this->response_to_data( $response, $embed ),
+ 'status' => $response->get_status(),
+ 'headers' => $response->get_headers(),
+ );
+
+ /**
+ * Filters the enveloped form of a REST API response.
+ *
+ * @since 4.4.0
+ *
+ * @param array $envelope {
+ * Envelope data.
+ *
+ * @type array $body Response data.
+ * @type int $status The 3-digit HTTP status code.
+ * @type array $headers Map of header name to header value.
+ * }
+ * @param WP_REST_Response $response Original response data.
+ */
+ $envelope = apply_filters( 'rest_envelope_response', $envelope, $response );
+
+ // Ensure it's still a response and return.
+ return rest_ensure_response( $envelope );
+ }
+
+ /**
+ * Registers a route to the server.
+ *
+ * @since 4.4.0
+ *
+ * @param string $route_namespace Namespace.
+ * @param string $route The REST route.
+ * @param array $route_args Route arguments.
+ * @param bool $override Optional. Whether the route should be overridden if it already exists.
+ * Default false.
+ */
+ public function register_route( $route_namespace, $route, $route_args, $override = false ) {
+ if ( ! isset( $this->namespaces[ $route_namespace ] ) ) {
+ $this->namespaces[ $route_namespace ] = array();
+
+ $this->register_route(
+ $route_namespace,
+ '/' . $route_namespace,
+ array(
+ array(
+ 'methods' => self::READABLE,
+ 'callback' => array( $this, 'get_namespace_index' ),
+ 'args' => array(
+ 'namespace' => array(
+ 'default' => $route_namespace,
+ ),
+ 'context' => array(
+ 'default' => 'view',
+ ),
+ ),
+ ),
+ )
+ );
+ }
+
+ // Associative to avoid double-registration.
+ $this->namespaces[ $route_namespace ][ $route ] = true;
+
+ $route_args['namespace'] = $route_namespace;
+
+ if ( $override || empty( $this->endpoints[ $route ] ) ) {
+ $this->endpoints[ $route ] = $route_args;
+ } else {
+ $this->endpoints[ $route ] = array_merge( $this->endpoints[ $route ], $route_args );
+ }
+ }
+
+ /**
+ * Retrieves the route map.
+ *
+ * The route map is an associative array with path regexes as the keys. The
+ * value is an indexed array with the callback function/method as the first
+ * item, and a bitmask of HTTP methods as the second item (see the class
+ * constants).
+ *
+ * Each route can be mapped to more than one callback by using an array of
+ * the indexed arrays. This allows mapping e.g. GET requests to one callback
+ * and POST requests to another.
+ *
+ * Note that the path regexes (array keys) must have @ escaped, as this is
+ * used as the delimiter with preg_match()
+ *
+ * @since 4.4.0
+ * @since 5.4.0 Added `$route_namespace` parameter.
+ *
+ * @param string $route_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( $route_namespace = '' ) {
+ $endpoints = $this->endpoints;
+
+ if ( $route_namespace ) {
+ $endpoints = wp_list_filter( $endpoints, array( 'namespace' => $route_namespace ) );
+ }
+
+ /**
+ * Filters the array of available REST API endpoints.
+ *
+ * @since 4.4.0
+ *
+ * @param array $endpoints The available endpoints. An array of matching regex patterns, each mapped
+ * to an array of callbacks for the endpoint. These take the format
+ * `'/path/regex' => array( $callback, $bitmask )` or
+ * `'/path/regex' => array( array( $callback, $bitmask ).
+ */
+ $endpoints = apply_filters( 'rest_endpoints', $endpoints );
+
+ // Normalize the endpoints.
+ $defaults = array(
+ 'methods' => '',
+ 'accept_json' => false,
+ 'accept_raw' => false,
+ 'show_in_index' => true,
+ 'args' => array(),
+ );
+
+ foreach ( $endpoints as $route => &$handlers ) {
+
+ if ( isset( $handlers['callback'] ) ) {
+ // Single endpoint, add one deeper.
+ $handlers = array( $handlers );
+ }
+
+ if ( ! isset( $this->route_options[ $route ] ) ) {
+ $this->route_options[ $route ] = array();
+ }
+
+ foreach ( $handlers as $key => &$handler ) {
+
+ if ( ! is_numeric( $key ) ) {
+ // Route option, move it to the options.
+ $this->route_options[ $route ][ $key ] = $handler;
+ unset( $handlers[ $key ] );
+ continue;
+ }
+
+ $handler = wp_parse_args( $handler, $defaults );
+
+ // Allow comma-separated HTTP methods.
+ if ( is_string( $handler['methods'] ) ) {
+ $methods = explode( ',', $handler['methods'] );
+ } elseif ( is_array( $handler['methods'] ) ) {
+ $methods = $handler['methods'];
+ } else {
+ $methods = array();
+ }
+
+ $handler['methods'] = array();
+
+ foreach ( $methods as $method ) {
+ $method = strtoupper( trim( $method ) );
+ $handler['methods'][ $method ] = true;
+ }
+ }
+ }
+
+ return $endpoints;
+ }
+
+ /**
+ * Retrieves namespaces registered on the server.
+ *
+ * @since 4.4.0
+ *
+ * @return string[] List of registered namespaces.
+ */
+ public function get_namespaces() {
+ return array_keys( $this->namespaces );
+ }
+
+ /**
+ * Retrieves specified options for a route.
+ *
+ * @since 4.4.0
+ *
+ * @param string $route Route pattern to fetch options for.
+ * @return array|null Data as an associative array if found, or null if not found.
+ */
+ public function get_route_options( $route ) {
+ if ( ! isset( $this->route_options[ $route ] ) ) {
+ return null;
+ }
+
+ return $this->route_options[ $route ];
+ }
+
+ /**
+ * Matches the request to a callback and call it.
+ *
+ * @since 4.4.0
+ *
+ * @param WP_REST_Request $request Request to attempt dispatching.
+ * @return WP_REST_Response Response returned by the callback.
+ */
+ public function dispatch( $request ) {
+ /**
+ * Filters the pre-calculated result of a REST API dispatch request.
+ *
+ * Allow hijacking the request before dispatching by returning a non-empty. The returned value
+ * will be used to serve the request instead.
+ *
+ * @since 4.4.0
+ *
+ * @param mixed $result Response to replace the requested version with. Can be anything
+ * a normal endpoint can return, or null to not hijack the request.
+ * @param WP_REST_Server $server Server instance.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ $result = apply_filters( 'rest_pre_dispatch', null, $this, $request );
+
+ if ( ! empty( $result ) ) {
+
+ // Normalize to either WP_Error or WP_REST_Response...
+ $result = rest_ensure_response( $result );
+
+ // ...then convert WP_Error across.
+ if ( is_wp_error( $result ) ) {
+ $result = $this->error_to_response( $result );
+ }
+
+ return $result;
+ }
+
+ $error = null;
+ $matched = $this->match_request_to_handler( $request );
+
+ if ( is_wp_error( $matched ) ) {
+ return $this->error_to_response( $matched );
+ }
+
+ list( $route, $handler ) = $matched;
+
+ if ( ! is_callable( $handler['callback'] ) ) {
+ $error = new WP_Error(
+ 'rest_invalid_handler',
+ __( 'The handler for the route is invalid.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ if ( ! is_wp_error( $error ) ) {
+ $check_required = $request->has_valid_params();
+ if ( is_wp_error( $check_required ) ) {
+ $error = $check_required;
+ } else {
+ $check_sanitized = $request->sanitize_params();
+ if ( is_wp_error( $check_sanitized ) ) {
+ $error = $check_sanitized;
+ }
+ }
+ }
+
+ return $this->respond_to_request( $request, $route, $handler, $error );
+ }
+
+ /**
+ * Matches a request object to its handler.
+ *
+ * @access private
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request The request object.
+ * @return array|WP_Error The route and request handler on success or a WP_Error instance if no handler was found.
+ */
+ protected function match_request_to_handler( $request ) {
+ $method = $request->get_method();
+ $path = $request->get_route();
+
+ $with_namespace = array();
+
+ foreach ( $this->get_namespaces() as $namespace ) {
+ if ( str_starts_with( 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 ) {
+ continue;
+ }
+
+ $args = array();
+
+ foreach ( $matches as $param => $value ) {
+ if ( ! is_int( $param ) ) {
+ $args[ $param ] = $value;
+ }
+ }
+
+ foreach ( $handlers as $handler ) {
+ $callback = $handler['callback'];
+
+ // Fallback to GET method if no HEAD method is registered.
+ $checked_method = $method;
+ if ( 'HEAD' === $method && empty( $handler['methods']['HEAD'] ) ) {
+ $checked_method = 'GET';
+ }
+ if ( empty( $handler['methods'][ $checked_method ] ) ) {
+ continue;
+ }
+
+ if ( ! is_callable( $callback ) ) {
+ return array( $route, $handler );
+ }
+
+ $request->set_url_params( $args );
+ $request->set_attributes( $handler );
+
+ $defaults = array();
+
+ foreach ( $handler['args'] as $arg => $options ) {
+ if ( isset( $options['default'] ) ) {
+ $defaults[ $arg ] = $options['default'];
+ }
+ }
+
+ $request->set_default_params( $defaults );
+
+ return array( $route, $handler );
+ }
+ }
+
+ return new WP_Error(
+ 'rest_no_route',
+ __( 'No route was found matching the URL and request method.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ /**
+ * Dispatches the request to the callback handler.
+ *
+ * @access private
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request The request object.
+ * @param string $route The matched route regex.
+ * @param array $handler The matched route handler.
+ * @param WP_Error|null $response The current error object if any.
+ * @return WP_REST_Response
+ */
+ protected function respond_to_request( $request, $route, $handler, $response ) {
+ /**
+ * Filters the response before executing any REST API callbacks.
+ *
+ * Allows plugins to perform additional validation after a
+ * request is initialized and matched to a registered route,
+ * but before it is executed.
+ *
+ * Note that this filter will not be called for requests that
+ * fail to authenticate or match to a registered route.
+ *
+ * @since 4.7.0
+ *
+ * @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 );
+
+ // Check permission specified on the route.
+ if ( ! is_wp_error( $response ) && ! empty( $handler['permission_callback'] ) ) {
+ $permission = call_user_func( $handler['permission_callback'], $request );
+
+ 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() )
+ );
+ }
+ }
+
+ if ( ! is_wp_error( $response ) ) {
+ /**
+ * Filters the REST API dispatch request result.
+ *
+ * Allow plugins to override dispatching the request.
+ *
+ * @since 4.4.0
+ * @since 4.5.0 Added `$route` and `$handler` parameters.
+ *
+ * @param mixed $dispatch_result Dispatch result, will be used if not empty.
+ * @param WP_REST_Request $request Request used to generate the response.
+ * @param string $route Route matched for the request.
+ * @param array $handler Route handler used for the request.
+ */
+ $dispatch_result = apply_filters( 'rest_dispatch_request', null, $request, $route, $handler );
+
+ // Allow plugins to halt the request via this filter.
+ if ( null !== $dispatch_result ) {
+ $response = $dispatch_result;
+ } else {
+ $response = call_user_func( $handler['callback'], $request );
+ }
+ }
+
+ /**
+ * Filters the response immediately after executing any REST API
+ * callbacks.
+ *
+ * Allows plugins to perform any needed cleanup, for example,
+ * to undo changes made during the {@see 'rest_request_before_callbacks'}
+ * filter.
+ *
+ * Note that this filter will not be called for requests that
+ * fail to authenticate or match to a registered route.
+ *
+ * Note that an endpoint's `permission_callback` can still be
+ * called after this filter - see `rest_send_allow_header()`.
+ *
+ * @since 4.7.0
+ *
+ * @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 );
+
+ if ( is_wp_error( $response ) ) {
+ $response = $this->error_to_response( $response );
+ } else {
+ $response = rest_ensure_response( $response );
+ }
+
+ $response->set_matched_route( $route );
+ $response->set_matched_handler( $handler );
+
+ return $response;
+ }
+
+ /**
+ * Returns if an error occurred during most recent JSON encode/decode.
+ *
+ * Strings to be translated will be in format like
+ * "Encoding error: Maximum stack depth exceeded".
+ *
+ * @since 4.4.0
+ *
+ * @return false|string Boolean false or string error message.
+ */
+ protected function get_json_last_error() {
+ $last_error_code = json_last_error();
+
+ if ( JSON_ERROR_NONE === $last_error_code || empty( $last_error_code ) ) {
+ return false;
+ }
+
+ return json_last_error_msg();
+ }
+
+ /**
+ * Retrieves the site index.
+ *
+ * This endpoint describes the capabilities of the site.
+ *
+ * @since 4.4.0
+ *
+ * @param array $request {
+ * Request.
+ *
+ * @type string $context Context.
+ * }
+ * @return WP_REST_Response The API root index data.
+ */
+ public function get_index( $request ) {
+ // General site data.
+ $available = array(
+ 'name' => get_option( 'blogname' ),
+ 'description' => get_option( 'blogdescription' ),
+ 'url' => get_option( 'siteurl' ),
+ 'home' => home_url(),
+ 'gmt_offset' => get_option( 'gmt_offset' ),
+ 'timezone_string' => get_option( 'timezone_string' ),
+ 'namespaces' => array_keys( $this->namespaces ),
+ 'authentication' => array(),
+ 'routes' => $this->get_data_for_routes( $this->get_routes(), $request['context'] ),
+ );
+
+ $response = new WP_REST_Response( $available );
+
+ $fields = isset( $request['_fields'] ) ? $request['_fields'] : '';
+ $fields = wp_parse_list( $fields );
+ if ( empty( $fields ) ) {
+ $fields[] = '_links';
+ }
+
+ if ( $request->has_param( '_embed' ) ) {
+ $fields[] = '_embedded';
+ }
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_link( 'help', 'https://developer.wordpress.org/rest-api/' );
+ $this->add_active_theme_link_to_index( $response );
+ $this->add_site_logo_to_index( $response );
+ $this->add_site_icon_to_index( $response );
+ } else {
+ if ( rest_is_field_included( 'site_logo', $fields ) ) {
+ $this->add_site_logo_to_index( $response );
+ }
+ if ( rest_is_field_included( 'site_icon', $fields ) || rest_is_field_included( 'site_icon_url', $fields ) ) {
+ $this->add_site_icon_to_index( $response );
+ }
+ }
+
+ /**
+ * Filters the REST API root index data.
+ *
+ * This contains the data describing the API. This includes information
+ * about supported authentication schemes, supported namespaces, routes
+ * available on the API, and a small amount of data about the site.
+ *
+ * @since 4.4.0
+ * @since 6.0.0 Added `$request` parameter.
+ *
+ * @param WP_REST_Response $response Response data.
+ * @param WP_REST_Request $request Request data.
+ */
+ return apply_filters( 'rest_index', $response, $request );
+ }
+
+ /**
+ * Adds a link to the active theme for users who have proper permissions.
+ *
+ * @since 5.7.0
+ *
+ * @param WP_REST_Response $response REST API response.
+ */
+ protected function add_active_theme_link_to_index( WP_REST_Response $response ) {
+ $should_add = current_user_can( 'switch_themes' ) || current_user_can( 'manage_network_themes' );
+
+ if ( ! $should_add && current_user_can( 'edit_posts' ) ) {
+ $should_add = true;
+ }
+
+ if ( ! $should_add ) {
+ foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
+ if ( current_user_can( $post_type->cap->edit_posts ) ) {
+ $should_add = true;
+ break;
+ }
+ }
+ }
+
+ if ( $should_add ) {
+ $theme = wp_get_theme();
+ $response->add_link( 'https://api.w.org/active-theme', rest_url( 'wp/v2/themes/' . $theme->get_stylesheet() ) );
+ }
+ }
+
+ /**
+ * Exposes the site logo through the WordPress REST API.
+ *
+ * This is used for fetching this information when user has no rights
+ * to update settings.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Response $response REST API response.
+ */
+ protected function add_site_logo_to_index( WP_REST_Response $response ) {
+ $site_logo_id = get_theme_mod( 'custom_logo', 0 );
+
+ $this->add_image_to_index( $response, $site_logo_id, 'site_logo' );
+ }
+
+ /**
+ * Exposes the site icon through the WordPress REST API.
+ *
+ * This is used for fetching this information when user has no rights
+ * to update settings.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Response $response REST API response.
+ */
+ protected function add_site_icon_to_index( WP_REST_Response $response ) {
+ $site_icon_id = get_option( 'site_icon', 0 );
+
+ $this->add_image_to_index( $response, $site_icon_id, 'site_icon' );
+
+ $response->data['site_icon_url'] = get_site_icon_url();
+ }
+
+ /**
+ * Exposes an image through the WordPress REST API.
+ * This is used for fetching this information when user has no rights
+ * to update settings.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Response $response REST API response.
+ * @param int $image_id Image attachment ID.
+ * @param string $type Type of Image.
+ */
+ protected function add_image_to_index( WP_REST_Response $response, $image_id, $type ) {
+ $response->data[ $type ] = (int) $image_id;
+ if ( $image_id ) {
+ $response->add_link(
+ 'https://api.w.org/featuredmedia',
+ rest_url( rest_get_route_for_post( $image_id ) ),
+ array(
+ 'embeddable' => true,
+ 'type' => $type,
+ )
+ );
+ }
+ }
+
+ /**
+ * Retrieves the index for a namespace.
+ *
+ * @since 4.4.0
+ *
+ * @param WP_REST_Request $request REST request instance.
+ * @return WP_REST_Response|WP_Error WP_REST_Response instance if the index was found,
+ * WP_Error if the namespace isn't set.
+ */
+ public function get_namespace_index( $request ) {
+ $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 )
+ );
+ }
+
+ $routes = $this->namespaces[ $namespace ];
+ $endpoints = array_intersect_key( $this->get_routes(), $routes );
+
+ $data = array(
+ 'namespace' => $namespace,
+ 'routes' => $this->get_data_for_routes( $endpoints, $request['context'] ),
+ );
+ $response = rest_ensure_response( $data );
+
+ // Link to the root index.
+ $response->add_link( 'up', rest_url( '/' ) );
+
+ /**
+ * Filters the REST API namespace index data.
+ *
+ * This typically is just the route data for the namespace, but you can
+ * add any data you'd like here.
+ *
+ * @since 4.4.0
+ *
+ * @param WP_REST_Response $response Response data.
+ * @param WP_REST_Request $request Request data. The namespace is passed as the 'namespace' parameter.
+ */
+ return apply_filters( 'rest_namespace_index', $response, $request );
+ }
+
+ /**
+ * Retrieves the publicly-visible data for routes.
+ *
+ * @since 4.4.0
+ *
+ * @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, keyed by route.
+ */
+ public function get_data_for_routes( $routes, $context = 'view' ) {
+ $available = array();
+
+ // Find the available routes.
+ foreach ( $routes as $route => $callbacks ) {
+ $data = $this->get_data_for_route( $route, $callbacks, $context );
+ if ( empty( $data ) ) {
+ continue;
+ }
+
+ /**
+ * Filters the publicly-visible data for a single REST API route.
+ *
+ * @since 4.4.0
+ *
+ * @param array $data Publicly-visible data for the route.
+ */
+ $available[ $route ] = apply_filters( 'rest_endpoints_description', $data );
+ }
+
+ /**
+ * Filters the publicly-visible data for REST API routes.
+ *
+ * This data is exposed on indexes and can be used by clients or
+ * developers to investigate the site and find out how to use it. It
+ * acts as a form of self-documentation.
+ *
+ * @since 4.4.0
+ *
+ * @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 );
+ }
+
+ /**
+ * Retrieves publicly-visible data for the route.
+ *
+ * @since 4.4.0
+ *
+ * @param string $route Route to get data for.
+ * @param array $callbacks Callbacks to convert to data.
+ * @param string $context Optional. Context for the data. Accepts 'view' or 'help'. Default 'view'.
+ * @return array|null Data for the route, or null if no publicly-visible data.
+ */
+ public function get_data_for_route( $route, $callbacks, $context = 'view' ) {
+ $data = array(
+ 'namespace' => '',
+ 'methods' => array(),
+ 'endpoints' => array(),
+ );
+
+ $allow_batch = false;
+
+ if ( isset( $this->route_options[ $route ] ) ) {
+ $options = $this->route_options[ $route ];
+
+ if ( isset( $options['namespace'] ) ) {
+ $data['namespace'] = $options['namespace'];
+ }
+
+ $allow_batch = isset( $options['allow_batch'] ) ? $options['allow_batch'] : false;
+
+ if ( isset( $options['schema'] ) && 'help' === $context ) {
+ $data['schema'] = call_user_func( $options['schema'] );
+ }
+ }
+
+ $allowed_schema_keywords = array_flip( rest_get_allowed_schema_keywords() );
+
+ $route = preg_replace( '#\(\?P<(\w+?)>.*?\)#', '{$1}', $route );
+
+ foreach ( $callbacks as $callback ) {
+ // Skip to the next route if any callback is hidden.
+ if ( empty( $callback['show_in_index'] ) ) {
+ continue;
+ }
+
+ $data['methods'] = array_merge( $data['methods'], array_keys( $callback['methods'] ) );
+ $endpoint_data = array(
+ 'methods' => array_keys( $callback['methods'] ),
+ );
+
+ $callback_batch = isset( $callback['allow_batch'] ) ? $callback['allow_batch'] : $allow_batch;
+
+ if ( $callback_batch ) {
+ $endpoint_data['allow_batch'] = $callback_batch;
+ }
+
+ if ( isset( $callback['args'] ) ) {
+ $endpoint_data['args'] = array();
+
+ foreach ( $callback['args'] as $key => $opts ) {
+ if ( is_string( $opts ) ) {
+ $opts = array( $opts => 0 );
+ } elseif ( ! is_array( $opts ) ) {
+ $opts = array();
+ }
+ $arg_data = array_intersect_key( $opts, $allowed_schema_keywords );
+ $arg_data['required'] = ! empty( $opts['required'] );
+
+ $endpoint_data['args'][ $key ] = $arg_data;
+ }
+ }
+
+ $data['endpoints'][] = $endpoint_data;
+
+ // For non-variable routes, generate links.
+ if ( ! str_contains( $route, '{' ) ) {
+ $data['_links'] = array(
+ 'self' => array(
+ array(
+ 'href' => rest_url( $route ),
+ ),
+ ),
+ );
+ }
+ }
+
+ if ( empty( $data['methods'] ) ) {
+ // No methods supported, hide the route.
+ return null;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Gets the maximum number of requests that can be included in a batch.
+ *
+ * @since 5.6.0
+ *
+ * @return int The maximum requests.
+ */
+ protected function get_max_batch_size() {
+ /**
+ * Filters the maximum number of REST API requests that can be included in a batch.
+ *
+ * @since 5.6.0
+ *
+ * @param int $max_size The maximum size.
+ */
+ return apply_filters( 'rest_get_max_batch_size', 25 );
+ }
+
+ /**
+ * Serves the batch/v1 request.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $batch_request The batch request object.
+ * @return WP_REST_Response The generated response object.
+ */
+ public function serve_batch_request_v1( WP_REST_Request $batch_request ) {
+ $requests = array();
+
+ foreach ( $batch_request['requests'] as $args ) {
+ $parsed_url = wp_parse_url( $args['path'] );
+
+ if ( false === $parsed_url ) {
+ $requests[] = new WP_Error( 'parse_path_failed', __( 'Could not parse the path.' ), array( 'status' => 400 ) );
+
+ continue;
+ }
+
+ $single_request = new WP_REST_Request( isset( $args['method'] ) ? $args['method'] : 'POST', $parsed_url['path'] );
+
+ if ( ! empty( $parsed_url['query'] ) ) {
+ $query_args = null; // Satisfy linter.
+ wp_parse_str( $parsed_url['query'], $query_args );
+ $single_request->set_query_params( $query_args );
+ }
+
+ if ( ! empty( $args['body'] ) ) {
+ $single_request->set_body_params( $args['body'] );
+ }
+
+ if ( ! empty( $args['headers'] ) ) {
+ $single_request->set_headers( $args['headers'] );
+ }
+
+ $requests[] = $single_request;
+ }
+
+ $matches = array();
+ $validation = array();
+ $has_error = false;
+
+ foreach ( $requests as $single_request ) {
+ $match = $this->match_request_to_handler( $single_request );
+ $matches[] = $match;
+ $error = null;
+
+ if ( is_wp_error( $match ) ) {
+ $error = $match;
+ }
+
+ if ( ! $error ) {
+ list( $route, $handler ) = $match;
+
+ if ( isset( $handler['allow_batch'] ) ) {
+ $allow_batch = $handler['allow_batch'];
+ } else {
+ $route_options = $this->get_route_options( $route );
+ $allow_batch = isset( $route_options['allow_batch'] ) ? $route_options['allow_batch'] : false;
+ }
+
+ if ( ! is_array( $allow_batch ) || empty( $allow_batch['v1'] ) ) {
+ $error = new WP_Error(
+ 'rest_batch_not_allowed',
+ __( 'The requested route does not support batch requests.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ if ( ! $error ) {
+ $check_required = $single_request->has_valid_params();
+ if ( is_wp_error( $check_required ) ) {
+ $error = $check_required;
+ }
+ }
+
+ if ( ! $error ) {
+ $check_sanitized = $single_request->sanitize_params();
+ if ( is_wp_error( $check_sanitized ) ) {
+ $error = $check_sanitized;
+ }
+ }
+
+ if ( $error ) {
+ $has_error = true;
+ $validation[] = $error;
+ } else {
+ $validation[] = true;
+ }
+ }
+
+ $responses = array();
+
+ if ( $has_error && 'require-all-validate' === $batch_request['validation'] ) {
+ foreach ( $validation as $valid ) {
+ if ( is_wp_error( $valid ) ) {
+ $responses[] = $this->envelope_response( $this->error_to_response( $valid ), false )->get_data();
+ } else {
+ $responses[] = null;
+ }
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'failed' => 'validation',
+ 'responses' => $responses,
+ ),
+ WP_Http::MULTI_STATUS
+ );
+ }
+
+ foreach ( $requests as $i => $single_request ) {
+ $clean_request = clone $single_request;
+ $clean_request->set_url_params( array() );
+ $clean_request->set_attributes( array() );
+ $clean_request->set_default_params( array() );
+
+ /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
+ $result = apply_filters( 'rest_pre_dispatch', null, $this, $clean_request );
+
+ if ( empty( $result ) ) {
+ $match = $matches[ $i ];
+ $error = null;
+
+ if ( is_wp_error( $validation[ $i ] ) ) {
+ $error = $validation[ $i ];
+ }
+
+ if ( is_wp_error( $match ) ) {
+ $result = $this->error_to_response( $match );
+ } else {
+ list( $route, $handler ) = $match;
+
+ if ( ! $error && ! is_callable( $handler['callback'] ) ) {
+ $error = new WP_Error(
+ 'rest_invalid_handler',
+ __( 'The handler for the route is invalid' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $result = $this->respond_to_request( $single_request, $route, $handler, $error );
+ }
+ }
+
+ /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
+ $result = apply_filters( 'rest_post_dispatch', rest_ensure_response( $result ), $this, $single_request );
+
+ $responses[] = $this->envelope_response( $result, false )->get_data();
+ }
+
+ return new WP_REST_Response( array( 'responses' => $responses ), WP_Http::MULTI_STATUS );
+ }
+
+ /**
+ * Sends an HTTP status code.
+ *
+ * @since 4.4.0
+ *
+ * @param int $code HTTP status.
+ */
+ protected function set_status( $code ) {
+ status_header( $code );
+ }
+
+ /**
+ * Sends an HTTP header.
+ *
+ * @since 4.4.0
+ *
+ * @param string $key Header key.
+ * @param string $value Header value.
+ */
+ public function send_header( $key, $value ) {
+ /*
+ * Sanitize as per RFC2616 (Section 4.2):
+ *
+ * Any LWS that occurs between field-content MAY be replaced with a
+ * single SP before interpreting the field value or forwarding the
+ * message downstream.
+ */
+ $value = preg_replace( '/\s+/', ' ', $value );
+ header( sprintf( '%s: %s', $key, $value ) );
+ }
+
+ /**
+ * Sends multiple HTTP headers.
+ *
+ * @since 4.4.0
+ *
+ * @param array $headers Map of header name to header value.
+ */
+ public function send_headers( $headers ) {
+ foreach ( $headers as $key => $value ) {
+ $this->send_header( $key, $value );
+ }
+ }
+
+ /**
+ * Removes an HTTP header from the current response.
+ *
+ * @since 4.8.0
+ *
+ * @param string $key Header key.
+ */
+ public function remove_header( $key ) {
+ header_remove( $key );
+ }
+
+ /**
+ * Retrieves the raw request entity (body).
+ *
+ * @since 4.4.0
+ *
+ * @global string $HTTP_RAW_POST_DATA Raw post data.
+ *
+ * @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;
+
+ // $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
+ }
+
+ /**
+ * Extracts headers from a PHP-style $_SERVER array.
+ *
+ * @since 4.4.0
+ *
+ * @param array $server Associative array similar to `$_SERVER`.
+ * @return array Headers extracted from the input.
+ */
+ public function get_headers( $server ) {
+ $headers = array();
+
+ // CONTENT_* headers are not prefixed with HTTP_.
+ $additional = array(
+ 'CONTENT_LENGTH' => true,
+ 'CONTENT_MD5' => true,
+ 'CONTENT_TYPE' => true,
+ );
+
+ foreach ( $server as $key => $value ) {
+ if ( str_starts_with( $key, 'HTTP_' ) ) {
+ $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;
+ }
+ }
+
+ return $headers;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php
new file mode 100644
index 0000000..b0ac65a
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php
@@ -0,0 +1,848 @@
+<?php
+/**
+ * REST API: WP_REST_Application_Passwords_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.6.0
+ */
+
+/**
+ * Core class to access a user's application passwords via the REST API.
+ *
+ * @since 5.6.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Application_Passwords_Controller extends WP_REST_Controller {
+
+ /**
+ * Application Passwords controller constructor.
+ *
+ * @since 5.6.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'users/(?P<user_id>(?:[\d]+|me))/application-passwords';
+ }
+
+ /**
+ * Registers the REST API routes for the application passwords controller.
+ *
+ * @since 5.6.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_items' ),
+ 'permission_callback' => array( $this, 'delete_items_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/introspect',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_current_item' ),
+ 'permission_callback' => array( $this, 'get_current_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<uuid>[\w\-]+)',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'permission_callback' => array( $this, 'update_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to get application passwords.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ if ( ! current_user_can( 'list_app_passwords', $user->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_list_application_passwords',
+ __( 'Sorry, you are not allowed to list application passwords for this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves a collection of application passwords.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $passwords = WP_Application_Passwords::get_user_application_passwords( $user->ID );
+ $response = array();
+
+ foreach ( $passwords as $password ) {
+ $response[] = $this->prepare_response_for_collection(
+ $this->prepare_item_for_response( $password, $request )
+ );
+ }
+
+ return new WP_REST_Response( $response );
+ }
+
+ /**
+ * Checks if a given request has access to get a specific application password.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ if ( ! current_user_can( 'read_app_password', $user->ID, $request['uuid'] ) ) {
+ return new WP_Error(
+ 'rest_cannot_read_application_password',
+ __( 'Sorry, you are not allowed to read this application password.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves one application password from the collection.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $password = $this->get_application_password( $request );
+
+ if ( is_wp_error( $password ) ) {
+ return $password;
+ }
+
+ return $this->prepare_item_for_response( $password, $request );
+ }
+
+ /**
+ * Checks if a given request has access to create application passwords.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
+ */
+ public function create_item_permissions_check( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ if ( ! current_user_can( 'create_app_password', $user->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_create_application_passwords',
+ __( 'Sorry, you are not allowed to create application passwords for this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Creates an application password.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $prepared = $this->prepare_item_for_database( $request );
+
+ if ( is_wp_error( $prepared ) ) {
+ return $prepared;
+ }
+
+ $created = WP_Application_Passwords::create_new_application_password( $user->ID, wp_slash( (array) $prepared ) );
+
+ if ( is_wp_error( $created ) ) {
+ return $created;
+ }
+
+ $password = $created[0];
+ $item = WP_Application_Passwords::get_user_application_password( $user->ID, $created[1]['uuid'] );
+
+ $item['new_password'] = WP_Application_Passwords::chunk_password( $password );
+ $fields_update = $this->update_additional_fields_for_object( $item, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ /**
+ * Fires after a single application password is completely created or updated via the REST API.
+ *
+ * @since 5.6.0
+ *
+ * @param array $item Inserted or updated password item.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating an application password, false when updating.
+ */
+ do_action( 'rest_after_insert_application_password', $item, $request, true );
+
+ $request->set_param( 'context', 'edit' );
+ $response = $this->prepare_item_for_response( $item, $request );
+
+ $response->set_status( 201 );
+ $response->header( 'Location', $response->get_links()['self'][0]['href'] );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to update application passwords.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
+ */
+ public function update_item_permissions_check( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ if ( ! current_user_can( 'edit_app_password', $user->ID, $request['uuid'] ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit_application_password',
+ __( 'Sorry, you are not allowed to edit this application password.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Updates an application password.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $item = $this->get_application_password( $request );
+
+ if ( is_wp_error( $item ) ) {
+ return $item;
+ }
+
+ $prepared = $this->prepare_item_for_database( $request );
+
+ if ( is_wp_error( $prepared ) ) {
+ return $prepared;
+ }
+
+ $saved = WP_Application_Passwords::update_application_password( $user->ID, $item['uuid'], wp_slash( (array) $prepared ) );
+
+ if ( is_wp_error( $saved ) ) {
+ return $saved;
+ }
+
+ $fields_update = $this->update_additional_fields_for_object( $item, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $item = WP_Application_Passwords::get_user_application_password( $user->ID, $item['uuid'] );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php */
+ do_action( 'rest_after_insert_application_password', $item, $request, false );
+
+ $request->set_param( 'context', 'edit' );
+ return $this->prepare_item_for_response( $item, $request );
+ }
+
+ /**
+ * Checks if a given request has access to delete all application passwords for a user.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise.
+ */
+ public function delete_items_permissions_check( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ if ( ! current_user_can( 'delete_app_passwords', $user->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_delete_application_passwords',
+ __( 'Sorry, you are not allowed to delete application passwords for this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Deletes all application passwords for a user.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_items( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $deleted = WP_Application_Passwords::delete_all_application_passwords( $user->ID );
+
+ if ( is_wp_error( $deleted ) ) {
+ return $deleted;
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'deleted' => true,
+ 'count' => $deleted,
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to delete a specific application password for a user.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise.
+ */
+ public function delete_item_permissions_check( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ if ( ! current_user_can( 'delete_app_password', $user->ID, $request['uuid'] ) ) {
+ return new WP_Error(
+ 'rest_cannot_delete_application_password',
+ __( 'Sorry, you are not allowed to delete this application password.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Deletes an application password for a user.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_item( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $password = $this->get_application_password( $request );
+
+ if ( is_wp_error( $password ) ) {
+ return $password;
+ }
+
+ $request->set_param( 'context', 'edit' );
+ $previous = $this->prepare_item_for_response( $password, $request );
+ $deleted = WP_Application_Passwords::delete_application_password( $user->ID, $password['uuid'] );
+
+ if ( is_wp_error( $deleted ) ) {
+ return $deleted;
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'deleted' => true,
+ 'previous' => $previous->get_data(),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to get the currently used application password for a user.
+ *
+ * @since 5.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_current_item_permissions_check( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ if ( get_current_user_id() !== $user->ID ) {
+ return new WP_Error(
+ 'rest_cannot_introspect_app_password_for_non_authenticated_user',
+ __( 'The authenticated application password can only be introspected for the current user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves the application password being currently used for authentication of a user.
+ *
+ * @since 5.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_current_item( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $uuid = rest_get_authenticated_app_password();
+
+ if ( ! $uuid ) {
+ return new WP_Error(
+ 'rest_no_authenticated_app_password',
+ __( 'Cannot introspect application password.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $password = WP_Application_Passwords::get_user_application_password( $user->ID, $uuid );
+
+ if ( ! $password ) {
+ return new WP_Error(
+ 'rest_application_password_not_found',
+ __( 'Application password not found.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return $this->prepare_item_for_response( $password, $request );
+ }
+
+ /**
+ * Performs a permissions check for the request.
+ *
+ * @since 5.6.0
+ * @deprecated 5.7.0 Use `edit_user` directly or one of the specific meta capabilities introduced in 5.7.0.
+ *
+ * @param WP_REST_Request $request
+ * @return true|WP_Error
+ */
+ protected function do_permissions_check( $request ) {
+ _deprecated_function( __METHOD__, '5.7.0' );
+
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ if ( ! current_user_can( 'edit_user', $user->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_manage_application_passwords',
+ __( 'Sorry, you are not allowed to manage application passwords for this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Prepares an application password for a create or update operation.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return object|WP_Error The prepared item, or WP_Error object on failure.
+ */
+ protected function prepare_item_for_database( $request ) {
+ $prepared = (object) array(
+ 'name' => $request['name'],
+ );
+
+ if ( $request['app_id'] && ! $request['uuid'] ) {
+ $prepared->app_id = $request['app_id'];
+ }
+
+ /**
+ * Filters an application password before it is inserted via the REST API.
+ *
+ * @since 5.6.0
+ *
+ * @param stdClass $prepared An object representing a single application password prepared for inserting or updating the database.
+ * @param WP_REST_Request $request Request object.
+ */
+ return apply_filters( 'rest_pre_insert_application_password', $prepared, $request );
+ }
+
+ /**
+ * Prepares the application password for the REST response.
+ *
+ * @since 5.6.0
+ *
+ * @param array $item WordPress representation of the item.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $fields = $this->get_fields_for_response( $request );
+
+ $prepared = array(
+ 'uuid' => $item['uuid'],
+ 'app_id' => empty( $item['app_id'] ) ? '' : $item['app_id'],
+ 'name' => $item['name'],
+ 'created' => gmdate( 'Y-m-d\TH:i:s', $item['created'] ),
+ 'last_used' => $item['last_used'] ? gmdate( 'Y-m-d\TH:i:s', $item['last_used'] ) : null,
+ 'last_ip' => $item['last_ip'] ? $item['last_ip'] : null,
+ );
+
+ if ( isset( $item['new_password'] ) ) {
+ $prepared['password'] = $item['new_password'];
+ }
+
+ $prepared = $this->add_additional_fields_to_object( $prepared, $request );
+ $prepared = $this->filter_response_by_context( $prepared, $request['context'] );
+
+ $response = new WP_REST_Response( $prepared );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $user, $item ) );
+ }
+
+ /**
+ * Filters the REST API response for an application password.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param array $item The application password array.
+ * @param WP_REST_Request $request The request object.
+ */
+ return apply_filters( 'rest_prepare_application_password', $response, $item, $request );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_User $user The requested user.
+ * @param array $item The application password.
+ * @return array The list of links.
+ */
+ protected function prepare_links( WP_User $user, $item ) {
+ return array(
+ 'self' => array(
+ 'href' => rest_url(
+ sprintf(
+ '%s/users/%d/application-passwords/%s',
+ $this->namespace,
+ $user->ID,
+ $item['uuid']
+ )
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Gets the requested user.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request The request object.
+ * @return WP_User|WP_Error The WordPress user associated with the request, or a WP_Error if none found.
+ */
+ protected function get_user( $request ) {
+ if ( ! wp_is_application_passwords_available() ) {
+ return new WP_Error(
+ 'application_passwords_disabled',
+ __( 'Application passwords are not available.' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ $error = new WP_Error(
+ 'rest_user_invalid_id',
+ __( 'Invalid user ID.' ),
+ array( 'status' => 404 )
+ );
+
+ $id = $request['user_id'];
+
+ if ( 'me' === $id ) {
+ if ( ! is_user_logged_in() ) {
+ return new WP_Error(
+ 'rest_not_logged_in',
+ __( 'You are not currently logged in.' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ $user = wp_get_current_user();
+ } else {
+ $id = (int) $id;
+
+ if ( $id <= 0 ) {
+ return $error;
+ }
+
+ $user = get_userdata( $id );
+ }
+
+ if ( empty( $user ) || ! $user->exists() ) {
+ return $error;
+ }
+
+ if ( is_multisite() && ! user_can( $user->ID, 'manage_sites' ) && ! is_user_member_of_blog( $user->ID ) ) {
+ return $error;
+ }
+
+ if ( ! wp_is_application_passwords_available_for_user( $user ) ) {
+ return new WP_Error(
+ 'application_passwords_disabled_for_user',
+ __( 'Application passwords are not available for your account. Please contact the site administrator for assistance.' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ return $user;
+ }
+
+ /**
+ * Gets the requested application password for a user.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request The request object.
+ * @return array|WP_Error The application password details if found, a WP_Error otherwise.
+ */
+ protected function get_application_password( $request ) {
+ $user = $this->get_user( $request );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $password = WP_Application_Passwords::get_user_application_password( $user->ID, $request['uuid'] );
+
+ if ( ! $password ) {
+ return new WP_Error(
+ 'rest_application_password_not_found',
+ __( 'Application password not found.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ return $password;
+ }
+
+ /**
+ * Retrieves the query params for the collections.
+ *
+ * @since 5.6.0
+ *
+ * @return array Query parameters for the collection.
+ */
+ public function get_collection_params() {
+ return array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ );
+ }
+
+ /**
+ * Retrieves the application password's schema, conforming to JSON Schema.
+ *
+ * @since 5.6.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'application-password',
+ 'type' => 'object',
+ 'properties' => array(
+ 'uuid' => array(
+ 'description' => __( 'The unique identifier for the application password.' ),
+ 'type' => 'string',
+ 'format' => 'uuid',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'app_id' => array(
+ 'description' => __( 'A UUID provided by the application to uniquely identify it. It is recommended to use an UUID v5 with the URL or DNS namespace.' ),
+ 'type' => 'string',
+ 'format' => 'uuid',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'name' => array(
+ 'description' => __( 'The name of the application password.' ),
+ 'type' => 'string',
+ 'required' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'minLength' => 1,
+ 'pattern' => '.*\S.*',
+ ),
+ 'password' => array(
+ 'description' => __( 'The generated password. Only available after adding an application.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'created' => array(
+ 'description' => __( 'The GMT date the application password was created.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'last_used' => array(
+ 'description' => __( 'The GMT date the application password was last used.' ),
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'last_ip' => array(
+ 'description' => __( 'The IP address the application password was last used by.' ),
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'ip',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
new file mode 100644
index 0000000..7367c0f
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php
@@ -0,0 +1,1455 @@
+<?php
+/**
+ * REST API: WP_REST_Attachments_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core controller used to access attachments via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Posts_Controller
+ */
+class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller {
+
+ /**
+ * Whether the controller supports batching.
+ *
+ * @since 5.9.0
+ * @var false
+ */
+ protected $allow_batch = false;
+
+ /**
+ * Registers the routes for attachments.
+ *
+ * @since 5.3.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+ parent::register_routes();
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[\d]+)/post-process',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'post_process_item' ),
+ 'permission_callback' => array( $this, 'post_process_item_permissions_check' ),
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the attachment.' ),
+ 'type' => 'integer',
+ ),
+ 'action' => array(
+ 'type' => 'string',
+ 'enum' => array( 'create-image-subsizes' ),
+ 'required' => true,
+ ),
+ ),
+ )
+ );
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[\d]+)/edit',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'edit_media_item' ),
+ 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
+ 'args' => $this->get_edit_media_item_args(),
+ )
+ );
+ }
+
+ /**
+ * Determines the allowed query_vars for a get_items() response and
+ * prepares for WP_Query.
+ *
+ * @since 4.7.0
+ *
+ * @param array $prepared_args Optional. Array of prepared arguments. Default empty array.
+ * @param WP_REST_Request $request Optional. Request to prepare items for.
+ * @return array Array of query arguments.
+ */
+ protected function prepare_items_query( $prepared_args = array(), $request = null ) {
+ $query_args = parent::prepare_items_query( $prepared_args, $request );
+
+ if ( empty( $query_args['post_status'] ) ) {
+ $query_args['post_status'] = 'inherit';
+ }
+
+ $media_types = $this->get_media_types();
+
+ if ( ! empty( $request['media_type'] ) && isset( $media_types[ $request['media_type'] ] ) ) {
+ $query_args['post_mime_type'] = $media_types[ $request['media_type'] ];
+ }
+
+ if ( ! empty( $request['mime_type'] ) ) {
+ $parts = explode( '/', $request['mime_type'] );
+ if ( isset( $media_types[ $parts[0] ] ) && in_array( $request['mime_type'], $media_types[ $parts[0] ], true ) ) {
+ $query_args['post_mime_type'] = $request['mime_type'];
+ }
+ }
+
+ // Filter query clauses to include filenames.
+ if ( isset( $query_args['s'] ) ) {
+ add_filter( 'wp_allow_query_attachment_by_filename', '__return_true' );
+ }
+
+ return $query_args;
+ }
+
+ /**
+ * Checks if a given request has access to create an attachment.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error Boolean true if the attachment may be created, or a WP_Error if not.
+ */
+ public function create_item_permissions_check( $request ) {
+ $ret = parent::create_item_permissions_check( $request );
+
+ if ( ! $ret || is_wp_error( $ret ) ) {
+ return $ret;
+ }
+
+ if ( ! current_user_can( 'upload_files' ) ) {
+ return new WP_Error(
+ 'rest_cannot_create',
+ __( 'Sorry, you are not allowed to upload media on this site.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Attaching media to a post requires ability to edit said post.
+ if ( ! empty( $request['post'] ) && ! current_user_can( 'edit_post', (int) $request['post'] ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit',
+ __( 'Sorry, you are not allowed to upload media to this post.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Creates a single attachment.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+ if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) {
+ return new WP_Error(
+ 'rest_invalid_param',
+ __( 'Invalid parent type.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $insert = $this->insert_attachment( $request );
+
+ if ( is_wp_error( $insert ) ) {
+ return $insert;
+ }
+
+ $schema = $this->get_item_schema();
+
+ // Extract by name.
+ $attachment_id = $insert['attachment_id'];
+ $file = $insert['file'];
+
+ if ( isset( $request['alt_text'] ) ) {
+ update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $request['alt_text'] ) );
+ }
+
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $attachment_id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $attachment = get_post( $attachment_id );
+ $fields_update = $this->update_additional_fields_for_object( $attachment, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ /**
+ * Fires after a single attachment is completely created or updated via the REST API.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_Post $attachment Inserted or updated attachment object.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating an attachment, false when updating.
+ */
+ do_action( 'rest_after_insert_attachment', $attachment, $request, true );
+
+ wp_after_insert_post( $attachment, false, null );
+
+ if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
+ /*
+ * Set a custom header with the attachment_id.
+ * Used by the browser/client to resume creating image sub-sizes after a PHP fatal error.
+ */
+ header( 'X-WP-Upload-Attachment-ID: ' . $attachment_id );
+ }
+
+ // Include media and image functions to get access to wp_generate_attachment_metadata().
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+
+ /*
+ * Post-process the upload (create image sub-sizes, make PDF thumbnails, etc.) and insert attachment meta.
+ * At this point the server may run out of resources and post-processing of uploaded images may fail.
+ */
+ wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file ) );
+
+ $response = $this->prepare_item_for_response( $attachment, $request );
+ $response = rest_ensure_response( $response );
+ $response->set_status( 201 );
+ $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $attachment_id ) ) );
+
+ return $response;
+ }
+
+ /**
+ * Inserts the attachment post in the database. Does not update the attachment meta.
+ *
+ * @since 5.3.0
+ *
+ * @param WP_REST_Request $request
+ * @return array|WP_Error
+ */
+ protected function insert_attachment( $request ) {
+ // Get the file via $_FILES or raw data.
+ $files = $request->get_file_params();
+ $headers = $request->get_headers();
+
+ if ( ! empty( $files ) ) {
+ $file = $this->upload_from_file( $files, $headers );
+ } else {
+ $file = $this->upload_from_data( $request->get_body(), $headers );
+ }
+
+ if ( is_wp_error( $file ) ) {
+ return $file;
+ }
+
+ $name = wp_basename( $file['file'] );
+ $name_parts = pathinfo( $name );
+ $name = trim( substr( $name, 0, -( 1 + strlen( $name_parts['extension'] ) ) ) );
+
+ $url = $file['url'];
+ $type = $file['type'];
+ $file = $file['file'];
+
+ // Include image functions to get access to wp_read_image_metadata().
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+
+ // Use image exif/iptc data for title and caption defaults if possible.
+ $image_meta = wp_read_image_metadata( $file );
+
+ if ( ! empty( $image_meta ) ) {
+ if ( empty( $request['title'] ) && trim( $image_meta['title'] ) && ! is_numeric( sanitize_title( $image_meta['title'] ) ) ) {
+ $request['title'] = $image_meta['title'];
+ }
+
+ if ( empty( $request['caption'] ) && trim( $image_meta['caption'] ) ) {
+ $request['caption'] = $image_meta['caption'];
+ }
+ }
+
+ $attachment = $this->prepare_item_for_database( $request );
+
+ $attachment->post_mime_type = $type;
+ $attachment->guid = $url;
+
+ if ( empty( $attachment->post_title ) ) {
+ $attachment->post_title = preg_replace( '/\.[^.]+$/', '', wp_basename( $file ) );
+ }
+
+ // $post_parent is inherited from $attachment['post_parent'].
+ $id = wp_insert_attachment( wp_slash( (array) $attachment ), $file, 0, true, false );
+
+ if ( is_wp_error( $id ) ) {
+ if ( 'db_update_error' === $id->get_error_code() ) {
+ $id->add_data( array( 'status' => 500 ) );
+ } else {
+ $id->add_data( array( 'status' => 400 ) );
+ }
+
+ return $id;
+ }
+
+ $attachment = get_post( $id );
+
+ /**
+ * Fires after a single attachment is created or updated via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post $attachment Inserted or updated attachment
+ * object.
+ * @param WP_REST_Request $request The request sent to the API.
+ * @param bool $creating True when creating an attachment, false when updating.
+ */
+ do_action( 'rest_insert_attachment', $attachment, $request, true );
+
+ return array(
+ 'attachment_id' => $id,
+ 'file' => $file,
+ );
+ }
+
+ /**
+ * Updates a single attachment.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) {
+ return new WP_Error(
+ 'rest_invalid_param',
+ __( 'Invalid parent type.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $attachment_before = get_post( $request['id'] );
+ $response = parent::update_item( $request );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $response = rest_ensure_response( $response );
+ $data = $response->get_data();
+
+ if ( isset( $request['alt_text'] ) ) {
+ update_post_meta( $data['id'], '_wp_attachment_image_alt', $request['alt_text'] );
+ }
+
+ $attachment = get_post( $request['id'] );
+
+ $fields_update = $this->update_additional_fields_for_object( $attachment, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */
+ do_action( 'rest_after_insert_attachment', $attachment, $request, false );
+
+ wp_after_insert_post( $attachment, true, $attachment_before );
+
+ $response = $this->prepare_item_for_response( $attachment, $request );
+ $response = rest_ensure_response( $response );
+
+ return $response;
+ }
+
+ /**
+ * Performs post processing on an attachment.
+ *
+ * @since 5.3.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
+ */
+ public function post_process_item( $request ) {
+ switch ( $request['action'] ) {
+ case 'create-image-subsizes':
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+ wp_update_image_subsizes( $request['id'] );
+ break;
+ }
+
+ $request['context'] = 'edit';
+
+ return $this->prepare_item_for_response( get_post( $request['id'] ), $request );
+ }
+
+ /**
+ * Checks if a given request can perform post processing on an attachment.
+ *
+ * @since 5.3.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
+ */
+ public function post_process_item_permissions_check( $request ) {
+ return $this->update_item_permissions_check( $request );
+ }
+
+ /**
+ * Checks if a given request has access to editing media.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function edit_media_item_permissions_check( $request ) {
+ if ( ! current_user_can( 'upload_files' ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit_image',
+ __( 'Sorry, you are not allowed to upload media on this site.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return $this->update_item_permissions_check( $request );
+ }
+
+ /**
+ * Applies edits to a media item and creates a new attachment record.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
+ */
+ public function edit_media_item( $request ) {
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+
+ $attachment_id = $request['id'];
+
+ // This also confirms the attachment is an image.
+ $image_file = wp_get_original_image_path( $attachment_id );
+ $image_meta = wp_get_attachment_metadata( $attachment_id );
+
+ if (
+ ! $image_meta ||
+ ! $image_file ||
+ ! wp_image_file_matches_image_meta( $request['src'], $image_meta, $attachment_id )
+ ) {
+ return new WP_Error(
+ 'rest_unknown_attachment',
+ __( 'Unable to get meta information for file.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $supported_types = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp' );
+ $mime_type = get_post_mime_type( $attachment_id );
+ if ( ! in_array( $mime_type, $supported_types, true ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit_file_type',
+ __( 'This type of file cannot be edited.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // The `modifiers` param takes precedence over the older format.
+ if ( isset( $request['modifiers'] ) ) {
+ $modifiers = $request['modifiers'];
+ } else {
+ $modifiers = array();
+
+ if ( ! empty( $request['rotation'] ) ) {
+ $modifiers[] = array(
+ 'type' => 'rotate',
+ 'args' => array(
+ 'angle' => $request['rotation'],
+ ),
+ );
+ }
+
+ if ( isset( $request['x'], $request['y'], $request['width'], $request['height'] ) ) {
+ $modifiers[] = array(
+ 'type' => 'crop',
+ 'args' => array(
+ 'left' => $request['x'],
+ 'top' => $request['y'],
+ 'width' => $request['width'],
+ 'height' => $request['height'],
+ ),
+ );
+ }
+
+ if ( 0 === count( $modifiers ) ) {
+ return new WP_Error(
+ 'rest_image_not_edited',
+ __( 'The image was not edited. Edit the image before applying the changes.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ /*
+ * If the file doesn't exist, attempt a URL fopen on the src link.
+ * This can occur with certain file replication plugins.
+ * Keep the original file path to get a modified name later.
+ */
+ $image_file_to_edit = $image_file;
+ if ( ! file_exists( $image_file_to_edit ) ) {
+ $image_file_to_edit = _load_image_to_edit_path( $attachment_id );
+ }
+
+ $image_editor = wp_get_image_editor( $image_file_to_edit );
+
+ if ( is_wp_error( $image_editor ) ) {
+ return new WP_Error(
+ 'rest_unknown_image_file_type',
+ __( 'Unable to edit this image.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ foreach ( $modifiers as $modifier ) {
+ $args = $modifier['args'];
+ switch ( $modifier['type'] ) {
+ case 'rotate':
+ // Rotation direction: clockwise vs. counter clockwise.
+ $rotate = 0 - $args['angle'];
+
+ if ( 0 !== $rotate ) {
+ $result = $image_editor->rotate( $rotate );
+
+ if ( is_wp_error( $result ) ) {
+ return new WP_Error(
+ 'rest_image_rotation_failed',
+ __( 'Unable to rotate this image.' ),
+ array( 'status' => 500 )
+ );
+ }
+ }
+
+ break;
+
+ case 'crop':
+ $size = $image_editor->get_size();
+
+ $crop_x = round( ( $size['width'] * $args['left'] ) / 100.0 );
+ $crop_y = round( ( $size['height'] * $args['top'] ) / 100.0 );
+ $width = round( ( $size['width'] * $args['width'] ) / 100.0 );
+ $height = round( ( $size['height'] * $args['height'] ) / 100.0 );
+
+ if ( $size['width'] !== $width && $size['height'] !== $height ) {
+ $result = $image_editor->crop( $crop_x, $crop_y, $width, $height );
+
+ if ( is_wp_error( $result ) ) {
+ return new WP_Error(
+ 'rest_image_crop_failed',
+ __( 'Unable to crop this image.' ),
+ array( 'status' => 500 )
+ );
+ }
+ }
+
+ break;
+
+ }
+ }
+
+ // Calculate the file name.
+ $image_ext = pathinfo( $image_file, PATHINFO_EXTENSION );
+ $image_name = wp_basename( $image_file, ".{$image_ext}" );
+
+ /*
+ * Do not append multiple `-edited` to the file name.
+ * The user may be editing a previously edited image.
+ */
+ if ( preg_match( '/-edited(-\d+)?$/', $image_name ) ) {
+ // Remove any `-1`, `-2`, etc. `wp_unique_filename()` will add the proper number.
+ $image_name = preg_replace( '/-edited(-\d+)?$/', '-edited', $image_name );
+ } else {
+ // Append `-edited` before the extension.
+ $image_name .= '-edited';
+ }
+
+ $filename = "{$image_name}.{$image_ext}";
+
+ // Create the uploads sub-directory if needed.
+ $uploads = wp_upload_dir();
+
+ // Make the file name unique in the (new) upload directory.
+ $filename = wp_unique_filename( $uploads['path'], $filename );
+
+ // Save to disk.
+ $saved = $image_editor->save( $uploads['path'] . "/$filename" );
+
+ if ( is_wp_error( $saved ) ) {
+ return $saved;
+ }
+
+ // Create new attachment post.
+ $new_attachment_post = array(
+ 'post_mime_type' => $saved['mime-type'],
+ 'guid' => $uploads['url'] . "/$filename",
+ 'post_title' => $image_name,
+ 'post_content' => '',
+ );
+
+ // Copy post_content, post_excerpt, and post_title from the edited image's attachment post.
+ $attachment_post = get_post( $attachment_id );
+
+ if ( $attachment_post ) {
+ $new_attachment_post['post_content'] = $attachment_post->post_content;
+ $new_attachment_post['post_excerpt'] = $attachment_post->post_excerpt;
+ $new_attachment_post['post_title'] = $attachment_post->post_title;
+ }
+
+ $new_attachment_id = wp_insert_attachment( wp_slash( $new_attachment_post ), $saved['path'], 0, true );
+
+ if ( is_wp_error( $new_attachment_id ) ) {
+ if ( 'db_update_error' === $new_attachment_id->get_error_code() ) {
+ $new_attachment_id->add_data( array( 'status' => 500 ) );
+ } else {
+ $new_attachment_id->add_data( array( 'status' => 400 ) );
+ }
+
+ return $new_attachment_id;
+ }
+
+ // Copy the image alt text from the edited image.
+ $image_alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
+
+ if ( ! empty( $image_alt ) ) {
+ // update_post_meta() expects slashed.
+ update_post_meta( $new_attachment_id, '_wp_attachment_image_alt', wp_slash( $image_alt ) );
+ }
+
+ if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
+ /*
+ * Set a custom header with the attachment_id.
+ * Used by the browser/client to resume creating image sub-sizes after a PHP fatal error.
+ */
+ header( 'X-WP-Upload-Attachment-ID: ' . $new_attachment_id );
+ }
+
+ // Generate image sub-sizes and meta.
+ $new_image_meta = wp_generate_attachment_metadata( $new_attachment_id, $saved['path'] );
+
+ // Copy the EXIF metadata from the original attachment if not generated for the edited image.
+ if ( isset( $image_meta['image_meta'] ) && isset( $new_image_meta['image_meta'] ) && is_array( $new_image_meta['image_meta'] ) ) {
+ // Merge but skip empty values.
+ foreach ( (array) $image_meta['image_meta'] as $key => $value ) {
+ if ( empty( $new_image_meta['image_meta'][ $key ] ) && ! empty( $value ) ) {
+ $new_image_meta['image_meta'][ $key ] = $value;
+ }
+ }
+ }
+
+ // Reset orientation. At this point the image is edited and orientation is correct.
+ if ( ! empty( $new_image_meta['image_meta']['orientation'] ) ) {
+ $new_image_meta['image_meta']['orientation'] = 1;
+ }
+
+ // The attachment_id may change if the site is exported and imported.
+ $new_image_meta['parent_image'] = array(
+ 'attachment_id' => $attachment_id,
+ // Path to the originally uploaded image file relative to the uploads directory.
+ 'file' => _wp_relative_upload_path( $image_file ),
+ );
+
+ /**
+ * Filters the meta data for the new image created by editing an existing image.
+ *
+ * @since 5.5.0
+ *
+ * @param array $new_image_meta Meta data for the new image.
+ * @param int $new_attachment_id Attachment post ID for the new image.
+ * @param int $attachment_id Attachment post ID for the edited (parent) image.
+ */
+ $new_image_meta = apply_filters( 'wp_edited_image_metadata', $new_image_meta, $new_attachment_id, $attachment_id );
+
+ wp_update_attachment_metadata( $new_attachment_id, $new_image_meta );
+
+ $response = $this->prepare_item_for_response( get_post( $new_attachment_id ), $request );
+ $response->set_status( 201 );
+ $response->header( 'Location', rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $new_attachment_id ) ) );
+
+ return $response;
+ }
+
+ /**
+ * Prepares a single attachment for create or update.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return stdClass|WP_Error Post object.
+ */
+ protected function prepare_item_for_database( $request ) {
+ $prepared_attachment = parent::prepare_item_for_database( $request );
+
+ // Attachment caption (post_excerpt internally).
+ if ( isset( $request['caption'] ) ) {
+ if ( is_string( $request['caption'] ) ) {
+ $prepared_attachment->post_excerpt = $request['caption'];
+ } elseif ( isset( $request['caption']['raw'] ) ) {
+ $prepared_attachment->post_excerpt = $request['caption']['raw'];
+ }
+ }
+
+ // Attachment description (post_content internally).
+ if ( isset( $request['description'] ) ) {
+ if ( is_string( $request['description'] ) ) {
+ $prepared_attachment->post_content = $request['description'];
+ } elseif ( isset( $request['description']['raw'] ) ) {
+ $prepared_attachment->post_content = $request['description']['raw'];
+ }
+ }
+
+ if ( isset( $request['post'] ) ) {
+ $prepared_attachment->post_parent = (int) $request['post'];
+ }
+
+ return $prepared_attachment;
+ }
+
+ /**
+ * Prepares a single attachment output for response.
+ *
+ * @since 4.7.0
+ * @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param WP_Post $item Attachment object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $post = $item;
+
+ $response = parent::prepare_item_for_response( $post, $request );
+ $fields = $this->get_fields_for_response( $request );
+ $data = $response->get_data();
+
+ if ( in_array( 'description', $fields, true ) ) {
+ $data['description'] = array(
+ 'raw' => $post->post_content,
+ /** This filter is documented in wp-includes/post-template.php */
+ 'rendered' => apply_filters( 'the_content', $post->post_content ),
+ );
+ }
+
+ if ( in_array( 'caption', $fields, true ) ) {
+ /** This filter is documented in wp-includes/post-template.php */
+ $caption = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post );
+
+ /** This filter is documented in wp-includes/post-template.php */
+ $caption = apply_filters( 'the_excerpt', $caption );
+
+ $data['caption'] = array(
+ 'raw' => $post->post_excerpt,
+ 'rendered' => $caption,
+ );
+ }
+
+ if ( in_array( 'alt_text', $fields, true ) ) {
+ $data['alt_text'] = get_post_meta( $post->ID, '_wp_attachment_image_alt', true );
+ }
+
+ if ( in_array( 'media_type', $fields, true ) ) {
+ $data['media_type'] = wp_attachment_is_image( $post->ID ) ? 'image' : 'file';
+ }
+
+ if ( in_array( 'mime_type', $fields, true ) ) {
+ $data['mime_type'] = $post->post_mime_type;
+ }
+
+ if ( in_array( 'media_details', $fields, true ) ) {
+ $data['media_details'] = wp_get_attachment_metadata( $post->ID );
+
+ // Ensure empty details is an empty object.
+ if ( empty( $data['media_details'] ) ) {
+ $data['media_details'] = new stdClass();
+ } elseif ( ! empty( $data['media_details']['sizes'] ) ) {
+
+ foreach ( $data['media_details']['sizes'] as $size => &$size_data ) {
+
+ if ( isset( $size_data['mime-type'] ) ) {
+ $size_data['mime_type'] = $size_data['mime-type'];
+ unset( $size_data['mime-type'] );
+ }
+
+ // Use the same method image_downsize() does.
+ $image_src = wp_get_attachment_image_src( $post->ID, $size );
+ if ( ! $image_src ) {
+ continue;
+ }
+
+ $size_data['source_url'] = $image_src[0];
+ }
+
+ $full_src = wp_get_attachment_image_src( $post->ID, 'full' );
+
+ if ( ! empty( $full_src ) ) {
+ $data['media_details']['sizes']['full'] = array(
+ 'file' => wp_basename( $full_src[0] ),
+ 'width' => $full_src[1],
+ 'height' => $full_src[2],
+ 'mime_type' => $post->post_mime_type,
+ 'source_url' => $full_src[0],
+ );
+ }
+ } else {
+ $data['media_details']['sizes'] = new stdClass();
+ }
+ }
+
+ if ( in_array( 'post', $fields, true ) ) {
+ $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null;
+ }
+
+ if ( in_array( 'source_url', $fields, true ) ) {
+ $data['source_url'] = wp_get_attachment_url( $post->ID );
+ }
+
+ if ( in_array( 'missing_image_sizes', $fields, true ) ) {
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+ $data['missing_image_sizes'] = array_keys( wp_get_missing_image_subsizes( $post->ID ) );
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $links = $response->get_links();
+
+ // Wrap the data in a response object.
+ $response = rest_ensure_response( $data );
+
+ foreach ( $links as $rel => $rel_links ) {
+ foreach ( $rel_links as $link ) {
+ $response->add_link( $rel, $link['href'], $link['attributes'] );
+ }
+ }
+
+ /**
+ * Filters an attachment returned from the REST API.
+ *
+ * Allows modification of the attachment right before it is returned.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Post $post The original attachment post.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ return apply_filters( 'rest_prepare_attachment', $response, $post, $request );
+ }
+
+ /**
+ * Retrieves the attachment's schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ *
+ * @return array Item schema as an array.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = parent::get_item_schema();
+
+ $schema['properties']['alt_text'] = array(
+ 'description' => __( 'Alternative text to display when attachment is not displayed.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ );
+
+ $schema['properties']['caption'] = array(
+ 'description' => __( 'The attachment caption.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database().
+ 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database().
+ ),
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'Caption for the attachment, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ ),
+ 'rendered' => array(
+ 'description' => __( 'HTML caption for the attachment, transformed for display.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ $schema['properties']['description'] = array(
+ 'description' => __( 'The attachment description.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database().
+ 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database().
+ ),
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'Description for the attachment, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ ),
+ 'rendered' => array(
+ 'description' => __( 'HTML description for the attachment, transformed for display.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ $schema['properties']['media_type'] = array(
+ 'description' => __( 'Attachment type.' ),
+ 'type' => 'string',
+ 'enum' => array( 'image', 'file' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ );
+
+ $schema['properties']['mime_type'] = array(
+ 'description' => __( 'The attachment MIME type.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ );
+
+ $schema['properties']['media_details'] = array(
+ 'description' => __( 'Details about the media file, specific to its type.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ );
+
+ $schema['properties']['post'] = array(
+ 'description' => __( 'The ID for the associated post of the attachment.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ );
+
+ $schema['properties']['source_url'] = array(
+ 'description' => __( 'URL to the original attachment file.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ );
+
+ $schema['properties']['missing_image_sizes'] = array(
+ 'description' => __( 'List of the missing image sizes of the attachment.' ),
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ );
+
+ unset( $schema['properties']['password'] );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Handles an upload via raw POST data.
+ *
+ * @since 4.7.0
+ *
+ * @param string $data Supplied file data.
+ * @param array $headers HTTP headers from the request.
+ * @return array|WP_Error Data from wp_handle_sideload().
+ */
+ protected function upload_from_data( $data, $headers ) {
+ if ( empty( $data ) ) {
+ return new WP_Error(
+ 'rest_upload_no_data',
+ __( 'No data supplied.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( empty( $headers['content_type'] ) ) {
+ return new WP_Error(
+ 'rest_upload_no_content_type',
+ __( 'No Content-Type supplied.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( empty( $headers['content_disposition'] ) ) {
+ return new WP_Error(
+ 'rest_upload_no_content_disposition',
+ __( 'No Content-Disposition supplied.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $filename = self::get_filename_from_disposition( $headers['content_disposition'] );
+
+ if ( empty( $filename ) ) {
+ return new WP_Error(
+ 'rest_upload_invalid_disposition',
+ __( 'Invalid Content-Disposition supplied. Content-Disposition needs to be formatted as `attachment; filename="image.png"` or similar.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( ! empty( $headers['content_md5'] ) ) {
+ $content_md5 = array_shift( $headers['content_md5'] );
+ $expected = trim( $content_md5 );
+ $actual = md5( $data );
+
+ if ( $expected !== $actual ) {
+ return new WP_Error(
+ 'rest_upload_hash_mismatch',
+ __( 'Content hash did not match expected.' ),
+ array( 'status' => 412 )
+ );
+ }
+ }
+
+ // Get the content-type.
+ $type = array_shift( $headers['content_type'] );
+
+ // Include filesystem functions to get access to wp_tempnam() and wp_handle_sideload().
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+
+ // Save the file.
+ $tmpfname = wp_tempnam( $filename );
+
+ $fp = fopen( $tmpfname, 'w+' );
+
+ if ( ! $fp ) {
+ return new WP_Error(
+ 'rest_upload_file_error',
+ __( 'Could not open file handle.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ fwrite( $fp, $data );
+ fclose( $fp );
+
+ // Now, sideload it in.
+ $file_data = array(
+ 'error' => null,
+ 'tmp_name' => $tmpfname,
+ 'name' => $filename,
+ 'type' => $type,
+ );
+
+ $size_check = self::check_upload_size( $file_data );
+ if ( is_wp_error( $size_check ) ) {
+ return $size_check;
+ }
+
+ $overrides = array(
+ 'test_form' => false,
+ );
+
+ $sideloaded = wp_handle_sideload( $file_data, $overrides );
+
+ if ( isset( $sideloaded['error'] ) ) {
+ @unlink( $tmpfname );
+
+ return new WP_Error(
+ 'rest_upload_sideload_error',
+ $sideloaded['error'],
+ array( 'status' => 500 )
+ );
+ }
+
+ return $sideloaded;
+ }
+
+ /**
+ * Parses filename from a Content-Disposition header value.
+ *
+ * As per RFC6266:
+ *
+ * content-disposition = "Content-Disposition" ":"
+ * disposition-type *( ";" disposition-parm )
+ *
+ * disposition-type = "inline" | "attachment" | disp-ext-type
+ * ; case-insensitive
+ * disp-ext-type = token
+ *
+ * disposition-parm = filename-parm | disp-ext-parm
+ *
+ * filename-parm = "filename" "=" value
+ * | "filename*" "=" ext-value
+ *
+ * disp-ext-parm = token "=" value
+ * | ext-token "=" ext-value
+ * ext-token = <the characters in token, followed by "*">
+ *
+ * @since 4.7.0
+ *
+ * @link https://tools.ietf.org/html/rfc2388
+ * @link https://tools.ietf.org/html/rfc6266
+ *
+ * @param string[] $disposition_header List of Content-Disposition header values.
+ * @return string|null Filename if available, or null if not found.
+ */
+ public static function get_filename_from_disposition( $disposition_header ) {
+ // Get the filename.
+ $filename = null;
+
+ foreach ( $disposition_header as $value ) {
+ $value = trim( $value );
+
+ if ( ! str_contains( $value, ';' ) ) {
+ continue;
+ }
+
+ list( $type, $attr_parts ) = explode( ';', $value, 2 );
+
+ $attr_parts = explode( ';', $attr_parts );
+ $attributes = array();
+
+ foreach ( $attr_parts as $part ) {
+ if ( ! str_contains( $part, '=' ) ) {
+ continue;
+ }
+
+ list( $key, $value ) = explode( '=', $part, 2 );
+
+ $attributes[ trim( $key ) ] = trim( $value );
+ }
+
+ if ( empty( $attributes['filename'] ) ) {
+ continue;
+ }
+
+ $filename = trim( $attributes['filename'] );
+
+ // Unquote quoted filename, but after trimming.
+ if ( str_starts_with( $filename, '"' ) && str_ends_with( $filename, '"' ) ) {
+ $filename = substr( $filename, 1, -1 );
+ }
+ }
+
+ return $filename;
+ }
+
+ /**
+ * Retrieves the query params for collections of attachments.
+ *
+ * @since 4.7.0
+ *
+ * @return array Query parameters for the attachment collection as an array.
+ */
+ public function get_collection_params() {
+ $params = parent::get_collection_params();
+ $params['status']['default'] = 'inherit';
+ $params['status']['items']['enum'] = array( 'inherit', 'private', 'trash' );
+ $media_types = $this->get_media_types();
+
+ $params['media_type'] = array(
+ 'default' => null,
+ 'description' => __( 'Limit result set to attachments of a particular media type.' ),
+ 'type' => 'string',
+ 'enum' => array_keys( $media_types ),
+ );
+
+ $params['mime_type'] = array(
+ 'default' => null,
+ 'description' => __( 'Limit result set to attachments of a particular MIME type.' ),
+ 'type' => 'string',
+ );
+
+ return $params;
+ }
+
+ /**
+ * Handles an upload via multipart/form-data ($_FILES).
+ *
+ * @since 4.7.0
+ *
+ * @param array $files Data from the `$_FILES` superglobal.
+ * @param array $headers HTTP headers from the request.
+ * @return array|WP_Error Data from wp_handle_upload().
+ */
+ protected function upload_from_file( $files, $headers ) {
+ if ( empty( $files ) ) {
+ return new WP_Error(
+ 'rest_upload_no_data',
+ __( 'No data supplied.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Verify hash, if given.
+ if ( ! empty( $headers['content_md5'] ) ) {
+ $content_md5 = array_shift( $headers['content_md5'] );
+ $expected = trim( $content_md5 );
+ $actual = md5_file( $files['file']['tmp_name'] );
+
+ if ( $expected !== $actual ) {
+ return new WP_Error(
+ 'rest_upload_hash_mismatch',
+ __( 'Content hash did not match expected.' ),
+ array( 'status' => 412 )
+ );
+ }
+ }
+
+ // Pass off to WP to handle the actual upload.
+ $overrides = array(
+ 'test_form' => false,
+ );
+
+ // Bypasses is_uploaded_file() when running unit tests.
+ if ( defined( 'DIR_TESTDATA' ) && DIR_TESTDATA ) {
+ $overrides['action'] = 'wp_handle_mock_upload';
+ }
+
+ $size_check = self::check_upload_size( $files['file'] );
+ if ( is_wp_error( $size_check ) ) {
+ return $size_check;
+ }
+
+ // Include filesystem functions to get access to wp_handle_upload().
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+
+ $file = wp_handle_upload( $files['file'], $overrides );
+
+ if ( isset( $file['error'] ) ) {
+ return new WP_Error(
+ 'rest_upload_unknown_error',
+ $file['error'],
+ array( 'status' => 500 )
+ );
+ }
+
+ return $file;
+ }
+
+ /**
+ * Retrieves the supported media types.
+ *
+ * Media types are considered the MIME type category.
+ *
+ * @since 4.7.0
+ *
+ * @return array Array of supported media types.
+ */
+ protected function get_media_types() {
+ $media_types = array();
+
+ foreach ( get_allowed_mime_types() as $mime_type ) {
+ $parts = explode( '/', $mime_type );
+
+ if ( ! isset( $media_types[ $parts[0] ] ) ) {
+ $media_types[ $parts[0] ] = array();
+ }
+
+ $media_types[ $parts[0] ][] = $mime_type;
+ }
+
+ return $media_types;
+ }
+
+ /**
+ * Determine if uploaded file exceeds space quota on multisite.
+ *
+ * Replicates check_upload_size().
+ *
+ * @since 4.9.8
+ *
+ * @param array $file $_FILES array for a given file.
+ * @return true|WP_Error True if can upload, error for errors.
+ */
+ protected function check_upload_size( $file ) {
+ if ( ! is_multisite() ) {
+ return true;
+ }
+
+ if ( get_site_option( 'upload_space_check_disabled' ) ) {
+ return true;
+ }
+
+ $space_left = get_upload_space_available();
+
+ $file_size = filesize( $file['tmp_name'] );
+
+ if ( $space_left < $file_size ) {
+ return new WP_Error(
+ 'rest_upload_limited_space',
+ /* translators: %s: Required disk space in kilobytes. */
+ sprintf( __( 'Not enough space to upload. %s KB needed.' ), number_format( ( $file_size - $space_left ) / KB_IN_BYTES ) ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( $file_size > ( KB_IN_BYTES * get_site_option( 'fileupload_maxk', 1500 ) ) ) {
+ return new WP_Error(
+ 'rest_upload_file_too_big',
+ /* translators: %s: Maximum allowed file size in kilobytes. */
+ sprintf( __( 'This file is too big. Files must be less than %s KB in size.' ), get_site_option( 'fileupload_maxk', 1500 ) ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Include multisite admin functions to get access to upload_is_user_over_quota().
+ require_once ABSPATH . 'wp-admin/includes/ms.php';
+
+ if ( upload_is_user_over_quota( false ) ) {
+ return new WP_Error(
+ 'rest_upload_user_quota_exceeded',
+ __( 'You have used your space quota. Please delete files before uploading.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Gets the request args for the edit item route.
+ *
+ * @since 5.5.0
+ *
+ * @return array
+ */
+ protected function get_edit_media_item_args() {
+ return array(
+ 'src' => array(
+ 'description' => __( 'URL to the edited image file.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'required' => true,
+ ),
+ 'modifiers' => array(
+ 'description' => __( 'Array of image edits.' ),
+ 'type' => 'array',
+ 'minItems' => 1,
+ 'items' => array(
+ 'description' => __( 'Image edit.' ),
+ 'type' => 'object',
+ 'required' => array(
+ 'type',
+ 'args',
+ ),
+ 'oneOf' => array(
+ array(
+ 'title' => __( 'Rotation' ),
+ 'properties' => array(
+ 'type' => array(
+ 'description' => __( 'Rotation type.' ),
+ 'type' => 'string',
+ 'enum' => array( 'rotate' ),
+ ),
+ 'args' => array(
+ 'description' => __( 'Rotation arguments.' ),
+ 'type' => 'object',
+ 'required' => array(
+ 'angle',
+ ),
+ 'properties' => array(
+ 'angle' => array(
+ 'description' => __( 'Angle to rotate clockwise in degrees.' ),
+ 'type' => 'number',
+ ),
+ ),
+ ),
+ ),
+ ),
+ array(
+ 'title' => __( 'Crop' ),
+ 'properties' => array(
+ 'type' => array(
+ 'description' => __( 'Crop type.' ),
+ 'type' => 'string',
+ 'enum' => array( 'crop' ),
+ ),
+ 'args' => array(
+ 'description' => __( 'Crop arguments.' ),
+ 'type' => 'object',
+ 'required' => array(
+ 'left',
+ 'top',
+ 'width',
+ 'height',
+ ),
+ 'properties' => array(
+ 'left' => array(
+ 'description' => __( 'Horizontal position from the left to begin the crop as a percentage of the image width.' ),
+ 'type' => 'number',
+ ),
+ 'top' => array(
+ 'description' => __( 'Vertical position from the top to begin the crop as a percentage of the image height.' ),
+ 'type' => 'number',
+ ),
+ 'width' => array(
+ 'description' => __( 'Width of the crop as a percentage of the image width.' ),
+ 'type' => 'number',
+ ),
+ 'height' => array(
+ 'description' => __( 'Height of the crop as a percentage of the image height.' ),
+ 'type' => 'number',
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ 'rotation' => array(
+ 'description' => __( 'The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.' ),
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'exclusiveMinimum' => true,
+ 'maximum' => 360,
+ 'exclusiveMaximum' => true,
+ ),
+ 'x' => array(
+ 'description' => __( 'As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.' ),
+ 'type' => 'number',
+ 'minimum' => 0,
+ 'maximum' => 100,
+ ),
+ 'y' => array(
+ 'description' => __( 'As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.' ),
+ 'type' => 'number',
+ 'minimum' => 0,
+ 'maximum' => 100,
+ ),
+ 'width' => array(
+ 'description' => __( 'As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.' ),
+ 'type' => 'number',
+ 'minimum' => 0,
+ 'maximum' => 100,
+ ),
+ 'height' => array(
+ 'description' => __( 'As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.' ),
+ 'type' => 'number',
+ 'minimum' => 0,
+ 'maximum' => 100,
+ ),
+ );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
new file mode 100644
index 0000000..5545625
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
@@ -0,0 +1,497 @@
+<?php
+/**
+ * REST API: WP_REST_Autosaves_Controller class.
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.0.0
+ */
+
+/**
+ * Core class used to access autosaves via the REST API.
+ *
+ * @since 5.0.0
+ *
+ * @see WP_REST_Revisions_Controller
+ * @see WP_REST_Controller
+ */
+class WP_REST_Autosaves_Controller extends WP_REST_Revisions_Controller {
+
+ /**
+ * Parent post type.
+ *
+ * @since 5.0.0
+ * @var string
+ */
+ private $parent_post_type;
+
+ /**
+ * Parent post controller.
+ *
+ * @since 5.0.0
+ * @var WP_REST_Controller
+ */
+ private $parent_controller;
+
+ /**
+ * Revision controller.
+ *
+ * @since 5.0.0
+ * @var WP_REST_Revisions_Controller
+ */
+ private $revisions_controller;
+
+ /**
+ * The base of the parent controller's route.
+ *
+ * @since 5.0.0
+ * @var string
+ */
+ private $parent_base;
+
+ /**
+ * Constructor.
+ *
+ * @since 5.0.0
+ *
+ * @param string $parent_post_type Post type of the parent.
+ */
+ public function __construct( $parent_post_type ) {
+ $this->parent_post_type = $parent_post_type;
+ $post_type_object = get_post_type_object( $parent_post_type );
+ $parent_controller = $post_type_object->get_rest_controller();
+
+ if ( ! $parent_controller ) {
+ $parent_controller = new WP_REST_Posts_Controller( $parent_post_type );
+ }
+
+ $this->parent_controller = $parent_controller;
+
+ $revisions_controller = $post_type_object->get_revisions_rest_controller();
+ if ( ! $revisions_controller ) {
+ $revisions_controller = new WP_REST_Revisions_Controller( $parent_post_type );
+ }
+ $this->revisions_controller = $revisions_controller;
+ $this->rest_base = 'autosaves';
+ $this->parent_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name;
+ $this->namespace = ! empty( $post_type_object->rest_namespace ) ? $post_type_object->rest_namespace : 'wp/v2';
+ }
+
+ /**
+ * Registers the routes for autosaves.
+ *
+ * @since 5.0.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->parent_base . '/(?P<id>[\d]+)/' . $this->rest_base,
+ array(
+ 'args' => array(
+ 'parent' => array(
+ 'description' => __( 'The ID for the parent of the autosave.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => $this->parent_controller->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->parent_base . '/(?P<parent>[\d]+)/' . $this->rest_base . '/(?P<id>[\d]+)',
+ array(
+ 'args' => array(
+ 'parent' => array(
+ 'description' => __( 'The ID for the parent of the autosave.' ),
+ 'type' => 'integer',
+ ),
+ 'id' => array(
+ 'description' => __( 'The ID for the autosave.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this->revisions_controller, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Get the parent post.
+ *
+ * @since 5.0.0
+ *
+ * @param int $parent_id Supplied ID.
+ * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_parent( $parent_id ) {
+ return $this->revisions_controller->get_parent( $parent_id );
+ }
+
+ /**
+ * Checks if a given request has access to get autosaves.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ $parent = $this->get_parent( $request['id'] );
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ if ( ! current_user_can( 'edit_post', $parent->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_read',
+ __( 'Sorry, you are not allowed to view autosaves of this post.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if a given request has access to create an autosave revision.
+ *
+ * Autosave revisions inherit permissions from the parent post,
+ * check if the current user has permission to edit the post.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to create the item, WP_Error object otherwise.
+ */
+ public function create_item_permissions_check( $request ) {
+ $id = $request->get_param( 'id' );
+
+ if ( empty( $id ) ) {
+ return new WP_Error(
+ 'rest_post_invalid_id',
+ __( 'Invalid item ID.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ return $this->parent_controller->update_item_permissions_check( $request );
+ }
+
+ /**
+ * Creates, updates or deletes an autosave revision.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+
+ if ( ! defined( 'WP_RUN_CORE_TESTS' ) && ! defined( 'DOING_AUTOSAVE' ) ) {
+ define( 'DOING_AUTOSAVE', true );
+ }
+
+ $post = $this->get_parent( $request['id'] );
+
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ $prepared_post = $this->parent_controller->prepare_item_for_database( $request );
+ $prepared_post->ID = $post->ID;
+ $user_id = get_current_user_id();
+
+ // We need to check post lock to ensure the original author didn't leave their browser tab open.
+ if ( ! function_exists( 'wp_check_post_lock' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/post.php';
+ }
+
+ $post_lock = wp_check_post_lock( $post->ID );
+ $is_draft = 'draft' === $post->post_status || 'auto-draft' === $post->post_status;
+
+ if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock ) {
+ /*
+ * Draft posts for the same author: autosaving updates the post and does not create a revision.
+ * Convert the post object to an array and add slashes, wp_update_post() expects escaped array.
+ */
+ $autosave_id = wp_update_post( wp_slash( (array) $prepared_post ), true );
+ } else {
+ // Non-draft posts: create or update the post autosave. Pass the meta data.
+ $autosave_id = $this->create_post_autosave( (array) $prepared_post, (array) $request->get_param( 'meta' ) );
+ }
+
+ if ( is_wp_error( $autosave_id ) ) {
+ return $autosave_id;
+ }
+
+ $autosave = get_post( $autosave_id );
+ $request->set_param( 'context', 'edit' );
+
+ $response = $this->prepare_item_for_response( $autosave, $request );
+ $response = rest_ensure_response( $response );
+
+ return $response;
+ }
+
+ /**
+ * Get the autosave, if the ID is valid.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Post|WP_Error Revision post object if ID is valid, WP_Error otherwise.
+ */
+ public function get_item( $request ) {
+ $parent_id = (int) $request->get_param( 'parent' );
+
+ if ( $parent_id <= 0 ) {
+ return new WP_Error(
+ 'rest_post_invalid_id',
+ __( 'Invalid post parent ID.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $autosave = wp_get_post_autosave( $parent_id );
+
+ if ( ! $autosave ) {
+ return new WP_Error(
+ 'rest_post_no_autosave',
+ __( 'There is no autosave revision for this post.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $response = $this->prepare_item_for_response( $autosave, $request );
+ return $response;
+ }
+
+ /**
+ * Gets a collection of autosaves using wp_get_post_autosave.
+ *
+ * Contains the user's autosave, for empty if it doesn't exist.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $parent = $this->get_parent( $request['id'] );
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ $response = array();
+ $parent_id = $parent->ID;
+ $revisions = wp_get_post_revisions( $parent_id, array( 'check_enabled' => false ) );
+
+ foreach ( $revisions as $revision ) {
+ if ( str_contains( $revision->post_name, "{$parent_id}-autosave" ) ) {
+ $data = $this->prepare_item_for_response( $revision, $request );
+ $response[] = $this->prepare_response_for_collection( $data );
+ }
+ }
+
+ return rest_ensure_response( $response );
+ }
+
+
+ /**
+ * Retrieves the autosave's schema, conforming to JSON Schema.
+ *
+ * @since 5.0.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = $this->revisions_controller->get_item_schema();
+
+ $schema['properties']['preview_link'] = array(
+ 'description' => __( 'Preview link for the post.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Creates autosave for the specified post.
+ *
+ * From wp-admin/post.php.
+ *
+ * @since 5.0.0
+ * @since 6.4.0 The `$meta` parameter was added.
+ *
+ * @param array $post_data Associative array containing the post data.
+ * @param array $meta Associative array containing the post meta data.
+ * @return mixed The autosave revision ID or WP_Error.
+ */
+ public function create_post_autosave( $post_data, array $meta = array() ) {
+
+ $post_id = (int) $post_data['ID'];
+ $post = get_post( $post_id );
+
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ // Only create an autosave when it is different from the saved post.
+ $autosave_is_different = false;
+ $new_autosave = _wp_post_revision_data( $post_data, true );
+
+ foreach ( array_intersect( array_keys( $new_autosave ), array_keys( _wp_post_revision_fields( $post ) ) ) as $field ) {
+ if ( normalize_whitespace( $new_autosave[ $field ] ) !== normalize_whitespace( $post->$field ) ) {
+ $autosave_is_different = true;
+ break;
+ }
+ }
+
+ // Check if meta values have changed.
+ if ( ! empty( $meta ) ) {
+ $revisioned_meta_keys = wp_post_revision_meta_keys( $post->post_type );
+ foreach ( $revisioned_meta_keys as $meta_key ) {
+ // get_metadata_raw is used to avoid retrieving the default value.
+ $old_meta = get_metadata_raw( 'post', $post_id, $meta_key, true );
+ $new_meta = isset( $meta[ $meta_key ] ) ? $meta[ $meta_key ] : '';
+
+ if ( $new_meta !== $old_meta ) {
+ $autosave_is_different = true;
+ break;
+ }
+ }
+ }
+
+ $user_id = get_current_user_id();
+
+ // Store one autosave per author. If there is already an autosave, overwrite it.
+ $old_autosave = wp_get_post_autosave( $post_id, $user_id );
+
+ if ( ! $autosave_is_different && $old_autosave ) {
+ // Nothing to save, return the existing autosave.
+ return $old_autosave->ID;
+ }
+
+ if ( $old_autosave ) {
+ $new_autosave['ID'] = $old_autosave->ID;
+ $new_autosave['post_author'] = $user_id;
+
+ /** This filter is documented in wp-admin/post.php */
+ do_action( 'wp_creating_autosave', $new_autosave );
+
+ // wp_update_post() expects escaped array.
+ $revision_id = wp_update_post( wp_slash( $new_autosave ) );
+ } else {
+ // Create the new autosave as a special post revision.
+ $revision_id = _wp_put_post_revision( $post_data, true );
+ }
+
+ if ( is_wp_error( $revision_id ) || 0 === $revision_id ) {
+ return $revision_id;
+ }
+
+ // Attached any passed meta values that have revisions enabled.
+ if ( ! empty( $meta ) ) {
+ foreach ( $revisioned_meta_keys as $meta_key ) {
+ if ( isset( $meta[ $meta_key ] ) ) {
+ update_metadata( 'post', $revision_id, $meta_key, wp_slash( $meta[ $meta_key ] ) );
+ }
+ }
+ }
+
+ return $revision_id;
+ }
+
+ /**
+ * Prepares the revision for the REST response.
+ *
+ * @since 5.0.0
+ * @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param WP_Post $item Post revision object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $post = $item;
+
+ $response = $this->revisions_controller->prepare_item_for_response( $post, $request );
+ $fields = $this->get_fields_for_response( $request );
+
+ if ( in_array( 'preview_link', $fields, true ) ) {
+ $parent_id = wp_is_post_autosave( $post );
+ $preview_post_id = false === $parent_id ? $post->ID : $parent_id;
+ $preview_query_args = array();
+
+ if ( false !== $parent_id ) {
+ $preview_query_args['preview_id'] = $parent_id;
+ $preview_query_args['preview_nonce'] = wp_create_nonce( 'post_preview_' . $parent_id );
+ }
+
+ $response->data['preview_link'] = get_preview_post_link( $preview_post_id, $preview_query_args );
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $response->data = $this->add_additional_fields_to_object( $response->data, $request );
+ $response->data = $this->filter_response_by_context( $response->data, $context );
+
+ /**
+ * Filters a revision returned from the REST API.
+ *
+ * Allows modification of the revision right before it is returned.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Post $post The original revision object.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ return apply_filters( 'rest_prepare_autosave', $response, $post, $request );
+ }
+
+ /**
+ * Retrieves the query params for the autosaves collection.
+ *
+ * @since 5.0.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ return array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-block-directory-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-block-directory-controller.php
new file mode 100644
index 0000000..cfcb0db
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-block-directory-controller.php
@@ -0,0 +1,328 @@
+<?php
+/**
+ * REST API: WP_REST_Block_Directory_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.5.0
+ */
+
+/**
+ * Controller which provides REST endpoint for the blocks.
+ *
+ * @since 5.5.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Block_Directory_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructs the controller.
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'block-directory';
+ }
+
+ /**
+ * Registers the necessary REST API routes.
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/search',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to install and activate plugins.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has permission, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( ! current_user_can( 'install_plugins' ) || ! current_user_can( 'activate_plugins' ) ) {
+ return new WP_Error(
+ 'rest_block_directory_cannot_view',
+ __( 'Sorry, you are not allowed to browse the block directory.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Search and retrieve blocks metadata
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+ $response = plugins_api(
+ 'query_plugins',
+ array(
+ 'block' => $request['term'],
+ 'per_page' => $request['per_page'],
+ 'page' => $request['page'],
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ $response->add_data( array( 'status' => 500 ) );
+
+ return $response;
+ }
+
+ $result = array();
+
+ foreach ( $response->plugins as $plugin ) {
+ // If the API returned a plugin with empty data for 'blocks', skip it.
+ if ( empty( $plugin['blocks'] ) ) {
+ continue;
+ }
+
+ $data = $this->prepare_item_for_response( $plugin, $request );
+ $result[] = $this->prepare_response_for_collection( $data );
+ }
+
+ return rest_ensure_response( $result );
+ }
+
+ /**
+ * Parse block metadata for a block, and prepare it for an API response.
+ *
+ * @since 5.5.0
+ * @since 5.9.0 Renamed `$plugin` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param array $item The plugin metadata.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $plugin = $item;
+
+ $fields = $this->get_fields_for_response( $request );
+
+ // There might be multiple blocks in a plugin. Only the first block is mapped.
+ $block_data = reset( $plugin['blocks'] );
+
+ // A data array containing the properties we'll return.
+ $block = array(
+ 'name' => $block_data['name'],
+ 'title' => ( $block_data['title'] ? $block_data['title'] : $plugin['name'] ),
+ 'description' => wp_trim_words( $plugin['short_description'], 30, '...' ),
+ 'id' => $plugin['slug'],
+ 'rating' => $plugin['rating'] / 20,
+ 'rating_count' => (int) $plugin['num_ratings'],
+ 'active_installs' => (int) $plugin['active_installs'],
+ 'author_block_rating' => $plugin['author_block_rating'] / 20,
+ 'author_block_count' => (int) $plugin['author_block_count'],
+ 'author' => wp_strip_all_tags( $plugin['author'] ),
+ 'icon' => ( isset( $plugin['icons']['1x'] ) ? $plugin['icons']['1x'] : 'block-default' ),
+ 'last_updated' => gmdate( 'Y-m-d\TH:i:s', strtotime( $plugin['last_updated'] ) ),
+ 'humanized_updated' => sprintf(
+ /* translators: %s: Human-readable time difference. */
+ __( '%s ago' ),
+ human_time_diff( strtotime( $plugin['last_updated'] ) )
+ ),
+ );
+
+ $this->add_additional_fields_to_object( $block, $request );
+
+ $response = new WP_REST_Response( $block );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $plugin ) );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Generates a list of links to include in the response for the plugin.
+ *
+ * @since 5.5.0
+ *
+ * @param array $plugin The plugin data from WordPress.org.
+ * @return array
+ */
+ protected function prepare_links( $plugin ) {
+ $links = array(
+ 'https://api.w.org/install-plugin' => array(
+ 'href' => add_query_arg( 'slug', urlencode( $plugin['slug'] ), rest_url( 'wp/v2/plugins' ) ),
+ ),
+ );
+
+ $plugin_file = $this->find_plugin_for_slug( $plugin['slug'] );
+
+ if ( $plugin_file ) {
+ $links['https://api.w.org/plugin'] = array(
+ 'href' => rest_url( 'wp/v2/plugins/' . substr( $plugin_file, 0, - 4 ) ),
+ 'embeddable' => true,
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * Finds an installed plugin for the given slug.
+ *
+ * @since 5.5.0
+ *
+ * @param string $slug The WordPress.org directory slug for a plugin.
+ * @return string The plugin file found matching it.
+ */
+ protected function find_plugin_for_slug( $slug ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+ $plugin_files = get_plugins( '/' . $slug );
+
+ if ( ! $plugin_files ) {
+ return '';
+ }
+
+ $plugin_files = array_keys( $plugin_files );
+
+ return $slug . '/' . reset( $plugin_files );
+ }
+
+ /**
+ * Retrieves the theme's schema, conforming to JSON Schema.
+ *
+ * @since 5.5.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'block-directory-item',
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array(
+ 'description' => __( 'The block name, in namespace/block-name format.' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ ),
+ 'title' => array(
+ 'description' => __( 'The block title, in human readable format.' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ ),
+ 'description' => array(
+ 'description' => __( 'A short description of the block, in human readable format.' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ ),
+ 'id' => array(
+ 'description' => __( 'The block slug.' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ ),
+ 'rating' => array(
+ 'description' => __( 'The star rating of the block.' ),
+ 'type' => 'number',
+ 'context' => array( 'view' ),
+ ),
+ 'rating_count' => array(
+ 'description' => __( 'The number of ratings.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view' ),
+ ),
+ 'active_installs' => array(
+ 'description' => __( 'The number sites that have activated this block.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view' ),
+ ),
+ 'author_block_rating' => array(
+ 'description' => __( 'The average rating of blocks published by the same author.' ),
+ 'type' => 'number',
+ 'context' => array( 'view' ),
+ ),
+ 'author_block_count' => array(
+ 'description' => __( 'The number of blocks published by the same author.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view' ),
+ ),
+ 'author' => array(
+ 'description' => __( 'The WordPress.org username of the block author.' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ ),
+ 'icon' => array(
+ 'description' => __( 'The block icon.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'view' ),
+ ),
+ 'last_updated' => array(
+ 'description' => __( 'The date when the block was last updated.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view' ),
+ ),
+ 'humanized_updated' => array(
+ 'description' => __( 'The date when the block was last updated, in fuzzy human readable format.' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the search params for the blocks collection.
+ *
+ * @since 5.5.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $query_params = parent::get_collection_params();
+
+ $query_params['context']['default'] = 'view';
+
+ $query_params['term'] = array(
+ 'description' => __( 'Limit result set to blocks matching the search term.' ),
+ 'type' => 'string',
+ 'required' => true,
+ 'minLength' => 1,
+ );
+
+ unset( $query_params['search'] );
+
+ /**
+ * Filters REST API collection parameters for the block directory controller.
+ *
+ * @since 5.5.0
+ *
+ * @param array $query_params JSON Schema-formatted collection parameters.
+ */
+ return apply_filters( 'rest_block_directory_collection_params', $query_params );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-block-pattern-categories-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-block-pattern-categories-controller.php
new file mode 100644
index 0000000..36a7534
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-block-pattern-categories-controller.php
@@ -0,0 +1,162 @@
+<?php
+/**
+ * REST API: WP_REST_Block_Pattern_Categories_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 6.0.0
+ */
+
+/**
+ * Core class used to access block pattern categories via the REST API.
+ *
+ * @since 6.0.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Block_Pattern_Categories_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructs the controller.
+ *
+ * @since 6.0.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'block-patterns/categories';
+ }
+
+ /**
+ * Registers the routes for the objects of the controller.
+ *
+ * @since 6.0.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to read block patterns.
+ *
+ * @since 6.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( current_user_can( 'edit_posts' ) ) {
+ return true;
+ }
+
+ foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
+ if ( current_user_can( $post_type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view',
+ __( 'Sorry, you are not allowed to view the registered block pattern categories.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Retrieves all block pattern categories.
+ *
+ * @since 6.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $response = array();
+ $categories = WP_Block_Pattern_Categories_Registry::get_instance()->get_all_registered();
+ foreach ( $categories as $category ) {
+ $prepared_category = $this->prepare_item_for_response( $category, $request );
+ $response[] = $this->prepare_response_for_collection( $prepared_category );
+ }
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Prepare a raw block pattern category before it gets output in a REST API response.
+ *
+ * @since 6.0.0
+ *
+ * @param array $item Raw category as registered, before any changes.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ $fields = $this->get_fields_for_response( $request );
+ $keys = array( 'name', 'label', 'description' );
+ $data = array();
+ foreach ( $keys as $key ) {
+ if ( isset( $item[ $key ] ) && rest_is_field_included( $key, $fields ) ) {
+ $data[ $key ] = $item[ $key ];
+ }
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Retrieves the block pattern category schema, conforming to JSON Schema.
+ *
+ * @since 6.0.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'block-pattern-category',
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array(
+ 'description' => __( 'The category name.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'label' => array(
+ 'description' => __( 'The category label, in human readable format.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'description' => array(
+ 'description' => __( 'The category description, in human readable format.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ ),
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-block-patterns-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-block-patterns-controller.php
new file mode 100644
index 0000000..d8f0839
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-block-patterns-controller.php
@@ -0,0 +1,298 @@
+<?php
+/**
+ * REST API: WP_REST_Block_Patterns_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 6.0.0
+ */
+
+/**
+ * Core class used to access block patterns via the REST API.
+ *
+ * @since 6.0.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Block_Patterns_Controller extends WP_REST_Controller {
+
+ /**
+ * Defines whether remote patterns should be loaded.
+ *
+ * @since 6.0.0
+ * @var bool
+ */
+ private $remote_patterns_loaded;
+
+ /**
+ * An array that maps old categories names to new ones.
+ *
+ * @since 6.2.0
+ * @var array
+ */
+ protected static $categories_migration = array(
+ 'buttons' => 'call-to-action',
+ 'columns' => 'text',
+ 'query' => 'posts',
+ );
+
+ /**
+ * Constructs the controller.
+ *
+ * @since 6.0.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'block-patterns/patterns';
+ }
+
+ /**
+ * Registers the routes for the objects of the controller.
+ *
+ * @since 6.0.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to read block patterns.
+ *
+ * @since 6.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( current_user_can( 'edit_posts' ) ) {
+ return true;
+ }
+
+ foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
+ if ( current_user_can( $post_type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view',
+ __( 'Sorry, you are not allowed to view the registered block patterns.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Retrieves all block patterns.
+ *
+ * @since 6.0.0
+ * @since 6.2.0 Added migration for old core pattern categories to the new ones.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ if ( ! $this->remote_patterns_loaded ) {
+ // Load block patterns from w.org.
+ _load_remote_block_patterns(); // Patterns with the `core` keyword.
+ _load_remote_featured_patterns(); // Patterns in the `featured` category.
+ _register_remote_theme_patterns(); // Patterns requested by current theme.
+
+ $this->remote_patterns_loaded = true;
+ }
+
+ $response = array();
+ $patterns = WP_Block_Patterns_Registry::get_instance()->get_all_registered();
+ foreach ( $patterns as $pattern ) {
+ $migrated_pattern = $this->migrate_pattern_categories( $pattern );
+ $prepared_pattern = $this->prepare_item_for_response( $migrated_pattern, $request );
+ $response[] = $this->prepare_response_for_collection( $prepared_pattern );
+ }
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Migrates old core pattern categories to the new categories.
+ *
+ * Core pattern categories are revamped. Migration is needed to ensure
+ * backwards compatibility.
+ *
+ * @since 6.2.0
+ *
+ * @param array $pattern Raw pattern as registered, before applying any changes.
+ * @return array Migrated pattern.
+ */
+ protected function migrate_pattern_categories( $pattern ) {
+ // No categories to migrate.
+ if (
+ ! isset( $pattern['categories'] ) ||
+ ! is_array( $pattern['categories'] )
+ ) {
+ return $pattern;
+ }
+
+ foreach ( $pattern['categories'] as $index => $category ) {
+ // If the category exists as a key, then it needs migration.
+ if ( isset( static::$categories_migration[ $category ] ) ) {
+ $pattern['categories'][ $index ] = static::$categories_migration[ $category ];
+ }
+ }
+
+ return $pattern;
+ }
+
+ /**
+ * Prepare a raw block pattern before it gets output in a REST API response.
+ *
+ * @since 6.0.0
+ * @since 6.3.0 Added `source` property.
+ *
+ * @param array $item Raw pattern as registered, before any changes.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ $fields = $this->get_fields_for_response( $request );
+ $keys = array(
+ 'name' => 'name',
+ 'title' => 'title',
+ 'content' => 'content',
+ 'description' => 'description',
+ 'viewportWidth' => 'viewport_width',
+ 'inserter' => 'inserter',
+ 'categories' => 'categories',
+ 'keywords' => 'keywords',
+ 'blockTypes' => 'block_types',
+ 'postTypes' => 'post_types',
+ 'templateTypes' => 'template_types',
+ 'source' => 'source',
+ );
+ $data = array();
+ foreach ( $keys as $item_key => $rest_key ) {
+ if ( isset( $item[ $item_key ] ) && rest_is_field_included( $rest_key, $fields ) ) {
+ $data[ $rest_key ] = $item[ $item_key ];
+ }
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Retrieves the block pattern schema, conforming to JSON Schema.
+ *
+ * @since 6.0.0
+ * @since 6.3.0 Added `source` property.
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'block-pattern',
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array(
+ 'description' => __( 'The pattern name.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'title' => array(
+ 'description' => __( 'The pattern title, in human readable format.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'content' => array(
+ 'description' => __( 'The pattern content.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'description' => array(
+ 'description' => __( 'The pattern detailed description.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'viewport_width' => array(
+ 'description' => __( 'The pattern viewport width for inserter preview.' ),
+ 'type' => 'number',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'inserter' => array(
+ 'description' => __( 'Determines whether the pattern is visible in inserter.' ),
+ 'type' => 'boolean',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'categories' => array(
+ 'description' => __( 'The pattern category slugs.' ),
+ 'type' => 'array',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'keywords' => array(
+ 'description' => __( 'The pattern keywords.' ),
+ 'type' => 'array',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'block_types' => array(
+ 'description' => __( 'Block types that the pattern is intended to be used with.' ),
+ 'type' => 'array',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'post_types' => array(
+ 'description' => __( 'An array of post types that the pattern is restricted to be used with.' ),
+ 'type' => 'array',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'template_types' => array(
+ 'description' => __( 'An array of template types where the pattern fits.' ),
+ 'type' => 'array',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'source' => array(
+ 'description' => __( 'Where the pattern comes from e.g. core' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'enum' => array(
+ 'core',
+ 'plugin',
+ 'theme',
+ 'pattern-directory/core',
+ 'pattern-directory/theme',
+ 'pattern-directory/featured',
+ ),
+ ),
+ ),
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-block-renderer-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-block-renderer-controller.php
new file mode 100644
index 0000000..3e4e8eb
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-block-renderer-controller.php
@@ -0,0 +1,224 @@
+<?php
+/**
+ * Block Renderer REST API: WP_REST_Block_Renderer_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.0.0
+ */
+
+/**
+ * Controller which provides REST endpoint for rendering a block.
+ *
+ * @since 5.0.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Block_Renderer_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructs the controller.
+ *
+ * @since 5.0.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'block-renderer';
+ }
+
+ /**
+ * Registers the necessary REST API routes, one for each dynamic block.
+ *
+ * @since 5.0.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<name>[a-z0-9-]+/[a-z0-9-]+)',
+ array(
+ 'args' => array(
+ 'name' => array(
+ 'description' => __( 'Unique registered name for the block.' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => array( WP_REST_Server::READABLE, WP_REST_Server::CREATABLE ),
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ 'attributes' => array(
+ 'description' => __( 'Attributes for the block.' ),
+ 'type' => 'object',
+ 'default' => array(),
+ 'validate_callback' => static function ( $value, $request ) {
+ $block = WP_Block_Type_Registry::get_instance()->get_registered( $request['name'] );
+
+ if ( ! $block ) {
+ // This will get rejected in ::get_item().
+ return true;
+ }
+
+ $schema = array(
+ 'type' => 'object',
+ 'properties' => $block->get_attributes(),
+ 'additionalProperties' => false,
+ );
+
+ return rest_validate_value_from_schema( $value, $schema );
+ },
+ 'sanitize_callback' => static function ( $value, $request ) {
+ $block = WP_Block_Type_Registry::get_instance()->get_registered( $request['name'] );
+
+ if ( ! $block ) {
+ // This will get rejected in ::get_item().
+ return true;
+ }
+
+ $schema = array(
+ 'type' => 'object',
+ 'properties' => $block->get_attributes(),
+ 'additionalProperties' => false,
+ );
+
+ return rest_sanitize_value_from_schema( $value, $schema );
+ },
+ ),
+ 'post_id' => array(
+ 'description' => __( 'ID of the post context.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to read blocks.
+ *
+ * @since 5.0.0
+ *
+ * @global WP_Post $post Global post object.
+ *
+ * @param WP_REST_Request $request Request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ global $post;
+
+ $post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
+
+ if ( $post_id > 0 ) {
+ $post = get_post( $post_id );
+
+ if ( ! $post || ! current_user_can( 'edit_post', $post->ID ) ) {
+ return new WP_Error(
+ 'block_cannot_read',
+ __( 'Sorry, you are not allowed to read blocks of this post.' ),
+ array(
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+ } else {
+ if ( ! current_user_can( 'edit_posts' ) ) {
+ return new WP_Error(
+ 'block_cannot_read',
+ __( 'Sorry, you are not allowed to read blocks as this user.' ),
+ array(
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns block output from block's registered render_callback.
+ *
+ * @since 5.0.0
+ *
+ * @global WP_Post $post Global post object.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ global $post;
+
+ $post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
+
+ if ( $post_id > 0 ) {
+ $post = get_post( $post_id );
+
+ // Set up postdata since this will be needed if post_id was set.
+ setup_postdata( $post );
+ }
+
+ $registry = WP_Block_Type_Registry::get_instance();
+ $registered = $registry->get_registered( $request['name'] );
+
+ if ( null === $registered || ! $registered->is_dynamic() ) {
+ return new WP_Error(
+ 'block_invalid',
+ __( 'Invalid block.' ),
+ array(
+ 'status' => 404,
+ )
+ );
+ }
+
+ $attributes = $request->get_param( 'attributes' );
+
+ // Create an array representation simulating the output of parse_blocks.
+ $block = array(
+ 'blockName' => $request['name'],
+ 'attrs' => $attributes,
+ 'innerHTML' => '',
+ 'innerContent' => array(),
+ );
+
+ // Render using render_block to ensure all relevant filters are used.
+ $data = array(
+ 'rendered' => render_block( $block ),
+ );
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Retrieves block's output schema, conforming to JSON Schema.
+ *
+ * @since 5.0.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->schema;
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/schema#',
+ 'title' => 'rendered-block',
+ 'type' => 'object',
+ 'properties' => array(
+ 'rendered' => array(
+ 'description' => __( 'The rendered block.' ),
+ 'type' => 'string',
+ 'required' => true,
+ 'context' => array( 'edit' ),
+ ),
+ ),
+ );
+
+ return $this->schema;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-block-types-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-block-types-controller.php
new file mode 100644
index 0000000..852143d
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-block-types-controller.php
@@ -0,0 +1,792 @@
+<?php
+/**
+ * REST API: WP_REST_Block_Types_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.5.0
+ */
+
+/**
+ * Core class used to access block types via the REST API.
+ *
+ * @since 5.5.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Block_Types_Controller extends WP_REST_Controller {
+
+ const NAME_PATTERN = '^[a-z][a-z0-9-]*/[a-z][a-z0-9-]*$';
+
+ /**
+ * Instance of WP_Block_Type_Registry.
+ *
+ * @since 5.5.0
+ * @var WP_Block_Type_Registry
+ */
+ protected $block_registry;
+
+ /**
+ * Instance of WP_Block_Styles_Registry.
+ *
+ * @since 5.5.0
+ * @var WP_Block_Styles_Registry
+ */
+ protected $style_registry;
+
+ /**
+ * Constructor.
+ *
+ * @since 5.5.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'block-types';
+ $this->block_registry = WP_Block_Type_Registry::get_instance();
+ $this->style_registry = WP_Block_Styles_Registry::get_instance();
+ }
+
+ /**
+ * Registers the routes for block types.
+ *
+ * @since 5.5.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<namespace>[a-zA-Z0-9_-]+)',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<namespace>[a-zA-Z0-9_-]+)/(?P<name>[a-zA-Z0-9_-]+)',
+ array(
+ 'args' => array(
+ 'name' => array(
+ 'description' => __( 'Block name.' ),
+ 'type' => 'string',
+ ),
+ 'namespace' => array(
+ 'description' => __( 'Block namespace.' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to read post block types.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ return $this->check_read_permission();
+ }
+
+ /**
+ * Retrieves all post block types, depending on user context.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $data = array();
+ $block_types = $this->block_registry->get_all_registered();
+
+ // Retrieve the list of registered collection query parameters.
+ $registered = $this->get_collection_params();
+ $namespace = '';
+ if ( isset( $registered['namespace'] ) && ! empty( $request['namespace'] ) ) {
+ $namespace = $request['namespace'];
+ }
+
+ foreach ( $block_types as $slug => $obj ) {
+ if ( $namespace ) {
+ list ( $block_namespace ) = explode( '/', $obj->name );
+
+ if ( $namespace !== $block_namespace ) {
+ continue;
+ }
+ }
+ $block_type = $this->prepare_item_for_response( $obj, $request );
+ $data[] = $this->prepare_response_for_collection( $block_type );
+ }
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Checks if a given request has access to read a block type.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $check = $this->check_read_permission();
+ if ( is_wp_error( $check ) ) {
+ return $check;
+ }
+ $block_name = sprintf( '%s/%s', $request['namespace'], $request['name'] );
+ $block_type = $this->get_block( $block_name );
+ if ( is_wp_error( $block_type ) ) {
+ return $block_type;
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks whether a given block type should be visible.
+ *
+ * @since 5.5.0
+ *
+ * @return true|WP_Error True if the block type is visible, WP_Error otherwise.
+ */
+ protected function check_read_permission() {
+ if ( current_user_can( 'edit_posts' ) ) {
+ return true;
+ }
+ foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
+ if ( current_user_can( $post_type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error( 'rest_block_type_cannot_view', __( 'Sorry, you are not allowed to manage block types.' ), array( 'status' => rest_authorization_required_code() ) );
+ }
+
+ /**
+ * Get the block, if the name is valid.
+ *
+ * @since 5.5.0
+ *
+ * @param string $name Block name.
+ * @return WP_Block_Type|WP_Error Block type object if name is valid, WP_Error otherwise.
+ */
+ protected function get_block( $name ) {
+ $block_type = $this->block_registry->get_registered( $name );
+ if ( empty( $block_type ) ) {
+ return new WP_Error( 'rest_block_type_invalid', __( 'Invalid block type.' ), array( 'status' => 404 ) );
+ }
+
+ return $block_type;
+ }
+
+ /**
+ * Retrieves a specific block type.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $block_name = sprintf( '%s/%s', $request['namespace'], $request['name'] );
+ $block_type = $this->get_block( $block_name );
+ if ( is_wp_error( $block_type ) ) {
+ return $block_type;
+ }
+ $data = $this->prepare_item_for_response( $block_type, $request );
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Prepares a block type object for serialization.
+ *
+ * @since 5.5.0
+ * @since 5.9.0 Renamed `$block_type` to `$item` to match parent class for PHP 8 named parameter support.
+ * @since 6.3.0 Added `selectors` field.
+ *
+ * @param WP_Block_Type $item Block type data.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response Block type data.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $block_type = $item;
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( rest_is_field_included( 'attributes', $fields ) ) {
+ $data['attributes'] = $block_type->get_attributes();
+ }
+
+ if ( rest_is_field_included( 'is_dynamic', $fields ) ) {
+ $data['is_dynamic'] = $block_type->is_dynamic();
+ }
+
+ $schema = $this->get_item_schema();
+ // Fields deprecated in WordPress 6.1, but left in the schema for backwards compatibility.
+ $deprecated_fields = array(
+ 'editor_script',
+ 'script',
+ 'view_script',
+ 'editor_style',
+ 'style',
+ );
+ $extra_fields = array_merge(
+ array(
+ 'api_version',
+ 'name',
+ 'title',
+ 'description',
+ 'icon',
+ 'category',
+ 'keywords',
+ 'parent',
+ 'ancestor',
+ 'provides_context',
+ 'uses_context',
+ 'selectors',
+ 'supports',
+ 'styles',
+ 'textdomain',
+ 'example',
+ 'editor_script_handles',
+ 'script_handles',
+ 'view_script_handles',
+ 'editor_style_handles',
+ 'style_handles',
+ 'variations',
+ 'block_hooks',
+ ),
+ $deprecated_fields
+ );
+ foreach ( $extra_fields as $extra_field ) {
+ if ( rest_is_field_included( $extra_field, $fields ) ) {
+ if ( isset( $block_type->$extra_field ) ) {
+ $field = $block_type->$extra_field;
+ if ( in_array( $extra_field, $deprecated_fields, true ) && is_array( $field ) ) {
+ // Since the schema only allows strings or null (but no arrays), we return the first array item.
+ $field = ! empty( $field ) ? array_shift( $field ) : '';
+ }
+ } elseif ( array_key_exists( 'default', $schema['properties'][ $extra_field ] ) ) {
+ $field = $schema['properties'][ $extra_field ]['default'];
+ } else {
+ $field = '';
+ }
+ $data[ $extra_field ] = rest_sanitize_value_from_schema( $field, $schema['properties'][ $extra_field ] );
+ }
+ }
+
+ if ( rest_is_field_included( 'styles', $fields ) ) {
+ $styles = $this->style_registry->get_registered_styles_for_block( $block_type->name );
+ $styles = array_values( $styles );
+ $data['styles'] = wp_parse_args( $styles, $data['styles'] );
+ $data['styles'] = array_filter( $data['styles'] );
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $block_type ) );
+ }
+
+ /**
+ * Filters a block type returned from the REST API.
+ *
+ * Allows modification of the block type data right before it is returned.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Block_Type $block_type The original block type object.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ return apply_filters( 'rest_prepare_block_type', $response, $block_type, $request );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_Block_Type $block_type Block type data.
+ * @return array Links for the given block type.
+ */
+ protected function prepare_links( $block_type ) {
+ list( $namespace ) = explode( '/', $block_type->name );
+
+ $links = array(
+ 'collection' => array(
+ 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+ ),
+ 'self' => array(
+ 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $block_type->name ) ),
+ ),
+ 'up' => array(
+ 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $namespace ) ),
+ ),
+ );
+
+ if ( $block_type->is_dynamic() ) {
+ $links['https://api.w.org/render-block'] = array(
+ 'href' => add_query_arg(
+ 'context',
+ 'edit',
+ rest_url( sprintf( '%s/%s/%s', 'wp/v2', 'block-renderer', $block_type->name ) )
+ ),
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * Retrieves the block type' schema, conforming to JSON Schema.
+ *
+ * @since 5.5.0
+ * @since 6.3.0 Added `selectors` field.
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ // rest_validate_value_from_schema doesn't understand $refs, pull out reused definitions for readability.
+ $inner_blocks_definition = array(
+ 'description' => __( 'The list of inner blocks used in the example.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array(
+ 'description' => __( 'The name of the inner block.' ),
+ 'type' => 'string',
+ 'pattern' => self::NAME_PATTERN,
+ 'required' => true,
+ ),
+ 'attributes' => array(
+ 'description' => __( 'The attributes of the inner block.' ),
+ 'type' => 'object',
+ ),
+ 'innerBlocks' => array(
+ 'description' => __( "A list of the inner block's own inner blocks. This is a recursive definition following the parent innerBlocks schema." ),
+ 'type' => 'array',
+ ),
+ ),
+ ),
+ );
+
+ $example_definition = array(
+ 'description' => __( 'Block example.' ),
+ 'type' => array( 'object', 'null' ),
+ 'default' => null,
+ 'properties' => array(
+ 'attributes' => array(
+ 'description' => __( 'The attributes used in the example.' ),
+ 'type' => 'object',
+ ),
+ 'innerBlocks' => $inner_blocks_definition,
+ ),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ );
+
+ $keywords_definition = array(
+ 'description' => __( 'Block keywords.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'default' => array(),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ );
+
+ $icon_definition = array(
+ 'description' => __( 'Icon of block type.' ),
+ 'type' => array( 'string', 'null' ),
+ 'default' => null,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ );
+
+ $category_definition = array(
+ 'description' => __( 'Block category.' ),
+ 'type' => array( 'string', 'null' ),
+ 'default' => null,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ );
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'block-type',
+ 'type' => 'object',
+ 'properties' => array(
+ 'api_version' => array(
+ 'description' => __( 'Version of block API.' ),
+ 'type' => 'integer',
+ 'default' => 1,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'title' => array(
+ 'description' => __( 'Title of block type.' ),
+ 'type' => 'string',
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'Unique name identifying the block type.' ),
+ 'type' => 'string',
+ 'pattern' => self::NAME_PATTERN,
+ 'required' => true,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'description' => __( 'Description of block type.' ),
+ 'type' => 'string',
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'icon' => $icon_definition,
+ 'attributes' => array(
+ 'description' => __( 'Block attributes.' ),
+ 'type' => array( 'object', 'null' ),
+ 'properties' => array(),
+ 'default' => null,
+ 'additionalProperties' => array(
+ 'type' => 'object',
+ ),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'provides_context' => array(
+ 'description' => __( 'Context provided by blocks of this type.' ),
+ 'type' => 'object',
+ 'properties' => array(),
+ 'additionalProperties' => array(
+ 'type' => 'string',
+ ),
+ 'default' => array(),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'uses_context' => array(
+ 'description' => __( 'Context values inherited by blocks of this type.' ),
+ 'type' => 'array',
+ 'default' => array(),
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'selectors' => array(
+ 'description' => __( 'Custom CSS selectors.' ),
+ 'type' => 'object',
+ 'default' => array(),
+ 'properties' => array(),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'supports' => array(
+ 'description' => __( 'Block supports.' ),
+ 'type' => 'object',
+ 'default' => array(),
+ 'properties' => array(),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'category' => $category_definition,
+ 'is_dynamic' => array(
+ 'description' => __( 'Is the block dynamically rendered.' ),
+ 'type' => 'boolean',
+ 'default' => false,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'editor_script_handles' => array(
+ 'description' => __( 'Editor script handles.' ),
+ 'type' => array( 'array' ),
+ 'default' => array(),
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'script_handles' => array(
+ 'description' => __( 'Public facing and editor script handles.' ),
+ 'type' => array( 'array' ),
+ 'default' => array(),
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'view_script_handles' => array(
+ 'description' => __( 'Public facing script handles.' ),
+ 'type' => array( 'array' ),
+ 'default' => array(),
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'editor_style_handles' => array(
+ 'description' => __( 'Editor style handles.' ),
+ 'type' => array( 'array' ),
+ 'default' => array(),
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'style_handles' => array(
+ 'description' => __( 'Public facing and editor style handles.' ),
+ 'type' => array( 'array' ),
+ 'default' => array(),
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'styles' => array(
+ 'description' => __( 'Block style variations.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array(
+ 'description' => __( 'Unique name identifying the style.' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'label' => array(
+ 'description' => __( 'The human-readable label for the style.' ),
+ 'type' => 'string',
+ ),
+ 'inline_style' => array(
+ 'description' => __( 'Inline CSS code that registers the CSS class required for the style.' ),
+ 'type' => 'string',
+ ),
+ 'style_handle' => array(
+ 'description' => __( 'Contains the handle that defines the block style.' ),
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ 'default' => array(),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'variations' => array(
+ 'description' => __( 'Block variations.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array(
+ 'description' => __( 'The unique and machine-readable name.' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'title' => array(
+ 'description' => __( 'A human-readable variation title.' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'description' => array(
+ 'description' => __( 'A detailed variation description.' ),
+ 'type' => 'string',
+ 'required' => false,
+ ),
+ 'category' => $category_definition,
+ 'icon' => $icon_definition,
+ 'isDefault' => array(
+ 'description' => __( 'Indicates whether the current variation is the default one.' ),
+ 'type' => 'boolean',
+ 'required' => false,
+ 'default' => false,
+ ),
+ 'attributes' => array(
+ 'description' => __( 'The initial values for attributes.' ),
+ 'type' => 'object',
+ ),
+ 'innerBlocks' => $inner_blocks_definition,
+ 'example' => $example_definition,
+ 'scope' => array(
+ 'description' => __( 'The list of scopes where the variation is applicable. When not provided, it assumes all available scopes.' ),
+ 'type' => array( 'array', 'null' ),
+ 'default' => null,
+ 'items' => array(
+ 'type' => 'string',
+ 'enum' => array( 'block', 'inserter', 'transform' ),
+ ),
+ 'readonly' => true,
+ ),
+ 'keywords' => $keywords_definition,
+ ),
+ ),
+ 'readonly' => true,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'default' => null,
+ ),
+ 'textdomain' => array(
+ 'description' => __( 'Public text domain.' ),
+ 'type' => array( 'string', 'null' ),
+ 'default' => null,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'parent' => array(
+ 'description' => __( 'Parent blocks.' ),
+ 'type' => array( 'array', 'null' ),
+ 'items' => array(
+ 'type' => 'string',
+ 'pattern' => self::NAME_PATTERN,
+ ),
+ 'default' => null,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'ancestor' => array(
+ 'description' => __( 'Ancestor blocks.' ),
+ 'type' => array( 'array', 'null' ),
+ 'items' => array(
+ 'type' => 'string',
+ 'pattern' => self::NAME_PATTERN,
+ ),
+ 'default' => null,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'keywords' => $keywords_definition,
+ 'example' => $example_definition,
+ 'block_hooks' => array(
+ 'description' => __( 'This block is automatically inserted near any occurrence of the block types used as keys of this map, into a relative position given by the corresponding value.' ),
+ 'type' => 'object',
+ 'patternProperties' => array(
+ self::NAME_PATTERN => array(
+ 'type' => 'string',
+ 'enum' => array( 'before', 'after', 'first_child', 'last_child' ),
+ ),
+ ),
+ 'default' => array(),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ // Properties deprecated in WordPress 6.1, but left in the schema for backwards compatibility.
+ $deprecated_properties = array(
+ 'editor_script' => array(
+ 'description' => __( 'Editor script handle. DEPRECATED: Use `editor_script_handles` instead.' ),
+ 'type' => array( 'string', 'null' ),
+ 'default' => null,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'script' => array(
+ 'description' => __( 'Public facing and editor script handle. DEPRECATED: Use `script_handles` instead.' ),
+ 'type' => array( 'string', 'null' ),
+ 'default' => null,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'view_script' => array(
+ 'description' => __( 'Public facing script handle. DEPRECATED: Use `view_script_handles` instead.' ),
+ 'type' => array( 'string', 'null' ),
+ 'default' => null,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'editor_style' => array(
+ 'description' => __( 'Editor style handle. DEPRECATED: Use `editor_style_handles` instead.' ),
+ 'type' => array( 'string', 'null' ),
+ 'default' => null,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'style' => array(
+ 'description' => __( 'Public facing and editor style handle. DEPRECATED: Use `style_handles` instead.' ),
+ 'type' => array( 'string', 'null' ),
+ 'default' => null,
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ );
+ $this->schema['properties'] = array_merge( $this->schema['properties'], $deprecated_properties );
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * @since 5.5.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ return array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ 'namespace' => array(
+ 'description' => __( 'Block namespace.' ),
+ 'type' => 'string',
+ ),
+ );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-blocks-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-blocks-controller.php
new file mode 100644
index 0000000..6f600a0
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-blocks-controller.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Synced patterns REST API: WP_REST_Blocks_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.0.0
+ */
+
+/**
+ * Controller which provides a REST endpoint for the editor to read, create,
+ * edit, and delete synced patterns (formerly called reusable blocks).
+ * Patterns are stored as posts with the wp_block post type.
+ *
+ * @since 5.0.0
+ *
+ * @see WP_REST_Posts_Controller
+ * @see WP_REST_Controller
+ */
+class WP_REST_Blocks_Controller extends WP_REST_Posts_Controller {
+
+ /**
+ * Checks if a pattern can be read.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_Post $post Post object that backs the block.
+ * @return bool Whether the pattern can be read.
+ */
+ public function check_read_permission( $post ) {
+ // By default the read_post capability is mapped to edit_posts.
+ if ( ! current_user_can( 'read_post', $post->ID ) ) {
+ return false;
+ }
+
+ return parent::check_read_permission( $post );
+ }
+
+ /**
+ * Filters a response based on the context defined in the schema.
+ *
+ * @since 5.0.0
+ * @since 6.3.0 Adds the `wp_pattern_sync_status` postmeta property to the top level of response.
+ *
+ * @param array $data Response data to filter.
+ * @param string $context Context defined in the schema.
+ * @return array Filtered response.
+ */
+ public function filter_response_by_context( $data, $context ) {
+ $data = parent::filter_response_by_context( $data, $context );
+
+ /*
+ * Remove `title.rendered` and `content.rendered` from the response.
+ * It doesn't make sense for a pattern to have rendered content on its own,
+ * since rendering a block requires it to be inside a post or a page.
+ */
+ unset( $data['title']['rendered'] );
+ unset( $data['content']['rendered'] );
+
+ // Add the core wp_pattern_sync_status meta as top level property to the response.
+ $data['wp_pattern_sync_status'] = isset( $data['meta']['wp_pattern_sync_status'] ) ? $data['meta']['wp_pattern_sync_status'] : '';
+ unset( $data['meta']['wp_pattern_sync_status'] );
+ return $data;
+ }
+
+ /**
+ * Retrieves the pattern's schema, conforming to JSON Schema.
+ *
+ * @since 5.0.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = parent::get_item_schema();
+
+ /*
+ * Allow all contexts to access `title.raw` and `content.raw`.
+ * Clients always need the raw markup of a pattern to do anything useful,
+ * e.g. parse it or display it in an editor.
+ */
+ $schema['properties']['title']['properties']['raw']['context'] = array( 'view', 'edit' );
+ $schema['properties']['content']['properties']['raw']['context'] = array( 'view', 'edit' );
+
+ /*
+ * Remove `title.rendered` and `content.rendered` from the schema.
+ * It doesn't make sense for a pattern to have rendered content on its own,
+ * since rendering a block requires it to be inside a post or a page.
+ */
+ unset( $schema['properties']['title']['properties']['rendered'] );
+ unset( $schema['properties']['content']['properties']['rendered'] );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php
new file mode 100644
index 0000000..1169b6b
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php
@@ -0,0 +1,1915 @@
+<?php
+/**
+ * REST API: WP_REST_Comments_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core controller used to access comments via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Comments_Controller extends WP_REST_Controller {
+
+ /**
+ * Instance of a comment meta fields object.
+ *
+ * @since 4.7.0
+ * @var WP_REST_Comment_Meta_Fields
+ */
+ protected $meta;
+
+ /**
+ * Constructor.
+ *
+ * @since 4.7.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'comments';
+
+ $this->meta = new WP_REST_Comment_Meta_Fields();
+ }
+
+ /**
+ * Registers the routes for comments.
+ *
+ * @since 4.7.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[\d]+)',
+ array(
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the comment.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ 'password' => array(
+ 'description' => __( 'The password for the parent post of the comment (if the post is password protected).' ),
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'permission_callback' => array( $this, 'update_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+ 'args' => array(
+ 'force' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ 'description' => __( 'Whether to bypass Trash and force deletion.' ),
+ ),
+ 'password' => array(
+ 'description' => __( 'The password for the parent post of the comment (if the post is password protected).' ),
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to read comments.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+
+ if ( ! empty( $request['post'] ) ) {
+ foreach ( (array) $request['post'] as $post_id ) {
+ $post = get_post( $post_id );
+
+ if ( ! empty( $post_id ) && $post && ! $this->check_read_post_permission( $post, $request ) ) {
+ return new WP_Error(
+ 'rest_cannot_read_post',
+ __( 'Sorry, you are not allowed to read the post for this comment.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ } elseif ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) {
+ return new WP_Error(
+ 'rest_cannot_read',
+ __( 'Sorry, you are not allowed to read comments without a post.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ }
+ }
+
+ if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to edit comments.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! current_user_can( 'edit_posts' ) ) {
+ $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' );
+ $forbidden_params = array();
+
+ foreach ( $protected_params as $param ) {
+ if ( 'status' === $param ) {
+ if ( 'approve' !== $request[ $param ] ) {
+ $forbidden_params[] = $param;
+ }
+ } elseif ( 'type' === $param ) {
+ if ( 'comment' !== $request[ $param ] ) {
+ $forbidden_params[] = $param;
+ }
+ } elseif ( ! empty( $request[ $param ] ) ) {
+ $forbidden_params[] = $param;
+ }
+ }
+
+ if ( ! empty( $forbidden_params ) ) {
+ return new WP_Error(
+ 'rest_forbidden_param',
+ /* translators: %s: List of forbidden parameters. */
+ sprintf( __( 'Query parameter not permitted: %s' ), implode( ', ', $forbidden_params ) ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves a list of comment items.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or error object on failure.
+ */
+ public function get_items( $request ) {
+
+ // Retrieve the list of registered collection query parameters.
+ $registered = $this->get_collection_params();
+
+ /*
+ * This array defines mappings between public API query parameters whose
+ * values are accepted as-passed, and their internal WP_Query parameter
+ * name equivalents (some are the same). Only values which are also
+ * present in $registered will be set.
+ */
+ $parameter_mappings = array(
+ 'author' => 'author__in',
+ 'author_email' => 'author_email',
+ 'author_exclude' => 'author__not_in',
+ 'exclude' => 'comment__not_in',
+ 'include' => 'comment__in',
+ 'offset' => 'offset',
+ 'order' => 'order',
+ 'parent' => 'parent__in',
+ 'parent_exclude' => 'parent__not_in',
+ 'per_page' => 'number',
+ 'post' => 'post__in',
+ 'search' => 'search',
+ 'status' => 'status',
+ 'type' => 'type',
+ );
+
+ $prepared_args = array();
+
+ /*
+ * For each known parameter which is both registered and present in the request,
+ * set the parameter's value on the query $prepared_args.
+ */
+ foreach ( $parameter_mappings as $api_param => $wp_param ) {
+ if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
+ $prepared_args[ $wp_param ] = $request[ $api_param ];
+ }
+ }
+
+ // Ensure certain parameter values default to empty strings.
+ foreach ( array( 'author_email', 'search' ) as $param ) {
+ if ( ! isset( $prepared_args[ $param ] ) ) {
+ $prepared_args[ $param ] = '';
+ }
+ }
+
+ if ( isset( $registered['orderby'] ) ) {
+ $prepared_args['orderby'] = $this->normalize_query_param( $request['orderby'] );
+ }
+
+ $prepared_args['no_found_rows'] = false;
+
+ $prepared_args['update_comment_post_cache'] = true;
+
+ $prepared_args['date_query'] = array();
+
+ // Set before into date query. Date query must be specified as an array of an array.
+ if ( isset( $registered['before'], $request['before'] ) ) {
+ $prepared_args['date_query'][0]['before'] = $request['before'];
+ }
+
+ // Set after into date query. Date query must be specified as an array of an array.
+ if ( isset( $registered['after'], $request['after'] ) ) {
+ $prepared_args['date_query'][0]['after'] = $request['after'];
+ }
+
+ if ( isset( $registered['page'] ) && empty( $request['offset'] ) ) {
+ $prepared_args['offset'] = $prepared_args['number'] * ( absint( $request['page'] ) - 1 );
+ }
+
+ /**
+ * Filters WP_Comment_Query arguments when querying comments via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @link https://developer.wordpress.org/reference/classes/wp_comment_query/
+ *
+ * @param array $prepared_args Array of arguments for WP_Comment_Query.
+ * @param WP_REST_Request $request The REST API request.
+ */
+ $prepared_args = apply_filters( 'rest_comment_query', $prepared_args, $request );
+
+ $query = new WP_Comment_Query();
+ $query_result = $query->query( $prepared_args );
+
+ $comments = array();
+
+ foreach ( $query_result as $comment ) {
+ if ( ! $this->check_read_permission( $comment, $request ) ) {
+ continue;
+ }
+
+ $data = $this->prepare_item_for_response( $comment, $request );
+ $comments[] = $this->prepare_response_for_collection( $data );
+ }
+
+ $total_comments = (int) $query->found_comments;
+ $max_pages = (int) $query->max_num_pages;
+
+ if ( $total_comments < 1 ) {
+ // Out-of-bounds, run the query again without LIMIT for total count.
+ unset( $prepared_args['number'], $prepared_args['offset'] );
+
+ $query = new WP_Comment_Query();
+ $prepared_args['count'] = true;
+ $prepared_args['orderby'] = 'none';
+
+ $total_comments = $query->query( $prepared_args );
+ $max_pages = ceil( $total_comments / $request['per_page'] );
+ }
+
+ $response = rest_ensure_response( $comments );
+ $response->header( 'X-WP-Total', $total_comments );
+ $response->header( 'X-WP-TotalPages', $max_pages );
+
+ $base = add_query_arg( urlencode_deep( $request->get_query_params() ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) );
+
+ if ( $request['page'] > 1 ) {
+ $prev_page = $request['page'] - 1;
+
+ if ( $prev_page > $max_pages ) {
+ $prev_page = $max_pages;
+ }
+
+ $prev_link = add_query_arg( 'page', $prev_page, $base );
+ $response->link_header( 'prev', $prev_link );
+ }
+
+ if ( $max_pages > $request['page'] ) {
+ $next_page = $request['page'] + 1;
+ $next_link = add_query_arg( 'page', $next_page, $base );
+
+ $response->link_header( 'next', $next_link );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Get the comment, if the ID is valid.
+ *
+ * @since 4.7.2
+ *
+ * @param int $id Supplied ID.
+ * @return WP_Comment|WP_Error Comment object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_comment( $id ) {
+ $error = new WP_Error(
+ 'rest_comment_invalid_id',
+ __( 'Invalid comment ID.' ),
+ array( 'status' => 404 )
+ );
+
+ if ( (int) $id <= 0 ) {
+ return $error;
+ }
+
+ $id = (int) $id;
+ $comment = get_comment( $id );
+ if ( empty( $comment ) ) {
+ return $error;
+ }
+
+ if ( ! empty( $comment->comment_post_ID ) ) {
+ $post = get_post( (int) $comment->comment_post_ID );
+
+ if ( empty( $post ) ) {
+ return new WP_Error(
+ 'rest_post_invalid_id',
+ __( 'Invalid post ID.' ),
+ array( 'status' => 404 )
+ );
+ }
+ }
+
+ return $comment;
+ }
+
+ /**
+ * Checks if a given request has access to read the comment.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $comment = $this->get_comment( $request['id'] );
+ if ( is_wp_error( $comment ) ) {
+ return $comment;
+ }
+
+ if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to edit comments.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $post = get_post( $comment->comment_post_ID );
+
+ if ( ! $this->check_read_permission( $comment, $request ) ) {
+ return new WP_Error(
+ 'rest_cannot_read',
+ __( 'Sorry, you are not allowed to read this comment.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( $post && ! $this->check_read_post_permission( $post, $request ) ) {
+ return new WP_Error(
+ 'rest_cannot_read_post',
+ __( 'Sorry, you are not allowed to read the post for this comment.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves a comment.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or error object on failure.
+ */
+ public function get_item( $request ) {
+ $comment = $this->get_comment( $request['id'] );
+ if ( is_wp_error( $comment ) ) {
+ return $comment;
+ }
+
+ $data = $this->prepare_item_for_response( $comment, $request );
+ $response = rest_ensure_response( $data );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to create a comment.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to create items, error object otherwise.
+ */
+ public function create_item_permissions_check( $request ) {
+ if ( ! is_user_logged_in() ) {
+ if ( get_option( 'comment_registration' ) ) {
+ return new WP_Error(
+ 'rest_comment_login_required',
+ __( 'Sorry, you must be logged in to comment.' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ /**
+ * Filters whether comments can be created via the REST API without authentication.
+ *
+ * Enables creating comments for anonymous users.
+ *
+ * @since 4.7.0
+ *
+ * @param bool $allow_anonymous Whether to allow anonymous comments to
+ * be created. Default `false`.
+ * @param WP_REST_Request $request Request used to generate the
+ * response.
+ */
+ $allow_anonymous = apply_filters( 'rest_allow_anonymous_comments', false, $request );
+
+ if ( ! $allow_anonymous ) {
+ return new WP_Error(
+ 'rest_comment_login_required',
+ __( 'Sorry, you must be logged in to comment.' ),
+ array( 'status' => 401 )
+ );
+ }
+ }
+
+ // Limit who can set comment `author`, `author_ip` or `status` to anything other than the default.
+ if ( isset( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( 'moderate_comments' ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_author',
+ /* translators: %s: Request parameter. */
+ sprintf( __( "Sorry, you are not allowed to edit '%s' for comments." ), 'author' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( isset( $request['author_ip'] ) && ! current_user_can( 'moderate_comments' ) ) {
+ if ( empty( $_SERVER['REMOTE_ADDR'] ) || $request['author_ip'] !== $_SERVER['REMOTE_ADDR'] ) {
+ return new WP_Error(
+ 'rest_comment_invalid_author_ip',
+ /* translators: %s: Request parameter. */
+ sprintf( __( "Sorry, you are not allowed to edit '%s' for comments." ), 'author_ip' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ }
+
+ if ( isset( $request['status'] ) && ! current_user_can( 'moderate_comments' ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_status',
+ /* translators: %s: Request parameter. */
+ sprintf( __( "Sorry, you are not allowed to edit '%s' for comments." ), 'status' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( empty( $request['post'] ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_post_id',
+ __( 'Sorry, you are not allowed to create this comment without a post.' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ $post = get_post( (int) $request['post'] );
+
+ if ( ! $post ) {
+ return new WP_Error(
+ 'rest_comment_invalid_post_id',
+ __( 'Sorry, you are not allowed to create this comment without a post.' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ if ( 'draft' === $post->post_status ) {
+ return new WP_Error(
+ 'rest_comment_draft_post',
+ __( 'Sorry, you are not allowed to create a comment on this post.' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ if ( 'trash' === $post->post_status ) {
+ return new WP_Error(
+ 'rest_comment_trash_post',
+ __( 'Sorry, you are not allowed to create a comment on this post.' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ if ( ! $this->check_read_post_permission( $post, $request ) ) {
+ return new WP_Error(
+ 'rest_cannot_read_post',
+ __( 'Sorry, you are not allowed to read the post for this comment.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! comments_open( $post->ID ) ) {
+ return new WP_Error(
+ 'rest_comment_closed',
+ __( 'Sorry, comments are closed for this item.' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Creates a comment.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or error object on failure.
+ */
+ public function create_item( $request ) {
+ if ( ! empty( $request['id'] ) ) {
+ return new WP_Error(
+ 'rest_comment_exists',
+ __( 'Cannot create existing comment.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Do not allow comments to be created with a non-default type.
+ if ( ! empty( $request['type'] ) && 'comment' !== $request['type'] ) {
+ return new WP_Error(
+ 'rest_invalid_comment_type',
+ __( 'Cannot create a comment with that type.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $prepared_comment = $this->prepare_item_for_database( $request );
+ if ( is_wp_error( $prepared_comment ) ) {
+ return $prepared_comment;
+ }
+
+ $prepared_comment['comment_type'] = 'comment';
+
+ if ( ! isset( $prepared_comment['comment_content'] ) ) {
+ $prepared_comment['comment_content'] = '';
+ }
+
+ if ( ! $this->check_is_comment_content_allowed( $prepared_comment ) ) {
+ return new WP_Error(
+ 'rest_comment_content_invalid',
+ __( 'Invalid comment content.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Setting remaining values before wp_insert_comment so we can use wp_allow_comment().
+ if ( ! isset( $prepared_comment['comment_date_gmt'] ) ) {
+ $prepared_comment['comment_date_gmt'] = current_time( 'mysql', true );
+ }
+
+ // Set author data if the user's logged in.
+ $missing_author = empty( $prepared_comment['user_id'] )
+ && empty( $prepared_comment['comment_author'] )
+ && empty( $prepared_comment['comment_author_email'] )
+ && empty( $prepared_comment['comment_author_url'] );
+
+ if ( is_user_logged_in() && $missing_author ) {
+ $user = wp_get_current_user();
+
+ $prepared_comment['user_id'] = $user->ID;
+ $prepared_comment['comment_author'] = $user->display_name;
+ $prepared_comment['comment_author_email'] = $user->user_email;
+ $prepared_comment['comment_author_url'] = $user->user_url;
+ }
+
+ // Honor the discussion setting that requires a name and email address of the comment author.
+ if ( get_option( 'require_name_email' ) ) {
+ if ( empty( $prepared_comment['comment_author'] ) || empty( $prepared_comment['comment_author_email'] ) ) {
+ return new WP_Error(
+ 'rest_comment_author_data_required',
+ __( 'Creating a comment requires valid author name and email values.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ if ( ! isset( $prepared_comment['comment_author_email'] ) ) {
+ $prepared_comment['comment_author_email'] = '';
+ }
+
+ if ( ! isset( $prepared_comment['comment_author_url'] ) ) {
+ $prepared_comment['comment_author_url'] = '';
+ }
+
+ if ( ! isset( $prepared_comment['comment_agent'] ) ) {
+ $prepared_comment['comment_agent'] = '';
+ }
+
+ $check_comment_lengths = wp_check_comment_data_max_lengths( $prepared_comment );
+
+ if ( is_wp_error( $check_comment_lengths ) ) {
+ $error_code = $check_comment_lengths->get_error_code();
+ return new WP_Error(
+ $error_code,
+ __( 'Comment field exceeds maximum length allowed.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $prepared_comment['comment_approved'] = wp_allow_comment( $prepared_comment, true );
+
+ if ( is_wp_error( $prepared_comment['comment_approved'] ) ) {
+ $error_code = $prepared_comment['comment_approved']->get_error_code();
+ $error_message = $prepared_comment['comment_approved']->get_error_message();
+
+ if ( 'comment_duplicate' === $error_code ) {
+ return new WP_Error(
+ $error_code,
+ $error_message,
+ array( 'status' => 409 )
+ );
+ }
+
+ if ( 'comment_flood' === $error_code ) {
+ return new WP_Error(
+ $error_code,
+ $error_message,
+ array( 'status' => 400 )
+ );
+ }
+
+ return $prepared_comment['comment_approved'];
+ }
+
+ /**
+ * Filters a comment before it is inserted via the REST API.
+ *
+ * Allows modification of the comment right before it is inserted via wp_insert_comment().
+ * Returning a WP_Error value from the filter will short-circuit insertion and allow
+ * skipping further processing.
+ *
+ * @since 4.7.0
+ * @since 4.8.0 `$prepared_comment` can now be a WP_Error to short-circuit insertion.
+ *
+ * @param array|WP_Error $prepared_comment The prepared comment data for wp_insert_comment().
+ * @param WP_REST_Request $request Request used to insert the comment.
+ */
+ $prepared_comment = apply_filters( 'rest_pre_insert_comment', $prepared_comment, $request );
+ if ( is_wp_error( $prepared_comment ) ) {
+ return $prepared_comment;
+ }
+
+ $comment_id = wp_insert_comment( wp_filter_comment( wp_slash( (array) $prepared_comment ) ) );
+
+ if ( ! $comment_id ) {
+ return new WP_Error(
+ 'rest_comment_failed_create',
+ __( 'Creating comment failed.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ if ( isset( $request['status'] ) ) {
+ $this->handle_status_param( $request['status'], $comment_id );
+ }
+
+ $comment = get_comment( $comment_id );
+
+ /**
+ * Fires after a comment is created or updated via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Comment $comment Inserted or updated comment object.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating a comment, false
+ * when updating.
+ */
+ do_action( 'rest_insert_comment', $comment, $request, true );
+
+ $schema = $this->get_item_schema();
+
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $comment_id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $fields_update = $this->update_additional_fields_for_object( $comment, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $context = current_user_can( 'moderate_comments' ) ? 'edit' : 'view';
+ $request->set_param( 'context', $context );
+
+ /**
+ * Fires completely after a comment is created or updated via the REST API.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_Comment $comment Inserted or updated comment object.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating a comment, false
+ * when updating.
+ */
+ do_action( 'rest_after_insert_comment', $comment, $request, true );
+
+ $response = $this->prepare_item_for_response( $comment, $request );
+ $response = rest_ensure_response( $response );
+
+ $response->set_status( 201 );
+ $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment_id ) ) );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given REST request has access to update a comment.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to update the item, error object otherwise.
+ */
+ public function update_item_permissions_check( $request ) {
+ $comment = $this->get_comment( $request['id'] );
+ if ( is_wp_error( $comment ) ) {
+ return $comment;
+ }
+
+ if ( ! $this->check_edit_permission( $comment ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit',
+ __( 'Sorry, you are not allowed to edit this comment.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Updates a comment.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or error object on failure.
+ */
+ public function update_item( $request ) {
+ $comment = $this->get_comment( $request['id'] );
+ if ( is_wp_error( $comment ) ) {
+ return $comment;
+ }
+
+ $id = $comment->comment_ID;
+
+ if ( isset( $request['type'] ) && get_comment_type( $id ) !== $request['type'] ) {
+ return new WP_Error(
+ 'rest_comment_invalid_type',
+ __( 'Sorry, you are not allowed to change the comment type.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $prepared_args = $this->prepare_item_for_database( $request );
+
+ if ( is_wp_error( $prepared_args ) ) {
+ return $prepared_args;
+ }
+
+ if ( ! empty( $prepared_args['comment_post_ID'] ) ) {
+ $post = get_post( $prepared_args['comment_post_ID'] );
+
+ if ( empty( $post ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_post_id',
+ __( 'Invalid post ID.' ),
+ array( 'status' => 403 )
+ );
+ }
+ }
+
+ if ( empty( $prepared_args ) && isset( $request['status'] ) ) {
+ // Only the comment status is being changed.
+ $change = $this->handle_status_param( $request['status'], $id );
+
+ if ( ! $change ) {
+ return new WP_Error(
+ 'rest_comment_failed_edit',
+ __( 'Updating comment status failed.' ),
+ array( 'status' => 500 )
+ );
+ }
+ } elseif ( ! empty( $prepared_args ) ) {
+ if ( is_wp_error( $prepared_args ) ) {
+ return $prepared_args;
+ }
+
+ if ( isset( $prepared_args['comment_content'] ) && empty( $prepared_args['comment_content'] ) ) {
+ return new WP_Error(
+ 'rest_comment_content_invalid',
+ __( 'Invalid comment content.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $prepared_args['comment_ID'] = $id;
+
+ $check_comment_lengths = wp_check_comment_data_max_lengths( $prepared_args );
+
+ if ( is_wp_error( $check_comment_lengths ) ) {
+ $error_code = $check_comment_lengths->get_error_code();
+ return new WP_Error(
+ $error_code,
+ __( 'Comment field exceeds maximum length allowed.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $updated = wp_update_comment( wp_slash( (array) $prepared_args ), true );
+
+ if ( is_wp_error( $updated ) ) {
+ return new WP_Error(
+ 'rest_comment_failed_edit',
+ __( 'Updating comment failed.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ if ( isset( $request['status'] ) ) {
+ $this->handle_status_param( $request['status'], $id );
+ }
+ }
+
+ $comment = get_comment( $id );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */
+ do_action( 'rest_insert_comment', $comment, $request, false );
+
+ $schema = $this->get_item_schema();
+
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $fields_update = $this->update_additional_fields_for_object( $comment, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */
+ do_action( 'rest_after_insert_comment', $comment, $request, false );
+
+ $response = $this->prepare_item_for_response( $comment, $request );
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Checks if a given request has access to delete a comment.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to delete the item, error object otherwise.
+ */
+ public function delete_item_permissions_check( $request ) {
+ $comment = $this->get_comment( $request['id'] );
+ if ( is_wp_error( $comment ) ) {
+ return $comment;
+ }
+
+ if ( ! $this->check_edit_permission( $comment ) ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'Sorry, you are not allowed to delete this comment.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ return true;
+ }
+
+ /**
+ * Deletes a comment.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or error object on failure.
+ */
+ public function delete_item( $request ) {
+ $comment = $this->get_comment( $request['id'] );
+ if ( is_wp_error( $comment ) ) {
+ return $comment;
+ }
+
+ $force = isset( $request['force'] ) ? (bool) $request['force'] : false;
+
+ /**
+ * Filters whether a comment can be trashed via the REST API.
+ *
+ * Return false to disable trash support for the comment.
+ *
+ * @since 4.7.0
+ *
+ * @param bool $supports_trash Whether the comment supports trashing.
+ * @param WP_Comment $comment The comment object being considered for trashing support.
+ */
+ $supports_trash = apply_filters( 'rest_comment_trashable', ( EMPTY_TRASH_DAYS > 0 ), $comment );
+
+ $request->set_param( 'context', 'edit' );
+
+ if ( $force ) {
+ $previous = $this->prepare_item_for_response( $comment, $request );
+ $result = wp_delete_comment( $comment->comment_ID, true );
+ $response = new WP_REST_Response();
+ $response->set_data(
+ array(
+ 'deleted' => true,
+ 'previous' => $previous->get_data(),
+ )
+ );
+ } else {
+ // If this type doesn't support trashing, error out.
+ if ( ! $supports_trash ) {
+ return new WP_Error(
+ 'rest_trash_not_supported',
+ /* translators: %s: force=true */
+ sprintf( __( "The comment does not support trashing. Set '%s' to delete." ), 'force=true' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ if ( 'trash' === $comment->comment_approved ) {
+ return new WP_Error(
+ 'rest_already_trashed',
+ __( 'The comment has already been trashed.' ),
+ array( 'status' => 410 )
+ );
+ }
+
+ $result = wp_trash_comment( $comment->comment_ID );
+ $comment = get_comment( $comment->comment_ID );
+ $response = $this->prepare_item_for_response( $comment, $request );
+ }
+
+ if ( ! $result ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'The comment cannot be deleted.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ /**
+ * Fires after a comment is deleted via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Comment $comment The deleted comment data.
+ * @param WP_REST_Response $response The response returned from the API.
+ * @param WP_REST_Request $request The request sent to the API.
+ */
+ do_action( 'rest_delete_comment', $comment, $response, $request );
+
+ return $response;
+ }
+
+ /**
+ * Prepares a single comment output for response.
+ *
+ * @since 4.7.0
+ * @since 5.9.0 Renamed `$comment` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param WP_Comment $item Comment object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $comment = $item;
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( in_array( 'id', $fields, true ) ) {
+ $data['id'] = (int) $comment->comment_ID;
+ }
+
+ if ( in_array( 'post', $fields, true ) ) {
+ $data['post'] = (int) $comment->comment_post_ID;
+ }
+
+ if ( in_array( 'parent', $fields, true ) ) {
+ $data['parent'] = (int) $comment->comment_parent;
+ }
+
+ if ( in_array( 'author', $fields, true ) ) {
+ $data['author'] = (int) $comment->user_id;
+ }
+
+ if ( in_array( 'author_name', $fields, true ) ) {
+ $data['author_name'] = $comment->comment_author;
+ }
+
+ if ( in_array( 'author_email', $fields, true ) ) {
+ $data['author_email'] = $comment->comment_author_email;
+ }
+
+ if ( in_array( 'author_url', $fields, true ) ) {
+ $data['author_url'] = $comment->comment_author_url;
+ }
+
+ if ( in_array( 'author_ip', $fields, true ) ) {
+ $data['author_ip'] = $comment->comment_author_IP;
+ }
+
+ if ( in_array( 'author_user_agent', $fields, true ) ) {
+ $data['author_user_agent'] = $comment->comment_agent;
+ }
+
+ if ( in_array( 'date', $fields, true ) ) {
+ $data['date'] = mysql_to_rfc3339( $comment->comment_date );
+ }
+
+ if ( in_array( 'date_gmt', $fields, true ) ) {
+ $data['date_gmt'] = mysql_to_rfc3339( $comment->comment_date_gmt );
+ }
+
+ if ( in_array( 'content', $fields, true ) ) {
+ $data['content'] = array(
+ /** This filter is documented in wp-includes/comment-template.php */
+ 'rendered' => apply_filters( 'comment_text', $comment->comment_content, $comment ),
+ 'raw' => $comment->comment_content,
+ );
+ }
+
+ if ( in_array( 'link', $fields, true ) ) {
+ $data['link'] = get_comment_link( $comment );
+ }
+
+ if ( in_array( 'status', $fields, true ) ) {
+ $data['status'] = $this->prepare_status_response( $comment->comment_approved );
+ }
+
+ if ( in_array( 'type', $fields, true ) ) {
+ $data['type'] = get_comment_type( $comment->comment_ID );
+ }
+
+ if ( in_array( 'author_avatar_urls', $fields, true ) ) {
+ $data['author_avatar_urls'] = rest_get_avatar_urls( $comment );
+ }
+
+ if ( in_array( 'meta', $fields, true ) ) {
+ $data['meta'] = $this->meta->get_value( $comment->comment_ID, $request );
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ // Wrap the data in a response object.
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $comment ) );
+ }
+
+ /**
+ * Filters a comment returned from the REST API.
+ *
+ * Allows modification of the comment right before it is returned.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Comment $comment The original comment object.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ return apply_filters( 'rest_prepare_comment', $response, $comment, $request );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Comment $comment Comment object.
+ * @return array Links for the given comment.
+ */
+ protected function prepare_links( $comment ) {
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment->comment_ID ) ),
+ ),
+ 'collection' => array(
+ 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+ ),
+ );
+
+ if ( 0 !== (int) $comment->user_id ) {
+ $links['author'] = array(
+ 'href' => rest_url( 'wp/v2/users/' . $comment->user_id ),
+ 'embeddable' => true,
+ );
+ }
+
+ if ( 0 !== (int) $comment->comment_post_ID ) {
+ $post = get_post( $comment->comment_post_ID );
+ $post_route = rest_get_route_for_post( $post );
+
+ if ( ! empty( $post->ID ) && $post_route ) {
+ $links['up'] = array(
+ 'href' => rest_url( $post_route ),
+ 'embeddable' => true,
+ 'post_type' => $post->post_type,
+ );
+ }
+ }
+
+ if ( 0 !== (int) $comment->comment_parent ) {
+ $links['in-reply-to'] = array(
+ 'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment->comment_parent ) ),
+ 'embeddable' => true,
+ );
+ }
+
+ // Only grab one comment to verify the comment has children.
+ $comment_children = $comment->get_children(
+ array(
+ 'count' => true,
+ 'orderby' => 'none',
+ )
+ );
+
+ if ( ! empty( $comment_children ) ) {
+ $args = array(
+ 'parent' => $comment->comment_ID,
+ );
+
+ $rest_url = add_query_arg( $args, rest_url( $this->namespace . '/' . $this->rest_base ) );
+
+ $links['children'] = array(
+ 'href' => $rest_url,
+ 'embeddable' => true,
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * Prepends internal property prefix to query parameters to match our response fields.
+ *
+ * @since 4.7.0
+ *
+ * @param string $query_param Query parameter.
+ * @return string The normalized query parameter.
+ */
+ protected function normalize_query_param( $query_param ) {
+ $prefix = 'comment_';
+
+ switch ( $query_param ) {
+ case 'id':
+ $normalized = $prefix . 'ID';
+ break;
+ case 'post':
+ $normalized = $prefix . 'post_ID';
+ break;
+ case 'parent':
+ $normalized = $prefix . 'parent';
+ break;
+ case 'include':
+ $normalized = 'comment__in';
+ break;
+ default:
+ $normalized = $prefix . $query_param;
+ break;
+ }
+
+ return $normalized;
+ }
+
+ /**
+ * Checks comment_approved to set comment status for single comment output.
+ *
+ * @since 4.7.0
+ *
+ * @param string|int $comment_approved comment status.
+ * @return string Comment status.
+ */
+ protected function prepare_status_response( $comment_approved ) {
+
+ switch ( $comment_approved ) {
+ case 'hold':
+ case '0':
+ $status = 'hold';
+ break;
+
+ case 'approve':
+ case '1':
+ $status = 'approved';
+ break;
+
+ case 'spam':
+ case 'trash':
+ default:
+ $status = $comment_approved;
+ break;
+ }
+
+ return $status;
+ }
+
+ /**
+ * Prepares a single comment to be inserted into the database.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return array|WP_Error Prepared comment, otherwise WP_Error object.
+ */
+ protected function prepare_item_for_database( $request ) {
+ $prepared_comment = array();
+
+ /*
+ * Allow the comment_content to be set via the 'content' or
+ * the 'content.raw' properties of the Request object.
+ */
+ if ( isset( $request['content'] ) && is_string( $request['content'] ) ) {
+ $prepared_comment['comment_content'] = trim( $request['content'] );
+ } elseif ( isset( $request['content']['raw'] ) && is_string( $request['content']['raw'] ) ) {
+ $prepared_comment['comment_content'] = trim( $request['content']['raw'] );
+ }
+
+ if ( isset( $request['post'] ) ) {
+ $prepared_comment['comment_post_ID'] = (int) $request['post'];
+ }
+
+ if ( isset( $request['parent'] ) ) {
+ $prepared_comment['comment_parent'] = $request['parent'];
+ }
+
+ if ( isset( $request['author'] ) ) {
+ $user = new WP_User( $request['author'] );
+
+ if ( $user->exists() ) {
+ $prepared_comment['user_id'] = $user->ID;
+ $prepared_comment['comment_author'] = $user->display_name;
+ $prepared_comment['comment_author_email'] = $user->user_email;
+ $prepared_comment['comment_author_url'] = $user->user_url;
+ } else {
+ return new WP_Error(
+ 'rest_comment_author_invalid',
+ __( 'Invalid comment author ID.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ if ( isset( $request['author_name'] ) ) {
+ $prepared_comment['comment_author'] = $request['author_name'];
+ }
+
+ if ( isset( $request['author_email'] ) ) {
+ $prepared_comment['comment_author_email'] = $request['author_email'];
+ }
+
+ if ( isset( $request['author_url'] ) ) {
+ $prepared_comment['comment_author_url'] = $request['author_url'];
+ }
+
+ if ( isset( $request['author_ip'] ) && current_user_can( 'moderate_comments' ) ) {
+ $prepared_comment['comment_author_IP'] = $request['author_ip'];
+ } elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) && rest_is_ip_address( $_SERVER['REMOTE_ADDR'] ) ) {
+ $prepared_comment['comment_author_IP'] = $_SERVER['REMOTE_ADDR'];
+ } else {
+ $prepared_comment['comment_author_IP'] = '127.0.0.1';
+ }
+
+ if ( ! empty( $request['author_user_agent'] ) ) {
+ $prepared_comment['comment_agent'] = $request['author_user_agent'];
+ } elseif ( $request->get_header( 'user_agent' ) ) {
+ $prepared_comment['comment_agent'] = $request->get_header( 'user_agent' );
+ }
+
+ if ( ! empty( $request['date'] ) ) {
+ $date_data = rest_get_date_with_gmt( $request['date'] );
+
+ if ( ! empty( $date_data ) ) {
+ list( $prepared_comment['comment_date'], $prepared_comment['comment_date_gmt'] ) = $date_data;
+ }
+ } elseif ( ! empty( $request['date_gmt'] ) ) {
+ $date_data = rest_get_date_with_gmt( $request['date_gmt'], true );
+
+ if ( ! empty( $date_data ) ) {
+ list( $prepared_comment['comment_date'], $prepared_comment['comment_date_gmt'] ) = $date_data;
+ }
+ }
+
+ /**
+ * Filters a comment added via the REST API after it is prepared for insertion into the database.
+ *
+ * Allows modification of the comment right after it is prepared for the database.
+ *
+ * @since 4.7.0
+ *
+ * @param array $prepared_comment The prepared comment data for `wp_insert_comment`.
+ * @param WP_REST_Request $request The current request.
+ */
+ return apply_filters( 'rest_preprocess_comment', $prepared_comment, $request );
+ }
+
+ /**
+ * Retrieves the comment's schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ *
+ * @return array
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'comment',
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the comment.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'author' => array(
+ 'description' => __( 'The ID of the user object, if author was a user.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'author_email' => array(
+ 'description' => __( 'Email address for the comment author.' ),
+ 'type' => 'string',
+ 'format' => 'email',
+ 'context' => array( 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => array( $this, 'check_comment_author_email' ),
+ 'validate_callback' => null, // Skip built-in validation of 'email'.
+ ),
+ ),
+ 'author_ip' => array(
+ 'description' => __( 'IP address for the comment author.' ),
+ 'type' => 'string',
+ 'format' => 'ip',
+ 'context' => array( 'edit' ),
+ ),
+ 'author_name' => array(
+ 'description' => __( 'Display name for the comment author.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ 'author_url' => array(
+ 'description' => __( 'URL for the comment author.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'author_user_agent' => array(
+ 'description' => __( 'User agent for the comment author.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ 'content' => array(
+ 'description' => __( 'The content for the comment.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database().
+ 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database().
+ ),
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'Content for the comment, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ ),
+ 'rendered' => array(
+ 'description' => __( 'HTML content for the comment, transformed for display.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ ),
+ 'date' => array(
+ 'description' => __( "The date the comment was published, in the site's timezone." ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'date_gmt' => array(
+ 'description' => __( 'The date the comment was published, as GMT.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'link' => array(
+ 'description' => __( 'URL to the comment.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'parent' => array(
+ 'description' => __( 'The ID for the parent of the comment.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'default' => 0,
+ ),
+ 'post' => array(
+ 'description' => __( 'The ID of the associated post object.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'default' => 0,
+ ),
+ 'status' => array(
+ 'description' => __( 'State of the comment.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_key',
+ ),
+ ),
+ 'type' => array(
+ 'description' => __( 'Type of the comment.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ if ( get_option( 'show_avatars' ) ) {
+ $avatar_properties = array();
+
+ $avatar_sizes = rest_get_avatar_sizes();
+
+ foreach ( $avatar_sizes as $size ) {
+ $avatar_properties[ $size ] = array(
+ /* translators: %d: Avatar image size in pixels. */
+ 'description' => sprintf( __( 'Avatar URL with image size of %d pixels.' ), $size ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ );
+ }
+
+ $schema['properties']['author_avatar_urls'] = array(
+ 'description' => __( 'Avatar URLs for the comment author.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ 'properties' => $avatar_properties,
+ );
+ }
+
+ $schema['properties']['meta'] = $this->meta->get_field_schema();
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * @since 4.7.0
+ *
+ * @return array Comments collection parameters.
+ */
+ public function get_collection_params() {
+ $query_params = parent::get_collection_params();
+
+ $query_params['context']['default'] = 'view';
+
+ $query_params['after'] = array(
+ 'description' => __( 'Limit response to comments published after a given ISO8601 compliant date.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ );
+
+ $query_params['author'] = array(
+ 'description' => __( 'Limit result set to comments assigned to specific user IDs. Requires authorization.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ );
+
+ $query_params['author_exclude'] = array(
+ 'description' => __( 'Ensure result set excludes comments assigned to specific user IDs. Requires authorization.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ );
+
+ $query_params['author_email'] = array(
+ 'default' => null,
+ 'description' => __( 'Limit result set to that from a specific author email. Requires authorization.' ),
+ 'format' => 'email',
+ 'type' => 'string',
+ );
+
+ $query_params['before'] = array(
+ 'description' => __( 'Limit response to comments published before a given ISO8601 compliant date.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ );
+
+ $query_params['exclude'] = array(
+ 'description' => __( 'Ensure result set excludes specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ $query_params['include'] = array(
+ 'description' => __( 'Limit result set to specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ $query_params['offset'] = array(
+ 'description' => __( 'Offset the result set by a specific number of items.' ),
+ 'type' => 'integer',
+ );
+
+ $query_params['order'] = array(
+ 'description' => __( 'Order sort attribute ascending or descending.' ),
+ 'type' => 'string',
+ 'default' => 'desc',
+ 'enum' => array(
+ 'asc',
+ 'desc',
+ ),
+ );
+
+ $query_params['orderby'] = array(
+ 'description' => __( 'Sort collection by comment attribute.' ),
+ 'type' => 'string',
+ 'default' => 'date_gmt',
+ 'enum' => array(
+ 'date',
+ 'date_gmt',
+ 'id',
+ 'include',
+ 'post',
+ 'parent',
+ 'type',
+ ),
+ );
+
+ $query_params['parent'] = array(
+ 'default' => array(),
+ 'description' => __( 'Limit result set to comments of specific parent IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ );
+
+ $query_params['parent_exclude'] = array(
+ 'default' => array(),
+ 'description' => __( 'Ensure result set excludes specific parent IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ );
+
+ $query_params['post'] = array(
+ 'default' => array(),
+ 'description' => __( 'Limit result set to comments assigned to specific post IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ );
+
+ $query_params['status'] = array(
+ 'default' => 'approve',
+ 'description' => __( 'Limit result set to comments assigned a specific status. Requires authorization.' ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
+ $query_params['type'] = array(
+ 'default' => 'comment',
+ 'description' => __( 'Limit result set to comments assigned a specific type. Requires authorization.' ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
+ $query_params['password'] = array(
+ 'description' => __( 'The password for the post if it is password protected.' ),
+ 'type' => 'string',
+ );
+
+ /**
+ * Filters REST API collection parameters for the comments controller.
+ *
+ * This filter registers the collection parameter, but does not map the
+ * collection parameter to an internal WP_Comment_Query parameter. Use the
+ * `rest_comment_query` filter to set WP_Comment_Query parameters.
+ *
+ * @since 4.7.0
+ *
+ * @param array $query_params JSON Schema-formatted collection parameters.
+ */
+ return apply_filters( 'rest_comment_collection_params', $query_params );
+ }
+
+ /**
+ * Sets the comment_status of a given comment object when creating or updating a comment.
+ *
+ * @since 4.7.0
+ *
+ * @param string|int $new_status New comment status.
+ * @param int $comment_id Comment ID.
+ * @return bool Whether the status was changed.
+ */
+ protected function handle_status_param( $new_status, $comment_id ) {
+ $old_status = wp_get_comment_status( $comment_id );
+
+ if ( $new_status === $old_status ) {
+ return false;
+ }
+
+ switch ( $new_status ) {
+ case 'approved':
+ case 'approve':
+ case '1':
+ $changed = wp_set_comment_status( $comment_id, 'approve' );
+ break;
+ case 'hold':
+ case '0':
+ $changed = wp_set_comment_status( $comment_id, 'hold' );
+ break;
+ case 'spam':
+ $changed = wp_spam_comment( $comment_id );
+ break;
+ case 'unspam':
+ $changed = wp_unspam_comment( $comment_id );
+ break;
+ case 'trash':
+ $changed = wp_trash_comment( $comment_id );
+ break;
+ case 'untrash':
+ $changed = wp_untrash_comment( $comment_id );
+ break;
+ default:
+ $changed = false;
+ break;
+ }
+
+ return $changed;
+ }
+
+ /**
+ * Checks if the post can be read.
+ *
+ * Correctly handles posts with the inherit status.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post $post Post object.
+ * @param WP_REST_Request $request Request data to check.
+ * @return bool Whether post can be read.
+ */
+ protected function check_read_post_permission( $post, $request ) {
+ $post_type = get_post_type_object( $post->post_type );
+
+ // Return false if custom post type doesn't exist
+ if ( ! $post_type ) {
+ return false;
+ }
+
+ $posts_controller = $post_type->get_rest_controller();
+
+ /*
+ * Ensure the posts controller is specifically a WP_REST_Posts_Controller instance
+ * before using methods specific to that controller.
+ */
+ if ( ! $posts_controller instanceof WP_REST_Posts_Controller ) {
+ $posts_controller = new WP_REST_Posts_Controller( $post->post_type );
+ }
+
+ $has_password_filter = false;
+
+ // Only check password if a specific post was queried for or a single comment
+ $requested_post = ! empty( $request['post'] ) && ( ! is_array( $request['post'] ) || 1 === count( $request['post'] ) );
+ $requested_comment = ! empty( $request['id'] );
+ if ( ( $requested_post || $requested_comment ) && $posts_controller->can_access_password_content( $post, $request ) ) {
+ add_filter( 'post_password_required', '__return_false' );
+
+ $has_password_filter = true;
+ }
+
+ if ( post_password_required( $post ) ) {
+ $result = current_user_can( 'edit_post', $post->ID );
+ } else {
+ $result = $posts_controller->check_read_permission( $post );
+ }
+
+ if ( $has_password_filter ) {
+ remove_filter( 'post_password_required', '__return_false' );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Checks if the comment can be read.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Comment $comment Comment object.
+ * @param WP_REST_Request $request Request data to check.
+ * @return bool Whether the comment can be read.
+ */
+ protected function check_read_permission( $comment, $request ) {
+ if ( ! empty( $comment->comment_post_ID ) ) {
+ $post = get_post( $comment->comment_post_ID );
+ if ( $post ) {
+ if ( $this->check_read_post_permission( $post, $request ) && 1 === (int) $comment->comment_approved ) {
+ return true;
+ }
+ }
+ }
+
+ if ( 0 === get_current_user_id() ) {
+ return false;
+ }
+
+ if ( empty( $comment->comment_post_ID ) && ! current_user_can( 'moderate_comments' ) ) {
+ return false;
+ }
+
+ if ( ! empty( $comment->user_id ) && get_current_user_id() === (int) $comment->user_id ) {
+ return true;
+ }
+
+ return current_user_can( 'edit_comment', $comment->comment_ID );
+ }
+
+ /**
+ * Checks if a comment can be edited or deleted.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Comment $comment Comment object.
+ * @return bool Whether the comment can be edited or deleted.
+ */
+ protected function check_edit_permission( $comment ) {
+ if ( 0 === (int) get_current_user_id() ) {
+ return false;
+ }
+
+ if ( current_user_can( 'moderate_comments' ) ) {
+ return true;
+ }
+
+ return current_user_can( 'edit_comment', $comment->comment_ID );
+ }
+
+ /**
+ * Checks a comment author email for validity.
+ *
+ * Accepts either a valid email address or empty string as a valid comment
+ * author email address. Setting the comment author email to an empty
+ * string is allowed when a comment is being updated.
+ *
+ * @since 4.7.0
+ *
+ * @param string $value Author email value submitted.
+ * @param WP_REST_Request $request Full details about the request.
+ * @param string $param The parameter name.
+ * @return string|WP_Error The sanitized email address, if valid,
+ * otherwise an error.
+ */
+ public function check_comment_author_email( $value, $request, $param ) {
+ $email = (string) $value;
+ if ( empty( $email ) ) {
+ return $email;
+ }
+
+ $check_email = rest_validate_request_arg( $email, $request, $param );
+ if ( is_wp_error( $check_email ) ) {
+ return $check_email;
+ }
+
+ return $email;
+ }
+
+ /**
+ * If empty comments are not allowed, checks if the provided comment content is not empty.
+ *
+ * @since 5.6.0
+ *
+ * @param array $prepared_comment The prepared comment data.
+ * @return bool True if the content is allowed, false otherwise.
+ */
+ protected function check_is_comment_content_allowed( $prepared_comment ) {
+ $check = wp_parse_args(
+ $prepared_comment,
+ array(
+ 'comment_post_ID' => 0,
+ 'comment_author' => null,
+ 'comment_author_email' => null,
+ 'comment_author_url' => null,
+ 'comment_parent' => 0,
+ 'user_id' => 0,
+ )
+ );
+
+ /** This filter is documented in wp-includes/comment.php */
+ $allow_empty = apply_filters( 'allow_empty_comment', false, $check );
+
+ if ( $allow_empty ) {
+ return true;
+ }
+
+ /*
+ * Do not allow a comment to be created with missing or empty
+ * comment_content. See wp_handle_comment_submission().
+ */
+ return '' !== $check['comment_content'];
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-controller.php
new file mode 100644
index 0000000..4b960d6
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-controller.php
@@ -0,0 +1,681 @@
+<?php
+/**
+ * REST API: WP_REST_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core base controller for managing and interacting with REST API items.
+ *
+ * @since 4.7.0
+ */
+#[AllowDynamicProperties]
+abstract class WP_REST_Controller {
+
+ /**
+ * The namespace of this controller's route.
+ *
+ * @since 4.7.0
+ * @var string
+ */
+ protected $namespace;
+
+ /**
+ * The base of this controller's route.
+ *
+ * @since 4.7.0
+ * @var string
+ */
+ protected $rest_base;
+
+ /**
+ * Cached results of get_item_schema.
+ *
+ * @since 5.3.0
+ * @var array
+ */
+ protected $schema;
+
+ /**
+ * Registers the routes for the objects of the controller.
+ *
+ * @since 4.7.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+ _doing_it_wrong(
+ 'WP_REST_Controller::register_routes',
+ /* translators: %s: register_routes() */
+ sprintf( __( "Method '%s' must be overridden." ), __METHOD__ ),
+ '4.7.0'
+ );
+ }
+
+ /**
+ * Checks if a given request has access to get items.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Retrieves a collection of items.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to get a specific item.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Retrieves one item from the collection.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to create items.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
+ */
+ public function create_item_permissions_check( $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Creates one item from the collection.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to update a specific item.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
+ */
+ public function update_item_permissions_check( $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Updates one item from the collection.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to delete a specific item.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise.
+ */
+ public function delete_item_permissions_check( $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Deletes one item from the collection.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_item( $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Prepares one item for create or update operation.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return object|WP_Error The prepared item, or WP_Error object on failure.
+ */
+ protected function prepare_item_for_database( $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Prepares the item for the REST response.
+ *
+ * @since 4.7.0
+ *
+ * @param mixed $item WordPress representation of the item.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ return new WP_Error(
+ 'invalid-method',
+ /* translators: %s: Method name. */
+ sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ),
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Prepares a response for insertion into a collection.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Response $response Response object.
+ * @return array|mixed Response data, ready for insertion into collection data.
+ */
+ public function prepare_response_for_collection( $response ) {
+ if ( ! ( $response instanceof WP_REST_Response ) ) {
+ return $response;
+ }
+
+ $data = (array) $response->get_data();
+ $server = rest_get_server();
+ $links = $server::get_compact_response_links( $response );
+
+ if ( ! empty( $links ) ) {
+ $data['_links'] = $links;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Filters a response based on the context defined in the schema.
+ *
+ * @since 4.7.0
+ *
+ * @param array $response_data Response data to filter.
+ * @param string $context Context defined in the schema.
+ * @return array Filtered response.
+ */
+ public function filter_response_by_context( $response_data, $context ) {
+
+ $schema = $this->get_item_schema();
+
+ return rest_filter_response_by_context( $response_data, $schema, $context );
+ }
+
+ /**
+ * Retrieves the item's schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ return $this->add_additional_fields_schema( array() );
+ }
+
+ /**
+ * Retrieves the item's schema for display / public consumption purposes.
+ *
+ * @since 4.7.0
+ *
+ * @return array Public item schema data.
+ */
+ public function get_public_item_schema() {
+
+ $schema = $this->get_item_schema();
+
+ if ( ! empty( $schema['properties'] ) ) {
+ foreach ( $schema['properties'] as &$property ) {
+ unset( $property['arg_options'] );
+ }
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Retrieves the query params for the collections.
+ *
+ * @since 4.7.0
+ *
+ * @return array Query parameters for the collection.
+ */
+ public function get_collection_params() {
+ return array(
+ 'context' => $this->get_context_param(),
+ 'page' => array(
+ 'description' => __( 'Current page of the collection.' ),
+ 'type' => 'integer',
+ 'default' => 1,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'minimum' => 1,
+ ),
+ 'per_page' => array(
+ 'description' => __( 'Maximum number of items to be returned in result set.' ),
+ 'type' => 'integer',
+ 'default' => 10,
+ 'minimum' => 1,
+ 'maximum' => 100,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ 'search' => array(
+ 'description' => __( 'Limit results to those matching a string.' ),
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ );
+ }
+
+ /**
+ * Retrieves the magical context param.
+ *
+ * Ensures consistent descriptions between endpoints, and populates enum from schema.
+ *
+ * @since 4.7.0
+ *
+ * @param array $args Optional. Additional arguments for context parameter. Default empty array.
+ * @return array Context parameter details.
+ */
+ public function get_context_param( $args = array() ) {
+ $param_details = array(
+ 'description' => __( 'Scope under which the request is made; determines fields present in response.' ),
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_key',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
+ $schema = $this->get_item_schema();
+
+ if ( empty( $schema['properties'] ) ) {
+ return array_merge( $param_details, $args );
+ }
+
+ $contexts = array();
+
+ foreach ( $schema['properties'] as $attributes ) {
+ if ( ! empty( $attributes['context'] ) ) {
+ $contexts = array_merge( $contexts, $attributes['context'] );
+ }
+ }
+
+ if ( ! empty( $contexts ) ) {
+ $param_details['enum'] = array_unique( $contexts );
+ rsort( $param_details['enum'] );
+ }
+
+ return array_merge( $param_details, $args );
+ }
+
+ /**
+ * Adds the values from additional fields to a data object.
+ *
+ * @since 4.7.0
+ *
+ * @param array $response_data Prepared response array.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return array Modified data object with additional fields.
+ */
+ protected function add_additional_fields_to_object( $response_data, $request ) {
+
+ $additional_fields = $this->get_additional_fields();
+
+ $requested_fields = $this->get_fields_for_response( $request );
+
+ foreach ( $additional_fields as $field_name => $field_options ) {
+ if ( ! $field_options['get_callback'] ) {
+ continue;
+ }
+
+ if ( ! rest_is_field_included( $field_name, $requested_fields ) ) {
+ continue;
+ }
+
+ $response_data[ $field_name ] = call_user_func(
+ $field_options['get_callback'],
+ $response_data,
+ $field_name,
+ $request,
+ $this->get_object_type()
+ );
+ }
+
+ return $response_data;
+ }
+
+ /**
+ * Updates the values of additional fields added to a data object.
+ *
+ * @since 4.7.0
+ *
+ * @param object $data_object Data model like WP_Term or WP_Post.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True on success, WP_Error object if a field cannot be updated.
+ */
+ protected function update_additional_fields_for_object( $data_object, $request ) {
+ $additional_fields = $this->get_additional_fields();
+
+ foreach ( $additional_fields as $field_name => $field_options ) {
+ if ( ! $field_options['update_callback'] ) {
+ continue;
+ }
+
+ // Don't run the update callbacks if the data wasn't passed in the request.
+ if ( ! isset( $request[ $field_name ] ) ) {
+ continue;
+ }
+
+ $result = call_user_func(
+ $field_options['update_callback'],
+ $request[ $field_name ],
+ $data_object,
+ $field_name,
+ $request,
+ $this->get_object_type()
+ );
+
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Adds the schema from additional fields to a schema array.
+ *
+ * The type of object is inferred from the passed schema.
+ *
+ * @since 4.7.0
+ *
+ * @param array $schema Schema array.
+ * @return array Modified Schema array.
+ */
+ protected function add_additional_fields_schema( $schema ) {
+ if ( empty( $schema['title'] ) ) {
+ return $schema;
+ }
+
+ // Can't use $this->get_object_type otherwise we cause an inf loop.
+ $object_type = $schema['title'];
+
+ $additional_fields = $this->get_additional_fields( $object_type );
+
+ foreach ( $additional_fields as $field_name => $field_options ) {
+ if ( ! $field_options['schema'] ) {
+ continue;
+ }
+
+ $schema['properties'][ $field_name ] = $field_options['schema'];
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Retrieves all of the registered additional fields for a given object-type.
+ *
+ * @since 4.7.0
+ *
+ * @global array $wp_rest_additional_fields Holds registered fields, organized by object type.
+ *
+ * @param string $object_type Optional. The object type.
+ * @return array Registered additional fields (if any), empty array if none or if the object type
+ * could not be inferred.
+ */
+ protected function get_additional_fields( $object_type = null ) {
+ global $wp_rest_additional_fields;
+
+ if ( ! $object_type ) {
+ $object_type = $this->get_object_type();
+ }
+
+ if ( ! $object_type ) {
+ return array();
+ }
+
+ if ( ! $wp_rest_additional_fields || ! isset( $wp_rest_additional_fields[ $object_type ] ) ) {
+ return array();
+ }
+
+ return $wp_rest_additional_fields[ $object_type ];
+ }
+
+ /**
+ * Retrieves the object type this controller is responsible for managing.
+ *
+ * @since 4.7.0
+ *
+ * @return string Object type for the controller.
+ */
+ protected function get_object_type() {
+ $schema = $this->get_item_schema();
+
+ if ( ! $schema || ! isset( $schema['title'] ) ) {
+ return null;
+ }
+
+ return $schema['title'];
+ }
+
+ /**
+ * Gets an array of fields to be included on the response.
+ *
+ * Included fields are based on item schema and `_fields=` request argument.
+ *
+ * @since 4.9.6
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return string[] Fields to be included in the response.
+ */
+ public function get_fields_for_response( $request ) {
+ $schema = $this->get_item_schema();
+ $properties = isset( $schema['properties'] ) ? $schema['properties'] : array();
+
+ $additional_fields = $this->get_additional_fields();
+
+ foreach ( $additional_fields as $field_name => $field_options ) {
+ /*
+ * For back-compat, include any field with an empty schema
+ * because it won't be present in $this->get_item_schema().
+ */
+ if ( is_null( $field_options['schema'] ) ) {
+ $properties[ $field_name ] = $field_options;
+ }
+ }
+
+ // Exclude fields that specify a different context than the request context.
+ $context = $request['context'];
+ if ( $context ) {
+ foreach ( $properties as $name => $options ) {
+ if ( ! empty( $options['context'] ) && ! in_array( $context, $options['context'], true ) ) {
+ unset( $properties[ $name ] );
+ }
+ }
+ }
+
+ $fields = array_keys( $properties );
+
+ /*
+ * '_links' and '_embedded' are not typically part of the item schema,
+ * but they can be specified in '_fields', so they are added here as a
+ * convenience for checking with rest_is_field_included().
+ */
+ $fields[] = '_links';
+ if ( $request->has_param( '_embed' ) ) {
+ $fields[] = '_embedded';
+ }
+
+ $fields = array_unique( $fields );
+
+ if ( ! isset( $request['_fields'] ) ) {
+ return $fields;
+ }
+ $requested_fields = wp_parse_list( $request['_fields'] );
+ if ( 0 === count( $requested_fields ) ) {
+ return $fields;
+ }
+ // Trim off outside whitespace from the comma delimited list.
+ $requested_fields = array_map( 'trim', $requested_fields );
+ // Always persist 'id', because it can be needed for add_additional_fields_to_object().
+ if ( in_array( 'id', $fields, true ) ) {
+ $requested_fields[] = 'id';
+ }
+ // Return the list of all requested fields which appear in the schema.
+ return array_reduce(
+ $requested_fields,
+ static 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()
+ );
+ }
+
+ /**
+ * Retrieves an array of endpoint arguments from the item schema for the controller.
+ *
+ * @since 4.7.0
+ *
+ * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
+ * checked for required values and may fall-back to a given default, this is not done
+ * on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
+ * @return array Endpoint arguments.
+ */
+ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
+ return rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method );
+ }
+
+ /**
+ * Sanitizes the slug value.
+ *
+ * @since 4.7.0
+ *
+ * @internal We can't use sanitize_title() directly, as the second
+ * parameter is the fallback title, which would end up being set to the
+ * request object.
+ *
+ * @see https://github.com/WP-API/WP-API/issues/1585
+ *
+ * @todo Remove this in favour of https://core.trac.wordpress.org/ticket/34659
+ *
+ * @param string $slug Slug value passed in request.
+ * @return string Sanitized value for the slug.
+ */
+ public function sanitize_slug( $slug ) {
+ return sanitize_title( $slug );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-edit-site-export-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-edit-site-export-controller.php
new file mode 100644
index 0000000..6917608
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-edit-site-export-controller.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * REST API: WP_REST_Edit_Site_Export_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ */
+
+/**
+ * Controller which provides REST endpoint for exporting current templates
+ * and template parts.
+ *
+ * @since 5.9.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Edit_Site_Export_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructor.
+ *
+ * @since 5.9.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp-block-editor/v1';
+ $this->rest_base = 'export';
+ }
+
+ /**
+ * Registers the site export route.
+ *
+ * @since 5.9.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'export' ),
+ 'permission_callback' => array( $this, 'permissions_check' ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to export.
+ *
+ * @since 5.9.0
+ *
+ * @return WP_Error|true True if the request has access, or WP_Error object.
+ */
+ public function permissions_check() {
+ if ( current_user_can( 'edit_theme_options' ) ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'rest_cannot_export_templates',
+ __( 'Sorry, you are not allowed to export templates and template parts.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Output a ZIP file with an export of the current templates
+ * and template parts from the site editor, and close the connection.
+ *
+ * @since 5.9.0
+ *
+ * @return WP_Error|void
+ */
+ public function export() {
+ // Generate the export file.
+ $filename = wp_generate_block_templates_export_file();
+
+ if ( is_wp_error( $filename ) ) {
+ $filename->add_data( array( 'status' => 500 ) );
+
+ return $filename;
+ }
+
+ $theme_name = basename( get_stylesheet() );
+ header( 'Content-Type: application/zip' );
+ header( 'Content-Disposition: attachment; filename=' . $theme_name . '.zip' );
+ header( 'Content-Length: ' . filesize( $filename ) );
+ flush();
+ readfile( $filename );
+ unlink( $filename );
+ exit;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php
new file mode 100644
index 0000000..9837f53
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php
@@ -0,0 +1,708 @@
+<?php
+/**
+ * REST API: WP_REST_Global_Styles_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.9.0
+ */
+
+/**
+ * Base Global Styles REST API Controller.
+ */
+class WP_REST_Global_Styles_Controller extends WP_REST_Controller {
+
+ /**
+ * Post type.
+ *
+ * @since 5.9.0
+ * @var string
+ */
+ protected $post_type;
+
+ /**
+ * Constructor.
+ * @since 5.9.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'global-styles';
+ $this->post_type = 'wp_global_styles';
+ }
+
+ /**
+ * Registers the controllers routes.
+ *
+ * @since 5.9.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/themes/(?P<stylesheet>[\/\s%\w\.\(\)\[\]\@_\-]+)/variations',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_theme_items' ),
+ 'permission_callback' => array( $this, 'get_theme_items_permissions_check' ),
+ 'args' => array(
+ 'stylesheet' => array(
+ 'description' => __( 'The theme identifier' ),
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ )
+ );
+
+ // List themes global styles.
+ register_rest_route(
+ $this->namespace,
+ // The route.
+ sprintf(
+ '/%s/themes/(?P<stylesheet>%s)',
+ $this->rest_base,
+ /*
+ * Matches theme's directory: `/themes/<subdirectory>/<theme>/` or `/themes/<theme>/`.
+ * Excludes invalid directory name characters: `/:<>*?"|`.
+ */
+ '[^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?'
+ ),
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_theme_item' ),
+ 'permission_callback' => array( $this, 'get_theme_item_permissions_check' ),
+ 'args' => array(
+ 'stylesheet' => array(
+ 'description' => __( 'The theme identifier' ),
+ 'type' => 'string',
+ 'sanitize_callback' => array( $this, '_sanitize_global_styles_callback' ),
+ ),
+ ),
+ ),
+ )
+ );
+
+ // Lists/updates a single global style variation based on the given id.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[\/\w-]+)',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The id of a template' ),
+ 'type' => 'string',
+ 'sanitize_callback' => array( $this, '_sanitize_global_styles_callback' ),
+ ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'permission_callback' => array( $this, 'update_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Sanitize the global styles ID or stylesheet to decode endpoint.
+ * For example, `wp/v2/global-styles/twentytwentytwo%200.4.0`
+ * would be decoded to `twentytwentytwo 0.4.0`.
+ *
+ * @since 5.9.0
+ *
+ * @param string $id_or_stylesheet Global styles ID or stylesheet.
+ * @return string Sanitized global styles ID or stylesheet.
+ */
+ public function _sanitize_global_styles_callback( $id_or_stylesheet ) {
+ return urldecode( $id_or_stylesheet );
+ }
+
+ /**
+ * Get the post, if the ID is valid.
+ *
+ * @since 5.9.0
+ *
+ * @param int $id Supplied ID.
+ * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_post( $id ) {
+ $error = new WP_Error(
+ 'rest_global_styles_not_found',
+ __( 'No global styles config exist with that id.' ),
+ array( 'status' => 404 )
+ );
+
+ $id = (int) $id;
+ if ( $id <= 0 ) {
+ return $error;
+ }
+
+ $post = get_post( $id );
+ if ( empty( $post ) || empty( $post->ID ) || $this->post_type !== $post->post_type ) {
+ return $error;
+ }
+
+ return $post;
+ }
+
+ /**
+ * Checks if a given request has access to read a single global style.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $post = $this->get_post( $request['id'] );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ if ( 'edit' === $request['context'] && $post && ! $this->check_update_permission( $post ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to edit this global style.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! $this->check_read_permission( $post ) ) {
+ return new WP_Error(
+ 'rest_cannot_view',
+ __( 'Sorry, you are not allowed to view this global style.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if a global style can be read.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_Post $post Post object.
+ * @return bool Whether the post can be read.
+ */
+ protected function check_read_permission( $post ) {
+ return current_user_can( 'read_post', $post->ID );
+ }
+
+ /**
+ * Returns the given global styles config.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request The request instance.
+ *
+ * @return WP_REST_Response|WP_Error
+ */
+ public function get_item( $request ) {
+ $post = $this->get_post( $request['id'] );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ return $this->prepare_item_for_response( $post, $request );
+ }
+
+ /**
+ * Checks if a given request has access to write a single global styles config.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has write access for the item, WP_Error object otherwise.
+ */
+ public function update_item_permissions_check( $request ) {
+ $post = $this->get_post( $request['id'] );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ if ( $post && ! $this->check_update_permission( $post ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit',
+ __( 'Sorry, you are not allowed to edit this global style.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if a global style can be edited.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_Post $post Post object.
+ * @return bool Whether the post can be edited.
+ */
+ protected function check_update_permission( $post ) {
+ return current_user_can( 'edit_post', $post->ID );
+ }
+
+ /**
+ * Updates a single global style config.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ $post_before = $this->get_post( $request['id'] );
+ if ( is_wp_error( $post_before ) ) {
+ return $post_before;
+ }
+
+ $changes = $this->prepare_item_for_database( $request );
+ if ( is_wp_error( $changes ) ) {
+ return $changes;
+ }
+
+ $result = wp_update_post( wp_slash( (array) $changes ), true, false );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ $post = get_post( $request['id'] );
+ $fields_update = $this->update_additional_fields_for_object( $post, $request );
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ wp_after_insert_post( $post, true, $post_before );
+
+ $response = $this->prepare_item_for_response( $post, $request );
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Prepares a single global styles config for update.
+ *
+ * @since 5.9.0
+ * @since 6.2.0 Added validation of styles.css property.
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return stdClass|WP_Error Prepared item on success. WP_Error on when the custom CSS is not valid.
+ */
+ protected function prepare_item_for_database( $request ) {
+ $changes = new stdClass();
+ $changes->ID = $request['id'];
+
+ $post = get_post( $request['id'] );
+ $existing_config = array();
+ if ( $post ) {
+ $existing_config = json_decode( $post->post_content, true );
+ $json_decoding_error = json_last_error();
+ if ( JSON_ERROR_NONE !== $json_decoding_error || ! isset( $existing_config['isGlobalStylesUserThemeJSON'] ) ||
+ ! $existing_config['isGlobalStylesUserThemeJSON'] ) {
+ $existing_config = array();
+ }
+ }
+
+ if ( isset( $request['styles'] ) || isset( $request['settings'] ) ) {
+ $config = array();
+ if ( isset( $request['styles'] ) ) {
+ if ( isset( $request['styles']['css'] ) ) {
+ $css_validation_result = $this->validate_custom_css( $request['styles']['css'] );
+ if ( is_wp_error( $css_validation_result ) ) {
+ return $css_validation_result;
+ }
+ }
+ $config['styles'] = $request['styles'];
+ } elseif ( isset( $existing_config['styles'] ) ) {
+ $config['styles'] = $existing_config['styles'];
+ }
+ if ( isset( $request['settings'] ) ) {
+ $config['settings'] = $request['settings'];
+ } elseif ( isset( $existing_config['settings'] ) ) {
+ $config['settings'] = $existing_config['settings'];
+ }
+ $config['isGlobalStylesUserThemeJSON'] = true;
+ $config['version'] = WP_Theme_JSON::LATEST_SCHEMA;
+ $changes->post_content = wp_json_encode( $config );
+ }
+
+ // Post title.
+ if ( isset( $request['title'] ) ) {
+ if ( is_string( $request['title'] ) ) {
+ $changes->post_title = $request['title'];
+ } elseif ( ! empty( $request['title']['raw'] ) ) {
+ $changes->post_title = $request['title']['raw'];
+ }
+ }
+
+ return $changes;
+ }
+
+ /**
+ * Prepare a global styles config output for response.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_Post $post Global Styles post object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $post, $request ) {
+ $raw_config = json_decode( $post->post_content, true );
+ $is_global_styles_user_theme_json = isset( $raw_config['isGlobalStylesUserThemeJSON'] ) && true === $raw_config['isGlobalStylesUserThemeJSON'];
+ $config = array();
+ if ( $is_global_styles_user_theme_json ) {
+ $config = ( new WP_Theme_JSON( $raw_config, 'custom' ) )->get_raw_data();
+ }
+
+ // Base fields for every post.
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( rest_is_field_included( 'id', $fields ) ) {
+ $data['id'] = $post->ID;
+ }
+
+ 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']['rendered'] = get_the_title( $post->ID );
+
+ remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
+ }
+
+ if ( rest_is_field_included( 'settings', $fields ) ) {
+ $data['settings'] = ! empty( $config['settings'] ) && $is_global_styles_user_theme_json ? $config['settings'] : new stdClass();
+ }
+
+ if ( rest_is_field_included( 'styles', $fields ) ) {
+ $data['styles'] = ! empty( $config['styles'] ) && $is_global_styles_user_theme_json ? $config['styles'] : new stdClass();
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ // Wrap the data in a response object.
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $links = $this->prepare_links( $post->ID );
+ $response->add_links( $links );
+ if ( ! empty( $links['self']['href'] ) ) {
+ $actions = $this->get_available_actions();
+ $self = $links['self']['href'];
+ foreach ( $actions as $rel ) {
+ $response->add_link( $rel, $self );
+ }
+ }
+ }
+
+ return $response;
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 5.9.0
+ * @since 6.3.0 Adds revisions count and rest URL href to version-history.
+ *
+ * @param integer $id ID.
+ * @return array Links for the given post.
+ */
+ protected function prepare_links( $id ) {
+ $base = sprintf( '%s/%s', $this->namespace, $this->rest_base );
+
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( trailingslashit( $base ) . $id ),
+ ),
+ );
+
+ if ( post_type_supports( $this->post_type, 'revisions' ) ) {
+ $revisions = wp_get_latest_revision_id_and_total_count( $id );
+ $revisions_count = ! is_wp_error( $revisions ) ? $revisions['count'] : 0;
+ $revisions_base = sprintf( '/%s/%d/revisions', $base, $id );
+ $links['version-history'] = array(
+ 'href' => rest_url( $revisions_base ),
+ 'count' => $revisions_count,
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * Get the link relations available for the post and current user.
+ *
+ * @since 5.9.0
+ * @since 6.2.0 Added 'edit-css' action.
+ *
+ * @return array List of link relations.
+ */
+ protected function get_available_actions() {
+ $rels = array();
+
+ $post_type = get_post_type_object( $this->post_type );
+ if ( current_user_can( $post_type->cap->publish_posts ) ) {
+ $rels[] = 'https://api.w.org/action-publish';
+ }
+
+ if ( current_user_can( 'edit_css' ) ) {
+ $rels[] = 'https://api.w.org/action-edit-css';
+ }
+
+ return $rels;
+ }
+
+ /**
+ * Overwrites the default protected title format.
+ *
+ * By default, WordPress will show password protected posts with a title of
+ * "Protected: %s", as the REST API communicates the protected status of a post
+ * in a machine readable format, we remove the "Protected: " prefix.
+ *
+ * @since 5.9.0
+ *
+ * @return string Protected title format.
+ */
+ public function protected_title_format() {
+ return '%s';
+ }
+
+ /**
+ * Retrieves the query params for the global styles collection.
+ *
+ * @since 5.9.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ return array();
+ }
+
+ /**
+ * Retrieves the global styles type' schema, conforming to JSON Schema.
+ *
+ * @since 5.9.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => $this->post_type,
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'ID of global styles config.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'styles' => array(
+ 'description' => __( 'Global styles.' ),
+ 'type' => array( 'object' ),
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'settings' => array(
+ 'description' => __( 'Global settings.' ),
+ 'type' => array( 'object' ),
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'title' => array(
+ 'description' => __( 'Title of the global styles variation.' ),
+ 'type' => array( 'object', 'string' ),
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'Title for the global styles variation, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'rendered' => array(
+ 'description' => __( 'HTML title for the post, transformed for display.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Checks if a given request has access to read a single theme global styles config.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_theme_item_permissions_check( $request ) {
+ /*
+ * Verify if the current user has edit_theme_options capability.
+ * This capability is required to edit/view/delete templates.
+ */
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'rest_cannot_manage_global_styles',
+ __( 'Sorry, you are not allowed to access the global styles on this site.' ),
+ array(
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the given theme global styles config.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request The request instance.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function get_theme_item( $request ) {
+ if ( get_stylesheet() !== $request['stylesheet'] ) {
+ // This endpoint only supports the active theme for now.
+ return new WP_Error(
+ 'rest_theme_not_found',
+ __( 'Theme not found.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $theme = WP_Theme_JSON_Resolver::get_merged_data( 'theme' );
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( rest_is_field_included( 'settings', $fields ) ) {
+ $data['settings'] = $theme->get_settings();
+ }
+
+ if ( rest_is_field_included( 'styles', $fields ) ) {
+ $raw_data = $theme->get_raw_data();
+ $data['styles'] = isset( $raw_data['styles'] ) ? $raw_data['styles'] : array();
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( sprintf( '%s/%s/themes/%s', $this->namespace, $this->rest_base, $request['stylesheet'] ) ),
+ ),
+ );
+ $response->add_links( $links );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to read a single theme global styles config.
+ *
+ * @since 6.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_theme_items_permissions_check( $request ) {
+ /*
+ * Verify if the current user has edit_theme_options capability.
+ * This capability is required to edit/view/delete templates.
+ */
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'rest_cannot_manage_global_styles',
+ __( 'Sorry, you are not allowed to access the global styles on this site.' ),
+ array(
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the given theme global styles variations.
+ *
+ * @since 6.0.0
+ * @since 6.2.0 Returns parent theme variations, if they exist.
+ *
+ * @param WP_REST_Request $request The request instance.
+ *
+ * @return WP_REST_Response|WP_Error
+ */
+ public function get_theme_items( $request ) {
+ if ( get_stylesheet() !== $request['stylesheet'] ) {
+ // This endpoint only supports the active theme for now.
+ return new WP_Error(
+ 'rest_theme_not_found',
+ __( 'Theme not found.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $variations = WP_Theme_JSON_Resolver::get_style_variations();
+
+ return rest_ensure_response( $variations );
+ }
+
+ /**
+ * Validate style.css as valid CSS.
+ *
+ * Currently just checks for invalid markup.
+ *
+ * @since 6.2.0
+ * @since 6.4.0 Changed method visibility to protected.
+ *
+ * @param string $css CSS to validate.
+ * @return true|WP_Error True if the input was validated, otherwise WP_Error.
+ */
+ protected function validate_custom_css( $css ) {
+ if ( preg_match( '#</?\w+#', $css ) ) {
+ return new WP_Error(
+ 'rest_custom_css_illegal_markup',
+ __( 'Markup is not allowed in CSS.' ),
+ array( 'status' => 400 )
+ );
+ }
+ return true;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-revisions-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-revisions-controller.php
new file mode 100644
index 0000000..9ce7e2b
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-revisions-controller.php
@@ -0,0 +1,474 @@
+<?php
+/**
+ * REST API: WP_REST_Global_Styles_Revisions_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 6.3.0
+ */
+
+/**
+ * Core class used to access global styles revisions via the REST API.
+ *
+ * @since 6.3.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Global_Styles_Revisions_Controller extends WP_REST_Controller {
+ /**
+ * Parent post type.
+ *
+ * @since 6.3.0
+ * @var string
+ */
+ protected $parent_post_type;
+
+ /**
+ * The base of the parent controller's route.
+ *
+ * @since 6.3.0
+ * @var string
+ */
+ protected $parent_base;
+
+ /**
+ * Constructor.
+ *
+ * @since 6.3.0
+ */
+ public function __construct() {
+ $this->parent_post_type = 'wp_global_styles';
+ $this->rest_base = 'revisions';
+ $this->parent_base = 'global-styles';
+ $this->namespace = 'wp/v2';
+ }
+
+ /**
+ * Registers the controller's routes.
+ *
+ * @since 6.3.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->parent_base . '/(?P<parent>[\d]+)/' . $this->rest_base,
+ array(
+ 'args' => array(
+ 'parent' => array(
+ 'description' => __( 'The ID for the parent of the revision.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * Inherits from WP_REST_Controller::get_collection_params(),
+ * also reflects changes to return value WP_REST_Revisions_Controller::get_collection_params().
+ *
+ * @since 6.3.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $collection_params = parent::get_collection_params();
+ $collection_params['context']['default'] = 'view';
+ $collection_params['offset'] = array(
+ 'description' => __( 'Offset the result set by a specific number of items.' ),
+ 'type' => 'integer',
+ );
+ unset( $collection_params['search'] );
+ unset( $collection_params['per_page']['default'] );
+
+ return $collection_params;
+ }
+
+ /**
+ * Returns decoded JSON from post content string,
+ * or a 404 if not found.
+ *
+ * @since 6.3.0
+ *
+ * @param string $raw_json Encoded JSON from global styles custom post content.
+ * @return Array|WP_Error
+ */
+ protected function get_decoded_global_styles_json( $raw_json ) {
+ $decoded_json = json_decode( $raw_json, true );
+
+ if ( is_array( $decoded_json ) && isset( $decoded_json['isGlobalStylesUserThemeJSON'] ) && true === $decoded_json['isGlobalStylesUserThemeJSON'] ) {
+ return $decoded_json;
+ }
+
+ return new WP_Error(
+ 'rest_global_styles_not_found',
+ __( 'Cannot find user global styles revisions.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ /**
+ * Returns paginated revisions of the given global styles config custom post type.
+ *
+ * The bulk of the body is taken from WP_REST_Revisions_Controller->get_items,
+ * but global styles does not require as many parameters.
+ *
+ * @since 6.3.0
+ *
+ * @param WP_REST_Request $request The request instance.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function get_items( $request ) {
+ $parent = $this->get_parent( $request['parent'] );
+
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ $global_styles_config = $this->get_decoded_global_styles_json( $parent->post_content );
+
+ if ( is_wp_error( $global_styles_config ) ) {
+ return $global_styles_config;
+ }
+
+ if ( wp_revisions_enabled( $parent ) ) {
+ $registered = $this->get_collection_params();
+ $query_args = array(
+ 'post_parent' => $parent->ID,
+ 'post_type' => 'revision',
+ 'post_status' => 'inherit',
+ 'posts_per_page' => -1,
+ 'orderby' => 'date ID',
+ 'order' => 'DESC',
+ );
+
+ $parameter_mappings = array(
+ 'offset' => 'offset',
+ 'page' => 'paged',
+ 'per_page' => 'posts_per_page',
+ );
+
+ foreach ( $parameter_mappings as $api_param => $wp_param ) {
+ if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
+ $query_args[ $wp_param ] = $request[ $api_param ];
+ }
+ }
+
+ $revisions_query = new WP_Query();
+ $revisions = $revisions_query->query( $query_args );
+ $offset = isset( $query_args['offset'] ) ? (int) $query_args['offset'] : 0;
+ $page = (int) $query_args['paged'];
+ $total_revisions = $revisions_query->found_posts;
+
+ if ( $total_revisions < 1 ) {
+ // Out-of-bounds, run the query again without LIMIT for total count.
+ unset( $query_args['paged'], $query_args['offset'] );
+ $count_query = new WP_Query();
+ $count_query->query( $query_args );
+
+ $total_revisions = $count_query->found_posts;
+ }
+
+ if ( $revisions_query->query_vars['posts_per_page'] > 0 ) {
+ $max_pages = ceil( $total_revisions / (int) $revisions_query->query_vars['posts_per_page'] );
+ } else {
+ $max_pages = $total_revisions > 0 ? 1 : 0;
+ }
+ if ( $total_revisions > 0 ) {
+ if ( $offset >= $total_revisions ) {
+ return new WP_Error(
+ 'rest_revision_invalid_offset_number',
+ __( 'The offset number requested is larger than or equal to the number of available revisions.' ),
+ array( 'status' => 400 )
+ );
+ } elseif ( ! $offset && $page > $max_pages ) {
+ return new WP_Error(
+ 'rest_revision_invalid_page_number',
+ __( 'The page number requested is larger than the number of pages available.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+ } else {
+ $revisions = array();
+ $total_revisions = 0;
+ $max_pages = 0;
+ $page = (int) $request['page'];
+ }
+
+ $response = array();
+
+ foreach ( $revisions as $revision ) {
+ $data = $this->prepare_item_for_response( $revision, $request );
+ $response[] = $this->prepare_response_for_collection( $data );
+ }
+
+ $response = rest_ensure_response( $response );
+
+ $response->header( 'X-WP-Total', (int) $total_revisions );
+ $response->header( 'X-WP-TotalPages', (int) $max_pages );
+
+ $request_params = $request->get_query_params();
+ $base_path = rest_url( sprintf( '%s/%s/%d/%s', $this->namespace, $this->parent_base, $request['parent'], $this->rest_base ) );
+ $base = add_query_arg( urlencode_deep( $request_params ), $base_path );
+
+ if ( $page > 1 ) {
+ $prev_page = $page - 1;
+
+ if ( $prev_page > $max_pages ) {
+ $prev_page = $max_pages;
+ }
+
+ $prev_link = add_query_arg( 'page', $prev_page, $base );
+ $response->link_header( 'prev', $prev_link );
+ }
+ if ( $max_pages > $page ) {
+ $next_page = $page + 1;
+ $next_link = add_query_arg( 'page', $next_page, $base );
+
+ $response->link_header( 'next', $next_link );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Checks the post_date_gmt or modified_gmt and prepare any post or
+ * modified date for single post output.
+ *
+ * Duplicate of WP_REST_Revisions_Controller::prepare_date_response.
+ *
+ * @since 6.3.0
+ *
+ * @param string $date_gmt GMT publication time.
+ * @param string|null $date Optional. Local publication time. Default null.
+ * @return string|null ISO8601/RFC3339 formatted datetime, otherwise null.
+ */
+ protected function prepare_date_response( $date_gmt, $date = null ) {
+ if ( '0000-00-00 00:00:00' === $date_gmt ) {
+ return null;
+ }
+
+ if ( isset( $date ) ) {
+ return mysql_to_rfc3339( $date );
+ }
+
+ return mysql_to_rfc3339( $date_gmt );
+ }
+
+ /**
+ * Prepares the revision for the REST response.
+ *
+ * @since 6.3.0
+ *
+ * @param WP_Post $post Post revision object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response|WP_Error Response object.
+ */
+ public function prepare_item_for_response( $post, $request ) {
+ $parent = $this->get_parent( $request['parent'] );
+ $global_styles_config = $this->get_decoded_global_styles_json( $post->post_content );
+
+ if ( is_wp_error( $global_styles_config ) ) {
+ return $global_styles_config;
+ }
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( ! empty( $global_styles_config['styles'] ) || ! empty( $global_styles_config['settings'] ) ) {
+ $global_styles_config = ( new WP_Theme_JSON( $global_styles_config, 'custom' ) )->get_raw_data();
+ if ( rest_is_field_included( 'settings', $fields ) ) {
+ $data['settings'] = ! empty( $global_styles_config['settings'] ) ? $global_styles_config['settings'] : new stdClass();
+ }
+ if ( rest_is_field_included( 'styles', $fields ) ) {
+ $data['styles'] = ! empty( $global_styles_config['styles'] ) ? $global_styles_config['styles'] : new stdClass();
+ }
+ }
+
+ if ( rest_is_field_included( 'author', $fields ) ) {
+ $data['author'] = (int) $post->post_author;
+ }
+
+ if ( rest_is_field_included( 'date', $fields ) ) {
+ $data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date );
+ }
+
+ if ( rest_is_field_included( 'date_gmt', $fields ) ) {
+ $data['date_gmt'] = $this->prepare_date_response( $post->post_date_gmt );
+ }
+
+ if ( rest_is_field_included( 'id', $fields ) ) {
+ $data['id'] = (int) $post->ID;
+ }
+
+ if ( rest_is_field_included( 'modified', $fields ) ) {
+ $data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified );
+ }
+
+ if ( rest_is_field_included( 'modified_gmt', $fields ) ) {
+ $data['modified_gmt'] = $this->prepare_date_response( $post->post_modified_gmt );
+ }
+
+ if ( rest_is_field_included( 'parent', $fields ) ) {
+ $data['parent'] = (int) $parent->ID;
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Retrieves the revision's schema, conforming to JSON Schema.
+ *
+ * @since 6.3.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => "{$this->parent_post_type}-revision",
+ 'type' => 'object',
+ // Base properties for every revision.
+ 'properties' => array(
+
+ /*
+ * Adds settings and styles from the WP_REST_Revisions_Controller item fields.
+ * Leaves out GUID as global styles shouldn't be accessible via URL.
+ */
+ 'author' => array(
+ 'description' => __( 'The ID for the author of the revision.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'date' => array(
+ 'description' => __( "The date the revision was published, in the site's timezone." ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'date_gmt' => array(
+ 'description' => __( 'The date the revision was published, as GMT.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the revision.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'modified' => array(
+ 'description' => __( "The date the revision was last modified, in the site's timezone." ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'modified_gmt' => array(
+ 'description' => __( 'The date the revision was last modified, as GMT.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'parent' => array(
+ 'description' => __( 'The ID for the parent of the revision.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+
+ // Adds settings and styles from the WP_REST_Global_Styles_Controller parent schema.
+ 'styles' => array(
+ 'description' => __( 'Global styles.' ),
+ 'type' => array( 'object' ),
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'settings' => array(
+ 'description' => __( 'Global settings.' ),
+ 'type' => array( 'object' ),
+ 'context' => array( 'view', 'edit' ),
+ ),
+ ),
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Checks if a given request has access to read a single global style.
+ *
+ * @since 6.3.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $post = $this->get_parent( $request['parent'] );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ /*
+ * The same check as WP_REST_Global_Styles_Controller::get_item_permissions_check.
+ */
+ if ( ! current_user_can( 'read_post', $post->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_view',
+ __( 'Sorry, you are not allowed to view revisions for this global style.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Gets the parent post, if the ID is valid.
+ *
+ * Duplicate of WP_REST_Revisions_Controller::get_parent.
+ *
+ * @since 6.3.0
+ *
+ * @param int $parent_post_id Supplied ID.
+ * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_parent( $parent_post_id ) {
+ $error = new WP_Error(
+ 'rest_post_invalid_parent',
+ __( 'Invalid post parent ID.' ),
+ array( 'status' => 404 )
+ );
+
+ if ( (int) $parent_post_id <= 0 ) {
+ return $error;
+ }
+
+ $parent_post = get_post( (int) $parent_post_id );
+
+ if ( empty( $parent_post ) || empty( $parent_post->ID )
+ || $this->parent_post_type !== $parent_post->post_type
+ ) {
+ return $error;
+ }
+
+ return $parent_post;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-menu-items-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-menu-items-controller.php
new file mode 100644
index 0000000..4119b65
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-menu-items-controller.php
@@ -0,0 +1,1022 @@
+<?php
+/**
+ * REST API: WP_REST_Menu_Items_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.9.0
+ */
+
+/**
+ * Core class to access nav items via the REST API.
+ *
+ * @since 5.9.0
+ *
+ * @see WP_REST_Posts_Controller
+ */
+class WP_REST_Menu_Items_Controller extends WP_REST_Posts_Controller {
+
+ /**
+ * Gets the nav menu item, if the ID is valid.
+ *
+ * @since 5.9.0
+ *
+ * @param int $id Supplied ID.
+ * @return object|WP_Error Post object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_nav_menu_item( $id ) {
+ $post = $this->get_post( $id );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ return wp_setup_nav_menu_item( $post );
+ }
+
+ /**
+ * Checks if a given request has access to read menu items.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ $has_permission = parent::get_items_permissions_check( $request );
+
+ if ( true !== $has_permission ) {
+ return $has_permission;
+ }
+
+ return $this->check_has_read_only_access( $request );
+ }
+
+ /**
+ * Checks if a given request has access to read a menu item if they have access to edit them.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return bool|WP_Error True if the request has read access for the item, WP_Error object or false otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $permission_check = parent::get_item_permissions_check( $request );
+
+ if ( true !== $permission_check ) {
+ return $permission_check;
+ }
+
+ return $this->check_has_read_only_access( $request );
+ }
+
+ /**
+ * Checks whether the current user has read permission for the endpoint.
+ *
+ * This allows for any user that can `edit_theme_options` or edit any REST API available post type.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ protected function check_has_read_only_access( $request ) {
+ if ( current_user_can( 'edit_theme_options' ) ) {
+ return true;
+ }
+
+ if ( current_user_can( 'edit_posts' ) ) {
+ return true;
+ }
+
+ foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
+ if ( current_user_can( $post_type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view',
+ __( 'Sorry, you are not allowed to view menu items.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Creates a single post.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+ if ( ! empty( $request['id'] ) ) {
+ return new WP_Error( 'rest_post_exists', __( 'Cannot create existing post.' ), array( 'status' => 400 ) );
+ }
+
+ $prepared_nav_item = $this->prepare_item_for_database( $request );
+
+ if ( is_wp_error( $prepared_nav_item ) ) {
+ return $prepared_nav_item;
+ }
+ $prepared_nav_item = (array) $prepared_nav_item;
+
+ $nav_menu_item_id = wp_update_nav_menu_item( $prepared_nav_item['menu-id'], $prepared_nav_item['menu-item-db-id'], wp_slash( $prepared_nav_item ), false );
+ if ( is_wp_error( $nav_menu_item_id ) ) {
+ if ( 'db_insert_error' === $nav_menu_item_id->get_error_code() ) {
+ $nav_menu_item_id->add_data( array( 'status' => 500 ) );
+ } else {
+ $nav_menu_item_id->add_data( array( 'status' => 400 ) );
+ }
+
+ return $nav_menu_item_id;
+ }
+
+ $nav_menu_item = $this->get_nav_menu_item( $nav_menu_item_id );
+ if ( is_wp_error( $nav_menu_item ) ) {
+ $nav_menu_item->add_data( array( 'status' => 404 ) );
+
+ return $nav_menu_item;
+ }
+
+ /**
+ * Fires after a single menu item is created or updated via the REST API.
+ *
+ * @since 5.9.0
+ *
+ * @param object $nav_menu_item Inserted or updated menu item object.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating a menu item, false when updating.
+ */
+ do_action( 'rest_insert_nav_menu_item', $nav_menu_item, $request, true );
+
+ $schema = $this->get_item_schema();
+
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $nav_menu_item_id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $nav_menu_item = $this->get_nav_menu_item( $nav_menu_item_id );
+ $fields_update = $this->update_additional_fields_for_object( $nav_menu_item, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ /**
+ * Fires after a single menu item is completely created or updated via the REST API.
+ *
+ * @since 5.9.0
+ *
+ * @param object $nav_menu_item Inserted or updated menu item object.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating a menu item, false when updating.
+ */
+ do_action( 'rest_after_insert_nav_menu_item', $nav_menu_item, $request, true );
+
+ $post = get_post( $nav_menu_item_id );
+ wp_after_insert_post( $post, false, null );
+
+ $response = $this->prepare_item_for_response( $post, $request );
+ $response = rest_ensure_response( $response );
+
+ $response->set_status( 201 );
+ $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $nav_menu_item_id ) ) );
+
+ return $response;
+ }
+
+ /**
+ * Updates a single nav menu item.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ $valid_check = $this->get_nav_menu_item( $request['id'] );
+ if ( is_wp_error( $valid_check ) ) {
+ return $valid_check;
+ }
+ $post_before = get_post( $request['id'] );
+ $prepared_nav_item = $this->prepare_item_for_database( $request );
+
+ if ( is_wp_error( $prepared_nav_item ) ) {
+ return $prepared_nav_item;
+ }
+
+ $prepared_nav_item = (array) $prepared_nav_item;
+
+ $nav_menu_item_id = wp_update_nav_menu_item( $prepared_nav_item['menu-id'], $prepared_nav_item['menu-item-db-id'], wp_slash( $prepared_nav_item ), false );
+
+ if ( is_wp_error( $nav_menu_item_id ) ) {
+ if ( 'db_update_error' === $nav_menu_item_id->get_error_code() ) {
+ $nav_menu_item_id->add_data( array( 'status' => 500 ) );
+ } else {
+ $nav_menu_item_id->add_data( array( 'status' => 400 ) );
+ }
+
+ return $nav_menu_item_id;
+ }
+
+ $nav_menu_item = $this->get_nav_menu_item( $nav_menu_item_id );
+ if ( is_wp_error( $nav_menu_item ) ) {
+ $nav_menu_item->add_data( array( 'status' => 404 ) );
+
+ return $nav_menu_item;
+ }
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-menu-items-controller.php */
+ do_action( 'rest_insert_nav_menu_item', $nav_menu_item, $request, false );
+
+ $schema = $this->get_item_schema();
+
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $nav_menu_item->ID );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $post = get_post( $nav_menu_item_id );
+ $nav_menu_item = $this->get_nav_menu_item( $nav_menu_item_id );
+ $fields_update = $this->update_additional_fields_for_object( $nav_menu_item, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-menu-items-controller.php */
+ do_action( 'rest_after_insert_nav_menu_item', $nav_menu_item, $request, false );
+
+ wp_after_insert_post( $post, true, $post_before );
+
+ $response = $this->prepare_item_for_response( get_post( $nav_menu_item_id ), $request );
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Deletes a single menu item.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error True on success, or WP_Error object on failure.
+ */
+ public function delete_item( $request ) {
+ $menu_item = $this->get_nav_menu_item( $request['id'] );
+ if ( is_wp_error( $menu_item ) ) {
+ return $menu_item;
+ }
+
+ // We don't support trashing for menu items.
+ if ( ! $request['force'] ) {
+ /* translators: %s: force=true */
+ return new WP_Error( 'rest_trash_not_supported', sprintf( __( "Menu items do not support trashing. Set '%s' to delete." ), 'force=true' ), array( 'status' => 501 ) );
+ }
+
+ $previous = $this->prepare_item_for_response( get_post( $request['id'] ), $request );
+
+ $result = wp_delete_post( $request['id'], true );
+
+ if ( ! $result ) {
+ return new WP_Error( 'rest_cannot_delete', __( 'The post cannot be deleted.' ), array( 'status' => 500 ) );
+ }
+
+ $response = new WP_REST_Response();
+ $response->set_data(
+ array(
+ 'deleted' => true,
+ 'previous' => $previous->get_data(),
+ )
+ );
+
+ /**
+ * Fires immediately after a single menu item is deleted via the REST API.
+ *
+ * @since 5.9.0
+ *
+ * @param object $nav_menu_item Inserted or updated menu item object.
+ * @param WP_REST_Response $response The response data.
+ * @param WP_REST_Request $request Request object.
+ */
+ do_action( 'rest_delete_nav_menu_item', $menu_item, $response, $request );
+
+ return $response;
+ }
+
+ /**
+ * Prepares a single post for create or update.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Request object.
+ *
+ * @return object|WP_Error
+ */
+ protected function prepare_item_for_database( $request ) {
+ $menu_item_db_id = $request['id'];
+ $menu_item_obj = $this->get_nav_menu_item( $menu_item_db_id );
+ // Need to persist the menu item data. See https://core.trac.wordpress.org/ticket/28138
+ if ( ! is_wp_error( $menu_item_obj ) ) {
+ // Correct the menu position if this was the first item. See https://core.trac.wordpress.org/ticket/28140
+ $position = ( 0 === $menu_item_obj->menu_order ) ? 1 : $menu_item_obj->menu_order;
+
+ $prepared_nav_item = array(
+ 'menu-item-db-id' => $menu_item_db_id,
+ 'menu-item-object-id' => $menu_item_obj->object_id,
+ 'menu-item-object' => $menu_item_obj->object,
+ 'menu-item-parent-id' => $menu_item_obj->menu_item_parent,
+ 'menu-item-position' => $position,
+ 'menu-item-type' => $menu_item_obj->type,
+ 'menu-item-title' => $menu_item_obj->title,
+ 'menu-item-url' => $menu_item_obj->url,
+ 'menu-item-description' => $menu_item_obj->description,
+ 'menu-item-attr-title' => $menu_item_obj->attr_title,
+ 'menu-item-target' => $menu_item_obj->target,
+ 'menu-item-classes' => $menu_item_obj->classes,
+ // Stored in the database as a string.
+ 'menu-item-xfn' => explode( ' ', $menu_item_obj->xfn ),
+ 'menu-item-status' => $menu_item_obj->post_status,
+ 'menu-id' => $this->get_menu_id( $menu_item_db_id ),
+ );
+ } else {
+ $prepared_nav_item = array(
+ 'menu-id' => 0,
+ 'menu-item-db-id' => 0,
+ 'menu-item-object-id' => 0,
+ 'menu-item-object' => '',
+ 'menu-item-parent-id' => 0,
+ 'menu-item-position' => 1,
+ 'menu-item-type' => 'custom',
+ 'menu-item-title' => '',
+ 'menu-item-url' => '',
+ 'menu-item-description' => '',
+ 'menu-item-attr-title' => '',
+ 'menu-item-target' => '',
+ 'menu-item-classes' => array(),
+ 'menu-item-xfn' => array(),
+ 'menu-item-status' => 'publish',
+ );
+ }
+
+ $mapping = array(
+ 'menu-item-db-id' => 'id',
+ 'menu-item-object-id' => 'object_id',
+ 'menu-item-object' => 'object',
+ 'menu-item-parent-id' => 'parent',
+ 'menu-item-position' => 'menu_order',
+ 'menu-item-type' => 'type',
+ 'menu-item-url' => 'url',
+ 'menu-item-description' => 'description',
+ 'menu-item-attr-title' => 'attr_title',
+ 'menu-item-target' => 'target',
+ 'menu-item-classes' => 'classes',
+ 'menu-item-xfn' => 'xfn',
+ 'menu-item-status' => 'status',
+ );
+
+ $schema = $this->get_item_schema();
+
+ foreach ( $mapping as $original => $api_request ) {
+ if ( isset( $request[ $api_request ] ) ) {
+ $prepared_nav_item[ $original ] = $request[ $api_request ];
+ }
+ }
+
+ $taxonomy = get_taxonomy( 'nav_menu' );
+ $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
+ // If menus submitted, cast to int.
+ if ( ! empty( $request[ $base ] ) ) {
+ $prepared_nav_item['menu-id'] = absint( $request[ $base ] );
+ }
+
+ // Nav menu title.
+ if ( ! empty( $schema['properties']['title'] ) && isset( $request['title'] ) ) {
+ if ( is_string( $request['title'] ) ) {
+ $prepared_nav_item['menu-item-title'] = $request['title'];
+ } elseif ( ! empty( $request['title']['raw'] ) ) {
+ $prepared_nav_item['menu-item-title'] = $request['title']['raw'];
+ }
+ }
+
+ $error = new WP_Error();
+
+ // Check if object id exists before saving.
+ if ( ! $prepared_nav_item['menu-item-object'] ) {
+ // If taxonomy, check if term exists.
+ if ( 'taxonomy' === $prepared_nav_item['menu-item-type'] ) {
+ $original = get_term( absint( $prepared_nav_item['menu-item-object-id'] ) );
+ if ( empty( $original ) || is_wp_error( $original ) ) {
+ $error->add( 'rest_term_invalid_id', __( 'Invalid term ID.' ), array( 'status' => 400 ) );
+ } else {
+ $prepared_nav_item['menu-item-object'] = get_term_field( 'taxonomy', $original );
+ }
+ // If post, check if post object exists.
+ } elseif ( 'post_type' === $prepared_nav_item['menu-item-type'] ) {
+ $original = get_post( absint( $prepared_nav_item['menu-item-object-id'] ) );
+ if ( empty( $original ) ) {
+ $error->add( 'rest_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 400 ) );
+ } else {
+ $prepared_nav_item['menu-item-object'] = get_post_type( $original );
+ }
+ }
+ }
+
+ // If post type archive, check if post type exists.
+ if ( 'post_type_archive' === $prepared_nav_item['menu-item-type'] ) {
+ $post_type = $prepared_nav_item['menu-item-object'] ? $prepared_nav_item['menu-item-object'] : false;
+ $original = get_post_type_object( $post_type );
+ if ( ! $original ) {
+ $error->add( 'rest_post_invalid_type', __( 'Invalid post type.' ), array( 'status' => 400 ) );
+ }
+ }
+
+ // Check if menu item is type custom, then title and url are required.
+ if ( 'custom' === $prepared_nav_item['menu-item-type'] ) {
+ if ( '' === $prepared_nav_item['menu-item-title'] ) {
+ $error->add( 'rest_title_required', __( 'The title is required when using a custom menu item type.' ), array( 'status' => 400 ) );
+ }
+ if ( empty( $prepared_nav_item['menu-item-url'] ) ) {
+ $error->add( 'rest_url_required', __( 'The url is required when using a custom menu item type.' ), array( 'status' => 400 ) );
+ }
+ }
+
+ if ( $error->has_errors() ) {
+ return $error;
+ }
+
+ // The xfn and classes properties are arrays, but passed to wp_update_nav_menu_item as a string.
+ foreach ( array( 'menu-item-xfn', 'menu-item-classes' ) as $key ) {
+ $prepared_nav_item[ $key ] = implode( ' ', $prepared_nav_item[ $key ] );
+ }
+
+ // Only draft / publish are valid post status for menu items.
+ if ( 'publish' !== $prepared_nav_item['menu-item-status'] ) {
+ $prepared_nav_item['menu-item-status'] = 'draft';
+ }
+
+ $prepared_nav_item = (object) $prepared_nav_item;
+
+ /**
+ * Filters a menu item before it is inserted via the REST API.
+ *
+ * @since 5.9.0
+ *
+ * @param object $prepared_nav_item An object representing a single menu item prepared
+ * for inserting or updating the database.
+ * @param WP_REST_Request $request Request object.
+ */
+ return apply_filters( 'rest_pre_insert_nav_menu_item', $prepared_nav_item, $request );
+ }
+
+ /**
+ * Prepares a single post output for response.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_Post $item Post object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Base fields for every post.
+ $fields = $this->get_fields_for_response( $request );
+ $menu_item = $this->get_nav_menu_item( $item->ID );
+ $data = array();
+
+ if ( rest_is_field_included( 'id', $fields ) ) {
+ $data['id'] = $menu_item->ID;
+ }
+
+ if ( rest_is_field_included( 'title', $fields ) ) {
+ $data['title'] = array();
+ }
+
+ if ( rest_is_field_included( 'title.raw', $fields ) ) {
+ $data['title']['raw'] = $menu_item->title;
+ }
+
+ if ( rest_is_field_included( 'title.rendered', $fields ) ) {
+ add_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
+
+ /** This filter is documented in wp-includes/post-template.php */
+ $title = apply_filters( 'the_title', $menu_item->title, $menu_item->ID );
+
+ $data['title']['rendered'] = $title;
+
+ remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
+ }
+
+ if ( rest_is_field_included( 'status', $fields ) ) {
+ $data['status'] = $menu_item->post_status;
+ }
+
+ if ( rest_is_field_included( 'url', $fields ) ) {
+ $data['url'] = $menu_item->url;
+ }
+
+ if ( rest_is_field_included( 'attr_title', $fields ) ) {
+ // Same as post_excerpt.
+ $data['attr_title'] = $menu_item->attr_title;
+ }
+
+ if ( rest_is_field_included( 'description', $fields ) ) {
+ // Same as post_content.
+ $data['description'] = $menu_item->description;
+ }
+
+ if ( rest_is_field_included( 'type', $fields ) ) {
+ $data['type'] = $menu_item->type;
+ }
+
+ if ( rest_is_field_included( 'type_label', $fields ) ) {
+ $data['type_label'] = $menu_item->type_label;
+ }
+
+ if ( rest_is_field_included( 'object', $fields ) ) {
+ $data['object'] = $menu_item->object;
+ }
+
+ if ( rest_is_field_included( 'object_id', $fields ) ) {
+ // It is stored as a string, but should be exposed as an integer.
+ $data['object_id'] = absint( $menu_item->object_id );
+ }
+
+ if ( rest_is_field_included( 'parent', $fields ) ) {
+ // Same as post_parent, exposed as an integer.
+ $data['parent'] = (int) $menu_item->menu_item_parent;
+ }
+
+ if ( rest_is_field_included( 'menu_order', $fields ) ) {
+ // Same as post_parent, exposed as an integer.
+ $data['menu_order'] = (int) $menu_item->menu_order;
+ }
+
+ if ( rest_is_field_included( 'target', $fields ) ) {
+ $data['target'] = $menu_item->target;
+ }
+
+ if ( rest_is_field_included( 'classes', $fields ) ) {
+ $data['classes'] = (array) $menu_item->classes;
+ }
+
+ if ( rest_is_field_included( 'xfn', $fields ) ) {
+ $data['xfn'] = array_map( 'sanitize_html_class', explode( ' ', $menu_item->xfn ) );
+ }
+
+ if ( rest_is_field_included( 'invalid', $fields ) ) {
+ $data['invalid'] = (bool) $menu_item->_invalid;
+ }
+
+ if ( rest_is_field_included( 'meta', $fields ) ) {
+ $data['meta'] = $this->meta->get_value( $menu_item->ID, $request );
+ }
+
+ $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) );
+
+ foreach ( $taxonomies as $taxonomy ) {
+ $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
+
+ if ( rest_is_field_included( $base, $fields ) ) {
+ $terms = get_the_terms( $item, $taxonomy->name );
+ if ( ! is_array( $terms ) ) {
+ continue;
+ }
+ $term_ids = $terms ? array_values( wp_list_pluck( $terms, 'term_id' ) ) : array();
+ if ( 'nav_menu' === $taxonomy->name ) {
+ $data[ $base ] = $term_ids ? array_shift( $term_ids ) : 0;
+ } else {
+ $data[ $base ] = $term_ids;
+ }
+ }
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ // Wrap the data in a response object.
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $links = $this->prepare_links( $item );
+ $response->add_links( $links );
+
+ if ( ! empty( $links['self']['href'] ) ) {
+ $actions = $this->get_available_actions( $item, $request );
+
+ $self = $links['self']['href'];
+
+ foreach ( $actions as $rel ) {
+ $response->add_link( $rel, $self );
+ }
+ }
+ }
+
+ /**
+ * Filters the menu item data for a REST API response.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param object $menu_item Menu item setup by {@see wp_setup_nav_menu_item()}.
+ * @param WP_REST_Request $request Request object.
+ */
+ return apply_filters( 'rest_prepare_nav_menu_item', $response, $menu_item, $request );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_Post $post Post object.
+ * @return array Links for the given post.
+ */
+ protected function prepare_links( $post ) {
+ $links = parent::prepare_links( $post );
+ $menu_item = $this->get_nav_menu_item( $post->ID );
+
+ if ( empty( $menu_item->object_id ) ) {
+ return $links;
+ }
+
+ $path = '';
+ $type = '';
+ $key = $menu_item->type;
+ if ( 'post_type' === $menu_item->type ) {
+ $path = rest_get_route_for_post( $menu_item->object_id );
+ $type = get_post_type( $menu_item->object_id );
+ } elseif ( 'taxonomy' === $menu_item->type ) {
+ $path = rest_get_route_for_term( $menu_item->object_id );
+ $type = get_term_field( 'taxonomy', $menu_item->object_id );
+ }
+
+ if ( $path && $type ) {
+ $links['https://api.w.org/menu-item-object'][] = array(
+ 'href' => rest_url( $path ),
+ $key => $type,
+ 'embeddable' => true,
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * Retrieves Link Description Objects that should be added to the Schema for the posts collection.
+ *
+ * @since 5.9.0
+ *
+ * @return array
+ */
+ protected function get_schema_links() {
+ $links = parent::get_schema_links();
+ $href = rest_url( "{$this->namespace}/{$this->rest_base}/{id}" );
+ $links[] = array(
+ 'rel' => 'https://api.w.org/menu-item-object',
+ 'title' => __( 'Get linked object.' ),
+ 'href' => $href,
+ 'targetSchema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'object' => array(
+ 'type' => 'integer',
+ ),
+ ),
+ ),
+ );
+
+ return $links;
+ }
+
+ /**
+ * Retrieves the term's schema, conforming to JSON Schema.
+ *
+ * @since 5.9.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => $this->post_type,
+ 'type' => 'object',
+ );
+
+ $schema['properties']['title'] = array(
+ 'description' => __( 'The title for the object.' ),
+ 'type' => array( 'string', 'object' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'Title for the object, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ ),
+ 'rendered' => array(
+ 'description' => __( 'HTML title for the object, transformed for display.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ $schema['properties']['id'] = array(
+ 'description' => __( 'Unique identifier for the object.' ),
+ 'type' => 'integer',
+ 'default' => 0,
+ 'minimum' => 0,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ );
+
+ $schema['properties']['type_label'] = array(
+ 'description' => __( 'The singular label used to describe this type of menu item.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ );
+
+ $schema['properties']['type'] = array(
+ 'description' => __( 'The family of objects originally represented, such as "post_type" or "taxonomy".' ),
+ 'type' => 'string',
+ 'enum' => array( 'taxonomy', 'post_type', 'post_type_archive', 'custom' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'default' => 'custom',
+ );
+
+ $schema['properties']['status'] = array(
+ 'description' => __( 'A named status for the object.' ),
+ 'type' => 'string',
+ 'enum' => array_keys( get_post_stati( array( 'internal' => false ) ) ),
+ 'default' => 'publish',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ );
+
+ $schema['properties']['parent'] = array(
+ 'description' => __( 'The ID for the parent of the object.' ),
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'default' => 0,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ );
+
+ $schema['properties']['attr_title'] = array(
+ 'description' => __( 'Text for the title attribute of the link element for this menu item.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ );
+
+ $schema['properties']['classes'] = array(
+ 'description' => __( 'Class names for the link element of this menu item.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => static function ( $value ) {
+ return array_map( 'sanitize_html_class', wp_parse_list( $value ) );
+ },
+ ),
+ );
+
+ $schema['properties']['description'] = array(
+ 'description' => __( 'The description of this menu item.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ );
+
+ $schema['properties']['menu_order'] = array(
+ 'description' => __( 'The DB ID of the nav_menu_item that is this item\'s menu parent, if any, otherwise 0.' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'default' => 1,
+ );
+
+ $schema['properties']['object'] = array(
+ 'description' => __( 'The type of object originally represented, such as "category", "post", or "attachment".' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'type' => 'string',
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_key',
+ ),
+ );
+
+ $schema['properties']['object_id'] = array(
+ 'description' => __( 'The database ID of the original object this menu item represents, for example the ID for posts or the term_id for categories.' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'default' => 0,
+ );
+
+ $schema['properties']['target'] = array(
+ 'description' => __( 'The target attribute of the link element for this menu item.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'enum' => array(
+ '_blank',
+ '',
+ ),
+ );
+
+ $schema['properties']['url'] = array(
+ 'description' => __( 'The URL to which this menu item points.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'validate_callback' => static function ( $url ) {
+ if ( '' === $url ) {
+ return true;
+ }
+
+ if ( sanitize_url( $url ) ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'rest_invalid_url',
+ __( 'Invalid URL.' )
+ );
+ },
+ ),
+ );
+
+ $schema['properties']['xfn'] = array(
+ 'description' => __( 'The XFN relationship expressed in the link of this menu item.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => static function ( $value ) {
+ return array_map( 'sanitize_html_class', wp_parse_list( $value ) );
+ },
+ ),
+ );
+
+ $schema['properties']['invalid'] = array(
+ 'description' => __( 'Whether the menu item represents an object that no longer exists.' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'type' => 'boolean',
+ 'readonly' => true,
+ );
+
+ $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) );
+
+ foreach ( $taxonomies as $taxonomy ) {
+ $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
+ $schema['properties'][ $base ] = array(
+ /* translators: %s: taxonomy name */
+ 'description' => sprintf( __( 'The terms assigned to the object in the %s taxonomy.' ), $taxonomy->name ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'context' => array( 'view', 'edit' ),
+ );
+
+ if ( 'nav_menu' === $taxonomy->name ) {
+ $schema['properties'][ $base ]['type'] = 'integer';
+ unset( $schema['properties'][ $base ]['items'] );
+ }
+ }
+
+ $schema['properties']['meta'] = $this->meta->get_field_schema();
+
+ $schema_links = $this->get_schema_links();
+
+ if ( $schema_links ) {
+ $schema['links'] = $schema_links;
+ }
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for the posts collection.
+ *
+ * @since 5.9.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $query_params = parent::get_collection_params();
+
+ $query_params['menu_order'] = array(
+ 'description' => __( 'Limit result set to posts with a specific menu_order value.' ),
+ 'type' => 'integer',
+ );
+
+ $query_params['order'] = array(
+ 'description' => __( 'Order sort attribute ascending or descending.' ),
+ 'type' => 'string',
+ 'default' => 'asc',
+ 'enum' => array( 'asc', 'desc' ),
+ );
+
+ $query_params['orderby'] = array(
+ 'description' => __( 'Sort collection by object attribute.' ),
+ 'type' => 'string',
+ 'default' => 'menu_order',
+ 'enum' => array(
+ 'author',
+ 'date',
+ 'id',
+ 'include',
+ 'modified',
+ 'parent',
+ 'relevance',
+ 'slug',
+ 'include_slugs',
+ 'title',
+ 'menu_order',
+ ),
+ );
+ // Change default to 100 items.
+ $query_params['per_page']['default'] = 100;
+
+ return $query_params;
+ }
+
+ /**
+ * Determines the allowed query_vars for a get_items() response and prepares
+ * them for WP_Query.
+ *
+ * @since 5.9.0
+ *
+ * @param array $prepared_args Optional. Prepared WP_Query arguments. Default empty array.
+ * @param WP_REST_Request $request Optional. Full details about the request.
+ * @return array Items query arguments.
+ */
+ protected function prepare_items_query( $prepared_args = array(), $request = null ) {
+ $query_args = parent::prepare_items_query( $prepared_args, $request );
+
+ // Map to proper WP_Query orderby param.
+ if ( isset( $query_args['orderby'], $request['orderby'] ) ) {
+ $orderby_mappings = array(
+ 'id' => 'ID',
+ 'include' => 'post__in',
+ 'slug' => 'post_name',
+ 'include_slugs' => 'post_name__in',
+ 'menu_order' => 'menu_order',
+ );
+
+ if ( isset( $orderby_mappings[ $request['orderby'] ] ) ) {
+ $query_args['orderby'] = $orderby_mappings[ $request['orderby'] ];
+ }
+ }
+
+ $query_args['update_menu_item_cache'] = true;
+
+ return $query_args;
+ }
+
+ /**
+ * Gets the id of the menu that the given menu item belongs to.
+ *
+ * @since 5.9.0
+ *
+ * @param int $menu_item_id Menu item id.
+ * @return int
+ */
+ protected function get_menu_id( $menu_item_id ) {
+ $menu_ids = wp_get_post_terms( $menu_item_id, 'nav_menu', array( 'fields' => 'ids' ) );
+ $menu_id = 0;
+ if ( $menu_ids && ! is_wp_error( $menu_ids ) ) {
+ $menu_id = array_shift( $menu_ids );
+ }
+
+ return $menu_id;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-menu-locations-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-menu-locations-controller.php
new file mode 100644
index 0000000..47686b9
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-menu-locations-controller.php
@@ -0,0 +1,304 @@
+<?php
+/**
+ * REST API: WP_REST_Menu_Locations_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.9.0
+ */
+
+/**
+ * Core class used to access menu locations via the REST API.
+ *
+ * @since 5.9.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Menu_Locations_Controller extends WP_REST_Controller {
+
+ /**
+ * Menu Locations Constructor.
+ *
+ * @since 5.9.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'menu-locations';
+ }
+
+ /**
+ * Registers the routes for the objects of the controller.
+ *
+ * @since 5.9.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<location>[\w-]+)',
+ array(
+ 'args' => array(
+ 'location' => array(
+ 'description' => __( 'An alphanumeric identifier for the menu location.' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to read menu locations.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|bool True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'rest_cannot_view',
+ __( 'Sorry, you are not allowed to view menu locations.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves all menu locations, depending on user context.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $data = array();
+
+ foreach ( get_registered_nav_menus() as $name => $description ) {
+ $location = new stdClass();
+ $location->name = $name;
+ $location->description = $description;
+
+ $location = $this->prepare_item_for_response( $location, $request );
+ $data[ $name ] = $this->prepare_response_for_collection( $location );
+ }
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Checks if a given request has access to read a menu location.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'rest_cannot_view',
+ __( 'Sorry, you are not allowed to view menu locations.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves a specific menu location.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $registered_menus = get_registered_nav_menus();
+ if ( ! array_key_exists( $request['location'], $registered_menus ) ) {
+ return new WP_Error( 'rest_menu_location_invalid', __( 'Invalid menu location.' ), array( 'status' => 404 ) );
+ }
+
+ $location = new stdClass();
+ $location->name = $request['location'];
+ $location->description = $registered_menus[ $location->name ];
+
+ $data = $this->prepare_item_for_response( $location, $request );
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Prepares a menu location object for serialization.
+ *
+ * @since 5.9.0
+ *
+ * @param stdClass $item Post status data.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response Menu location data.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $location = $item;
+
+ $locations = get_nav_menu_locations();
+ $menu = isset( $locations[ $location->name ] ) ? $locations[ $location->name ] : 0;
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( rest_is_field_included( 'name', $fields ) ) {
+ $data['name'] = $location->name;
+ }
+
+ if ( rest_is_field_included( 'description', $fields ) ) {
+ $data['description'] = $location->description;
+ }
+
+ if ( rest_is_field_included( 'menu', $fields ) ) {
+ $data['menu'] = (int) $menu;
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $location ) );
+ }
+
+ /**
+ * Filters menu location data returned from the REST API.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param object $location The original location object.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ return apply_filters( 'rest_prepare_menu_location', $response, $location, $request );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 5.9.0
+ *
+ * @param stdClass $location Menu location.
+ * @return array Links for the given menu location.
+ */
+ protected function prepare_links( $location ) {
+ $base = sprintf( '%s/%s', $this->namespace, $this->rest_base );
+
+ // Entity meta.
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( trailingslashit( $base ) . $location->name ),
+ ),
+ 'collection' => array(
+ 'href' => rest_url( $base ),
+ ),
+ );
+
+ $locations = get_nav_menu_locations();
+ $menu = isset( $locations[ $location->name ] ) ? $locations[ $location->name ] : 0;
+ if ( $menu ) {
+ $path = rest_get_route_for_term( $menu );
+ if ( $path ) {
+ $url = rest_url( $path );
+
+ $links['https://api.w.org/menu'][] = array(
+ 'href' => $url,
+ 'embeddable' => true,
+ );
+ }
+ }
+
+ return $links;
+ }
+
+ /**
+ * Retrieves the menu location's schema, conforming to JSON Schema.
+ *
+ * @since 5.9.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'menu-location',
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array(
+ 'description' => __( 'The name of the menu location.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'description' => __( 'The description of the menu location.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'menu' => array(
+ 'description' => __( 'The ID of the assigned menu.' ),
+ 'type' => 'integer',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * @since 5.9.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ return array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php
new file mode 100644
index 0000000..3b8205f
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php
@@ -0,0 +1,577 @@
+<?php
+/**
+ * REST API: WP_REST_Menus_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.9.0
+ */
+
+/**
+ * Core class used to managed menu terms associated via the REST API.
+ *
+ * @since 5.9.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Menus_Controller extends WP_REST_Terms_Controller {
+
+ /**
+ * Checks if a request has access to read menus.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return bool|WP_Error True if the request has read access, otherwise false or WP_Error object.
+ */
+ public function get_items_permissions_check( $request ) {
+ $has_permission = parent::get_items_permissions_check( $request );
+
+ if ( true !== $has_permission ) {
+ return $has_permission;
+ }
+
+ return $this->check_has_read_only_access( $request );
+ }
+
+ /**
+ * Checks if a request has access to read or edit the specified menu.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, otherwise WP_Error object.
+ */
+ public function get_item_permissions_check( $request ) {
+ $has_permission = parent::get_item_permissions_check( $request );
+
+ if ( true !== $has_permission ) {
+ return $has_permission;
+ }
+
+ return $this->check_has_read_only_access( $request );
+ }
+
+ /**
+ * Gets the term, if the ID is valid.
+ *
+ * @since 5.9.0
+ *
+ * @param int $id Supplied ID.
+ * @return WP_Term|WP_Error Term object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_term( $id ) {
+ $term = parent::get_term( $id );
+
+ if ( is_wp_error( $term ) ) {
+ return $term;
+ }
+
+ $nav_term = wp_get_nav_menu_object( $term );
+ $nav_term->auto_add = $this->get_menu_auto_add( $nav_term->term_id );
+
+ return $nav_term;
+ }
+
+ /**
+ * Checks whether the current user has read permission for the endpoint.
+ *
+ * This allows for any user that can `edit_theme_options` or edit any REST API available post type.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the current user has permission, WP_Error object otherwise.
+ */
+ protected function check_has_read_only_access( $request ) {
+ if ( current_user_can( 'edit_theme_options' ) ) {
+ return true;
+ }
+
+ if ( current_user_can( 'edit_posts' ) ) {
+ return true;
+ }
+
+ foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
+ if ( current_user_can( $post_type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view',
+ __( 'Sorry, you are not allowed to view menus.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Prepares a single term output for response.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_Term $term Term object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $term, $request ) {
+ $nav_menu = wp_get_nav_menu_object( $term );
+ $response = parent::prepare_item_for_response( $nav_menu, $request );
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = $response->get_data();
+
+ if ( rest_is_field_included( 'locations', $fields ) ) {
+ $data['locations'] = $this->get_menu_locations( $nav_menu->term_id );
+ }
+
+ if ( rest_is_field_included( 'auto_add', $fields ) ) {
+ $data['auto_add'] = $this->get_menu_auto_add( $nav_menu->term_id );
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $term ) );
+ }
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php */
+ return apply_filters( "rest_prepare_{$this->taxonomy}", $response, $term, $request );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_Term $term Term object.
+ * @return array Links for the given term.
+ */
+ protected function prepare_links( $term ) {
+ $links = parent::prepare_links( $term );
+
+ $locations = $this->get_menu_locations( $term->term_id );
+ foreach ( $locations as $location ) {
+ $url = rest_url( sprintf( 'wp/v2/menu-locations/%s', $location ) );
+
+ $links['https://api.w.org/menu-location'][] = array(
+ 'href' => $url,
+ 'embeddable' => true,
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * Prepares a single term for create or update.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return object Prepared term data.
+ */
+ public function prepare_item_for_database( $request ) {
+ $prepared_term = parent::prepare_item_for_database( $request );
+
+ $schema = $this->get_item_schema();
+
+ if ( isset( $request['name'] ) && ! empty( $schema['properties']['name'] ) ) {
+ $prepared_term->{'menu-name'} = $request['name'];
+ }
+
+ return $prepared_term;
+ }
+
+ /**
+ * Creates a single term in a taxonomy.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+ if ( isset( $request['parent'] ) ) {
+ if ( ! is_taxonomy_hierarchical( $this->taxonomy ) ) {
+ return new WP_Error( 'rest_taxonomy_not_hierarchical', __( 'Cannot set parent term, taxonomy is not hierarchical.' ), array( 'status' => 400 ) );
+ }
+
+ $parent = wp_get_nav_menu_object( (int) $request['parent'] );
+
+ if ( ! $parent ) {
+ return new WP_Error( 'rest_term_invalid', __( 'Parent term does not exist.' ), array( 'status' => 400 ) );
+ }
+ }
+
+ $prepared_term = $this->prepare_item_for_database( $request );
+
+ $term = wp_update_nav_menu_object( 0, wp_slash( (array) $prepared_term ) );
+
+ if ( is_wp_error( $term ) ) {
+ /*
+ * If we're going to inform the client that the term already exists,
+ * give them the identifier for future use.
+ */
+
+ if ( in_array( 'menu_exists', $term->get_error_codes(), true ) ) {
+ $existing_term = get_term_by( 'name', $prepared_term->{'menu-name'}, $this->taxonomy );
+ $term->add_data( $existing_term->term_id, 'menu_exists' );
+ $term->add_data(
+ array(
+ 'status' => 400,
+ 'term_id' => $existing_term->term_id,
+ )
+ );
+ } else {
+ $term->add_data( array( 'status' => 400 ) );
+ }
+
+ return $term;
+ }
+
+ $term = $this->get_term( $term );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php */
+ do_action( "rest_insert_{$this->taxonomy}", $term, $request, true );
+
+ $schema = $this->get_item_schema();
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $term->term_id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $locations_update = $this->handle_locations( $term->term_id, $request );
+
+ if ( is_wp_error( $locations_update ) ) {
+ return $locations_update;
+ }
+
+ $this->handle_auto_add( $term->term_id, $request );
+
+ $fields_update = $this->update_additional_fields_for_object( $term, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'view' );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php */
+ do_action( "rest_after_insert_{$this->taxonomy}", $term, $request, true );
+
+ $response = $this->prepare_item_for_response( $term, $request );
+ $response = rest_ensure_response( $response );
+
+ $response->set_status( 201 );
+ $response->header( 'Location', rest_url( $this->namespace . '/' . $this->rest_base . '/' . $term->term_id ) );
+
+ return $response;
+ }
+
+ /**
+ * Updates a single term from a taxonomy.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ $term = $this->get_term( $request['id'] );
+ if ( is_wp_error( $term ) ) {
+ return $term;
+ }
+
+ if ( isset( $request['parent'] ) ) {
+ if ( ! is_taxonomy_hierarchical( $this->taxonomy ) ) {
+ return new WP_Error( 'rest_taxonomy_not_hierarchical', __( 'Cannot set parent term, taxonomy is not hierarchical.' ), array( 'status' => 400 ) );
+ }
+
+ $parent = get_term( (int) $request['parent'], $this->taxonomy );
+
+ if ( ! $parent ) {
+ return new WP_Error( 'rest_term_invalid', __( 'Parent term does not exist.' ), array( 'status' => 400 ) );
+ }
+ }
+
+ $prepared_term = $this->prepare_item_for_database( $request );
+
+ // Only update the term if we have something to update.
+ if ( ! empty( $prepared_term ) ) {
+ if ( ! isset( $prepared_term->{'menu-name'} ) ) {
+ // wp_update_nav_menu_object() requires that the menu-name is always passed.
+ $prepared_term->{'menu-name'} = $term->name;
+ }
+
+ $update = wp_update_nav_menu_object( $term->term_id, wp_slash( (array) $prepared_term ) );
+
+ if ( is_wp_error( $update ) ) {
+ return $update;
+ }
+ }
+
+ $term = get_term( $term->term_id, $this->taxonomy );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php */
+ do_action( "rest_insert_{$this->taxonomy}", $term, $request, false );
+
+ $schema = $this->get_item_schema();
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $term->term_id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $locations_update = $this->handle_locations( $term->term_id, $request );
+
+ if ( is_wp_error( $locations_update ) ) {
+ return $locations_update;
+ }
+
+ $this->handle_auto_add( $term->term_id, $request );
+
+ $fields_update = $this->update_additional_fields_for_object( $term, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'view' );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php */
+ do_action( "rest_after_insert_{$this->taxonomy}", $term, $request, false );
+
+ $response = $this->prepare_item_for_response( $term, $request );
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Deletes a single term from a taxonomy.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_item( $request ) {
+ $term = $this->get_term( $request['id'] );
+ if ( is_wp_error( $term ) ) {
+ return $term;
+ }
+
+ // We don't support trashing for terms.
+ if ( ! $request['force'] ) {
+ /* translators: %s: force=true */
+ return new WP_Error( 'rest_trash_not_supported', sprintf( __( "Menus do not support trashing. Set '%s' to delete." ), 'force=true' ), array( 'status' => 501 ) );
+ }
+
+ $request->set_param( 'context', 'view' );
+
+ $previous = $this->prepare_item_for_response( $term, $request );
+
+ $result = wp_delete_nav_menu( $term );
+
+ if ( ! $result || is_wp_error( $result ) ) {
+ return new WP_Error( 'rest_cannot_delete', __( 'The menu cannot be deleted.' ), array( 'status' => 500 ) );
+ }
+
+ $response = new WP_REST_Response();
+ $response->set_data(
+ array(
+ 'deleted' => true,
+ 'previous' => $previous->get_data(),
+ )
+ );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php */
+ do_action( "rest_delete_{$this->taxonomy}", $term, $response, $request );
+
+ return $response;
+ }
+
+ /**
+ * Returns the value of a menu's auto_add setting.
+ *
+ * @since 5.9.0
+ *
+ * @param int $menu_id The menu id to query.
+ * @return bool The value of auto_add.
+ */
+ protected function get_menu_auto_add( $menu_id ) {
+ $nav_menu_option = (array) get_option( 'nav_menu_options', array( 'auto_add' => array() ) );
+
+ return in_array( $menu_id, $nav_menu_option['auto_add'], true );
+ }
+
+ /**
+ * Updates the menu's auto add from a REST request.
+ *
+ * @since 5.9.0
+ *
+ * @param int $menu_id The menu id to update.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return bool True if the auto add setting was successfully updated.
+ */
+ protected function handle_auto_add( $menu_id, $request ) {
+ if ( ! isset( $request['auto_add'] ) ) {
+ return true;
+ }
+
+ $nav_menu_option = (array) get_option( 'nav_menu_options', array( 'auto_add' => array() ) );
+
+ if ( ! isset( $nav_menu_option['auto_add'] ) ) {
+ $nav_menu_option['auto_add'] = array();
+ }
+
+ $auto_add = $request['auto_add'];
+
+ $i = array_search( $menu_id, $nav_menu_option['auto_add'], true );
+
+ if ( $auto_add && false === $i ) {
+ $nav_menu_option['auto_add'][] = $menu_id;
+ } elseif ( ! $auto_add && false !== $i ) {
+ array_splice( $nav_menu_option['auto_add'], $i, 1 );
+ }
+
+ $update = update_option( 'nav_menu_options', $nav_menu_option );
+
+ /** This action is documented in wp-includes/nav-menu.php */
+ do_action( 'wp_update_nav_menu', $menu_id );
+
+ return $update;
+ }
+
+ /**
+ * Returns the names of the locations assigned to the menu.
+ *
+ * @since 5.9.0
+ *
+ * @param int $menu_id The menu id.
+ * @return string[] The locations assigned to the menu.
+ */
+ protected function get_menu_locations( $menu_id ) {
+ $locations = get_nav_menu_locations();
+ $menu_locations = array();
+
+ foreach ( $locations as $location => $assigned_menu_id ) {
+ if ( $menu_id === $assigned_menu_id ) {
+ $menu_locations[] = $location;
+ }
+ }
+
+ return $menu_locations;
+ }
+
+ /**
+ * Updates the menu's locations from a REST request.
+ *
+ * @since 5.9.0
+ *
+ * @param int $menu_id The menu id to update.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True on success, a WP_Error on an error updating any of the locations.
+ */
+ protected function handle_locations( $menu_id, $request ) {
+ if ( ! isset( $request['locations'] ) ) {
+ return true;
+ }
+
+ $menu_locations = get_registered_nav_menus();
+ $menu_locations = array_keys( $menu_locations );
+ $new_locations = array();
+ foreach ( $request['locations'] as $location ) {
+ if ( ! in_array( $location, $menu_locations, true ) ) {
+ return new WP_Error(
+ 'rest_invalid_menu_location',
+ __( 'Invalid menu location.' ),
+ array(
+ 'status' => 400,
+ 'location' => $location,
+ )
+ );
+ }
+ $new_locations[ $location ] = $menu_id;
+ }
+ $assigned_menu = get_nav_menu_locations();
+ foreach ( $assigned_menu as $location => $term_id ) {
+ if ( $term_id === $menu_id ) {
+ unset( $assigned_menu[ $location ] );
+ }
+ }
+ $new_assignments = array_merge( $assigned_menu, $new_locations );
+ set_theme_mod( 'nav_menu_locations', $new_assignments );
+
+ return true;
+ }
+
+ /**
+ * Retrieves the term's schema, conforming to JSON Schema.
+ *
+ * @since 5.9.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = parent::get_item_schema();
+ unset( $schema['properties']['count'], $schema['properties']['link'], $schema['properties']['taxonomy'] );
+
+ $schema['properties']['locations'] = array(
+ 'description' => __( 'The locations assigned to the menu.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'view', 'edit' ),
+ 'arg_options' => array(
+ 'validate_callback' => static function ( $locations, $request, $param ) {
+ $valid = rest_validate_request_arg( $locations, $request, $param );
+
+ if ( true !== $valid ) {
+ return $valid;
+ }
+
+ $locations = rest_sanitize_request_arg( $locations, $request, $param );
+
+ foreach ( $locations as $location ) {
+ if ( ! array_key_exists( $location, get_registered_nav_menus() ) ) {
+ return new WP_Error(
+ 'rest_invalid_menu_location',
+ __( 'Invalid menu location.' ),
+ array(
+ 'location' => $location,
+ )
+ );
+ }
+ }
+
+ return true;
+ },
+ ),
+ );
+
+ $schema['properties']['auto_add'] = array(
+ 'description' => __( 'Whether to automatically add top level pages to this menu.' ),
+ 'context' => array( 'view', 'edit' ),
+ 'type' => 'boolean',
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-navigation-fallback-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-navigation-fallback-controller.php
new file mode 100644
index 0000000..575a1a9
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-navigation-fallback-controller.php
@@ -0,0 +1,191 @@
+<?php
+/**
+ * WP_REST_Navigation_Fallback_Controller class
+ *
+ * REST Controller to create/fetch a fallback Navigation Menu.
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 6.3.0
+ */
+
+/**
+ * REST Controller to fetch a fallback Navigation Block Menu. If needed it creates one.
+ *
+ * @since 6.3.0
+ */
+class WP_REST_Navigation_Fallback_Controller extends WP_REST_Controller {
+
+ /**
+ * The Post Type for the Controller
+ *
+ * @since 6.3.0
+ *
+ * @var string
+ */
+ private $post_type;
+
+ /**
+ * Constructs the controller.
+ *
+ * @since 6.3.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp-block-editor/v1';
+ $this->rest_base = 'navigation-fallback';
+ $this->post_type = 'wp_navigation';
+ }
+
+ /**
+ * Registers the controllers routes.
+ *
+ * @since 6.3.0
+ */
+ public function register_routes() {
+
+ // Lists a single nav item based on the given id or slug.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::READABLE ),
+ ),
+ 'schema' => array( $this, 'get_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to read fallbacks.
+ *
+ * @since 6.3.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+
+ $post_type = get_post_type_object( $this->post_type );
+
+ // Getting fallbacks requires creating and reading `wp_navigation` posts.
+ if ( ! current_user_can( $post_type->cap->create_posts ) || ! current_user_can( 'edit_theme_options' ) || ! current_user_can( 'edit_posts' ) ) {
+ return new WP_Error(
+ 'rest_cannot_create',
+ __( 'Sorry, you are not allowed to create Navigation Menus as this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( 'edit' === $request['context'] && ! current_user_can( $post_type->cap->edit_posts ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to edit Navigation Menus as this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Gets the most appropriate fallback Navigation Menu.
+ *
+ * @since 6.3.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $post = WP_Navigation_Fallback::get_fallback();
+
+ if ( empty( $post ) ) {
+ return rest_ensure_response( new WP_Error( 'no_fallback_menu', __( 'No fallback menu found.' ), array( 'status' => 404 ) ) );
+ }
+
+ $response = $this->prepare_item_for_response( $post, $request );
+
+ return $response;
+ }
+
+ /**
+ * Retrieves the fallbacks' schema, conforming to JSON Schema.
+ *
+ * @since 6.3.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'navigation-fallback',
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'The unique identifier for the Navigation Menu.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Matches the post data to the schema we want.
+ *
+ * @since 6.3.0
+ *
+ * @param WP_Post $item The wp_navigation Post object whose response is being prepared.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response $response The response data.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ $data = array();
+
+ $fields = $this->get_fields_for_response( $request );
+
+ if ( rest_is_field_included( 'id', $fields ) ) {
+ $data['id'] = (int) $item->ID;
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $links = $this->prepare_links( $item );
+ $response->add_links( $links );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Prepares the links for the request.
+ *
+ * @since 6.3.0
+ *
+ * @param WP_Post $post the Navigation Menu post object.
+ * @return array Links for the given request.
+ */
+ private function prepare_links( $post ) {
+ return array(
+ 'self' => array(
+ 'href' => rest_url( rest_get_route_for_post( $post->ID ) ),
+ 'embeddable' => true,
+ ),
+ );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php
new file mode 100644
index 0000000..41f37c9
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php
@@ -0,0 +1,410 @@
+<?php
+/**
+ * Block Pattern Directory REST API: WP_REST_Pattern_Directory_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.8.0
+ */
+
+/**
+ * Controller which provides REST endpoint for block patterns.
+ *
+ * This simply proxies the endpoint at http://api.wordpress.org/patterns/1.0/. That isn't necessary for
+ * functionality, but is desired for privacy. It prevents api.wordpress.org from knowing the user's IP address.
+ *
+ * @since 5.8.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Pattern_Directory_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructs the controller.
+ *
+ * @since 5.8.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'pattern-directory';
+ }
+
+ /**
+ * Registers the necessary REST API routes.
+ *
+ * @since 5.8.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/patterns',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to view the local block pattern directory.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has permission, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( current_user_can( 'edit_posts' ) ) {
+ return true;
+ }
+
+ foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
+ if ( current_user_can( $post_type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error(
+ 'rest_pattern_directory_cannot_view',
+ __( 'Sorry, you are not allowed to browse the local block pattern directory.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Search and retrieve block patterns metadata
+ *
+ * @since 5.8.0
+ * @since 6.0.0 Added 'slug' to request.
+ * @since 6.2.0 Added 'per_page', 'page', 'offset', 'order', and 'orderby' to request.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ /*
+ * Include an unmodified `$wp_version`, so the API can craft a response that's tailored to
+ * it. Some plugins modify the version in a misguided attempt to improve security by
+ * obscuring the version, which can cause invalid requests.
+ */
+ require ABSPATH . WPINC . '/version.php';
+
+ $valid_query_args = array(
+ 'offset' => true,
+ 'order' => true,
+ 'orderby' => true,
+ 'page' => true,
+ 'per_page' => true,
+ 'search' => true,
+ 'slug' => true,
+ );
+ $query_args = array_intersect_key( $request->get_params(), $valid_query_args );
+
+ $query_args['locale'] = get_user_locale();
+ $query_args['wp-version'] = $wp_version;
+ $query_args['pattern-categories'] = isset( $request['category'] ) ? $request['category'] : false;
+ $query_args['pattern-keywords'] = isset( $request['keyword'] ) ? $request['keyword'] : false;
+
+ $query_args = array_filter( $query_args );
+
+ $transient_key = $this->get_transient_key( $query_args );
+
+ /*
+ * Use network-wide transient to improve performance. The locale is the only site
+ * configuration that affects the response, and it's included in the transient key.
+ */
+ $raw_patterns = get_site_transient( $transient_key );
+
+ if ( ! $raw_patterns ) {
+ $api_url = 'http://api.wordpress.org/patterns/1.0/?' . build_query( $query_args );
+ if ( wp_http_supports( array( 'ssl' ) ) ) {
+ $api_url = set_url_scheme( $api_url, 'https' );
+ }
+
+ /*
+ * Default to a short TTL, to mitigate cache stampedes on high-traffic sites.
+ * This assumes that most errors will be short-lived, e.g., packet loss that causes the
+ * first request to fail, but a follow-up one will succeed. The value should be high
+ * enough to avoid stampedes, but low enough to not interfere with users manually
+ * re-trying a failed request.
+ */
+ $cache_ttl = 5;
+ $wporg_response = wp_remote_get( $api_url );
+ $raw_patterns = json_decode( wp_remote_retrieve_body( $wporg_response ) );
+
+ if ( is_wp_error( $wporg_response ) ) {
+ $raw_patterns = $wporg_response;
+
+ } elseif ( ! is_array( $raw_patterns ) ) {
+ // HTTP request succeeded, but response data is invalid.
+ $raw_patterns = new WP_Error(
+ 'pattern_api_failed',
+ sprintf(
+ /* translators: %s: Support forums URL. */
+ __( 'An unexpected error occurred. Something may be wrong with WordPress.org or this server&#8217;s configuration. If you continue to have problems, please try the <a href="%s">support forums</a>.' ),
+ __( 'https://wordpress.org/support/forums/' )
+ ),
+ array(
+ 'response' => wp_remote_retrieve_body( $wporg_response ),
+ )
+ );
+
+ } else {
+ // Response has valid data.
+ $cache_ttl = HOUR_IN_SECONDS;
+ }
+
+ set_site_transient( $transient_key, $raw_patterns, $cache_ttl );
+ }
+
+ if ( is_wp_error( $raw_patterns ) ) {
+ $raw_patterns->add_data( array( 'status' => 500 ) );
+
+ return $raw_patterns;
+ }
+
+ $response = array();
+
+ if ( $raw_patterns ) {
+ foreach ( $raw_patterns as $pattern ) {
+ $response[] = $this->prepare_response_for_collection(
+ $this->prepare_item_for_response( $pattern, $request )
+ );
+ }
+ }
+
+ return new WP_REST_Response( $response );
+ }
+
+ /**
+ * Prepare a raw block pattern before it gets output in a REST API response.
+ *
+ * @since 5.8.0
+ * @since 5.9.0 Renamed `$raw_pattern` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param object $item Raw pattern from api.wordpress.org, before any changes.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $raw_pattern = $item;
+
+ $prepared_pattern = array(
+ 'id' => absint( $raw_pattern->id ),
+ 'title' => sanitize_text_field( $raw_pattern->title->rendered ),
+ 'content' => wp_kses_post( $raw_pattern->pattern_content ),
+ 'categories' => array_map( 'sanitize_title', $raw_pattern->category_slugs ),
+ 'keywords' => array_map( 'sanitize_text_field', explode( ',', $raw_pattern->meta->wpop_keywords ) ),
+ 'description' => sanitize_text_field( $raw_pattern->meta->wpop_description ),
+ 'viewport_width' => absint( $raw_pattern->meta->wpop_viewport_width ),
+ 'block_types' => array_map( 'sanitize_text_field', $raw_pattern->meta->wpop_block_types ),
+ );
+
+ $prepared_pattern = $this->add_additional_fields_to_object( $prepared_pattern, $request );
+
+ $response = new WP_REST_Response( $prepared_pattern );
+
+ /**
+ * Filters the REST API response for a block pattern.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param object $raw_pattern The unprepared block pattern.
+ * @param WP_REST_Request $request The request object.
+ */
+ return apply_filters( 'rest_prepare_block_pattern', $response, $raw_pattern, $request );
+ }
+
+ /**
+ * Retrieves the block pattern's schema, conforming to JSON Schema.
+ *
+ * @since 5.8.0
+ * @since 6.2.0 Added `'block_types'` to schema.
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'pattern-directory-item',
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'The pattern ID.' ),
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+
+ 'title' => array(
+ 'description' => __( 'The pattern title, in human readable format.' ),
+ 'type' => 'string',
+ 'minLength' => 1,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+
+ 'content' => array(
+ 'description' => __( 'The pattern content.' ),
+ 'type' => 'string',
+ 'minLength' => 1,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+
+ 'categories' => array(
+ 'description' => __( "The pattern's category slugs." ),
+ 'type' => 'array',
+ 'uniqueItems' => true,
+ 'items' => array( 'type' => 'string' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+
+ 'keywords' => array(
+ 'description' => __( "The pattern's keywords." ),
+ 'type' => 'array',
+ 'uniqueItems' => true,
+ 'items' => array( 'type' => 'string' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+
+ 'description' => array(
+ 'description' => __( 'A description of the pattern.' ),
+ 'type' => 'string',
+ 'minLength' => 1,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+
+ 'viewport_width' => array(
+ 'description' => __( 'The preferred width of the viewport when previewing a pattern, in pixels.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+
+ 'block_types' => array(
+ 'description' => __( 'The block types which can use this pattern.' ),
+ 'type' => 'array',
+ 'uniqueItems' => true,
+ 'items' => array( 'type' => 'string' ),
+ 'context' => array( 'view', 'embed' ),
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the search parameters for the block pattern's collection.
+ *
+ * @since 5.8.0
+ * @since 6.2.0 Added 'per_page', 'page', 'offset', 'order', and 'orderby' to request.
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $query_params = parent::get_collection_params();
+
+ $query_params['per_page']['default'] = 100;
+ $query_params['search']['minLength'] = 1;
+ $query_params['context']['default'] = 'view';
+
+ $query_params['category'] = array(
+ 'description' => __( 'Limit results to those matching a category ID.' ),
+ 'type' => 'integer',
+ 'minimum' => 1,
+ );
+
+ $query_params['keyword'] = array(
+ 'description' => __( 'Limit results to those matching a keyword ID.' ),
+ 'type' => 'integer',
+ 'minimum' => 1,
+ );
+
+ $query_params['slug'] = array(
+ 'description' => __( 'Limit results to those matching a pattern (slug).' ),
+ 'type' => 'array',
+ );
+
+ $query_params['offset'] = array(
+ 'description' => __( 'Offset the result set by a specific number of items.' ),
+ 'type' => 'integer',
+ );
+
+ $query_params['order'] = array(
+ 'description' => __( 'Order sort attribute ascending or descending.' ),
+ 'type' => 'string',
+ 'default' => 'desc',
+ 'enum' => array( 'asc', 'desc' ),
+ );
+
+ $query_params['orderby'] = array(
+ 'description' => __( 'Sort collection by post attribute.' ),
+ 'type' => 'string',
+ 'default' => 'date',
+ 'enum' => array(
+ 'author',
+ 'date',
+ 'id',
+ 'include',
+ 'modified',
+ 'parent',
+ 'relevance',
+ 'slug',
+ 'include_slugs',
+ 'title',
+ 'favorite_count',
+ ),
+ );
+
+ /**
+ * Filter collection parameters for the block pattern directory controller.
+ *
+ * @since 5.8.0
+ *
+ * @param array $query_params JSON Schema-formatted collection parameters.
+ */
+ return apply_filters( 'rest_pattern_directory_collection_params', $query_params );
+ }
+
+ /*
+ * Include a hash of the query args, so that different requests are stored in
+ * separate caches.
+ *
+ * MD5 is chosen for its speed, low-collision rate, universal availability, and to stay
+ * under the character limit for `_site_transient_timeout_{...}` keys.
+ *
+ * @link https://stackoverflow.com/questions/3665247/fastest-hash-for-non-cryptographic-uses
+ *
+ * @since 6.0.0
+ *
+ * @param array $query_args Query arguments to generate a transient key from.
+ * @return string Transient key.
+ */
+ protected function get_transient_key( $query_args ) {
+
+ if ( isset( $query_args['slug'] ) ) {
+ // This is an additional precaution because the "sort" function expects an array.
+ $query_args['slug'] = wp_parse_list( $query_args['slug'] );
+
+ // Empty arrays should not affect the transient key.
+ if ( empty( $query_args['slug'] ) ) {
+ unset( $query_args['slug'] );
+ } else {
+ // Sort the array so that the transient key doesn't depend on the order of slugs.
+ sort( $query_args['slug'] );
+ }
+ }
+
+ return 'wp_remote_block_patterns_' . md5( serialize( $query_args ) );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php
new file mode 100644
index 0000000..8d24b60
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php
@@ -0,0 +1,1004 @@
+<?php
+/**
+ * REST API: WP_REST_Plugins_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.5.0
+ */
+
+/**
+ * Core class to access plugins via the REST API.
+ *
+ * @since 5.5.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Plugins_Controller extends WP_REST_Controller {
+
+ const PATTERN = '[^.\/]+(?:\/[^.\/]+)?';
+
+ /**
+ * Plugins controller constructor.
+ *
+ * @since 5.5.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'plugins';
+ }
+
+ /**
+ * Registers the routes for the plugins controller.
+ *
+ * @since 5.5.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => array(
+ 'slug' => array(
+ 'type' => 'string',
+ 'required' => true,
+ 'description' => __( 'WordPress.org plugin directory slug.' ),
+ 'pattern' => '[\w\-]+',
+ ),
+ 'status' => array(
+ 'description' => __( 'The plugin activation status.' ),
+ 'type' => 'string',
+ 'enum' => is_multisite() ? array( 'inactive', 'active', 'network-active' ) : array( 'inactive', 'active' ),
+ 'default' => 'inactive',
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<plugin>' . self::PATTERN . ')',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'permission_callback' => array( $this, 'update_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+ ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ 'plugin' => array(
+ 'type' => 'string',
+ 'pattern' => self::PATTERN,
+ 'validate_callback' => array( $this, 'validate_plugin_param' ),
+ 'sanitize_callback' => array( $this, 'sanitize_plugin_param' ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to get plugins.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( ! current_user_can( 'activate_plugins' ) ) {
+ return new WP_Error(
+ 'rest_cannot_view_plugins',
+ __( 'Sorry, you are not allowed to manage plugins for this site.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves a collection of plugins.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+ $plugins = array();
+
+ foreach ( get_plugins() as $file => $data ) {
+ if ( is_wp_error( $this->check_read_permission( $file ) ) ) {
+ continue;
+ }
+
+ $data['_file'] = $file;
+
+ if ( ! $this->does_plugin_match_request( $request, $data ) ) {
+ continue;
+ }
+
+ $plugins[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $data, $request ) );
+ }
+
+ return new WP_REST_Response( $plugins );
+ }
+
+ /**
+ * Checks if a given request has access to get a specific plugin.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ if ( ! current_user_can( 'activate_plugins' ) ) {
+ return new WP_Error(
+ 'rest_cannot_view_plugin',
+ __( 'Sorry, you are not allowed to manage plugins for this site.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $can_read = $this->check_read_permission( $request['plugin'] );
+
+ if ( is_wp_error( $can_read ) ) {
+ return $can_read;
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves one plugin from the site.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+ $data = $this->get_plugin_data( $request['plugin'] );
+
+ if ( is_wp_error( $data ) ) {
+ return $data;
+ }
+
+ return $this->prepare_item_for_response( $data, $request );
+ }
+
+ /**
+ * Checks if the given plugin can be viewed by the current user.
+ *
+ * On multisite, this hides non-active network only plugins if the user does not have permission
+ * to manage network plugins.
+ *
+ * @since 5.5.0
+ *
+ * @param string $plugin The plugin file to check.
+ * @return true|WP_Error True if can read, a WP_Error instance otherwise.
+ */
+ protected function check_read_permission( $plugin ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+ if ( ! $this->is_plugin_installed( $plugin ) ) {
+ return new WP_Error( 'rest_plugin_not_found', __( 'Plugin not found.' ), array( 'status' => 404 ) );
+ }
+
+ if ( ! is_multisite() ) {
+ return true;
+ }
+
+ if ( ! is_network_only_plugin( $plugin ) || is_plugin_active( $plugin ) || current_user_can( 'manage_network_plugins' ) ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view_plugin',
+ __( 'Sorry, you are not allowed to manage this plugin.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to upload plugins.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
+ */
+ public function create_item_permissions_check( $request ) {
+ if ( ! current_user_can( 'install_plugins' ) ) {
+ return new WP_Error(
+ 'rest_cannot_install_plugin',
+ __( 'Sorry, you are not allowed to install plugins on this site.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( 'inactive' !== $request['status'] && ! current_user_can( 'activate_plugins' ) ) {
+ return new WP_Error(
+ 'rest_cannot_activate_plugin',
+ __( 'Sorry, you are not allowed to activate plugins.' ),
+ array(
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Uploads a plugin and optionally activates it.
+ *
+ * @since 5.5.0
+ *
+ * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+ global $wp_filesystem;
+
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
+ require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
+
+ $slug = $request['slug'];
+
+ // Verify filesystem is accessible first.
+ $filesystem_available = $this->is_filesystem_available();
+ if ( is_wp_error( $filesystem_available ) ) {
+ return $filesystem_available;
+ }
+
+ $api = plugins_api(
+ 'plugin_information',
+ array(
+ 'slug' => $slug,
+ 'fields' => array(
+ 'sections' => false,
+ 'language_packs' => true,
+ ),
+ )
+ );
+
+ if ( is_wp_error( $api ) ) {
+ if ( str_contains( $api->get_error_message(), 'Plugin not found.' ) ) {
+ $api->add_data( array( 'status' => 404 ) );
+ } else {
+ $api->add_data( array( 'status' => 500 ) );
+ }
+
+ return $api;
+ }
+
+ $skin = new WP_Ajax_Upgrader_Skin();
+ $upgrader = new Plugin_Upgrader( $skin );
+
+ $result = $upgrader->install( $api->download_link );
+
+ if ( is_wp_error( $result ) ) {
+ $result->add_data( array( 'status' => 500 ) );
+
+ return $result;
+ }
+
+ // This should be the same as $result above.
+ if ( is_wp_error( $skin->result ) ) {
+ $skin->result->add_data( array( 'status' => 500 ) );
+
+ return $skin->result;
+ }
+
+ if ( $skin->get_errors()->has_errors() ) {
+ $error = $skin->get_errors();
+ $error->add_data( array( 'status' => 500 ) );
+
+ return $error;
+ }
+
+ if ( is_null( $result ) ) {
+ // Pass through the error from WP_Filesystem if one was raised.
+ if ( $wp_filesystem instanceof WP_Filesystem_Base
+ && is_wp_error( $wp_filesystem->errors ) && $wp_filesystem->errors->has_errors()
+ ) {
+ return new WP_Error(
+ 'unable_to_connect_to_filesystem',
+ $wp_filesystem->errors->get_error_message(),
+ array( 'status' => 500 )
+ );
+ }
+
+ return new WP_Error(
+ 'unable_to_connect_to_filesystem',
+ __( 'Unable to connect to the filesystem. Please confirm your credentials.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $file = $upgrader->plugin_info();
+
+ if ( ! $file ) {
+ return new WP_Error(
+ 'unable_to_determine_installed_plugin',
+ __( 'Unable to determine what plugin was installed.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ if ( 'inactive' !== $request['status'] ) {
+ $can_change_status = $this->plugin_status_permission_check( $file, $request['status'], 'inactive' );
+
+ if ( is_wp_error( $can_change_status ) ) {
+ return $can_change_status;
+ }
+
+ $changed_status = $this->handle_plugin_status( $file, $request['status'], 'inactive' );
+
+ if ( is_wp_error( $changed_status ) ) {
+ return $changed_status;
+ }
+ }
+
+ // Install translations.
+ $installed_locales = array_values( get_available_languages() );
+ /** This filter is documented in wp-includes/update.php */
+ $installed_locales = apply_filters( 'plugins_update_check_locales', $installed_locales );
+
+ $language_packs = array_map(
+ static function ( $item ) {
+ return (object) $item;
+ },
+ $api->language_packs
+ );
+
+ $language_packs = array_filter(
+ $language_packs,
+ static function ( $pack ) use ( $installed_locales ) {
+ return in_array( $pack->language, $installed_locales, true );
+ }
+ );
+
+ if ( $language_packs ) {
+ $lp_upgrader = new Language_Pack_Upgrader( $skin );
+
+ // Install all applicable language packs for the plugin.
+ $lp_upgrader->bulk_upgrade( $language_packs );
+ }
+
+ $path = WP_PLUGIN_DIR . '/' . $file;
+ $data = get_plugin_data( $path, false, false );
+ $data['_file'] = $file;
+
+ $response = $this->prepare_item_for_response( $data, $request );
+ $response->set_status( 201 );
+ $response->header( 'Location', rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, substr( $file, 0, - 4 ) ) ) );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to update a specific plugin.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
+ */
+ public function update_item_permissions_check( $request ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+ if ( ! current_user_can( 'activate_plugins' ) ) {
+ return new WP_Error(
+ 'rest_cannot_manage_plugins',
+ __( 'Sorry, you are not allowed to manage plugins for this site.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $can_read = $this->check_read_permission( $request['plugin'] );
+
+ if ( is_wp_error( $can_read ) ) {
+ return $can_read;
+ }
+
+ $status = $this->get_plugin_status( $request['plugin'] );
+
+ if ( $request['status'] && $status !== $request['status'] ) {
+ $can_change_status = $this->plugin_status_permission_check( $request['plugin'], $request['status'], $status );
+
+ if ( is_wp_error( $can_change_status ) ) {
+ return $can_change_status;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Updates one plugin.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+ $data = $this->get_plugin_data( $request['plugin'] );
+
+ if ( is_wp_error( $data ) ) {
+ return $data;
+ }
+
+ $status = $this->get_plugin_status( $request['plugin'] );
+
+ if ( $request['status'] && $status !== $request['status'] ) {
+ $handled = $this->handle_plugin_status( $request['plugin'], $request['status'], $status );
+
+ if ( is_wp_error( $handled ) ) {
+ return $handled;
+ }
+ }
+
+ $this->update_additional_fields_for_object( $data, $request );
+
+ $request['context'] = 'edit';
+
+ return $this->prepare_item_for_response( $data, $request );
+ }
+
+ /**
+ * Checks if a given request has access to delete a specific plugin.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise.
+ */
+ public function delete_item_permissions_check( $request ) {
+ if ( ! current_user_can( 'activate_plugins' ) ) {
+ return new WP_Error(
+ 'rest_cannot_manage_plugins',
+ __( 'Sorry, you are not allowed to manage plugins for this site.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! current_user_can( 'delete_plugins' ) ) {
+ return new WP_Error(
+ 'rest_cannot_manage_plugins',
+ __( 'Sorry, you are not allowed to delete plugins for this site.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $can_read = $this->check_read_permission( $request['plugin'] );
+
+ if ( is_wp_error( $can_read ) ) {
+ return $can_read;
+ }
+
+ return true;
+ }
+
+ /**
+ * Deletes one plugin from the site.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_item( $request ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+ $data = $this->get_plugin_data( $request['plugin'] );
+
+ if ( is_wp_error( $data ) ) {
+ return $data;
+ }
+
+ if ( is_plugin_active( $request['plugin'] ) ) {
+ return new WP_Error(
+ 'rest_cannot_delete_active_plugin',
+ __( 'Cannot delete an active plugin. Please deactivate it first.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $filesystem_available = $this->is_filesystem_available();
+ if ( is_wp_error( $filesystem_available ) ) {
+ return $filesystem_available;
+ }
+
+ $prepared = $this->prepare_item_for_response( $data, $request );
+ $deleted = delete_plugins( array( $request['plugin'] ) );
+
+ if ( is_wp_error( $deleted ) ) {
+ $deleted->add_data( array( 'status' => 500 ) );
+
+ return $deleted;
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'deleted' => true,
+ 'previous' => $prepared->get_data(),
+ )
+ );
+ }
+
+ /**
+ * Prepares the plugin for the REST response.
+ *
+ * @since 5.5.0
+ *
+ * @param array $item Unmarked up and untranslated plugin data from {@see get_plugin_data()}.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ $fields = $this->get_fields_for_response( $request );
+
+ $item = _get_plugin_data_markup_translate( $item['_file'], $item, false );
+ $marked = _get_plugin_data_markup_translate( $item['_file'], $item, true );
+
+ $data = array(
+ 'plugin' => substr( $item['_file'], 0, - 4 ),
+ 'status' => $this->get_plugin_status( $item['_file'] ),
+ 'name' => $item['Name'],
+ 'plugin_uri' => $item['PluginURI'],
+ 'author' => $item['Author'],
+ 'author_uri' => $item['AuthorURI'],
+ 'description' => array(
+ 'raw' => $item['Description'],
+ 'rendered' => $marked['Description'],
+ ),
+ 'version' => $item['Version'],
+ 'network_only' => $item['Network'],
+ 'requires_wp' => $item['RequiresWP'],
+ 'requires_php' => $item['RequiresPHP'],
+ 'textdomain' => $item['TextDomain'],
+ );
+
+ $data = $this->add_additional_fields_to_object( $data, $request );
+
+ $response = new WP_REST_Response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $item ) );
+ }
+
+ /**
+ * Filters plugin data for a REST API response.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param array $item The plugin item from {@see get_plugin_data()}.
+ * @param WP_REST_Request $request The request object.
+ */
+ return apply_filters( 'rest_prepare_plugin', $response, $item, $request );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 5.5.0
+ *
+ * @param array $item The plugin item.
+ * @return array[]
+ */
+ protected function prepare_links( $item ) {
+ return array(
+ 'self' => array(
+ 'href' => rest_url(
+ sprintf(
+ '%s/%s/%s',
+ $this->namespace,
+ $this->rest_base,
+ substr( $item['_file'], 0, - 4 )
+ )
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Gets the plugin header data for a plugin.
+ *
+ * @since 5.5.0
+ *
+ * @param string $plugin The plugin file to get data for.
+ * @return array|WP_Error The plugin data, or a WP_Error if the plugin is not installed.
+ */
+ protected function get_plugin_data( $plugin ) {
+ $plugins = get_plugins();
+
+ if ( ! isset( $plugins[ $plugin ] ) ) {
+ return new WP_Error( 'rest_plugin_not_found', __( 'Plugin not found.' ), array( 'status' => 404 ) );
+ }
+
+ $data = $plugins[ $plugin ];
+ $data['_file'] = $plugin;
+
+ return $data;
+ }
+
+ /**
+ * Get's the activation status for a plugin.
+ *
+ * @since 5.5.0
+ *
+ * @param string $plugin The plugin file to check.
+ * @return string Either 'network-active', 'active' or 'inactive'.
+ */
+ protected function get_plugin_status( $plugin ) {
+ if ( is_plugin_active_for_network( $plugin ) ) {
+ return 'network-active';
+ }
+
+ if ( is_plugin_active( $plugin ) ) {
+ return 'active';
+ }
+
+ return 'inactive';
+ }
+
+ /**
+ * Handle updating a plugin's status.
+ *
+ * @since 5.5.0
+ *
+ * @param string $plugin The plugin file to update.
+ * @param string $new_status The plugin's new status.
+ * @param string $current_status The plugin's current status.
+ * @return true|WP_Error
+ */
+ protected function plugin_status_permission_check( $plugin, $new_status, $current_status ) {
+ if ( is_multisite() && ( 'network-active' === $current_status || 'network-active' === $new_status ) && ! current_user_can( 'manage_network_plugins' ) ) {
+ return new WP_Error(
+ 'rest_cannot_manage_network_plugins',
+ __( 'Sorry, you are not allowed to manage network plugins.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ( 'active' === $new_status || 'network-active' === $new_status ) && ! current_user_can( 'activate_plugin', $plugin ) ) {
+ return new WP_Error(
+ 'rest_cannot_activate_plugin',
+ __( 'Sorry, you are not allowed to activate this plugin.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( 'inactive' === $new_status && ! current_user_can( 'deactivate_plugin', $plugin ) ) {
+ return new WP_Error(
+ 'rest_cannot_deactivate_plugin',
+ __( 'Sorry, you are not allowed to deactivate this plugin.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle updating a plugin's status.
+ *
+ * @since 5.5.0
+ *
+ * @param string $plugin The plugin file to update.
+ * @param string $new_status The plugin's new status.
+ * @param string $current_status The plugin's current status.
+ * @return true|WP_Error
+ */
+ protected function handle_plugin_status( $plugin, $new_status, $current_status ) {
+ if ( 'inactive' === $new_status ) {
+ deactivate_plugins( $plugin, false, 'network-active' === $current_status );
+
+ return true;
+ }
+
+ if ( 'active' === $new_status && 'network-active' === $current_status ) {
+ return true;
+ }
+
+ $network_activate = 'network-active' === $new_status;
+
+ if ( is_multisite() && ! $network_activate && is_network_only_plugin( $plugin ) ) {
+ return new WP_Error(
+ 'rest_network_only_plugin',
+ __( 'Network only plugin must be network activated.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $activated = activate_plugin( $plugin, '', $network_activate );
+
+ if ( is_wp_error( $activated ) ) {
+ $activated->add_data( array( 'status' => 500 ) );
+
+ return $activated;
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks that the "plugin" parameter is a valid path.
+ *
+ * @since 5.5.0
+ *
+ * @param string $file The plugin file parameter.
+ * @return bool
+ */
+ public function validate_plugin_param( $file ) {
+ if ( ! is_string( $file ) || ! preg_match( '/' . self::PATTERN . '/u', $file ) ) {
+ return false;
+ }
+
+ $validated = validate_file( plugin_basename( $file ) );
+
+ return 0 === $validated;
+ }
+
+ /**
+ * Sanitizes the "plugin" parameter to be a proper plugin file with ".php" appended.
+ *
+ * @since 5.5.0
+ *
+ * @param string $file The plugin file parameter.
+ * @return string
+ */
+ public function sanitize_plugin_param( $file ) {
+ return plugin_basename( sanitize_text_field( $file . '.php' ) );
+ }
+
+ /**
+ * Checks if the plugin matches the requested parameters.
+ *
+ * @since 5.5.0
+ *
+ * @param WP_REST_Request $request The request to require the plugin matches against.
+ * @param array $item The plugin item.
+ * @return bool
+ */
+ protected function does_plugin_match_request( $request, $item ) {
+ $search = $request['search'];
+
+ if ( $search ) {
+ $matched_search = false;
+
+ foreach ( $item as $field ) {
+ if ( is_string( $field ) && str_contains( strip_tags( $field ), $search ) ) {
+ $matched_search = true;
+ break;
+ }
+ }
+
+ if ( ! $matched_search ) {
+ return false;
+ }
+ }
+
+ $status = $request['status'];
+
+ if ( $status && ! in_array( $this->get_plugin_status( $item['_file'] ), $status, true ) ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if the plugin is installed.
+ *
+ * @since 5.5.0
+ *
+ * @param string $plugin The plugin file.
+ * @return bool
+ */
+ protected function is_plugin_installed( $plugin ) {
+ return file_exists( WP_PLUGIN_DIR . '/' . $plugin );
+ }
+
+ /**
+ * Determine if the endpoints are available.
+ *
+ * Only the 'Direct' filesystem transport, and SSH/FTP when credentials are stored are supported at present.
+ *
+ * @since 5.5.0
+ *
+ * @return true|WP_Error True if filesystem is available, WP_Error otherwise.
+ */
+ protected function is_filesystem_available() {
+ $filesystem_method = get_filesystem_method();
+
+ if ( 'direct' === $filesystem_method ) {
+ return true;
+ }
+
+ ob_start();
+ $filesystem_credentials_are_stored = request_filesystem_credentials( self_admin_url() );
+ ob_end_clean();
+
+ if ( $filesystem_credentials_are_stored ) {
+ return true;
+ }
+
+ return new WP_Error( 'fs_unavailable', __( 'The filesystem is currently unavailable for managing plugins.' ), array( 'status' => 500 ) );
+ }
+
+ /**
+ * Retrieves the plugin's schema, conforming to JSON Schema.
+ *
+ * @since 5.5.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'plugin',
+ 'type' => 'object',
+ 'properties' => array(
+ 'plugin' => array(
+ 'description' => __( 'The plugin file.' ),
+ 'type' => 'string',
+ 'pattern' => self::PATTERN,
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'status' => array(
+ 'description' => __( 'The plugin activation status.' ),
+ 'type' => 'string',
+ 'enum' => is_multisite() ? array( 'inactive', 'active', 'network-active' ) : array( 'inactive', 'active' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'name' => array(
+ 'description' => __( 'The plugin name.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'plugin_uri' => array(
+ 'description' => __( 'The plugin\'s website address.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'author' => array(
+ 'description' => __( 'The plugin author.' ),
+ 'type' => 'object',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'author_uri' => array(
+ 'description' => __( 'Plugin author\'s website address.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'description' => array(
+ 'description' => __( 'The plugin description.' ),
+ 'type' => 'object',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit' ),
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'The raw plugin description.' ),
+ 'type' => 'string',
+ ),
+ 'rendered' => array(
+ 'description' => __( 'The plugin description formatted for display.' ),
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ 'version' => array(
+ 'description' => __( 'The plugin version number.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'network_only' => array(
+ 'description' => __( 'Whether the plugin can only be activated network-wide.' ),
+ 'type' => 'boolean',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'requires_wp' => array(
+ 'description' => __( 'Minimum required version of WordPress.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'requires_php' => array(
+ 'description' => __( 'Minimum required version of PHP.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'textdomain' => array(
+ 'description' => __( 'The plugin\'s text domain.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit' ),
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for the collections.
+ *
+ * @since 5.5.0
+ *
+ * @return array Query parameters for the collection.
+ */
+ public function get_collection_params() {
+ $query_params = parent::get_collection_params();
+
+ $query_params['context']['default'] = 'view';
+
+ $query_params['status'] = array(
+ 'description' => __( 'Limits results to plugins with the given status.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ 'enum' => is_multisite() ? array( 'inactive', 'active', 'network-active' ) : array( 'inactive', 'active' ),
+ ),
+ );
+
+ unset( $query_params['page'], $query_params['per_page'] );
+
+ return $query_params;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-post-statuses-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-post-statuses-controller.php
new file mode 100644
index 0000000..572950c
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-post-statuses-controller.php
@@ -0,0 +1,373 @@
+<?php
+/**
+ * REST API: WP_REST_Post_Statuses_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class used to access post statuses via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Post_Statuses_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructor.
+ *
+ * @since 4.7.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'statuses';
+ }
+
+ /**
+ * Registers the routes for post statuses.
+ *
+ * @since 4.7.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<status>[\w-]+)',
+ array(
+ 'args' => array(
+ 'status' => array(
+ 'description' => __( 'An alphanumeric identifier for the status.' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to read post statuses.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( 'edit' === $request['context'] ) {
+ $types = get_post_types( array( 'show_in_rest' => true ), 'objects' );
+
+ foreach ( $types as $type ) {
+ if ( current_user_can( $type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view',
+ __( 'Sorry, you are not allowed to manage post statuses.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves all post statuses, depending on user context.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $data = array();
+ $statuses = get_post_stati( array( 'internal' => false ), 'object' );
+ $statuses['trash'] = get_post_status_object( 'trash' );
+
+ foreach ( $statuses as $slug => $obj ) {
+ $ret = $this->check_read_permission( $obj );
+
+ if ( ! $ret ) {
+ continue;
+ }
+
+ $status = $this->prepare_item_for_response( $obj, $request );
+ $data[ $obj->name ] = $this->prepare_response_for_collection( $status );
+ }
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Checks if a given request has access to read a post status.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $status = get_post_status_object( $request['status'] );
+
+ if ( empty( $status ) ) {
+ return new WP_Error(
+ 'rest_status_invalid',
+ __( 'Invalid status.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $check = $this->check_read_permission( $status );
+
+ if ( ! $check ) {
+ return new WP_Error(
+ 'rest_cannot_read_status',
+ __( 'Cannot view status.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks whether a given post status should be visible.
+ *
+ * @since 4.7.0
+ *
+ * @param object $status Post status.
+ * @return bool True if the post status is visible, otherwise false.
+ */
+ protected function check_read_permission( $status ) {
+ if ( true === $status->public ) {
+ return true;
+ }
+
+ if ( false === $status->internal || 'trash' === $status->name ) {
+ $types = get_post_types( array( 'show_in_rest' => true ), 'objects' );
+
+ foreach ( $types as $type ) {
+ if ( current_user_can( $type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Retrieves a specific post status.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $obj = get_post_status_object( $request['status'] );
+
+ if ( empty( $obj ) ) {
+ return new WP_Error(
+ 'rest_status_invalid',
+ __( 'Invalid status.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $data = $this->prepare_item_for_response( $obj, $request );
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Prepares a post status object for serialization.
+ *
+ * @since 4.7.0
+ * @since 5.9.0 Renamed `$status` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param stdClass $item Post status data.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response Post status data.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $status = $item;
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( in_array( 'name', $fields, true ) ) {
+ $data['name'] = $status->label;
+ }
+
+ if ( in_array( 'private', $fields, true ) ) {
+ $data['private'] = (bool) $status->private;
+ }
+
+ if ( in_array( 'protected', $fields, true ) ) {
+ $data['protected'] = (bool) $status->protected;
+ }
+
+ if ( in_array( 'public', $fields, true ) ) {
+ $data['public'] = (bool) $status->public;
+ }
+
+ if ( in_array( 'queryable', $fields, true ) ) {
+ $data['queryable'] = (bool) $status->publicly_queryable;
+ }
+
+ if ( in_array( 'show_in_list', $fields, true ) ) {
+ $data['show_in_list'] = (bool) $status->show_in_admin_all_list;
+ }
+
+ if ( in_array( 'slug', $fields, true ) ) {
+ $data['slug'] = $status->name;
+ }
+
+ if ( in_array( 'date_floating', $fields, true ) ) {
+ $data['date_floating'] = $status->date_floating;
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $response = rest_ensure_response( $data );
+
+ $rest_url = rest_url( rest_get_route_for_post_type_items( 'post' ) );
+ if ( 'publish' === $status->name ) {
+ $response->add_link( 'archives', $rest_url );
+ } else {
+ $response->add_link( 'archives', add_query_arg( 'status', $status->name, $rest_url ) );
+ }
+
+ /**
+ * Filters a post status returned from the REST API.
+ *
+ * Allows modification of the status data right before it is returned.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param object $status The original post status object.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ return apply_filters( 'rest_prepare_status', $response, $status, $request );
+ }
+
+ /**
+ * Retrieves the post status' schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'status',
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array(
+ 'description' => __( 'The title for the status.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'private' => array(
+ 'description' => __( 'Whether posts with this status should be private.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'protected' => array(
+ 'description' => __( 'Whether posts with this status should be protected.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'public' => array(
+ 'description' => __( 'Whether posts of this status should be shown in the front end of the site.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'queryable' => array(
+ 'description' => __( 'Whether posts with this status should be publicly-queryable.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'show_in_list' => array(
+ 'description' => __( 'Whether to include posts in the edit listing for their post type.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'slug' => array(
+ 'description' => __( 'An alphanumeric identifier for the status.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'date_floating' => array(
+ 'description' => __( 'Whether posts of this status may have floating published dates.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * @since 4.7.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ return array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-post-types-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-post-types-controller.php
new file mode 100644
index 0000000..0d3d0e1
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-post-types-controller.php
@@ -0,0 +1,430 @@
+<?php
+/**
+ * REST API: WP_REST_Post_Types_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class to access post types via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Post_Types_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructor.
+ *
+ * @since 4.7.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'types';
+ }
+
+ /**
+ * Registers the routes for post types.
+ *
+ * @since 4.7.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<type>[\w-]+)',
+ array(
+ 'args' => array(
+ 'type' => array(
+ 'description' => __( 'An alphanumeric identifier for the post type.' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to read types.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( 'edit' === $request['context'] ) {
+ $types = get_post_types( array( 'show_in_rest' => true ), 'objects' );
+
+ foreach ( $types as $type ) {
+ if ( current_user_can( $type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view',
+ __( 'Sorry, you are not allowed to edit posts in this post type.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves all public post types.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $data = array();
+ $types = get_post_types( array( 'show_in_rest' => true ), 'objects' );
+
+ foreach ( $types as $type ) {
+ if ( 'edit' === $request['context'] && ! current_user_can( $type->cap->edit_posts ) ) {
+ continue;
+ }
+
+ $post_type = $this->prepare_item_for_response( $type, $request );
+ $data[ $type->name ] = $this->prepare_response_for_collection( $post_type );
+ }
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Retrieves a specific post type.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $obj = get_post_type_object( $request['type'] );
+
+ if ( empty( $obj ) ) {
+ return new WP_Error(
+ 'rest_type_invalid',
+ __( 'Invalid post type.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ if ( empty( $obj->show_in_rest ) ) {
+ return new WP_Error(
+ 'rest_cannot_read_type',
+ __( 'Cannot view post type.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( 'edit' === $request['context'] && ! current_user_can( $obj->cap->edit_posts ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to edit posts in this post type.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $data = $this->prepare_item_for_response( $obj, $request );
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Prepares a post type object for serialization.
+ *
+ * @since 4.7.0
+ * @since 5.9.0 Renamed `$post_type` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param WP_Post_Type $item Post type object.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $post_type = $item;
+
+ $taxonomies = wp_list_filter( get_object_taxonomies( $post_type->name, 'objects' ), array( 'show_in_rest' => true ) );
+ $taxonomies = wp_list_pluck( $taxonomies, 'name' );
+ $base = ! empty( $post_type->rest_base ) ? $post_type->rest_base : $post_type->name;
+ $namespace = ! empty( $post_type->rest_namespace ) ? $post_type->rest_namespace : 'wp/v2';
+ $supports = get_all_post_type_supports( $post_type->name );
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( rest_is_field_included( 'capabilities', $fields ) ) {
+ $data['capabilities'] = $post_type->cap;
+ }
+
+ if ( rest_is_field_included( 'description', $fields ) ) {
+ $data['description'] = $post_type->description;
+ }
+
+ if ( rest_is_field_included( 'hierarchical', $fields ) ) {
+ $data['hierarchical'] = $post_type->hierarchical;
+ }
+
+ if ( rest_is_field_included( 'has_archive', $fields ) ) {
+ $data['has_archive'] = $post_type->has_archive;
+ }
+
+ if ( rest_is_field_included( 'visibility', $fields ) ) {
+ $data['visibility'] = array(
+ 'show_in_nav_menus' => (bool) $post_type->show_in_nav_menus,
+ 'show_ui' => (bool) $post_type->show_ui,
+ );
+ }
+
+ if ( rest_is_field_included( 'viewable', $fields ) ) {
+ $data['viewable'] = is_post_type_viewable( $post_type );
+ }
+
+ if ( rest_is_field_included( 'labels', $fields ) ) {
+ $data['labels'] = $post_type->labels;
+ }
+
+ if ( rest_is_field_included( 'name', $fields ) ) {
+ $data['name'] = $post_type->label;
+ }
+
+ if ( rest_is_field_included( 'slug', $fields ) ) {
+ $data['slug'] = $post_type->name;
+ }
+
+ if ( rest_is_field_included( 'icon', $fields ) ) {
+ $data['icon'] = $post_type->menu_icon;
+ }
+
+ if ( rest_is_field_included( 'supports', $fields ) ) {
+ $data['supports'] = $supports;
+ }
+
+ if ( rest_is_field_included( 'taxonomies', $fields ) ) {
+ $data['taxonomies'] = array_values( $taxonomies );
+ }
+
+ if ( rest_is_field_included( 'rest_base', $fields ) ) {
+ $data['rest_base'] = $base;
+ }
+
+ if ( rest_is_field_included( 'rest_namespace', $fields ) ) {
+ $data['rest_namespace'] = $namespace;
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ // Wrap the data in a response object.
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $post_type ) );
+ }
+
+ /**
+ * Filters a post type returned from the REST API.
+ *
+ * Allows modification of the post type data right before it is returned.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Post_Type $post_type The original post type object.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ return apply_filters( 'rest_prepare_post_type', $response, $post_type, $request );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 6.1.0
+ *
+ * @param WP_Post_Type $post_type The post type.
+ * @return array Links for the given post type.
+ */
+ protected function prepare_links( $post_type ) {
+ return array(
+ 'collection' => array(
+ 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+ ),
+ 'https://api.w.org/items' => array(
+ 'href' => rest_url( rest_get_route_for_post_type_items( $post_type->name ) ),
+ ),
+ );
+ }
+
+ /**
+ * Retrieves the post type's schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ * @since 4.8.0 The `supports` property was added.
+ * @since 5.9.0 The `visibility` and `rest_namespace` properties were added.
+ * @since 6.1.0 The `icon` property was added.
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'type',
+ 'type' => 'object',
+ 'properties' => array(
+ 'capabilities' => array(
+ 'description' => __( 'All capabilities used by the post type.' ),
+ 'type' => 'object',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'description' => __( 'A human-readable description of the post type.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'hierarchical' => array(
+ 'description' => __( 'Whether or not the post type should have children.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'viewable' => array(
+ 'description' => __( 'Whether or not the post type can be viewed.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'labels' => array(
+ 'description' => __( 'Human-readable labels for the post type for various contexts.' ),
+ 'type' => 'object',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'The title for the post type.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'slug' => array(
+ 'description' => __( 'An alphanumeric identifier for the post type.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'supports' => array(
+ 'description' => __( 'All features, supported by the post type.' ),
+ 'type' => 'object',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'has_archive' => array(
+ 'description' => __( 'If the value is a string, the value will be used as the archive slug. If the value is false the post type has no archive.' ),
+ 'type' => array( 'string', 'boolean' ),
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'taxonomies' => array(
+ 'description' => __( 'Taxonomies associated with post type.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'rest_base' => array(
+ 'description' => __( 'REST base route for the post type.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'rest_namespace' => array(
+ 'description' => __( 'REST route\'s namespace for the post type.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'visibility' => array(
+ 'description' => __( 'The visibility settings for the post type.' ),
+ 'type' => 'object',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ 'properties' => array(
+ 'show_ui' => array(
+ 'description' => __( 'Whether to generate a default UI for managing this post type.' ),
+ 'type' => 'boolean',
+ ),
+ 'show_in_nav_menus' => array(
+ 'description' => __( 'Whether to make the post type available for selection in navigation menus.' ),
+ 'type' => 'boolean',
+ ),
+ ),
+ ),
+ 'icon' => array(
+ 'description' => __( 'The icon for the post type.' ),
+ 'type' => array( 'string', 'null' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * @since 4.7.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ return array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php
new file mode 100644
index 0000000..abfef4e
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php
@@ -0,0 +1,3173 @@
+<?php
+/**
+ * REST API: WP_REST_Posts_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class to access posts via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Posts_Controller extends WP_REST_Controller {
+ /**
+ * Post type.
+ *
+ * @since 4.7.0
+ * @var string
+ */
+ protected $post_type;
+
+ /**
+ * Instance of a post meta fields object.
+ *
+ * @since 4.7.0
+ * @var WP_REST_Post_Meta_Fields
+ */
+ protected $meta;
+
+ /**
+ * Passwordless post access permitted.
+ *
+ * @since 5.7.1
+ * @var int[]
+ */
+ protected $password_check_passed = array();
+
+ /**
+ * Whether the controller supports batching.
+ *
+ * @since 5.9.0
+ * @var array
+ */
+ protected $allow_batch = array( 'v1' => true );
+
+ /**
+ * Constructor.
+ *
+ * @since 4.7.0
+ *
+ * @param string $post_type Post type.
+ */
+ public function __construct( $post_type ) {
+ $this->post_type = $post_type;
+ $obj = get_post_type_object( $post_type );
+ $this->rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name;
+ $this->namespace = ! empty( $obj->rest_namespace ) ? $obj->rest_namespace : 'wp/v2';
+
+ $this->meta = new WP_REST_Post_Meta_Fields( $this->post_type );
+ }
+
+ /**
+ * Registers the routes for posts.
+ *
+ * @since 4.7.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
+ ),
+ 'allow_batch' => $this->allow_batch,
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ $schema = $this->get_item_schema();
+ $get_item_args = array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ );
+ if ( isset( $schema['properties']['password'] ) ) {
+ $get_item_args['password'] = array(
+ 'description' => __( 'The password for the post if it is password protected.' ),
+ 'type' => 'string',
+ );
+ }
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[\d]+)',
+ array(
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the post.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => $get_item_args,
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'permission_callback' => array( $this, 'update_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+ 'args' => array(
+ 'force' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ 'description' => __( 'Whether to bypass Trash and force deletion.' ),
+ ),
+ ),
+ ),
+ 'allow_batch' => $this->allow_batch,
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to read posts.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+
+ $post_type = get_post_type_object( $this->post_type );
+
+ if ( 'edit' === $request['context'] && ! current_user_can( $post_type->cap->edit_posts ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to edit posts in this post type.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Overrides the result of the post password check for REST requested posts.
+ *
+ * Allow users to read the content of password protected posts if they have
+ * previously passed a permission check or if they have the `edit_post` capability
+ * for the post being checked.
+ *
+ * @since 5.7.1
+ *
+ * @param bool $required Whether the post requires a password check.
+ * @param WP_Post $post The post been password checked.
+ * @return bool Result of password check taking in to account REST API considerations.
+ */
+ public function check_password_required( $required, $post ) {
+ if ( ! $required ) {
+ return $required;
+ }
+
+ $post = get_post( $post );
+
+ if ( ! $post ) {
+ return $required;
+ }
+
+ if ( ! empty( $this->password_check_passed[ $post->ID ] ) ) {
+ // Password previously checked and approved.
+ return false;
+ }
+
+ return ! current_user_can( 'edit_post', $post->ID );
+ }
+
+ /**
+ * Retrieves a collection of posts.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+
+ // Ensure a search string is set in case the orderby is set to 'relevance'.
+ if ( ! empty( $request['orderby'] ) && 'relevance' === $request['orderby'] && empty( $request['search'] ) ) {
+ return new WP_Error(
+ 'rest_no_search_term_defined',
+ __( 'You need to define a search term to order by relevance.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Ensure an include parameter is set in case the orderby is set to 'include'.
+ if ( ! empty( $request['orderby'] ) && 'include' === $request['orderby'] && empty( $request['include'] ) ) {
+ return new WP_Error(
+ 'rest_orderby_include_missing_include',
+ __( 'You need to define an include parameter to order by include.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Retrieve the list of registered collection query parameters.
+ $registered = $this->get_collection_params();
+ $args = array();
+
+ /*
+ * This array defines mappings between public API query parameters whose
+ * values are accepted as-passed, and their internal WP_Query parameter
+ * name equivalents (some are the same). Only values which are also
+ * present in $registered will be set.
+ */
+ $parameter_mappings = array(
+ 'author' => 'author__in',
+ 'author_exclude' => 'author__not_in',
+ 'exclude' => 'post__not_in',
+ 'include' => 'post__in',
+ 'menu_order' => 'menu_order',
+ 'offset' => 'offset',
+ 'order' => 'order',
+ 'orderby' => 'orderby',
+ 'page' => 'paged',
+ 'parent' => 'post_parent__in',
+ 'parent_exclude' => 'post_parent__not_in',
+ 'search' => 's',
+ 'search_columns' => 'search_columns',
+ 'slug' => 'post_name__in',
+ 'status' => 'post_status',
+ );
+
+ /*
+ * For each known parameter which is both registered and present in the request,
+ * set the parameter's value on the query $args.
+ */
+ foreach ( $parameter_mappings as $api_param => $wp_param ) {
+ if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
+ $args[ $wp_param ] = $request[ $api_param ];
+ }
+ }
+
+ // Check for & assign any parameters which require special handling or setting.
+ $args['date_query'] = array();
+
+ if ( isset( $registered['before'], $request['before'] ) ) {
+ $args['date_query'][] = array(
+ 'before' => $request['before'],
+ 'column' => 'post_date',
+ );
+ }
+
+ if ( isset( $registered['modified_before'], $request['modified_before'] ) ) {
+ $args['date_query'][] = array(
+ 'before' => $request['modified_before'],
+ 'column' => 'post_modified',
+ );
+ }
+
+ if ( isset( $registered['after'], $request['after'] ) ) {
+ $args['date_query'][] = array(
+ 'after' => $request['after'],
+ 'column' => 'post_date',
+ );
+ }
+
+ if ( isset( $registered['modified_after'], $request['modified_after'] ) ) {
+ $args['date_query'][] = array(
+ 'after' => $request['modified_after'],
+ 'column' => 'post_modified',
+ );
+ }
+
+ // Ensure our per_page parameter overrides any provided posts_per_page filter.
+ if ( isset( $registered['per_page'] ) ) {
+ $args['posts_per_page'] = $request['per_page'];
+ }
+
+ if ( isset( $registered['sticky'], $request['sticky'] ) ) {
+ $sticky_posts = get_option( 'sticky_posts', array() );
+ if ( ! is_array( $sticky_posts ) ) {
+ $sticky_posts = array();
+ }
+ if ( $request['sticky'] ) {
+ /*
+ * As post__in will be used to only get sticky posts,
+ * we have to support the case where post__in was already
+ * specified.
+ */
+ $args['post__in'] = $args['post__in'] ? array_intersect( $sticky_posts, $args['post__in'] ) : $sticky_posts;
+
+ /*
+ * If we intersected, but there are no post IDs in common,
+ * WP_Query won't return "no posts" for post__in = array()
+ * so we have to fake it a bit.
+ */
+ if ( ! $args['post__in'] ) {
+ $args['post__in'] = array( 0 );
+ }
+ } elseif ( $sticky_posts ) {
+ /*
+ * As post___not_in will be used to only get posts that
+ * are not sticky, we have to support the case where post__not_in
+ * was already specified.
+ */
+ $args['post__not_in'] = array_merge( $args['post__not_in'], $sticky_posts );
+ }
+ }
+
+ $args = $this->prepare_tax_query( $args, $request );
+
+ // Force the post_type argument, since it's not a user input variable.
+ $args['post_type'] = $this->post_type;
+
+ /**
+ * Filters WP_Query arguments when querying posts via the REST API.
+ *
+ * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_post_query`
+ * - `rest_page_query`
+ * - `rest_attachment_query`
+ *
+ * Enables adding extra arguments or setting defaults for a post collection request.
+ *
+ * @since 4.7.0
+ * @since 5.7.0 Moved after the `tax_query` query arg is generated.
+ *
+ * @link https://developer.wordpress.org/reference/classes/wp_query/
+ *
+ * @param array $args Array of arguments for WP_Query.
+ * @param WP_REST_Request $request The REST API request.
+ */
+ $args = apply_filters( "rest_{$this->post_type}_query", $args, $request );
+ $query_args = $this->prepare_items_query( $args, $request );
+
+ $posts_query = new WP_Query();
+ $query_result = $posts_query->query( $query_args );
+
+ // Allow access to all password protected posts if the context is edit.
+ if ( 'edit' === $request['context'] ) {
+ add_filter( 'post_password_required', array( $this, 'check_password_required' ), 10, 2 );
+ }
+
+ $posts = array();
+
+ update_post_author_caches( $query_result );
+ update_post_parent_caches( $query_result );
+
+ if ( post_type_supports( $this->post_type, 'thumbnail' ) ) {
+ update_post_thumbnail_cache( $posts_query );
+ }
+
+ foreach ( $query_result as $post ) {
+ if ( ! $this->check_read_permission( $post ) ) {
+ continue;
+ }
+
+ $data = $this->prepare_item_for_response( $post, $request );
+ $posts[] = $this->prepare_response_for_collection( $data );
+ }
+
+ // Reset filter.
+ if ( 'edit' === $request['context'] ) {
+ remove_filter( 'post_password_required', array( $this, 'check_password_required' ) );
+ }
+
+ $page = (int) $query_args['paged'];
+ $total_posts = $posts_query->found_posts;
+
+ if ( $total_posts < 1 && $page > 1 ) {
+ // Out-of-bounds, run the query again without LIMIT for total count.
+ unset( $query_args['paged'] );
+
+ $count_query = new WP_Query();
+ $count_query->query( $query_args );
+ $total_posts = $count_query->found_posts;
+ }
+
+ $max_pages = ceil( $total_posts / (int) $posts_query->query_vars['posts_per_page'] );
+
+ if ( $page > $max_pages && $total_posts > 0 ) {
+ return new WP_Error(
+ 'rest_post_invalid_page_number',
+ __( 'The page number requested is larger than the number of pages available.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $response = rest_ensure_response( $posts );
+
+ $response->header( 'X-WP-Total', (int) $total_posts );
+ $response->header( 'X-WP-TotalPages', (int) $max_pages );
+
+ $request_params = $request->get_query_params();
+ $collection_url = rest_url( rest_get_route_for_post_type_items( $this->post_type ) );
+ $base = add_query_arg( urlencode_deep( $request_params ), $collection_url );
+
+ if ( $page > 1 ) {
+ $prev_page = $page - 1;
+
+ if ( $prev_page > $max_pages ) {
+ $prev_page = $max_pages;
+ }
+
+ $prev_link = add_query_arg( 'page', $prev_page, $base );
+ $response->link_header( 'prev', $prev_link );
+ }
+ if ( $max_pages > $page ) {
+ $next_page = $page + 1;
+ $next_link = add_query_arg( 'page', $next_page, $base );
+
+ $response->link_header( 'next', $next_link );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Gets the post, if the ID is valid.
+ *
+ * @since 4.7.2
+ *
+ * @param int $id Supplied ID.
+ * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_post( $id ) {
+ $error = new WP_Error(
+ 'rest_post_invalid_id',
+ __( 'Invalid post ID.' ),
+ array( 'status' => 404 )
+ );
+
+ if ( (int) $id <= 0 ) {
+ return $error;
+ }
+
+ $post = get_post( (int) $id );
+ if ( empty( $post ) || empty( $post->ID ) || $this->post_type !== $post->post_type ) {
+ return $error;
+ }
+
+ return $post;
+ }
+
+ /**
+ * Checks if a given request has access to read a post.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return bool|WP_Error True if the request has read access for the item, WP_Error object or false otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $post = $this->get_post( $request['id'] );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ if ( 'edit' === $request['context'] && $post && ! $this->check_update_permission( $post ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to edit this post.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( $post && ! empty( $request['password'] ) ) {
+ // Check post password, and return error if invalid.
+ if ( ! hash_equals( $post->post_password, $request['password'] ) ) {
+ return new WP_Error(
+ 'rest_post_incorrect_password',
+ __( 'Incorrect post password.' ),
+ array( 'status' => 403 )
+ );
+ }
+ }
+
+ // Allow access to all password protected posts if the context is edit.
+ if ( 'edit' === $request['context'] ) {
+ add_filter( 'post_password_required', array( $this, 'check_password_required' ), 10, 2 );
+ }
+
+ if ( $post ) {
+ return $this->check_read_permission( $post );
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if the user can access password-protected content.
+ *
+ * This method determines whether we need to override the regular password
+ * check in core with a filter.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post $post Post to check against.
+ * @param WP_REST_Request $request Request data to check.
+ * @return bool True if the user can access password-protected content, otherwise false.
+ */
+ public function can_access_password_content( $post, $request ) {
+ if ( empty( $post->post_password ) ) {
+ // No filter required.
+ return false;
+ }
+
+ /*
+ * Users always gets access to password protected content in the edit
+ * context if they have the `edit_post` meta capability.
+ */
+ if (
+ 'edit' === $request['context'] &&
+ current_user_can( 'edit_post', $post->ID )
+ ) {
+ return true;
+ }
+
+ // No password, no auth.
+ if ( empty( $request['password'] ) ) {
+ return false;
+ }
+
+ // Double-check the request password.
+ return hash_equals( $post->post_password, $request['password'] );
+ }
+
+ /**
+ * Retrieves a single post.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $post = $this->get_post( $request['id'] );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ $data = $this->prepare_item_for_response( $post, $request );
+ $response = rest_ensure_response( $data );
+
+ if ( is_post_type_viewable( get_post_type_object( $post->post_type ) ) ) {
+ $response->link_header( 'alternate', get_permalink( $post->ID ), array( 'type' => 'text/html' ) );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to create a post.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
+ */
+ public function create_item_permissions_check( $request ) {
+ if ( ! empty( $request['id'] ) ) {
+ return new WP_Error(
+ 'rest_post_exists',
+ __( 'Cannot create existing post.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $post_type = get_post_type_object( $this->post_type );
+
+ if ( ! empty( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( $post_type->cap->edit_others_posts ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit_others',
+ __( 'Sorry, you are not allowed to create posts as this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! empty( $request['sticky'] ) && ! current_user_can( $post_type->cap->edit_others_posts ) && ! current_user_can( $post_type->cap->publish_posts ) ) {
+ return new WP_Error(
+ 'rest_cannot_assign_sticky',
+ __( 'Sorry, you are not allowed to make posts sticky.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! current_user_can( $post_type->cap->create_posts ) ) {
+ return new WP_Error(
+ 'rest_cannot_create',
+ __( 'Sorry, you are not allowed to create posts as this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! $this->check_assign_terms_permission( $request ) ) {
+ return new WP_Error(
+ 'rest_cannot_assign_term',
+ __( 'Sorry, you are not allowed to assign the provided terms.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Creates a single post.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+ if ( ! empty( $request['id'] ) ) {
+ return new WP_Error(
+ 'rest_post_exists',
+ __( 'Cannot create existing post.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $prepared_post = $this->prepare_item_for_database( $request );
+
+ if ( is_wp_error( $prepared_post ) ) {
+ return $prepared_post;
+ }
+
+ $prepared_post->post_type = $this->post_type;
+
+ if ( ! empty( $prepared_post->post_name )
+ && ! empty( $prepared_post->post_status )
+ && in_array( $prepared_post->post_status, array( 'draft', 'pending' ), true )
+ ) {
+ /*
+ * `wp_unique_post_slug()` returns the same slug for 'draft' or 'pending' posts.
+ *
+ * To ensure that a unique slug is generated, pass the post data with the 'publish' status.
+ */
+ $prepared_post->post_name = wp_unique_post_slug(
+ $prepared_post->post_name,
+ $prepared_post->id,
+ 'publish',
+ $prepared_post->post_type,
+ $prepared_post->post_parent
+ );
+ }
+
+ $post_id = wp_insert_post( wp_slash( (array) $prepared_post ), true, false );
+
+ if ( is_wp_error( $post_id ) ) {
+
+ if ( 'db_insert_error' === $post_id->get_error_code() ) {
+ $post_id->add_data( array( 'status' => 500 ) );
+ } else {
+ $post_id->add_data( array( 'status' => 400 ) );
+ }
+
+ return $post_id;
+ }
+
+ $post = get_post( $post_id );
+
+ /**
+ * Fires after a single post is created or updated via the REST API.
+ *
+ * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_insert_post`
+ * - `rest_insert_page`
+ * - `rest_insert_attachment`
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post $post Inserted or updated post object.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating a post, false when updating.
+ */
+ do_action( "rest_insert_{$this->post_type}", $post, $request, true );
+
+ $schema = $this->get_item_schema();
+
+ if ( ! empty( $schema['properties']['sticky'] ) ) {
+ if ( ! empty( $request['sticky'] ) ) {
+ stick_post( $post_id );
+ } else {
+ unstick_post( $post_id );
+ }
+ }
+
+ if ( ! empty( $schema['properties']['featured_media'] ) && isset( $request['featured_media'] ) ) {
+ $this->handle_featured_media( $request['featured_media'], $post_id );
+ }
+
+ if ( ! empty( $schema['properties']['format'] ) && ! empty( $request['format'] ) ) {
+ set_post_format( $post, $request['format'] );
+ }
+
+ if ( ! empty( $schema['properties']['template'] ) && isset( $request['template'] ) ) {
+ $this->handle_template( $request['template'], $post_id, true );
+ }
+
+ $terms_update = $this->handle_terms( $post_id, $request );
+
+ if ( is_wp_error( $terms_update ) ) {
+ return $terms_update;
+ }
+
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $post_id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $post = get_post( $post_id );
+ $fields_update = $this->update_additional_fields_for_object( $post, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ /**
+ * Fires after a single post is completely created or updated via the REST API.
+ *
+ * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_after_insert_post`
+ * - `rest_after_insert_page`
+ * - `rest_after_insert_attachment`
+ *
+ * @since 5.0.0
+ *
+ * @param WP_Post $post Inserted or updated post object.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating a post, false when updating.
+ */
+ do_action( "rest_after_insert_{$this->post_type}", $post, $request, true );
+
+ wp_after_insert_post( $post, false, null );
+
+ $response = $this->prepare_item_for_response( $post, $request );
+ $response = rest_ensure_response( $response );
+
+ $response->set_status( 201 );
+ $response->header( 'Location', rest_url( rest_get_route_for_post( $post ) ) );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to update a post.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
+ */
+ public function update_item_permissions_check( $request ) {
+ $post = $this->get_post( $request['id'] );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ $post_type = get_post_type_object( $this->post_type );
+
+ if ( $post && ! $this->check_update_permission( $post ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit',
+ __( 'Sorry, you are not allowed to edit this post.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! empty( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( $post_type->cap->edit_others_posts ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit_others',
+ __( 'Sorry, you are not allowed to update posts as this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! empty( $request['sticky'] ) && ! current_user_can( $post_type->cap->edit_others_posts ) && ! current_user_can( $post_type->cap->publish_posts ) ) {
+ return new WP_Error(
+ 'rest_cannot_assign_sticky',
+ __( 'Sorry, you are not allowed to make posts sticky.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! $this->check_assign_terms_permission( $request ) ) {
+ return new WP_Error(
+ 'rest_cannot_assign_term',
+ __( 'Sorry, you are not allowed to assign the provided terms.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Updates a single post.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ $valid_check = $this->get_post( $request['id'] );
+ if ( is_wp_error( $valid_check ) ) {
+ return $valid_check;
+ }
+
+ $post_before = get_post( $request['id'] );
+ $post = $this->prepare_item_for_database( $request );
+
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ if ( ! empty( $post->post_status ) ) {
+ $post_status = $post->post_status;
+ } else {
+ $post_status = $post_before->post_status;
+ }
+
+ /*
+ * `wp_unique_post_slug()` returns the same slug for 'draft' or 'pending' posts.
+ *
+ * To ensure that a unique slug is generated, pass the post data with the 'publish' status.
+ */
+ if ( ! empty( $post->post_name ) && in_array( $post_status, array( 'draft', 'pending' ), true ) ) {
+ $post_parent = ! empty( $post->post_parent ) ? $post->post_parent : 0;
+ $post->post_name = wp_unique_post_slug(
+ $post->post_name,
+ $post->ID,
+ 'publish',
+ $post->post_type,
+ $post_parent
+ );
+ }
+
+ // Convert the post object to an array, otherwise wp_update_post() will expect non-escaped input.
+ $post_id = wp_update_post( wp_slash( (array) $post ), true, false );
+
+ if ( is_wp_error( $post_id ) ) {
+ if ( 'db_update_error' === $post_id->get_error_code() ) {
+ $post_id->add_data( array( 'status' => 500 ) );
+ } else {
+ $post_id->add_data( array( 'status' => 400 ) );
+ }
+ return $post_id;
+ }
+
+ $post = get_post( $post_id );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */
+ do_action( "rest_insert_{$this->post_type}", $post, $request, false );
+
+ $schema = $this->get_item_schema();
+
+ if ( ! empty( $schema['properties']['format'] ) && ! empty( $request['format'] ) ) {
+ set_post_format( $post, $request['format'] );
+ }
+
+ if ( ! empty( $schema['properties']['featured_media'] ) && isset( $request['featured_media'] ) ) {
+ $this->handle_featured_media( $request['featured_media'], $post_id );
+ }
+
+ if ( ! empty( $schema['properties']['sticky'] ) && isset( $request['sticky'] ) ) {
+ if ( ! empty( $request['sticky'] ) ) {
+ stick_post( $post_id );
+ } else {
+ unstick_post( $post_id );
+ }
+ }
+
+ if ( ! empty( $schema['properties']['template'] ) && isset( $request['template'] ) ) {
+ $this->handle_template( $request['template'], $post->ID );
+ }
+
+ $terms_update = $this->handle_terms( $post->ID, $request );
+
+ if ( is_wp_error( $terms_update ) ) {
+ return $terms_update;
+ }
+
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $post->ID );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $post = get_post( $post_id );
+ $fields_update = $this->update_additional_fields_for_object( $post, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ // Filter is fired in WP_REST_Attachments_Controller subclass.
+ if ( 'attachment' === $this->post_type ) {
+ $response = $this->prepare_item_for_response( $post, $request );
+ return rest_ensure_response( $response );
+ }
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */
+ do_action( "rest_after_insert_{$this->post_type}", $post, $request, false );
+
+ wp_after_insert_post( $post, true, $post_before );
+
+ $response = $this->prepare_item_for_response( $post, $request );
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Checks if a given request has access to delete a post.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise.
+ */
+ public function delete_item_permissions_check( $request ) {
+ $post = $this->get_post( $request['id'] );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ if ( $post && ! $this->check_delete_permission( $post ) ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'Sorry, you are not allowed to delete this post.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Deletes a single post.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_item( $request ) {
+ $post = $this->get_post( $request['id'] );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ $id = $post->ID;
+ $force = (bool) $request['force'];
+
+ $supports_trash = ( EMPTY_TRASH_DAYS > 0 );
+
+ if ( 'attachment' === $post->post_type ) {
+ $supports_trash = $supports_trash && MEDIA_TRASH;
+ }
+
+ /**
+ * Filters whether a post is trashable.
+ *
+ * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_post_trashable`
+ * - `rest_page_trashable`
+ * - `rest_attachment_trashable`
+ *
+ * Pass false to disable Trash support for the post.
+ *
+ * @since 4.7.0
+ *
+ * @param bool $supports_trash Whether the post type support trashing.
+ * @param WP_Post $post The Post object being considered for trashing support.
+ */
+ $supports_trash = apply_filters( "rest_{$this->post_type}_trashable", $supports_trash, $post );
+
+ if ( ! $this->check_delete_permission( $post ) ) {
+ return new WP_Error(
+ 'rest_user_cannot_delete_post',
+ __( 'Sorry, you are not allowed to delete this post.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ // If we're forcing, then delete permanently.
+ if ( $force ) {
+ $previous = $this->prepare_item_for_response( $post, $request );
+ $result = wp_delete_post( $id, true );
+ $response = new WP_REST_Response();
+ $response->set_data(
+ array(
+ 'deleted' => true,
+ 'previous' => $previous->get_data(),
+ )
+ );
+ } else {
+ // If we don't support trashing for this type, error out.
+ if ( ! $supports_trash ) {
+ return new WP_Error(
+ 'rest_trash_not_supported',
+ /* translators: %s: force=true */
+ sprintf( __( "The post does not support trashing. Set '%s' to delete." ), 'force=true' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ // Otherwise, only trash if we haven't already.
+ if ( 'trash' === $post->post_status ) {
+ return new WP_Error(
+ 'rest_already_trashed',
+ __( 'The post has already been deleted.' ),
+ array( 'status' => 410 )
+ );
+ }
+
+ /*
+ * (Note that internally this falls through to `wp_delete_post()`
+ * if the Trash is disabled.)
+ */
+ $result = wp_trash_post( $id );
+ $post = get_post( $id );
+ $response = $this->prepare_item_for_response( $post, $request );
+ }
+
+ if ( ! $result ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'The post cannot be deleted.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ /**
+ * Fires immediately after a single post is deleted or trashed via the REST API.
+ *
+ * They dynamic portion of the hook name, `$this->post_type`, refers to the post type slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_delete_post`
+ * - `rest_delete_page`
+ * - `rest_delete_attachment`
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post $post The deleted or trashed post.
+ * @param WP_REST_Response $response The response data.
+ * @param WP_REST_Request $request The request sent to the API.
+ */
+ do_action( "rest_delete_{$this->post_type}", $post, $response, $request );
+
+ return $response;
+ }
+
+ /**
+ * Determines the allowed query_vars for a get_items() response and prepares
+ * them for WP_Query.
+ *
+ * @since 4.7.0
+ *
+ * @param array $prepared_args Optional. Prepared WP_Query arguments. Default empty array.
+ * @param WP_REST_Request $request Optional. Full details about the request.
+ * @return array Items query arguments.
+ */
+ protected function prepare_items_query( $prepared_args = array(), $request = null ) {
+ $query_args = array();
+
+ foreach ( $prepared_args as $key => $value ) {
+ /**
+ * Filters the query_vars used in get_items() for the constructed query.
+ *
+ * The dynamic portion of the hook name, `$key`, refers to the query_var key.
+ *
+ * @since 4.7.0
+ *
+ * @param string $value The query_var value.
+ */
+ $query_args[ $key ] = apply_filters( "rest_query_var-{$key}", $value ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
+ }
+
+ if ( 'post' !== $this->post_type || ! isset( $query_args['ignore_sticky_posts'] ) ) {
+ $query_args['ignore_sticky_posts'] = true;
+ }
+
+ // Map to proper WP_Query orderby param.
+ if ( isset( $query_args['orderby'] ) && isset( $request['orderby'] ) ) {
+ $orderby_mappings = array(
+ 'id' => 'ID',
+ 'include' => 'post__in',
+ 'slug' => 'post_name',
+ 'include_slugs' => 'post_name__in',
+ );
+
+ if ( isset( $orderby_mappings[ $request['orderby'] ] ) ) {
+ $query_args['orderby'] = $orderby_mappings[ $request['orderby'] ];
+ }
+ }
+
+ return $query_args;
+ }
+
+ /**
+ * Checks the post_date_gmt or modified_gmt and prepare any post or
+ * modified date for single post output.
+ *
+ * @since 4.7.0
+ *
+ * @param string $date_gmt GMT publication time.
+ * @param string|null $date Optional. Local publication time. Default null.
+ * @return string|null ISO8601/RFC3339 formatted datetime.
+ */
+ protected function prepare_date_response( $date_gmt, $date = null ) {
+ // Use the date if passed.
+ if ( isset( $date ) ) {
+ return mysql_to_rfc3339( $date );
+ }
+
+ // Return null if $date_gmt is empty/zeros.
+ if ( '0000-00-00 00:00:00' === $date_gmt ) {
+ return null;
+ }
+
+ // Return the formatted datetime.
+ return mysql_to_rfc3339( $date_gmt );
+ }
+
+ /**
+ * Prepares a single post for create or update.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return stdClass|WP_Error Post object or WP_Error.
+ */
+ protected function prepare_item_for_database( $request ) {
+ $prepared_post = new stdClass();
+ $current_status = '';
+
+ // Post ID.
+ if ( isset( $request['id'] ) ) {
+ $existing_post = $this->get_post( $request['id'] );
+ if ( is_wp_error( $existing_post ) ) {
+ return $existing_post;
+ }
+
+ $prepared_post->ID = $existing_post->ID;
+ $current_status = $existing_post->post_status;
+ }
+
+ $schema = $this->get_item_schema();
+
+ // Post title.
+ if ( ! empty( $schema['properties']['title'] ) && isset( $request['title'] ) ) {
+ if ( is_string( $request['title'] ) ) {
+ $prepared_post->post_title = $request['title'];
+ } elseif ( ! empty( $request['title']['raw'] ) ) {
+ $prepared_post->post_title = $request['title']['raw'];
+ }
+ }
+
+ // Post content.
+ if ( ! empty( $schema['properties']['content'] ) && isset( $request['content'] ) ) {
+ if ( is_string( $request['content'] ) ) {
+ $prepared_post->post_content = $request['content'];
+ } elseif ( isset( $request['content']['raw'] ) ) {
+ $prepared_post->post_content = $request['content']['raw'];
+ }
+ }
+
+ // Post excerpt.
+ if ( ! empty( $schema['properties']['excerpt'] ) && isset( $request['excerpt'] ) ) {
+ if ( is_string( $request['excerpt'] ) ) {
+ $prepared_post->post_excerpt = $request['excerpt'];
+ } elseif ( isset( $request['excerpt']['raw'] ) ) {
+ $prepared_post->post_excerpt = $request['excerpt']['raw'];
+ }
+ }
+
+ // Post type.
+ if ( empty( $request['id'] ) ) {
+ // Creating new post, use default type for the controller.
+ $prepared_post->post_type = $this->post_type;
+ } else {
+ // Updating a post, use previous type.
+ $prepared_post->post_type = get_post_type( $request['id'] );
+ }
+
+ $post_type = get_post_type_object( $prepared_post->post_type );
+
+ // Post status.
+ if (
+ ! empty( $schema['properties']['status'] ) &&
+ isset( $request['status'] ) &&
+ ( ! $current_status || $current_status !== $request['status'] )
+ ) {
+ $status = $this->handle_status_param( $request['status'], $post_type );
+
+ if ( is_wp_error( $status ) ) {
+ return $status;
+ }
+
+ $prepared_post->post_status = $status;
+ }
+
+ // Post date.
+ if ( ! empty( $schema['properties']['date'] ) && ! empty( $request['date'] ) ) {
+ $current_date = isset( $prepared_post->ID ) ? get_post( $prepared_post->ID )->post_date : false;
+ $date_data = rest_get_date_with_gmt( $request['date'] );
+
+ if ( ! empty( $date_data ) && $current_date !== $date_data[0] ) {
+ list( $prepared_post->post_date, $prepared_post->post_date_gmt ) = $date_data;
+ $prepared_post->edit_date = true;
+ }
+ } elseif ( ! empty( $schema['properties']['date_gmt'] ) && ! empty( $request['date_gmt'] ) ) {
+ $current_date = isset( $prepared_post->ID ) ? get_post( $prepared_post->ID )->post_date_gmt : false;
+ $date_data = rest_get_date_with_gmt( $request['date_gmt'], true );
+
+ if ( ! empty( $date_data ) && $current_date !== $date_data[1] ) {
+ list( $prepared_post->post_date, $prepared_post->post_date_gmt ) = $date_data;
+ $prepared_post->edit_date = true;
+ }
+ }
+
+ /*
+ * Sending a null date or date_gmt value resets date and date_gmt to their
+ * default values (`0000-00-00 00:00:00`).
+ */
+ if (
+ ( ! empty( $schema['properties']['date_gmt'] ) && $request->has_param( 'date_gmt' ) && null === $request['date_gmt'] ) ||
+ ( ! empty( $schema['properties']['date'] ) && $request->has_param( 'date' ) && null === $request['date'] )
+ ) {
+ $prepared_post->post_date_gmt = null;
+ $prepared_post->post_date = null;
+ }
+
+ // Post slug.
+ if ( ! empty( $schema['properties']['slug'] ) && isset( $request['slug'] ) ) {
+ $prepared_post->post_name = $request['slug'];
+ }
+
+ // Author.
+ if ( ! empty( $schema['properties']['author'] ) && ! empty( $request['author'] ) ) {
+ $post_author = (int) $request['author'];
+
+ if ( get_current_user_id() !== $post_author ) {
+ $user_obj = get_userdata( $post_author );
+
+ if ( ! $user_obj ) {
+ return new WP_Error(
+ 'rest_invalid_author',
+ __( 'Invalid author ID.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ $prepared_post->post_author = $post_author;
+ }
+
+ // Post password.
+ if ( ! empty( $schema['properties']['password'] ) && isset( $request['password'] ) ) {
+ $prepared_post->post_password = $request['password'];
+
+ if ( '' !== $request['password'] ) {
+ if ( ! empty( $schema['properties']['sticky'] ) && ! empty( $request['sticky'] ) ) {
+ return new WP_Error(
+ 'rest_invalid_field',
+ __( 'A post can not be sticky and have a password.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( ! empty( $prepared_post->ID ) && is_sticky( $prepared_post->ID ) ) {
+ return new WP_Error(
+ 'rest_invalid_field',
+ __( 'A sticky post can not be password protected.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+ }
+
+ if ( ! empty( $schema['properties']['sticky'] ) && ! empty( $request['sticky'] ) ) {
+ if ( ! empty( $prepared_post->ID ) && post_password_required( $prepared_post->ID ) ) {
+ return new WP_Error(
+ 'rest_invalid_field',
+ __( 'A password protected post can not be set to sticky.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ // Parent.
+ if ( ! empty( $schema['properties']['parent'] ) && isset( $request['parent'] ) ) {
+ if ( 0 === (int) $request['parent'] ) {
+ $prepared_post->post_parent = 0;
+ } else {
+ $parent = get_post( (int) $request['parent'] );
+
+ if ( empty( $parent ) ) {
+ return new WP_Error(
+ 'rest_post_invalid_id',
+ __( 'Invalid post parent ID.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $prepared_post->post_parent = (int) $parent->ID;
+ }
+ }
+
+ // Menu order.
+ if ( ! empty( $schema['properties']['menu_order'] ) && isset( $request['menu_order'] ) ) {
+ $prepared_post->menu_order = (int) $request['menu_order'];
+ }
+
+ // Comment status.
+ if ( ! empty( $schema['properties']['comment_status'] ) && ! empty( $request['comment_status'] ) ) {
+ $prepared_post->comment_status = $request['comment_status'];
+ }
+
+ // Ping status.
+ if ( ! empty( $schema['properties']['ping_status'] ) && ! empty( $request['ping_status'] ) ) {
+ $prepared_post->ping_status = $request['ping_status'];
+ }
+
+ if ( ! empty( $schema['properties']['template'] ) ) {
+ // Force template to null so that it can be handled exclusively by the REST controller.
+ $prepared_post->page_template = null;
+ }
+
+ /**
+ * Filters a post before it is inserted via the REST API.
+ *
+ * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_pre_insert_post`
+ * - `rest_pre_insert_page`
+ * - `rest_pre_insert_attachment`
+ *
+ * @since 4.7.0
+ *
+ * @param stdClass $prepared_post An object representing a single post prepared
+ * for inserting or updating the database.
+ * @param WP_REST_Request $request Request object.
+ */
+ return apply_filters( "rest_pre_insert_{$this->post_type}", $prepared_post, $request );
+ }
+
+ /**
+ * Checks whether the status is valid for the given post.
+ *
+ * Allows for sending an update request with the current status, even if that status would not be acceptable.
+ *
+ * @since 5.6.0
+ *
+ * @param string $status The provided status.
+ * @param WP_REST_Request $request The request object.
+ * @param string $param The parameter name.
+ * @return true|WP_Error True if the status is valid, or WP_Error if not.
+ */
+ public function check_status( $status, $request, $param ) {
+ if ( $request['id'] ) {
+ $post = $this->get_post( $request['id'] );
+
+ if ( ! is_wp_error( $post ) && $post->post_status === $status ) {
+ return true;
+ }
+ }
+
+ $args = $request->get_attributes()['args'][ $param ];
+
+ return rest_validate_value_from_schema( $status, $args, $param );
+ }
+
+ /**
+ * Determines validity and normalizes the given status parameter.
+ *
+ * @since 4.7.0
+ *
+ * @param string $post_status Post status.
+ * @param WP_Post_Type $post_type Post type.
+ * @return string|WP_Error Post status or WP_Error if lacking the proper permission.
+ */
+ protected function handle_status_param( $post_status, $post_type ) {
+
+ switch ( $post_status ) {
+ case 'draft':
+ case 'pending':
+ break;
+ case 'private':
+ if ( ! current_user_can( $post_type->cap->publish_posts ) ) {
+ return new WP_Error(
+ 'rest_cannot_publish',
+ __( 'Sorry, you are not allowed to create private posts in this post type.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ break;
+ case 'publish':
+ case 'future':
+ if ( ! current_user_can( $post_type->cap->publish_posts ) ) {
+ return new WP_Error(
+ 'rest_cannot_publish',
+ __( 'Sorry, you are not allowed to publish posts in this post type.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ break;
+ default:
+ if ( ! get_post_status_object( $post_status ) ) {
+ $post_status = 'draft';
+ }
+ break;
+ }
+
+ return $post_status;
+ }
+
+ /**
+ * Determines the featured media based on a request param.
+ *
+ * @since 4.7.0
+ *
+ * @param int $featured_media Featured Media ID.
+ * @param int $post_id Post ID.
+ * @return bool|WP_Error Whether the post thumbnail was successfully deleted, otherwise WP_Error.
+ */
+ protected function handle_featured_media( $featured_media, $post_id ) {
+
+ $featured_media = (int) $featured_media;
+ if ( $featured_media ) {
+ $result = set_post_thumbnail( $post_id, $featured_media );
+ if ( $result ) {
+ return true;
+ } else {
+ return new WP_Error(
+ 'rest_invalid_featured_media',
+ __( 'Invalid featured media ID.' ),
+ array( 'status' => 400 )
+ );
+ }
+ } else {
+ return delete_post_thumbnail( $post_id );
+ }
+ }
+
+ /**
+ * Checks whether the template is valid for the given post.
+ *
+ * @since 4.9.0
+ *
+ * @param string $template Page template filename.
+ * @param WP_REST_Request $request Request.
+ * @return true|WP_Error True if template is still valid or if the same as existing value, or a WP_Error if template not supported.
+ */
+ public function check_template( $template, $request ) {
+
+ if ( ! $template ) {
+ return true;
+ }
+
+ if ( $request['id'] ) {
+ $post = get_post( $request['id'] );
+ $current_template = get_page_template_slug( $request['id'] );
+ } else {
+ $post = null;
+ $current_template = '';
+ }
+
+ // Always allow for updating a post to the same template, even if that template is no longer supported.
+ if ( $template === $current_template ) {
+ return true;
+ }
+
+ // If this is a create request, get_post() will return null and wp theme will fallback to the passed post type.
+ $allowed_templates = wp_get_theme()->get_page_templates( $post, $this->post_type );
+
+ if ( isset( $allowed_templates[ $template ] ) ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'rest_invalid_param',
+ /* translators: 1: Parameter, 2: List of valid values. */
+ sprintf( __( '%1$s is not one of %2$s.' ), 'template', implode( ', ', array_keys( $allowed_templates ) ) )
+ );
+ }
+
+ /**
+ * Sets the template for a post.
+ *
+ * @since 4.7.0
+ * @since 4.9.0 Added the `$validate` parameter.
+ *
+ * @param string $template Page template filename.
+ * @param int $post_id Post ID.
+ * @param bool $validate Whether to validate that the template selected is valid.
+ */
+ public function handle_template( $template, $post_id, $validate = false ) {
+
+ if ( $validate && ! array_key_exists( $template, wp_get_theme()->get_page_templates( get_post( $post_id ) ) ) ) {
+ $template = '';
+ }
+
+ update_post_meta( $post_id, '_wp_page_template', $template );
+ }
+
+ /**
+ * Updates the post's terms from a REST request.
+ *
+ * @since 4.7.0
+ *
+ * @param int $post_id The post ID to update the terms form.
+ * @param WP_REST_Request $request The request object with post and terms data.
+ * @return null|WP_Error WP_Error on an error assigning any of the terms, otherwise null.
+ */
+ protected function handle_terms( $post_id, $request ) {
+ $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) );
+
+ foreach ( $taxonomies as $taxonomy ) {
+ $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
+
+ if ( ! isset( $request[ $base ] ) ) {
+ continue;
+ }
+
+ $result = wp_set_object_terms( $post_id, $request[ $base ], $taxonomy->name );
+
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ }
+ }
+
+ /**
+ * Checks whether current user can assign all terms sent with the current request.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request The request object with post and terms data.
+ * @return bool Whether the current user can assign the provided terms.
+ */
+ protected function check_assign_terms_permission( $request ) {
+ $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) );
+ foreach ( $taxonomies as $taxonomy ) {
+ $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
+
+ if ( ! isset( $request[ $base ] ) ) {
+ continue;
+ }
+
+ foreach ( (array) $request[ $base ] as $term_id ) {
+ // Invalid terms will be rejected later.
+ if ( ! get_term( $term_id, $taxonomy->name ) ) {
+ continue;
+ }
+
+ if ( ! current_user_can( 'assign_term', (int) $term_id ) ) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if a given post type can be viewed or managed.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post_Type|string $post_type Post type name or object.
+ * @return bool Whether the post type is allowed in REST.
+ */
+ protected function check_is_post_type_allowed( $post_type ) {
+ if ( ! is_object( $post_type ) ) {
+ $post_type = get_post_type_object( $post_type );
+ }
+
+ if ( ! empty( $post_type ) && ! empty( $post_type->show_in_rest ) ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks if a post can be read.
+ *
+ * Correctly handles posts with the inherit status.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post $post Post object.
+ * @return bool Whether the post can be read.
+ */
+ public function check_read_permission( $post ) {
+ $post_type = get_post_type_object( $post->post_type );
+ if ( ! $this->check_is_post_type_allowed( $post_type ) ) {
+ return false;
+ }
+
+ // Is the post readable?
+ if ( 'publish' === $post->post_status || current_user_can( 'read_post', $post->ID ) ) {
+ return true;
+ }
+
+ $post_status_obj = get_post_status_object( $post->post_status );
+ if ( $post_status_obj && $post_status_obj->public ) {
+ return true;
+ }
+
+ // Can we read the parent if we're inheriting?
+ if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) {
+ $parent = get_post( $post->post_parent );
+ if ( $parent ) {
+ return $this->check_read_permission( $parent );
+ }
+ }
+
+ /*
+ * If there isn't a parent, but the status is set to inherit, assume
+ * it's published (as per get_post_status()).
+ */
+ if ( 'inherit' === $post->post_status ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks if a post can be edited.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post $post Post object.
+ * @return bool Whether the post can be edited.
+ */
+ protected function check_update_permission( $post ) {
+ $post_type = get_post_type_object( $post->post_type );
+
+ if ( ! $this->check_is_post_type_allowed( $post_type ) ) {
+ return false;
+ }
+
+ return current_user_can( 'edit_post', $post->ID );
+ }
+
+ /**
+ * Checks if a post can be created.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post $post Post object.
+ * @return bool Whether the post can be created.
+ */
+ protected function check_create_permission( $post ) {
+ $post_type = get_post_type_object( $post->post_type );
+
+ if ( ! $this->check_is_post_type_allowed( $post_type ) ) {
+ return false;
+ }
+
+ return current_user_can( $post_type->cap->create_posts );
+ }
+
+ /**
+ * Checks if a post can be deleted.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post $post Post object.
+ * @return bool Whether the post can be deleted.
+ */
+ protected function check_delete_permission( $post ) {
+ $post_type = get_post_type_object( $post->post_type );
+
+ if ( ! $this->check_is_post_type_allowed( $post_type ) ) {
+ return false;
+ }
+
+ return current_user_can( 'delete_post', $post->ID );
+ }
+
+ /**
+ * Prepares a single post output for response.
+ *
+ * @since 4.7.0
+ * @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param WP_Post $item Post object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $post = $item;
+
+ $GLOBALS['post'] = $post;
+
+ setup_postdata( $post );
+
+ $fields = $this->get_fields_for_response( $request );
+
+ // Base fields for every post.
+ $data = array();
+
+ if ( rest_is_field_included( 'id', $fields ) ) {
+ $data['id'] = $post->ID;
+ }
+
+ if ( rest_is_field_included( 'date', $fields ) ) {
+ $data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date );
+ }
+
+ 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` field
+ * with the site's timezone offset applied.
+ */
+ if ( '0000-00-00 00:00:00' === $post->post_date_gmt ) {
+ $post_date_gmt = get_gmt_from_date( $post->post_date );
+ } else {
+ $post_date_gmt = $post->post_date_gmt;
+ }
+ $data['date_gmt'] = $this->prepare_date_response( $post_date_gmt );
+ }
+
+ 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 ),
+ 'raw' => $post->guid,
+ );
+ }
+
+ if ( rest_is_field_included( 'modified', $fields ) ) {
+ $data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified );
+ }
+
+ 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 offset applied.
+ */
+ if ( '0000-00-00 00:00:00' === $post->post_modified_gmt ) {
+ $post_modified_gmt = gmdate( 'Y-m-d H:i:s', strtotime( $post->post_modified ) - ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ) );
+ } else {
+ $post_modified_gmt = $post->post_modified_gmt;
+ }
+ $data['modified_gmt'] = $this->prepare_date_response( $post_modified_gmt );
+ }
+
+ if ( rest_is_field_included( 'password', $fields ) ) {
+ $data['password'] = $post->post_password;
+ }
+
+ if ( rest_is_field_included( 'slug', $fields ) ) {
+ $data['slug'] = $post->post_name;
+ }
+
+ if ( rest_is_field_included( 'status', $fields ) ) {
+ $data['status'] = $post->post_status;
+ }
+
+ if ( rest_is_field_included( 'type', $fields ) ) {
+ $data['type'] = $post->post_type;
+ }
+
+ if ( rest_is_field_included( 'link', $fields ) ) {
+ $data['link'] = get_permalink( $post->ID );
+ }
+
+ 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']['rendered'] = get_the_title( $post->ID );
+
+ remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
+ }
+
+ $has_password_filter = false;
+
+ if ( $this->can_access_password_content( $post, $request ) ) {
+ $this->password_check_passed[ $post->ID ] = true;
+ // Allow access to the post, permissions already checked before.
+ add_filter( 'post_password_required', array( $this, 'check_password_required' ), 10, 2 );
+
+ $has_password_filter = true;
+ }
+
+ 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 ( rest_is_field_included( 'excerpt', $fields ) ) {
+ /** This filter is documented in wp-includes/post-template.php */
+ $excerpt = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post );
+
+ /** This filter is documented in wp-includes/post-template.php */
+ $excerpt = apply_filters( 'the_excerpt', $excerpt );
+
+ $data['excerpt'] = array(
+ 'raw' => $post->post_excerpt,
+ 'rendered' => post_password_required( $post ) ? '' : $excerpt,
+ 'protected' => (bool) $post->post_password,
+ );
+ }
+
+ if ( $has_password_filter ) {
+ // Reset filter.
+ remove_filter( 'post_password_required', array( $this, 'check_password_required' ) );
+ }
+
+ if ( rest_is_field_included( 'author', $fields ) ) {
+ $data['author'] = (int) $post->post_author;
+ }
+
+ if ( rest_is_field_included( 'featured_media', $fields ) ) {
+ $data['featured_media'] = (int) get_post_thumbnail_id( $post->ID );
+ }
+
+ if ( rest_is_field_included( 'parent', $fields ) ) {
+ $data['parent'] = (int) $post->post_parent;
+ }
+
+ if ( rest_is_field_included( 'menu_order', $fields ) ) {
+ $data['menu_order'] = (int) $post->menu_order;
+ }
+
+ if ( rest_is_field_included( 'comment_status', $fields ) ) {
+ $data['comment_status'] = $post->comment_status;
+ }
+
+ if ( rest_is_field_included( 'ping_status', $fields ) ) {
+ $data['ping_status'] = $post->ping_status;
+ }
+
+ if ( rest_is_field_included( 'sticky', $fields ) ) {
+ $data['sticky'] = is_sticky( $post->ID );
+ }
+
+ if ( rest_is_field_included( 'template', $fields ) ) {
+ $template = get_page_template_slug( $post->ID );
+ if ( $template ) {
+ $data['template'] = $template;
+ } else {
+ $data['template'] = '';
+ }
+ }
+
+ if ( rest_is_field_included( 'format', $fields ) ) {
+ $data['format'] = get_post_format( $post->ID );
+
+ // Fill in blank post format.
+ if ( empty( $data['format'] ) ) {
+ $data['format'] = 'standard';
+ }
+ }
+
+ if ( rest_is_field_included( 'meta', $fields ) ) {
+ $data['meta'] = $this->meta->get_value( $post->ID, $request );
+ }
+
+ $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) );
+
+ foreach ( $taxonomies as $taxonomy ) {
+ $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
+
+ 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();
+ }
+ }
+
+ $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 = 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' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/post.php';
+ }
+
+ $sample_permalink = get_sample_permalink( $post->ID, $post->post_title, '' );
+
+ if ( $permalink_template_requested ) {
+ $data['permalink_template'] = $sample_permalink[0];
+ }
+
+ if ( $generated_slug_requested ) {
+ $data['generated_slug'] = $sample_permalink[1];
+ }
+ }
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ // Wrap the data in a response object.
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $links = $this->prepare_links( $post );
+ $response->add_links( $links );
+
+ if ( ! empty( $links['self']['href'] ) ) {
+ $actions = $this->get_available_actions( $post, $request );
+
+ $self = $links['self']['href'];
+
+ foreach ( $actions as $rel ) {
+ $response->add_link( $rel, $self );
+ }
+ }
+ }
+
+ /**
+ * Filters the post data for a REST API response.
+ *
+ * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_prepare_post`
+ * - `rest_prepare_page`
+ * - `rest_prepare_attachment`
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Post $post Post object.
+ * @param WP_REST_Request $request Request object.
+ */
+ return apply_filters( "rest_prepare_{$this->post_type}", $response, $post, $request );
+ }
+
+ /**
+ * Overwrites the default protected title format.
+ *
+ * By default, WordPress will show password protected posts with a title of
+ * "Protected: %s", as the REST API communicates the protected status of a post
+ * in a machine readable format, we remove the "Protected: " prefix.
+ *
+ * @since 4.7.0
+ *
+ * @return string Protected title format.
+ */
+ public function protected_title_format() {
+ return '%s';
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post $post Post object.
+ * @return array Links for the given post.
+ */
+ protected function prepare_links( $post ) {
+ // Entity meta.
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( rest_get_route_for_post( $post->ID ) ),
+ ),
+ 'collection' => array(
+ 'href' => rest_url( rest_get_route_for_post_type_items( $this->post_type ) ),
+ ),
+ 'about' => array(
+ 'href' => rest_url( 'wp/v2/types/' . $this->post_type ),
+ ),
+ );
+
+ if ( ( in_array( $post->post_type, array( 'post', 'page' ), true ) || post_type_supports( $post->post_type, 'author' ) )
+ && ! empty( $post->post_author ) ) {
+ $links['author'] = array(
+ 'href' => rest_url( 'wp/v2/users/' . $post->post_author ),
+ 'embeddable' => true,
+ );
+ }
+
+ if ( in_array( $post->post_type, array( 'post', 'page' ), true ) || post_type_supports( $post->post_type, 'comments' ) ) {
+ $replies_url = rest_url( 'wp/v2/comments' );
+ $replies_url = add_query_arg( 'post', $post->ID, $replies_url );
+
+ $links['replies'] = array(
+ 'href' => $replies_url,
+ 'embeddable' => true,
+ );
+ }
+
+ if ( in_array( $post->post_type, array( 'post', 'page' ), true ) || post_type_supports( $post->post_type, 'revisions' ) ) {
+ $revisions = wp_get_latest_revision_id_and_total_count( $post->ID );
+ $revisions_count = ! is_wp_error( $revisions ) ? $revisions['count'] : 0;
+ $revisions_base = sprintf( '/%s/%s/%d/revisions', $this->namespace, $this->rest_base, $post->ID );
+
+ $links['version-history'] = array(
+ 'href' => rest_url( $revisions_base ),
+ 'count' => $revisions_count,
+ );
+
+ if ( $revisions_count > 0 ) {
+ $links['predecessor-version'] = array(
+ 'href' => rest_url( $revisions_base . '/' . $revisions['latest_id'] ),
+ 'id' => $revisions['latest_id'],
+ );
+ }
+ }
+
+ $post_type_obj = get_post_type_object( $post->post_type );
+
+ if ( $post_type_obj->hierarchical && ! empty( $post->post_parent ) ) {
+ $links['up'] = array(
+ 'href' => rest_url( rest_get_route_for_post( $post->post_parent ) ),
+ 'embeddable' => true,
+ );
+ }
+
+ // If we have a featured media, add that.
+ $featured_media = get_post_thumbnail_id( $post->ID );
+ if ( $featured_media ) {
+ $image_url = rest_url( rest_get_route_for_post( $featured_media ) );
+
+ $links['https://api.w.org/featuredmedia'] = array(
+ 'href' => $image_url,
+ 'embeddable' => true,
+ );
+ }
+
+ if ( ! in_array( $post->post_type, array( 'attachment', 'nav_menu_item', 'revision' ), true ) ) {
+ $attachments_url = rest_url( rest_get_route_for_post_type_items( 'attachment' ) );
+ $attachments_url = add_query_arg( 'parent', $post->ID, $attachments_url );
+
+ $links['https://api.w.org/attachment'] = array(
+ 'href' => $attachments_url,
+ );
+ }
+
+ $taxonomies = get_object_taxonomies( $post->post_type );
+
+ if ( ! empty( $taxonomies ) ) {
+ $links['https://api.w.org/term'] = array();
+
+ foreach ( $taxonomies as $tax ) {
+ $taxonomy_route = rest_get_route_for_taxonomy_items( $tax );
+
+ // Skip taxonomies that are not public.
+ if ( empty( $taxonomy_route ) ) {
+ continue;
+ }
+ $terms_url = add_query_arg(
+ 'post',
+ $post->ID,
+ rest_url( $taxonomy_route )
+ );
+
+ $links['https://api.w.org/term'][] = array(
+ 'href' => $terms_url,
+ 'taxonomy' => $tax,
+ 'embeddable' => true,
+ );
+ }
+ }
+
+ return $links;
+ }
+
+ /**
+ * Gets the link relations available for the post and current user.
+ *
+ * @since 4.9.8
+ *
+ * @param WP_Post $post Post object.
+ * @param WP_REST_Request $request Request object.
+ * @return array List of link relations.
+ */
+ protected function get_available_actions( $post, $request ) {
+
+ if ( 'edit' !== $request['context'] ) {
+ return array();
+ }
+
+ $rels = array();
+
+ $post_type = get_post_type_object( $post->post_type );
+
+ if ( 'attachment' !== $this->post_type && current_user_can( $post_type->cap->publish_posts ) ) {
+ $rels[] = 'https://api.w.org/action-publish';
+ }
+
+ if ( current_user_can( 'unfiltered_html' ) ) {
+ $rels[] = 'https://api.w.org/action-unfiltered-html';
+ }
+
+ if ( 'post' === $post_type->name ) {
+ if ( current_user_can( $post_type->cap->edit_others_posts ) && current_user_can( $post_type->cap->publish_posts ) ) {
+ $rels[] = 'https://api.w.org/action-sticky';
+ }
+ }
+
+ if ( post_type_supports( $post_type->name, 'author' ) ) {
+ if ( current_user_can( $post_type->cap->edit_others_posts ) ) {
+ $rels[] = 'https://api.w.org/action-assign-author';
+ }
+ }
+
+ $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) );
+
+ foreach ( $taxonomies as $tax ) {
+ $tax_base = ! empty( $tax->rest_base ) ? $tax->rest_base : $tax->name;
+ $create_cap = is_taxonomy_hierarchical( $tax->name ) ? $tax->cap->edit_terms : $tax->cap->assign_terms;
+
+ if ( current_user_can( $create_cap ) ) {
+ $rels[] = 'https://api.w.org/action-create-' . $tax_base;
+ }
+
+ if ( current_user_can( $tax->cap->assign_terms ) ) {
+ $rels[] = 'https://api.w.org/action-assign-' . $tax_base;
+ }
+ }
+
+ return $rels;
+ }
+
+ /**
+ * Retrieves the post's schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => $this->post_type,
+ 'type' => 'object',
+ // Base properties for every Post.
+ 'properties' => array(
+ 'date' => array(
+ 'description' => __( "The date the post was published, in the site's timezone." ),
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'date_gmt' => array(
+ 'description' => __( 'The date the post was published, as GMT.' ),
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'guid' => array(
+ 'description' => __( 'The globally unique identifier for the post.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'GUID for the post, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'rendered' => array(
+ 'description' => __( 'GUID for the post, transformed for display.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ ),
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the post.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'link' => array(
+ 'description' => __( 'URL to the post.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'modified' => array(
+ 'description' => __( "The date the post was last modified, in the site's timezone." ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'modified_gmt' => array(
+ 'description' => __( 'The date the post was last modified, as GMT.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'slug' => array(
+ 'description' => __( 'An alphanumeric identifier for the post unique to its type.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => array( $this, 'sanitize_slug' ),
+ ),
+ ),
+ 'status' => array(
+ 'description' => __( 'A named status for the post.' ),
+ 'type' => 'string',
+ 'enum' => array_keys( get_post_stati( array( 'internal' => false ) ) ),
+ 'context' => array( 'view', 'edit' ),
+ 'arg_options' => array(
+ 'validate_callback' => array( $this, 'check_status' ),
+ ),
+ ),
+ 'type' => array(
+ 'description' => __( 'Type of post.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'password' => array(
+ 'description' => __( 'A password to protect access to the content and excerpt.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ ),
+ ),
+ );
+
+ $post_type_obj = get_post_type_object( $this->post_type );
+ if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) {
+ $schema['properties']['permalink_template'] = array(
+ 'description' => __( 'Permalink template for the post.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ );
+
+ $schema['properties']['generated_slug'] = array(
+ 'description' => __( 'Slug automatically generated from the post title.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ );
+ }
+
+ if ( $post_type_obj->hierarchical ) {
+ $schema['properties']['parent'] = array(
+ 'description' => __( 'The ID for the parent of the post.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ );
+ }
+
+ $post_type_attributes = array(
+ 'title',
+ 'editor',
+ 'author',
+ 'excerpt',
+ 'thumbnail',
+ 'comments',
+ 'revisions',
+ 'page-attributes',
+ 'post-formats',
+ 'custom-fields',
+ );
+ $fixed_schemas = array(
+ 'post' => array(
+ 'title',
+ 'editor',
+ 'author',
+ 'excerpt',
+ 'thumbnail',
+ 'comments',
+ 'revisions',
+ 'post-formats',
+ 'custom-fields',
+ ),
+ 'page' => array(
+ 'title',
+ 'editor',
+ 'author',
+ 'excerpt',
+ 'thumbnail',
+ 'comments',
+ 'revisions',
+ 'page-attributes',
+ 'custom-fields',
+ ),
+ 'attachment' => array(
+ 'title',
+ 'author',
+ 'comments',
+ 'revisions',
+ 'custom-fields',
+ ),
+ );
+
+ foreach ( $post_type_attributes as $attribute ) {
+ if ( isset( $fixed_schemas[ $this->post_type ] ) && ! in_array( $attribute, $fixed_schemas[ $this->post_type ], true ) ) {
+ continue;
+ } elseif ( ! isset( $fixed_schemas[ $this->post_type ] ) && ! post_type_supports( $this->post_type, $attribute ) ) {
+ continue;
+ }
+
+ switch ( $attribute ) {
+
+ case 'title':
+ $schema['properties']['title'] = array(
+ 'description' => __( 'The title for the post.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database().
+ 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database().
+ ),
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'Title for the post, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ ),
+ 'rendered' => array(
+ 'description' => __( 'HTML title for the post, transformed for display.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+ break;
+
+ case 'editor':
+ $schema['properties']['content'] = array(
+ 'description' => __( 'The content for the post.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database().
+ 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database().
+ ),
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'Content for the post, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ ),
+ 'rendered' => array(
+ 'description' => __( 'HTML content for the post, transformed for display.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'block_version' => array(
+ 'description' => __( 'Version of the content block format used by the post.' ),
+ 'type' => 'integer',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'protected' => array(
+ 'description' => __( 'Whether the content is protected with a password.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+ break;
+
+ case 'author':
+ $schema['properties']['author'] = array(
+ 'description' => __( 'The ID for the author of the post.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ );
+ break;
+
+ case 'excerpt':
+ $schema['properties']['excerpt'] = array(
+ 'description' => __( 'The excerpt for the post.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database().
+ 'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database().
+ ),
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'Excerpt for the post, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ ),
+ 'rendered' => array(
+ 'description' => __( 'HTML excerpt for the post, transformed for display.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'protected' => array(
+ 'description' => __( 'Whether the excerpt is protected with a password.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+ break;
+
+ case 'thumbnail':
+ $schema['properties']['featured_media'] = array(
+ 'description' => __( 'The ID of the featured media for the post.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ );
+ break;
+
+ case 'comments':
+ $schema['properties']['comment_status'] = array(
+ 'description' => __( 'Whether or not comments are open on the post.' ),
+ 'type' => 'string',
+ 'enum' => array( 'open', 'closed' ),
+ 'context' => array( 'view', 'edit' ),
+ );
+ $schema['properties']['ping_status'] = array(
+ 'description' => __( 'Whether or not the post can be pinged.' ),
+ 'type' => 'string',
+ 'enum' => array( 'open', 'closed' ),
+ 'context' => array( 'view', 'edit' ),
+ );
+ break;
+
+ case 'page-attributes':
+ $schema['properties']['menu_order'] = array(
+ 'description' => __( 'The order of the post in relation to other posts.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ );
+ break;
+
+ case 'post-formats':
+ // Get the native post formats and remove the array keys.
+ $formats = array_values( get_post_format_slugs() );
+
+ $schema['properties']['format'] = array(
+ 'description' => __( 'The format for the post.' ),
+ 'type' => 'string',
+ 'enum' => $formats,
+ 'context' => array( 'view', 'edit' ),
+ );
+ break;
+
+ case 'custom-fields':
+ $schema['properties']['meta'] = $this->meta->get_field_schema();
+ break;
+
+ }
+ }
+
+ if ( 'post' === $this->post_type ) {
+ $schema['properties']['sticky'] = array(
+ 'description' => __( 'Whether or not the post should be treated as sticky.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit' ),
+ );
+ }
+
+ $schema['properties']['template'] = array(
+ 'description' => __( 'The theme file to use to display the post.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'arg_options' => array(
+ 'validate_callback' => array( $this, 'check_template' ),
+ ),
+ );
+
+ $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) );
+
+ foreach ( $taxonomies as $taxonomy ) {
+ $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
+
+ if ( array_key_exists( $base, $schema['properties'] ) ) {
+ $taxonomy_field_name_with_conflict = ! empty( $taxonomy->rest_base ) ? 'rest_base' : 'name';
+ _doing_it_wrong(
+ 'register_taxonomy',
+ sprintf(
+ /* translators: 1: The taxonomy name, 2: The property name, either 'rest_base' or 'name', 3: The conflicting value. */
+ __( 'The "%1$s" taxonomy "%2$s" property (%3$s) conflicts with an existing property on the REST API Posts Controller. Specify a custom "rest_base" when registering the taxonomy to avoid this error.' ),
+ $taxonomy->name,
+ $taxonomy_field_name_with_conflict,
+ $base
+ ),
+ '5.4.0'
+ );
+ }
+
+ $schema['properties'][ $base ] = array(
+ /* translators: %s: Taxonomy name. */
+ 'description' => sprintf( __( 'The terms assigned to the post in the %s taxonomy.' ), $taxonomy->name ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'context' => array( 'view', 'edit' ),
+ );
+ }
+
+ $schema_links = $this->get_schema_links();
+
+ if ( $schema_links ) {
+ $schema['links'] = $schema_links;
+ }
+
+ // Take a snapshot of which fields are in the schema pre-filtering.
+ $schema_fields = array_keys( $schema['properties'] );
+
+ /**
+ * Filters the post's schema.
+ *
+ * The dynamic portion of the filter, `$this->post_type`, refers to the
+ * post type slug for the controller.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_post_item_schema`
+ * - `rest_page_item_schema`
+ * - `rest_attachment_item_schema`
+ *
+ * @since 5.4.0
+ *
+ * @param array $schema Item schema data.
+ */
+ $schema = apply_filters( "rest_{$this->post_type}_item_schema", $schema );
+
+ // Emit a _doing_it_wrong warning if user tries to add new properties using this filter.
+ $new_fields = array_diff( array_keys( $schema['properties'] ), $schema_fields );
+ if ( count( $new_fields ) > 0 ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: register_rest_field */
+ __( 'Please use %s to add new schema properties.' ),
+ 'register_rest_field'
+ ),
+ '5.4.0'
+ );
+ }
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves Link Description Objects that should be added to the Schema for the posts collection.
+ *
+ * @since 4.9.8
+ *
+ * @return array
+ */
+ protected function get_schema_links() {
+
+ $href = rest_url( "{$this->namespace}/{$this->rest_base}/{id}" );
+
+ $links = array();
+
+ if ( 'attachment' !== $this->post_type ) {
+ $links[] = array(
+ 'rel' => 'https://api.w.org/action-publish',
+ 'title' => __( 'The current user can publish this post.' ),
+ 'href' => $href,
+ 'targetSchema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'status' => array(
+ 'type' => 'string',
+ 'enum' => array( 'publish', 'future' ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ $links[] = array(
+ 'rel' => 'https://api.w.org/action-unfiltered-html',
+ 'title' => __( 'The current user can post unfiltered HTML markup and JavaScript.' ),
+ 'href' => $href,
+ 'targetSchema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'content' => array(
+ 'raw' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ ),
+ );
+
+ if ( 'post' === $this->post_type ) {
+ $links[] = array(
+ 'rel' => 'https://api.w.org/action-sticky',
+ 'title' => __( 'The current user can sticky this post.' ),
+ 'href' => $href,
+ 'targetSchema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'sticky' => array(
+ 'type' => 'boolean',
+ ),
+ ),
+ ),
+ );
+ }
+
+ if ( post_type_supports( $this->post_type, 'author' ) ) {
+ $links[] = array(
+ 'rel' => 'https://api.w.org/action-assign-author',
+ 'title' => __( 'The current user can change the author on this post.' ),
+ 'href' => $href,
+ 'targetSchema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'author' => array(
+ 'type' => 'integer',
+ ),
+ ),
+ ),
+ );
+ }
+
+ $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) );
+
+ foreach ( $taxonomies as $tax ) {
+ $tax_base = ! empty( $tax->rest_base ) ? $tax->rest_base : $tax->name;
+
+ /* translators: %s: Taxonomy name. */
+ $assign_title = sprintf( __( 'The current user can assign terms in the %s taxonomy.' ), $tax->name );
+ /* translators: %s: Taxonomy name. */
+ $create_title = sprintf( __( 'The current user can create terms in the %s taxonomy.' ), $tax->name );
+
+ $links[] = array(
+ 'rel' => 'https://api.w.org/action-assign-' . $tax_base,
+ 'title' => $assign_title,
+ 'href' => $href,
+ 'targetSchema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ $tax_base => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ ),
+ ),
+ ),
+ );
+
+ $links[] = array(
+ 'rel' => 'https://api.w.org/action-create-' . $tax_base,
+ 'title' => $create_title,
+ 'href' => $href,
+ 'targetSchema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ $tax_base => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * Retrieves the query params for the posts collection.
+ *
+ * @since 4.7.0
+ * @since 5.4.0 The `tax_relation` query parameter was added.
+ * @since 5.7.0 The `modified_after` and `modified_before` query parameters were added.
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $query_params = parent::get_collection_params();
+
+ $query_params['context']['default'] = 'view';
+
+ $query_params['after'] = array(
+ 'description' => __( 'Limit response to posts published after a given ISO8601 compliant date.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ );
+
+ $query_params['modified_after'] = array(
+ 'description' => __( 'Limit response to posts modified after a given ISO8601 compliant date.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ );
+
+ if ( post_type_supports( $this->post_type, 'author' ) ) {
+ $query_params['author'] = array(
+ 'description' => __( 'Limit result set to posts assigned to specific authors.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+ $query_params['author_exclude'] = array(
+ 'description' => __( 'Ensure result set excludes posts assigned to specific authors.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+ }
+
+ $query_params['before'] = array(
+ 'description' => __( 'Limit response to posts published before a given ISO8601 compliant date.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ );
+
+ $query_params['modified_before'] = array(
+ 'description' => __( 'Limit response to posts modified before a given ISO8601 compliant date.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ );
+
+ $query_params['exclude'] = array(
+ 'description' => __( 'Ensure result set excludes specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ $query_params['include'] = array(
+ 'description' => __( 'Limit result set to specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ if ( 'page' === $this->post_type || post_type_supports( $this->post_type, 'page-attributes' ) ) {
+ $query_params['menu_order'] = array(
+ 'description' => __( 'Limit result set to posts with a specific menu_order value.' ),
+ 'type' => 'integer',
+ );
+ }
+
+ $query_params['offset'] = array(
+ 'description' => __( 'Offset the result set by a specific number of items.' ),
+ 'type' => 'integer',
+ );
+
+ $query_params['order'] = array(
+ 'description' => __( 'Order sort attribute ascending or descending.' ),
+ 'type' => 'string',
+ 'default' => 'desc',
+ 'enum' => array( 'asc', 'desc' ),
+ );
+
+ $query_params['orderby'] = array(
+ 'description' => __( 'Sort collection by post attribute.' ),
+ 'type' => 'string',
+ 'default' => 'date',
+ 'enum' => array(
+ 'author',
+ 'date',
+ 'id',
+ 'include',
+ 'modified',
+ 'parent',
+ 'relevance',
+ 'slug',
+ 'include_slugs',
+ 'title',
+ ),
+ );
+
+ if ( 'page' === $this->post_type || post_type_supports( $this->post_type, 'page-attributes' ) ) {
+ $query_params['orderby']['enum'][] = 'menu_order';
+ }
+
+ $post_type = get_post_type_object( $this->post_type );
+
+ if ( $post_type->hierarchical || 'attachment' === $this->post_type ) {
+ $query_params['parent'] = array(
+ 'description' => __( 'Limit result set to items with particular parent IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+ $query_params['parent_exclude'] = array(
+ 'description' => __( 'Limit result set to all items except those of a particular parent ID.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+ }
+
+ $query_params['search_columns'] = array(
+ 'default' => array(),
+ 'description' => __( 'Array of column names to be searched.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'enum' => array( 'post_title', 'post_content', 'post_excerpt' ),
+ 'type' => 'string',
+ ),
+ );
+
+ $query_params['slug'] = array(
+ 'description' => __( 'Limit result set to posts with one or more specific slugs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ );
+
+ $query_params['status'] = array(
+ 'default' => 'publish',
+ 'description' => __( 'Limit result set to posts assigned one or more statuses.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'enum' => array_merge( array_keys( get_post_stati() ), array( 'any' ) ),
+ 'type' => 'string',
+ ),
+ 'sanitize_callback' => array( $this, 'sanitize_post_statuses' ),
+ );
+
+ $query_params = $this->prepare_taxonomy_limit_schema( $query_params );
+
+ if ( 'post' === $this->post_type ) {
+ $query_params['sticky'] = array(
+ 'description' => __( 'Limit result set to items that are sticky.' ),
+ 'type' => 'boolean',
+ );
+ }
+
+ /**
+ * Filters collection parameters for the posts controller.
+ *
+ * The dynamic part of the filter `$this->post_type` refers to the post
+ * type slug for the controller.
+ *
+ * This filter registers the collection parameter, but does not map the
+ * collection parameter to an internal WP_Query parameter. Use the
+ * `rest_{$this->post_type}_query` filter to set WP_Query parameters.
+ *
+ * @since 4.7.0
+ *
+ * @param array $query_params JSON Schema-formatted collection parameters.
+ * @param WP_Post_Type $post_type Post type object.
+ */
+ return apply_filters( "rest_{$this->post_type}_collection_params", $query_params, $post_type );
+ }
+
+ /**
+ * Sanitizes and validates the list of post statuses, including whether the
+ * user can query private statuses.
+ *
+ * @since 4.7.0
+ *
+ * @param string|array $statuses One or more post statuses.
+ * @param WP_REST_Request $request Full details about the request.
+ * @param string $parameter Additional parameter to pass to validation.
+ * @return array|WP_Error A list of valid statuses, otherwise WP_Error object.
+ */
+ public function sanitize_post_statuses( $statuses, $request, $parameter ) {
+ $statuses = wp_parse_slug_list( $statuses );
+
+ // The default status is different in WP_REST_Attachments_Controller.
+ $attributes = $request->get_attributes();
+ $default_status = $attributes['args']['status']['default'];
+
+ foreach ( $statuses as $status ) {
+ if ( $status === $default_status ) {
+ continue;
+ }
+
+ $post_type_obj = get_post_type_object( $this->post_type );
+
+ if ( current_user_can( $post_type_obj->cap->edit_posts ) || 'private' === $status && current_user_can( $post_type_obj->cap->read_private_posts ) ) {
+ $result = rest_validate_request_arg( $status, $request, $parameter );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ } else {
+ return new WP_Error(
+ 'rest_forbidden_status',
+ __( 'Status is forbidden.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ }
+
+ return $statuses;
+ }
+
+ /**
+ * Prepares the 'tax_query' for a collection of posts.
+ *
+ * @since 5.7.0
+ *
+ * @param array $args WP_Query arguments.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return array Updated query arguments.
+ */
+ private function prepare_tax_query( array $args, WP_REST_Request $request ) {
+ $relation = $request['tax_relation'];
+
+ if ( $relation ) {
+ $args['tax_query'] = array( 'relation' => $relation );
+ }
+
+ $taxonomies = wp_list_filter(
+ get_object_taxonomies( $this->post_type, 'objects' ),
+ array( 'show_in_rest' => true )
+ );
+
+ foreach ( $taxonomies as $taxonomy ) {
+ $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
+
+ $tax_include = $request[ $base ];
+ $tax_exclude = $request[ $base . '_exclude' ];
+
+ if ( $tax_include ) {
+ $terms = array();
+ $include_children = false;
+ $operator = 'IN';
+
+ if ( rest_is_array( $tax_include ) ) {
+ $terms = $tax_include;
+ } elseif ( rest_is_object( $tax_include ) ) {
+ $terms = empty( $tax_include['terms'] ) ? array() : $tax_include['terms'];
+ $include_children = ! empty( $tax_include['include_children'] );
+
+ if ( isset( $tax_include['operator'] ) && 'AND' === $tax_include['operator'] ) {
+ $operator = 'AND';
+ }
+ }
+
+ if ( $terms ) {
+ $args['tax_query'][] = array(
+ 'taxonomy' => $taxonomy->name,
+ 'field' => 'term_id',
+ 'terms' => $terms,
+ 'include_children' => $include_children,
+ 'operator' => $operator,
+ );
+ }
+ }
+
+ if ( $tax_exclude ) {
+ $terms = array();
+ $include_children = false;
+
+ if ( rest_is_array( $tax_exclude ) ) {
+ $terms = $tax_exclude;
+ } elseif ( rest_is_object( $tax_exclude ) ) {
+ $terms = empty( $tax_exclude['terms'] ) ? array() : $tax_exclude['terms'];
+ $include_children = ! empty( $tax_exclude['include_children'] );
+ }
+
+ if ( $terms ) {
+ $args['tax_query'][] = array(
+ 'taxonomy' => $taxonomy->name,
+ 'field' => 'term_id',
+ 'terms' => $terms,
+ 'include_children' => $include_children,
+ 'operator' => 'NOT IN',
+ );
+ }
+ }
+ }
+
+ return $args;
+ }
+
+ /**
+ * Prepares the collection schema for including and excluding items by terms.
+ *
+ * @since 5.7.0
+ *
+ * @param array $query_params Collection schema.
+ * @return array Updated schema.
+ */
+ private function prepare_taxonomy_limit_schema( array $query_params ) {
+ $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) );
+
+ if ( ! $taxonomies ) {
+ return $query_params;
+ }
+
+ $query_params['tax_relation'] = array(
+ 'description' => __( 'Limit result set based on relationship between multiple taxonomies.' ),
+ 'type' => 'string',
+ 'enum' => array( 'AND', 'OR' ),
+ );
+
+ $limit_schema = array(
+ 'type' => array( 'object', 'array' ),
+ 'oneOf' => array(
+ array(
+ 'title' => __( 'Term ID List' ),
+ 'description' => __( 'Match terms with the listed IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'title' => __( 'Term ID Taxonomy Query' ),
+ 'description' => __( 'Perform an advanced term query.' ),
+ 'type' => 'object',
+ 'properties' => array(
+ 'terms' => array(
+ 'description' => __( 'Term IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ ),
+ 'include_children' => array(
+ 'description' => __( 'Whether to include child terms in the terms limiting the result set.' ),
+ 'type' => 'boolean',
+ 'default' => false,
+ ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ ),
+ );
+
+ $include_schema = array_merge(
+ array(
+ /* translators: %s: Taxonomy name. */
+ 'description' => __( 'Limit result set to items with specific terms assigned in the %s taxonomy.' ),
+ ),
+ $limit_schema
+ );
+ // 'operator' is supported only for 'include' queries.
+ $include_schema['oneOf'][1]['properties']['operator'] = array(
+ 'description' => __( 'Whether items must be assigned all or any of the specified terms.' ),
+ 'type' => 'string',
+ 'enum' => array( 'AND', 'OR' ),
+ 'default' => 'OR',
+ );
+
+ $exclude_schema = array_merge(
+ array(
+ /* translators: %s: Taxonomy name. */
+ 'description' => __( 'Limit result set to items except those with specific terms assigned in the %s taxonomy.' ),
+ ),
+ $limit_schema
+ );
+
+ foreach ( $taxonomies as $taxonomy ) {
+ $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
+ $base_exclude = $base . '_exclude';
+
+ $query_params[ $base ] = $include_schema;
+ $query_params[ $base ]['description'] = sprintf( $query_params[ $base ]['description'], $base );
+
+ $query_params[ $base_exclude ] = $exclude_schema;
+ $query_params[ $base_exclude ]['description'] = sprintf( $query_params[ $base_exclude ]['description'], $base );
+
+ if ( ! $taxonomy->hierarchical ) {
+ unset( $query_params[ $base ]['oneOf'][1]['properties']['include_children'] );
+ unset( $query_params[ $base_exclude ]['oneOf'][1]['properties']['include_children'] );
+ }
+ }
+
+ return $query_params;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
new file mode 100644
index 0000000..5501c19
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
@@ -0,0 +1,857 @@
+<?php
+/**
+ * REST API: WP_REST_Revisions_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class used to access revisions via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Revisions_Controller extends WP_REST_Controller {
+
+ /**
+ * Parent post type.
+ *
+ * @since 4.7.0
+ * @var string
+ */
+ private $parent_post_type;
+
+ /**
+ * Instance of a revision meta fields object.
+ *
+ * @since 6.4.0
+ * @var WP_REST_Post_Meta_Fields
+ */
+ protected $meta;
+
+ /**
+ * Parent controller.
+ *
+ * @since 4.7.0
+ * @var WP_REST_Controller
+ */
+ private $parent_controller;
+
+ /**
+ * The base of the parent controller's route.
+ *
+ * @since 4.7.0
+ * @var string
+ */
+ private $parent_base;
+
+ /**
+ * Constructor.
+ *
+ * @since 4.7.0
+ *
+ * @param string $parent_post_type Post type of the parent.
+ */
+ public function __construct( $parent_post_type ) {
+ $this->parent_post_type = $parent_post_type;
+ $post_type_object = get_post_type_object( $parent_post_type );
+ $parent_controller = $post_type_object->get_rest_controller();
+
+ if ( ! $parent_controller ) {
+ $parent_controller = new WP_REST_Posts_Controller( $parent_post_type );
+ }
+
+ $this->parent_controller = $parent_controller;
+ $this->rest_base = 'revisions';
+ $this->parent_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name;
+ $this->namespace = ! empty( $post_type_object->rest_namespace ) ? $post_type_object->rest_namespace : 'wp/v2';
+ $this->meta = new WP_REST_Post_Meta_Fields( $parent_post_type );
+ }
+
+ /**
+ * Registers the routes for revisions based on post types supporting revisions.
+ *
+ * @since 4.7.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->parent_base . '/(?P<parent>[\d]+)/' . $this->rest_base,
+ array(
+ 'args' => array(
+ 'parent' => array(
+ 'description' => __( 'The ID for the parent of the revision.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->parent_base . '/(?P<parent>[\d]+)/' . $this->rest_base . '/(?P<id>[\d]+)',
+ array(
+ 'args' => array(
+ 'parent' => array(
+ 'description' => __( 'The ID for the parent of the revision.' ),
+ 'type' => 'integer',
+ ),
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the revision.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+ 'args' => array(
+ 'force' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ 'description' => __( 'Required to be true, as revisions do not support trashing.' ),
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Get the parent post, if the ID is valid.
+ *
+ * @since 4.7.2
+ *
+ * @param int $parent_post_id Supplied ID.
+ * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_parent( $parent_post_id ) {
+ $error = new WP_Error(
+ 'rest_post_invalid_parent',
+ __( 'Invalid post parent ID.' ),
+ array( 'status' => 404 )
+ );
+
+ if ( (int) $parent_post_id <= 0 ) {
+ return $error;
+ }
+
+ $parent_post = get_post( (int) $parent_post_id );
+
+ if ( empty( $parent_post ) || empty( $parent_post->ID )
+ || $this->parent_post_type !== $parent_post->post_type
+ ) {
+ return $error;
+ }
+
+ return $parent_post;
+ }
+
+ /**
+ * Checks if a given request has access to get revisions.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ $parent = $this->get_parent( $request['parent'] );
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ if ( ! current_user_can( 'edit_post', $parent->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_read',
+ __( 'Sorry, you are not allowed to view revisions of this post.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the revision, if the ID is valid.
+ *
+ * @since 4.7.2
+ *
+ * @param int $id Supplied ID.
+ * @return WP_Post|WP_Error Revision post object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_revision( $id ) {
+ $error = new WP_Error(
+ 'rest_post_invalid_id',
+ __( 'Invalid revision ID.' ),
+ array( 'status' => 404 )
+ );
+
+ if ( (int) $id <= 0 ) {
+ return $error;
+ }
+
+ $revision = get_post( (int) $id );
+ if ( empty( $revision ) || empty( $revision->ID ) || 'revision' !== $revision->post_type ) {
+ return $error;
+ }
+
+ return $revision;
+ }
+
+ /**
+ * Gets a collection of revisions.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $parent = $this->get_parent( $request['parent'] );
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ // Ensure a search string is set in case the orderby is set to 'relevance'.
+ if ( ! empty( $request['orderby'] ) && 'relevance' === $request['orderby'] && empty( $request['search'] ) ) {
+ return new WP_Error(
+ 'rest_no_search_term_defined',
+ __( 'You need to define a search term to order by relevance.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Ensure an include parameter is set in case the orderby is set to 'include'.
+ if ( ! empty( $request['orderby'] ) && 'include' === $request['orderby'] && empty( $request['include'] ) ) {
+ return new WP_Error(
+ 'rest_orderby_include_missing_include',
+ __( 'You need to define an include parameter to order by include.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( wp_revisions_enabled( $parent ) ) {
+ $registered = $this->get_collection_params();
+ $args = array(
+ 'post_parent' => $parent->ID,
+ 'post_type' => 'revision',
+ 'post_status' => 'inherit',
+ 'posts_per_page' => -1,
+ 'orderby' => 'date ID',
+ 'order' => 'DESC',
+ 'suppress_filters' => true,
+ );
+
+ $parameter_mappings = array(
+ 'exclude' => 'post__not_in',
+ 'include' => 'post__in',
+ 'offset' => 'offset',
+ 'order' => 'order',
+ 'orderby' => 'orderby',
+ 'page' => 'paged',
+ 'per_page' => 'posts_per_page',
+ 'search' => 's',
+ );
+
+ foreach ( $parameter_mappings as $api_param => $wp_param ) {
+ if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
+ $args[ $wp_param ] = $request[ $api_param ];
+ }
+ }
+
+ // For backward-compatibility, 'date' needs to resolve to 'date ID'.
+ if ( isset( $args['orderby'] ) && 'date' === $args['orderby'] ) {
+ $args['orderby'] = 'date ID';
+ }
+
+ /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */
+ $args = apply_filters( 'rest_revision_query', $args, $request );
+ $query_args = $this->prepare_items_query( $args, $request );
+
+ $revisions_query = new WP_Query();
+ $revisions = $revisions_query->query( $query_args );
+ $offset = isset( $query_args['offset'] ) ? (int) $query_args['offset'] : 0;
+ $page = (int) $query_args['paged'];
+ $total_revisions = $revisions_query->found_posts;
+
+ if ( $total_revisions < 1 ) {
+ // Out-of-bounds, run the query again without LIMIT for total count.
+ unset( $query_args['paged'], $query_args['offset'] );
+
+ $count_query = new WP_Query();
+ $count_query->query( $query_args );
+
+ $total_revisions = $count_query->found_posts;
+ }
+
+ if ( $revisions_query->query_vars['posts_per_page'] > 0 ) {
+ $max_pages = ceil( $total_revisions / (int) $revisions_query->query_vars['posts_per_page'] );
+ } else {
+ $max_pages = $total_revisions > 0 ? 1 : 0;
+ }
+
+ if ( $total_revisions > 0 ) {
+ if ( $offset >= $total_revisions ) {
+ return new WP_Error(
+ 'rest_revision_invalid_offset_number',
+ __( 'The offset number requested is larger than or equal to the number of available revisions.' ),
+ array( 'status' => 400 )
+ );
+ } elseif ( ! $offset && $page > $max_pages ) {
+ return new WP_Error(
+ 'rest_revision_invalid_page_number',
+ __( 'The page number requested is larger than the number of pages available.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+ } else {
+ $revisions = array();
+ $total_revisions = 0;
+ $max_pages = 0;
+ $page = (int) $request['page'];
+ }
+
+ $response = array();
+
+ foreach ( $revisions as $revision ) {
+ $data = $this->prepare_item_for_response( $revision, $request );
+ $response[] = $this->prepare_response_for_collection( $data );
+ }
+
+ $response = rest_ensure_response( $response );
+
+ $response->header( 'X-WP-Total', (int) $total_revisions );
+ $response->header( 'X-WP-TotalPages', (int) $max_pages );
+
+ $request_params = $request->get_query_params();
+ $base_path = rest_url( sprintf( '%s/%s/%d/%s', $this->namespace, $this->parent_base, $request['parent'], $this->rest_base ) );
+ $base = add_query_arg( urlencode_deep( $request_params ), $base_path );
+
+ if ( $page > 1 ) {
+ $prev_page = $page - 1;
+
+ if ( $prev_page > $max_pages ) {
+ $prev_page = $max_pages;
+ }
+
+ $prev_link = add_query_arg( 'page', $prev_page, $base );
+ $response->link_header( 'prev', $prev_link );
+ }
+ if ( $max_pages > $page ) {
+ $next_page = $page + 1;
+ $next_link = add_query_arg( 'page', $next_page, $base );
+
+ $response->link_header( 'next', $next_link );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to get a specific revision.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ return $this->get_items_permissions_check( $request );
+ }
+
+ /**
+ * Retrieves one revision from the collection.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $parent = $this->get_parent( $request['parent'] );
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ $revision = $this->get_revision( $request['id'] );
+ if ( is_wp_error( $revision ) ) {
+ return $revision;
+ }
+
+ $response = $this->prepare_item_for_response( $revision, $request );
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Checks if a given request has access to delete a revision.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise.
+ */
+ public function delete_item_permissions_check( $request ) {
+ $parent = $this->get_parent( $request['parent'] );
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ if ( ! current_user_can( 'delete_post', $parent->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'Sorry, you are not allowed to delete revisions of this post.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $revision = $this->get_revision( $request['id'] );
+ if ( is_wp_error( $revision ) ) {
+ return $revision;
+ }
+
+ $response = $this->get_items_permissions_check( $request );
+ if ( ! $response || is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ if ( ! current_user_can( 'delete_post', $revision->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'Sorry, you are not allowed to delete this revision.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Deletes a single revision.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_item( $request ) {
+ $revision = $this->get_revision( $request['id'] );
+ if ( is_wp_error( $revision ) ) {
+ return $revision;
+ }
+
+ $force = isset( $request['force'] ) ? (bool) $request['force'] : false;
+
+ // We don't support trashing for revisions.
+ if ( ! $force ) {
+ return new WP_Error(
+ 'rest_trash_not_supported',
+ /* translators: %s: force=true */
+ sprintf( __( "Revisions do not support trashing. Set '%s' to delete." ), 'force=true' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ $previous = $this->prepare_item_for_response( $revision, $request );
+
+ $result = wp_delete_post( $request['id'], true );
+
+ /**
+ * Fires after a revision is deleted via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Post|false|null $result The revision object (if it was deleted or moved to the Trash successfully)
+ * or false or null (failure). If the revision was moved to the Trash, $result represents
+ * its new state; if it was deleted, $result represents its state before deletion.
+ * @param WP_REST_Request $request The request sent to the API.
+ */
+ do_action( 'rest_delete_revision', $result, $request );
+
+ if ( ! $result ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'The post cannot be deleted.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $response = new WP_REST_Response();
+ $response->set_data(
+ array(
+ 'deleted' => true,
+ 'previous' => $previous->get_data(),
+ )
+ );
+ return $response;
+ }
+
+ /**
+ * Determines the allowed query_vars for a get_items() response and prepares
+ * them for WP_Query.
+ *
+ * @since 5.0.0
+ *
+ * @param array $prepared_args Optional. Prepared WP_Query arguments. Default empty array.
+ * @param WP_REST_Request $request Optional. Full details about the request.
+ * @return array Items query arguments.
+ */
+ protected function prepare_items_query( $prepared_args = array(), $request = null ) {
+ $query_args = array();
+
+ foreach ( $prepared_args as $key => $value ) {
+ /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */
+ $query_args[ $key ] = apply_filters( "rest_query_var-{$key}", $value ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
+ }
+
+ // Map to proper WP_Query orderby param.
+ if ( isset( $query_args['orderby'] ) && isset( $request['orderby'] ) ) {
+ $orderby_mappings = array(
+ 'id' => 'ID',
+ 'include' => 'post__in',
+ 'slug' => 'post_name',
+ 'include_slugs' => 'post_name__in',
+ );
+
+ if ( isset( $orderby_mappings[ $request['orderby'] ] ) ) {
+ $query_args['orderby'] = $orderby_mappings[ $request['orderby'] ];
+ }
+ }
+
+ return $query_args;
+ }
+
+ /**
+ * Prepares the revision for the REST response.
+ *
+ * @since 4.7.0
+ * @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param WP_Post $item Post revision object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $post = $item;
+
+ $GLOBALS['post'] = $post;
+
+ setup_postdata( $post );
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( in_array( 'author', $fields, true ) ) {
+ $data['author'] = (int) $post->post_author;
+ }
+
+ if ( in_array( 'date', $fields, true ) ) {
+ $data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date );
+ }
+
+ if ( in_array( 'date_gmt', $fields, true ) ) {
+ $data['date_gmt'] = $this->prepare_date_response( $post->post_date_gmt );
+ }
+
+ if ( in_array( 'id', $fields, true ) ) {
+ $data['id'] = $post->ID;
+ }
+
+ if ( in_array( 'modified', $fields, true ) ) {
+ $data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified );
+ }
+
+ if ( in_array( 'modified_gmt', $fields, true ) ) {
+ $data['modified_gmt'] = $this->prepare_date_response( $post->post_modified_gmt );
+ }
+
+ if ( in_array( 'parent', $fields, true ) ) {
+ $data['parent'] = (int) $post->post_parent;
+ }
+
+ if ( in_array( 'slug', $fields, true ) ) {
+ $data['slug'] = $post->post_name;
+ }
+
+ if ( in_array( 'guid', $fields, true ) ) {
+ $data['guid'] = array(
+ /** This filter is documented in wp-includes/post-template.php */
+ 'rendered' => apply_filters( 'get_the_guid', $post->guid, $post->ID ),
+ 'raw' => $post->guid,
+ );
+ }
+
+ if ( in_array( 'title', $fields, true ) ) {
+ $data['title'] = array(
+ 'raw' => $post->post_title,
+ 'rendered' => get_the_title( $post->ID ),
+ );
+ }
+
+ if ( in_array( 'content', $fields, true ) ) {
+
+ $data['content'] = array(
+ 'raw' => $post->post_content,
+ /** This filter is documented in wp-includes/post-template.php */
+ 'rendered' => apply_filters( 'the_content', $post->post_content ),
+ );
+ }
+
+ if ( in_array( 'excerpt', $fields, true ) ) {
+ $data['excerpt'] = array(
+ 'raw' => $post->post_excerpt,
+ 'rendered' => $this->prepare_excerpt_response( $post->post_excerpt, $post ),
+ );
+ }
+
+ if ( rest_is_field_included( 'meta', $fields ) ) {
+ $data['meta'] = $this->meta->get_value( $post->ID, $request );
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+ $response = rest_ensure_response( $data );
+
+ if ( ! empty( $data['parent'] ) ) {
+ $response->add_link( 'parent', rest_url( rest_get_route_for_post( $data['parent'] ) ) );
+ }
+
+ /**
+ * Filters a revision returned from the REST API.
+ *
+ * Allows modification of the revision right before it is returned.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Post $post The original revision object.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ return apply_filters( 'rest_prepare_revision', $response, $post, $request );
+ }
+
+ /**
+ * Checks the post_date_gmt or modified_gmt and prepare any post or
+ * modified date for single post output.
+ *
+ * @since 4.7.0
+ *
+ * @param string $date_gmt GMT publication time.
+ * @param string|null $date Optional. Local publication time. Default null.
+ * @return string|null ISO8601/RFC3339 formatted datetime, otherwise null.
+ */
+ protected function prepare_date_response( $date_gmt, $date = null ) {
+ if ( '0000-00-00 00:00:00' === $date_gmt ) {
+ return null;
+ }
+
+ if ( isset( $date ) ) {
+ return mysql_to_rfc3339( $date );
+ }
+
+ return mysql_to_rfc3339( $date_gmt );
+ }
+
+ /**
+ * Retrieves the revision's schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => "{$this->parent_post_type}-revision",
+ 'type' => 'object',
+ // Base properties for every Revision.
+ 'properties' => array(
+ 'author' => array(
+ 'description' => __( 'The ID for the author of the revision.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'date' => array(
+ 'description' => __( "The date the revision was published, in the site's timezone." ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'date_gmt' => array(
+ 'description' => __( 'The date the revision was published, as GMT.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'guid' => array(
+ 'description' => __( 'GUID for the revision, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the revision.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'modified' => array(
+ 'description' => __( "The date the revision was last modified, in the site's timezone." ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'modified_gmt' => array(
+ 'description' => __( 'The date the revision was last modified, as GMT.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'parent' => array(
+ 'description' => __( 'The ID for the parent of the revision.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'slug' => array(
+ 'description' => __( 'An alphanumeric identifier for the revision unique to its type.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ ),
+ );
+
+ $parent_schema = $this->parent_controller->get_item_schema();
+
+ if ( ! empty( $parent_schema['properties']['title'] ) ) {
+ $schema['properties']['title'] = $parent_schema['properties']['title'];
+ }
+
+ if ( ! empty( $parent_schema['properties']['content'] ) ) {
+ $schema['properties']['content'] = $parent_schema['properties']['content'];
+ }
+
+ if ( ! empty( $parent_schema['properties']['excerpt'] ) ) {
+ $schema['properties']['excerpt'] = $parent_schema['properties']['excerpt'];
+ }
+
+ if ( ! empty( $parent_schema['properties']['guid'] ) ) {
+ $schema['properties']['guid'] = $parent_schema['properties']['guid'];
+ }
+
+ $schema['properties']['meta'] = $this->meta->get_field_schema();
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * @since 4.7.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $query_params = parent::get_collection_params();
+
+ $query_params['context']['default'] = 'view';
+
+ unset( $query_params['per_page']['default'] );
+
+ $query_params['exclude'] = array(
+ 'description' => __( 'Ensure result set excludes specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ $query_params['include'] = array(
+ 'description' => __( 'Limit result set to specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ $query_params['offset'] = array(
+ 'description' => __( 'Offset the result set by a specific number of items.' ),
+ 'type' => 'integer',
+ );
+
+ $query_params['order'] = array(
+ 'description' => __( 'Order sort attribute ascending or descending.' ),
+ 'type' => 'string',
+ 'default' => 'desc',
+ 'enum' => array( 'asc', 'desc' ),
+ );
+
+ $query_params['orderby'] = array(
+ 'description' => __( 'Sort collection by object attribute.' ),
+ 'type' => 'string',
+ 'default' => 'date',
+ 'enum' => array(
+ 'date',
+ 'id',
+ 'include',
+ 'relevance',
+ 'slug',
+ 'include_slugs',
+ 'title',
+ ),
+ );
+
+ return $query_params;
+ }
+
+ /**
+ * Checks the post excerpt and prepare it for single post output.
+ *
+ * @since 4.7.0
+ *
+ * @param string $excerpt The post excerpt.
+ * @param WP_Post $post Post revision object.
+ * @return string Prepared excerpt or empty string.
+ */
+ protected function prepare_excerpt_response( $excerpt, $post ) {
+
+ /** This filter is documented in wp-includes/post-template.php */
+ $excerpt = apply_filters( 'the_excerpt', $excerpt, $post );
+
+ if ( empty( $excerpt ) ) {
+ return '';
+ }
+
+ return $excerpt;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-search-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-search-controller.php
new file mode 100644
index 0000000..4c44f67
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-search-controller.php
@@ -0,0 +1,408 @@
+<?php
+/**
+ * REST API: WP_REST_Search_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.0.0
+ */
+
+/**
+ * Core class to search through all WordPress content via the REST API.
+ *
+ * @since 5.0.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Search_Controller extends WP_REST_Controller {
+
+ /**
+ * ID property name.
+ */
+ const PROP_ID = 'id';
+
+ /**
+ * Title property name.
+ */
+ const PROP_TITLE = 'title';
+
+ /**
+ * URL property name.
+ */
+ const PROP_URL = 'url';
+
+ /**
+ * Type property name.
+ */
+ const PROP_TYPE = 'type';
+
+ /**
+ * Subtype property name.
+ */
+ const PROP_SUBTYPE = 'subtype';
+
+ /**
+ * Identifier for the 'any' type.
+ */
+ const TYPE_ANY = 'any';
+
+ /**
+ * Search handlers used by the controller.
+ *
+ * @since 5.0.0
+ * @var WP_REST_Search_Handler[]
+ */
+ protected $search_handlers = array();
+
+ /**
+ * Constructor.
+ *
+ * @since 5.0.0
+ *
+ * @param array $search_handlers List of search handlers to use in the controller. Each search
+ * handler instance must extend the `WP_REST_Search_Handler` class.
+ */
+ public function __construct( array $search_handlers ) {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'search';
+
+ foreach ( $search_handlers as $search_handler ) {
+ if ( ! $search_handler instanceof WP_REST_Search_Handler ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: PHP class name. */
+ sprintf( __( 'REST search handlers must extend the %s class.' ), 'WP_REST_Search_Handler' ),
+ '5.0.0'
+ );
+ continue;
+ }
+
+ $this->search_handlers[ $search_handler->get_type() ] = $search_handler;
+ }
+ }
+
+ /**
+ * Registers the routes for the search controller.
+ *
+ * @since 5.0.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permission_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to search content.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has search access, WP_Error object otherwise.
+ */
+ public function get_items_permission_check( $request ) {
+ return true;
+ }
+
+ /**
+ * Retrieves a collection of search results.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $handler = $this->get_search_handler( $request );
+ if ( is_wp_error( $handler ) ) {
+ return $handler;
+ }
+
+ $result = $handler->search_items( $request );
+
+ if ( ! isset( $result[ WP_REST_Search_Handler::RESULT_IDS ] ) || ! is_array( $result[ WP_REST_Search_Handler::RESULT_IDS ] ) || ! isset( $result[ WP_REST_Search_Handler::RESULT_TOTAL ] ) ) {
+ return new WP_Error(
+ 'rest_search_handler_error',
+ __( 'Internal search handler error.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $ids = $result[ WP_REST_Search_Handler::RESULT_IDS ];
+
+ $results = array();
+
+ foreach ( $ids as $id ) {
+ $data = $this->prepare_item_for_response( $id, $request );
+ $results[] = $this->prepare_response_for_collection( $data );
+ }
+
+ $total = (int) $result[ WP_REST_Search_Handler::RESULT_TOTAL ];
+ $page = (int) $request['page'];
+ $per_page = (int) $request['per_page'];
+ $max_pages = ceil( $total / $per_page );
+
+ if ( $page > $max_pages && $total > 0 ) {
+ return new WP_Error(
+ 'rest_search_invalid_page_number',
+ __( 'The page number requested is larger than the number of pages available.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $response = rest_ensure_response( $results );
+ $response->header( 'X-WP-Total', $total );
+ $response->header( 'X-WP-TotalPages', $max_pages );
+
+ $request_params = $request->get_query_params();
+ $base = add_query_arg( urlencode_deep( $request_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) );
+
+ if ( $page > 1 ) {
+ $prev_link = add_query_arg( 'page', $page - 1, $base );
+ $response->link_header( 'prev', $prev_link );
+ }
+ if ( $page < $max_pages ) {
+ $next_link = add_query_arg( 'page', $page + 1, $base );
+ $response->link_header( 'next', $next_link );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Prepares a single search result for response.
+ *
+ * @since 5.0.0
+ * @since 5.6.0 The `$id` parameter can accept a string.
+ * @since 5.9.0 Renamed `$id` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param int|string $item ID of the item to prepare.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $item_id = $item;
+
+ $handler = $this->get_search_handler( $request );
+ if ( is_wp_error( $handler ) ) {
+ return new WP_REST_Response();
+ }
+
+ $fields = $this->get_fields_for_response( $request );
+
+ $data = $handler->prepare_item( $item_id, $fields );
+ $data = $this->add_additional_fields_to_object( $data, $request );
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $links = $handler->prepare_item_links( $item_id );
+ $links['collection'] = array(
+ 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+ );
+ $response->add_links( $links );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Retrieves the item schema, conforming to JSON Schema.
+ *
+ * @since 5.0.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $types = array();
+ $subtypes = array();
+
+ foreach ( $this->search_handlers as $search_handler ) {
+ $types[] = $search_handler->get_type();
+ $subtypes = array_merge( $subtypes, $search_handler->get_subtypes() );
+ }
+
+ $types = array_unique( $types );
+ $subtypes = array_unique( $subtypes );
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'search-result',
+ 'type' => 'object',
+ 'properties' => array(
+ self::PROP_ID => array(
+ 'description' => __( 'Unique identifier for the object.' ),
+ 'type' => array( 'integer', 'string' ),
+ 'context' => array( 'view', 'embed' ),
+ 'readonly' => true,
+ ),
+ self::PROP_TITLE => array(
+ 'description' => __( 'The title for the object.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'embed' ),
+ 'readonly' => true,
+ ),
+ self::PROP_URL => array(
+ 'description' => __( 'URL to the object.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'view', 'embed' ),
+ 'readonly' => true,
+ ),
+ self::PROP_TYPE => array(
+ 'description' => __( 'Object type.' ),
+ 'type' => 'string',
+ 'enum' => $types,
+ 'context' => array( 'view', 'embed' ),
+ 'readonly' => true,
+ ),
+ self::PROP_SUBTYPE => array(
+ 'description' => __( 'Object subtype.' ),
+ 'type' => 'string',
+ 'enum' => $subtypes,
+ 'context' => array( 'view', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for the search results collection.
+ *
+ * @since 5.0.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $types = array();
+ $subtypes = array();
+
+ foreach ( $this->search_handlers as $search_handler ) {
+ $types[] = $search_handler->get_type();
+ $subtypes = array_merge( $subtypes, $search_handler->get_subtypes() );
+ }
+
+ $types = array_unique( $types );
+ $subtypes = array_unique( $subtypes );
+
+ $query_params = parent::get_collection_params();
+
+ $query_params['context']['default'] = 'view';
+
+ $query_params[ self::PROP_TYPE ] = array(
+ 'default' => $types[0],
+ 'description' => __( 'Limit results to items of an object type.' ),
+ 'type' => 'string',
+ 'enum' => $types,
+ );
+
+ $query_params[ self::PROP_SUBTYPE ] = array(
+ 'default' => self::TYPE_ANY,
+ 'description' => __( 'Limit results to items of one or more object subtypes.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'enum' => array_merge( $subtypes, array( self::TYPE_ANY ) ),
+ 'type' => 'string',
+ ),
+ 'sanitize_callback' => array( $this, 'sanitize_subtypes' ),
+ );
+
+ $query_params['exclude'] = array(
+ 'description' => __( 'Ensure result set excludes specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ $query_params['include'] = array(
+ 'description' => __( 'Limit result set to specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ return $query_params;
+ }
+
+ /**
+ * Sanitizes the list of subtypes, to ensure only subtypes of the passed type are included.
+ *
+ * @since 5.0.0
+ *
+ * @param string|array $subtypes One or more subtypes.
+ * @param WP_REST_Request $request Full details about the request.
+ * @param string $parameter Parameter name.
+ * @return string[]|WP_Error List of valid subtypes, or WP_Error object on failure.
+ */
+ public function sanitize_subtypes( $subtypes, $request, $parameter ) {
+ $subtypes = wp_parse_slug_list( $subtypes );
+
+ $subtypes = rest_parse_request_arg( $subtypes, $request, $parameter );
+ if ( is_wp_error( $subtypes ) ) {
+ return $subtypes;
+ }
+
+ // 'any' overrides any other subtype.
+ if ( in_array( self::TYPE_ANY, $subtypes, true ) ) {
+ return array( self::TYPE_ANY );
+ }
+
+ $handler = $this->get_search_handler( $request );
+ if ( is_wp_error( $handler ) ) {
+ return $handler;
+ }
+
+ return array_intersect( $subtypes, $handler->get_subtypes() );
+ }
+
+ /**
+ * Gets the search handler to handle the current request.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Search_Handler|WP_Error Search handler for the request type, or WP_Error object on failure.
+ */
+ protected function get_search_handler( $request ) {
+ $type = $request->get_param( self::PROP_TYPE );
+
+ if ( ! $type || ! isset( $this->search_handlers[ $type ] ) ) {
+ return new WP_Error(
+ 'rest_search_invalid_type',
+ __( 'Invalid type parameter.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return $this->search_handlers[ $type ];
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php
new file mode 100644
index 0000000..365afd3
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php
@@ -0,0 +1,342 @@
+<?php
+/**
+ * REST API: WP_REST_Settings_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class used to manage a site's settings via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Settings_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructor.
+ *
+ * @since 4.7.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'settings';
+ }
+
+ /**
+ * Registers the routes for the site's settings.
+ *
+ * @since 4.7.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'args' => array(),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to read and manage settings.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return bool True if the request has read access for the item, otherwise false.
+ */
+ public function get_item_permissions_check( $request ) {
+ return current_user_can( 'manage_options' );
+ }
+
+ /**
+ * Retrieves the settings.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return array|WP_Error Array on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $options = $this->get_registered_options();
+ $response = array();
+
+ foreach ( $options as $name => $args ) {
+ /**
+ * Filters the value of a setting recognized by the REST API.
+ *
+ * Allow hijacking the setting value and overriding the built-in behavior by returning a
+ * non-null value. The returned value will be presented as the setting value instead.
+ *
+ * @since 4.7.0
+ *
+ * @param mixed $result Value to use for the requested setting. Can be a scalar
+ * matching the registered schema for the setting, or null to
+ * follow the default get_option() behavior.
+ * @param string $name Setting name (as shown in REST API responses).
+ * @param array $args Arguments passed to register_setting() for this setting.
+ */
+ $response[ $name ] = apply_filters( 'rest_pre_get_setting', null, $name, $args );
+
+ if ( is_null( $response[ $name ] ) ) {
+ // Default to a null value as "null" in the response means "not set".
+ $response[ $name ] = get_option( $args['option_name'], $args['schema']['default'] );
+ }
+
+ /*
+ * Because get_option() is lossy, we have to
+ * cast values to the type they are registered with.
+ */
+ $response[ $name ] = $this->prepare_value( $response[ $name ], $args['schema'] );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Prepares a value for output based off a schema array.
+ *
+ * @since 4.7.0
+ *
+ * @param mixed $value Value to prepare.
+ * @param array $schema Schema to match.
+ * @return mixed The prepared value.
+ */
+ protected function prepare_value( $value, $schema ) {
+ /*
+ * If the value is not valid by the schema, set the value to null.
+ * Null values are specifically non-destructive, so this will not cause
+ * overwriting the current invalid value to null.
+ */
+ if ( is_wp_error( rest_validate_value_from_schema( $value, $schema ) ) ) {
+ return null;
+ }
+
+ return rest_sanitize_value_from_schema( $value, $schema );
+ }
+
+ /**
+ * Updates settings for the settings object.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return array|WP_Error Array on success, or error object on failure.
+ */
+ public function update_item( $request ) {
+ $options = $this->get_registered_options();
+
+ $params = $request->get_params();
+
+ foreach ( $options as $name => $args ) {
+ if ( ! array_key_exists( $name, $params ) ) {
+ continue;
+ }
+
+ /**
+ * Filters whether to preempt a setting value update via the REST API.
+ *
+ * Allows hijacking the setting update logic and overriding the built-in behavior by
+ * returning true.
+ *
+ * @since 4.7.0
+ *
+ * @param bool $result Whether to override the default behavior for updating the
+ * value of a setting.
+ * @param string $name Setting name (as shown in REST API responses).
+ * @param mixed $value Updated setting value.
+ * @param array $args Arguments passed to register_setting() for this setting.
+ */
+ $updated = apply_filters( 'rest_pre_update_setting', false, $name, $request[ $name ], $args );
+
+ if ( $updated ) {
+ continue;
+ }
+
+ /*
+ * A null value for an option would have the same effect as
+ * deleting the option from the database, and relying on the
+ * default value.
+ */
+ if ( is_null( $request[ $name ] ) ) {
+ /*
+ * A null value is returned in the response for any option
+ * that has a non-scalar value.
+ *
+ * To protect clients from accidentally including the null
+ * values from a response object in a request, we do not allow
+ * options with values that don't pass validation to be updated to null.
+ * Without this added protection a client could mistakenly
+ * delete all options that have invalid values from the
+ * database.
+ */
+ if ( is_wp_error( rest_validate_value_from_schema( get_option( $args['option_name'], false ), $args['schema'] ) ) ) {
+ return new WP_Error(
+ 'rest_invalid_stored_value',
+ /* translators: %s: Property name. */
+ sprintf( __( 'The %s property has an invalid stored value, and cannot be updated to null.' ), $name ),
+ array( 'status' => 500 )
+ );
+ }
+
+ delete_option( $args['option_name'] );
+ } else {
+ update_option( $args['option_name'], $request[ $name ] );
+ }
+ }
+
+ return $this->get_item( $request );
+ }
+
+ /**
+ * Retrieves all of the registered options for the Settings API.
+ *
+ * @since 4.7.0
+ *
+ * @return array Array of registered options.
+ */
+ protected function get_registered_options() {
+ $rest_options = array();
+
+ foreach ( get_registered_settings() as $name => $args ) {
+ if ( empty( $args['show_in_rest'] ) ) {
+ continue;
+ }
+
+ $rest_args = array();
+
+ if ( is_array( $args['show_in_rest'] ) ) {
+ $rest_args = $args['show_in_rest'];
+ }
+
+ $defaults = array(
+ 'name' => ! empty( $rest_args['name'] ) ? $rest_args['name'] : $name,
+ 'schema' => array(),
+ );
+
+ $rest_args = array_merge( $defaults, $rest_args );
+
+ $default_schema = array(
+ 'type' => empty( $args['type'] ) ? null : $args['type'],
+ 'description' => empty( $args['description'] ) ? '' : $args['description'],
+ 'default' => isset( $args['default'] ) ? $args['default'] : null,
+ );
+
+ $rest_args['schema'] = array_merge( $default_schema, $rest_args['schema'] );
+ $rest_args['option_name'] = $name;
+
+ // Skip over settings that don't have a defined type in the schema.
+ if ( empty( $rest_args['schema']['type'] ) ) {
+ continue;
+ }
+
+ /*
+ * Allow the supported types for settings, as we don't want invalid types
+ * to be updated with arbitrary values that we can't do decent sanitizing for.
+ */
+ if ( ! in_array( $rest_args['schema']['type'], array( 'number', 'integer', 'string', 'boolean', 'array', 'object' ), true ) ) {
+ continue;
+ }
+
+ $rest_args['schema'] = rest_default_additional_properties_to_false( $rest_args['schema'] );
+
+ $rest_options[ $rest_args['name'] ] = $rest_args;
+ }
+
+ return $rest_options;
+ }
+
+ /**
+ * Retrieves the site setting schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $options = $this->get_registered_options();
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'settings',
+ 'type' => 'object',
+ 'properties' => array(),
+ );
+
+ foreach ( $options as $option_name => $option ) {
+ $schema['properties'][ $option_name ] = $option['schema'];
+ $schema['properties'][ $option_name ]['arg_options'] = array(
+ 'sanitize_callback' => array( $this, 'sanitize_callback' ),
+ );
+ }
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Custom sanitize callback used for all options to allow the use of 'null'.
+ *
+ * By default, the schema of settings will throw an error if a value is set to
+ * `null` as it's not a valid value for something like "type => string". We
+ * provide a wrapper sanitizer to allow the use of `null`.
+ *
+ * @since 4.7.0
+ *
+ * @param mixed $value The value for the setting.
+ * @param WP_REST_Request $request The request object.
+ * @param string $param The parameter name.
+ * @return mixed|WP_Error
+ */
+ public function sanitize_callback( $value, $request, $param ) {
+ if ( is_null( $value ) ) {
+ return $value;
+ }
+
+ return rest_parse_request_arg( $value, $request, $param );
+ }
+
+ /**
+ * Recursively add additionalProperties = false to all objects in a schema
+ * if no additionalProperties setting is specified.
+ *
+ * This is needed to restrict properties of objects in settings values to only
+ * registered items, as the REST API will allow additional properties by
+ * default.
+ *
+ * @since 4.9.0
+ * @deprecated 6.1.0 Use {@see rest_default_additional_properties_to_false()} instead.
+ *
+ * @param array $schema The schema array.
+ * @return array
+ */
+ protected function set_additional_properties_to_false( $schema ) {
+ _deprecated_function( __METHOD__, '6.1.0', 'rest_default_additional_properties_to_false()' );
+
+ return rest_default_additional_properties_to_false( $schema );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-sidebars-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-sidebars-controller.php
new file mode 100644
index 0000000..4ff1007
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-sidebars-controller.php
@@ -0,0 +1,509 @@
+<?php
+/**
+ * REST API: WP_REST_Sidebars_Controller class
+ *
+ * Original code from {@link https://github.com/martin-pettersson/wp-rest-api-sidebars Martin Pettersson (martin_pettersson@outlook.com)}.
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.8.0
+ */
+
+/**
+ * Core class used to manage a site's sidebars.
+ *
+ * @since 5.8.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Sidebars_Controller extends WP_REST_Controller {
+
+ /**
+ * Tracks whether {@see retrieve_widgets()} has been called in the current request.
+ *
+ * @since 5.9.0
+ * @var bool
+ */
+ protected $widgets_retrieved = false;
+
+ /**
+ * Sidebars controller constructor.
+ *
+ * @since 5.8.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'sidebars';
+ }
+
+ /**
+ * Registers the controllers routes.
+ *
+ * @since 5.8.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[\w-]+)',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The id of a registered sidebar' ),
+ 'type' => 'string',
+ ),
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'permission_callback' => array( $this, 'update_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to get sidebars.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ $this->retrieve_widgets();
+ foreach ( wp_get_sidebars_widgets() as $id => $widgets ) {
+ $sidebar = $this->get_sidebar( $id );
+
+ if ( ! $sidebar ) {
+ continue;
+ }
+
+ if ( $this->check_read_permission( $sidebar ) ) {
+ return true;
+ }
+ }
+
+ return $this->do_permissions_check();
+ }
+
+ /**
+ * Retrieves the list of sidebars (active or inactive).
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response Response object on success.
+ */
+ public function get_items( $request ) {
+ $this->retrieve_widgets();
+
+ $data = array();
+ $permissions_check = $this->do_permissions_check();
+
+ foreach ( wp_get_sidebars_widgets() as $id => $widgets ) {
+ $sidebar = $this->get_sidebar( $id );
+
+ if ( ! $sidebar ) {
+ continue;
+ }
+
+ if ( is_wp_error( $permissions_check ) && ! $this->check_read_permission( $sidebar ) ) {
+ continue;
+ }
+
+ $data[] = $this->prepare_response_for_collection(
+ $this->prepare_item_for_response( $sidebar, $request )
+ );
+ }
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Checks if a given request has access to get a single sidebar.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $this->retrieve_widgets();
+
+ $sidebar = $this->get_sidebar( $request['id'] );
+ if ( $sidebar && $this->check_read_permission( $sidebar ) ) {
+ return true;
+ }
+
+ return $this->do_permissions_check();
+ }
+
+ /**
+ * Checks if a sidebar can be read publicly.
+ *
+ * @since 5.9.0
+ *
+ * @param array $sidebar The registered sidebar configuration.
+ * @return bool Whether the side can be read.
+ */
+ protected function check_read_permission( $sidebar ) {
+ return ! empty( $sidebar['show_in_rest'] );
+ }
+
+ /**
+ * Retrieves one sidebar from the collection.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $this->retrieve_widgets();
+
+ $sidebar = $this->get_sidebar( $request['id'] );
+ if ( ! $sidebar ) {
+ return new WP_Error( 'rest_sidebar_not_found', __( 'No sidebar exists with that id.' ), array( 'status' => 404 ) );
+ }
+
+ return $this->prepare_item_for_response( $sidebar, $request );
+ }
+
+ /**
+ * Checks if a given request has access to update sidebars.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function update_item_permissions_check( $request ) {
+ return $this->do_permissions_check();
+ }
+
+ /**
+ * Updates a sidebar.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ if ( isset( $request['widgets'] ) ) {
+ $sidebars = wp_get_sidebars_widgets();
+
+ foreach ( $sidebars as $sidebar_id => $widgets ) {
+ foreach ( $widgets as $i => $widget_id ) {
+ // This automatically removes the passed widget IDs from any other sidebars in use.
+ if ( $sidebar_id !== $request['id'] && in_array( $widget_id, $request['widgets'], true ) ) {
+ unset( $sidebars[ $sidebar_id ][ $i ] );
+ }
+
+ // This automatically removes omitted widget IDs to the inactive sidebar.
+ if ( $sidebar_id === $request['id'] && ! in_array( $widget_id, $request['widgets'], true ) ) {
+ $sidebars['wp_inactive_widgets'][] = $widget_id;
+ }
+ }
+ }
+
+ $sidebars[ $request['id'] ] = $request['widgets'];
+
+ wp_set_sidebars_widgets( $sidebars );
+ }
+
+ $request['context'] = 'edit';
+
+ $sidebar = $this->get_sidebar( $request['id'] );
+
+ /**
+ * Fires after a sidebar is updated via the REST API.
+ *
+ * @since 5.8.0
+ *
+ * @param array $sidebar The updated sidebar.
+ * @param WP_REST_Request $request Request object.
+ */
+ do_action( 'rest_save_sidebar', $sidebar, $request );
+
+ return $this->prepare_item_for_response( $sidebar, $request );
+ }
+
+ /**
+ * Checks if the user has permissions to make the request.
+ *
+ * @since 5.8.0
+ *
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ protected function do_permissions_check() {
+ /*
+ * Verify if the current user has edit_theme_options capability.
+ * This capability is required to access the widgets screen.
+ */
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'rest_cannot_manage_widgets',
+ __( 'Sorry, you are not allowed to manage widgets on this site.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves the registered sidebar with the given id.
+ *
+ * @since 5.8.0
+ *
+ * @param string|int $id ID of the sidebar.
+ * @return array|null The discovered sidebar, or null if it is not registered.
+ */
+ protected function get_sidebar( $id ) {
+ return wp_get_sidebar( $id );
+ }
+
+ /**
+ * Looks for "lost" widgets once per request.
+ *
+ * @since 5.9.0
+ *
+ * @see retrieve_widgets()
+ */
+ protected function retrieve_widgets() {
+ if ( ! $this->widgets_retrieved ) {
+ retrieve_widgets();
+ $this->widgets_retrieved = true;
+ }
+ }
+
+ /**
+ * Prepares a single sidebar output for response.
+ *
+ * @since 5.8.0
+ * @since 5.9.0 Renamed `$raw_sidebar` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @global array $wp_registered_sidebars The registered sidebars.
+ * @global array $wp_registered_widgets The registered widgets.
+ *
+ * @param array $item Sidebar instance.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response Prepared response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ global $wp_registered_sidebars, $wp_registered_widgets;
+
+ // Restores the more descriptive, specific name for use within this method.
+ $raw_sidebar = $item;
+
+ $id = $raw_sidebar['id'];
+ $sidebar = array( 'id' => $id );
+
+ if ( isset( $wp_registered_sidebars[ $id ] ) ) {
+ $registered_sidebar = $wp_registered_sidebars[ $id ];
+
+ $sidebar['status'] = 'active';
+ $sidebar['name'] = isset( $registered_sidebar['name'] ) ? $registered_sidebar['name'] : '';
+ $sidebar['description'] = isset( $registered_sidebar['description'] ) ? wp_sidebar_description( $id ) : '';
+ $sidebar['class'] = isset( $registered_sidebar['class'] ) ? $registered_sidebar['class'] : '';
+ $sidebar['before_widget'] = isset( $registered_sidebar['before_widget'] ) ? $registered_sidebar['before_widget'] : '';
+ $sidebar['after_widget'] = isset( $registered_sidebar['after_widget'] ) ? $registered_sidebar['after_widget'] : '';
+ $sidebar['before_title'] = isset( $registered_sidebar['before_title'] ) ? $registered_sidebar['before_title'] : '';
+ $sidebar['after_title'] = isset( $registered_sidebar['after_title'] ) ? $registered_sidebar['after_title'] : '';
+ } else {
+ $sidebar['status'] = 'inactive';
+ $sidebar['name'] = $raw_sidebar['name'];
+ $sidebar['description'] = '';
+ $sidebar['class'] = '';
+ }
+
+ if ( wp_is_block_theme() ) {
+ $sidebar['status'] = 'inactive';
+ }
+
+ $fields = $this->get_fields_for_response( $request );
+ if ( rest_is_field_included( 'widgets', $fields ) ) {
+ $sidebars = wp_get_sidebars_widgets();
+ $widgets = array_filter(
+ isset( $sidebars[ $sidebar['id'] ] ) ? $sidebars[ $sidebar['id'] ] : array(),
+ static function ( $widget_id ) use ( $wp_registered_widgets ) {
+ return isset( $wp_registered_widgets[ $widget_id ] );
+ }
+ );
+
+ $sidebar['widgets'] = array_values( $widgets );
+ }
+
+ $schema = $this->get_item_schema();
+ $data = array();
+ foreach ( $schema['properties'] as $property_id => $property ) {
+ if ( isset( $sidebar[ $property_id ] ) && true === rest_validate_value_from_schema( $sidebar[ $property_id ], $property ) ) {
+ $data[ $property_id ] = $sidebar[ $property_id ];
+ } elseif ( isset( $property['default'] ) ) {
+ $data[ $property_id ] = $property['default'];
+ }
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $sidebar ) );
+ }
+
+ /**
+ * Filters the REST API response for a sidebar.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param array $raw_sidebar The raw sidebar data.
+ * @param WP_REST_Request $request The request object.
+ */
+ return apply_filters( 'rest_prepare_sidebar', $response, $raw_sidebar, $request );
+ }
+
+ /**
+ * Prepares links for the sidebar.
+ *
+ * @since 5.8.0
+ *
+ * @param array $sidebar Sidebar.
+ * @return array Links for the given widget.
+ */
+ protected function prepare_links( $sidebar ) {
+ return array(
+ 'collection' => array(
+ 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+ ),
+ 'self' => array(
+ 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $sidebar['id'] ) ),
+ ),
+ 'https://api.w.org/widget' => array(
+ 'href' => add_query_arg( 'sidebar', $sidebar['id'], rest_url( '/wp/v2/widgets' ) ),
+ 'embeddable' => true,
+ ),
+ );
+ }
+
+ /**
+ * Retrieves the block type' schema, conforming to JSON Schema.
+ *
+ * @since 5.8.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'sidebar',
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'ID of sidebar.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'Unique name identifying the sidebar.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'description' => __( 'Description of sidebar.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'class' => array(
+ 'description' => __( 'Extra CSS class to assign to the sidebar in the Widgets interface.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'before_widget' => array(
+ 'description' => __( 'HTML content to prepend to each widget\'s HTML output when assigned to this sidebar. Default is an opening list item element.' ),
+ 'type' => 'string',
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'after_widget' => array(
+ 'description' => __( 'HTML content to append to each widget\'s HTML output when assigned to this sidebar. Default is a closing list item element.' ),
+ 'type' => 'string',
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'before_title' => array(
+ 'description' => __( 'HTML content to prepend to the sidebar title when displayed. Default is an opening h2 element.' ),
+ 'type' => 'string',
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'after_title' => array(
+ 'description' => __( 'HTML content to append to the sidebar title when displayed. Default is a closing h2 element.' ),
+ 'type' => 'string',
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'status' => array(
+ 'description' => __( 'Status of sidebar.' ),
+ 'type' => 'string',
+ 'enum' => array( 'active', 'inactive' ),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'widgets' => array(
+ 'description' => __( 'Nested widgets.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => array( 'object', 'string' ),
+ ),
+ 'default' => array(),
+ 'context' => array( 'embed', 'view', 'edit' ),
+ ),
+ ),
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-site-health-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-site-health-controller.php
new file mode 100644
index 0000000..41e1533
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-site-health-controller.php
@@ -0,0 +1,407 @@
+<?php
+/**
+ * REST API: WP_REST_Site_Health_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.6.0
+ */
+
+/**
+ * Core class for interacting with Site Health tests.
+ *
+ * @since 5.6.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Site_Health_Controller extends WP_REST_Controller {
+
+ /**
+ * An instance of the site health class.
+ *
+ * @since 5.6.0
+ *
+ * @var WP_Site_Health
+ */
+ private $site_health;
+
+ /**
+ * Site Health controller constructor.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_Site_Health $site_health An instance of the site health class.
+ */
+ public function __construct( $site_health ) {
+ $this->namespace = 'wp-site-health/v1';
+ $this->rest_base = 'tests';
+
+ $this->site_health = $site_health;
+ }
+
+ /**
+ * Registers API routes.
+ *
+ * @since 5.6.0
+ * @since 6.1.0 Adds page-cache async test.
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ sprintf(
+ '/%s/%s',
+ $this->rest_base,
+ 'background-updates'
+ ),
+ array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( $this, 'test_background_updates' ),
+ 'permission_callback' => function () {
+ return $this->validate_request_permission( 'background_updates' );
+ },
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ sprintf(
+ '/%s/%s',
+ $this->rest_base,
+ 'loopback-requests'
+ ),
+ array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( $this, 'test_loopback_requests' ),
+ 'permission_callback' => function () {
+ return $this->validate_request_permission( 'loopback_requests' );
+ },
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ sprintf(
+ '/%s/%s',
+ $this->rest_base,
+ 'https-status'
+ ),
+ array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( $this, 'test_https_status' ),
+ 'permission_callback' => function () {
+ return $this->validate_request_permission( 'https_status' );
+ },
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ sprintf(
+ '/%s/%s',
+ $this->rest_base,
+ 'dotorg-communication'
+ ),
+ array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( $this, 'test_dotorg_communication' ),
+ 'permission_callback' => function () {
+ return $this->validate_request_permission( 'dotorg_communication' );
+ },
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ sprintf(
+ '/%s/%s',
+ $this->rest_base,
+ 'authorization-header'
+ ),
+ array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( $this, 'test_authorization_header' ),
+ 'permission_callback' => function () {
+ return $this->validate_request_permission( 'authorization_header' );
+ },
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ sprintf(
+ '/%s',
+ 'directory-sizes'
+ ),
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( $this, 'get_directory_sizes' ),
+ 'permission_callback' => function () {
+ return $this->validate_request_permission( 'directory_sizes' ) && ! is_multisite();
+ },
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ sprintf(
+ '/%s/%s',
+ $this->rest_base,
+ 'page-cache'
+ ),
+ array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( $this, 'test_page_cache' ),
+ 'permission_callback' => function () {
+ return $this->validate_request_permission( 'page_cache' );
+ },
+ ),
+ )
+ );
+ }
+
+ /**
+ * Validates if the current user can request this REST endpoint.
+ *
+ * @since 5.6.0
+ *
+ * @param string $check The endpoint check being ran.
+ * @return bool
+ */
+ protected function validate_request_permission( $check ) {
+ $default_capability = 'view_site_health_checks';
+
+ /**
+ * Filters the capability needed to run a given Site Health check.
+ *
+ * @since 5.6.0
+ *
+ * @param string $default_capability The default capability required for this check.
+ * @param string $check The Site Health check being performed.
+ */
+ $capability = apply_filters( "site_health_test_rest_capability_{$check}", $default_capability, $check );
+
+ return current_user_can( $capability );
+ }
+
+ /**
+ * Checks if background updates work as expected.
+ *
+ * @since 5.6.0
+ *
+ * @return array
+ */
+ public function test_background_updates() {
+ $this->load_admin_textdomain();
+ return $this->site_health->get_test_background_updates();
+ }
+
+ /**
+ * Checks that the site can reach the WordPress.org API.
+ *
+ * @since 5.6.0
+ *
+ * @return array
+ */
+ public function test_dotorg_communication() {
+ $this->load_admin_textdomain();
+ return $this->site_health->get_test_dotorg_communication();
+ }
+
+ /**
+ * Checks that loopbacks can be performed.
+ *
+ * @since 5.6.0
+ *
+ * @return array
+ */
+ public function test_loopback_requests() {
+ $this->load_admin_textdomain();
+ return $this->site_health->get_test_loopback_requests();
+ }
+
+ /**
+ * Checks that the site's frontend can be accessed over HTTPS.
+ *
+ * @since 5.7.0
+ *
+ * @return array
+ */
+ public function test_https_status() {
+ $this->load_admin_textdomain();
+ return $this->site_health->get_test_https_status();
+ }
+
+ /**
+ * Checks that the authorization header is valid.
+ *
+ * @since 5.6.0
+ *
+ * @return array
+ */
+ public function test_authorization_header() {
+ $this->load_admin_textdomain();
+ return $this->site_health->get_test_authorization_header();
+ }
+
+ /**
+ * Checks that full page cache is active.
+ *
+ * @since 6.1.0
+ *
+ * @return array The test result.
+ */
+ public function test_page_cache() {
+ $this->load_admin_textdomain();
+ return $this->site_health->get_test_page_cache();
+ }
+
+ /**
+ * Gets the current directory sizes for this install.
+ *
+ * @since 5.6.0
+ *
+ * @return array|WP_Error
+ */
+ public function get_directory_sizes() {
+ if ( ! class_exists( 'WP_Debug_Data' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/class-wp-debug-data.php';
+ }
+
+ $this->load_admin_textdomain();
+
+ $sizes_data = WP_Debug_Data::get_sizes();
+ $all_sizes = array( 'raw' => 0 );
+
+ foreach ( $sizes_data as $name => $value ) {
+ $name = sanitize_text_field( $name );
+ $data = array();
+
+ if ( isset( $value['size'] ) ) {
+ if ( is_string( $value['size'] ) ) {
+ $data['size'] = sanitize_text_field( $value['size'] );
+ } else {
+ $data['size'] = (int) $value['size'];
+ }
+ }
+
+ if ( isset( $value['debug'] ) ) {
+ if ( is_string( $value['debug'] ) ) {
+ $data['debug'] = sanitize_text_field( $value['debug'] );
+ } else {
+ $data['debug'] = (int) $value['debug'];
+ }
+ }
+
+ if ( ! empty( $value['raw'] ) ) {
+ $data['raw'] = (int) $value['raw'];
+ }
+
+ $all_sizes[ $name ] = $data;
+ }
+
+ if ( isset( $all_sizes['total_size']['debug'] ) && 'not available' === $all_sizes['total_size']['debug'] ) {
+ return new WP_Error( 'not_available', __( 'Directory sizes could not be returned.' ), array( 'status' => 500 ) );
+ }
+
+ return $all_sizes;
+ }
+
+ /**
+ * Loads the admin textdomain for Site Health tests.
+ *
+ * The {@see WP_Site_Health} class is defined in WP-Admin, while the REST API operates in a front-end context.
+ * This means that the translations for Site Health won't be loaded by default in {@see load_default_textdomain()}.
+ *
+ * @since 5.6.0
+ */
+ protected function load_admin_textdomain() {
+ // Accounts for inner REST API requests in the admin.
+ if ( ! is_admin() ) {
+ $locale = determine_locale();
+ load_textdomain( 'default', WP_LANG_DIR . "/admin-$locale.mo", $locale );
+ }
+ }
+
+ /**
+ * Gets the schema for each site health test.
+ *
+ * @since 5.6.0
+ *
+ * @return array The test schema.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->schema;
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'wp-site-health-test',
+ 'type' => 'object',
+ 'properties' => array(
+ 'test' => array(
+ 'type' => 'string',
+ 'description' => __( 'The name of the test being run.' ),
+ 'readonly' => true,
+ ),
+ 'label' => array(
+ 'type' => 'string',
+ 'description' => __( 'A label describing the test.' ),
+ 'readonly' => true,
+ ),
+ 'status' => array(
+ 'type' => 'string',
+ 'description' => __( 'The status of the test.' ),
+ 'enum' => array( 'good', 'recommended', 'critical' ),
+ 'readonly' => true,
+ ),
+ 'badge' => array(
+ 'type' => 'object',
+ 'description' => __( 'The category this test is grouped in.' ),
+ 'properties' => array(
+ 'label' => array(
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'color' => array(
+ 'type' => 'string',
+ 'enum' => array( 'blue', 'orange', 'red', 'green', 'purple', 'gray' ),
+ 'readonly' => true,
+ ),
+ ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'type' => 'string',
+ 'description' => __( 'A more descriptive explanation of what the test looks for, and why it is important for the user.' ),
+ 'readonly' => true,
+ ),
+ 'actions' => array(
+ 'type' => 'string',
+ 'description' => __( 'HTML containing an action to direct the user to where they can resolve the issue.' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ return $this->schema;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-taxonomies-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-taxonomies-controller.php
new file mode 100644
index 0000000..6c8ddc3
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-taxonomies-controller.php
@@ -0,0 +1,452 @@
+<?php
+/**
+ * REST API: WP_REST_Taxonomies_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class used to manage taxonomies via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Taxonomies_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructor.
+ *
+ * @since 4.7.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'taxonomies';
+ }
+
+ /**
+ * Registers the routes for taxonomies.
+ *
+ * @since 4.7.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<taxonomy>[\w-]+)',
+ array(
+ 'args' => array(
+ 'taxonomy' => array(
+ 'description' => __( 'An alphanumeric identifier for the taxonomy.' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to read taxonomies.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( 'edit' === $request['context'] ) {
+ if ( ! empty( $request['type'] ) ) {
+ $taxonomies = get_object_taxonomies( $request['type'], 'objects' );
+ } else {
+ $taxonomies = get_taxonomies( '', 'objects' );
+ }
+
+ foreach ( $taxonomies as $taxonomy ) {
+ if ( ! empty( $taxonomy->show_in_rest ) && current_user_can( $taxonomy->cap->assign_terms ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view',
+ __( 'Sorry, you are not allowed to manage terms in this taxonomy.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves all public taxonomies.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+
+ // Retrieve the list of registered collection query parameters.
+ $registered = $this->get_collection_params();
+
+ if ( isset( $registered['type'] ) && ! empty( $request['type'] ) ) {
+ $taxonomies = get_object_taxonomies( $request['type'], 'objects' );
+ } else {
+ $taxonomies = get_taxonomies( '', 'objects' );
+ }
+
+ $data = array();
+
+ foreach ( $taxonomies as $tax_type => $value ) {
+ if ( empty( $value->show_in_rest ) || ( 'edit' === $request['context'] && ! current_user_can( $value->cap->assign_terms ) ) ) {
+ continue;
+ }
+
+ $tax = $this->prepare_item_for_response( $value, $request );
+ $tax = $this->prepare_response_for_collection( $tax );
+ $data[ $tax_type ] = $tax;
+ }
+
+ if ( empty( $data ) ) {
+ // Response should still be returned as a JSON object when it is empty.
+ $data = (object) $data;
+ }
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Checks if a given request has access to a taxonomy.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, otherwise false or WP_Error object.
+ */
+ public function get_item_permissions_check( $request ) {
+
+ $tax_obj = get_taxonomy( $request['taxonomy'] );
+
+ if ( $tax_obj ) {
+ if ( empty( $tax_obj->show_in_rest ) ) {
+ return false;
+ }
+
+ if ( 'edit' === $request['context'] && ! current_user_can( $tax_obj->cap->assign_terms ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to manage terms in this taxonomy.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves a specific taxonomy.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $tax_obj = get_taxonomy( $request['taxonomy'] );
+
+ if ( empty( $tax_obj ) ) {
+ return new WP_Error(
+ 'rest_taxonomy_invalid',
+ __( 'Invalid taxonomy.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $data = $this->prepare_item_for_response( $tax_obj, $request );
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Prepares a taxonomy object for serialization.
+ *
+ * @since 4.7.0
+ * @since 5.9.0 Renamed `$taxonomy` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param WP_Taxonomy $item Taxonomy data.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $taxonomy = $item;
+
+ $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( in_array( 'name', $fields, true ) ) {
+ $data['name'] = $taxonomy->label;
+ }
+
+ if ( in_array( 'slug', $fields, true ) ) {
+ $data['slug'] = $taxonomy->name;
+ }
+
+ if ( in_array( 'capabilities', $fields, true ) ) {
+ $data['capabilities'] = $taxonomy->cap;
+ }
+
+ if ( in_array( 'description', $fields, true ) ) {
+ $data['description'] = $taxonomy->description;
+ }
+
+ if ( in_array( 'labels', $fields, true ) ) {
+ $data['labels'] = $taxonomy->labels;
+ }
+
+ if ( in_array( 'types', $fields, true ) ) {
+ $data['types'] = array_values( $taxonomy->object_type );
+ }
+
+ if ( in_array( 'show_cloud', $fields, true ) ) {
+ $data['show_cloud'] = $taxonomy->show_tagcloud;
+ }
+
+ if ( in_array( 'hierarchical', $fields, true ) ) {
+ $data['hierarchical'] = $taxonomy->hierarchical;
+ }
+
+ if ( in_array( 'rest_base', $fields, true ) ) {
+ $data['rest_base'] = $base;
+ }
+
+ if ( in_array( 'rest_namespace', $fields, true ) ) {
+ $data['rest_namespace'] = $taxonomy->rest_namespace;
+ }
+
+ if ( in_array( 'visibility', $fields, true ) ) {
+ $data['visibility'] = array(
+ 'public' => (bool) $taxonomy->public,
+ 'publicly_queryable' => (bool) $taxonomy->publicly_queryable,
+ 'show_admin_column' => (bool) $taxonomy->show_admin_column,
+ 'show_in_nav_menus' => (bool) $taxonomy->show_in_nav_menus,
+ 'show_in_quick_edit' => (bool) $taxonomy->show_in_quick_edit,
+ 'show_ui' => (bool) $taxonomy->show_ui,
+ );
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ // Wrap the data in a response object.
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $taxonomy ) );
+ }
+
+ /**
+ * Filters a taxonomy returned from the REST API.
+ *
+ * Allows modification of the taxonomy data right before it is returned.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Taxonomy $item The original taxonomy object.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ return apply_filters( 'rest_prepare_taxonomy', $response, $taxonomy, $request );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 6.1.0
+ *
+ * @param WP_Taxonomy $taxonomy The taxonomy.
+ * @return array Links for the given taxonomy.
+ */
+ protected function prepare_links( $taxonomy ) {
+ return array(
+ 'collection' => array(
+ 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+ ),
+ 'https://api.w.org/items' => array(
+ 'href' => rest_url( rest_get_route_for_taxonomy_items( $taxonomy->name ) ),
+ ),
+ );
+ }
+
+ /**
+ * Retrieves the taxonomy's schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ * @since 5.0.0 The `visibility` property was added.
+ * @since 5.9.0 The `rest_namespace` property was added.
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'taxonomy',
+ 'type' => 'object',
+ 'properties' => array(
+ 'capabilities' => array(
+ 'description' => __( 'All capabilities used by the taxonomy.' ),
+ 'type' => 'object',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'description' => __( 'A human-readable description of the taxonomy.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'hierarchical' => array(
+ 'description' => __( 'Whether or not the taxonomy should have children.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'labels' => array(
+ 'description' => __( 'Human-readable labels for the taxonomy for various contexts.' ),
+ 'type' => 'object',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'The title for the taxonomy.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'slug' => array(
+ 'description' => __( 'An alphanumeric identifier for the taxonomy.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'show_cloud' => array(
+ 'description' => __( 'Whether or not the term cloud should be displayed.' ),
+ 'type' => 'boolean',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'types' => array(
+ 'description' => __( 'Types associated with the taxonomy.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'rest_base' => array(
+ 'description' => __( 'REST base route for the taxonomy.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'rest_namespace' => array(
+ 'description' => __( 'REST namespace route for the taxonomy.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'visibility' => array(
+ 'description' => __( 'The visibility settings for the taxonomy.' ),
+ 'type' => 'object',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ 'properties' => array(
+ 'public' => array(
+ 'description' => __( 'Whether a taxonomy is intended for use publicly either via the admin interface or by front-end users.' ),
+ 'type' => 'boolean',
+ ),
+ 'publicly_queryable' => array(
+ 'description' => __( 'Whether the taxonomy is publicly queryable.' ),
+ 'type' => 'boolean',
+ ),
+ 'show_ui' => array(
+ 'description' => __( 'Whether to generate a default UI for managing this taxonomy.' ),
+ 'type' => 'boolean',
+ ),
+ 'show_admin_column' => array(
+ 'description' => __( 'Whether to allow automatic creation of taxonomy columns on associated post-types table.' ),
+ 'type' => 'boolean',
+ ),
+ 'show_in_nav_menus' => array(
+ 'description' => __( 'Whether to make the taxonomy available for selection in navigation menus.' ),
+ 'type' => 'boolean',
+ ),
+ 'show_in_quick_edit' => array(
+ 'description' => __( 'Whether to show the taxonomy in the quick/bulk edit panel.' ),
+ 'type' => 'boolean',
+ ),
+
+ ),
+ ),
+ ),
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * @since 4.7.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $new_params = array();
+ $new_params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
+ $new_params['type'] = array(
+ 'description' => __( 'Limit results to taxonomies associated with a specific post type.' ),
+ 'type' => 'string',
+ );
+ return $new_params;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php
new file mode 100644
index 0000000..c996894
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php
@@ -0,0 +1,276 @@
+<?php
+/**
+ * REST API: WP_REST_Template_Autosaves_Controller class.
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 6.4.0
+ */
+
+/**
+ * Core class used to access template autosaves via the REST API.
+ *
+ * @since 6.4.0
+ *
+ * @see WP_REST_Autosaves_Controller
+ */
+class WP_REST_Template_Autosaves_Controller extends WP_REST_Autosaves_Controller {
+ /**
+ * Parent post type.
+ *
+ * @since 6.4.0
+ * @var string
+ */
+ private $parent_post_type;
+
+ /**
+ * Parent post controller.
+ *
+ * @since 6.4.0
+ * @var WP_REST_Controller
+ */
+ private $parent_controller;
+
+ /**
+ * Revision controller.
+ *
+ * @since 6.4.0
+ * @var WP_REST_Revisions_Controller
+ */
+ private $revisions_controller;
+
+ /**
+ * The base of the parent controller's route.
+ *
+ * @since 6.4.0
+ * @var string
+ */
+ private $parent_base;
+
+ /**
+ * Constructor.
+ *
+ * @since 6.4.0
+ *
+ * @param string $parent_post_type Post type of the parent.
+ */
+ public function __construct( $parent_post_type ) {
+ parent::__construct( $parent_post_type );
+ $this->parent_post_type = $parent_post_type;
+ $post_type_object = get_post_type_object( $parent_post_type );
+ $parent_controller = $post_type_object->get_rest_controller();
+
+ if ( ! $parent_controller ) {
+ $parent_controller = new WP_REST_Templates_Controller( $parent_post_type );
+ }
+
+ $this->parent_controller = $parent_controller;
+
+ $revisions_controller = $post_type_object->get_revisions_rest_controller();
+ if ( ! $revisions_controller ) {
+ $revisions_controller = new WP_REST_Revisions_Controller( $parent_post_type );
+ }
+ $this->revisions_controller = $revisions_controller;
+ $this->rest_base = 'autosaves';
+ $this->parent_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name;
+ $this->namespace = ! empty( $post_type_object->rest_namespace ) ? $post_type_object->rest_namespace : 'wp/v2';
+ }
+
+ /**
+ * Registers the routes for autosaves.
+ *
+ * @since 6.4.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ sprintf(
+ '/%s/(?P<id>%s%s)/%s',
+ $this->parent_base,
+ /*
+ * Matches theme's directory: `/themes/<subdirectory>/<theme>/` or `/themes/<theme>/`.
+ * Excludes invalid directory name characters: `/:<>*?"|`.
+ */
+ '([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)',
+ // Matches the template name.
+ '[\/\w%-]+',
+ $this->rest_base
+ ),
+ array(
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The id of a template' ),
+ 'type' => 'string',
+ 'sanitize_callback' => array( $this->parent_controller, '_sanitize_template_id' ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => $this->parent_controller->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ sprintf(
+ '/%s/(?P<parent>%s%s)/%s/%s',
+ $this->parent_base,
+ /*
+ * Matches theme's directory: `/themes/<subdirectory>/<theme>/` or `/themes/<theme>/`.
+ * Excludes invalid directory name characters: `/:<>*?"|`.
+ */
+ '([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)',
+ // Matches the template name.
+ '[\/\w%-]+',
+ $this->rest_base,
+ '(?P<id>[\d]+)'
+ ),
+ array(
+ 'args' => array(
+ 'parent' => array(
+ 'description' => __( 'The id of a template' ),
+ 'type' => 'string',
+ 'sanitize_callback' => array( $this->parent_controller, '_sanitize_template_id' ),
+ ),
+ 'id' => array(
+ 'description' => __( 'The ID for the autosave.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this->revisions_controller, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Prepares the item for the REST response.
+ *
+ * @since 6.4.0
+ *
+ * @param WP_Post $item Post revision object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ $template = _build_block_template_result_from_post( $item );
+ $response = $this->parent_controller->prepare_item_for_response( $template, $request );
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = $response->get_data();
+
+ if ( in_array( 'parent', $fields, true ) ) {
+ $data['parent'] = (int) $item->post_parent;
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->filter_response_by_context( $data, $context );
+
+ // Wrap the data in a response object.
+ $response = new WP_REST_Response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $links = $this->prepare_links( $template );
+ $response->add_links( $links );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Gets the autosave, if the ID is valid.
+ *
+ * @since 6.4.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Post|WP_Error Autosave post object if ID is valid, WP_Error otherwise.
+ */
+ public function get_item( $request ) {
+ $parent = $this->get_parent( $request['parent'] );
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ $autosave = wp_get_post_autosave( $parent->ID );
+
+ if ( ! $autosave ) {
+ return new WP_Error(
+ 'rest_post_no_autosave',
+ __( 'There is no autosave revision for this template.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $response = $this->prepare_item_for_response( $autosave, $request );
+ return $response;
+ }
+
+ /**
+ * Get the parent post.
+ *
+ * @since 6.4.0
+ *
+ * @param int $parent_id Supplied ID.
+ * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_parent( $parent_id ) {
+ return $this->revisions_controller->get_parent( $parent_id );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 6.4.0
+ *
+ * @param WP_Block_Template $template Template.
+ * @return array Links for the given post.
+ */
+ protected function prepare_links( $template ) {
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( sprintf( '/%s/%s/%s/%s/%d', $this->namespace, $this->parent_base, $template->id, $this->rest_base, $template->wp_id ) ),
+ ),
+ 'parent' => array(
+ 'href' => rest_url( sprintf( '/%s/%s/%s', $this->namespace, $this->parent_base, $template->id ) ),
+ ),
+ );
+
+ return $links;
+ }
+
+ /**
+ * Retrieves the autosave's schema, conforming to JSON Schema.
+ *
+ * @since 6.4.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $this->schema = $this->revisions_controller->get_item_schema();
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-template-revisions-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-template-revisions-controller.php
new file mode 100644
index 0000000..8d32ecb
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-template-revisions-controller.php
@@ -0,0 +1,297 @@
+<?php
+/**
+ * REST API: WP_REST_Template_Revisions_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 6.4.0
+ */
+
+/**
+ * Core class used to access template revisions via the REST API.
+ *
+ * @since 6.4.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Template_Revisions_Controller extends WP_REST_Revisions_Controller {
+ /**
+ * Parent post type.
+ *
+ * @since 6.4.0
+ * @var string
+ */
+ private $parent_post_type;
+
+ /**
+ * Parent controller.
+ *
+ * @since 6.4.0
+ * @var WP_REST_Controller
+ */
+ private $parent_controller;
+
+ /**
+ * The base of the parent controller's route.
+ *
+ * @since 6.4.0
+ * @var string
+ */
+ private $parent_base;
+
+ /**
+ * Constructor.
+ *
+ * @since 6.4.0
+ *
+ * @param string $parent_post_type Post type of the parent.
+ */
+ public function __construct( $parent_post_type ) {
+ parent::__construct( $parent_post_type );
+ $this->parent_post_type = $parent_post_type;
+ $post_type_object = get_post_type_object( $parent_post_type );
+ $parent_controller = $post_type_object->get_rest_controller();
+
+ if ( ! $parent_controller ) {
+ $parent_controller = new WP_REST_Templates_Controller( $parent_post_type );
+ }
+
+ $this->parent_controller = $parent_controller;
+ $this->rest_base = 'revisions';
+ $this->parent_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name;
+ $this->namespace = ! empty( $post_type_object->rest_namespace ) ? $post_type_object->rest_namespace : 'wp/v2';
+ }
+
+ /**
+ * Registers the routes for revisions based on post types supporting revisions.
+ *
+ * @since 6.4.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+
+ register_rest_route(
+ $this->namespace,
+ sprintf(
+ '/%s/(?P<parent>%s%s)/%s',
+ $this->parent_base,
+ /*
+ * Matches theme's directory: `/themes/<subdirectory>/<theme>/` or `/themes/<theme>/`.
+ * Excludes invalid directory name characters: `/:<>*?"|`.
+ */
+ '([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)',
+ // Matches the template name.
+ '[\/\w%-]+',
+ $this->rest_base
+ ),
+ array(
+ 'args' => array(
+ 'parent' => array(
+ 'description' => __( 'The id of a template' ),
+ 'type' => 'string',
+ 'sanitize_callback' => array( $this->parent_controller, '_sanitize_template_id' ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ sprintf(
+ '/%s/(?P<parent>%s%s)/%s/%s',
+ $this->parent_base,
+ /*
+ * Matches theme's directory: `/themes/<subdirectory>/<theme>/` or `/themes/<theme>/`.
+ * Excludes invalid directory name characters: `/:<>*?"|`.
+ */
+ '([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)',
+ // Matches the template name.
+ '[\/\w%-]+',
+ $this->rest_base,
+ '(?P<id>[\d]+)'
+ ),
+ array(
+ 'args' => array(
+ 'parent' => array(
+ 'description' => __( 'The id of a template' ),
+ 'type' => 'string',
+ 'sanitize_callback' => array( $this->parent_controller, '_sanitize_template_id' ),
+ ),
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the revision.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+ 'args' => array(
+ 'force' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ 'description' => __( 'Required to be true, as revisions do not support trashing.' ),
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Gets the parent post, if the ID is valid.
+ *
+ * @since 6.4.0
+ *
+ * @param int $parent_post_id Supplied ID.
+ * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_parent( $parent_post_id ) {
+ $template = get_block_template( $parent_post_id, $this->parent_post_type );
+
+ if ( ! $template ) {
+ return new WP_Error(
+ 'rest_post_invalid_parent',
+ __( 'Invalid template parent ID.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ return get_post( $template->wp_id );
+ }
+
+ /**
+ * Prepares the item for the REST response.
+ *
+ * @since 6.4.0
+ *
+ * @param WP_Post $item Post revision object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ $template = _build_block_template_result_from_post( $item );
+ $response = $this->parent_controller->prepare_item_for_response( $template, $request );
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = $response->get_data();
+
+ if ( in_array( 'parent', $fields, true ) ) {
+ $data['parent'] = (int) $item->post_parent;
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->filter_response_by_context( $data, $context );
+
+ // Wrap the data in a response object.
+ $response = new WP_REST_Response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $links = $this->prepare_links( $template );
+ $response->add_links( $links );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to delete a revision.
+ *
+ * @since 6.4.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise.
+ */
+ public function delete_item_permissions_check( $request ) {
+ $parent = $this->get_parent( $request['parent'] );
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ if ( ! current_user_can( 'delete_post', $parent->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'Sorry, you are not allowed to delete revisions of this post.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $revision = $this->get_revision( $request['id'] );
+ if ( is_wp_error( $revision ) ) {
+ return $revision;
+ }
+
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'Sorry, you are not allowed to delete this revision.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 6.4.0
+ *
+ * @param WP_Block_Template $template Template.
+ * @return array Links for the given post.
+ */
+ protected function prepare_links( $template ) {
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( sprintf( '/%s/%s/%s/%s/%d', $this->namespace, $this->parent_base, $template->id, $this->rest_base, $template->wp_id ) ),
+ ),
+ 'parent' => array(
+ 'href' => rest_url( sprintf( '/%s/%s/%s', $this->namespace, $this->parent_base, $template->id ) ),
+ ),
+ );
+
+ return $links;
+ }
+
+ /**
+ * Retrieves the item's schema, conforming to JSON Schema.
+ *
+ * @since 6.4.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = $this->parent_controller->get_item_schema();
+
+ $schema['properties']['parent'] = array(
+ 'description' => __( 'The ID for the parent of the revision.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php
new file mode 100644
index 0000000..53f8faa
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php
@@ -0,0 +1,999 @@
+<?php
+/**
+ * REST API: WP_REST_Templates_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.8.0
+ */
+
+/**
+ * Base Templates REST API Controller.
+ *
+ * @since 5.8.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Templates_Controller extends WP_REST_Controller {
+
+ /**
+ * Post type.
+ *
+ * @since 5.8.0
+ * @var string
+ */
+ protected $post_type;
+
+ /**
+ * Constructor.
+ *
+ * @since 5.8.0
+ *
+ * @param string $post_type Post type.
+ */
+ public function __construct( $post_type ) {
+ $this->post_type = $post_type;
+ $obj = get_post_type_object( $post_type );
+ $this->rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name;
+ $this->namespace = ! empty( $obj->rest_namespace ) ? $obj->rest_namespace : 'wp/v2';
+ }
+
+ /**
+ * Registers the controllers routes.
+ *
+ * @since 5.8.0
+ * @since 6.1.0 Endpoint for fallback template content.
+ */
+ public function register_routes() {
+ // Lists all templates.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ // Get fallback template content.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/lookup',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_template_fallback' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'slug' => array(
+ 'description' => __( 'The slug of the template to get the fallback for' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'is_custom' => array(
+ 'description' => __( 'Indicates if a template is custom or part of the template hierarchy' ),
+ 'type' => 'boolean',
+ ),
+ 'template_prefix' => array(
+ 'description' => __( 'The template prefix for the created template. This is used to extract the main template type, e.g. in `taxonomy-books` extracts the `taxonomy`' ),
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ )
+ );
+
+ // Lists/updates a single template based on the given id.
+ register_rest_route(
+ $this->namespace,
+ // The route.
+ sprintf(
+ '/%s/(?P<id>%s%s)',
+ $this->rest_base,
+ /*
+ * Matches theme's directory: `/themes/<subdirectory>/<theme>/` or `/themes/<theme>/`.
+ * Excludes invalid directory name characters: `/:<>*?"|`.
+ */
+ '([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)',
+ // Matches the template name.
+ '[\/\w%-]+'
+ ),
+ array(
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The id of a template' ),
+ 'type' => 'string',
+ 'sanitize_callback' => array( $this, '_sanitize_template_id' ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'permission_callback' => array( $this, 'update_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+ 'args' => array(
+ 'force' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ 'description' => __( 'Whether to bypass Trash and force deletion.' ),
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Returns the fallback template for the given slug.
+ *
+ * @since 6.1.0
+ * @since 6.3.0 Ignore empty templates.
+ *
+ * @param WP_REST_Request $request The request instance.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function get_template_fallback( $request ) {
+ $hierarchy = get_template_hierarchy( $request['slug'], $request['is_custom'], $request['template_prefix'] );
+
+ do {
+ $fallback_template = resolve_block_template( $request['slug'], $hierarchy, '' );
+ array_shift( $hierarchy );
+ } while ( ! empty( $hierarchy ) && empty( $fallback_template->content ) );
+
+ $response = $this->prepare_item_for_response( $fallback_template, $request );
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Checks if the user has permissions to make the request.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ protected function permissions_check( $request ) {
+ /*
+ * Verify if the current user has edit_theme_options capability.
+ * This capability is required to edit/view/delete templates.
+ */
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'rest_cannot_manage_templates',
+ __( 'Sorry, you are not allowed to access the templates on this site.' ),
+ array(
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Requesting this endpoint for a template like 'twentytwentytwo//home'
+ * requires using a path like /wp/v2/templates/twentytwentytwo//home. There
+ * are special cases when WordPress routing corrects the name to contain
+ * only a single slash like 'twentytwentytwo/home'.
+ *
+ * This method doubles the last slash if it's not already doubled. It relies
+ * on the template ID format {theme_name}//{template_slug} and the fact that
+ * slugs cannot contain slashes.
+ *
+ * @since 5.9.0
+ * @see https://core.trac.wordpress.org/ticket/54507
+ *
+ * @param string $id Template ID.
+ * @return string Sanitized template ID.
+ */
+ public function _sanitize_template_id( $id ) {
+ $id = urldecode( $id );
+
+ $last_slash_pos = strrpos( $id, '/' );
+ if ( false === $last_slash_pos ) {
+ return $id;
+ }
+
+ $is_double_slashed = substr( $id, $last_slash_pos - 1, 1 ) === '/';
+ if ( $is_double_slashed ) {
+ return $id;
+ }
+ return (
+ substr( $id, 0, $last_slash_pos )
+ . '/'
+ . substr( $id, $last_slash_pos )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to read templates.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ return $this->permissions_check( $request );
+ }
+
+ /**
+ * Returns a list of templates.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request The request instance.
+ * @return WP_REST_Response
+ */
+ public function get_items( $request ) {
+ $query = array();
+ if ( isset( $request['wp_id'] ) ) {
+ $query['wp_id'] = $request['wp_id'];
+ }
+ if ( isset( $request['area'] ) ) {
+ $query['area'] = $request['area'];
+ }
+ if ( isset( $request['post_type'] ) ) {
+ $query['post_type'] = $request['post_type'];
+ }
+
+ $templates = array();
+ foreach ( get_block_templates( $query, $this->post_type ) as $template ) {
+ $data = $this->prepare_item_for_response( $template, $request );
+ $templates[] = $this->prepare_response_for_collection( $data );
+ }
+
+ return rest_ensure_response( $templates );
+ }
+
+ /**
+ * Checks if a given request has access to read a single template.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ return $this->permissions_check( $request );
+ }
+
+ /**
+ * Returns the given template
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request The request instance.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function get_item( $request ) {
+ if ( isset( $request['source'] ) && 'theme' === $request['source'] ) {
+ $template = get_block_file_template( $request['id'], $this->post_type );
+ } else {
+ $template = get_block_template( $request['id'], $this->post_type );
+ }
+
+ if ( ! $template ) {
+ return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.' ), array( 'status' => 404 ) );
+ }
+
+ return $this->prepare_item_for_response( $template, $request );
+ }
+
+ /**
+ * Checks if a given request has access to write a single template.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has write access for the item, WP_Error object otherwise.
+ */
+ public function update_item_permissions_check( $request ) {
+ return $this->permissions_check( $request );
+ }
+
+ /**
+ * Updates a single template.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ $template = get_block_template( $request['id'], $this->post_type );
+ if ( ! $template ) {
+ return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.' ), array( 'status' => 404 ) );
+ }
+
+ $post_before = get_post( $template->wp_id );
+
+ if ( isset( $request['source'] ) && 'theme' === $request['source'] ) {
+ wp_delete_post( $template->wp_id, true );
+ $request->set_param( 'context', 'edit' );
+
+ $template = get_block_template( $request['id'], $this->post_type );
+ $response = $this->prepare_item_for_response( $template, $request );
+
+ return rest_ensure_response( $response );
+ }
+
+ $changes = $this->prepare_item_for_database( $request );
+
+ if ( is_wp_error( $changes ) ) {
+ return $changes;
+ }
+
+ if ( 'custom' === $template->source ) {
+ $update = true;
+ $result = wp_update_post( wp_slash( (array) $changes ), false );
+ } else {
+ $update = false;
+ $post_before = null;
+ $result = wp_insert_post( wp_slash( (array) $changes ), false );
+ }
+
+ if ( is_wp_error( $result ) ) {
+ if ( 'db_update_error' === $result->get_error_code() ) {
+ $result->add_data( array( 'status' => 500 ) );
+ } else {
+ $result->add_data( array( 'status' => 400 ) );
+ }
+ return $result;
+ }
+
+ $template = get_block_template( $request['id'], $this->post_type );
+ $fields_update = $this->update_additional_fields_for_object( $template, $request );
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ $post = get_post( $template->wp_id );
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */
+ do_action( "rest_after_insert_{$this->post_type}", $post, $request, false );
+
+ wp_after_insert_post( $post, $update, $post_before );
+
+ $response = $this->prepare_item_for_response( $template, $request );
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Checks if a given request has access to create a template.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
+ */
+ public function create_item_permissions_check( $request ) {
+ return $this->permissions_check( $request );
+ }
+
+ /**
+ * Creates a single template.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+ $prepared_post = $this->prepare_item_for_database( $request );
+
+ if ( is_wp_error( $prepared_post ) ) {
+ return $prepared_post;
+ }
+
+ $prepared_post->post_name = $request['slug'];
+ $post_id = wp_insert_post( wp_slash( (array) $prepared_post ), true );
+ if ( is_wp_error( $post_id ) ) {
+ if ( 'db_insert_error' === $post_id->get_error_code() ) {
+ $post_id->add_data( array( 'status' => 500 ) );
+ } else {
+ $post_id->add_data( array( 'status' => 400 ) );
+ }
+
+ return $post_id;
+ }
+ $posts = get_block_templates( array( 'wp_id' => $post_id ), $this->post_type );
+ if ( ! count( $posts ) ) {
+ return new WP_Error( 'rest_template_insert_error', __( 'No templates exist with that id.' ), array( 'status' => 400 ) );
+ }
+ $id = $posts[0]->id;
+ $post = get_post( $post_id );
+ $template = get_block_template( $id, $this->post_type );
+ $fields_update = $this->update_additional_fields_for_object( $template, $request );
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */
+ do_action( "rest_after_insert_{$this->post_type}", $post, $request, true );
+
+ wp_after_insert_post( $post, false, null );
+
+ $response = $this->prepare_item_for_response( $template, $request );
+ $response = rest_ensure_response( $response );
+
+ $response->set_status( 201 );
+ $response->header( 'Location', rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $template->id ) ) );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to delete a single template.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has delete access for the item, WP_Error object otherwise.
+ */
+ public function delete_item_permissions_check( $request ) {
+ return $this->permissions_check( $request );
+ }
+
+ /**
+ * Deletes a single template.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_item( $request ) {
+ $template = get_block_template( $request['id'], $this->post_type );
+ if ( ! $template ) {
+ return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.' ), array( 'status' => 404 ) );
+ }
+ if ( 'custom' !== $template->source ) {
+ return new WP_Error( 'rest_invalid_template', __( 'Templates based on theme files can\'t be removed.' ), array( 'status' => 400 ) );
+ }
+
+ $id = $template->wp_id;
+ $force = (bool) $request['force'];
+
+ $request->set_param( 'context', 'edit' );
+
+ // If we're forcing, then delete permanently.
+ if ( $force ) {
+ $previous = $this->prepare_item_for_response( $template, $request );
+ $result = wp_delete_post( $id, true );
+ $response = new WP_REST_Response();
+ $response->set_data(
+ array(
+ 'deleted' => true,
+ 'previous' => $previous->get_data(),
+ )
+ );
+ } else {
+ // Otherwise, only trash if we haven't already.
+ if ( 'trash' === $template->status ) {
+ return new WP_Error(
+ 'rest_template_already_trashed',
+ __( 'The template has already been deleted.' ),
+ array( 'status' => 410 )
+ );
+ }
+
+ /*
+ * (Note that internally this falls through to `wp_delete_post()`
+ * if the Trash is disabled.)
+ */
+ $result = wp_trash_post( $id );
+ $template->status = 'trash';
+ $response = $this->prepare_item_for_response( $template, $request );
+ }
+
+ if ( ! $result ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'The template cannot be deleted.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Prepares a single template for create or update.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return stdClass Changes to pass to wp_update_post.
+ */
+ protected function prepare_item_for_database( $request ) {
+ $template = $request['id'] ? get_block_template( $request['id'], $this->post_type ) : null;
+ $changes = new stdClass();
+ if ( null === $template ) {
+ $changes->post_type = $this->post_type;
+ $changes->post_status = 'publish';
+ $changes->tax_input = array(
+ 'wp_theme' => isset( $request['theme'] ) ? $request['theme'] : get_stylesheet(),
+ );
+ } elseif ( 'custom' !== $template->source ) {
+ $changes->post_name = $template->slug;
+ $changes->post_type = $this->post_type;
+ $changes->post_status = 'publish';
+ $changes->tax_input = array(
+ 'wp_theme' => $template->theme,
+ );
+ $changes->meta_input = array(
+ 'origin' => $template->source,
+ );
+ } else {
+ $changes->post_name = $template->slug;
+ $changes->ID = $template->wp_id;
+ $changes->post_status = 'publish';
+ }
+ if ( isset( $request['content'] ) ) {
+ if ( is_string( $request['content'] ) ) {
+ $changes->post_content = $request['content'];
+ } elseif ( isset( $request['content']['raw'] ) ) {
+ $changes->post_content = $request['content']['raw'];
+ }
+ } elseif ( null !== $template && 'custom' !== $template->source ) {
+ $changes->post_content = $template->content;
+ }
+ if ( isset( $request['title'] ) ) {
+ if ( is_string( $request['title'] ) ) {
+ $changes->post_title = $request['title'];
+ } elseif ( ! empty( $request['title']['raw'] ) ) {
+ $changes->post_title = $request['title']['raw'];
+ }
+ } elseif ( null !== $template && 'custom' !== $template->source ) {
+ $changes->post_title = $template->title;
+ }
+ if ( isset( $request['description'] ) ) {
+ $changes->post_excerpt = $request['description'];
+ } elseif ( null !== $template && 'custom' !== $template->source ) {
+ $changes->post_excerpt = $template->description;
+ }
+
+ if ( 'wp_template' === $this->post_type && isset( $request['is_wp_suggestion'] ) ) {
+ $changes->meta_input = wp_parse_args(
+ array(
+ 'is_wp_suggestion' => $request['is_wp_suggestion'],
+ ),
+ $changes->meta_input = array()
+ );
+ }
+
+ if ( 'wp_template_part' === $this->post_type ) {
+ if ( isset( $request['area'] ) ) {
+ $changes->tax_input['wp_template_part_area'] = _filter_block_template_part_area( $request['area'] );
+ } elseif ( null !== $template && 'custom' !== $template->source && $template->area ) {
+ $changes->tax_input['wp_template_part_area'] = _filter_block_template_part_area( $template->area );
+ } elseif ( empty( $template->area ) ) {
+ $changes->tax_input['wp_template_part_area'] = WP_TEMPLATE_PART_AREA_UNCATEGORIZED;
+ }
+ }
+
+ if ( ! empty( $request['author'] ) ) {
+ $post_author = (int) $request['author'];
+
+ if ( get_current_user_id() !== $post_author ) {
+ $user_obj = get_userdata( $post_author );
+
+ if ( ! $user_obj ) {
+ return new WP_Error(
+ 'rest_invalid_author',
+ __( 'Invalid author ID.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ $changes->post_author = $post_author;
+ }
+
+ return $changes;
+ }
+
+ /**
+ * Prepare a single template output for response
+ *
+ * @since 5.8.0
+ * @since 5.9.0 Renamed `$template` to `$item` to match parent class for PHP 8 named parameter support.
+ * @since 6.3.0 Added `modified` property to the response.
+ *
+ * @param WP_Block_Template $item Template instance.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $template = $item;
+
+ $fields = $this->get_fields_for_response( $request );
+
+ // Base fields for every template.
+ $data = array();
+
+ if ( rest_is_field_included( 'id', $fields ) ) {
+ $data['id'] = $template->id;
+ }
+
+ if ( rest_is_field_included( 'theme', $fields ) ) {
+ $data['theme'] = $template->theme;
+ }
+
+ if ( rest_is_field_included( 'content', $fields ) ) {
+ $data['content'] = array();
+ }
+ if ( rest_is_field_included( 'content.raw', $fields ) ) {
+ $data['content']['raw'] = $template->content;
+ }
+
+ if ( rest_is_field_included( 'content.block_version', $fields ) ) {
+ $data['content']['block_version'] = block_version( $template->content );
+ }
+
+ if ( rest_is_field_included( 'slug', $fields ) ) {
+ $data['slug'] = $template->slug;
+ }
+
+ if ( rest_is_field_included( 'source', $fields ) ) {
+ $data['source'] = $template->source;
+ }
+
+ if ( rest_is_field_included( 'origin', $fields ) ) {
+ $data['origin'] = $template->origin;
+ }
+
+ if ( rest_is_field_included( 'type', $fields ) ) {
+ $data['type'] = $template->type;
+ }
+
+ if ( rest_is_field_included( 'description', $fields ) ) {
+ $data['description'] = $template->description;
+ }
+
+ if ( rest_is_field_included( 'title', $fields ) ) {
+ $data['title'] = array();
+ }
+
+ if ( rest_is_field_included( 'title.raw', $fields ) ) {
+ $data['title']['raw'] = $template->title;
+ }
+
+ if ( rest_is_field_included( 'title.rendered', $fields ) ) {
+ if ( $template->wp_id ) {
+ /** This filter is documented in wp-includes/post-template.php */
+ $data['title']['rendered'] = apply_filters( 'the_title', $template->title, $template->wp_id );
+ } else {
+ $data['title']['rendered'] = $template->title;
+ }
+ }
+
+ if ( rest_is_field_included( 'status', $fields ) ) {
+ $data['status'] = $template->status;
+ }
+
+ if ( rest_is_field_included( 'wp_id', $fields ) ) {
+ $data['wp_id'] = (int) $template->wp_id;
+ }
+
+ if ( rest_is_field_included( 'has_theme_file', $fields ) ) {
+ $data['has_theme_file'] = (bool) $template->has_theme_file;
+ }
+
+ if ( rest_is_field_included( 'is_custom', $fields ) && 'wp_template' === $template->type ) {
+ $data['is_custom'] = $template->is_custom;
+ }
+
+ if ( rest_is_field_included( 'author', $fields ) ) {
+ $data['author'] = (int) $template->author;
+ }
+
+ if ( rest_is_field_included( 'area', $fields ) && 'wp_template_part' === $template->type ) {
+ $data['area'] = $template->area;
+ }
+
+ if ( rest_is_field_included( 'modified', $fields ) ) {
+ $data['modified'] = mysql_to_rfc3339( $template->modified );
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ // Wrap the data in a response object.
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $links = $this->prepare_links( $template->id );
+ $response->add_links( $links );
+ if ( ! empty( $links['self']['href'] ) ) {
+ $actions = $this->get_available_actions();
+ $self = $links['self']['href'];
+ foreach ( $actions as $rel ) {
+ $response->add_link( $rel, $self );
+ }
+ }
+ }
+
+ return $response;
+ }
+
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 5.8.0
+ *
+ * @param integer $id ID.
+ * @return array Links for the given post.
+ */
+ protected function prepare_links( $id ) {
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( sprintf( '/%s/%s/%s', $this->namespace, $this->rest_base, $id ) ),
+ ),
+ 'collection' => array(
+ 'href' => rest_url( rest_get_route_for_post_type_items( $this->post_type ) ),
+ ),
+ 'about' => array(
+ 'href' => rest_url( 'wp/v2/types/' . $this->post_type ),
+ ),
+ );
+
+ if ( post_type_supports( $this->post_type, 'revisions' ) ) {
+ $template = get_block_template( $id, $this->post_type );
+ if ( $template instanceof WP_Block_Template && ! empty( $template->wp_id ) ) {
+ $revisions = wp_get_latest_revision_id_and_total_count( $template->wp_id );
+ $revisions_count = ! is_wp_error( $revisions ) ? $revisions['count'] : 0;
+ $revisions_base = sprintf( '/%s/%s/%s/revisions', $this->namespace, $this->rest_base, $id );
+
+ $links['version-history'] = array(
+ 'href' => rest_url( $revisions_base ),
+ 'count' => $revisions_count,
+ );
+
+ if ( $revisions_count > 0 ) {
+ $links['predecessor-version'] = array(
+ 'href' => rest_url( $revisions_base . '/' . $revisions['latest_id'] ),
+ 'id' => $revisions['latest_id'],
+ );
+ }
+ }
+ }
+
+ return $links;
+ }
+
+ /**
+ * Get the link relations available for the post and current user.
+ *
+ * @since 5.8.0
+ *
+ * @return string[] List of link relations.
+ */
+ protected function get_available_actions() {
+ $rels = array();
+
+ $post_type = get_post_type_object( $this->post_type );
+
+ if ( current_user_can( $post_type->cap->publish_posts ) ) {
+ $rels[] = 'https://api.w.org/action-publish';
+ }
+
+ if ( current_user_can( 'unfiltered_html' ) ) {
+ $rels[] = 'https://api.w.org/action-unfiltered-html';
+ }
+
+ return $rels;
+ }
+
+ /**
+ * Retrieves the query params for the posts collection.
+ *
+ * @since 5.8.0
+ * @since 5.9.0 Added `'area'` and `'post_type'`.
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ return array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ 'wp_id' => array(
+ 'description' => __( 'Limit to the specified post id.' ),
+ 'type' => 'integer',
+ ),
+ 'area' => array(
+ 'description' => __( 'Limit to the specified template part area.' ),
+ 'type' => 'string',
+ ),
+ 'post_type' => array(
+ 'description' => __( 'Post type to get the templates for.' ),
+ 'type' => 'string',
+ ),
+ );
+ }
+
+ /**
+ * Retrieves the block type' schema, conforming to JSON Schema.
+ *
+ * @since 5.8.0
+ * @since 5.9.0 Added `'area'`.
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => $this->post_type,
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'ID of template.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'slug' => array(
+ 'description' => __( 'Unique slug identifying the template.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'required' => true,
+ 'minLength' => 1,
+ 'pattern' => '[a-zA-Z0-9_\%-]+',
+ ),
+ 'theme' => array(
+ 'description' => __( 'Theme identifier for the template.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ ),
+ 'type' => array(
+ 'description' => __( 'Type of template.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ ),
+ 'source' => array(
+ 'description' => __( 'Source of template' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'origin' => array(
+ 'description' => __( 'Source of a customized template' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'content' => array(
+ 'description' => __( 'Content of template.' ),
+ 'type' => array( 'object', 'string' ),
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'Content for the template, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'block_version' => array(
+ 'description' => __( 'Version of the content block format used by the template.' ),
+ 'type' => 'integer',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ ),
+ 'title' => array(
+ 'description' => __( 'Title of template.' ),
+ 'type' => array( 'object', 'string' ),
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'Title for the template, as it exists in the database.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'rendered' => array(
+ 'description' => __( 'HTML title for the template, transformed for display.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ ),
+ 'description' => array(
+ 'description' => __( 'Description of template.' ),
+ 'type' => 'string',
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ ),
+ 'status' => array(
+ 'description' => __( 'Status of template.' ),
+ 'type' => 'string',
+ 'enum' => array_keys( get_post_stati( array( 'internal' => false ) ) ),
+ 'default' => 'publish',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ ),
+ 'wp_id' => array(
+ 'description' => __( 'Post ID.' ),
+ 'type' => 'integer',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'has_theme_file' => array(
+ 'description' => __( 'Theme file exists.' ),
+ 'type' => 'bool',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'author' => array(
+ 'description' => __( 'The ID for the author of the template.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'modified' => array(
+ 'description' => __( "The date the template was last modified, in the site's timezone." ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ if ( 'wp_template' === $this->post_type ) {
+ $schema['properties']['is_custom'] = array(
+ 'description' => __( 'Whether a template is a custom template.' ),
+ 'type' => 'bool',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ );
+ }
+
+ if ( 'wp_template_part' === $this->post_type ) {
+ $schema['properties']['area'] = array(
+ 'description' => __( 'Where the template part is intended for use (header, footer, etc.)' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ );
+ }
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php
new file mode 100644
index 0000000..cf9dc91
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php
@@ -0,0 +1,1211 @@
+<?php
+/**
+ * REST API: WP_REST_Terms_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class used to managed terms associated with a taxonomy via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Terms_Controller extends WP_REST_Controller {
+
+ /**
+ * Taxonomy key.
+ *
+ * @since 4.7.0
+ * @var string
+ */
+ protected $taxonomy;
+
+ /**
+ * Instance of a term meta fields object.
+ *
+ * @since 4.7.0
+ * @var WP_REST_Term_Meta_Fields
+ */
+ protected $meta;
+
+ /**
+ * Column to have the terms be sorted by.
+ *
+ * @since 4.7.0
+ * @var string
+ */
+ protected $sort_column;
+
+ /**
+ * Number of terms that were found.
+ *
+ * @since 4.7.0
+ * @var int
+ */
+ protected $total_terms;
+
+ /**
+ * Whether the controller supports batching.
+ *
+ * @since 5.9.0
+ * @var array
+ */
+ protected $allow_batch = array( 'v1' => true );
+
+ /**
+ * Constructor.
+ *
+ * @since 4.7.0
+ *
+ * @param string $taxonomy Taxonomy key.
+ */
+ public function __construct( $taxonomy ) {
+ $this->taxonomy = $taxonomy;
+ $tax_obj = get_taxonomy( $taxonomy );
+ $this->rest_base = ! empty( $tax_obj->rest_base ) ? $tax_obj->rest_base : $tax_obj->name;
+ $this->namespace = ! empty( $tax_obj->rest_namespace ) ? $tax_obj->rest_namespace : 'wp/v2';
+
+ $this->meta = new WP_REST_Term_Meta_Fields( $taxonomy );
+ }
+
+ /**
+ * Registers the routes for terms.
+ *
+ * @since 4.7.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
+ ),
+ 'allow_batch' => $this->allow_batch,
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[\d]+)',
+ array(
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the term.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'permission_callback' => array( $this, 'update_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+ 'args' => array(
+ 'force' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ 'description' => __( 'Required to be true, as terms do not support trashing.' ),
+ ),
+ ),
+ ),
+ 'allow_batch' => $this->allow_batch,
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if the terms for a post can be read.
+ *
+ * @since 6.0.3
+ *
+ * @param WP_Post $post Post object.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return bool Whether the terms for the post can be read.
+ */
+ public function check_read_terms_permission_for_post( $post, $request ) {
+ // If the requested post isn't associated with this taxonomy, deny access.
+ if ( ! is_object_in_taxonomy( $post->post_type, $this->taxonomy ) ) {
+ return false;
+ }
+
+ // Grant access if the post is publicly viewable.
+ if ( is_post_publicly_viewable( $post ) ) {
+ return true;
+ }
+
+ // Otherwise grant access if the post is readable by the logged in user.
+ if ( current_user_can( 'read_post', $post->ID ) ) {
+ return true;
+ }
+
+ // Otherwise, deny access.
+ return false;
+ }
+
+ /**
+ * Checks if a request has access to read terms in the specified taxonomy.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return bool|WP_Error True if the request has read access, otherwise false or WP_Error object.
+ */
+ public function get_items_permissions_check( $request ) {
+ $tax_obj = get_taxonomy( $this->taxonomy );
+
+ if ( ! $tax_obj || ! $this->check_is_taxonomy_allowed( $this->taxonomy ) ) {
+ return false;
+ }
+
+ if ( 'edit' === $request['context'] && ! current_user_can( $tax_obj->cap->edit_terms ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to edit terms in this taxonomy.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! empty( $request['post'] ) ) {
+ $post = get_post( $request['post'] );
+
+ if ( ! $post ) {
+ return new WP_Error(
+ 'rest_post_invalid_id',
+ __( 'Invalid post ID.' ),
+ array(
+ 'status' => 400,
+ )
+ );
+ }
+
+ if ( ! $this->check_read_terms_permission_for_post( $post, $request ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to view terms for this post.' ),
+ array(
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves terms associated with a taxonomy.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+
+ // Retrieve the list of registered collection query parameters.
+ $registered = $this->get_collection_params();
+
+ /*
+ * This array defines mappings between public API query parameters whose
+ * values are accepted as-passed, and their internal WP_Query parameter
+ * name equivalents (some are the same). Only values which are also
+ * present in $registered will be set.
+ */
+ $parameter_mappings = array(
+ 'exclude' => 'exclude',
+ 'include' => 'include',
+ 'order' => 'order',
+ 'orderby' => 'orderby',
+ 'post' => 'post',
+ 'hide_empty' => 'hide_empty',
+ 'per_page' => 'number',
+ 'search' => 'search',
+ 'slug' => 'slug',
+ );
+
+ $prepared_args = array( 'taxonomy' => $this->taxonomy );
+
+ /*
+ * For each known parameter which is both registered and present in the request,
+ * set the parameter's value on the query $prepared_args.
+ */
+ foreach ( $parameter_mappings as $api_param => $wp_param ) {
+ if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
+ $prepared_args[ $wp_param ] = $request[ $api_param ];
+ }
+ }
+
+ if ( isset( $prepared_args['orderby'] ) && isset( $request['orderby'] ) ) {
+ $orderby_mappings = array(
+ 'include_slugs' => 'slug__in',
+ );
+
+ if ( isset( $orderby_mappings[ $request['orderby'] ] ) ) {
+ $prepared_args['orderby'] = $orderby_mappings[ $request['orderby'] ];
+ }
+ }
+
+ if ( isset( $registered['offset'] ) && ! empty( $request['offset'] ) ) {
+ $prepared_args['offset'] = $request['offset'];
+ } else {
+ $prepared_args['offset'] = ( $request['page'] - 1 ) * $prepared_args['number'];
+ }
+
+ $taxonomy_obj = get_taxonomy( $this->taxonomy );
+
+ if ( $taxonomy_obj->hierarchical && isset( $registered['parent'], $request['parent'] ) ) {
+ if ( 0 === $request['parent'] ) {
+ // Only query top-level terms.
+ $prepared_args['parent'] = 0;
+ } else {
+ if ( $request['parent'] ) {
+ $prepared_args['parent'] = $request['parent'];
+ }
+ }
+ }
+
+ /**
+ * Filters get_terms() arguments when querying terms via the REST API.
+ *
+ * The dynamic portion of the hook name, `$this->taxonomy`, refers to the taxonomy slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_category_query`
+ * - `rest_post_tag_query`
+ *
+ * Enables adding extra arguments or setting defaults for a terms
+ * collection request.
+ *
+ * @since 4.7.0
+ *
+ * @link https://developer.wordpress.org/reference/functions/get_terms/
+ *
+ * @param array $prepared_args Array of arguments for get_terms().
+ * @param WP_REST_Request $request The REST API request.
+ */
+ $prepared_args = apply_filters( "rest_{$this->taxonomy}_query", $prepared_args, $request );
+
+ if ( ! empty( $prepared_args['post'] ) ) {
+ $query_result = wp_get_object_terms( $prepared_args['post'], $this->taxonomy, $prepared_args );
+
+ // Used when calling wp_count_terms() below.
+ $prepared_args['object_ids'] = $prepared_args['post'];
+ } else {
+ $query_result = get_terms( $prepared_args );
+ }
+
+ $count_args = $prepared_args;
+
+ unset( $count_args['number'], $count_args['offset'] );
+
+ $total_terms = wp_count_terms( $count_args );
+
+ // wp_count_terms() can return a falsey value when the term has no children.
+ if ( ! $total_terms ) {
+ $total_terms = 0;
+ }
+
+ $response = array();
+
+ foreach ( $query_result as $term ) {
+ $data = $this->prepare_item_for_response( $term, $request );
+ $response[] = $this->prepare_response_for_collection( $data );
+ }
+
+ $response = rest_ensure_response( $response );
+
+ // Store pagination values for headers.
+ $per_page = (int) $prepared_args['number'];
+ $page = ceil( ( ( (int) $prepared_args['offset'] ) / $per_page ) + 1 );
+
+ $response->header( 'X-WP-Total', (int) $total_terms );
+
+ $max_pages = ceil( $total_terms / $per_page );
+
+ $response->header( 'X-WP-TotalPages', (int) $max_pages );
+
+ $request_params = $request->get_query_params();
+ $collection_url = rest_url( rest_get_route_for_taxonomy_items( $this->taxonomy ) );
+ $base = add_query_arg( urlencode_deep( $request_params ), $collection_url );
+
+ if ( $page > 1 ) {
+ $prev_page = $page - 1;
+
+ if ( $prev_page > $max_pages ) {
+ $prev_page = $max_pages;
+ }
+
+ $prev_link = add_query_arg( 'page', $prev_page, $base );
+ $response->link_header( 'prev', $prev_link );
+ }
+ if ( $max_pages > $page ) {
+ $next_page = $page + 1;
+ $next_link = add_query_arg( 'page', $next_page, $base );
+
+ $response->link_header( 'next', $next_link );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Get the term, if the ID is valid.
+ *
+ * @since 4.7.2
+ *
+ * @param int $id Supplied ID.
+ * @return WP_Term|WP_Error Term object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_term( $id ) {
+ $error = new WP_Error(
+ 'rest_term_invalid',
+ __( 'Term does not exist.' ),
+ array( 'status' => 404 )
+ );
+
+ if ( ! $this->check_is_taxonomy_allowed( $this->taxonomy ) ) {
+ return $error;
+ }
+
+ if ( (int) $id <= 0 ) {
+ return $error;
+ }
+
+ $term = get_term( (int) $id, $this->taxonomy );
+ if ( empty( $term ) || $term->taxonomy !== $this->taxonomy ) {
+ return $error;
+ }
+
+ return $term;
+ }
+
+ /**
+ * Checks if a request has access to read or edit the specified term.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, otherwise WP_Error object.
+ */
+ public function get_item_permissions_check( $request ) {
+ $term = $this->get_term( $request['id'] );
+
+ if ( is_wp_error( $term ) ) {
+ return $term;
+ }
+
+ if ( 'edit' === $request['context'] && ! current_user_can( 'edit_term', $term->term_id ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to edit this term.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Gets a single term from a taxonomy.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $term = $this->get_term( $request['id'] );
+ if ( is_wp_error( $term ) ) {
+ return $term;
+ }
+
+ $response = $this->prepare_item_for_response( $term, $request );
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Checks if a request has access to create a term.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to create items, false or WP_Error object otherwise.
+ */
+ public function create_item_permissions_check( $request ) {
+
+ if ( ! $this->check_is_taxonomy_allowed( $this->taxonomy ) ) {
+ return false;
+ }
+
+ $taxonomy_obj = get_taxonomy( $this->taxonomy );
+
+ if ( ( is_taxonomy_hierarchical( $this->taxonomy )
+ && ! current_user_can( $taxonomy_obj->cap->edit_terms ) )
+ || ( ! is_taxonomy_hierarchical( $this->taxonomy )
+ && ! current_user_can( $taxonomy_obj->cap->assign_terms ) ) ) {
+ return new WP_Error(
+ 'rest_cannot_create',
+ __( 'Sorry, you are not allowed to create terms in this taxonomy.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Creates a single term in a taxonomy.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+ if ( isset( $request['parent'] ) ) {
+ if ( ! is_taxonomy_hierarchical( $this->taxonomy ) ) {
+ return new WP_Error(
+ 'rest_taxonomy_not_hierarchical',
+ __( 'Cannot set parent term, taxonomy is not hierarchical.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $parent = get_term( (int) $request['parent'], $this->taxonomy );
+
+ if ( ! $parent ) {
+ return new WP_Error(
+ 'rest_term_invalid',
+ __( 'Parent term does not exist.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ $prepared_term = $this->prepare_item_for_database( $request );
+
+ $term = wp_insert_term( wp_slash( $prepared_term->name ), $this->taxonomy, wp_slash( (array) $prepared_term ) );
+ if ( is_wp_error( $term ) ) {
+ /*
+ * If we're going to inform the client that the term already exists,
+ * give them the identifier for future use.
+ */
+ $term_id = $term->get_error_data( 'term_exists' );
+ if ( $term_id ) {
+ $existing_term = get_term( $term_id, $this->taxonomy );
+ $term->add_data( $existing_term->term_id, 'term_exists' );
+ $term->add_data(
+ array(
+ 'status' => 400,
+ 'term_id' => $term_id,
+ )
+ );
+ }
+
+ return $term;
+ }
+
+ $term = get_term( $term['term_id'], $this->taxonomy );
+
+ /**
+ * Fires after a single term is created or updated via the REST API.
+ *
+ * The dynamic portion of the hook name, `$this->taxonomy`, refers to the taxonomy slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_insert_category`
+ * - `rest_insert_post_tag`
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Term $term Inserted or updated term object.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating a term, false when updating.
+ */
+ do_action( "rest_insert_{$this->taxonomy}", $term, $request, true );
+
+ $schema = $this->get_item_schema();
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $term->term_id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $fields_update = $this->update_additional_fields_for_object( $term, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ /**
+ * Fires after a single term is completely created or updated via the REST API.
+ *
+ * The dynamic portion of the hook name, `$this->taxonomy`, refers to the taxonomy slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_after_insert_category`
+ * - `rest_after_insert_post_tag`
+ *
+ * @since 5.0.0
+ *
+ * @param WP_Term $term Inserted or updated term object.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating a term, false when updating.
+ */
+ do_action( "rest_after_insert_{$this->taxonomy}", $term, $request, true );
+
+ $response = $this->prepare_item_for_response( $term, $request );
+ $response = rest_ensure_response( $response );
+
+ $response->set_status( 201 );
+ $response->header( 'Location', rest_url( $this->namespace . '/' . $this->rest_base . '/' . $term->term_id ) );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a request has access to update the specified term.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to update the item, false or WP_Error object otherwise.
+ */
+ public function update_item_permissions_check( $request ) {
+ $term = $this->get_term( $request['id'] );
+
+ if ( is_wp_error( $term ) ) {
+ return $term;
+ }
+
+ if ( ! current_user_can( 'edit_term', $term->term_id ) ) {
+ return new WP_Error(
+ 'rest_cannot_update',
+ __( 'Sorry, you are not allowed to edit this term.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Updates a single term from a taxonomy.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ $term = $this->get_term( $request['id'] );
+ if ( is_wp_error( $term ) ) {
+ return $term;
+ }
+
+ if ( isset( $request['parent'] ) ) {
+ if ( ! is_taxonomy_hierarchical( $this->taxonomy ) ) {
+ return new WP_Error(
+ 'rest_taxonomy_not_hierarchical',
+ __( 'Cannot set parent term, taxonomy is not hierarchical.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $parent = get_term( (int) $request['parent'], $this->taxonomy );
+
+ if ( ! $parent ) {
+ return new WP_Error(
+ 'rest_term_invalid',
+ __( 'Parent term does not exist.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ $prepared_term = $this->prepare_item_for_database( $request );
+
+ // Only update the term if we have something to update.
+ if ( ! empty( $prepared_term ) ) {
+ $update = wp_update_term( $term->term_id, $term->taxonomy, wp_slash( (array) $prepared_term ) );
+
+ if ( is_wp_error( $update ) ) {
+ return $update;
+ }
+ }
+
+ $term = get_term( $term->term_id, $this->taxonomy );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php */
+ do_action( "rest_insert_{$this->taxonomy}", $term, $request, false );
+
+ $schema = $this->get_item_schema();
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $term->term_id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $fields_update = $this->update_additional_fields_for_object( $term, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-terms-controller.php */
+ do_action( "rest_after_insert_{$this->taxonomy}", $term, $request, false );
+
+ $response = $this->prepare_item_for_response( $term, $request );
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Checks if a request has access to delete the specified term.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to delete the item, otherwise false or WP_Error object.
+ */
+ public function delete_item_permissions_check( $request ) {
+ $term = $this->get_term( $request['id'] );
+
+ if ( is_wp_error( $term ) ) {
+ return $term;
+ }
+
+ if ( ! current_user_can( 'delete_term', $term->term_id ) ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'Sorry, you are not allowed to delete this term.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Deletes a single term from a taxonomy.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_item( $request ) {
+ $term = $this->get_term( $request['id'] );
+ if ( is_wp_error( $term ) ) {
+ return $term;
+ }
+
+ $force = isset( $request['force'] ) ? (bool) $request['force'] : false;
+
+ // We don't support trashing for terms.
+ if ( ! $force ) {
+ return new WP_Error(
+ 'rest_trash_not_supported',
+ /* translators: %s: force=true */
+ sprintf( __( "Terms do not support trashing. Set '%s' to delete." ), 'force=true' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ $request->set_param( 'context', 'view' );
+
+ $previous = $this->prepare_item_for_response( $term, $request );
+
+ $retval = wp_delete_term( $term->term_id, $term->taxonomy );
+
+ if ( ! $retval ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'The term cannot be deleted.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $response = new WP_REST_Response();
+ $response->set_data(
+ array(
+ 'deleted' => true,
+ 'previous' => $previous->get_data(),
+ )
+ );
+
+ /**
+ * Fires after a single term is deleted via the REST API.
+ *
+ * The dynamic portion of the hook name, `$this->taxonomy`, refers to the taxonomy slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_delete_category`
+ * - `rest_delete_post_tag`
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Term $term The deleted term.
+ * @param WP_REST_Response $response The response data.
+ * @param WP_REST_Request $request The request sent to the API.
+ */
+ do_action( "rest_delete_{$this->taxonomy}", $term, $response, $request );
+
+ return $response;
+ }
+
+ /**
+ * Prepares a single term for create or update.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return object Term object.
+ */
+ public function prepare_item_for_database( $request ) {
+ $prepared_term = new stdClass();
+
+ $schema = $this->get_item_schema();
+ if ( isset( $request['name'] ) && ! empty( $schema['properties']['name'] ) ) {
+ $prepared_term->name = $request['name'];
+ }
+
+ if ( isset( $request['slug'] ) && ! empty( $schema['properties']['slug'] ) ) {
+ $prepared_term->slug = $request['slug'];
+ }
+
+ if ( isset( $request['taxonomy'] ) && ! empty( $schema['properties']['taxonomy'] ) ) {
+ $prepared_term->taxonomy = $request['taxonomy'];
+ }
+
+ if ( isset( $request['description'] ) && ! empty( $schema['properties']['description'] ) ) {
+ $prepared_term->description = $request['description'];
+ }
+
+ if ( isset( $request['parent'] ) && ! empty( $schema['properties']['parent'] ) ) {
+ $parent_term_id = 0;
+ $requested_parent = (int) $request['parent'];
+
+ if ( $requested_parent ) {
+ $parent_term = get_term( $requested_parent, $this->taxonomy );
+
+ if ( $parent_term instanceof WP_Term ) {
+ $parent_term_id = $parent_term->term_id;
+ }
+ }
+
+ $prepared_term->parent = $parent_term_id;
+ }
+
+ /**
+ * Filters term data before inserting term via the REST API.
+ *
+ * The dynamic portion of the hook name, `$this->taxonomy`, refers to the taxonomy slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_pre_insert_category`
+ * - `rest_pre_insert_post_tag`
+ *
+ * @since 4.7.0
+ *
+ * @param object $prepared_term Term object.
+ * @param WP_REST_Request $request Request object.
+ */
+ return apply_filters( "rest_pre_insert_{$this->taxonomy}", $prepared_term, $request );
+ }
+
+ /**
+ * Prepares a single term output for response.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Term $item Term object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( in_array( 'id', $fields, true ) ) {
+ $data['id'] = (int) $item->term_id;
+ }
+
+ if ( in_array( 'count', $fields, true ) ) {
+ $data['count'] = (int) $item->count;
+ }
+
+ if ( in_array( 'description', $fields, true ) ) {
+ $data['description'] = $item->description;
+ }
+
+ if ( in_array( 'link', $fields, true ) ) {
+ $data['link'] = get_term_link( $item );
+ }
+
+ if ( in_array( 'name', $fields, true ) ) {
+ $data['name'] = $item->name;
+ }
+
+ if ( in_array( 'slug', $fields, true ) ) {
+ $data['slug'] = $item->slug;
+ }
+
+ if ( in_array( 'taxonomy', $fields, true ) ) {
+ $data['taxonomy'] = $item->taxonomy;
+ }
+
+ if ( in_array( 'parent', $fields, true ) ) {
+ $data['parent'] = (int) $item->parent;
+ }
+
+ if ( in_array( 'meta', $fields, true ) ) {
+ $data['meta'] = $this->meta->get_value( $item->term_id, $request );
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $item ) );
+ }
+
+ /**
+ * Filters the term data for a REST API response.
+ *
+ * The dynamic portion of the hook name, `$this->taxonomy`, refers to the taxonomy slug.
+ *
+ * Possible hook names include:
+ *
+ * - `rest_prepare_category`
+ * - `rest_prepare_post_tag`
+ *
+ * Allows modification of the term data right before it is returned.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Term $item The original term object.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ return apply_filters( "rest_prepare_{$this->taxonomy}", $response, $item, $request );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_Term $term Term object.
+ * @return array Links for the given term.
+ */
+ protected function prepare_links( $term ) {
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( rest_get_route_for_term( $term ) ),
+ ),
+ 'collection' => array(
+ 'href' => rest_url( rest_get_route_for_taxonomy_items( $this->taxonomy ) ),
+ ),
+ 'about' => array(
+ 'href' => rest_url( sprintf( 'wp/v2/taxonomies/%s', $this->taxonomy ) ),
+ ),
+ );
+
+ if ( $term->parent ) {
+ $parent_term = get_term( (int) $term->parent, $term->taxonomy );
+
+ if ( $parent_term ) {
+ $links['up'] = array(
+ 'href' => rest_url( rest_get_route_for_term( $parent_term ) ),
+ 'embeddable' => true,
+ );
+ }
+ }
+
+ $taxonomy_obj = get_taxonomy( $term->taxonomy );
+
+ if ( empty( $taxonomy_obj->object_type ) ) {
+ return $links;
+ }
+
+ $post_type_links = array();
+
+ foreach ( $taxonomy_obj->object_type as $type ) {
+ $rest_path = rest_get_route_for_post_type_items( $type );
+
+ if ( empty( $rest_path ) ) {
+ continue;
+ }
+
+ $post_type_links[] = array(
+ 'href' => add_query_arg( $this->rest_base, $term->term_id, rest_url( $rest_path ) ),
+ );
+ }
+
+ if ( ! empty( $post_type_links ) ) {
+ $links['https://api.w.org/post_type'] = $post_type_links;
+ }
+
+ return $links;
+ }
+
+ /**
+ * Retrieves the term's schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'post_tag' === $this->taxonomy ? 'tag' : $this->taxonomy,
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the term.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'embed', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'count' => array(
+ 'description' => __( 'Number of published posts for the term.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'description' => __( 'HTML description of the term.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ 'link' => array(
+ 'description' => __( 'URL of the term.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'view', 'embed', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'HTML title for the term.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'embed', 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'required' => true,
+ ),
+ 'slug' => array(
+ 'description' => __( 'An alphanumeric identifier for the term unique to its type.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'embed', 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => array( $this, 'sanitize_slug' ),
+ ),
+ ),
+ 'taxonomy' => array(
+ 'description' => __( 'Type attribution for the term.' ),
+ 'type' => 'string',
+ 'enum' => array( $this->taxonomy ),
+ 'context' => array( 'view', 'embed', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ $taxonomy = get_taxonomy( $this->taxonomy );
+
+ if ( $taxonomy->hierarchical ) {
+ $schema['properties']['parent'] = array(
+ 'description' => __( 'The parent term ID.' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ );
+ }
+
+ $schema['properties']['meta'] = $this->meta->get_field_schema();
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * @since 4.7.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $query_params = parent::get_collection_params();
+ $taxonomy = get_taxonomy( $this->taxonomy );
+
+ $query_params['context']['default'] = 'view';
+
+ $query_params['exclude'] = array(
+ 'description' => __( 'Ensure result set excludes specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ $query_params['include'] = array(
+ 'description' => __( 'Limit result set to specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ if ( ! $taxonomy->hierarchical ) {
+ $query_params['offset'] = array(
+ 'description' => __( 'Offset the result set by a specific number of items.' ),
+ 'type' => 'integer',
+ );
+ }
+
+ $query_params['order'] = array(
+ 'description' => __( 'Order sort attribute ascending or descending.' ),
+ 'type' => 'string',
+ 'default' => 'asc',
+ 'enum' => array(
+ 'asc',
+ 'desc',
+ ),
+ );
+
+ $query_params['orderby'] = array(
+ 'description' => __( 'Sort collection by term attribute.' ),
+ 'type' => 'string',
+ 'default' => 'name',
+ 'enum' => array(
+ 'id',
+ 'include',
+ 'name',
+ 'slug',
+ 'include_slugs',
+ 'term_group',
+ 'description',
+ 'count',
+ ),
+ );
+
+ $query_params['hide_empty'] = array(
+ 'description' => __( 'Whether to hide terms not assigned to any posts.' ),
+ 'type' => 'boolean',
+ 'default' => false,
+ );
+
+ if ( $taxonomy->hierarchical ) {
+ $query_params['parent'] = array(
+ 'description' => __( 'Limit result set to terms assigned to a specific parent.' ),
+ 'type' => 'integer',
+ );
+ }
+
+ $query_params['post'] = array(
+ 'description' => __( 'Limit result set to terms assigned to a specific post.' ),
+ 'type' => 'integer',
+ 'default' => null,
+ );
+
+ $query_params['slug'] = array(
+ 'description' => __( 'Limit result set to terms with one or more specific slugs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ );
+
+ /**
+ * Filters collection parameters for the terms controller.
+ *
+ * The dynamic part of the filter `$this->taxonomy` refers to the taxonomy
+ * slug for the controller.
+ *
+ * This filter registers the collection parameter, but does not map the
+ * collection parameter to an internal WP_Term_Query parameter. Use the
+ * `rest_{$this->taxonomy}_query` filter to set WP_Term_Query parameters.
+ *
+ * @since 4.7.0
+ *
+ * @param array $query_params JSON Schema-formatted collection parameters.
+ * @param WP_Taxonomy $taxonomy Taxonomy object.
+ */
+ return apply_filters( "rest_{$this->taxonomy}_collection_params", $query_params, $taxonomy );
+ }
+
+ /**
+ * Checks that the taxonomy is valid.
+ *
+ * @since 4.7.0
+ *
+ * @param string $taxonomy Taxonomy to check.
+ * @return bool Whether the taxonomy is allowed for REST management.
+ */
+ protected function check_is_taxonomy_allowed( $taxonomy ) {
+ $taxonomy_obj = get_taxonomy( $taxonomy );
+ if ( $taxonomy_obj && ! empty( $taxonomy_obj->show_in_rest ) ) {
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-themes-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-themes-controller.php
new file mode 100644
index 0000000..064fc2d
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-themes-controller.php
@@ -0,0 +1,667 @@
+<?php
+/**
+ * REST API: WP_REST_Themes_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.0.0
+ */
+
+/**
+ * Core class used to manage themes via the REST API.
+ *
+ * @since 5.0.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Themes_Controller extends WP_REST_Controller {
+
+ /**
+ * Matches theme's directory: `/themes/<subdirectory>/<theme>/` or `/themes/<theme>/`.
+ * Excludes invalid directory name characters: `/:<>*?"|`.
+ */
+ const PATTERN = '[^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?';
+
+ /**
+ * Constructor.
+ *
+ * @since 5.0.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'themes';
+ }
+
+ /**
+ * Registers the routes for themes.
+ *
+ * @since 5.0.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ sprintf( '/%s/(?P<stylesheet>%s)', $this->rest_base, self::PATTERN ),
+ array(
+ 'args' => array(
+ 'stylesheet' => array(
+ 'description' => __( "The theme's stylesheet. This uniquely identifies the theme." ),
+ 'type' => 'string',
+ 'sanitize_callback' => array( $this, '_sanitize_stylesheet_callback' ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Sanitize the stylesheet to decode endpoint.
+ *
+ * @since 5.9.0
+ *
+ * @param string $stylesheet The stylesheet name.
+ * @return string Sanitized stylesheet.
+ */
+ public function _sanitize_stylesheet_callback( $stylesheet ) {
+ return urldecode( $stylesheet );
+ }
+
+ /**
+ * Checks if a given request has access to read the theme.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, otherwise WP_Error object.
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( current_user_can( 'switch_themes' ) || current_user_can( 'manage_network_themes' ) ) {
+ return true;
+ }
+
+ $registered = $this->get_collection_params();
+ if ( isset( $registered['status'], $request['status'] ) && is_array( $request['status'] ) && array( 'active' ) === $request['status'] ) {
+ return $this->check_read_active_theme_permission();
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view_themes',
+ __( 'Sorry, you are not allowed to view themes.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to read the theme.
+ *
+ * @since 5.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, otherwise WP_Error object.
+ */
+ public function get_item_permissions_check( $request ) {
+ if ( current_user_can( 'switch_themes' ) || current_user_can( 'manage_network_themes' ) ) {
+ return true;
+ }
+
+ $wp_theme = wp_get_theme( $request['stylesheet'] );
+ $current_theme = wp_get_theme();
+
+ if ( $this->is_same_theme( $wp_theme, $current_theme ) ) {
+ return $this->check_read_active_theme_permission();
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view_themes',
+ __( 'Sorry, you are not allowed to view themes.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Checks if a theme can be read.
+ *
+ * @since 5.7.0
+ *
+ * @return true|WP_Error True if the theme can be read, WP_Error object otherwise.
+ */
+ protected function check_read_active_theme_permission() {
+ if ( current_user_can( 'edit_posts' ) ) {
+ return true;
+ }
+
+ foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
+ if ( current_user_can( $post_type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view_active_theme',
+ __( 'Sorry, you are not allowed to view the active theme.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Retrieves a single theme.
+ *
+ * @since 5.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $wp_theme = wp_get_theme( $request['stylesheet'] );
+ if ( ! $wp_theme->exists() ) {
+ return new WP_Error(
+ 'rest_theme_not_found',
+ __( 'Theme not found.' ),
+ array( 'status' => 404 )
+ );
+ }
+ $data = $this->prepare_item_for_response( $wp_theme, $request );
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Retrieves a collection of themes.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $themes = array();
+
+ $active_themes = wp_get_themes();
+ $current_theme = wp_get_theme();
+ $status = $request['status'];
+
+ foreach ( $active_themes as $theme_name => $theme ) {
+ $theme_status = ( $this->is_same_theme( $theme, $current_theme ) ) ? 'active' : 'inactive';
+ if ( is_array( $status ) && ! in_array( $theme_status, $status, true ) ) {
+ continue;
+ }
+
+ $prepared = $this->prepare_item_for_response( $theme, $request );
+ $themes[] = $this->prepare_response_for_collection( $prepared );
+ }
+
+ $response = rest_ensure_response( $themes );
+
+ $response->header( 'X-WP-Total', count( $themes ) );
+ $response->header( 'X-WP-TotalPages', 1 );
+
+ return $response;
+ }
+
+ /**
+ * Prepares a single theme output for response.
+ *
+ * @since 5.0.0
+ * @since 5.9.0 Renamed `$theme` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param WP_Theme $item Theme object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $theme = $item;
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( rest_is_field_included( 'stylesheet', $fields ) ) {
+ $data['stylesheet'] = $theme->get_stylesheet();
+ }
+
+ if ( rest_is_field_included( 'template', $fields ) ) {
+ /**
+ * Use the get_template() method, not the 'Template' header, for finding the template.
+ * The 'Template' header is only good for what was written in the style.css, while
+ * get_template() takes into account where WordPress actually located the theme and
+ * whether it is actually valid.
+ */
+ $data['template'] = $theme->get_template();
+ }
+
+ $plain_field_mappings = array(
+ 'requires_php' => 'RequiresPHP',
+ 'requires_wp' => 'RequiresWP',
+ 'textdomain' => 'TextDomain',
+ 'version' => 'Version',
+ );
+
+ foreach ( $plain_field_mappings as $field => $header ) {
+ if ( rest_is_field_included( $field, $fields ) ) {
+ $data[ $field ] = $theme->get( $header );
+ }
+ }
+
+ if ( rest_is_field_included( 'screenshot', $fields ) ) {
+ // Using $theme->get_screenshot() with no args to get absolute URL.
+ $data['screenshot'] = $theme->get_screenshot() ? $theme->get_screenshot() : '';
+ }
+
+ $rich_field_mappings = array(
+ 'author' => 'Author',
+ 'author_uri' => 'AuthorURI',
+ 'description' => 'Description',
+ 'name' => 'Name',
+ 'tags' => 'Tags',
+ 'theme_uri' => 'ThemeURI',
+ );
+
+ foreach ( $rich_field_mappings as $field => $header ) {
+ if ( rest_is_field_included( "{$field}.raw", $fields ) ) {
+ $data[ $field ]['raw'] = $theme->display( $header, false, true );
+ }
+
+ if ( rest_is_field_included( "{$field}.rendered", $fields ) ) {
+ $data[ $field ]['rendered'] = $theme->display( $header );
+ }
+ }
+
+ $current_theme = wp_get_theme();
+ if ( rest_is_field_included( 'status', $fields ) ) {
+ $data['status'] = ( $this->is_same_theme( $theme, $current_theme ) ) ? 'active' : 'inactive';
+ }
+
+ if ( rest_is_field_included( 'theme_supports', $fields ) && $this->is_same_theme( $theme, $current_theme ) ) {
+ foreach ( get_registered_theme_features() as $feature => $config ) {
+ if ( ! is_array( $config['show_in_rest'] ) ) {
+ continue;
+ }
+
+ $name = $config['show_in_rest']['name'];
+
+ if ( ! rest_is_field_included( "theme_supports.{$name}", $fields ) ) {
+ continue;
+ }
+
+ if ( ! current_theme_supports( $feature ) ) {
+ $data['theme_supports'][ $name ] = $config['show_in_rest']['schema']['default'];
+ continue;
+ }
+
+ $support = get_theme_support( $feature );
+
+ if ( isset( $config['show_in_rest']['prepare_callback'] ) ) {
+ $prepare = $config['show_in_rest']['prepare_callback'];
+ } else {
+ $prepare = array( $this, 'prepare_theme_support' );
+ }
+
+ $prepared = $prepare( $support, $config, $feature, $request );
+
+ if ( is_wp_error( $prepared ) ) {
+ continue;
+ }
+
+ $data['theme_supports'][ $name ] = $prepared;
+ }
+ }
+
+ if ( rest_is_field_included( 'is_block_theme', $fields ) ) {
+ $data['is_block_theme'] = $theme->is_block_theme();
+ }
+
+ $data = $this->add_additional_fields_to_object( $data, $request );
+
+ // Wrap the data in a response object.
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $theme ) );
+ }
+
+ /**
+ * Filters theme data returned from the REST API.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Theme $theme Theme object used to create response.
+ * @param WP_REST_Request $request Request object.
+ */
+ return apply_filters( 'rest_prepare_theme', $response, $theme, $request );
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * @since 5.7.0
+ *
+ * @param WP_Theme $theme Theme data.
+ * @return array Links for the given block type.
+ */
+ protected function prepare_links( $theme ) {
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $theme->get_stylesheet() ) ),
+ ),
+ 'collection' => array(
+ 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+ ),
+ );
+
+ if ( $this->is_same_theme( $theme, wp_get_theme() ) ) {
+ // This creates a record for the active theme if not existent.
+ $id = WP_Theme_JSON_Resolver::get_user_global_styles_post_id();
+ } else {
+ $user_cpt = WP_Theme_JSON_Resolver::get_user_data_from_wp_global_styles( $theme );
+ $id = isset( $user_cpt['ID'] ) ? $user_cpt['ID'] : null;
+ }
+
+ if ( $id ) {
+ $links['https://api.w.org/user-global-styles'] = array(
+ 'href' => rest_url( 'wp/v2/global-styles/' . $id ),
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * Helper function to compare two themes.
+ *
+ * @since 5.7.0
+ *
+ * @param WP_Theme $theme_a First theme to compare.
+ * @param WP_Theme $theme_b Second theme to compare.
+ * @return bool
+ */
+ protected function is_same_theme( $theme_a, $theme_b ) {
+ return $theme_a->get_stylesheet() === $theme_b->get_stylesheet();
+ }
+
+ /**
+ * Prepares the theme support value for inclusion in the REST API response.
+ *
+ * @since 5.5.0
+ *
+ * @param mixed $support The raw value from get_theme_support().
+ * @param array $args The feature's registration args.
+ * @param string $feature The feature name.
+ * @param WP_REST_Request $request The request object.
+ * @return mixed The prepared support value.
+ */
+ protected function prepare_theme_support( $support, $args, $feature, $request ) {
+ $schema = $args['show_in_rest']['schema'];
+
+ if ( 'boolean' === $schema['type'] ) {
+ return true;
+ }
+
+ if ( is_array( $support ) && ! $args['variadic'] ) {
+ $support = $support[0];
+ }
+
+ return rest_sanitize_value_from_schema( $support, $schema );
+ }
+
+ /**
+ * Retrieves the theme's schema, conforming to JSON Schema.
+ *
+ * @since 5.0.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'theme',
+ 'type' => 'object',
+ 'properties' => array(
+ 'stylesheet' => array(
+ 'description' => __( 'The theme\'s stylesheet. This uniquely identifies the theme.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'template' => array(
+ 'description' => __( 'The theme\'s template. If this is a child theme, this refers to the parent theme, otherwise this is the same as the theme\'s stylesheet.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'author' => array(
+ 'description' => __( 'The theme author.' ),
+ 'type' => 'object',
+ 'readonly' => true,
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'The theme author\'s name, as found in the theme header.' ),
+ 'type' => 'string',
+ ),
+ 'rendered' => array(
+ 'description' => __( 'HTML for the theme author, transformed for display.' ),
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ 'author_uri' => array(
+ 'description' => __( 'The website of the theme author.' ),
+ 'type' => 'object',
+ 'readonly' => true,
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'The website of the theme author, as found in the theme header.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ 'rendered' => array(
+ 'description' => __( 'The website of the theme author, transformed for display.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ ),
+ ),
+ 'description' => array(
+ 'description' => __( 'A description of the theme.' ),
+ 'type' => 'object',
+ 'readonly' => true,
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'The theme description, as found in the theme header.' ),
+ 'type' => 'string',
+ ),
+ 'rendered' => array(
+ 'description' => __( 'The theme description, transformed for display.' ),
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ 'is_block_theme' => array(
+ 'description' => __( 'Whether the theme is a block-based theme.' ),
+ 'type' => 'boolean',
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'The name of the theme.' ),
+ 'type' => 'object',
+ 'readonly' => true,
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'The theme name, as found in the theme header.' ),
+ 'type' => 'string',
+ ),
+ 'rendered' => array(
+ 'description' => __( 'The theme name, transformed for display.' ),
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ 'requires_php' => array(
+ 'description' => __( 'The minimum PHP version required for the theme to work.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'requires_wp' => array(
+ 'description' => __( 'The minimum WordPress version required for the theme to work.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'screenshot' => array(
+ 'description' => __( 'The theme\'s screenshot URL.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'readonly' => true,
+ ),
+ 'tags' => array(
+ 'description' => __( 'Tags indicating styles and features of the theme.' ),
+ 'type' => 'object',
+ 'readonly' => true,
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'The theme tags, as found in the theme header.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ ),
+ 'rendered' => array(
+ 'description' => __( 'The theme tags, transformed for display.' ),
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ 'textdomain' => array(
+ 'description' => __( 'The theme\'s text domain.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'theme_supports' => array(
+ 'description' => __( 'Features supported by this theme.' ),
+ 'type' => 'object',
+ 'readonly' => true,
+ 'properties' => array(),
+ ),
+ 'theme_uri' => array(
+ 'description' => __( 'The URI of the theme\'s webpage.' ),
+ 'type' => 'object',
+ 'readonly' => true,
+ 'properties' => array(
+ 'raw' => array(
+ 'description' => __( 'The URI of the theme\'s webpage, as found in the theme header.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ 'rendered' => array(
+ 'description' => __( 'The URI of the theme\'s webpage, transformed for display.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ ),
+ ),
+ 'version' => array(
+ 'description' => __( 'The theme\'s current version.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'status' => array(
+ 'description' => __( 'A named status for the theme.' ),
+ 'type' => 'string',
+ 'enum' => array( 'inactive', 'active' ),
+ ),
+ ),
+ );
+
+ foreach ( get_registered_theme_features() as $feature => $config ) {
+ if ( ! is_array( $config['show_in_rest'] ) ) {
+ continue;
+ }
+
+ $name = $config['show_in_rest']['name'];
+
+ $schema['properties']['theme_supports']['properties'][ $name ] = $config['show_in_rest']['schema'];
+ }
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the search params for the themes collection.
+ *
+ * @since 5.0.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $query_params = array(
+ 'status' => array(
+ 'description' => __( 'Limit result set to themes assigned one or more statuses.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'enum' => array( 'active', 'inactive' ),
+ 'type' => 'string',
+ ),
+ ),
+ );
+
+ /**
+ * Filters REST API collection parameters for the themes controller.
+ *
+ * @since 5.0.0
+ *
+ * @param array $query_params JSON Schema-formatted collection parameters.
+ */
+ return apply_filters( 'rest_themes_collection_params', $query_params );
+ }
+
+ /**
+ * Sanitizes and validates the list of theme status.
+ *
+ * @since 5.0.0
+ * @deprecated 5.7.0
+ *
+ * @param string|array $statuses One or more theme statuses.
+ * @param WP_REST_Request $request Full details about the request.
+ * @param string $parameter Additional parameter to pass to validation.
+ * @return array|WP_Error A list of valid statuses, otherwise WP_Error object.
+ */
+ public function sanitize_theme_status( $statuses, $request, $parameter ) {
+ _deprecated_function( __METHOD__, '5.7.0' );
+
+ $statuses = wp_parse_slug_list( $statuses );
+
+ foreach ( $statuses as $status ) {
+ $result = rest_validate_request_arg( $status, $request, $parameter );
+
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ }
+
+ return $statuses;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php
new file mode 100644
index 0000000..c9ac667
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php
@@ -0,0 +1,668 @@
+<?php
+/**
+ * REST API: WP_REST_URL_Details_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.9.0
+ */
+
+/**
+ * Controller which provides REST endpoint for retrieving information
+ * from a remote site's HTML response.
+ *
+ * @since 5.9.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_URL_Details_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructs the controller.
+ *
+ * @since 5.9.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp-block-editor/v1';
+ $this->rest_base = 'url-details';
+ }
+
+ /**
+ * Registers the necessary REST API routes.
+ *
+ * @since 5.9.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'parse_url_details' ),
+ 'args' => array(
+ 'url' => array(
+ 'required' => true,
+ 'description' => __( 'The URL to process.' ),
+ 'validate_callback' => 'wp_http_validate_url',
+ 'sanitize_callback' => 'sanitize_url',
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ ),
+ 'permission_callback' => array( $this, 'permissions_check' ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Retrieves the item's schema, conforming to JSON Schema.
+ *
+ * @since 5.9.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'url-details',
+ 'type' => 'object',
+ 'properties' => array(
+ 'title' => array(
+ 'description' => sprintf(
+ /* translators: %s: HTML title tag. */
+ __( 'The contents of the %s element from the URL.' ),
+ '<title>'
+ ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'icon' => array(
+ 'description' => sprintf(
+ /* translators: %s: HTML link tag. */
+ __( 'The favicon image link of the %s element from the URL.' ),
+ '<link rel="icon">'
+ ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'description' => sprintf(
+ /* translators: %s: HTML meta tag. */
+ __( 'The content of the %s element from the URL.' ),
+ '<meta name="description">'
+ ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'image' => array(
+ 'description' => sprintf(
+ /* translators: 1: HTML meta tag, 2: HTML meta tag. */
+ __( 'The Open Graph image link of the %1$s or %2$s element from the URL.' ),
+ '<meta property="og:image">',
+ '<meta property="og:image:url">'
+ ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the contents of the title tag from the HTML response.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_REQUEST $request Full details about the request.
+ * @return WP_REST_Response|WP_Error The parsed details as a response object. WP_Error if there are errors.
+ */
+ public function parse_url_details( $request ) {
+ $url = untrailingslashit( $request['url'] );
+
+ if ( empty( $url ) ) {
+ return new WP_Error( 'rest_invalid_url', __( 'Invalid URL' ), array( 'status' => 404 ) );
+ }
+
+ // Transient per URL.
+ $cache_key = $this->build_cache_key_for_url( $url );
+
+ // Attempt to retrieve cached response.
+ $cached_response = $this->get_cache( $cache_key );
+
+ if ( ! empty( $cached_response ) ) {
+ $remote_url_response = $cached_response;
+ } else {
+ $remote_url_response = $this->get_remote_url( $url );
+
+ // Exit if we don't have a valid body or it's empty.
+ if ( is_wp_error( $remote_url_response ) || empty( $remote_url_response ) ) {
+ return $remote_url_response;
+ }
+
+ // Cache the valid response.
+ $this->set_cache( $cache_key, $remote_url_response );
+ }
+
+ $html_head = $this->get_document_head( $remote_url_response );
+ $meta_elements = $this->get_meta_with_content_elements( $html_head );
+
+ $data = $this->add_additional_fields_to_object(
+ array(
+ 'title' => $this->get_title( $html_head ),
+ 'icon' => $this->get_icon( $html_head, $url ),
+ 'description' => $this->get_description( $meta_elements ),
+ 'image' => $this->get_image( $meta_elements, $url ),
+ ),
+ $request
+ );
+
+ // Wrap the data in a response object.
+ $response = rest_ensure_response( $data );
+
+ /**
+ * Filters the URL data for the response.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param string $url The requested URL.
+ * @param WP_REST_Request $request Request object.
+ * @param string $remote_url_response HTTP response body from the remote URL.
+ */
+ return apply_filters( 'rest_prepare_url_details', $response, $url, $request, $remote_url_response );
+ }
+
+ /**
+ * Checks whether a given request has permission to read remote URLs.
+ *
+ * @since 5.9.0
+ *
+ * @return WP_Error|bool True if the request has permission, else WP_Error.
+ */
+ public function permissions_check() {
+ if ( current_user_can( 'edit_posts' ) ) {
+ return true;
+ }
+
+ foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
+ if ( current_user_can( $post_type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error(
+ 'rest_cannot_view_url_details',
+ __( 'Sorry, you are not allowed to process remote URLs.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Retrieves the document title from a remote URL.
+ *
+ * @since 5.9.0
+ *
+ * @param string $url The website URL whose HTML to access.
+ * @return string|WP_Error The HTTP response from the remote URL on success.
+ * WP_Error if no response or no content.
+ */
+ private function get_remote_url( $url ) {
+
+ /*
+ * Provide a modified UA string to workaround web properties which block WordPress "Pingbacks".
+ * Why? The UA string used for pingback requests contains `WordPress/` which is very similar
+ * to that used as the default UA string by the WP HTTP API. Therefore requests from this
+ * REST endpoint are being unintentionally blocked as they are misidentified as pingback requests.
+ * By slightly modifying the UA string, but still retaining the "WordPress" identification (via "WP")
+ * we are able to work around this issue.
+ * Example UA string: `WP-URLDetails/5.9-alpha-51389 (+http://localhost:8888)`.
+ */
+ $modified_user_agent = 'WP-URLDetails/' . get_bloginfo( 'version' ) . ' (+' . get_bloginfo( 'url' ) . ')';
+
+ $args = array(
+ 'limit_response_size' => 150 * KB_IN_BYTES,
+ 'user-agent' => $modified_user_agent,
+ );
+
+ /**
+ * Filters the HTTP request args for URL data retrieval.
+ *
+ * Can be used to adjust response size limit and other WP_Http::request() args.
+ *
+ * @since 5.9.0
+ *
+ * @param array $args Arguments used for the HTTP request.
+ * @param string $url The attempted URL.
+ */
+ $args = apply_filters( 'rest_url_details_http_request_args', $args, $url );
+
+ $response = wp_safe_remote_get( $url, $args );
+
+ if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) {
+ // Not saving the error response to cache since the error might be temporary.
+ return new WP_Error(
+ 'no_response',
+ __( 'URL not found. Response returned a non-200 status code for this URL.' ),
+ array( 'status' => WP_Http::NOT_FOUND )
+ );
+ }
+
+ $remote_body = wp_remote_retrieve_body( $response );
+
+ if ( empty( $remote_body ) ) {
+ return new WP_Error(
+ 'no_content',
+ __( 'Unable to retrieve body from response at this URL.' ),
+ array( 'status' => WP_Http::NOT_FOUND )
+ );
+ }
+
+ return $remote_body;
+ }
+
+ /**
+ * Parses the title tag contents from the provided HTML.
+ *
+ * @since 5.9.0
+ *
+ * @param string $html The HTML from the remote website at URL.
+ * @return string The title tag contents on success. Empty string if not found.
+ */
+ private function get_title( $html ) {
+ $pattern = '#<title[^>]*>(.*?)<\s*/\s*title>#is';
+ preg_match( $pattern, $html, $match_title );
+
+ if ( empty( $match_title[1] ) || ! is_string( $match_title[1] ) ) {
+ return '';
+ }
+
+ $title = trim( $match_title[1] );
+
+ return $this->prepare_metadata_for_output( $title );
+ }
+
+ /**
+ * Parses the site icon from the provided HTML.
+ *
+ * @since 5.9.0
+ *
+ * @param string $html The HTML from the remote website at URL.
+ * @param string $url The target website URL.
+ * @return string The icon URI on success. Empty string if not found.
+ */
+ private function get_icon( $html, $url ) {
+ // Grab the icon's link element.
+ $pattern = '#<link\s[^>]*rel=(?:[\"\']??)\s*(?:icon|shortcut icon|icon shortcut)\s*(?:[\"\']??)[^>]*\/?>#isU';
+ preg_match( $pattern, $html, $element );
+ if ( empty( $element[0] ) || ! is_string( $element[0] ) ) {
+ return '';
+ }
+ $element = trim( $element[0] );
+
+ // Get the icon's href value.
+ $pattern = '#href=([\"\']??)([^\" >]*?)\\1[^>]*#isU';
+ preg_match( $pattern, $element, $icon );
+ if ( empty( $icon[2] ) || ! is_string( $icon[2] ) ) {
+ return '';
+ }
+ $icon = trim( $icon[2] );
+
+ // If the icon is a data URL, return it.
+ $parsed_icon = parse_url( $icon );
+ if ( isset( $parsed_icon['scheme'] ) && 'data' === $parsed_icon['scheme'] ) {
+ return $icon;
+ }
+
+ // Attempt to convert relative URLs to absolute.
+ if ( ! is_string( $url ) || '' === $url ) {
+ return $icon;
+ }
+ $parsed_url = parse_url( $url );
+ if ( isset( $parsed_url['scheme'] ) && isset( $parsed_url['host'] ) ) {
+ $root_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . '/';
+ $icon = WP_Http::make_absolute_url( $icon, $root_url );
+ }
+
+ return $icon;
+ }
+
+ /**
+ * Parses the meta description from the provided HTML.
+ *
+ * @since 5.9.0
+ *
+ * @param array $meta_elements {
+ * A multi-dimensional indexed array on success, else empty array.
+ *
+ * @type string[] $0 Meta elements with a content attribute.
+ * @type string[] $1 Content attribute's opening quotation mark.
+ * @type string[] $2 Content attribute's value for each meta element.
+ * }
+ * @return string The meta description contents on success. Empty string if not found.
+ */
+ private function get_description( $meta_elements ) {
+ // Bail out if there are no meta elements.
+ if ( empty( $meta_elements[0] ) ) {
+ return '';
+ }
+
+ $description = $this->get_metadata_from_meta_element(
+ $meta_elements,
+ 'name',
+ '(?:description|og:description)'
+ );
+
+ // Bail out if description not found.
+ if ( '' === $description ) {
+ return '';
+ }
+
+ return $this->prepare_metadata_for_output( $description );
+ }
+
+ /**
+ * Parses the Open Graph (OG) Image from the provided HTML.
+ *
+ * See: https://ogp.me/.
+ *
+ * @since 5.9.0
+ *
+ * @param array $meta_elements {
+ * A multi-dimensional indexed array on success, else empty array.
+ *
+ * @type string[] $0 Meta elements with a content attribute.
+ * @type string[] $1 Content attribute's opening quotation mark.
+ * @type string[] $2 Content attribute's value for each meta element.
+ * }
+ * @param string $url The target website URL.
+ * @return string The OG image on success. Empty string if not found.
+ */
+ private function get_image( $meta_elements, $url ) {
+ $image = $this->get_metadata_from_meta_element(
+ $meta_elements,
+ 'property',
+ '(?:og:image|og:image:url)'
+ );
+
+ // Bail out if image not found.
+ if ( '' === $image ) {
+ return '';
+ }
+
+ // Attempt to convert relative URLs to absolute.
+ $parsed_url = parse_url( $url );
+ if ( isset( $parsed_url['scheme'] ) && isset( $parsed_url['host'] ) ) {
+ $root_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . '/';
+ $image = WP_Http::make_absolute_url( $image, $root_url );
+ }
+
+ return $image;
+ }
+
+ /**
+ * Prepares the metadata by:
+ * - stripping all HTML tags and tag entities.
+ * - converting non-tag entities into characters.
+ *
+ * @since 5.9.0
+ *
+ * @param string $metadata The metadata content to prepare.
+ * @return string The prepared metadata.
+ */
+ private function prepare_metadata_for_output( $metadata ) {
+ $metadata = html_entity_decode( $metadata, ENT_QUOTES, get_bloginfo( 'charset' ) );
+ $metadata = wp_strip_all_tags( $metadata );
+ return $metadata;
+ }
+
+ /**
+ * Utility function to build cache key for a given URL.
+ *
+ * @since 5.9.0
+ *
+ * @param string $url The URL for which to build a cache key.
+ * @return string The cache key.
+ */
+ private function build_cache_key_for_url( $url ) {
+ return 'g_url_details_response_' . md5( $url );
+ }
+
+ /**
+ * Utility function to retrieve a value from the cache at a given key.
+ *
+ * @since 5.9.0
+ *
+ * @param string $key The cache key.
+ * @return mixed The value from the cache.
+ */
+ private function get_cache( $key ) {
+ return get_site_transient( $key );
+ }
+
+ /**
+ * Utility function to cache a given data set at a given cache key.
+ *
+ * @since 5.9.0
+ *
+ * @param string $key The cache key under which to store the value.
+ * @param string $data The data to be stored at the given cache key.
+ * @return bool True when transient set. False if not set.
+ */
+ private function set_cache( $key, $data = '' ) {
+ $ttl = HOUR_IN_SECONDS;
+
+ /**
+ * Filters the cache expiration.
+ *
+ * Can be used to adjust the time until expiration in seconds for the cache
+ * of the data retrieved for the given URL.
+ *
+ * @since 5.9.0
+ *
+ * @param int $ttl The time until cache expiration in seconds.
+ */
+ $cache_expiration = apply_filters( 'rest_url_details_cache_expiration', $ttl );
+
+ return set_site_transient( $key, $data, $cache_expiration );
+ }
+
+ /**
+ * Retrieves the head element section.
+ *
+ * @since 5.9.0
+ *
+ * @param string $html The string of HTML to parse.
+ * @return string The `<head>..</head>` section on success. Given `$html` if not found.
+ */
+ private function get_document_head( $html ) {
+ $head_html = $html;
+
+ // Find the opening `<head>` tag.
+ $head_start = strpos( $html, '<head' );
+ if ( false === $head_start ) {
+ // Didn't find it. Return the original HTML.
+ return $html;
+ }
+
+ // Find the closing `</head>` tag.
+ $head_end = strpos( $head_html, '</head>' );
+ if ( false === $head_end ) {
+ // Didn't find it. Find the opening `<body>` tag.
+ $head_end = strpos( $head_html, '<body' );
+
+ // Didn't find it. Return the original HTML.
+ if ( false === $head_end ) {
+ return $html;
+ }
+ }
+
+ // Extract the HTML from opening tag to the closing tag. Then add the closing tag.
+ $head_html = substr( $head_html, $head_start, $head_end );
+ $head_html .= '</head>';
+
+ return $head_html;
+ }
+
+ /**
+ * Gets all the meta tag elements that have a 'content' attribute.
+ *
+ * @since 5.9.0
+ *
+ * @param string $html The string of HTML to be parsed.
+ * @return array {
+ * A multi-dimensional indexed array on success, else empty array.
+ *
+ * @type string[] $0 Meta elements with a content attribute.
+ * @type string[] $1 Content attribute's opening quotation mark.
+ * @type string[] $2 Content attribute's value for each meta element.
+ * }
+ */
+ private function get_meta_with_content_elements( $html ) {
+ /*
+ * Parse all meta elements with a content attribute.
+ *
+ * Why first search for the content attribute rather than directly searching for name=description element?
+ * tl;dr The content attribute's value will be truncated when it contains a > symbol.
+ *
+ * The content attribute's value (i.e. the description to get) can have HTML in it and be well-formed as
+ * it's a string to the browser. Imagine what happens when attempting to match for the name=description
+ * first. Hmm, if a > or /> symbol is in the content attribute's value, then it terminates the match
+ * as the element's closing symbol. But wait, it's in the content attribute and is not the end of the
+ * element. This is a limitation of using regex. It can't determine "wait a minute this is inside of quotation".
+ * If this happens, what gets matched is not the entire element or all of the content.
+ *
+ * Why not search for the name=description and then content="(.*)"?
+ * The attribute order could be opposite. Plus, additional attributes may exist including being between
+ * the name and content attributes.
+ *
+ * Why not lookahead?
+ * Lookahead is not constrained to stay within the element. The first <meta it finds may not include
+ * the name or content, but rather could be from a different element downstream.
+ */
+ $pattern = '#<meta\s' .
+
+ /*
+ * Allows for additional attributes before the content attribute.
+ * Searches for anything other than > symbol.
+ */
+ '[^>]*' .
+
+ /*
+ * Find the content attribute. When found, capture its value (.*).
+ *
+ * Allows for (a) single or double quotes and (b) whitespace in the value.
+ *
+ * Why capture the opening quotation mark, i.e. (["\']), and then backreference,
+ * i.e \1, for the closing quotation mark?
+ * To ensure the closing quotation mark matches the opening one. Why? Attribute values
+ * can contain quotation marks, such as an apostrophe in the content.
+ */
+ 'content=(["\']??)(.*)\1' .
+
+ /*
+ * Allows for additional attributes after the content attribute.
+ * Searches for anything other than > symbol.
+ */
+ '[^>]*' .
+
+ /*
+ * \/?> searches for the closing > symbol, which can be in either /> or > format.
+ * # ends the pattern.
+ */
+ '\/?>#' .
+
+ /*
+ * These are the options:
+ * - i : case insensitive
+ * - s : allows newline characters for the . match (needed for multiline elements)
+ * - U means non-greedy matching
+ */
+ 'isU';
+
+ preg_match_all( $pattern, $html, $elements );
+
+ return $elements;
+ }
+
+ /**
+ * Gets the metadata from a target meta element.
+ *
+ * @since 5.9.0
+ *
+ * @param array $meta_elements {
+ * A multi-dimensional indexed array on success, else empty array.
+ *
+ * @type string[] $0 Meta elements with a content attribute.
+ * @type string[] $1 Content attribute's opening quotation mark.
+ * @type string[] $2 Content attribute's value for each meta element.
+ * }
+ * @param string $attr Attribute that identifies the element with the target metadata.
+ * @param string $attr_value The attribute's value that identifies the element with the target metadata.
+ * @return string The metadata on success. Empty string if not found.
+ */
+ private function get_metadata_from_meta_element( $meta_elements, $attr, $attr_value ) {
+ // Bail out if there are no meta elements.
+ if ( empty( $meta_elements[0] ) ) {
+ return '';
+ }
+
+ $metadata = '';
+ $pattern = '#' .
+ /*
+ * Target this attribute and value to find the metadata element.
+ *
+ * Allows for (a) no, single, double quotes and (b) whitespace in the value.
+ *
+ * Why capture the opening quotation mark, i.e. (["\']), and then backreference,
+ * i.e \1, for the closing quotation mark?
+ * To ensure the closing quotation mark matches the opening one. Why? Attribute values
+ * can contain quotation marks, such as an apostrophe in the content.
+ */
+ $attr . '=([\"\']??)\s*' . $attr_value . '\s*\1' .
+
+ /*
+ * These are the options:
+ * - i : case insensitive
+ * - s : allows newline characters for the . match (needed for multiline elements)
+ * - U means non-greedy matching
+ */
+ '#isU';
+
+ // Find the metadata element.
+ foreach ( $meta_elements[0] as $index => $element ) {
+ preg_match( $pattern, $element, $match );
+
+ // This is not the metadata element. Skip it.
+ if ( empty( $match ) ) {
+ continue;
+ }
+
+ /*
+ * Found the metadata element.
+ * Get the metadata from its matching content array.
+ */
+ if ( isset( $meta_elements[2][ $index ] ) && is_string( $meta_elements[2][ $index ] ) ) {
+ $metadata = trim( $meta_elements[2][ $index ] );
+ }
+
+ break;
+ }
+
+ return $metadata;
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php
new file mode 100644
index 0000000..9b38470
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php
@@ -0,0 +1,1614 @@
+<?php
+/**
+ * REST API: WP_REST_Users_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class used to manage users via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Users_Controller extends WP_REST_Controller {
+
+ /**
+ * Instance of a user meta fields object.
+ *
+ * @since 4.7.0
+ * @var WP_REST_User_Meta_Fields
+ */
+ protected $meta;
+
+ /**
+ * Constructor.
+ *
+ * @since 4.7.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'users';
+
+ $this->meta = new WP_REST_User_Meta_Fields();
+ }
+
+ /**
+ * Registers the routes for users.
+ *
+ * @since 4.7.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[\d]+)',
+ array(
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the user.' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'permission_callback' => array( $this, 'update_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+ 'args' => array(
+ 'force' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ 'description' => __( 'Required to be true, as users do not support trashing.' ),
+ ),
+ 'reassign' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Reassign the deleted user\'s posts and links to this user ID.' ),
+ 'required' => true,
+ 'sanitize_callback' => array( $this, 'check_reassign' ),
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/me',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'permission_callback' => '__return_true',
+ 'callback' => array( $this, 'get_current_item' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_current_item' ),
+ 'permission_callback' => array( $this, 'update_current_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_current_item' ),
+ 'permission_callback' => array( $this, 'delete_current_item_permissions_check' ),
+ 'args' => array(
+ 'force' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ 'description' => __( 'Required to be true, as users do not support trashing.' ),
+ ),
+ 'reassign' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Reassign the deleted user\'s posts and links to this user ID.' ),
+ 'required' => true,
+ 'sanitize_callback' => array( $this, 'check_reassign' ),
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks for a valid value for the reassign parameter when deleting users.
+ *
+ * The value can be an integer, 'false', false, or ''.
+ *
+ * @since 4.7.0
+ *
+ * @param int|bool $value The value passed to the reassign parameter.
+ * @param WP_REST_Request $request Full details about the request.
+ * @param string $param The parameter that is being sanitized.
+ * @return int|bool|WP_Error
+ */
+ public function check_reassign( $value, $request, $param ) {
+ if ( is_numeric( $value ) ) {
+ return $value;
+ }
+
+ if ( empty( $value ) || false === $value || 'false' === $value ) {
+ return false;
+ }
+
+ return new WP_Error(
+ 'rest_invalid_param',
+ __( 'Invalid user parameter(s).' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /**
+ * Permissions check for getting all users.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, otherwise WP_Error object.
+ */
+ public function get_items_permissions_check( $request ) {
+ // Check if roles is specified in GET request and if user can list users.
+ if ( ! empty( $request['roles'] ) && ! current_user_can( 'list_users' ) ) {
+ return new WP_Error(
+ 'rest_user_cannot_view',
+ __( 'Sorry, you are not allowed to filter users by role.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ // Check if capabilities is specified in GET request and if user can list users.
+ if ( ! empty( $request['capabilities'] ) && ! current_user_can( 'list_users' ) ) {
+ return new WP_Error(
+ 'rest_user_cannot_view',
+ __( 'Sorry, you are not allowed to filter users by capability.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( 'edit' === $request['context'] && ! current_user_can( 'list_users' ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to list users.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( in_array( $request['orderby'], array( 'email', 'registered_date' ), true ) && ! current_user_can( 'list_users' ) ) {
+ return new WP_Error(
+ 'rest_forbidden_orderby',
+ __( 'Sorry, you are not allowed to order users by this parameter.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( 'authors' === $request['who'] ) {
+ $types = get_post_types( array( 'show_in_rest' => true ), 'objects' );
+
+ foreach ( $types as $type ) {
+ if ( post_type_supports( $type->name, 'author' )
+ && current_user_can( $type->cap->edit_posts ) ) {
+ return true;
+ }
+ }
+
+ return new WP_Error(
+ 'rest_forbidden_who',
+ __( 'Sorry, you are not allowed to query users by this parameter.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves all users.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+
+ // Retrieve the list of registered collection query parameters.
+ $registered = $this->get_collection_params();
+
+ /*
+ * This array defines mappings between public API query parameters whose
+ * values are accepted as-passed, and their internal WP_Query parameter
+ * name equivalents (some are the same). Only values which are also
+ * present in $registered will be set.
+ */
+ $parameter_mappings = array(
+ 'exclude' => 'exclude',
+ 'include' => 'include',
+ 'order' => 'order',
+ 'per_page' => 'number',
+ 'search' => 'search',
+ 'roles' => 'role__in',
+ 'capabilities' => 'capability__in',
+ 'slug' => 'nicename__in',
+ );
+
+ $prepared_args = array();
+
+ /*
+ * For each known parameter which is both registered and present in the request,
+ * set the parameter's value on the query $prepared_args.
+ */
+ foreach ( $parameter_mappings as $api_param => $wp_param ) {
+ if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
+ $prepared_args[ $wp_param ] = $request[ $api_param ];
+ }
+ }
+
+ if ( isset( $registered['offset'] ) && ! empty( $request['offset'] ) ) {
+ $prepared_args['offset'] = $request['offset'];
+ } else {
+ $prepared_args['offset'] = ( $request['page'] - 1 ) * $prepared_args['number'];
+ }
+
+ if ( isset( $registered['orderby'] ) ) {
+ $orderby_possibles = array(
+ 'id' => 'ID',
+ 'include' => 'include',
+ 'name' => 'display_name',
+ 'registered_date' => 'registered',
+ 'slug' => 'user_nicename',
+ 'include_slugs' => 'nicename__in',
+ 'email' => 'user_email',
+ 'url' => 'user_url',
+ );
+ $prepared_args['orderby'] = $orderby_possibles[ $request['orderby'] ];
+ }
+
+ if ( isset( $registered['who'] ) && ! empty( $request['who'] ) && 'authors' === $request['who'] ) {
+ $prepared_args['who'] = 'authors';
+ } elseif ( ! current_user_can( 'list_users' ) ) {
+ $prepared_args['has_published_posts'] = get_post_types( array( 'show_in_rest' => true ), 'names' );
+ }
+
+ if ( ! empty( $request['has_published_posts'] ) ) {
+ $prepared_args['has_published_posts'] = ( true === $request['has_published_posts'] )
+ ? get_post_types( array( 'show_in_rest' => true ), 'names' )
+ : (array) $request['has_published_posts'];
+ }
+
+ if ( ! empty( $prepared_args['search'] ) ) {
+ if ( ! current_user_can( 'list_users' ) ) {
+ $prepared_args['search_columns'] = array( 'ID', 'user_login', 'user_nicename', 'display_name' );
+ }
+ $prepared_args['search'] = '*' . $prepared_args['search'] . '*';
+ }
+ /**
+ * Filters WP_User_Query arguments when querying users via the REST API.
+ *
+ * @link https://developer.wordpress.org/reference/classes/wp_user_query/
+ *
+ * @since 4.7.0
+ *
+ * @param array $prepared_args Array of arguments for WP_User_Query.
+ * @param WP_REST_Request $request The REST API request.
+ */
+ $prepared_args = apply_filters( 'rest_user_query', $prepared_args, $request );
+
+ $query = new WP_User_Query( $prepared_args );
+
+ $users = array();
+
+ foreach ( $query->results as $user ) {
+ $data = $this->prepare_item_for_response( $user, $request );
+ $users[] = $this->prepare_response_for_collection( $data );
+ }
+
+ $response = rest_ensure_response( $users );
+
+ // Store pagination values for headers then unset for count query.
+ $per_page = (int) $prepared_args['number'];
+ $page = ceil( ( ( (int) $prepared_args['offset'] ) / $per_page ) + 1 );
+
+ $prepared_args['fields'] = 'ID';
+
+ $total_users = $query->get_total();
+
+ if ( $total_users < 1 ) {
+ // Out-of-bounds, run the query again without LIMIT for total count.
+ unset( $prepared_args['number'], $prepared_args['offset'] );
+ $count_query = new WP_User_Query( $prepared_args );
+ $total_users = $count_query->get_total();
+ }
+
+ $response->header( 'X-WP-Total', (int) $total_users );
+
+ $max_pages = ceil( $total_users / $per_page );
+
+ $response->header( 'X-WP-TotalPages', (int) $max_pages );
+
+ $base = add_query_arg( urlencode_deep( $request->get_query_params() ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) );
+ if ( $page > 1 ) {
+ $prev_page = $page - 1;
+
+ if ( $prev_page > $max_pages ) {
+ $prev_page = $max_pages;
+ }
+
+ $prev_link = add_query_arg( 'page', $prev_page, $base );
+ $response->link_header( 'prev', $prev_link );
+ }
+ if ( $max_pages > $page ) {
+ $next_page = $page + 1;
+ $next_link = add_query_arg( 'page', $next_page, $base );
+
+ $response->link_header( 'next', $next_link );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Get the user, if the ID is valid.
+ *
+ * @since 4.7.2
+ *
+ * @param int $id Supplied ID.
+ * @return WP_User|WP_Error True if ID is valid, WP_Error otherwise.
+ */
+ protected function get_user( $id ) {
+ $error = new WP_Error(
+ 'rest_user_invalid_id',
+ __( 'Invalid user ID.' ),
+ array( 'status' => 404 )
+ );
+
+ if ( (int) $id <= 0 ) {
+ return $error;
+ }
+
+ $user = get_userdata( (int) $id );
+ if ( empty( $user ) || ! $user->exists() ) {
+ return $error;
+ }
+
+ if ( is_multisite() && ! is_user_member_of_blog( $user->ID ) ) {
+ return $error;
+ }
+
+ return $user;
+ }
+
+ /**
+ * Checks if a given request has access to read a user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, otherwise WP_Error object.
+ */
+ public function get_item_permissions_check( $request ) {
+ $user = $this->get_user( $request['id'] );
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $types = get_post_types( array( 'show_in_rest' => true ), 'names' );
+
+ if ( get_current_user_id() === $user->ID ) {
+ return true;
+ }
+
+ if ( 'edit' === $request['context'] && ! current_user_can( 'list_users' ) ) {
+ return new WP_Error(
+ 'rest_user_cannot_view',
+ __( 'Sorry, you are not allowed to list users.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ } elseif ( ! count_user_posts( $user->ID, $types ) && ! current_user_can( 'edit_user', $user->ID ) && ! current_user_can( 'list_users' ) ) {
+ return new WP_Error(
+ 'rest_user_cannot_view',
+ __( 'Sorry, you are not allowed to list users.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves a single user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $user = $this->get_user( $request['id'] );
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $user = $this->prepare_item_for_response( $user, $request );
+ $response = rest_ensure_response( $user );
+
+ return $response;
+ }
+
+ /**
+ * Retrieves the current user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_current_item( $request ) {
+ $current_user_id = get_current_user_id();
+
+ if ( empty( $current_user_id ) ) {
+ return new WP_Error(
+ 'rest_not_logged_in',
+ __( 'You are not currently logged in.' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ $user = wp_get_current_user();
+ $response = $this->prepare_item_for_response( $user, $request );
+ $response = rest_ensure_response( $response );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access create users.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
+ */
+ public function create_item_permissions_check( $request ) {
+
+ if ( ! current_user_can( 'create_users' ) ) {
+ return new WP_Error(
+ 'rest_cannot_create_user',
+ __( 'Sorry, you are not allowed to create new users.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Creates a single user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+ if ( ! empty( $request['id'] ) ) {
+ return new WP_Error(
+ 'rest_user_exists',
+ __( 'Cannot create existing user.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $schema = $this->get_item_schema();
+
+ if ( ! empty( $request['roles'] ) && ! empty( $schema['properties']['roles'] ) ) {
+ $check_permission = $this->check_role_update( $request['id'], $request['roles'] );
+
+ if ( is_wp_error( $check_permission ) ) {
+ return $check_permission;
+ }
+ }
+
+ $user = $this->prepare_item_for_database( $request );
+
+ if ( is_multisite() ) {
+ $ret = wpmu_validate_user_signup( $user->user_login, $user->user_email );
+
+ if ( is_wp_error( $ret['errors'] ) && $ret['errors']->has_errors() ) {
+ $error = new WP_Error(
+ 'rest_invalid_param',
+ __( 'Invalid user parameter(s).' ),
+ array( 'status' => 400 )
+ );
+
+ foreach ( $ret['errors']->errors as $code => $messages ) {
+ foreach ( $messages as $message ) {
+ $error->add( $code, $message );
+ }
+
+ $error_data = $error->get_error_data( $code );
+
+ if ( $error_data ) {
+ $error->add_data( $error_data, $code );
+ }
+ }
+ return $error;
+ }
+ }
+
+ if ( is_multisite() ) {
+ $user_id = wpmu_create_user( $user->user_login, $user->user_pass, $user->user_email );
+
+ if ( ! $user_id ) {
+ return new WP_Error(
+ 'rest_user_create',
+ __( 'Error creating new user.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $user->ID = $user_id;
+ $user_id = wp_update_user( wp_slash( (array) $user ) );
+
+ if ( is_wp_error( $user_id ) ) {
+ return $user_id;
+ }
+
+ $result = add_user_to_blog( get_site()->id, $user_id, '' );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ } else {
+ $user_id = wp_insert_user( wp_slash( (array) $user ) );
+
+ if ( is_wp_error( $user_id ) ) {
+ return $user_id;
+ }
+ }
+
+ $user = get_user_by( 'id', $user_id );
+
+ /**
+ * Fires immediately after a user is created or updated via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_User $user Inserted or updated user object.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating a user, false when updating.
+ */
+ do_action( 'rest_insert_user', $user, $request, true );
+
+ if ( ! empty( $request['roles'] ) && ! empty( $schema['properties']['roles'] ) ) {
+ array_map( array( $user, 'add_role' ), $request['roles'] );
+ }
+
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $user_id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $user = get_user_by( 'id', $user_id );
+ $fields_update = $this->update_additional_fields_for_object( $user, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ /**
+ * Fires after a user is completely created or updated via the REST API.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_User $user Inserted or updated user object.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating a user, false when updating.
+ */
+ do_action( 'rest_after_insert_user', $user, $request, true );
+
+ $response = $this->prepare_item_for_response( $user, $request );
+ $response = rest_ensure_response( $response );
+
+ $response->set_status( 201 );
+ $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $user_id ) ) );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to update a user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
+ */
+ public function update_item_permissions_check( $request ) {
+ $user = $this->get_user( $request['id'] );
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ if ( ! empty( $request['roles'] ) ) {
+ if ( ! current_user_can( 'promote_user', $user->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit_roles',
+ __( 'Sorry, you are not allowed to edit roles of this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $request_params = array_keys( $request->get_params() );
+ sort( $request_params );
+ /*
+ * If only 'id' and 'roles' are specified (we are only trying to
+ * edit roles), then only the 'promote_user' cap is required.
+ */
+ if ( array( 'id', 'roles' ) === $request_params ) {
+ return true;
+ }
+ }
+
+ if ( ! current_user_can( 'edit_user', $user->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit',
+ __( 'Sorry, you are not allowed to edit this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Updates a single user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ $user = $this->get_user( $request['id'] );
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $id = $user->ID;
+
+ $owner_id = false;
+ if ( is_string( $request['email'] ) ) {
+ $owner_id = email_exists( $request['email'] );
+ }
+
+ if ( $owner_id && $owner_id !== $id ) {
+ return new WP_Error(
+ 'rest_user_invalid_email',
+ __( 'Invalid email address.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( ! empty( $request['username'] ) && $request['username'] !== $user->user_login ) {
+ return new WP_Error(
+ 'rest_user_invalid_argument',
+ __( 'Username is not editable.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( ! empty( $request['slug'] ) && $request['slug'] !== $user->user_nicename && get_user_by( 'slug', $request['slug'] ) ) {
+ return new WP_Error(
+ 'rest_user_invalid_slug',
+ __( 'Invalid slug.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( ! empty( $request['roles'] ) ) {
+ $check_permission = $this->check_role_update( $id, $request['roles'] );
+
+ if ( is_wp_error( $check_permission ) ) {
+ return $check_permission;
+ }
+ }
+
+ $user = $this->prepare_item_for_database( $request );
+
+ // Ensure we're operating on the same user we already checked.
+ $user->ID = $id;
+
+ $user_id = wp_update_user( wp_slash( (array) $user ) );
+
+ if ( is_wp_error( $user_id ) ) {
+ return $user_id;
+ }
+
+ $user = get_user_by( 'id', $user_id );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php */
+ do_action( 'rest_insert_user', $user, $request, false );
+
+ if ( ! empty( $request['roles'] ) ) {
+ array_map( array( $user, 'add_role' ), $request['roles'] );
+ }
+
+ $schema = $this->get_item_schema();
+
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $user = get_user_by( 'id', $user_id );
+ $fields_update = $this->update_additional_fields_for_object( $user, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php */
+ do_action( 'rest_after_insert_user', $user, $request, false );
+
+ $response = $this->prepare_item_for_response( $user, $request );
+ $response = rest_ensure_response( $response );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to update the current user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
+ */
+ public function update_current_item_permissions_check( $request ) {
+ $request['id'] = get_current_user_id();
+
+ return $this->update_item_permissions_check( $request );
+ }
+
+ /**
+ * Updates the current user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_current_item( $request ) {
+ $request['id'] = get_current_user_id();
+
+ return $this->update_item( $request );
+ }
+
+ /**
+ * Checks if a given request has access delete a user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise.
+ */
+ public function delete_item_permissions_check( $request ) {
+ $user = $this->get_user( $request['id'] );
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ if ( ! current_user_can( 'delete_user', $user->ID ) ) {
+ return new WP_Error(
+ 'rest_user_cannot_delete',
+ __( 'Sorry, you are not allowed to delete this user.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Deletes a single user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_item( $request ) {
+ // We don't support delete requests in multisite.
+ if ( is_multisite() ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'The user cannot be deleted.' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ $user = $this->get_user( $request['id'] );
+
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $id = $user->ID;
+ $reassign = false === $request['reassign'] ? null : absint( $request['reassign'] );
+ $force = isset( $request['force'] ) ? (bool) $request['force'] : false;
+
+ // We don't support trashing for users.
+ if ( ! $force ) {
+ return new WP_Error(
+ 'rest_trash_not_supported',
+ /* translators: %s: force=true */
+ sprintf( __( "Users do not support trashing. Set '%s' to delete." ), 'force=true' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ if ( ! empty( $reassign ) ) {
+ if ( $reassign === $id || ! get_userdata( $reassign ) ) {
+ return new WP_Error(
+ 'rest_user_invalid_reassign',
+ __( 'Invalid user ID for reassignment.' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ $previous = $this->prepare_item_for_response( $user, $request );
+
+ // Include user admin functions to get access to wp_delete_user().
+ require_once ABSPATH . 'wp-admin/includes/user.php';
+
+ $result = wp_delete_user( $id, $reassign );
+
+ if ( ! $result ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ __( 'The user cannot be deleted.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $response = new WP_REST_Response();
+ $response->set_data(
+ array(
+ 'deleted' => true,
+ 'previous' => $previous->get_data(),
+ )
+ );
+
+ /**
+ * Fires immediately after a user is deleted via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_User $user The user data.
+ * @param WP_REST_Response $response The response returned from the API.
+ * @param WP_REST_Request $request The request sent to the API.
+ */
+ do_action( 'rest_delete_user', $user, $response, $request );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to delete the current user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise.
+ */
+ public function delete_current_item_permissions_check( $request ) {
+ $request['id'] = get_current_user_id();
+
+ return $this->delete_item_permissions_check( $request );
+ }
+
+ /**
+ * Deletes the current user.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_current_item( $request ) {
+ $request['id'] = get_current_user_id();
+
+ return $this->delete_item( $request );
+ }
+
+ /**
+ * Prepares a single user output for response.
+ *
+ * @since 4.7.0
+ * @since 5.9.0 Renamed `$user` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param WP_User $item User object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $user = $item;
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array();
+
+ if ( in_array( 'id', $fields, true ) ) {
+ $data['id'] = $user->ID;
+ }
+
+ if ( in_array( 'username', $fields, true ) ) {
+ $data['username'] = $user->user_login;
+ }
+
+ if ( in_array( 'name', $fields, true ) ) {
+ $data['name'] = $user->display_name;
+ }
+
+ if ( in_array( 'first_name', $fields, true ) ) {
+ $data['first_name'] = $user->first_name;
+ }
+
+ if ( in_array( 'last_name', $fields, true ) ) {
+ $data['last_name'] = $user->last_name;
+ }
+
+ if ( in_array( 'email', $fields, true ) ) {
+ $data['email'] = $user->user_email;
+ }
+
+ if ( in_array( 'url', $fields, true ) ) {
+ $data['url'] = $user->user_url;
+ }
+
+ if ( in_array( 'description', $fields, true ) ) {
+ $data['description'] = $user->description;
+ }
+
+ if ( in_array( 'link', $fields, true ) ) {
+ $data['link'] = get_author_posts_url( $user->ID, $user->user_nicename );
+ }
+
+ if ( in_array( 'locale', $fields, true ) ) {
+ $data['locale'] = get_user_locale( $user );
+ }
+
+ if ( in_array( 'nickname', $fields, true ) ) {
+ $data['nickname'] = $user->nickname;
+ }
+
+ if ( in_array( 'slug', $fields, true ) ) {
+ $data['slug'] = $user->user_nicename;
+ }
+
+ if ( in_array( 'roles', $fields, true ) ) {
+ // Defensively call array_values() to ensure an array is returned.
+ $data['roles'] = array_values( $user->roles );
+ }
+
+ if ( in_array( 'registered_date', $fields, true ) ) {
+ $data['registered_date'] = gmdate( 'c', strtotime( $user->user_registered ) );
+ }
+
+ if ( in_array( 'capabilities', $fields, true ) ) {
+ $data['capabilities'] = (object) $user->allcaps;
+ }
+
+ if ( in_array( 'extra_capabilities', $fields, true ) ) {
+ $data['extra_capabilities'] = (object) $user->caps;
+ }
+
+ if ( in_array( 'avatar_urls', $fields, true ) ) {
+ $data['avatar_urls'] = rest_get_avatar_urls( $user );
+ }
+
+ if ( in_array( 'meta', $fields, true ) ) {
+ $data['meta'] = $this->meta->get_value( $user->ID, $request );
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'embed';
+
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ // Wrap the data in a response object.
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $user ) );
+ }
+
+ /**
+ * Filters user data returned from the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_User $user User object used to create response.
+ * @param WP_REST_Request $request Request object.
+ */
+ return apply_filters( 'rest_prepare_user', $response, $user, $request );
+ }
+
+ /**
+ * Prepares links for the user request.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_User $user User object.
+ * @return array Links for the given user.
+ */
+ protected function prepare_links( $user ) {
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $user->ID ) ),
+ ),
+ 'collection' => array(
+ 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+ ),
+ );
+
+ return $links;
+ }
+
+ /**
+ * Prepares a single user for creation or update.
+ *
+ * @since 4.7.0
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return object User object.
+ */
+ protected function prepare_item_for_database( $request ) {
+ $prepared_user = new stdClass();
+
+ $schema = $this->get_item_schema();
+
+ // Required arguments.
+ if ( isset( $request['email'] ) && ! empty( $schema['properties']['email'] ) ) {
+ $prepared_user->user_email = $request['email'];
+ }
+
+ if ( isset( $request['username'] ) && ! empty( $schema['properties']['username'] ) ) {
+ $prepared_user->user_login = $request['username'];
+ }
+
+ if ( isset( $request['password'] ) && ! empty( $schema['properties']['password'] ) ) {
+ $prepared_user->user_pass = $request['password'];
+ }
+
+ // Optional arguments.
+ if ( isset( $request['id'] ) ) {
+ $prepared_user->ID = absint( $request['id'] );
+ }
+
+ if ( isset( $request['name'] ) && ! empty( $schema['properties']['name'] ) ) {
+ $prepared_user->display_name = $request['name'];
+ }
+
+ if ( isset( $request['first_name'] ) && ! empty( $schema['properties']['first_name'] ) ) {
+ $prepared_user->first_name = $request['first_name'];
+ }
+
+ if ( isset( $request['last_name'] ) && ! empty( $schema['properties']['last_name'] ) ) {
+ $prepared_user->last_name = $request['last_name'];
+ }
+
+ if ( isset( $request['nickname'] ) && ! empty( $schema['properties']['nickname'] ) ) {
+ $prepared_user->nickname = $request['nickname'];
+ }
+
+ if ( isset( $request['slug'] ) && ! empty( $schema['properties']['slug'] ) ) {
+ $prepared_user->user_nicename = $request['slug'];
+ }
+
+ if ( isset( $request['description'] ) && ! empty( $schema['properties']['description'] ) ) {
+ $prepared_user->description = $request['description'];
+ }
+
+ if ( isset( $request['url'] ) && ! empty( $schema['properties']['url'] ) ) {
+ $prepared_user->user_url = $request['url'];
+ }
+
+ if ( isset( $request['locale'] ) && ! empty( $schema['properties']['locale'] ) ) {
+ $prepared_user->locale = $request['locale'];
+ }
+
+ // Setting roles will be handled outside of this function.
+ if ( isset( $request['roles'] ) ) {
+ $prepared_user->role = false;
+ }
+
+ /**
+ * Filters user data before insertion via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @param object $prepared_user User object.
+ * @param WP_REST_Request $request Request object.
+ */
+ return apply_filters( 'rest_pre_insert_user', $prepared_user, $request );
+ }
+
+ /**
+ * Determines if the current user is allowed to make the desired roles change.
+ *
+ * @since 4.7.0
+ *
+ * @global WP_Roles $wp_roles WordPress role management object.
+ *
+ * @param int $user_id User ID.
+ * @param array $roles New user roles.
+ * @return true|WP_Error True if the current user is allowed to make the role change,
+ * otherwise a WP_Error object.
+ */
+ protected function check_role_update( $user_id, $roles ) {
+ global $wp_roles;
+
+ foreach ( $roles as $role ) {
+
+ if ( ! isset( $wp_roles->role_objects[ $role ] ) ) {
+ return new WP_Error(
+ 'rest_user_invalid_role',
+ /* translators: %s: Role key. */
+ sprintf( __( 'The role %s does not exist.' ), $role ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $potential_role = $wp_roles->role_objects[ $role ];
+
+ /*
+ * Don't let anyone with 'edit_users' (admins) edit their own role to something without it.
+ * Multisite super admins can freely edit their blog roles -- they possess all caps.
+ */
+ if ( ! ( is_multisite()
+ && current_user_can( 'manage_sites' ) )
+ && get_current_user_id() === $user_id
+ && ! $potential_role->has_cap( 'edit_users' )
+ ) {
+ return new WP_Error(
+ 'rest_user_invalid_role',
+ __( 'Sorry, you are not allowed to give users that role.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ // Include user admin functions to get access to get_editable_roles().
+ require_once ABSPATH . 'wp-admin/includes/user.php';
+
+ // The new role must be editable by the logged-in user.
+ $editable_roles = get_editable_roles();
+
+ if ( empty( $editable_roles[ $role ] ) ) {
+ return new WP_Error(
+ 'rest_user_invalid_role',
+ __( 'Sorry, you are not allowed to give users that role.' ),
+ array( 'status' => 403 )
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Check a username for the REST API.
+ *
+ * Performs a couple of checks like edit_user() in wp-admin/includes/user.php.
+ *
+ * @since 4.7.0
+ *
+ * @param string $value The username submitted in the request.
+ * @param WP_REST_Request $request Full details about the request.
+ * @param string $param The parameter name.
+ * @return string|WP_Error The sanitized username, if valid, otherwise an error.
+ */
+ public function check_username( $value, $request, $param ) {
+ $username = (string) $value;
+
+ if ( ! validate_username( $username ) ) {
+ return new WP_Error(
+ 'rest_user_invalid_username',
+ __( 'This username is invalid because it uses illegal characters. Please enter a valid username.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /** This filter is documented in wp-includes/user.php */
+ $illegal_logins = (array) apply_filters( 'illegal_user_logins', array() );
+
+ if ( in_array( strtolower( $username ), array_map( 'strtolower', $illegal_logins ), true ) ) {
+ return new WP_Error(
+ 'rest_user_invalid_username',
+ __( 'Sorry, that username is not allowed.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return $username;
+ }
+
+ /**
+ * Check a user password for the REST API.
+ *
+ * Performs a couple of checks like edit_user() in wp-admin/includes/user.php.
+ *
+ * @since 4.7.0
+ *
+ * @param string $value The password submitted in the request.
+ * @param WP_REST_Request $request Full details about the request.
+ * @param string $param The parameter name.
+ * @return string|WP_Error The sanitized password, if valid, otherwise an error.
+ */
+ public function check_user_password( $value, $request, $param ) {
+ $password = (string) $value;
+
+ if ( empty( $password ) ) {
+ return new WP_Error(
+ 'rest_user_invalid_password',
+ __( 'Passwords cannot be empty.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( str_contains( $password, '\\' ) ) {
+ return new WP_Error(
+ 'rest_user_invalid_password',
+ sprintf(
+ /* translators: %s: The '\' character. */
+ __( 'Passwords cannot contain the "%s" character.' ),
+ '\\'
+ ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return $password;
+ }
+
+ /**
+ * Retrieves the user's schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'user',
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the user.' ),
+ 'type' => 'integer',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'username' => array(
+ 'description' => __( 'Login name for the user.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ 'required' => true,
+ 'arg_options' => array(
+ 'sanitize_callback' => array( $this, 'check_username' ),
+ ),
+ ),
+ 'name' => array(
+ 'description' => __( 'Display name for the user.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ 'first_name' => array(
+ 'description' => __( 'First name for the user.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ 'last_name' => array(
+ 'description' => __( 'Last name for the user.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ 'email' => array(
+ 'description' => __( 'The email address for the user.' ),
+ 'type' => 'string',
+ 'format' => 'email',
+ 'context' => array( 'edit' ),
+ 'required' => true,
+ ),
+ 'url' => array(
+ 'description' => __( 'URL of the user.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ ),
+ 'description' => array(
+ 'description' => __( 'Description of the user.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ ),
+ 'link' => array(
+ 'description' => __( 'Author URL of the user.' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'locale' => array(
+ 'description' => __( 'Locale for the user.' ),
+ 'type' => 'string',
+ 'enum' => array_merge( array( '', 'en_US' ), get_available_languages() ),
+ 'context' => array( 'edit' ),
+ ),
+ 'nickname' => array(
+ 'description' => __( 'The nickname for the user.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ 'slug' => array(
+ 'description' => __( 'An alphanumeric identifier for the user.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => array( $this, 'sanitize_slug' ),
+ ),
+ ),
+ 'registered_date' => array(
+ 'description' => __( 'Registration date for the user.' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'roles' => array(
+ 'description' => __( 'Roles assigned to the user.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'context' => array( 'edit' ),
+ ),
+ 'password' => array(
+ 'description' => __( 'Password for the user (never included).' ),
+ 'type' => 'string',
+ 'context' => array(), // Password is never displayed.
+ 'required' => true,
+ 'arg_options' => array(
+ 'sanitize_callback' => array( $this, 'check_user_password' ),
+ ),
+ ),
+ 'capabilities' => array(
+ 'description' => __( 'All capabilities assigned to the user.' ),
+ 'type' => 'object',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'extra_capabilities' => array(
+ 'description' => __( 'Any extra capabilities assigned to the user.' ),
+ 'type' => 'object',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ if ( get_option( 'show_avatars' ) ) {
+ $avatar_properties = array();
+
+ $avatar_sizes = rest_get_avatar_sizes();
+
+ foreach ( $avatar_sizes as $size ) {
+ $avatar_properties[ $size ] = array(
+ /* translators: %d: Avatar image size in pixels. */
+ 'description' => sprintf( __( 'Avatar URL with image size of %d pixels.' ), $size ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ );
+ }
+
+ $schema['properties']['avatar_urls'] = array(
+ 'description' => __( 'Avatar URLs for the user.' ),
+ 'type' => 'object',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ 'properties' => $avatar_properties,
+ );
+ }
+
+ $schema['properties']['meta'] = $this->meta->get_field_schema();
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * @since 4.7.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $query_params = parent::get_collection_params();
+
+ $query_params['context']['default'] = 'view';
+
+ $query_params['exclude'] = array(
+ 'description' => __( 'Ensure result set excludes specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ $query_params['include'] = array(
+ 'description' => __( 'Limit result set to specific IDs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ $query_params['offset'] = array(
+ 'description' => __( 'Offset the result set by a specific number of items.' ),
+ 'type' => 'integer',
+ );
+
+ $query_params['order'] = array(
+ 'default' => 'asc',
+ 'description' => __( 'Order sort attribute ascending or descending.' ),
+ 'enum' => array( 'asc', 'desc' ),
+ 'type' => 'string',
+ );
+
+ $query_params['orderby'] = array(
+ 'default' => 'name',
+ 'description' => __( 'Sort collection by user attribute.' ),
+ 'enum' => array(
+ 'id',
+ 'include',
+ 'name',
+ 'registered_date',
+ 'slug',
+ 'include_slugs',
+ 'email',
+ 'url',
+ ),
+ 'type' => 'string',
+ );
+
+ $query_params['slug'] = array(
+ 'description' => __( 'Limit result set to users with one or more specific slugs.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ );
+
+ $query_params['roles'] = array(
+ 'description' => __( 'Limit result set to users matching at least one specific role provided. Accepts csv list or single role.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ );
+
+ $query_params['capabilities'] = array(
+ 'description' => __( 'Limit result set to users matching at least one specific capability provided. Accepts csv list or single capability.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ );
+
+ $query_params['who'] = array(
+ 'description' => __( 'Limit result set to users who are considered authors.' ),
+ 'type' => 'string',
+ 'enum' => array(
+ 'authors',
+ ),
+ );
+
+ $query_params['has_published_posts'] = array(
+ 'description' => __( 'Limit result set to users who have published posts.' ),
+ 'type' => array( 'boolean', 'array' ),
+ 'items' => array(
+ 'type' => 'string',
+ 'enum' => get_post_types( array( 'show_in_rest' => true ), 'names' ),
+ ),
+ );
+
+ /**
+ * Filters REST API collection parameters for the users controller.
+ *
+ * This filter registers the collection parameter, but does not map the
+ * collection parameter to an internal WP_User_Query parameter. Use the
+ * `rest_user_query` filter to set WP_User_Query arguments.
+ *
+ * @since 4.7.0
+ *
+ * @param array $query_params JSON Schema-formatted collection parameters.
+ */
+ return apply_filters( 'rest_user_collection_params', $query_params );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-widget-types-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-widget-types-controller.php
new file mode 100644
index 0000000..d70e524
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-widget-types-controller.php
@@ -0,0 +1,674 @@
+<?php
+/**
+ * REST API: WP_REST_Widget_Types_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.8.0
+ */
+
+/**
+ * Core class to access widget types via the REST API.
+ *
+ * @since 5.8.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Widget_Types_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructor.
+ *
+ * @since 5.8.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'widget-types';
+ }
+
+ /**
+ * Registers the widget type routes.
+ *
+ * @since 5.8.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[a-zA-Z0-9_-]+)',
+ array(
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The widget type id.' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[a-zA-Z0-9_-]+)/encode',
+ array(
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The widget type id.' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'instance' => array(
+ 'description' => __( 'Current instance settings of the widget.' ),
+ 'type' => 'object',
+ ),
+ 'form_data' => array(
+ 'description' => __( 'Serialized widget form data to encode into instance settings.' ),
+ 'type' => 'string',
+ 'sanitize_callback' => static function ( $form_data ) {
+ $array = array();
+ wp_parse_str( $form_data, $array );
+ return $array;
+ },
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'callback' => array( $this, 'encode_form_data' ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[a-zA-Z0-9_-]+)/render',
+ array(
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'callback' => array( $this, 'render' ),
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The widget type id.' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'instance' => array(
+ 'description' => __( 'Current instance settings of the widget.' ),
+ 'type' => 'object',
+ ),
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether a given request has permission to read widget types.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ return $this->check_read_permission();
+ }
+
+ /**
+ * Retrieves the list of all widget types.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $data = array();
+ foreach ( $this->get_widgets() as $widget ) {
+ $widget_type = $this->prepare_item_for_response( $widget, $request );
+ $data[] = $this->prepare_response_for_collection( $widget_type );
+ }
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Checks if a given request has access to read a widget type.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $check = $this->check_read_permission();
+ if ( is_wp_error( $check ) ) {
+ return $check;
+ }
+ $widget_id = $request['id'];
+ $widget_type = $this->get_widget( $widget_id );
+ if ( is_wp_error( $widget_type ) ) {
+ return $widget_type;
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks whether the user can read widget types.
+ *
+ * @since 5.8.0
+ *
+ * @return true|WP_Error True if the widget type is visible, WP_Error otherwise.
+ */
+ protected function check_read_permission() {
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'rest_cannot_manage_widgets',
+ __( 'Sorry, you are not allowed to manage widgets on this site.' ),
+ array(
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Gets the details about the requested widget.
+ *
+ * @since 5.8.0
+ *
+ * @param string $id The widget type id.
+ * @return array|WP_Error The array of widget data if the name is valid, WP_Error otherwise.
+ */
+ public function get_widget( $id ) {
+ foreach ( $this->get_widgets() as $widget ) {
+ if ( $id === $widget['id'] ) {
+ return $widget;
+ }
+ }
+
+ return new WP_Error( 'rest_widget_type_invalid', __( 'Invalid widget type.' ), array( 'status' => 404 ) );
+ }
+
+ /**
+ * Normalize array of widgets.
+ *
+ * @since 5.8.0
+ *
+ * @global WP_Widget_Factory $wp_widget_factory
+ * @global array $wp_registered_widgets The list of registered widgets.
+ *
+ * @return array Array of widgets.
+ */
+ protected function get_widgets() {
+ global $wp_widget_factory, $wp_registered_widgets;
+
+ $widgets = array();
+
+ foreach ( $wp_registered_widgets as $widget ) {
+ $parsed_id = wp_parse_widget_id( $widget['id'] );
+ $widget_object = $wp_widget_factory->get_widget_object( $parsed_id['id_base'] );
+
+ $widget['id'] = $parsed_id['id_base'];
+ $widget['is_multi'] = (bool) $widget_object;
+
+ if ( isset( $widget['name'] ) ) {
+ $widget['name'] = html_entity_decode( $widget['name'], ENT_QUOTES, get_bloginfo( 'charset' ) );
+ }
+
+ if ( isset( $widget['description'] ) ) {
+ $widget['description'] = html_entity_decode( $widget['description'], ENT_QUOTES, get_bloginfo( 'charset' ) );
+ }
+
+ unset( $widget['callback'] );
+
+ $classname = '';
+ foreach ( (array) $widget['classname'] as $cn ) {
+ if ( is_string( $cn ) ) {
+ $classname .= '_' . $cn;
+ } elseif ( is_object( $cn ) ) {
+ $classname .= '_' . get_class( $cn );
+ }
+ }
+ $widget['classname'] = ltrim( $classname, '_' );
+
+ $widgets[ $widget['id'] ] = $widget;
+ }
+
+ ksort( $widgets );
+
+ return $widgets;
+ }
+
+ /**
+ * Retrieves a single widget type from the collection.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $widget_id = $request['id'];
+ $widget_type = $this->get_widget( $widget_id );
+ if ( is_wp_error( $widget_type ) ) {
+ return $widget_type;
+ }
+ $data = $this->prepare_item_for_response( $widget_type, $request );
+
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Prepares a widget type object for serialization.
+ *
+ * @since 5.8.0
+ * @since 5.9.0 Renamed `$widget_type` to `$item` to match parent class for PHP 8 named parameter support.
+ *
+ * @param array $item Widget type data.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response Widget type data.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ // Restores the more descriptive, specific name for use within this method.
+ $widget_type = $item;
+
+ $fields = $this->get_fields_for_response( $request );
+ $data = array(
+ 'id' => $widget_type['id'],
+ );
+
+ $schema = $this->get_item_schema();
+ $extra_fields = array(
+ 'name',
+ 'description',
+ 'is_multi',
+ 'classname',
+ 'widget_class',
+ 'option_name',
+ 'customize_selective_refresh',
+ );
+
+ foreach ( $extra_fields as $extra_field ) {
+ if ( ! rest_is_field_included( $extra_field, $fields ) ) {
+ continue;
+ }
+
+ if ( isset( $widget_type[ $extra_field ] ) ) {
+ $field = $widget_type[ $extra_field ];
+ } elseif ( array_key_exists( 'default', $schema['properties'][ $extra_field ] ) ) {
+ $field = $schema['properties'][ $extra_field ]['default'];
+ } else {
+ $field = '';
+ }
+
+ $data[ $extra_field ] = rest_sanitize_value_from_schema( $field, $schema['properties'][ $extra_field ] );
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $response = rest_ensure_response( $data );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $widget_type ) );
+ }
+
+ /**
+ * Filters the REST API response for a widget type.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param array $widget_type The array of widget data.
+ * @param WP_REST_Request $request The request object.
+ */
+ return apply_filters( 'rest_prepare_widget_type', $response, $widget_type, $request );
+ }
+
+ /**
+ * Prepares links for the widget type.
+ *
+ * @since 5.8.0
+ *
+ * @param array $widget_type Widget type data.
+ * @return array Links for the given widget type.
+ */
+ protected function prepare_links( $widget_type ) {
+ return array(
+ 'collection' => array(
+ 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+ ),
+ 'self' => array(
+ 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $widget_type['id'] ) ),
+ ),
+ );
+ }
+
+ /**
+ * Retrieves the widget type's schema, conforming to JSON Schema.
+ *
+ * @since 5.8.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'widget-type',
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'Unique slug identifying the widget type.' ),
+ 'type' => 'string',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'Human-readable name identifying the widget type.' ),
+ 'type' => 'string',
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'description' => __( 'Description of the widget.' ),
+ 'type' => 'string',
+ 'default' => '',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'is_multi' => array(
+ 'description' => __( 'Whether the widget supports multiple instances' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'classname' => array(
+ 'description' => __( 'Class name' ),
+ 'type' => 'string',
+ 'default' => '',
+ 'context' => array( 'embed', 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ $this->schema = $schema;
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * An RPC-style endpoint which can be used by clients to turn user input in
+ * a widget admin form into an encoded instance object.
+ *
+ * Accepts:
+ *
+ * - id: A widget type ID.
+ * - instance: A widget's encoded instance object. Optional.
+ * - form_data: Form data from submitting a widget's admin form. Optional.
+ *
+ * Returns:
+ * - instance: The encoded instance object after updating the widget with
+ * the given form data.
+ * - form: The widget's admin form after updating the widget with the
+ * given form data.
+ *
+ * @since 5.8.0
+ *
+ * @global WP_Widget_Factory $wp_widget_factory
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function encode_form_data( $request ) {
+ global $wp_widget_factory;
+
+ $id = $request['id'];
+ $widget_object = $wp_widget_factory->get_widget_object( $id );
+
+ if ( ! $widget_object ) {
+ return new WP_Error(
+ 'rest_invalid_widget',
+ __( 'Cannot preview a widget that does not extend WP_Widget.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /*
+ * Set the widget's number so that the id attributes in the HTML that we
+ * return are predictable.
+ */
+ if ( isset( $request['number'] ) && is_numeric( $request['number'] ) ) {
+ $widget_object->_set( (int) $request['number'] );
+ } else {
+ $widget_object->_set( -1 );
+ }
+
+ if ( isset( $request['instance']['encoded'], $request['instance']['hash'] ) ) {
+ $serialized_instance = base64_decode( $request['instance']['encoded'] );
+ if ( ! hash_equals( wp_hash( $serialized_instance ), $request['instance']['hash'] ) ) {
+ return new WP_Error(
+ 'rest_invalid_widget',
+ __( 'The provided instance is malformed.' ),
+ array( 'status' => 400 )
+ );
+ }
+ $instance = unserialize( $serialized_instance );
+ } else {
+ $instance = array();
+ }
+
+ if (
+ isset( $request['form_data'][ "widget-$id" ] ) &&
+ is_array( $request['form_data'][ "widget-$id" ] )
+ ) {
+ $new_instance = array_values( $request['form_data'][ "widget-$id" ] )[0];
+ $old_instance = $instance;
+
+ $instance = $widget_object->update( $new_instance, $old_instance );
+
+ /** This filter is documented in wp-includes/class-wp-widget.php */
+ $instance = apply_filters(
+ 'widget_update_callback',
+ $instance,
+ $new_instance,
+ $old_instance,
+ $widget_object
+ );
+ }
+
+ $serialized_instance = serialize( $instance );
+ $widget_key = $wp_widget_factory->get_widget_key( $id );
+
+ $response = array(
+ 'form' => trim(
+ $this->get_widget_form(
+ $widget_object,
+ $instance
+ )
+ ),
+ 'preview' => trim(
+ $this->get_widget_preview(
+ $widget_key,
+ $instance
+ )
+ ),
+ 'instance' => array(
+ 'encoded' => base64_encode( $serialized_instance ),
+ 'hash' => wp_hash( $serialized_instance ),
+ ),
+ );
+
+ if ( ! empty( $widget_object->widget_options['show_instance_in_rest'] ) ) {
+ // Use new stdClass so that JSON result is {} and not [].
+ $response['instance']['raw'] = empty( $instance ) ? new stdClass() : $instance;
+ }
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Returns the output of WP_Widget::widget() when called with the provided
+ * instance. Used by encode_form_data() to preview a widget.
+
+ * @since 5.8.0
+ *
+ * @param string $widget The widget's PHP class name (see class-wp-widget.php).
+ * @param array $instance Widget instance settings.
+ * @return string
+ */
+ private function get_widget_preview( $widget, $instance ) {
+ ob_start();
+ the_widget( $widget, $instance );
+ return ob_get_clean();
+ }
+
+ /**
+ * Returns the output of WP_Widget::form() when called with the provided
+ * instance. Used by encode_form_data() to preview a widget's form.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_Widget $widget_object Widget object to call widget() on.
+ * @param array $instance Widget instance settings.
+ * @return string
+ */
+ private function get_widget_form( $widget_object, $instance ) {
+ ob_start();
+
+ /** This filter is documented in wp-includes/class-wp-widget.php */
+ $instance = apply_filters(
+ 'widget_form_callback',
+ $instance,
+ $widget_object
+ );
+
+ if ( false !== $instance ) {
+ $return = $widget_object->form( $instance );
+
+ /** This filter is documented in wp-includes/class-wp-widget.php */
+ do_action_ref_array(
+ 'in_widget_form',
+ array( &$widget_object, &$return, $instance )
+ );
+ }
+
+ return ob_get_clean();
+ }
+
+ /**
+ * Renders a single Legacy Widget and wraps it in a JSON-encodable array.
+ *
+ * @since 5.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ *
+ * @return array An array with rendered Legacy Widget HTML.
+ */
+ public function render( $request ) {
+ return array(
+ 'preview' => $this->render_legacy_widget_preview_iframe(
+ $request['id'],
+ isset( $request['instance'] ) ? $request['instance'] : null
+ ),
+ );
+ }
+
+ /**
+ * Renders a page containing a preview of the requested Legacy Widget block.
+ *
+ * @since 5.9.0
+ *
+ * @param string $id_base The id base of the requested widget.
+ * @param array $instance The widget instance attributes.
+ *
+ * @return string Rendered Legacy Widget block preview.
+ */
+ private function render_legacy_widget_preview_iframe( $id_base, $instance ) {
+ if ( ! defined( 'IFRAME_REQUEST' ) ) {
+ define( 'IFRAME_REQUEST', true );
+ }
+
+ ob_start();
+ ?>
+ <!doctype html>
+ <html <?php language_attributes(); ?>>
+ <head>
+ <meta charset="<?php bloginfo( 'charset' ); ?>" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <link rel="profile" href="https://gmpg.org/xfn/11" />
+ <?php wp_head(); ?>
+ <style>
+ /* Reset theme styles */
+ html, body, #page, #content {
+ padding: 0 !important;
+ margin: 0 !important;
+ }
+ </style>
+ </head>
+ <body <?php body_class(); ?>>
+ <div id="page" class="site">
+ <div id="content" class="site-content">
+ <?php
+ $registry = WP_Block_Type_Registry::get_instance();
+ $block = $registry->get_registered( 'core/legacy-widget' );
+ echo $block->render(
+ array(
+ 'idBase' => $id_base,
+ 'instance' => $instance,
+ )
+ );
+ ?>
+ </div><!-- #content -->
+ </div><!-- #page -->
+ <?php wp_footer(); ?>
+ </body>
+ </html>
+ <?php
+ return ob_get_clean();
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * @since 5.8.0
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ return array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ );
+ }
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-widgets-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-widgets-controller.php
new file mode 100644
index 0000000..acbd88c
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-widgets-controller.php
@@ -0,0 +1,876 @@
+<?php
+/**
+ * REST API: WP_REST_Widgets_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.8.0
+ */
+
+/**
+ * Core class to access widgets via the REST API.
+ *
+ * @since 5.8.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Widgets_Controller extends WP_REST_Controller {
+
+ /**
+ * Tracks whether {@see retrieve_widgets()} has been called in the current request.
+ *
+ * @since 5.9.0
+ * @var bool
+ */
+ protected $widgets_retrieved = false;
+
+ /**
+ * Whether the controller supports batching.
+ *
+ * @since 5.9.0
+ * @var array
+ */
+ protected $allow_batch = array( 'v1' => true );
+
+ /**
+ * Widgets controller constructor.
+ *
+ * @since 5.8.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'widgets';
+ }
+
+ /**
+ * Registers the widget routes for the controller.
+ *
+ * @since 5.8.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema(),
+ ),
+ 'allow_batch' => $this->allow_batch,
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ $this->rest_base . '/(?P<id>[\w\-]+)',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'permission_callback' => array( $this, 'update_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+ 'args' => array(
+ 'force' => array(
+ 'description' => __( 'Whether to force removal of the widget, or move it to the inactive sidebar.' ),
+ 'type' => 'boolean',
+ ),
+ ),
+ ),
+ 'allow_batch' => $this->allow_batch,
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to get widgets.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ $this->retrieve_widgets();
+ if ( isset( $request['sidebar'] ) && $this->check_read_sidebar_permission( $request['sidebar'] ) ) {
+ return true;
+ }
+
+ foreach ( wp_get_sidebars_widgets() as $sidebar_id => $widget_ids ) {
+ if ( $this->check_read_sidebar_permission( $sidebar_id ) ) {
+ return true;
+ }
+ }
+
+ return $this->permissions_check( $request );
+ }
+
+ /**
+ * Retrieves a collection of widgets.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $this->retrieve_widgets();
+
+ $prepared = array();
+ $permissions_check = $this->permissions_check( $request );
+
+ foreach ( wp_get_sidebars_widgets() as $sidebar_id => $widget_ids ) {
+ if ( isset( $request['sidebar'] ) && $sidebar_id !== $request['sidebar'] ) {
+ continue;
+ }
+
+ if ( is_wp_error( $permissions_check ) && ! $this->check_read_sidebar_permission( $sidebar_id ) ) {
+ continue;
+ }
+
+ foreach ( $widget_ids as $widget_id ) {
+ $response = $this->prepare_item_for_response( compact( 'sidebar_id', 'widget_id' ), $request );
+
+ if ( ! is_wp_error( $response ) ) {
+ $prepared[] = $this->prepare_response_for_collection( $response );
+ }
+ }
+ }
+
+ return new WP_REST_Response( $prepared );
+ }
+
+ /**
+ * Checks if a given request has access to get a widget.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $this->retrieve_widgets();
+
+ $widget_id = $request['id'];
+ $sidebar_id = wp_find_widgets_sidebar( $widget_id );
+
+ if ( $sidebar_id && $this->check_read_sidebar_permission( $sidebar_id ) ) {
+ return true;
+ }
+
+ return $this->permissions_check( $request );
+ }
+
+ /**
+ * Checks if a sidebar can be read publicly.
+ *
+ * @since 5.9.0
+ *
+ * @param string $sidebar_id The sidebar ID.
+ * @return bool Whether the sidebar can be read.
+ */
+ protected function check_read_sidebar_permission( $sidebar_id ) {
+ $sidebar = wp_get_sidebar( $sidebar_id );
+
+ return ! empty( $sidebar['show_in_rest'] );
+ }
+
+ /**
+ * Gets an individual widget.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $this->retrieve_widgets();
+
+ $widget_id = $request['id'];
+ $sidebar_id = wp_find_widgets_sidebar( $widget_id );
+
+ if ( is_null( $sidebar_id ) ) {
+ return new WP_Error(
+ 'rest_widget_not_found',
+ __( 'No widget was found with that id.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ return $this->prepare_item_for_response( compact( 'widget_id', 'sidebar_id' ), $request );
+ }
+
+ /**
+ * Checks if a given request has access to create widgets.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function create_item_permissions_check( $request ) {
+ return $this->permissions_check( $request );
+ }
+
+ /**
+ * Creates a widget.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function create_item( $request ) {
+ $sidebar_id = $request['sidebar'];
+
+ $widget_id = $this->save_widget( $request, $sidebar_id );
+
+ if ( is_wp_error( $widget_id ) ) {
+ return $widget_id;
+ }
+
+ wp_assign_widget_to_sidebar( $widget_id, $sidebar_id );
+
+ $request['context'] = 'edit';
+
+ $response = $this->prepare_item_for_response( compact( 'sidebar_id', 'widget_id' ), $request );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $response->set_status( 201 );
+
+ return $response;
+ }
+
+ /**
+ * Checks if a given request has access to update widgets.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function update_item_permissions_check( $request ) {
+ return $this->permissions_check( $request );
+ }
+
+ /**
+ * Updates an existing widget.
+ *
+ * @since 5.8.0
+ *
+ * @global WP_Widget_Factory $wp_widget_factory
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ global $wp_widget_factory;
+
+ /*
+ * retrieve_widgets() contains logic to move "hidden" or "lost" widgets to the
+ * wp_inactive_widgets sidebar based on the contents of the $sidebars_widgets global.
+ *
+ * When batch requests are processed, this global is not properly updated by previous
+ * calls, resulting in widgets incorrectly being moved to the wp_inactive_widgets
+ * sidebar.
+ *
+ * See https://core.trac.wordpress.org/ticket/53657.
+ */
+ wp_get_sidebars_widgets();
+ $this->retrieve_widgets();
+
+ $widget_id = $request['id'];
+ $sidebar_id = wp_find_widgets_sidebar( $widget_id );
+
+ // Allow sidebar to be unset or missing when widget is not a WP_Widget.
+ $parsed_id = wp_parse_widget_id( $widget_id );
+ $widget_object = $wp_widget_factory->get_widget_object( $parsed_id['id_base'] );
+ if ( is_null( $sidebar_id ) && $widget_object ) {
+ return new WP_Error(
+ 'rest_widget_not_found',
+ __( 'No widget was found with that id.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ if (
+ $request->has_param( 'instance' ) ||
+ $request->has_param( 'form_data' )
+ ) {
+ $maybe_error = $this->save_widget( $request, $sidebar_id );
+ if ( is_wp_error( $maybe_error ) ) {
+ return $maybe_error;
+ }
+ }
+
+ if ( $request->has_param( 'sidebar' ) ) {
+ if ( $sidebar_id !== $request['sidebar'] ) {
+ $sidebar_id = $request['sidebar'];
+ wp_assign_widget_to_sidebar( $widget_id, $sidebar_id );
+ }
+ }
+
+ $request['context'] = 'edit';
+
+ return $this->prepare_item_for_response( compact( 'widget_id', 'sidebar_id' ), $request );
+ }
+
+ /**
+ * Checks if a given request has access to delete widgets.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function delete_item_permissions_check( $request ) {
+ return $this->permissions_check( $request );
+ }
+
+ /**
+ * Deletes a widget.
+ *
+ * @since 5.8.0
+ *
+ * @global WP_Widget_Factory $wp_widget_factory
+ * @global array $wp_registered_widget_updates The registered widget update functions.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function delete_item( $request ) {
+ global $wp_widget_factory, $wp_registered_widget_updates;
+
+ /*
+ * retrieve_widgets() contains logic to move "hidden" or "lost" widgets to the
+ * wp_inactive_widgets sidebar based on the contents of the $sidebars_widgets global.
+ *
+ * When batch requests are processed, this global is not properly updated by previous
+ * calls, resulting in widgets incorrectly being moved to the wp_inactive_widgets
+ * sidebar.
+ *
+ * See https://core.trac.wordpress.org/ticket/53657.
+ */
+ wp_get_sidebars_widgets();
+ $this->retrieve_widgets();
+
+ $widget_id = $request['id'];
+ $sidebar_id = wp_find_widgets_sidebar( $widget_id );
+
+ if ( is_null( $sidebar_id ) ) {
+ return new WP_Error(
+ 'rest_widget_not_found',
+ __( 'No widget was found with that id.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $request['context'] = 'edit';
+
+ if ( $request['force'] ) {
+ $response = $this->prepare_item_for_response( compact( 'widget_id', 'sidebar_id' ), $request );
+
+ $parsed_id = wp_parse_widget_id( $widget_id );
+ $id_base = $parsed_id['id_base'];
+
+ $original_post = $_POST;
+ $original_request = $_REQUEST;
+
+ $_POST = array(
+ 'sidebar' => $sidebar_id,
+ "widget-$id_base" => array(),
+ 'the-widget-id' => $widget_id,
+ 'delete_widget' => '1',
+ );
+ $_REQUEST = $_POST;
+
+ /** This action is documented in wp-admin/widgets-form.php */
+ do_action( 'delete_widget', $widget_id, $sidebar_id, $id_base );
+
+ $callback = $wp_registered_widget_updates[ $id_base ]['callback'];
+ $params = $wp_registered_widget_updates[ $id_base ]['params'];
+
+ if ( is_callable( $callback ) ) {
+ ob_start();
+ call_user_func_array( $callback, $params );
+ ob_end_clean();
+ }
+
+ $_POST = $original_post;
+ $_REQUEST = $original_request;
+
+ $widget_object = $wp_widget_factory->get_widget_object( $id_base );
+
+ if ( $widget_object ) {
+ /*
+ * WP_Widget sets `updated = true` after an update to prevent more than one widget
+ * from being saved per request. This isn't what we want in the REST API, though,
+ * as we support batch requests.
+ */
+ $widget_object->updated = false;
+ }
+
+ wp_assign_widget_to_sidebar( $widget_id, '' );
+
+ $response->set_data(
+ array(
+ 'deleted' => true,
+ 'previous' => $response->get_data(),
+ )
+ );
+ } else {
+ wp_assign_widget_to_sidebar( $widget_id, 'wp_inactive_widgets' );
+
+ $response = $this->prepare_item_for_response(
+ array(
+ 'sidebar_id' => 'wp_inactive_widgets',
+ 'widget_id' => $widget_id,
+ ),
+ $request
+ );
+ }
+
+ /**
+ * Fires after a widget is deleted via the REST API.
+ *
+ * @since 5.8.0
+ *
+ * @param string $widget_id ID of the widget marked for deletion.
+ * @param string $sidebar_id ID of the sidebar the widget was deleted from.
+ * @param WP_REST_Response|WP_Error $response The response data, or WP_Error object on failure.
+ * @param WP_REST_Request $request The request sent to the API.
+ */
+ do_action( 'rest_delete_widget', $widget_id, $sidebar_id, $response, $request );
+
+ return $response;
+ }
+
+ /**
+ * Performs a permissions check for managing widgets.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error
+ */
+ protected function permissions_check( $request ) {
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'rest_cannot_manage_widgets',
+ __( 'Sorry, you are not allowed to manage widgets on this site.' ),
+ array(
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Looks for "lost" widgets once per request.
+ *
+ * @since 5.9.0
+ *
+ * @see retrieve_widgets()
+ */
+ protected function retrieve_widgets() {
+ if ( ! $this->widgets_retrieved ) {
+ retrieve_widgets();
+ $this->widgets_retrieved = true;
+ }
+ }
+
+ /**
+ * Saves the widget in the request object.
+ *
+ * @since 5.8.0
+ *
+ * @global WP_Widget_Factory $wp_widget_factory
+ * @global array $wp_registered_widget_updates The registered widget update functions.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @param string $sidebar_id ID of the sidebar the widget belongs to.
+ * @return string|WP_Error The saved widget ID.
+ */
+ protected function save_widget( $request, $sidebar_id ) {
+ global $wp_widget_factory, $wp_registered_widget_updates;
+
+ require_once ABSPATH . 'wp-admin/includes/widgets.php'; // For next_widget_id_number().
+
+ if ( isset( $request['id'] ) ) {
+ // Saving an existing widget.
+ $id = $request['id'];
+ $parsed_id = wp_parse_widget_id( $id );
+ $id_base = $parsed_id['id_base'];
+ $number = isset( $parsed_id['number'] ) ? $parsed_id['number'] : null;
+ $widget_object = $wp_widget_factory->get_widget_object( $id_base );
+ $creating = false;
+ } elseif ( $request['id_base'] ) {
+ // Saving a new widget.
+ $id_base = $request['id_base'];
+ $widget_object = $wp_widget_factory->get_widget_object( $id_base );
+ $number = $widget_object ? next_widget_id_number( $id_base ) : null;
+ $id = $widget_object ? $id_base . '-' . $number : $id_base;
+ $creating = true;
+ } else {
+ return new WP_Error(
+ 'rest_invalid_widget',
+ __( 'Widget type (id_base) is required.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( ! isset( $wp_registered_widget_updates[ $id_base ] ) ) {
+ return new WP_Error(
+ 'rest_invalid_widget',
+ __( 'The provided widget type (id_base) cannot be updated.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( isset( $request['instance'] ) ) {
+ if ( ! $widget_object ) {
+ return new WP_Error(
+ 'rest_invalid_widget',
+ __( 'Cannot set instance on a widget that does not extend WP_Widget.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( isset( $request['instance']['raw'] ) ) {
+ if ( empty( $widget_object->widget_options['show_instance_in_rest'] ) ) {
+ return new WP_Error(
+ 'rest_invalid_widget',
+ __( 'Widget type does not support raw instances.' ),
+ array( 'status' => 400 )
+ );
+ }
+ $instance = $request['instance']['raw'];
+ } elseif ( isset( $request['instance']['encoded'], $request['instance']['hash'] ) ) {
+ $serialized_instance = base64_decode( $request['instance']['encoded'] );
+ if ( ! hash_equals( wp_hash( $serialized_instance ), $request['instance']['hash'] ) ) {
+ return new WP_Error(
+ 'rest_invalid_widget',
+ __( 'The provided instance is malformed.' ),
+ array( 'status' => 400 )
+ );
+ }
+ $instance = unserialize( $serialized_instance );
+ } else {
+ return new WP_Error(
+ 'rest_invalid_widget',
+ __( 'The provided instance is invalid. Must contain raw OR encoded and hash.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $form_data = array(
+ "widget-$id_base" => array(
+ $number => $instance,
+ ),
+ 'sidebar' => $sidebar_id,
+ );
+ } elseif ( isset( $request['form_data'] ) ) {
+ $form_data = $request['form_data'];
+ } else {
+ $form_data = array();
+ }
+
+ $original_post = $_POST;
+ $original_request = $_REQUEST;
+
+ foreach ( $form_data as $key => $value ) {
+ $slashed_value = wp_slash( $value );
+ $_POST[ $key ] = $slashed_value;
+ $_REQUEST[ $key ] = $slashed_value;
+ }
+
+ $callback = $wp_registered_widget_updates[ $id_base ]['callback'];
+ $params = $wp_registered_widget_updates[ $id_base ]['params'];
+
+ if ( is_callable( $callback ) ) {
+ ob_start();
+ call_user_func_array( $callback, $params );
+ ob_end_clean();
+ }
+
+ $_POST = $original_post;
+ $_REQUEST = $original_request;
+
+ if ( $widget_object ) {
+ // Register any multi-widget that the update callback just created.
+ $widget_object->_set( $number );
+ $widget_object->_register_one( $number );
+
+ /*
+ * WP_Widget sets `updated = true` after an update to prevent more than one widget
+ * from being saved per request. This isn't what we want in the REST API, though,
+ * as we support batch requests.
+ */
+ $widget_object->updated = false;
+ }
+
+ /**
+ * Fires after a widget is created or updated via the REST API.
+ *
+ * @since 5.8.0
+ *
+ * @param string $id ID of the widget being saved.
+ * @param string $sidebar_id ID of the sidebar containing the widget being saved.
+ * @param WP_REST_Request $request Request object.
+ * @param bool $creating True when creating a widget, false when updating.
+ */
+ do_action( 'rest_after_save_widget', $id, $sidebar_id, $request, $creating );
+
+ return $id;
+ }
+
+ /**
+ * Prepares the widget for the REST response.
+ *
+ * @since 5.8.0
+ *
+ * @global WP_Widget_Factory $wp_widget_factory
+ * @global array $wp_registered_widgets The registered widgets.
+ *
+ * @param array $item An array containing a widget_id and sidebar_id.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ global $wp_widget_factory, $wp_registered_widgets;
+
+ $widget_id = $item['widget_id'];
+ $sidebar_id = $item['sidebar_id'];
+
+ if ( ! isset( $wp_registered_widgets[ $widget_id ] ) ) {
+ return new WP_Error(
+ 'rest_invalid_widget',
+ __( 'The requested widget is invalid.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $widget = $wp_registered_widgets[ $widget_id ];
+ $parsed_id = wp_parse_widget_id( $widget_id );
+ $fields = $this->get_fields_for_response( $request );
+
+ $prepared = array(
+ 'id' => $widget_id,
+ 'id_base' => $parsed_id['id_base'],
+ 'sidebar' => $sidebar_id,
+ 'rendered' => '',
+ 'rendered_form' => null,
+ 'instance' => null,
+ );
+
+ if (
+ rest_is_field_included( 'rendered', $fields ) &&
+ 'wp_inactive_widgets' !== $sidebar_id
+ ) {
+ $prepared['rendered'] = trim( wp_render_widget( $widget_id, $sidebar_id ) );
+ }
+
+ if ( rest_is_field_included( 'rendered_form', $fields ) ) {
+ $rendered_form = wp_render_widget_control( $widget_id );
+ if ( ! is_null( $rendered_form ) ) {
+ $prepared['rendered_form'] = trim( $rendered_form );
+ }
+ }
+
+ if ( rest_is_field_included( 'instance', $fields ) ) {
+ $widget_object = $wp_widget_factory->get_widget_object( $parsed_id['id_base'] );
+ if ( $widget_object && isset( $parsed_id['number'] ) ) {
+ $all_instances = $widget_object->get_settings();
+ $instance = $all_instances[ $parsed_id['number'] ];
+ $serialized_instance = serialize( $instance );
+ $prepared['instance']['encoded'] = base64_encode( $serialized_instance );
+ $prepared['instance']['hash'] = wp_hash( $serialized_instance );
+
+ if ( ! empty( $widget_object->widget_options['show_instance_in_rest'] ) ) {
+ // Use new stdClass so that JSON result is {} and not [].
+ $prepared['instance']['raw'] = empty( $instance ) ? new stdClass() : $instance;
+ }
+ }
+ }
+
+ $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
+ $prepared = $this->add_additional_fields_to_object( $prepared, $request );
+ $prepared = $this->filter_response_by_context( $prepared, $context );
+
+ $response = rest_ensure_response( $prepared );
+
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $response->add_links( $this->prepare_links( $prepared ) );
+ }
+
+ /**
+ * Filters the REST API response for a widget.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Response|WP_Error $response The response object, or WP_Error object on failure.
+ * @param array $widget The registered widget data.
+ * @param WP_REST_Request $request Request used to generate the response.
+ */
+ return apply_filters( 'rest_prepare_widget', $response, $widget, $request );
+ }
+
+ /**
+ * Prepares links for the widget.
+ *
+ * @since 5.8.0
+ *
+ * @param array $prepared Widget.
+ * @return array Links for the given widget.
+ */
+ protected function prepare_links( $prepared ) {
+ $id_base = ! empty( $prepared['id_base'] ) ? $prepared['id_base'] : $prepared['id'];
+
+ return array(
+ 'self' => array(
+ 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $prepared['id'] ) ),
+ ),
+ 'collection' => array(
+ 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+ ),
+ 'about' => array(
+ 'href' => rest_url( sprintf( 'wp/v2/widget-types/%s', $id_base ) ),
+ 'embeddable' => true,
+ ),
+ 'https://api.w.org/sidebar' => array(
+ 'href' => rest_url( sprintf( 'wp/v2/sidebars/%s/', $prepared['sidebar'] ) ),
+ ),
+ );
+ }
+
+ /**
+ * Gets the list of collection params.
+ *
+ * @since 5.8.0
+ *
+ * @return array[]
+ */
+ public function get_collection_params() {
+ return array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ 'sidebar' => array(
+ 'description' => __( 'The sidebar to return widgets for.' ),
+ 'type' => 'string',
+ ),
+ );
+ }
+
+ /**
+ * Retrieves the widget's schema, conforming to JSON Schema.
+ *
+ * @since 5.8.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'widget',
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the widget.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'id_base' => array(
+ 'description' => __( 'The type of the widget. Corresponds to ID in widget-types endpoint.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'sidebar' => array(
+ 'description' => __( 'The sidebar the widget belongs to.' ),
+ 'type' => 'string',
+ 'default' => 'wp_inactive_widgets',
+ 'required' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
+ 'rendered' => array(
+ 'description' => __( 'HTML representation of the widget.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'rendered_form' => array(
+ 'description' => __( 'HTML representation of the widget admin form.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ ),
+ 'instance' => array(
+ 'description' => __( 'Instance settings of the widget, if supported.' ),
+ 'type' => 'object',
+ 'context' => array( 'edit' ),
+ 'default' => null,
+ 'properties' => array(
+ 'encoded' => array(
+ 'description' => __( 'Base64 encoded representation of the instance settings.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ ),
+ 'hash' => array(
+ 'description' => __( 'Cryptographic hash of the instance settings.' ),
+ 'type' => 'string',
+ 'context' => array( 'edit' ),
+ ),
+ 'raw' => array(
+ 'description' => __( 'Unencoded instance settings, if supported.' ),
+ 'type' => 'object',
+ 'context' => array( 'edit' ),
+ ),
+ ),
+ ),
+ 'form_data' => array(
+ 'description' => __( 'URL-encoded form data from the widget admin form. Used to update a widget that does not support instance. Write only.' ),
+ 'type' => 'string',
+ 'context' => array(),
+ 'arg_options' => array(
+ 'sanitize_callback' => static function ( $form_data ) {
+ $array = array();
+ wp_parse_str( $form_data, $array );
+ return $array;
+ },
+ ),
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+}
diff --git a/wp-includes/rest-api/fields/class-wp-rest-comment-meta-fields.php b/wp-includes/rest-api/fields/class-wp-rest-comment-meta-fields.php
new file mode 100644
index 0000000..033ef9c
--- /dev/null
+++ b/wp-includes/rest-api/fields/class-wp-rest-comment-meta-fields.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * REST API: WP_REST_Comment_Meta_Fields class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class to manage comment meta via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Meta_Fields
+ */
+class WP_REST_Comment_Meta_Fields extends WP_REST_Meta_Fields {
+
+ /**
+ * Retrieves the comment type for comment meta.
+ *
+ * @since 4.7.0
+ *
+ * @return string The meta type.
+ */
+ protected function get_meta_type() {
+ return 'comment';
+ }
+
+ /**
+ * Retrieves the comment meta subtype.
+ *
+ * @since 4.9.8
+ *
+ * @return string 'comment' There are no subtypes.
+ */
+ protected function get_meta_subtype() {
+ return 'comment';
+ }
+
+ /**
+ * Retrieves the type for register_rest_field() in the context of comments.
+ *
+ * @since 4.7.0
+ *
+ * @return string The REST field type.
+ */
+ public function get_rest_field_type() {
+ return 'comment';
+ }
+}
diff --git a/wp-includes/rest-api/fields/class-wp-rest-meta-fields.php b/wp-includes/rest-api/fields/class-wp-rest-meta-fields.php
new file mode 100644
index 0000000..60c3c3a
--- /dev/null
+++ b/wp-includes/rest-api/fields/class-wp-rest-meta-fields.php
@@ -0,0 +1,625 @@
+<?php
+/**
+ * REST API: WP_REST_Meta_Fields class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class to manage meta values for an object via the REST API.
+ *
+ * @since 4.7.0
+ */
+#[AllowDynamicProperties]
+abstract class WP_REST_Meta_Fields {
+
+ /**
+ * Retrieves the object meta type.
+ *
+ * @since 4.7.0
+ *
+ * @return string One of 'post', 'comment', 'term', 'user', or anything
+ * else supported by `_get_meta_table()`.
+ */
+ abstract protected function get_meta_type();
+
+ /**
+ * Retrieves the object meta subtype.
+ *
+ * @since 4.9.8
+ *
+ * @return string Subtype for the meta type, or empty string if no specific subtype.
+ */
+ protected function get_meta_subtype() {
+ return '';
+ }
+
+ /**
+ * Retrieves the object type for register_rest_field().
+ *
+ * @since 4.7.0
+ *
+ * @return string The REST field type, such as post type name, taxonomy name, 'comment', or `user`.
+ */
+ abstract protected function get_rest_field_type();
+
+ /**
+ * Registers the meta field.
+ *
+ * @since 4.7.0
+ * @deprecated 5.6.0
+ *
+ * @see register_rest_field()
+ */
+ public function register_field() {
+ _deprecated_function( __METHOD__, '5.6.0' );
+
+ register_rest_field(
+ $this->get_rest_field_type(),
+ 'meta',
+ array(
+ 'get_callback' => array( $this, 'get_value' ),
+ 'update_callback' => array( $this, 'update_value' ),
+ 'schema' => $this->get_field_schema(),
+ )
+ );
+ }
+
+ /**
+ * Retrieves the meta field value.
+ *
+ * @since 4.7.0
+ *
+ * @param int $object_id Object ID to fetch meta for.
+ * @param WP_REST_Request $request Full details about the request.
+ * @return array Array containing the meta values keyed by name.
+ */
+ public function get_value( $object_id, $request ) {
+ $fields = $this->get_registered_fields();
+ $response = array();
+
+ foreach ( $fields as $meta_key => $args ) {
+ $name = $args['name'];
+ $all_values = get_metadata( $this->get_meta_type(), $object_id, $meta_key, false );
+
+ if ( $args['single'] ) {
+ if ( empty( $all_values ) ) {
+ $value = $args['schema']['default'];
+ } else {
+ $value = $all_values[0];
+ }
+
+ $value = $this->prepare_value_for_response( $value, $request, $args );
+ } else {
+ $value = array();
+
+ if ( is_array( $all_values ) ) {
+ foreach ( $all_values as $row ) {
+ $value[] = $this->prepare_value_for_response( $row, $request, $args );
+ }
+ }
+ }
+
+ $response[ $name ] = $value;
+ }
+
+ return $response;
+ }
+
+ /**
+ * Prepares a meta value for a response.
+ *
+ * This is required because some native types cannot be stored correctly
+ * in the database, such as booleans. We need to cast back to the relevant
+ * type before passing back to JSON.
+ *
+ * @since 4.7.0
+ *
+ * @param mixed $value Meta value to prepare.
+ * @param WP_REST_Request $request Current request object.
+ * @param array $args Options for the field.
+ * @return mixed Prepared value.
+ */
+ protected function prepare_value_for_response( $value, $request, $args ) {
+ if ( ! empty( $args['prepare_callback'] ) ) {
+ $value = call_user_func( $args['prepare_callback'], $value, $request, $args );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Updates meta values.
+ *
+ * @since 4.7.0
+ *
+ * @param array $meta Array of meta parsed from the request.
+ * @param int $object_id Object ID to fetch meta for.
+ * @return null|WP_Error Null on success, WP_Error object on failure.
+ */
+ public function update_value( $meta, $object_id ) {
+ $fields = $this->get_registered_fields();
+
+ foreach ( $fields as $meta_key => $args ) {
+ $name = $args['name'];
+ if ( ! array_key_exists( $name, $meta ) ) {
+ continue;
+ }
+
+ $value = $meta[ $name ];
+
+ /*
+ * A null value means reset the field, which is essentially deleting it
+ * from the database and then relying on the default value.
+ *
+ * Non-single meta can also be removed by passing an empty array.
+ */
+ if ( is_null( $value ) || ( array() === $value && ! $args['single'] ) ) {
+ $args = $this->get_registered_fields()[ $meta_key ];
+
+ if ( $args['single'] ) {
+ $current = get_metadata( $this->get_meta_type(), $object_id, $meta_key, true );
+
+ if ( is_wp_error( rest_validate_value_from_schema( $current, $args['schema'] ) ) ) {
+ return new WP_Error(
+ 'rest_invalid_stored_value',
+ /* translators: %s: Custom field key. */
+ sprintf( __( 'The %s property has an invalid stored value, and cannot be updated to null.' ), $name ),
+ array( 'status' => 500 )
+ );
+ }
+ }
+
+ $result = $this->delete_meta_value( $object_id, $meta_key, $name );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ continue;
+ }
+
+ if ( ! $args['single'] && is_array( $value ) && count( array_filter( $value, 'is_null' ) ) ) {
+ return new WP_Error(
+ 'rest_invalid_stored_value',
+ /* translators: %s: Custom field key. */
+ sprintf( __( 'The %s property has an invalid stored value, and cannot be updated to null.' ), $name ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $is_valid = rest_validate_value_from_schema( $value, $args['schema'], 'meta.' . $name );
+ if ( is_wp_error( $is_valid ) ) {
+ $is_valid->add_data( array( 'status' => 400 ) );
+ return $is_valid;
+ }
+
+ $value = rest_sanitize_value_from_schema( $value, $args['schema'] );
+
+ if ( $args['single'] ) {
+ $result = $this->update_meta_value( $object_id, $meta_key, $name, $value );
+ } else {
+ $result = $this->update_multi_meta_value( $object_id, $meta_key, $name, $value );
+ }
+
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Deletes a meta value for an object.
+ *
+ * @since 4.7.0
+ *
+ * @param int $object_id Object ID the field belongs to.
+ * @param string $meta_key Key for the field.
+ * @param string $name Name for the field that is exposed in the REST API.
+ * @return true|WP_Error True if meta field is deleted, WP_Error otherwise.
+ */
+ protected function delete_meta_value( $object_id, $meta_key, $name ) {
+ $meta_type = $this->get_meta_type();
+
+ if ( ! current_user_can( "delete_{$meta_type}_meta", $object_id, $meta_key ) ) {
+ return new WP_Error(
+ 'rest_cannot_delete',
+ /* translators: %s: Custom field key. */
+ sprintf( __( 'Sorry, you are not allowed to edit the %s custom field.' ), $name ),
+ array(
+ 'key' => $name,
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+
+ if ( null === get_metadata_raw( $meta_type, $object_id, wp_slash( $meta_key ) ) ) {
+ return true;
+ }
+
+ if ( ! delete_metadata( $meta_type, $object_id, wp_slash( $meta_key ) ) ) {
+ return new WP_Error(
+ 'rest_meta_database_error',
+ __( 'Could not delete meta value from database.' ),
+ array(
+ 'key' => $name,
+ 'status' => WP_Http::INTERNAL_SERVER_ERROR,
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Updates multiple meta values for an object.
+ *
+ * Alters the list of values in the database to match the list of provided values.
+ *
+ * @since 4.7.0
+ *
+ * @param int $object_id Object ID to update.
+ * @param string $meta_key Key for the custom field.
+ * @param string $name Name for the field that is exposed in the REST API.
+ * @param array $values List of values to update to.
+ * @return true|WP_Error True if meta fields are updated, WP_Error otherwise.
+ */
+ protected function update_multi_meta_value( $object_id, $meta_key, $name, $values ) {
+ $meta_type = $this->get_meta_type();
+
+ if ( ! current_user_can( "edit_{$meta_type}_meta", $object_id, $meta_key ) ) {
+ return new WP_Error(
+ 'rest_cannot_update',
+ /* translators: %s: Custom field key. */
+ sprintf( __( 'Sorry, you are not allowed to edit the %s custom field.' ), $name ),
+ array(
+ 'key' => $name,
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+
+ $current_values = get_metadata( $meta_type, $object_id, $meta_key, false );
+ $subtype = get_object_subtype( $meta_type, $object_id );
+
+ if ( ! is_array( $current_values ) ) {
+ $current_values = array();
+ }
+
+ $to_remove = $current_values;
+ $to_add = $values;
+
+ foreach ( $to_add as $add_key => $value ) {
+ $remove_keys = array_keys(
+ array_filter(
+ $current_values,
+ function ( $stored_value ) use ( $meta_key, $subtype, $value ) {
+ return $this->is_meta_value_same_as_stored_value( $meta_key, $subtype, $stored_value, $value );
+ }
+ )
+ );
+
+ if ( empty( $remove_keys ) ) {
+ continue;
+ }
+
+ if ( count( $remove_keys ) > 1 ) {
+ // To remove, we need to remove first, then add, so don't touch.
+ continue;
+ }
+
+ $remove_key = $remove_keys[0];
+
+ unset( $to_remove[ $remove_key ] );
+ unset( $to_add[ $add_key ] );
+ }
+
+ /*
+ * `delete_metadata` removes _all_ instances of the value, so only call once. Otherwise,
+ * `delete_metadata` will return false for subsequent calls of the same value.
+ * Use serialization to produce a predictable string that can be used by array_unique.
+ */
+ $to_remove = array_map( 'maybe_unserialize', array_unique( array_map( 'maybe_serialize', $to_remove ) ) );
+
+ foreach ( $to_remove as $value ) {
+ if ( ! delete_metadata( $meta_type, $object_id, wp_slash( $meta_key ), wp_slash( $value ) ) ) {
+ return new WP_Error(
+ 'rest_meta_database_error',
+ /* translators: %s: Custom field key. */
+ sprintf( __( 'Could not update the meta value of %s in database.' ), $meta_key ),
+ array(
+ 'key' => $name,
+ 'status' => WP_Http::INTERNAL_SERVER_ERROR,
+ )
+ );
+ }
+ }
+
+ foreach ( $to_add as $value ) {
+ if ( ! add_metadata( $meta_type, $object_id, wp_slash( $meta_key ), wp_slash( $value ) ) ) {
+ return new WP_Error(
+ 'rest_meta_database_error',
+ /* translators: %s: Custom field key. */
+ sprintf( __( 'Could not update the meta value of %s in database.' ), $meta_key ),
+ array(
+ 'key' => $name,
+ 'status' => WP_Http::INTERNAL_SERVER_ERROR,
+ )
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Updates a meta value for an object.
+ *
+ * @since 4.7.0
+ *
+ * @param int $object_id Object ID to update.
+ * @param string $meta_key Key for the custom field.
+ * @param string $name Name for the field that is exposed in the REST API.
+ * @param mixed $value Updated value.
+ * @return true|WP_Error True if the meta field was updated, WP_Error otherwise.
+ */
+ protected function update_meta_value( $object_id, $meta_key, $name, $value ) {
+ $meta_type = $this->get_meta_type();
+
+ // Do the exact same check for a duplicate value as in update_metadata() to avoid update_metadata() returning false.
+ $old_value = get_metadata( $meta_type, $object_id, $meta_key );
+ $subtype = get_object_subtype( $meta_type, $object_id );
+
+ if ( is_array( $old_value ) && 1 === count( $old_value )
+ && $this->is_meta_value_same_as_stored_value( $meta_key, $subtype, $old_value[0], $value )
+ ) {
+ return true;
+ }
+
+ if ( ! current_user_can( "edit_{$meta_type}_meta", $object_id, $meta_key ) ) {
+ return new WP_Error(
+ 'rest_cannot_update',
+ /* translators: %s: Custom field key. */
+ sprintf( __( 'Sorry, you are not allowed to edit the %s custom field.' ), $name ),
+ array(
+ 'key' => $name,
+ 'status' => rest_authorization_required_code(),
+ )
+ );
+ }
+
+ if ( ! update_metadata( $meta_type, $object_id, wp_slash( $meta_key ), wp_slash( $value ) ) ) {
+ return new WP_Error(
+ 'rest_meta_database_error',
+ /* translators: %s: Custom field key. */
+ sprintf( __( 'Could not update the meta value of %s in database.' ), $meta_key ),
+ array(
+ 'key' => $name,
+ 'status' => WP_Http::INTERNAL_SERVER_ERROR,
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if the user provided value is equivalent to a stored value for the given meta key.
+ *
+ * @since 5.5.0
+ *
+ * @param string $meta_key The meta key being checked.
+ * @param string $subtype The object subtype.
+ * @param mixed $stored_value The currently stored value retrieved from get_metadata().
+ * @param mixed $user_value The value provided by the user.
+ * @return bool
+ */
+ protected function is_meta_value_same_as_stored_value( $meta_key, $subtype, $stored_value, $user_value ) {
+ $args = $this->get_registered_fields()[ $meta_key ];
+ $sanitized = sanitize_meta( $meta_key, $user_value, $this->get_meta_type(), $subtype );
+
+ if ( in_array( $args['type'], array( 'string', 'number', 'integer', 'boolean' ), true ) ) {
+ // The return value of get_metadata will always be a string for scalar types.
+ $sanitized = (string) $sanitized;
+ }
+
+ return $sanitized === $stored_value;
+ }
+
+ /**
+ * Retrieves all the registered meta fields.
+ *
+ * @since 4.7.0
+ *
+ * @return array Registered fields.
+ */
+ protected function get_registered_fields() {
+ $registered = array();
+
+ $meta_type = $this->get_meta_type();
+ $meta_subtype = $this->get_meta_subtype();
+
+ $meta_keys = get_registered_meta_keys( $meta_type );
+ if ( ! empty( $meta_subtype ) ) {
+ $meta_keys = array_merge( $meta_keys, get_registered_meta_keys( $meta_type, $meta_subtype ) );
+ }
+
+ foreach ( $meta_keys as $name => $args ) {
+ if ( empty( $args['show_in_rest'] ) ) {
+ continue;
+ }
+
+ $rest_args = array();
+
+ if ( is_array( $args['show_in_rest'] ) ) {
+ $rest_args = $args['show_in_rest'];
+ }
+
+ $default_args = array(
+ 'name' => $name,
+ 'single' => $args['single'],
+ 'type' => ! empty( $args['type'] ) ? $args['type'] : null,
+ 'schema' => array(),
+ 'prepare_callback' => array( $this, 'prepare_value' ),
+ );
+
+ $default_schema = array(
+ 'type' => $default_args['type'],
+ 'description' => empty( $args['description'] ) ? '' : $args['description'],
+ 'default' => isset( $args['default'] ) ? $args['default'] : null,
+ );
+
+ $rest_args = array_merge( $default_args, $rest_args );
+ $rest_args['schema'] = array_merge( $default_schema, $rest_args['schema'] );
+
+ $type = ! empty( $rest_args['type'] ) ? $rest_args['type'] : null;
+ $type = ! empty( $rest_args['schema']['type'] ) ? $rest_args['schema']['type'] : $type;
+
+ if ( null === $rest_args['schema']['default'] ) {
+ $rest_args['schema']['default'] = static::get_empty_value_for_type( $type );
+ }
+
+ $rest_args['schema'] = rest_default_additional_properties_to_false( $rest_args['schema'] );
+
+ if ( ! in_array( $type, array( 'string', 'boolean', 'integer', 'number', 'array', 'object' ), true ) ) {
+ continue;
+ }
+
+ if ( empty( $rest_args['single'] ) ) {
+ $rest_args['schema'] = array(
+ 'type' => 'array',
+ 'items' => $rest_args['schema'],
+ );
+ }
+
+ $registered[ $name ] = $rest_args;
+ }
+
+ return $registered;
+ }
+
+ /**
+ * Retrieves the object's meta schema, conforming to JSON Schema.
+ *
+ * @since 4.7.0
+ *
+ * @return array Field schema data.
+ */
+ public function get_field_schema() {
+ $fields = $this->get_registered_fields();
+
+ $schema = array(
+ 'description' => __( 'Meta fields.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ 'properties' => array(),
+ 'arg_options' => array(
+ 'sanitize_callback' => null,
+ 'validate_callback' => array( $this, 'check_meta_is_array' ),
+ ),
+ );
+
+ foreach ( $fields as $args ) {
+ $schema['properties'][ $args['name'] ] = $args['schema'];
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Prepares a meta value for output.
+ *
+ * Default preparation for meta fields. Override by passing the
+ * `prepare_callback` in your `show_in_rest` options.
+ *
+ * @since 4.7.0
+ *
+ * @param mixed $value Meta value from the database.
+ * @param WP_REST_Request $request Request object.
+ * @param array $args REST-specific options for the meta key.
+ * @return mixed Value prepared for output. If a non-JsonSerializable object, null.
+ */
+ public static function prepare_value( $value, $request, $args ) {
+ if ( $args['single'] ) {
+ $schema = $args['schema'];
+ } else {
+ $schema = $args['schema']['items'];
+ }
+
+ if ( '' === $value && in_array( $schema['type'], array( 'boolean', 'integer', 'number' ), true ) ) {
+ $value = static::get_empty_value_for_type( $schema['type'] );
+ }
+
+ if ( is_wp_error( rest_validate_value_from_schema( $value, $schema ) ) ) {
+ return null;
+ }
+
+ return rest_sanitize_value_from_schema( $value, $schema );
+ }
+
+ /**
+ * Check the 'meta' value of a request is an associative array.
+ *
+ * @since 4.7.0
+ *
+ * @param mixed $value The meta value submitted in the request.
+ * @param WP_REST_Request $request Full details about the request.
+ * @param string $param The parameter name.
+ * @return array|false The meta array, if valid, false otherwise.
+ */
+ public function check_meta_is_array( $value, $request, $param ) {
+ if ( ! is_array( $value ) ) {
+ return false;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Recursively add additionalProperties = false to all objects in a schema if no additionalProperties setting
+ * is specified.
+ *
+ * This is needed to restrict properties of objects in meta values to only
+ * registered items, as the REST API will allow additional properties by
+ * default.
+ *
+ * @since 5.3.0
+ * @deprecated 5.6.0 Use rest_default_additional_properties_to_false() instead.
+ *
+ * @param array $schema The schema array.
+ * @return array
+ */
+ protected function default_additional_properties_to_false( $schema ) {
+ _deprecated_function( __METHOD__, '5.6.0', 'rest_default_additional_properties_to_false()' );
+
+ return rest_default_additional_properties_to_false( $schema );
+ }
+
+ /**
+ * Gets the empty value for a schema type.
+ *
+ * @since 5.3.0
+ *
+ * @param string $type The schema type.
+ * @return mixed
+ */
+ protected static function get_empty_value_for_type( $type ) {
+ switch ( $type ) {
+ case 'string':
+ return '';
+ case 'boolean':
+ return false;
+ case 'integer':
+ return 0;
+ case 'number':
+ return 0.0;
+ case 'array':
+ case 'object':
+ return array();
+ default:
+ return null;
+ }
+ }
+}
diff --git a/wp-includes/rest-api/fields/class-wp-rest-post-meta-fields.php b/wp-includes/rest-api/fields/class-wp-rest-post-meta-fields.php
new file mode 100644
index 0000000..4b1dc23
--- /dev/null
+++ b/wp-includes/rest-api/fields/class-wp-rest-post-meta-fields.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * REST API: WP_REST_Post_Meta_Fields class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class used to manage meta values for posts via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Meta_Fields
+ */
+class WP_REST_Post_Meta_Fields extends WP_REST_Meta_Fields {
+
+ /**
+ * Post type to register fields for.
+ *
+ * @since 4.7.0
+ * @var string
+ */
+ protected $post_type;
+
+ /**
+ * Constructor.
+ *
+ * @since 4.7.0
+ *
+ * @param string $post_type Post type to register fields for.
+ */
+ public function __construct( $post_type ) {
+ $this->post_type = $post_type;
+ }
+
+ /**
+ * Retrieves the post meta type.
+ *
+ * @since 4.7.0
+ *
+ * @return string The meta type.
+ */
+ protected function get_meta_type() {
+ return 'post';
+ }
+
+ /**
+ * Retrieves the post meta subtype.
+ *
+ * @since 4.9.8
+ *
+ * @return string Subtype for the meta type, or empty string if no specific subtype.
+ */
+ protected function get_meta_subtype() {
+ return $this->post_type;
+ }
+
+ /**
+ * Retrieves the type for register_rest_field().
+ *
+ * @since 4.7.0
+ *
+ * @see register_rest_field()
+ *
+ * @return string The REST field type.
+ */
+ public function get_rest_field_type() {
+ return $this->post_type;
+ }
+}
diff --git a/wp-includes/rest-api/fields/class-wp-rest-term-meta-fields.php b/wp-includes/rest-api/fields/class-wp-rest-term-meta-fields.php
new file mode 100644
index 0000000..281e131
--- /dev/null
+++ b/wp-includes/rest-api/fields/class-wp-rest-term-meta-fields.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * REST API: WP_REST_Term_Meta_Fields class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class used to manage meta values for terms via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Meta_Fields
+ */
+class WP_REST_Term_Meta_Fields extends WP_REST_Meta_Fields {
+
+ /**
+ * Taxonomy to register fields for.
+ *
+ * @since 4.7.0
+ * @var string
+ */
+ protected $taxonomy;
+
+ /**
+ * Constructor.
+ *
+ * @since 4.7.0
+ *
+ * @param string $taxonomy Taxonomy to register fields for.
+ */
+ public function __construct( $taxonomy ) {
+ $this->taxonomy = $taxonomy;
+ }
+
+ /**
+ * Retrieves the term meta type.
+ *
+ * @since 4.7.0
+ *
+ * @return string The meta type.
+ */
+ protected function get_meta_type() {
+ return 'term';
+ }
+
+ /**
+ * Retrieves the term meta subtype.
+ *
+ * @since 4.9.8
+ *
+ * @return string Subtype for the meta type, or empty string if no specific subtype.
+ */
+ protected function get_meta_subtype() {
+ return $this->taxonomy;
+ }
+
+ /**
+ * Retrieves the type for register_rest_field().
+ *
+ * @since 4.7.0
+ *
+ * @return string The REST field type.
+ */
+ public function get_rest_field_type() {
+ return 'post_tag' === $this->taxonomy ? 'tag' : $this->taxonomy;
+ }
+}
diff --git a/wp-includes/rest-api/fields/class-wp-rest-user-meta-fields.php b/wp-includes/rest-api/fields/class-wp-rest-user-meta-fields.php
new file mode 100644
index 0000000..fb1513c
--- /dev/null
+++ b/wp-includes/rest-api/fields/class-wp-rest-user-meta-fields.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * REST API: WP_REST_User_Meta_Fields class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 4.7.0
+ */
+
+/**
+ * Core class used to manage meta values for users via the REST API.
+ *
+ * @since 4.7.0
+ *
+ * @see WP_REST_Meta_Fields
+ */
+class WP_REST_User_Meta_Fields extends WP_REST_Meta_Fields {
+
+ /**
+ * Retrieves the user meta type.
+ *
+ * @since 4.7.0
+ *
+ * @return string The user meta type.
+ */
+ protected function get_meta_type() {
+ return 'user';
+ }
+
+ /**
+ * Retrieves the user meta subtype.
+ *
+ * @since 4.9.8
+ *
+ * @return string 'user' There are no subtypes.
+ */
+ protected function get_meta_subtype() {
+ return 'user';
+ }
+
+ /**
+ * Retrieves the type for register_rest_field().
+ *
+ * @since 4.7.0
+ *
+ * @return string The user REST field type.
+ */
+ public function get_rest_field_type() {
+ return 'user';
+ }
+}
diff --git a/wp-includes/rest-api/search/class-wp-rest-post-format-search-handler.php b/wp-includes/rest-api/search/class-wp-rest-post-format-search-handler.php
new file mode 100644
index 0000000..b219281
--- /dev/null
+++ b/wp-includes/rest-api/search/class-wp-rest-post-format-search-handler.php
@@ -0,0 +1,128 @@
+<?php
+/**
+ * REST API: WP_REST_Post_Format_Search_Handler class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.6.0
+ */
+
+/**
+ * Core class representing a search handler for post formats in the REST API.
+ *
+ * @since 5.6.0
+ *
+ * @see WP_REST_Search_Handler
+ */
+class WP_REST_Post_Format_Search_Handler extends WP_REST_Search_Handler {
+
+ /**
+ * Constructor.
+ *
+ * @since 5.6.0
+ */
+ public function __construct() {
+ $this->type = 'post-format';
+ }
+
+ /**
+ * Searches the object type content for a given search request.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full REST request.
+ * @return array Associative array containing an `WP_REST_Search_Handler::RESULT_IDS` containing
+ * an array of found IDs and `WP_REST_Search_Handler::RESULT_TOTAL` containing the
+ * total count for the matching search results.
+ */
+ public function search_items( WP_REST_Request $request ) {
+ $format_strings = get_post_format_strings();
+ $format_slugs = array_keys( $format_strings );
+
+ $query_args = array();
+
+ if ( ! empty( $request['search'] ) ) {
+ $query_args['search'] = $request['search'];
+ }
+
+ /**
+ * Filters the query arguments for a REST API search request.
+ *
+ * Enables adding extra arguments or setting defaults for a post format search request.
+ *
+ * @since 5.6.0
+ *
+ * @param array $query_args Key value array of query var to query value.
+ * @param WP_REST_Request $request The request used.
+ */
+ $query_args = apply_filters( 'rest_post_format_search_query', $query_args, $request );
+
+ $found_ids = array();
+ foreach ( $format_slugs as $index => $format_slug ) {
+ if ( ! empty( $query_args['search'] ) ) {
+ $format_string = get_post_format_string( $format_slug );
+ $format_slug_match = stripos( $format_slug, $query_args['search'] ) !== false;
+ $format_string_match = stripos( $format_string, $query_args['search'] ) !== false;
+ if ( ! $format_slug_match && ! $format_string_match ) {
+ continue;
+ }
+ }
+
+ $format_link = get_post_format_link( $format_slug );
+ if ( $format_link ) {
+ $found_ids[] = $format_slug;
+ }
+ }
+
+ $page = (int) $request['page'];
+ $per_page = (int) $request['per_page'];
+
+ return array(
+ self::RESULT_IDS => array_slice( $found_ids, ( $page - 1 ) * $per_page, $per_page ),
+ self::RESULT_TOTAL => count( $found_ids ),
+ );
+ }
+
+ /**
+ * Prepares the search result for a given ID.
+ *
+ * @since 5.6.0
+ *
+ * @param string $id Item ID, the post format slug.
+ * @param array $fields Fields to include for the item.
+ * @return array Associative array containing all fields for the item.
+ */
+ public function prepare_item( $id, array $fields ) {
+ $data = array();
+
+ if ( in_array( WP_REST_Search_Controller::PROP_ID, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_ID ] = $id;
+ }
+
+ if ( in_array( WP_REST_Search_Controller::PROP_TITLE, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_TITLE ] = get_post_format_string( $id );
+ }
+
+ if ( in_array( WP_REST_Search_Controller::PROP_URL, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_URL ] = get_post_format_link( $id );
+ }
+
+ if ( in_array( WP_REST_Search_Controller::PROP_TYPE, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_TYPE ] = $this->type;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Prepares links for the search result.
+ *
+ * @since 5.6.0
+ *
+ * @param string $id Item ID, the post format slug.
+ * @return array Links for the given item.
+ */
+ public function prepare_item_links( $id ) {
+ return array();
+ }
+}
diff --git a/wp-includes/rest-api/search/class-wp-rest-post-search-handler.php b/wp-includes/rest-api/search/class-wp-rest-post-search-handler.php
new file mode 100644
index 0000000..fb85fce
--- /dev/null
+++ b/wp-includes/rest-api/search/class-wp-rest-post-search-handler.php
@@ -0,0 +1,205 @@
+<?php
+/**
+ * REST API: WP_REST_Post_Search_Handler class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.0.0
+ */
+
+/**
+ * Core class representing a search handler for posts in the REST API.
+ *
+ * @since 5.0.0
+ *
+ * @see WP_REST_Search_Handler
+ */
+class WP_REST_Post_Search_Handler extends WP_REST_Search_Handler {
+
+ /**
+ * Constructor.
+ *
+ * @since 5.0.0
+ */
+ public function __construct() {
+ $this->type = 'post';
+
+ // Support all public post types except attachments.
+ $this->subtypes = array_diff(
+ array_values(
+ get_post_types(
+ array(
+ 'public' => true,
+ 'show_in_rest' => true,
+ ),
+ 'names'
+ )
+ ),
+ array( 'attachment' )
+ );
+ }
+
+ /**
+ * Searches the object type content for a given search request.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full REST request.
+ * @return array Associative array containing an `WP_REST_Search_Handler::RESULT_IDS` containing
+ * an array of found IDs and `WP_REST_Search_Handler::RESULT_TOTAL` containing the
+ * total count for the matching search results.
+ */
+ public function search_items( WP_REST_Request $request ) {
+
+ // Get the post types to search for the current request.
+ $post_types = $request[ WP_REST_Search_Controller::PROP_SUBTYPE ];
+ if ( in_array( WP_REST_Search_Controller::TYPE_ANY, $post_types, true ) ) {
+ $post_types = $this->subtypes;
+ }
+
+ $query_args = array(
+ 'post_type' => $post_types,
+ 'post_status' => 'publish',
+ 'paged' => (int) $request['page'],
+ 'posts_per_page' => (int) $request['per_page'],
+ 'ignore_sticky_posts' => true,
+ );
+
+ if ( ! empty( $request['search'] ) ) {
+ $query_args['s'] = $request['search'];
+ }
+
+ if ( ! empty( $request['exclude'] ) ) {
+ $query_args['post__not_in'] = $request['exclude'];
+ }
+
+ if ( ! empty( $request['include'] ) ) {
+ $query_args['post__in'] = $request['include'];
+ }
+
+ /**
+ * Filters the query arguments for a REST API search request.
+ *
+ * Enables adding extra arguments or setting defaults for a post search request.
+ *
+ * @since 5.1.0
+ *
+ * @param array $query_args Key value array of query var to query value.
+ * @param WP_REST_Request $request The request used.
+ */
+ $query_args = apply_filters( 'rest_post_search_query', $query_args, $request );
+
+ $query = new WP_Query();
+ $posts = $query->query( $query_args );
+ // Querying the whole post object will warm the object cache, avoiding an extra query per result.
+ $found_ids = wp_list_pluck( $posts, 'ID' );
+ $total = $query->found_posts;
+
+ return array(
+ self::RESULT_IDS => $found_ids,
+ self::RESULT_TOTAL => $total,
+ );
+ }
+
+ /**
+ * Prepares the search result for a given ID.
+ *
+ * @since 5.0.0
+ *
+ * @param int $id Item ID.
+ * @param array $fields Fields to include for the item.
+ * @return array Associative array containing all fields for the item.
+ */
+ public function prepare_item( $id, array $fields ) {
+ $post = get_post( $id );
+
+ $data = array();
+
+ if ( in_array( WP_REST_Search_Controller::PROP_ID, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_ID ] = (int) $post->ID;
+ }
+
+ if ( in_array( WP_REST_Search_Controller::PROP_TITLE, $fields, true ) ) {
+ if ( post_type_supports( $post->post_type, 'title' ) ) {
+ add_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
+ $data[ WP_REST_Search_Controller::PROP_TITLE ] = get_the_title( $post->ID );
+ remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
+ } else {
+ $data[ WP_REST_Search_Controller::PROP_TITLE ] = '';
+ }
+ }
+
+ if ( in_array( WP_REST_Search_Controller::PROP_URL, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_URL ] = get_permalink( $post->ID );
+ }
+
+ if ( in_array( WP_REST_Search_Controller::PROP_TYPE, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_TYPE ] = $this->type;
+ }
+
+ if ( in_array( WP_REST_Search_Controller::PROP_SUBTYPE, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_SUBTYPE ] = $post->post_type;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Prepares links for the search result of a given ID.
+ *
+ * @since 5.0.0
+ *
+ * @param int $id Item ID.
+ * @return array Links for the given item.
+ */
+ public function prepare_item_links( $id ) {
+ $post = get_post( $id );
+
+ $links = array();
+
+ $item_route = rest_get_route_for_post( $post );
+ if ( ! empty( $item_route ) ) {
+ $links['self'] = array(
+ 'href' => rest_url( $item_route ),
+ 'embeddable' => true,
+ );
+ }
+
+ $links['about'] = array(
+ 'href' => rest_url( 'wp/v2/types/' . $post->post_type ),
+ );
+
+ return $links;
+ }
+
+ /**
+ * Overwrites the default protected title format.
+ *
+ * By default, WordPress will show password protected posts with a title of
+ * "Protected: %s". As the REST API communicates the protected status of a post
+ * in a machine readable format, we remove the "Protected: " prefix.
+ *
+ * @since 5.0.0
+ *
+ * @return string Protected title format.
+ */
+ public function protected_title_format() {
+ return '%s';
+ }
+
+ /**
+ * Attempts to detect the route to access a single item.
+ *
+ * @since 5.0.0
+ * @deprecated 5.5.0 Use rest_get_route_for_post()
+ * @see rest_get_route_for_post()
+ *
+ * @param WP_Post $post Post object.
+ * @return string REST route relative to the REST base URI, or empty string if unknown.
+ */
+ protected function detect_rest_item_route( $post ) {
+ _deprecated_function( __METHOD__, '5.5.0', 'rest_get_route_for_post()' );
+
+ return rest_get_route_for_post( $post );
+ }
+}
diff --git a/wp-includes/rest-api/search/class-wp-rest-search-handler.php b/wp-includes/rest-api/search/class-wp-rest-search-handler.php
new file mode 100644
index 0000000..cc9dcf8
--- /dev/null
+++ b/wp-includes/rest-api/search/class-wp-rest-search-handler.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * REST API: WP_REST_Search_Handler class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.0.0
+ */
+
+/**
+ * Core base class representing a search handler for an object type in the REST API.
+ *
+ * @since 5.0.0
+ */
+#[AllowDynamicProperties]
+abstract class WP_REST_Search_Handler {
+
+ /**
+ * Field containing the IDs in the search result.
+ */
+ const RESULT_IDS = 'ids';
+
+ /**
+ * Field containing the total count in the search result.
+ */
+ const RESULT_TOTAL = 'total';
+
+ /**
+ * Object type managed by this search handler.
+ *
+ * @since 5.0.0
+ * @var string
+ */
+ protected $type = '';
+
+ /**
+ * Object subtypes managed by this search handler.
+ *
+ * @since 5.0.0
+ * @var string[]
+ */
+ protected $subtypes = array();
+
+ /**
+ * Gets the object type managed by this search handler.
+ *
+ * @since 5.0.0
+ *
+ * @return string Object type identifier.
+ */
+ public function get_type() {
+ return $this->type;
+ }
+
+ /**
+ * Gets the object subtypes managed by this search handler.
+ *
+ * @since 5.0.0
+ *
+ * @return string[] Array of object subtype identifiers.
+ */
+ public function get_subtypes() {
+ return $this->subtypes;
+ }
+
+ /**
+ * Searches the object type content for a given search request.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_REST_Request $request Full REST request.
+ * @return array Associative array containing an `WP_REST_Search_Handler::RESULT_IDS` containing
+ * an array of found IDs and `WP_REST_Search_Handler::RESULT_TOTAL` containing the
+ * total count for the matching search results.
+ */
+ abstract public function search_items( WP_REST_Request $request );
+
+ /**
+ * Prepares the search result for a given ID.
+ *
+ * @since 5.0.0
+ * @since 5.6.0 The `$id` parameter can accept a string.
+ *
+ * @param int|string $id Item ID.
+ * @param array $fields Fields to include for the item.
+ * @return array Associative array containing all fields for the item.
+ */
+ abstract public function prepare_item( $id, array $fields );
+
+ /**
+ * Prepares links for the search result of a given ID.
+ *
+ * @since 5.0.0
+ * @since 5.6.0 The `$id` parameter can accept a string.
+ *
+ * @param int|string $id Item ID.
+ * @return array Links for the given item.
+ */
+ abstract public function prepare_item_links( $id );
+}
diff --git a/wp-includes/rest-api/search/class-wp-rest-term-search-handler.php b/wp-includes/rest-api/search/class-wp-rest-term-search-handler.php
new file mode 100644
index 0000000..eed35e7
--- /dev/null
+++ b/wp-includes/rest-api/search/class-wp-rest-term-search-handler.php
@@ -0,0 +1,169 @@
+<?php
+/**
+ * REST API: WP_REST_Term_Search_Handler class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.6.0
+ */
+
+/**
+ * Core class representing a search handler for terms in the REST API.
+ *
+ * @since 5.6.0
+ *
+ * @see WP_REST_Search_Handler
+ */
+class WP_REST_Term_Search_Handler extends WP_REST_Search_Handler {
+
+ /**
+ * Constructor.
+ *
+ * @since 5.6.0
+ */
+ public function __construct() {
+ $this->type = 'term';
+
+ $this->subtypes = array_values(
+ get_taxonomies(
+ array(
+ 'public' => true,
+ 'show_in_rest' => true,
+ ),
+ 'names'
+ )
+ );
+ }
+
+ /**
+ * Searches the object type content for a given search request.
+ *
+ * @since 5.6.0
+ *
+ * @param WP_REST_Request $request Full REST request.
+ * @return array {
+ * Associative array containing found IDs and total count for the matching search results.
+ *
+ * @type int[] $ids Found IDs.
+ * @type string|int|WP_Error $total Numeric string containing the number of terms in that
+ * taxonomy, 0 if there are no results, or WP_Error if
+ * the requested taxonomy does not exist.
+ * }
+ */
+ public function search_items( WP_REST_Request $request ) {
+ $taxonomies = $request[ WP_REST_Search_Controller::PROP_SUBTYPE ];
+ if ( in_array( WP_REST_Search_Controller::TYPE_ANY, $taxonomies, true ) ) {
+ $taxonomies = $this->subtypes;
+ }
+
+ $page = (int) $request['page'];
+ $per_page = (int) $request['per_page'];
+
+ $query_args = array(
+ 'taxonomy' => $taxonomies,
+ 'hide_empty' => false,
+ 'offset' => ( $page - 1 ) * $per_page,
+ 'number' => $per_page,
+ );
+
+ if ( ! empty( $request['search'] ) ) {
+ $query_args['search'] = $request['search'];
+ }
+
+ if ( ! empty( $request['exclude'] ) ) {
+ $query_args['exclude'] = $request['exclude'];
+ }
+
+ if ( ! empty( $request['include'] ) ) {
+ $query_args['include'] = $request['include'];
+ }
+
+ /**
+ * Filters the query arguments for a REST API search request.
+ *
+ * Enables adding extra arguments or setting defaults for a term search request.
+ *
+ * @since 5.6.0
+ *
+ * @param array $query_args Key value array of query var to query value.
+ * @param WP_REST_Request $request The request used.
+ */
+ $query_args = apply_filters( 'rest_term_search_query', $query_args, $request );
+
+ $query = new WP_Term_Query();
+ $found_terms = $query->query( $query_args );
+ $found_ids = wp_list_pluck( $found_terms, 'term_id' );
+
+ unset( $query_args['offset'], $query_args['number'] );
+
+ $total = wp_count_terms( $query_args );
+
+ // wp_count_terms() can return a falsey value when the term has no children.
+ if ( ! $total ) {
+ $total = 0;
+ }
+
+ return array(
+ self::RESULT_IDS => $found_ids,
+ self::RESULT_TOTAL => $total,
+ );
+ }
+
+ /**
+ * Prepares the search result for a given ID.
+ *
+ * @since 5.6.0
+ *
+ * @param int $id Item ID.
+ * @param array $fields Fields to include for the item.
+ * @return array Associative array containing all fields for the item.
+ */
+ public function prepare_item( $id, array $fields ) {
+ $term = get_term( $id );
+
+ $data = array();
+
+ if ( in_array( WP_REST_Search_Controller::PROP_ID, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_ID ] = (int) $id;
+ }
+ if ( in_array( WP_REST_Search_Controller::PROP_TITLE, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_TITLE ] = $term->name;
+ }
+ if ( in_array( WP_REST_Search_Controller::PROP_URL, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_URL ] = get_term_link( $id );
+ }
+ if ( in_array( WP_REST_Search_Controller::PROP_TYPE, $fields, true ) ) {
+ $data[ WP_REST_Search_Controller::PROP_TYPE ] = $term->taxonomy;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Prepares links for the search result of a given ID.
+ *
+ * @since 5.6.0
+ *
+ * @param int $id Item ID.
+ * @return array[] Array of link arrays for the given item.
+ */
+ public function prepare_item_links( $id ) {
+ $term = get_term( $id );
+
+ $links = array();
+
+ $item_route = rest_get_route_for_term( $term );
+ if ( $item_route ) {
+ $links['self'] = array(
+ 'href' => rest_url( $item_route ),
+ 'embeddable' => true,
+ );
+ }
+
+ $links['about'] = array(
+ 'href' => rest_url( sprintf( 'wp/v2/taxonomies/%s', $term->taxonomy ) ),
+ );
+
+ return $links;
+ }
+}