diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index d54cee18c5b39..ee8de238a423f 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -324,6 +324,12 @@ function create_initial_rest_routes() { $controller = new WP_REST_Comments_Controller(); $controller->register_routes(); + // Sites. + if ( is_multisite() ) { + $controller = new WP_REST_Sites_Controller(); + $controller->register_routes(); + } + $search_handlers = array( new WP_REST_Post_Search_Handler(), new WP_REST_Term_Search_Handler(), diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php new file mode 100644 index 0000000000000..9d3b6b6c7cf4f --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php @@ -0,0 +1,1278 @@ +namespace = 'wp/v2'; + $this->rest_base = 'sites'; + + $this->meta = new WP_REST_Site_Meta_Fields(); + } + + /** + * Registers the routes for the objects of the controller. + * + * @since 7.2.0 + */ + public function register_routes() { + + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + // Title and administrator reach wp_initialize_site(), which only runs on creation. + $update_args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ); + unset( $update_args['title'], $update_args['user_id'] ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the object.' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_item' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + 'args' => $update_args, + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + 'args' => array( + 'force' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Required to be true, as sites do not support trashing.' ), + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to read sites. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|bool True if the request has read access, error object otherwise. + */ + public function get_items_permissions_check( $request ) { + + if ( 0 === get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + if ( current_user_can( 'manage_sites' ) ) { + return true; + } + + // Without that capability a user may still ask for their own sites. + if ( $this->is_own_user_filter( $request ) ) { + return true; + } + + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + /** + * Checks whether the request is limited to the sites of the current user. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return bool Whether the request asks for the current user's own sites. + */ + protected function is_own_user_filter( $request ) { + $user = $request['user']; + + if ( empty( $user ) ) { + return false; + } + + if ( 'me' === $user ) { + return true; + } + + return get_current_user_id() === (int) $user; + } + + /** + * Retrieves a list of site items. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, or error object on failure. + */ + public function get_items( $request ) { + + // Retrieve the list of registered collection query parameters. + $registered = $this->get_collection_params(); + + /* + * This array defines mappings between public API query parameters whose + * values are accepted as-passed, and their internal WP_Query parameter + * name equivalents (some are the same). Only values which are also + * present in $registered will be set. + */ + $parameter_mappings = array( + 'domain' => 'domain__in', + 'domain_exclude' => 'domain__not_in', + 'exclude' => 'site__not_in', + 'include' => 'site__in', + 'offset' => 'offset', + 'order' => 'order', + 'network' => 'network__in', + 'network_exclude' => 'network__not_in', + 'per_page' => 'number', + 'path' => 'path__in', + 'path_exclude' => 'path__not_in', + 'search' => 'search', + 'public' => 'public', + 'archived' => 'archived', + 'mature' => 'mature', + 'spam' => 'spam', + 'deleted' => 'deleted', + 'lang_id' => 'lang__in', + 'lang_id_exclude' => 'lang__not_in', + ); + + $prepared_args = array(); + + /* + * For each known parameter which is both registered and present in the request, + * set the parameter's value on the query $prepared_args. + */ + foreach ( $parameter_mappings as $api_param => $wp_param ) { + if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) { + $prepared_args[ $wp_param ] = $request[ $api_param ]; + } + } + + // WP_Site_Query tests the status columns with is_numeric(), and a boolean is not numeric. + foreach ( array( 'public', 'archived', 'mature', 'spam', 'deleted' ) as $status_param ) { + if ( isset( $prepared_args[ $status_param ] ) ) { + $prepared_args[ $status_param ] = (int) rest_sanitize_boolean( $prepared_args[ $status_param ] ); + } + } + + $user = $request['user']; + + if ( ! empty( $user ) ) { + $user_id = ( 'me' === $user ) ? get_current_user_id() : (int) $user; + $site_ids = $this->get_user_site_ids( $user_id ); + + if ( ! empty( $prepared_args['site__in'] ) ) { + $site_ids = array_intersect( $prepared_args['site__in'], $site_ids ); + } + + // An empty site__in is no restriction at all, so ask for an impossible ID instead. + $prepared_args['site__in'] = $site_ids ? array_values( $site_ids ) : array( 0 ); + } + + if ( isset( $registered['orderby'] ) ) { + $orderby = $request['orderby']; + + // Ordering by an ID list needs a list to order by. + if ( in_array( $orderby, array( 'site__in', 'network__in' ), true ) && empty( $prepared_args[ $orderby ] ) ) { + $orderby = 'id'; + } + + $prepared_args['orderby'] = $orderby; + } + + $prepared_args['no_found_rows'] = false; + + $prepared_args['date_query'] = array(); + + /* + * WP_Date_Query reads the registered column as local time, but wp_blogs + * stores GMT, so the boundaries are converted before they are handed over. + */ + foreach ( array( 'before', 'after' ) as $date_param ) { + if ( ! isset( $registered[ $date_param ], $request[ $date_param ] ) ) { + continue; + } + + $timestamp = rest_parse_date( $request[ $date_param ] ); + + if ( false === $timestamp ) { + continue; + } + + $prepared_args['date_query'][0][ $date_param ] = gmdate( 'Y-m-d H:i:s', $timestamp ); + } + + if ( isset( $registered['page'] ) && empty( $request['offset'] ) ) { + $prepared_args['offset'] = $prepared_args['number'] * ( absint( $request['page'] ) - 1 ); + } + + /** + * Filters arguments, before passing to WP_Site_Query, when querying sites via the REST API. + * + * @since 7.2.0 + * + * @link https://developer.wordpress.org/reference/classes/wp_site_query/ + * @param array $prepared_args Array of arguments for WP_Site_Query. + * @param WP_REST_Request $request The current request. + */ + $prepared_args = apply_filters( 'rest_site_query', $prepared_args, $request ); + + $is_head_request = $request->is_method( 'HEAD' ); + + if ( $is_head_request ) { + // The body stays empty, so the rows are not needed. + $prepared_args['fields'] = 'ids'; + } + + $query = new WP_Site_Query(); + $query_result = $query->query( $prepared_args ); + + $sites = array(); + + if ( ! $is_head_request ) { + foreach ( $query_result as $site ) { + $data = $this->prepare_item_for_response( $site, $request ); + $sites[] = $this->prepare_response_for_collection( $data ); + } + } + + $total_sites = $query->found_sites; + $max_pages = $query->max_num_pages; + + if ( $total_sites < 1 ) { + // Out-of-bounds, run the query again without LIMIT for total count. + unset( $prepared_args['number'], $prepared_args['offset'] ); + + $query = new WP_Site_Query(); + $prepared_args['count'] = true; + + $total_sites = $query->query( $prepared_args ); + $max_pages = (int) ceil( $total_sites / $request['per_page'] ); + } + + $response = $is_head_request ? new WP_REST_Response( array() ) : rest_ensure_response( $sites ); + $response->header( 'X-WP-Total', (string) $total_sites ); + $response->header( 'X-WP-TotalPages', (string) $max_pages ); + + $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); + + if ( $request['page'] > 1 ) { + $prev_page = $request['page'] - 1; + + if ( $prev_page > $max_pages ) { + $prev_page = $max_pages; + } + + $prev_link = add_query_arg( 'page', $prev_page, $base ); + $response->link_header( 'prev', $prev_link ); + } + + if ( $max_pages > $request['page'] ) { + $next_page = $request['page'] + 1; + $next_link = add_query_arg( 'page', $next_page, $base ); + + $response->link_header( 'next', $next_link ); + } + + return $response; + } + + /** + * Get the site, if the ID is valid. + * + * @since 7.2.0 + * + * @param int $id Supplied ID. + * @return WP_Site|WP_Error Site object if ID is valid, WP_Error otherwise. + */ + protected function get_site( $id ) { + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + $error = new WP_Error( 'rest_site_invalid_id', __( 'Invalid site ID.' ), array( 'status' => 404 ) ); + if ( (int) $id <= 0 ) { + return $error; + } + + $id = (int) $id; + $site = get_site( $id ); + if ( empty( $site ) ) { + return $error; + } + + return $site; + } + + /** + * Retrieves the IDs of the sites a user is a member of. + * + * @since 7.2.0 + * + * @param int|string $user_id User ID. + * @return int[] Site IDs, empty when the user has none. + */ + public function get_user_site_ids( $user_id ) { + if ( ! is_numeric( $user_id ) || (int) $user_id <= 0 ) { + return array(); + } + + return array_map( 'intval', array_keys( get_blogs_of_user( (int) $user_id ) ) ); + } + + /** + * Checks if a given request has access to read the site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|bool True if the request has read access for the item, error object otherwise. + */ + public function get_item_permissions_check( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + if ( 0 === get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'manage_sites' ) ) { + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + return true; + } + + /** + * Retrieves a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, or error object on failure. + */ + public function get_item( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + $data = $this->prepare_item_for_response( $site, $request ); + $response = rest_ensure_response( $data ); + + return $response; + } + + /** + * Checks if a given request has access to create a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|bool True if the request has access to create items, error object otherwise. + */ + public function create_item_permissions_check( $request ) { + if ( 0 === get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + return current_user_can( 'create_sites' ); + } + + /** + * Creates a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, or error object on failure. + */ + public function create_item( $request ) { + + if ( ! empty( $request['id'] ) ) { + return new WP_Error( 'rest_site_exists', __( 'Cannot create existing site.' ), array( 'status' => 400 ) ); + } + + $prepared_site = $this->prepare_item_for_database( $request ); + if ( is_wp_error( $prepared_site ) ) { + return $prepared_site; + } + + /** + * Filters a site before it is inserted via the REST API. + * + * Allows modification of the site right before it is inserted via wp_insert_site(). + * Returning a WP_Error value from the filter will shortcircuit insertion and allow + * skipping further processing. + * + * @since 7.2.0 + * + * @param array|WP_Error $prepared_site The prepared site data for wp_insert_site(). + * @param WP_REST_Request $request Request used to insert the site. + */ + $prepared_site = apply_filters( 'rest_pre_insert_site', $prepared_site, $request ); + if ( is_wp_error( $prepared_site ) ) { + return $prepared_site; + } + + $site_id = wp_insert_site( $prepared_site ); + + if ( is_wp_error( $site_id ) ) { + $site_id->add_data( array( 'status' => 500 ) ); + + return $site_id; + } + + if ( ! $site_id ) { + return new WP_Error( 'rest_site_failed_create', __( 'Creating site failed.' ), array( 'status' => 500 ) ); + } + + $site = get_site( $site_id ); + + /** + * Fires after a site is created or updated via the REST API. + * + * @since 7.2.0 + * + * @param WP_Site $site Inserted or updated site object. + * @param WP_REST_Request $request Request object. + * @param bool $creating True when creating a site, false + * when updating. + */ + do_action( 'rest_insert_site', $site, $request, true ); + + $schema = $this->get_item_schema(); + + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], $site_id ); + + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $site, $request ); + + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + + $context = current_user_can( 'manage_sites' ) ? 'edit' : 'view'; + + $request->set_param( 'context', $context ); + + $response = $this->prepare_item_for_response( $site, $request ); + $response = rest_ensure_response( $response ); + + $response->set_status( 201 ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $site_id ) ) ); + + return $response; + } + + /** + * Checks if a given REST request has access to update a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|bool True if the request has access to update the item, error object otherwise. + */ + public function update_item_permissions_check( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + if ( 0 === get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + if ( ! $this->check_edit_permission( $site ) ) { + return new WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to edit this site.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + return true; + } + + /** + * Updates a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, or error object on failure. + */ + public function update_item( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + $id = (int) $site->blog_id; + + $prepared_args = $this->prepare_item_for_database( $request ); + + if ( is_wp_error( $prepared_args ) ) { + return $prepared_args; + } + + if ( ! empty( $prepared_args ) ) { + $result = wp_update_site( $id, $prepared_args ); + if ( is_wp_error( $result ) ) { + $result->add_data( array( 'status' => 500 ) ); + + return $result; + } + } + + $site = get_site( $id ); + + /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php */ + do_action( 'rest_insert_site', $site, $request, false ); + + $schema = $this->get_item_schema(); + + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + if ( function_exists( 'is_site_meta_supported' ) && ! is_site_meta_supported() ) { + + return new WP_Error( + 'reset_site_meta_not_supported', + /* translators: %s: database table name */ + sprintf( __( 'The %s table is not installed. Please run the network database upgrade.' ), $GLOBALS['wpdb']->blogmeta ), + array( 'status' => 500 ) + ); + } + + $meta_update = $this->meta->update_value( $request['meta'], $id ); + + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $site, $request ); + + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + + $request->set_param( 'context', 'edit' ); + + $response = $this->prepare_item_for_response( $site, $request ); + + return rest_ensure_response( $response ); + } + + /** + * Checks if a given request has access to delete a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|bool True if the request has access to delete the item, error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + if ( 0 === (int) get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + if ( ! current_user_can( 'delete_sites' ) ) { + return new WP_Error( 'rest_cannot_delete', __( 'Sorry, you are not allowed to delete this site.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + if ( get_main_site_id( (int) $site->site_id ) === (int) $site->blog_id ) { + return new WP_Error( 'rest_cannot_delete_main_site', __( 'Sorry, the main site of a network cannot be deleted.' ), array( 'status' => 403 ) ); + } + + return true; + } + + /** + * Deletes a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, or error object on failure. + */ + public function delete_item( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + if ( ! (bool) $request['force'] ) { + return new WP_Error( + 'rest_trash_not_supported', + /* translators: %s: force=true */ + sprintf( __( "Sites do not support trashing. Set '%s' to delete." ), 'force=true' ), + array( 'status' => 501 ) + ); + } + + $request->set_param( 'context', 'edit' ); + + $previous = $this->prepare_item_for_response( $site, $request ); + $result = wp_delete_site( $request['id'] ); + + $response = new WP_REST_Response(); + $response->set_data( + array( + 'deleted' => true, + 'previous' => $previous->get_data(), + ) + ); + + if ( is_wp_error( $result ) ) { + $result->add_data( array( 'status' => 500 ) ); + + return $result; + } + + /** + * Fires after a site is deleted via the REST API. + * + * @since 7.2.0 + * + * @param WP_Site $site The deleted site data. + * @param WP_REST_Response $response The response returned from the API. + * @param WP_REST_Request $request The request sent to the API. + */ + do_action( 'rest_delete_site', $site, $response, $request ); + + return $response; + } + + /** + * Prepares a single site output for response. + * + * @since 7.2.0 + * + * @param WP_Site $site Site object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $site, $request ) { + // A HEAD response carries no body, so nothing needs to be prepared. + if ( $request->is_method( 'HEAD' ) ) { + /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php */ + return apply_filters( 'rest_prepare_site', new WP_REST_Response( array() ), $site, $request ); + } + + $data = array( + 'id' => (int) $site->blog_id, + 'network' => (int) $site->site_id, + 'domain' => $site->domain, + 'path' => $site->path, + 'registered' => $this->prepare_date_response( $site->registered ), + 'registered_gmt' => $this->prepare_date_response( $site->registered, true ), + 'last_updated' => $this->prepare_date_response( $site->last_updated ), + 'last_updated_gmt' => $this->prepare_date_response( $site->last_updated, true ), + 'public' => (bool) $site->public, + 'archived' => (bool) $site->archived, + 'mature' => (bool) $site->mature, + 'spam' => (bool) $site->spam, + 'deleted' => (bool) $site->deleted, + 'lang_id' => (int) $site->lang_id, + ); + + $fields = $this->get_fields_for_response( $request ); + + // These four are not columns of the sites table. Reading one switches to the site. + if ( rest_is_field_included( 'blogname', $fields ) ) { + $data['blogname'] = $site->blogname; + } + + if ( rest_is_field_included( 'siteurl', $fields ) ) { + $data['siteurl'] = $site->siteurl; + } + + if ( rest_is_field_included( 'home', $fields ) ) { + $data['home'] = $site->home; + } + + if ( rest_is_field_included( 'post_count', $fields ) ) { + $data['post_count'] = (int) $site->post_count; + } + + $schema = $this->get_item_schema(); + + if ( ! empty( $schema['properties']['meta'] ) && rest_is_field_included( 'meta', $fields ) ) { + $data['meta'] = $this->meta->get_value( (int) $site->blog_id, $request ); + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + // Wrap the data in a response object. + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $response->add_links( $this->prepare_links( $site ) ); + } + + /** + * Filters a site returned from the API. + * + * Allows modification of the site right before it is returned. + * + * @since 7.2.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Site $site The original site object. + * @param WP_REST_Request $request Request used to generate the response. + */ + return apply_filters( 'rest_prepare_site', $response, $site, $request ); + } + + /** + * Checks a date against the site's timezone, wp_blogs stores GMT. + * + * @since 7.2.0 + * + * @param string $date_gmt The date as it is stored, in GMT. + * @param bool $gmt Optional. Whether to return the GMT date. Default false. + * @return string|null ISO8601/RFC3339 formatted date, null for an empty date. + */ + protected function prepare_date_response( $date_gmt, $gmt = false ) { + if ( empty( $date_gmt ) || '0000-00-00 00:00:00' === $date_gmt ) { + return null; + } + + if ( $gmt ) { + return mysql_to_rfc3339( $date_gmt ); + } + + return mysql_to_rfc3339( get_date_from_gmt( $date_gmt ) ); + } + + /** + * Prepares the links for the request. + * + * @since 7.2.0 + * + * @param WP_Site $site Site object. + * @return array Links for the given site. + */ + protected function prepare_links( $site ) { + $links = array( + 'self' => array( + 'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $site->blog_id ) ), + ), + 'collection' => array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ), + ); + + /** + * Filters the links for a site returned from the API. + * + * @since 7.2.0 + * + * @param array $links Links for the given site. + * @param WP_Site $site The site object. + */ + return apply_filters( 'rest_site_links', $links, $site ); + } + + /** + * Prepares a single site to be inserted into the database. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Request object. + * @return array|WP_Error Prepared site, otherwise WP_Error object. + */ + protected function prepare_item_for_database( $request ) { + $prepared_site = array(); + + // Schema defaults apply to POST only, so on an update anything left out is null. + foreach ( array( 'public', 'archived', 'mature', 'spam', 'deleted' ) as $status_field ) { + if ( isset( $request[ $status_field ] ) ) { + // The columns are TINYINT, the schema says boolean. + $prepared_site[ $status_field ] = (int) rest_sanitize_boolean( $request[ $status_field ] ); + } + } + + if ( isset( $request['lang_id'] ) ) { + $prepared_site['lang_id'] = (int) $request['lang_id']; + } + + /* + * Title, administrator and the initial options reach wp_initialize_site() + * through wp_insert_site(), so they only apply while the site is created. + */ + if ( WP_REST_Server::CREATABLE === $request->get_method() ) { + if ( isset( $request['title'] ) ) { + $prepared_site['title'] = $request['title']; + } + + if ( isset( $request['user_id'] ) ) { + if ( ! get_userdata( (int) $request['user_id'] ) ) { + return new WP_Error( 'rest_site_invalid_user_id', __( 'Invalid user ID.' ), array( 'status' => 400 ) ); + } + + $prepared_site['user_id'] = (int) $request['user_id']; + } + } + + if ( isset( $request['network'] ) ) { + if ( ! get_network( $request['network'] ) ) { + return new WP_Error( 'rest_network_id_invalid', __( 'Invalid network ID.' ), array( 'status' => 400 ) ); + } + $prepared_site['network_id'] = (int) $request['network']; + } + + if ( isset( $request['path'] ) ) { + $prepared_site['path'] = $request['path']; + } + + if ( isset( $request['domain'] ) ) { + $prepared_site['domain'] = $request['domain']; + } + + /** + * Filters a site after it is prepared for the database. + * + * Allows modification of the site right after it is prepared for the database. + * + * @since 7.2.0 + * + * @param array $prepared_site The prepared site data for `wp_insert_site`. + * @param WP_REST_Request $request The current request. + */ + return apply_filters( 'rest_preprocess_site', $prepared_site, $request ); + } + + /** + * Retrieves the site's schema, conforming to JSON Schema. + * + * @since 7.2.0 + * + * @return array + */ + public function get_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/schema#', + 'title' => 'site', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the object.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'network' => array( + 'description' => __( 'The site\'s network ID. Default is the current network ID.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'domain' => array( + 'description' => __( 'Site domain.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'default' => '', + ), + 'path' => array( + 'description' => __( 'Site path.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'default' => '/', + ), + 'registered' => array( + 'description' => __( 'When the site was registered, in the site\'s timezone.' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'registered_gmt' => array( + 'description' => __( 'When the site was registered, as GMT.' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'last_updated' => array( + 'description' => __( 'When the site was last updated, in the site\'s timezone.' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'last_updated_gmt' => array( + 'description' => __( 'When the site was last updated, as GMT.' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'public' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'Whether the site is public. Default true.' ), + 'type' => 'boolean', + 'default' => true, + ), + 'archived' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'Whether the site is archived. Default false.' ), + 'type' => 'boolean', + 'default' => false, + ), + 'mature' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'Whether the site is mature. Default false.' ), + 'type' => 'boolean', + 'default' => false, + ), + 'spam' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'Whether the site is spam. Default false.' ), + 'type' => 'boolean', + 'default' => false, + ), + 'deleted' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'Whether the site is deleted. Default false.' ), + 'type' => 'boolean', + 'default' => false, + ), + 'lang_id' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'The site\'s language ID. Currently unused. Default 0.' ), + 'type' => 'integer', + 'default' => 0, + ), + 'blogname' => array( + 'description' => __( 'Site name, stored in the blogname option.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'siteurl' => array( + 'description' => __( 'Site address, stored in the siteurl option.' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'home' => array( + 'description' => __( 'Home address, stored in the home option.' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'post_count' => array( + 'description' => __( 'Number of posts on the site.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'title' => array( + 'description' => __( 'Site title, set when the site is created. Default is the word "Site" followed by the site ID.' ), + 'type' => 'string', + 'context' => array(), + ), + 'user_id' => array( + 'description' => __( 'User ID of the site administrator, set when the site is created.' ), + 'type' => 'integer', + 'context' => array(), + ), + ), + ); + + $schema['properties']['meta'] = $this->meta->get_field_schema(); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Retrieves the query params for collections. + * + * @since 7.2.0 + * + * @return array Sites collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + $query_params['context']['default'] = 'view'; + + $query_params['domain'] = array( + 'description' => __( 'Limit result set to sites assigned to specific domain. ' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ); + + $query_params['domain_exclude'] = array( + 'description' => __( 'Ensure result set excludes sites assigned to specific domain. ' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ); + + $query_params['path'] = array( + 'description' => __( 'Limit result set to sites assigned to specific path. ' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ); + + $query_params['path_exclude'] = array( + 'description' => __( 'Ensure result set excludes sites assigned to specific path. ' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ); + + $query_params['exclude'] = array( + 'description' => __( 'Ensure result set excludes specific IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'default' => array(), + ); + + $query_params['include'] = array( + 'description' => __( 'Limit result set to specific IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'default' => array(), + ); + + $query_params['offset'] = array( + 'description' => __( 'Offset the result set by a specific number of items.' ), + 'type' => 'integer', + ); + + $query_params['order'] = array( + 'description' => __( 'Order sort attribute ascending or descending.' ), + 'type' => 'string', + 'default' => 'asc', + 'enum' => array( + 'asc', + 'desc', + ), + ); + + $query_params['orderby'] = array( + 'description' => __( 'Sort collection by object attribute.' ), + 'type' => 'string', + 'default' => 'id', + 'enum' => array( + 'id', + 'domain', + 'path', + 'network_id', + 'last_updated', + 'registered', + 'domain_length', + 'path_length', + 'site__in', + 'network__in', + ), + ); + $query_params['user'] = array( + 'description' => __( 'Limit result set to the sites a user is a member of. Accepts a user ID or "me".' ), + 'type' => 'string', + ); + + $status_descriptions = array( + 'public' => __( 'Limit result set to sites with a specific public status.' ), + 'archived' => __( 'Limit result set to sites with a specific archived status.' ), + 'mature' => __( 'Limit result set to sites with a specific mature status.' ), + 'spam' => __( 'Limit result set to sites with a specific spam status.' ), + 'deleted' => __( 'Limit result set to sites with a specific deleted status.' ), + ); + + foreach ( $status_descriptions as $status_param => $status_description ) { + // No default, an absent parameter must not filter the collection. + $query_params[ $status_param ] = array( + 'description' => $status_description, + 'type' => 'boolean', + ); + } + + $query_params['lang_id'] = array( + 'default' => array(), + 'description' => __( 'Limit result set to sites of specific language IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ); + + $query_params['lang_id_exclude'] = array( + 'default' => array(), + 'description' => __( 'Ensure result set excludes specific language IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ); + + $query_params['before'] = array( + 'description' => __( 'Limit response to sites registered before a given ISO8601 compliant date.' ), + 'type' => 'string', + 'format' => 'date-time', + ); + + $query_params['after'] = array( + 'description' => __( 'Limit response to sites registered after a given ISO8601 compliant date.' ), + 'type' => 'string', + 'format' => 'date-time', + ); + + $query_params['network'] = array( + 'default' => array(), + 'description' => __( 'Limit result set to sites of specific network IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ); + + $query_params['network_exclude'] = array( + 'default' => array(), + 'description' => __( 'Ensure result set excludes specific network IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ); + + /** + * Filter collection parameters for the sites controller. + * + * This filter registers the collection parameter, but does not map the + * collection parameter to an internal WP_Site_Query parameter. Use the + * `rest_site_query` filter to set WP_Site_Query parameters. + * + * @since 7.2.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_site_collection_params', $query_params ); + } + + /** + * Checks if a site can be edited or deleted. + * + * @since 7.2.0 + * + * @param object $site Site object. + * @return bool Whether the site can be edited or deleted. + */ + protected function check_edit_permission( $site ) { + if ( 0 === (int) get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return false; + } + + return current_user_can( 'manage_sites' ); + } +} diff --git a/src/wp-includes/rest-api/fields/class-wp-rest-site-meta-fields.php b/src/wp-includes/rest-api/fields/class-wp-rest-site-meta-fields.php new file mode 100644 index 0000000000000..c98f30a0ac8e3 --- /dev/null +++ b/src/wp-includes/rest-api/fields/class-wp-rest-site-meta-fields.php @@ -0,0 +1,54 @@ +user->create( + array( + 'role' => 'administrator', + 'user_login' => 'superadmin', + ) + ); + + update_site_option( 'site_admins', array( 'superadmin' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$superadmin_id ); + } + + public function set_up() { + parent::set_up(); + $this->endpoint = new WP_REST_Sites_Controller(); + } + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/sites', $routes ); + $this->assertCount( 2, $routes['/wp/v2/sites'] ); + $this->assertArrayHasKey( '/wp/v2/sites/(?P[\d]+)', $routes ); + $this->assertCount( 3, $routes['/wp/v2/sites/(?P[\d]+)'] ); + } + + public function test_context_param() { + wp_set_current_user( self::$superadmin_id ); + // Collection + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertEquals( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + // Single + $blog_id = self::factory()->blog->create(); + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertEquals( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + + public function test_get_items() { + wp_set_current_user( self::$superadmin_id ); + self::factory()->blog->create_many( 6 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $sites = $response->get_data(); + $this->assertCount( 7, $sites ); + } + + + public function test_get_item() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/nulla/' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $site = get_site( $blog_id ); + + $this->assertEquals( $blog_id, $data['id'] ); + $this->assertEquals( $site->domain, $data['domain'] ); + $this->assertEquals( '/nulla/', $data['path'] ); + $this->assertEquals( 1, $data['network'] ); + $this->assertEquals( 1, $data['public'] ); + } + + /** + * An unknown ID is a 404, not an empty site. + */ + public function test_get_item_invalid_id() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_site_invalid_id', $response, 404 ); + } + + public function test_create_item() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/tempor/' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( '/tempor/', $data['path'] ); + $this->assertEquals( WP_TESTS_DOMAIN, $data['domain'] ); + + $site = get_site( $data['id'] ); + + $this->assertNotNull( $site ); + $this->assertEquals( '/tempor/', $site->path ); + } + + public function test_update_item() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/eiusmod/' ) ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'path', '/incididunt/' ); + $request->set_param( 'mature', 1 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( '/incididunt/', $data['path'] ); + $this->assertEquals( 1, $data['mature'] ); + $this->assertEquals( '/incididunt/', get_site( $blog_id )->path ); + } + + public function test_delete_item() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/amet/' ) ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertTrue( $data['deleted'] ); + $this->assertEquals( $blog_id, $data['previous']['id'] ); + $this->assertNull( get_site( $blog_id ) ); + } + + /** + * Deleting a site drops its tables. + */ + public function test_delete_item_uninitializes_the_site() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/aliqua/' ) ); + + $this->assertTrue( wp_is_site_initialized( $blog_id ) ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertFalse( wp_is_site_initialized( $blog_id ) ); + } + + /** + * Sites have no trash, so deleting has to be explicit. + */ + public function test_delete_item_requires_force() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/consectetur/' ) ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501 ); + $this->assertNotNull( get_site( $blog_id ) ); + } + + /** + * The main site of a network holds the network together. + */ + public function test_delete_main_site_is_not_allowed() { + wp_set_current_user( self::$superadmin_id ); + + $main_site_id = get_main_site_id(); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $main_site_id ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_delete_main_site', $response, 403 ); + $this->assertNotNull( get_site( $main_site_id ) ); + } + + public function test_prepare_item() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/labore/' ) ); + $site = get_site( $blog_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'context', 'edit' ); + + $data = $this->endpoint->prepare_item_for_response( $site, $request )->get_data(); + + $this->assertEquals( (int) $site->blog_id, $data['id'] ); + $this->assertEquals( (int) $site->site_id, $data['network'] ); + $this->assertEquals( $site->domain, $data['domain'] ); + $this->assertEquals( $site->path, $data['path'] ); + $this->assertEquals( mysql_to_rfc3339( $site->registered ), $data['registered_gmt'] ); + $this->assertEquals( $site->blogname, $data['blogname'] ); + $this->assertEquals( $site->home, $data['home'] ); + $this->assertEquals( $site->siteurl, $data['siteurl'] ); + $this->assertIsBool( $data['public'] ); + $this->assertIsInt( $data['post_count'] ); + } + + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $expected = array( + 'id', + 'network', + 'domain', + 'path', + 'registered', + 'registered_gmt', + 'last_updated', + 'last_updated_gmt', + 'public', + 'archived', + 'mature', + 'spam', + 'deleted', + 'lang_id', + 'blogname', + 'siteurl', + 'home', + 'post_count', + 'title', + 'user_id', + 'meta', + ); + + $this->assertEqualSets( $expected, array_keys( $properties ) ); + $this->assertTrue( $properties['id']['readonly'] ); + $this->assertTrue( $properties['registered']['readonly'] ); + $this->assertTrue( $properties['blogname']['readonly'] ); + $this->assertEquals( 'boolean', $properties['public']['type'] ); + $this->assertEquals( 'string', $properties['domain']['type'] ); + + // Write-only, so they carry no context. + $this->assertSame( array(), $properties['title']['context'] ); + $this->assertSame( array(), $properties['user_id']['context'] ); + } + + /** + * The status flags are booleans, the dates are RFC3339 with a GMT counterpart. + */ + public function test_get_item_uses_the_schema_types() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/tempora/' ) ); + $site = get_site( $blog_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + foreach ( array( 'public', 'archived', 'mature', 'spam', 'deleted' ) as $flag ) { + $this->assertIsBool( $data[ $flag ], $flag ); + } + + $this->assertEquals( mysql_to_rfc3339( $site->registered ), $data['registered_gmt'] ); + $this->assertEquals( mysql_to_rfc3339( get_date_from_gmt( $site->registered ) ), $data['registered'] ); + $this->assertEquals( mysql_to_rfc3339( $site->last_updated ), $data['last_updated_gmt'] ); + } + + /** + * The collection can be narrowed down by status. + */ + public function test_get_items_filter_by_status() { + wp_set_current_user( self::$superadmin_id ); + + $archived = self::factory()->blog->create( array( 'path' => '/dolores/' ) ); + self::factory()->blog->create( array( 'path' => '/nemo/' ) ); + + wp_update_site( $archived, array( 'archived' => 1 ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'archived', true ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $data ); + $this->assertEquals( $archived, $data[0]['id'] ); + $this->assertEquals( 1, (int) $response->get_headers()['X-WP-Total'] ); + + $request->set_param( 'archived', false ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertNotContains( $archived, wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * Without the parameter the status does not narrow anything. + */ + public function test_get_items_without_status_filter_returns_every_site() { + wp_set_current_user( self::$superadmin_id ); + + $archived = self::factory()->blog->create( array( 'path' => '/officiis/' ) ); + + wp_update_site( $archived, array( 'archived' => 1 ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertContains( $archived, wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * The language IDs narrow the collection, in both directions. + */ + public function test_get_items_filter_by_lang_id() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/magni/' ) ); + + wp_update_site( $blog_id, array( 'lang_id' => 7 ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'lang_id', array( 7 ) ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 1, $data ); + $this->assertEquals( $blog_id, $data[0]['id'] ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'lang_id_exclude', array( 7 ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertNotContains( $blog_id, wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * Registration dates are GMT, so the boundaries are read as GMT. + */ + public function test_get_items_filter_by_registration_date() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/harum/' ) ); + + wp_update_site( $blog_id, array( 'registered' => '2019-06-01 12:00:00' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'before', '2019-07-01T00:00:00Z' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 1, $data ); + $this->assertEquals( $blog_id, $data[0]['id'] ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'after', '2019-07-01T00:00:00Z' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertNotContains( $blog_id, wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * A created site gets the title and the administrator that were asked for. + */ + public function test_create_item_sets_the_title_and_the_administrator() { + wp_set_current_user( self::$superadmin_id ); + + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/voluptas/' ); + $request->set_param( 'title', 'Voluptas' ); + $request->set_param( 'user_id', $user_id ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $blog_id = $response->get_data()['id']; + + switch_to_blog( $blog_id ); + $blogname = get_option( 'blogname' ); + $is_admin = user_can( $user_id, 'manage_options' ); + restore_current_blog(); + + $this->assertEquals( 'Voluptas', $blogname ); + $this->assertTrue( $is_admin ); + $this->assertTrue( is_user_member_of_blog( $user_id, $blog_id ) ); + } + + /** + * An unknown administrator is refused before the site is created. + */ + public function test_create_item_rejects_an_unknown_user_id() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/quisquam/' ); + $request->set_param( 'user_id', 99999 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'rest_site_invalid_user_id', $response->get_data()['code'] ); + + // The check runs before wp_insert_site(), so nothing was created. + $this->assertEquals( 0, get_blog_id_from_url( WP_TESTS_DOMAIN, '/quisquam/' ) ); + } + + /** + * Title and administrator belong to creation, an update does not accept them. + */ + public function test_update_item_does_not_accept_the_creation_fields() { + wp_set_current_user( self::$superadmin_id ); + + $routes = rest_get_server()->get_routes(); + $args = array(); + + foreach ( $routes['/wp/v2/sites/(?P[\d]+)'] as $handler ) { + if ( ! empty( $handler['methods']['PUT'] ) ) { + $args = $handler['args']; + } + } + + $this->assertArrayNotHasKey( 'title', $args ); + $this->assertArrayNotHasKey( 'user_id', $args ); + $this->assertArrayHasKey( 'domain', $args ); + } + + /** + * Filtering by user narrows the total, not just the current page. + */ + public function test_get_items_me_filter_reports_the_filtered_total() { + $blog_ids = self::factory()->blog->create_many( 3 ); + $user_id = self::factory()->user->create(); + + wp_set_current_user( $user_id ); + + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + + self::factory()->blog->create_many( 2 ); + + $expected = count( get_blogs_of_user( $user_id ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + + $response = rest_get_server()->dispatch( $request ); + $headers = $response->get_headers(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertLessThan( (int) get_sites( array( 'count' => true ) ), $expected ); + $this->assertCount( $expected, $response->get_data() ); + $this->assertEquals( $expected, (int) $headers['X-WP-Total'] ); + } + + /** + * A user without sites gets nothing, not everything. + */ + public function test_get_items_filter_user_without_sites() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create_many( 3 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', (string) REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $response = rest_get_server()->dispatch( $request ); + $headers = $response->get_headers(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 0, $response->get_data() ); + $this->assertEquals( 0, (int) $headers['X-WP-Total'] ); + } + + /** + * A site links to itself and to the collection. + */ + public function test_get_item_has_links() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/veniam/' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + $links = $response->get_links(); + + $this->assertArrayHasKey( 'self', $links ); + $this->assertArrayHasKey( 'collection', $links ); + $this->assertStringEndsWith( '/wp/v2/sites/' . $blog_id, $links['self'][0]['href'] ); + $this->assertStringEndsWith( '/wp/v2/sites', $links['collection'][0]['href'] ); + } + + /** + * Reading a site's options means switching to it, so avoid it when the + * fields that need it were not asked for. + */ + public function test_get_items_does_not_switch_blogs_for_table_columns() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create_many( 3 ); + + $switches = 0; + $counter = static function () use ( &$switches ) { + ++$switches; + }; + + add_action( 'switch_blog', $counter ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( '_fields', 'id,domain,path' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_action( 'switch_blog', $counter ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 0, $switches ); + + $site = $response->get_data()[0]; + + foreach ( array( 'blogname', 'siteurl', 'home', 'post_count', 'meta' ) as $field ) { + $this->assertArrayNotHasKey( $field, $site ); + } + + $this->assertArrayHasKey( 'domain', $site ); + } + + /** + * A HEAD request answers with the headers and an empty body. + */ + public function test_head_request_returns_no_body() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create_many( 2 ); + + $request = new WP_REST_Request( 'HEAD', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $headers = $response->get_headers(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + $this->assertEquals( (int) get_sites( array( 'count' => true ) ), (int) $headers['X-WP-Total'] ); + } + + /** + * A HEAD request on a single site answers with an empty body, and the + * fields that need a switch stay untouched. + */ + public function test_head_request_on_a_single_site_returns_no_body() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/quidem/' ) ); + + $switches = 0; + $counter = static function () use ( &$switches ) { + ++$switches; + }; + + add_action( 'switch_blog', $counter ); + + $request = new WP_REST_Request( 'HEAD', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + remove_action( 'switch_blog', $counter ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + $this->assertSame( array(), $response->get_links() ); + $this->assertSame( 0, $switches ); + } + + /** + * The collection is ordered by ID, ascending. + */ + public function test_get_items_are_ordered_ascending() { + wp_set_current_user( self::$superadmin_id ); + + $blog_ids = self::factory()->blog->create_many( 3 ); + array_unshift( $blog_ids, 1 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( $blog_ids, wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * Ordering by an ID list falls back when there is no list. + */ + public function test_get_items_orderby_id_list_without_a_list() { + wp_set_current_user( self::$superadmin_id ); + + foreach ( array( 'site__in', 'network__in' ) as $orderby ) { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'orderby', $orderby ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status(), $orderby ); + $this->assertNotEmpty( $response->get_data(), $orderby ); + } + } + + /** + * The data is stored as sent, without added slashes. + */ + public function test_update_item_does_not_slash_the_stored_data() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/sit/' ) ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'path', "/o'brien/" ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( "/o'brien/", get_site( $blog_id )->path ); + } + + /** + * The status fields are stored when a site is created. + */ + public function test_create_item_stores_the_status_fields() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/dolor/' ); + $request->set_param( 'public', 0 ); + $request->set_param( 'archived', 1 ); + $request->set_param( 'lang_id', 7 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $data = $response->get_data(); + $site = get_site( $data['id'] ); + + $this->assertEquals( 0, $site->public ); + $this->assertEquals( 1, $site->archived ); + $this->assertEquals( 7, $site->lang_id ); + } + + /** + * A partial update must not touch fields the request left out. + */ + public function test_update_item_keeps_fields_that_were_not_sent() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/lorem/' ) ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'archived', 1 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( 1, $data['archived'] ); + $this->assertEquals( '/lorem/', $data['path'] ); + $this->assertEquals( '/lorem/', get_site( $blog_id )->path ); + } + + /** + * The domain is left alone when the request does not carry one. + */ + public function test_update_item_keeps_the_domain() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/ipsum/' ) ); + $domain = get_site( $blog_id )->domain; + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'public', 0 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( $domain, get_site( $blog_id )->domain ); + $this->assertEquals( 0, get_site( $blog_id )->public ); + } + + /** + * Site meta is exposed through the endpoint. + * + * Registering under the `blog` meta type is what `add_site_meta()` and + * `get_site_meta()` do, so the controller has to read the same type. + */ + public function test_get_item_exposes_site_meta() { + if ( ! is_site_meta_supported() ) { + $this->markTestSkipped( 'Site meta is not supported on this installation.' ); + } + + wp_set_current_user( self::$superadmin_id ); + + register_meta( + 'blog', + 'rest_test_site_meta', + array( + 'type' => 'string', + 'single' => true, + 'show_in_rest' => true, + ) + ); + + $blog_id = self::factory()->blog->create(); + update_site_meta( $blog_id, 'rest_test_site_meta', 'from blogmeta' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'meta', $data ); + $this->assertArrayHasKey( 'rest_test_site_meta', $data['meta'] ); + $this->assertEquals( 'from blogmeta', $data['meta']['rest_test_site_meta'] ); + + unregister_meta_key( 'blog', 'rest_test_site_meta' ); + } + + public function test_invalid_user_input() { + $this->assertEquals( array(), $this->endpoint->get_user_site_ids( false ) ); + $this->assertEquals( array(), $this->endpoint->get_user_site_ids( 0 ) ); + $this->assertEquals( array(), $this->endpoint->get_user_site_ids( '' ) ); + $this->assertEquals( array(), $this->endpoint->get_user_site_ids( REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); + $this->assertEquals( array(), $this->endpoint->get_user_site_ids( 999 ) ); + } + + public function test_valid_user_input() { + + $blog_ids = self::factory()->blog->create_many( 5 ); + $user_id = self::factory()->user->create(); + array_unshift( $blog_ids, 1 ); + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + + $this->assertEquals( $blog_ids, $this->endpoint->get_user_site_ids( $user_id ) ); + } + + public function test_get_items_filter_user() { + wp_set_current_user( self::$superadmin_id ); + $blog_ids = self::factory()->blog->create_many( 5 ); + $user_id = self::factory()->user->create(); + + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + array_unshift( $blog_ids, 1 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', (string) $user_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $sites = $response->get_data(); + $this->assertCount( 6, $sites ); + $this->assertEquals( $blog_ids, wp_list_pluck( $sites, 'id' ) ); + } + + public function test_get_items_me_filter_user() { + + $blog_ids = self::factory()->blog->create_many( 5 ); + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + array_unshift( $blog_ids, 1 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $sites = $response->get_data(); + $this->assertCount( 6, $sites ); + $this->assertEquals( $blog_ids, wp_list_pluck( $sites, 'id' ) ); + } + + public function test_get_items_filter_user_no_access() { + + $blog_ids = self::factory()->blog->create_many( 5 ); + $user_id = self::factory()->user->create(); + $user_id2 = self::factory()->user->create(); + wp_set_current_user( $user_id2 ); + + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + array_unshift( $blog_ids, 1 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', (string) $user_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 403, $response->get_status() ); + } + + public function test_get_items_filter_with_includes_user() { + wp_set_current_user( self::$superadmin_id ); + $blog_ids = self::factory()->blog->create_many( 5 ); + $user_id = self::factory()->user->create(); + + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', (string) $user_id ); + $request->set_param( 'include', $blog_ids[0] ); + $response = rest_get_server()->dispatch( $request ); + //$this->assertEquals( 200, $response->get_status() ); + $sites = $response->get_data(); + $this->assertCount( 1, $sites ); + $this->assertEquals( array( $blog_ids[0] ), wp_list_pluck( $sites, 'id' ) ); + } +}