From f5c02815500284407adfeb4e89c29c67735730e2 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 15 Oct 2021 16:58:08 -0600 Subject: [PATCH 1/5] Add a custom endpoint to use for building the Pull screen for external connections. When making a request for new content with previously pulled or skipped items and when using an external connection, use this new endpoint via a POST request --- .../WordPressExternalConnection.php | 121 ++++++++++-- includes/classes/PullListTable.php | 15 +- includes/rest-api.php | 187 ++++++++++++++++++ 3 files changed, 299 insertions(+), 24 deletions(-) diff --git a/includes/classes/ExternalConnections/WordPressExternalConnection.php b/includes/classes/ExternalConnections/WordPressExternalConnection.php index 4a1514645..cb44d6cd5 100644 --- a/includes/classes/ExternalConnections/WordPressExternalConnection.php +++ b/includes/classes/ExternalConnections/WordPressExternalConnection.php @@ -165,6 +165,33 @@ public function remote_get( $args = array() ) { } } + /** + * Filter the remote_get query arguments + * + * @since 1.0 + * @hook dt_remote_get_query_args + * + * @param {array} $query_args The existing query arguments. + * @param {array} $args The arguments originally passed to `remote_get`. + * @param {object} $this The authentication class. + * + * @return {array} The existing query arguments. + */ + $query_args = apply_filters( 'dt_remote_get_query_args', $query_args, $args, $this ); + + // When running a query for the Pull screen with excluded items, make a POST request instead + if ( empty( $id ) && isset( $args['post__not_in'] ) && isset( $args['dt_pull_list'] ) ) { + $query_args['post_type'] = isset( $post_type ) ? $post_type : 'post'; + $query_args['per_page'] = isset( $posts_per_page ) ? $posts_per_page : 20; + + $posts_response = $this->remote_post( + untrailingslashit( $this->base_url ) . '/' . self::$namespace . '/distributor/list-pull-content', + $query_args + ); + + return $posts_response; + } + static $types_urls; $types_urls = array(); @@ -230,20 +257,6 @@ public function remote_get( $args = array() ) { $args_str .= 'per_page=' . (int) $posts_per_page; } - /** - * Filter the remote_get query arguments - * - * @since 1.0 - * @hook dt_remote_get_query_args - * - * @param {array} $query_args The existing query arguments. - * @param {array} $args The arguments originally passed to `remote_get`. - * @param {object} $this The authentication class. - * - * @return {array} The existing query arguments. - */ - $query_args = apply_filters( 'dt_remote_get_query_args', $query_args, $args, $this ); - foreach ( $query_args as $arg_key => $arg_value ) { if ( is_array( $arg_value ) ) { foreach ( $arg_value as $arg_value_value ) { @@ -370,6 +383,86 @@ public function remote_get( $args = array() ) { } } + /** + * Make a remote_post request. + * + * @param string $url Endpoint URL. + * @param array $args Query arguments + * @return array|\WP_Error + */ + public function remote_post( $url = '', $args = array() ) { + if ( ! $url ) { + return new \WP_Error( 'endpoint-error', esc_html__( 'Endpoint URL must be set', 'distributor' ) ); + } + + $request = wp_remote_post( + $url, + $this->auth_handler->format_post_args( + array( + /** + * Filter the timeout used when calling `remote_post` + * + * @hook dt_remote_post_timeout + * + * @param int $timeout The timeout to use for the remote post. Default `45`. + * @param array $args The request arguments + * + * @return int The timeout to use for the remote_post call. + */ + 'timeout' => apply_filters( 'dt_remote_post_timeout', 45, $args ), + 'body' => $args, + ) + ) + ); + + if ( is_wp_error( $request ) ) { + return $request; + } + + $response_code = wp_remote_retrieve_response_code( $request ); + + if ( 200 !== $response_code ) { + if ( 404 === $response_code ) { + return new \WP_Error( 'bad-endpoint', esc_html__( 'Could not connect to API endpoint.', 'distributor' ) ); + } + + $posts_body = json_decode( wp_remote_retrieve_body( $request ), true ); + + $code = empty( $posts_body['code'] ) ? 'endpoint-error' : esc_html( $posts_body['code'] ); + $message = empty( $posts_body['message'] ) ? esc_html__( 'API endpoint error.', 'distributor' ) : esc_html( $posts_body['message'] ); + + return new \WP_Error( $code, $message ); + } + + $posts_body = wp_remote_retrieve_body( $request ); + $response_headers = wp_remote_retrieve_headers( $request ); + + if ( empty( $posts_body ) ) { + return new \WP_Error( 'no-response-body', esc_html__( 'Response body is empty', 'distributor' ) ); + } + + $posts = json_decode( $posts_body, true ); + $formatted_posts = array(); + + foreach ( $posts as $post ) { + $post['full_connection'] = ! empty( $response_headers['X-Distributor'] ); + + $formatted_posts[] = $this->to_wp_post( $post ); + } + + $total_posts = ! empty( $response_headers['X-WP-Total'] ) ? $response_headers['X-WP-Total'] : count( $formatted_posts ); + + return apply_filters( + 'dt_remote_post', + [ + 'items' => $formatted_posts, + 'total_items' => $total_posts, + ], + $args, + $this + ); + } + /** * Pull items. Pass array of posts, each post should look like: * [ 'remote_post_id' => POST ID TO GET, 'post_id' (optional) => POST ID TO MAP TO ] diff --git a/includes/classes/PullListTable.php b/includes/classes/PullListTable.php index fca4b8af4..26f099017 100644 --- a/includes/classes/PullListTable.php +++ b/includes/classes/PullListTable.php @@ -441,8 +441,7 @@ public function prepare_items() { 'posts_per_page' => $per_page, 'paged' => $current_page, 'post_type' => $post_type, - 'orderby' => 'ID', // this is because of include/exclude truncation - 'order' => 'DESC', // default but specifying to be safe + 'dt_pull_list' => true, // custom argument used to only run code on this screen ]; if ( ! empty( $_GET['s'] ) ) { // @codingStandardsIgnoreLine Nonce isn't required. @@ -478,15 +477,11 @@ public function prepare_items() { } if ( empty( $_GET['status'] ) || 'new' === $_GET['status'] ) { // @codingStandardsIgnoreLine Nonce not required. - // Sort from highest ID (newest) to low so the slice only affects later pagination. - rsort( $skipped, SORT_NUMERIC ); - rsort( $syndicated, SORT_NUMERIC ); + $post_ids = array_merge( $skipped, $syndicated ); - // This is somewhat arbitrarily set to 200 and should probably be made filterable eventually. - // IDs can get rather large and 400 easily exceeds typical header size limits. - $post_ids = array_slice( array_merge( $skipped, $syndicated ), 0, 200, true ); - - $remote_get_args['post__not_in'] = $post_ids; + if ( ! empty( $post_ids ) ) { + $remote_get_args['post__not_in'] = $post_ids; + } $remote_get_args['meta_query'] = [ [ diff --git a/includes/rest-api.php b/includes/rest-api.php index 8e8389342..9795d6f24 100644 --- a/includes/rest-api.php +++ b/includes/rest-api.php @@ -143,6 +143,74 @@ function register_rest_routes() { 'permission_callback' => '__return_true', ) ); + + register_rest_route( + 'wp/v2', + 'distributor/list-pull-content', + array( + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\get_pull_content', + 'permission_callback' => '__return_true', + 'args' => get_pull_content_list_args(), + ) + ); +} + +/** + * Set the accepted arguments for the pull content list endpoint + * + * @return array + */ +function get_pull_content_list_args() { + return array( + 'exclude' => array( + 'description' => esc_html__( 'Ensure result set excludes specific IDs.', 'distributor' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'default' => array(), + ), + 'page' => array( + 'description' => esc_html__( 'Current page of the collection.', 'distributor' ), + 'type' => 'integer', + 'default' => 1, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + 'minimum' => 1, + ), + 'per_page' => array( + 'description' => esc_html__( 'Maximum number of items to be returned in result set.', 'distributor' ), + 'type' => 'integer', + 'default' => 10, + 'minimum' => 1, + 'maximum' => 100, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + ), + 'post_type' => array( + 'description' => esc_html__( 'Limit results to content matching a certain type.', 'distributor' ), + 'type' => 'string', + 'default' => 'post', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'rest_validate_request_arg', + ), + 'search' => array( + 'description' => esc_html__( 'Limit results to those matching a string.', 'distributor' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'rest_validate_request_arg', + ), + 'post_status' => array( + 'default' => 'publish', + 'description' => esc_html__( 'Limit result set to content assigned one or more statuses.', 'distributor' ), + 'type' => 'array', + 'items' => array( + 'enum' => array_merge( array_keys( get_post_stati() ), array( 'any' ) ), + 'type' => 'string', + ), + ), + ); } /** @@ -338,6 +406,125 @@ function check_post_types_permissions() { return $response; } +/** + * Get a list of content to show on the Pull screen + * + * @param \WP_Rest_Request $request API request arguments + * @return \WP_REST_Response|\WP_Error + */ +function get_pull_content( $request ) { + $args = [ + 'posts_per_page' => isset( $request['per_page'] ) ? $request['per_page'] : 10, + 'paged' => isset( $request['page'] ) ? $request['page'] : 1, + 'post_type' => isset( $request['post_type'] ) ? $request['post_type'] : 'post', + 'post_status' => isset( $request['post_status'] ) ? $request['post_status'] : array( 'any' ), + ]; + + if ( ! empty( $request['search'] ) ) { + $args['s'] = urldecode( $request['search'] ); + } + + if ( ! empty( $request['exclude'] ) ) { + $args['post__not_in'] = $request['exclude']; + } + + // Filter documented in includes/classes/ExternalConnections/WordPressExternalConnection.php + $query = new \WP_Query( apply_filters( 'dt_remote_get_query_args', $args, $request ) ); + + if ( empty( $query->posts ) ) { + return rest_ensure_response( array() ); + } + + $page = (int) $args['paged']; + $total_posts = $query->found_posts; + + $max_pages = ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ); + + if ( $page > $max_pages && $total_posts > 0 ) { + return new \WP_Error( + 'rest_post_invalid_page_number', + esc_html__( 'The page number requested is larger than the number of pages available.', 'distributor' ), + array( 'status' => 400 ) + ); + } + + $formatted_posts = array(); + foreach ( $query->posts as $post ) { + if ( ! check_read_permission( $post ) ) { + continue; + } + + $formatted_posts[] = array( + 'id' => $post->ID, + 'title' => array( 'rendered' => $post->post_title ), + 'excerpt' => array( 'rendered' => $post->post_excerpt ), + 'content' => array( 'raw' => $post->post_content ), + 'password' => $post->post_password, + 'date' => $post->post_date, + 'date_gmt' => $post->post_date_gmt, + 'guid' => array( 'rendered' => $post->guid ), + 'modified' => $post->post_modified, + 'modified_gmt' => $post->post_modified_gmt, + 'type' => $post->post_type, + 'link' => get_the_permalink( $post ), + 'comment_status' => $post->comment_status, + 'ping_status' => $post->ping_status, + ); + } + + $response = rest_ensure_response( $formatted_posts ); + + $response->header( 'X-WP-Total', (int) $total_posts ); + $response->header( 'X-WP-TotalPages', (int) $max_pages ); + + return $response; +} + +/** + * Checks if a post can be read. + * + * Copied from WordPress core. + * + * @param \WP_Post $post Post object. + * @return bool + */ +function check_read_permission( $post ) { + // Validate the post type. + $post_type = \get_post_type_object( $post->post_type ); + + if ( empty( $post_type ) || empty( $post_type->show_in_rest ) ) { + 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 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; +} + /** * Register push errors field so we can send errors over the REST API. */ From cbc512007fd1a0ac532b31034a245b338a8a5639 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 18 Oct 2021 15:26:48 -0600 Subject: [PATCH 2/5] Require a user to be logged in to access our custom endpoint --- includes/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest-api.php b/includes/rest-api.php index 9795d6f24..f139b5f45 100644 --- a/includes/rest-api.php +++ b/includes/rest-api.php @@ -150,7 +150,7 @@ function register_rest_routes() { array( 'methods' => 'POST', 'callback' => __NAMESPACE__ . '\get_pull_content', - 'permission_callback' => '__return_true', + 'permission_callback' => 'is_user_logged_in', 'args' => get_pull_content_list_args(), ) ); From 125406ab6eae8da2d8f86351c172bb0079148c52 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 18 Oct 2021 16:02:57 -0600 Subject: [PATCH 3/5] Remove sanitize callback on the search input --- includes/rest-api.php | 1 - 1 file changed, 1 deletion(-) diff --git a/includes/rest-api.php b/includes/rest-api.php index f139b5f45..72839f6db 100644 --- a/includes/rest-api.php +++ b/includes/rest-api.php @@ -198,7 +198,6 @@ function get_pull_content_list_args() { 'search' => array( 'description' => esc_html__( 'Limit results to those matching a string.', 'distributor' ), 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), 'post_status' => array( From 7b3c323e811871a0831ff20162538324fd77ac5f Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 29 Oct 2021 13:12:33 -0600 Subject: [PATCH 4/5] CR feedback --- .../WordPressExternalConnection.php | 12 ++++++++++++ includes/rest-api.php | 3 +-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/includes/classes/ExternalConnections/WordPressExternalConnection.php b/includes/classes/ExternalConnections/WordPressExternalConnection.php index cb44d6cd5..939a985de 100644 --- a/includes/classes/ExternalConnections/WordPressExternalConnection.php +++ b/includes/classes/ExternalConnections/WordPressExternalConnection.php @@ -452,6 +452,18 @@ public function remote_post( $url = '', $args = array() ) { $total_posts = ! empty( $response_headers['X-WP-Total'] ) ? $response_headers['X-WP-Total'] : count( $formatted_posts ); + /** + * Filter the items returned when using `WordPressExternalConnection::remote_post` + * + * @since 1.6.7 + * @hook dt_remote_post + * + * @param {array} $items The items returned from the POST request. + * @param {array} $args The arguments used in the POST request. + * @param {WordPressExternalConnection} $this The current connection object. + * + * @return {array} The items returned from a remote POST request. + */ return apply_filters( 'dt_remote_post', [ diff --git a/includes/rest-api.php b/includes/rest-api.php index 72839f6db..fbf3d4642 100644 --- a/includes/rest-api.php +++ b/includes/rest-api.php @@ -427,8 +427,7 @@ function get_pull_content( $request ) { $args['post__not_in'] = $request['exclude']; } - // Filter documented in includes/classes/ExternalConnections/WordPressExternalConnection.php - $query = new \WP_Query( apply_filters( 'dt_remote_get_query_args', $args, $request ) ); + $query = new \WP_Query( $args, $request ); if ( empty( $query->posts ) ) { return rest_ensure_response( array() ); From def546f6fff264624e1dfc0f36d3e5c65ef87509 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 29 Oct 2021 13:20:08 -0600 Subject: [PATCH 5/5] Add new filter around the remote post query args --- .../WordPressExternalConnection.php | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/includes/classes/ExternalConnections/WordPressExternalConnection.php b/includes/classes/ExternalConnections/WordPressExternalConnection.php index 939a985de..24b698cc9 100644 --- a/includes/classes/ExternalConnections/WordPressExternalConnection.php +++ b/includes/classes/ExternalConnections/WordPressExternalConnection.php @@ -165,20 +165,6 @@ public function remote_get( $args = array() ) { } } - /** - * Filter the remote_get query arguments - * - * @since 1.0 - * @hook dt_remote_get_query_args - * - * @param {array} $query_args The existing query arguments. - * @param {array} $args The arguments originally passed to `remote_get`. - * @param {object} $this The authentication class. - * - * @return {array} The existing query arguments. - */ - $query_args = apply_filters( 'dt_remote_get_query_args', $query_args, $args, $this ); - // When running a query for the Pull screen with excluded items, make a POST request instead if ( empty( $id ) && isset( $args['post__not_in'] ) && isset( $args['dt_pull_list'] ) ) { $query_args['post_type'] = isset( $post_type ) ? $post_type : 'post'; @@ -257,6 +243,20 @@ public function remote_get( $args = array() ) { $args_str .= 'per_page=' . (int) $posts_per_page; } + /** + * Filter the remote_get query arguments + * + * @since 1.0 + * @hook dt_remote_get_query_args + * + * @param {array} $query_args The existing query arguments. + * @param {array} $args The arguments originally passed to `remote_get`. + * @param {object} $this The authentication class. + * + * @return {array} The existing query arguments. + */ + $query_args = apply_filters( 'dt_remote_get_query_args', $query_args, $args, $this ); + foreach ( $query_args as $arg_key => $arg_value ) { if ( is_array( $arg_value ) ) { foreach ( $arg_value as $arg_value_value ) { @@ -402,6 +402,7 @@ public function remote_post( $url = '', $args = array() ) { /** * Filter the timeout used when calling `remote_post` * + * @since 1.6.7 * @hook dt_remote_post_timeout * * @param int $timeout The timeout to use for the remote post. Default `45`. @@ -410,7 +411,18 @@ public function remote_post( $url = '', $args = array() ) { * @return int The timeout to use for the remote_post call. */ 'timeout' => apply_filters( 'dt_remote_post_timeout', 45, $args ), - 'body' => $args, + /** + * Filter the remote_post query arguments + * + * @since 1.6.7 + * @hook dt_remote_post_query_args + * + * @param {array} $args The request arguments. + * @param {WordPressExternalConnection} $this The current connection object. + * + * @return {array} The query arguments. + */ + 'body' => apply_filters( 'dt_remote_post_query_args', $args, $this ), ) ) );