<?php
/**
* REST API functionality
*
* @package distributor
*/
namespace Distributor\RestApi;
use Distributor\DistributorPost;
use Distributor\Utils;
use WP_Error;
/**
* Setup actions and filters
*
* @since 1.0
*/
function setup() {
add_action(
'init',
function() {
add_action( 'rest_api_init', __NAMESPACE__ . '\register_endpoints' );
add_action( 'rest_api_init', __NAMESPACE__ . '\register_rest_routes' );
add_action( 'rest_api_init', __NAMESPACE__ . '\register_push_errors_field' );
$post_types = get_post_types(
array(
'show_in_rest' => true,
)
);
foreach ( $post_types as $post_type ) {
add_action( "rest_insert_{$post_type}", __NAMESPACE__ . '\process_distributor_attributes', 10, 3 );
add_filter( "rest_pre_insert_{$post_type}", __NAMESPACE__ . '\filter_distributor_content', 1, 2 );
add_filter( "rest_prepare_{$post_type}", __NAMESPACE__ . '\prepare_distributor_content', 10, 3 );
}
},
100
);
}
/**
* Filter the data inserted by the REST API when a post is pushed.
*
* Use the raw content for Gutenberg->Gutenberg posts. Note: `distributor_raw_content`
* is only sent when the origin supports Gutenberg.
*
* @param Object $prepared_post An object representing a single post prepared.
* @param \WP_REST_Request $request Request object.
*
* @return Object $prepared_post The filtered post object.
*/
function filter_distributor_content( $prepared_post, $request ) {
if (
isset( $request['distributor_raw_content'] ) &&
\Distributor\Utils\dt_use_block_editor_for_post_type( $prepared_post->post_type )
) {
$prepared_post->post_content = $request['distributor_raw_content'];
}
return $prepared_post;
}
/**
* When an API push is being received, handle Distributor specific attributes
*
* @param \WP_Post $post Post object.
* @param \WP_REST_Request $request Request object.
* @param bool $update Update or create.
* @since 1.0
*/
function process_distributor_attributes( $post, $request, $update ) {
if ( empty( $post ) || is_wp_error( $post ) ) {
return;
}
/**
* Not Distributor push so ignore it. Other things use the REST API besides Distributor.
*/
if ( empty( $request['distributor_original_source_id'] ) ) {
return;
}
if ( ! empty( $request['distributor_remote_post_id'] ) ) {
update_post_meta( $post->ID, 'dt_original_post_id', (int) $request['distributor_remote_post_id'] );
}
if ( ! empty( $request['distributor_original_site_name'] ) ) {
update_post_meta( $post->ID, 'dt_original_site_name', sanitize_text_field( $request['distributor_original_site_name'] ) );
}
if ( ! empty( $request['distributor_original_site_url'] ) ) {
update_post_meta( $post->ID, 'dt_original_site_url', sanitize_text_field( $request['distributor_original_site_url'] ) );
}
if ( ! empty( $request['distributor_original_post_url'] ) ) {
update_post_meta( $post->ID, 'dt_original_post_url', esc_url_raw( $request['distributor_original_post_url'] ) );
}
if ( ! empty( $request['distributor_signature'] ) ) {
update_post_meta( $post->ID, 'dt_subscription_signature', sanitize_text_field( $request['distributor_signature'] ) );
}
update_post_meta( $post->ID, 'dt_syndicate_time', time() );
update_post_meta( $post->ID, 'dt_full_connection', true );
update_post_meta( $post->ID, 'dt_original_source_id', (int) $request['distributor_original_source_id'] );
if ( isset( $request['distributor_meta'] ) ) {
\Distributor\Utils\set_meta( $post->ID, $request['distributor_meta'] );
}
if ( isset( $request['distributor_terms'] ) ) {
\Distributor\Utils\set_taxonomy_terms( $post->ID, $request['distributor_terms'] );
}
if ( isset( $request['distributor_media'] ) ) {
\Distributor\Utils\set_media( $post->ID, $request['distributor_media'] );
} else {
// Remove any previously set featured image.
delete_post_meta( $post->ID, '_thumbnail_id' );
}
/**
* Fires after an API push is handled by Distributor.
*
* @since 1.0
* @hook dt_process_distributor_attributes
*
* @param {WP_Post} $post Inserted or updated post object.
* @param {WP_REST_Request} $request Request object.
* @param {bool} $update True when creating a post, false when updating.
*/
do_action( 'dt_process_distributor_attributes', $post, $request, $update );
}
/**
* Register custom routes to handle distributor specific functionality.
*/
function register_rest_routes() {
register_rest_route(
'wp/v2',
'distributor/post-types-permissions',
array(
'methods' => 'GET',
'callback' => __NAMESPACE__ . '\check_post_types_permissions',
'permission_callback' => '__return_true',
)
);
register_rest_route(
'wp/v2',
'distributor/list-pull-content',
array(
'methods' => 'POST',
'callback' => __NAMESPACE__ . '\\get_pull_content_list',
'permission_callback' => __NAMESPACE__ . '\\get_pull_content_permissions',
'args' => get_pull_content_list_args(),
)
);
}
/**
* Set the accepted arguments for the pull content list endpoint.
*
* @since 2.0.0 Introduced the include, order and orderby arguments.
*
* @return array
*/
function get_pull_content_list_args() {
return array(
// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude
'exclude' => array(
'description' => esc_html__( 'Ensure result set excludes specific IDs.', 'distributor' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
),
'include' => array(
'description' => esc_html__( 'Ensure result set includes specific IDs.', 'distributor' ),
'type' => array( 'array', 'integer' ),
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => function( $param ) {
if ( ! is_array( $param ) ) {
$param = array( $param );
}
return wp_parse_id_list( $param );
},
),
'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,
),
'posts_per_page' => array(
'description' => esc_html__( 'Maximum number of items to be returned in result set.', 'distributor' ),
'type' => 'integer',
'default' => 20,
'minimum' => 1,
'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' => array( 'array', 'string' ),
'items' => array(
'type' => 'string',
),
'default' => array( 'post' ),
'validate_callback' => function( $param ) {
if ( is_string( $param ) ) {
return sanitize_key( $param ) === $param;
}
foreach ( $param as $post_type ) {
if ( sanitize_key( $post_type ) !== $post_type ) {
return false;
}
}
return true;
},
'sanitize_callback' => function( $param ) {
if ( is_string( $param ) ) {
$param = array( $param );
}
$allowed_post_types = array_keys(
get_post_types(
array(
'show_in_rest' => true,
)
)
);
/*
* Only post types viewable on the front end should be allowed.
*
* Some post types may be visible in the REST API but not intended
* to be viewed on the front end. This removes any such posts from the
* list of allowed post types.
*
* `is_post_type_viewable()` is used to filter the results as
* WordPress applies different rules for custom and built in post
* types to determine whether they are viewable on the front end.
*/
$allowed_post_types = array_filter( $allowed_post_types, 'is_post_type_viewable' );
if ( in_array( 'any', $param, true ) ) {
$param = $allowed_post_types;
} else {
$param = array_intersect( $param, $allowed_post_types );
}
$param = array_filter(
$param,
function( $post_type ) {
$post_type_object = get_post_type_object( $post_type );
return current_user_can( $post_type_object->cap->edit_posts );
}
);
if ( empty( $param ) ) {
// This will cause the parameter to fall back to the default.
$param = null;
}
return $param;
},
),
'search' => array(
'description' => esc_html__( 'Limit results to those matching a string.', 'distributor' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
),
'post_status' => array(
'default' => array( 'publish' ),
'description' => esc_html__( 'Limit result set to content assigned one or more statuses.', 'distributor' ),
'type' => array( 'array', 'string' ),
'items' => array(
'type' => 'string',
),
'validate_callback' => function( $param ) {
if ( is_string( $param ) ) {
return sanitize_key( $param ) === $param;
}
foreach ( $param as $post_status ) {
if ( sanitize_key( $post_status ) !== $post_status ) {
return false;
}
}
return true;
},
'sanitize_callback' => function( $param ) {
if ( is_string( $param ) ) {
$param = array( $param );
}
/*
* Only show viewable post statues.
*
* `is_post_status_viewable()` is used to filter the results as
* WordPress applies a complex set of rules to determine if a post
* status is viewable.
*/
$allowed_statues = array_keys( array_filter( get_post_stati(), 'is_post_status_viewable' ) );
if ( in_array( 'any', $param, true ) ) {
return $allowed_statues;
}
$param = array_intersect( $param, $allowed_statues );
if ( empty( $param ) ) {
// This will cause the parameter to fall back to the default.
$param = null;
}
return $param;
},
),
'order' => array(
'description' => esc_html__( 'Order sort attribute ascending or descending.', 'distributor' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
),
'orderby' => array(
'description' => esc_html__( 'Sort collection by object attribute.', 'distributor' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'author',
'date',
'id',
'include',
'modified',
'parent',
'relevance',
'slug',
'title',
),
),
);
}
/**
* Check if the current user has permission to pull content.
*
* Checks whether the user can pull content for the specified post type.
*
* @since 1.9.1
*
* @param \WP_REST_Request $request Full details about the request.
* @return bool Whether the current user has permission to pull content.
*/
function get_pull_content_permissions( $request ) {
/*
* Ensure Distributor requests are coming from a supported version.
*
* Changes to this endpoint in Distributor 2.0.0 require both the source and remote
* sites use a 2.x release of Distributor. This check ensures that the remote site
* is running a version of Distributor that supports the new endpoint.
*
* Development versions of the plugin and Non-Distributor requests are allowed
* to pass through this check.
*/
if (
false === Utils\is_development_version()
&& null !== $request->get_param( 'distributor_request' )
&& (
null === $request->get_header( 'X-Distributor-Version' )
|| version_compare( $request->get_header( 'X-Distributor-Version' ), '2.0.0', '<' )
)
) {
return new \WP_Error(
'distributor_pull_content_permissions',
esc_html__( 'Pulling content from external connections requires Distributor version 2.0.0 or later.', 'distributor' ),
array( 'status' => 403 )
);
}
$post_types = $request->get_param( 'post_type' );
if ( empty( $post_types ) ) {
return false;
}
if ( is_string( $post_types ) ) {
$post_types = array( $post_types );
}
foreach ( $post_types as $post_type ) {
$post_type_object = get_post_type_object( $post_type );
if ( ! $post_type_object ) {
return false;
}
if ( ! current_user_can( $post_type_object->cap->edit_posts ) ) {
return false;
}
}
// User can edit all post types.
return true;
}
/**
* Filter the data requested over REST API when a post is pulled.
*
* @param \WP_REST_Response $response Response object.
* @param \WP_Post $post Post object.
* @param \WP_REST_Request $request Request object.
*
* @return \WP_REST_Response $response The filtered response object.
*/
function prepare_distributor_content( $response, $post, $request ) {
// Only adjust distributor requests.
if ( '1' !== $request->get_param( 'distributor_request' ) ) {
return $response;
}
$post_data = $response->get_data();
// Is the local site is running Gutenberg?
if ( \Distributor\Utils\is_using_gutenberg( $post ) ) {
$post_data['is_using_gutenberg'] = true;
}
$response->set_data( $post_data );
return $response;
}
/**
* We need to register distributor post fields for getting all the meta, terms, and media. This
* is easier than modifying existing fields which other plugins may depend on.
*
* @since 1.0
*/
function register_endpoints() {
$post_types = get_post_types(
array(
'show_in_rest' => true,
)
);
register_rest_field(
$post_types,
'distributor_meta',
array(
'get_callback' => function( $post_array ) {
if ( ! isset( $post_array['id'] ) ) {
return false;
}
if ( ! current_user_can( 'edit_post', $post_array['id'] ) ) {
return false;
}
return \Distributor\Utils\prepare_meta( $post_array['id'] );
},
'update_callback' => function( $value, $post ) { },
'schema' => array(
'description' => esc_html__( 'Post meta for Distributor.', 'distributor' ),
'type' => 'object',
),
)
);
register_rest_field(
$post_types,
'distributor_terms',
array(
'get_callback' => function( $post_array ) {
if ( ! isset( $post_array['id'] ) ) {
return false;
}
if ( ! current_user_can( 'edit_post', $post_array['id'] ) ) {
return false;
}
return \Distributor\Utils\prepare_taxonomy_terms( $post_array['id'] );
},
'update_callback' => function( $value, $post ) { },
'schema' => array(
'description' => esc_html__( 'Taxonomy terms for Distributor.', 'distributor' ),
'type' => 'object',
),
)
);
register_rest_field(
$post_types,
'distributor_media',
array(
'get_callback' => function( $post_array ) {
if ( ! isset( $post_array['id'] ) ) {
return false;
}
if ( ! current_user_can( 'edit_post', $post_array['id'] ) ) {
return false;
}
return \Distributor\Utils\prepare_media( $post_array['id'] );
},
'update_callback' => function( $value, $post ) { },
'schema' => array(
'description' => esc_html__( 'Media for Distributor.', 'distributor' ),
'type' => 'object',
),
)
);
register_rest_field(
$post_types,
'distributor_original_site_name',
array(
'get_callback' => function( $post_array ) {
$site_name = isset( $post_array['id'] ) ? get_post_meta( $post_array['id'], 'dt_original_site_name', true ) : '';
if ( ! $site_name ) {
$site_name = get_bloginfo( 'name' );
}
return esc_html( $site_name );
},
'update_callback' => function( $value, $post ) { },
'schema' => array(
'description' => esc_html__( 'Original site name for Distributor.', 'distributor' ),
'type' => 'string',
),
)
);
register_rest_field(
$post_types,
'distributor_original_site_url',
array(
'get_callback' => function( $post_array ) {
$site_url = isset( $post_array['id'] ) ? get_post_meta( $post_array['id'], 'dt_original_site_url', true ) : '';
if ( ! $site_url ) {
$site_url = home_url();
}
return esc_url_raw( $site_url );
},
'update_callback' => function( $value, $post ) { },
'schema' => array(
'description' => esc_html__( 'Original site url for Distributor.', 'distributor' ),
'type' => 'string',
),
)
);
// Register a distributor meta endpoint
register_rest_route(
'wp/v2',
'/dt_meta',
array(
'methods' => 'GET',
'callback' => __NAMESPACE__ . '\distributor_meta',
'permission_callback' => '__return_true',
)
);
}
/**
* Return plugin meta information.
*/
function distributor_meta() {
return array(
'version' => DT_VERSION,
'core_has_application_passwords' => true,
'core_application_passwords_available' => ! wp_is_application_passwords_available() ? false : true,
'core_application_passwords_endpoint' => admin_url( 'authorize-application.php' ),
);
}
/**
* Check user permissions for available post types
*/
function check_post_types_permissions() {
$types = Utils\distributable_post_types( 'objects' );
$response = array(
'can_get' => array(),
'can_post' => array(),
'is_authenticated' => get_current_user_id() ? 'yes' : 'no',
);
foreach ( $types as $type ) {
$caps = $type->cap;
if ( current_user_can( $caps->edit_posts ) ) {
$response['can_get'][] = $type->name;
}
if ( current_user_can( $caps->edit_posts ) && current_user_can( $caps->create_posts ) && current_user_can( $caps->publish_posts ) ) {
$response['can_post'][] = $type->name;
}
}
return $response;
}
/**
* Get a list of content to show on the Pull screen
*
* @since 2.0.0 Renamed from get_pull_content() to get_pull_content_list().
*
* @param \WP_Rest_Request $request API request arguments
* @return \WP_REST_Response|\WP_Error
*/
function get_pull_content_list( $request ) {
$post_type = ! empty( $request['post_type'] ) ? $request['post_type'] : array( 'post' );
$args = [
'posts_per_page' => isset( $request['posts_per_page'] ) ? $request['posts_per_page'] : 20,
'paged' => isset( $request['page'] ) ? $request['page'] : 1,
'post_type' => $post_type,
'post_status' => isset( $request['post_status'] ) ? $request['post_status'] : array( 'any' ),
'order' => ! empty( $request['order'] ) ? strtoupper( $request['order'] ) : 'DESC',
];
if ( ! empty( $request['search'] ) ) {
$args['s'] = rawurldecode( $request['search'] );
$args['orderby'] = 'relevance';
}
if ( ! empty( $request['exclude'] ) && ! empty( $request['include'] ) ) {
/*
* Use only `post__in` if both `include` and `exclude` are populated.
*
* Excluded posts take priority over included posts, if the same post is
* included in both arrays, it will be excluded.
*/
$args['post__in'] = array_diff( $request['include'], $request['exclude'] );
} elseif ( ! empty( $request['exclude'] ) ) {
$args['post__not_in'] = $request['exclude'];
} elseif ( ! empty( $request['include'] ) ) {
$args['post__in'] = $request['include'];
}
if ( ! empty( $request['orderby'] ) ) {
$args['orderby'] = $request['orderby'];
if ( 'id' === $request['orderby'] ) {
// Flip the case to uppercase for WP_Query.
$args['orderby'] = 'ID';
} elseif ( 'slug' === $request['orderby'] ) {
$args['orderby'] = 'name';
} elseif ( 'relevance' === $request['orderby'] ) {
$args['orderby'] = 'relevance';
// If ordering by relevance, a search term must be defined.
if ( empty( $request['search'] ) ) {
return new WP_Error(
'rest_no_search_term_defined',
__( 'You need to define a search term to order by relevance.', 'distributor' ),
array( 'status' => 400 )
);
}
} elseif ( 'include' === $request['orderby'] ) {
$args['orderby'] = 'post__in';
}
}
/**
* Filters WP_Query arguments when querying posts via the REST API.
*
* Enables adding extra arguments or setting defaults for a post collection request.
*
* @hook dt_get_pull_content_rest_query_args
*
* @param {array} $args Array of arguments for WP_Query.
* @param {WP_REST_Request} $request The REST API request.
*
* @return {array} The array of arguments for WP_Query.
*/
$args = apply_filters( 'dt_get_pull_content_rest_query_args', $args, $request );
// Only get posts that are editable by the user.
$args['perm'] = 'editable';
$query = new \WP_Query( $args );
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 ( ! current_user_can( 'edit_post', $post->ID ) ) {
continue;
}
$dt_post = new DistributorPost( $post->ID );
$formatted_posts[] = $dt_post->to_pull_list();
}
$response = rest_ensure_response( $formatted_posts );
$response->header( 'X-WP-Total', (int) $total_posts );
$response->header( 'X-WP-TotalPages', (int) $max_pages );
return $response;
}
/**
* Get a list of content to show on the Pull screen
*
* @since 2.0.0 Deprecated in favour of get_pull_content_list().
*
* @param array ...$args Arguments.
* @return \WP_REST_Response|\WP_Error
*/
function get_pull_content( ...$args ) {
_deprecated_function( __FUNCTION__, '2.0.0', __NAMESPACE__ . '\\get_pull_content_list' );
return get_pull_content_list( ...$args );
}
/**
* 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 );
}
}
/*
* When 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.
*/
function register_push_errors_field() {
$post_types = get_post_types(
array(
'show_in_rest' => true,
)
);
foreach ( $post_types as $post_type ) {
register_rest_field(
$post_type,
'push-errors',
array(
'get_callback' => function( $params ) {
$media_errors = isset( $params['id'] ) ? get_transient( 'dt_media_errors_' . $params['id'] ) : '';
if ( ! empty( $media_errors ) ) {
delete_transient( 'dt_media_errors_' . $params['id'] );
return $media_errors;
}
return false;
},
)
);
}
}