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']['excerpt'] ) ) { $get_item_args['excerpt_length'] = array( 'description' => __( 'Override the default excerpt length.' ), 'type' => 'integer', ); } 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[\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 into 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', 'ignore_sticky' => 'ignore_sticky_posts', '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 ); } } /* * Honor the original REST API `post__in` behavior. Don't prepend sticky posts * when `post__in` has been specified. */ if ( ! empty( $args['post__in'] ) ) { unset( $args['ignore_sticky_posts'] ); } if ( isset( $registered['search_semantics'], $request['search_semantics'] ) && 'exact' === $request['search_semantics'] ) { $args['exact'] = true; } $args = $this->prepare_tax_query( $args, $request ); if ( isset( $registered['format'], $request['format'] ) ) { $formats = $request['format']; /* * The relation needs to be set to `OR` since the request can contain * two separate conditions. The user may be querying for items that have * either the `standard` format or a specific format. */ $formats_query = array( 'relation' => 'OR' ); /* * The default post format, `standard`, is not stored in the database. * If `standard` is part of the request, the query needs to exclude all post items that * have a format assigned. */ if ( in_array( 'standard', $formats, true ) ) { $formats_query[] = array( 'taxonomy' => 'post_format', 'field' => 'slug', 'operator' => 'NOT EXISTS', ); // Remove the `standard` format, since it cannot be queried. unset( $formats[ array_search( 'standard', $formats, true ) ] ); } // Add any remaining formats to the formats query. if ( ! empty( $formats ) ) { // Add the `post-format-` prefix. $terms = array_map( static function ( $format ) { return "post-format-$format"; }, $formats ); $formats_query[] = array( 'taxonomy' => 'post_format', 'field' => 'slug', 'terms' => $terms, 'operator' => 'IN', ); } // Enable filtering by both post formats and other taxonomies by combining them with `AND`. if ( isset( $args['tax_query'] ) ) { $args['tax_query'][] = array( 'relation' => 'AND', $formats_query, ); } else { $args['tax_query'] = $formats_query; } } // Force the post_type argument, since it's not a user input variable. $args['post_type'] = $this->post_type; $is_head_request = $request->is_method( 'HEAD' ); if ( $is_head_request ) { // Force the 'fields' argument. For HEAD requests, only post IDs are required to calculate pagination. $args['fields'] = 'ids'; // Disable priming post meta for HEAD requests to improve performance. $args['update_post_term_cache'] = false; $args['update_post_meta_cache'] = false; } /** * 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 ); } if ( ! $is_head_request ) { $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 = isset( $query_args['paged'] ) ? (int) $query_args['paged'] : 0; $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 = (int) 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 = $is_head_request ? new WP_REST_Response( array() ) : 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->get_query_params()['password'] ) ) { // Check post password, and return error if invalid. if ( ! hash_equals( $post->post_password, $request->get_query_params()['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; } } return null; } /** * 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. * * @global WP_Post $post Global post object. * * @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 ); // Don't prepare the response body for HEAD requests. if ( $request->is_method( 'HEAD' ) ) { /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */ return apply_filters( "rest_prepare_{$this->post_type}", new WP_REST_Response( array() ), $post, $request ); } $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 ) - (int) ( (float) 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' ) ); add_filter( 'private_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' ) ); remove_filter( 'private_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 ) ) { if ( isset( $request['excerpt_length'] ) ) { $excerpt_length = $request['excerpt_length']; $override_excerpt_length = static function () use ( $excerpt_length ) { return $excerpt_length; }; add_filter( 'excerpt_length', $override_excerpt_length, 20 ); } /** 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 ( isset( $override_excerpt_length ) ) { remove_filter( 'excerpt_length', $override_excerpt_length, 20 ); } } 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]; } } if ( rest_is_field_included( 'class_list', $fields ) ) { $data['class_list'] = get_post_class( array(), $post->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 ); // 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 and private title format. * * By default, WordPress will show password protected or private posts with a title of * "Protected: %s" or "Private: %s", as the REST API communicates the status of a post * in a machine-readable format, we remove the prefix. * * @since 4.7.0 * * @return string 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, ); $schema['properties']['class_list'] = array( 'description' => __( 'An array of the class names for the post container element.' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'items' => array( 'type' => 'string', ), ); } 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', 'thumbnail', ), ); 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['search_semantics'] = array( 'description' => __( 'How to interpret the search input.' ), 'type' => 'string', 'enum' => array( 'exact' ), ); $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', ); $query_params['ignore_sticky'] = array( 'description' => __( 'Whether to ignore sticky posts or not.' ), 'type' => 'boolean', 'default' => true, ); } if ( post_type_supports( $this->post_type, 'post-formats' ) ) { $query_params['format'] = array( 'description' => __( 'Limit result set to items assigned one or more given formats.' ), 'type' => 'array', 'uniqueItems' => true, 'items' => array( 'enum' => array_values( get_post_format_slugs() ), 'type' => 'string', ), ); } /** * 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( 'statnamespace, '/' . $this->rest_base . '/(?P[\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[\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() ) ); } $files = $request->get_file_params(); /** * Filter whether the server should prevent uploads for image types it doesn't support. Default true. * * Developers can use this filter to enable uploads of certain image types. By default image types that are not * supported by the server are prevented from being uploaded. * * @since 6.8.0 * * @param bool $check_mime Whether to prevent uploads of unsupported image types. * @param string|null $mime_type The mime type of the file being uploaded (if available). */ $prevent_unsupported_uploads = apply_filters( 'wp_prevent_unsupported_mime_type_uploads', true, isset( $files['file']['type'] ) ? $files['file']['type'] : null ); // If the upload is an image, check if the server can handle the mime type. if ( $prevent_unsupported_uploads && isset( $files['file']['type'] ) && str_starts_with( $files['file']['type'], 'image/' ) ) { // List of non-resizable image formats. $editor_non_resizable_formats = array( 'image/svg+xml', ); // Check if the image editor supports the type or ignore if it isn't a format resizable by an editor. if ( ! in_array( $files['file']['type'], $editor_non_resizable_formats, true ) && ! wp_image_editor_supports( array( 'mime_type' => $files['file']['type'] ) ) ) { return new WP_Error( 'rest_upload_image_type_not_supported', __( 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.' ), array( 'status' => 400 ) ); } } 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']['featured_media'] ) && isset( $request['featured_media'] ) ) { $thumbnail_update = $this->handle_featured_media( $request['featured_media'], $attachment_id ); if ( is_wp_error( $thumbnail_update ) ) { return $thumbnail_update; } } 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; } $terms_update = $this->handle_terms( $attachment_id, $request ); if ( is_wp_error( $terms_update ) ) { return $terms_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 ( wp_is_serving_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(); $time = null; // Matches logic in media_handle_upload(). if ( ! empty( $request['post'] ) ) { $post = get_post( $request['post'] ); // The post date doesn't usually matter for pages, so don't backdate this upload. if ( $post && 'page' !== $post->post_type && substr( $post->post_date, 0, 4 ) > 0 ) { $time = $post->post_date; } } if ( ! empty( $files ) ) { $file = $this->upload_from_file( $files, $headers, $time ); } else { $file = $this->upload_from_data( $request->get_body(), $headers, $time ); } 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 the title was not set, use the original filename. if ( empty( $attachment->post_title ) && ! empty( $files['file']['name'] ) ) { // Remove the file extension (after the last `.`) $tmp_title = substr( $files['file']['name'], 0, strrpos( $files['file']['name'], '.' ) ); if ( ! empty( $tmp_title ) ) { $attachment->post_title = $tmp_title; } } // Fall back to the original approach. 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, ); } /** * Determines the featured media based on a request param. * * @since 6.5.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 ) { $post_type = get_post_type( $post_id ); $thumbnail_support = current_theme_supports( 'post-thumbnails', $post_type ) && post_type_supports( $post_type, 'thumbnail' ); // Similar check as in wp_insert_post(). if ( ! $thumbnail_support && get_post_mime_type( $post_id ) ) { if ( wp_attachment_is( 'audio', $post_id ) ) { $thumbnail_support = post_type_supports( 'attachment:audio', 'thumbnail' ) || current_theme_supports( 'post-thumbnails', 'attachment:audio' ); } elseif ( wp_attachment_is( 'video', $post_id ) ) { $thumbnail_support = post_type_supports( 'attachment:video', 'thumbnail' ) || current_theme_supports( 'post-thumbnails', 'attachment:video' ); } } if ( $thumbnail_support ) { return parent::handle_featured_media( $featured_media, $post_id ); } return new WP_Error( 'rest_no_featured_media', sprintf( /* translators: %s: attachment mime type */ __( 'This site does not support post thumbnails on attachments with MIME type %s.' ), get_post_mime_type( $post_id ) ), array( 'status' => 400 ) ); } /** * 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'] ); if ( ! empty( $schema['properties']['featured_media'] ) && isset( $request['featured_media'] ) ) { $thumbnail_update = $this->handle_featured_media( $request['featured_media'], $attachment->ID ); if ( is_wp_error( $thumbnail_update ) ) { return $thumbnail_update; } } $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', 'image/avif', 'image/heic' ); $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. counterclockwise. $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 = (int) round( ( $size['width'] * $args['left'] ) / 100.0 ); $crop_y = (int) round( ( $size['height'] * $args['top'] ) / 100.0 ); $width = (int) round( ( $size['width'] * $args['width'] ) / 100.0 ); $height = (int) 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 subdirectory 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 ( wp_is_serving_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 * @since 6.6.0 Added the `$time` parameter. * * @param string $data Supplied file data. * @param array $headers HTTP headers from the request. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. * @return array|WP_Error Data from wp_handle_sideload(). */ protected function upload_from_data( $data, $headers, $time = null ) { 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, $time ); 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 = * * @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( , $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 * @since 6.6.0 Added the `$time` parameter. * * @param array $files Data from the `$_FILES` superglobal. * @param array $headers HTTP headers from the request. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. * @return array|WP_Error Data from wp_handle_upload(). */ protected function upload_from_file( $files, $headers, $time = null ) { 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, $time ); 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' => 'num