diff options
Diffstat (limited to 'plugins/jetpack/_inc/lib/core-api')
10 files changed, 1311 insertions, 2 deletions
diff --git a/plugins/jetpack/_inc/lib/core-api/class-wpcom-rest-field-controller.php b/plugins/jetpack/_inc/lib/core-api/class-wpcom-rest-field-controller.php new file mode 100644 index 00000000..e599b275 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/class-wpcom-rest-field-controller.php @@ -0,0 +1,330 @@ +<?php + +// @todo - nicer API for array values? + +/** + * `WP_REST_Controller` is basically a wrapper for `register_rest_route()` + * `WPCOM_REST_API_V2_Field_Controller` is a mostly-analogous wrapper for `register_rest_field()` + */ +abstract class WPCOM_REST_API_V2_Field_Controller { + /** + * @var string|string[] $object_type The REST Object Type(s) to which the field should be added. + */ + protected $object_type; + + /** + * @var string $field_name The name of the REST API field to add. + */ + protected $field_name; + + public function __construct() { + if ( ! $this->object_type ) { + /* translators: %s: object_type */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::$object_type', sprintf( __( "Property '%s' must be overridden.", 'jetpack' ), 'object_type' ), 'Jetpack 6.8' ); + return; + } + + if ( ! $this->field_name ) { + /* translators: %s: field_name */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::$field_name', sprintf( __( "Property '%s' must be overridden.", 'jetpack' ), 'field_name' ), 'Jetpack 6.8' ); + return; + } + + add_action( 'rest_api_init', array( $this, 'register_fields' ) ); + } + + /** + * Registers the field with the appropriate schema and callbacks. + */ + public function register_fields() { + foreach ( (array) $this->object_type as $object_type ) { + register_rest_field( + $object_type, + $this->field_name, + array( + 'get_callback' => array( $this, 'get_for_response' ), + 'update_callback' => array( $this, 'update_from_request' ), + 'schema' => $this->get_schema(), + ) + ); + } + } + + /** + * Ensures the response matches the schema and request context. + * + * @param mixed $value + * @param WP_REST_Request $request + * @return mixed + */ + private function prepare_for_response( $value, $request ) { + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $schema = $this->get_schema(); + + $is_valid = rest_validate_value_from_schema( $value, $schema, $this->field_name ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + + return $this->filter_response_by_context( $value, $schema, $context ); + } + + /** + * Returns the schema's default value + * + * If there is no default, returns the type's falsey value. + * + * @param array $schema + * @return mixed + */ + final public function get_default_value( $schema ) { + if ( isset( $schema['default'] ) ) { + return $schema['default']; + } + + // If you have something more complicated, use $schema['default']; + switch ( isset( $schema['type'] ) ? $schema['type'] : 'null' ) { + case 'string': + return ''; + case 'integer': + case 'number': + return 0; + case 'object': + return (object) array(); + case 'array': + return array(); + case 'boolean': + return false; + case 'null': + default: + return null; + } + } + + /** + * The field's wrapped getter. Does permission checks and output preparation. + * + * This cannot be extended: implement `->get()` instead. + * + * @param mixed $object_data Probably an array. Whatever the endpoint returns. + * @param string $field_name Should always match `->field_name` + * @param WP_REST_Request $request + * @param string $object_type Should always match `->object_type` + * @return mixed + */ + final public function get_for_response( $object_data, $field_name, $request, $object_type ) { + $permission_check = $this->get_permission_check( $object_data, $request ); + + if ( ! $permission_check ) { + /* translators: %s: get_permission_check() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::get_permission_check', sprintf( __( "Method '%s' must return either true or WP_Error.", 'jetpack' ), 'get_permission_check' ), 'Jetpack 6.8' ); + return $this->get_default_value( $this->get_schema() ); + } + + if ( is_wp_error( $permission_check ) ) { + return $this->get_default_value( $this->get_schema() ); + } + + $value = $this->get( $object_data, $request ); + + return $this->prepare_for_response( $value, $request ); + } + + /** + * The field's wrapped setter. Does permission checks. + * + * This cannot be extended: implement `->update()` instead. + * + * @param mixed $value The new value for the field. + * @param mixed $object_data Probably a WordPress object (e.g., WP_Post) + * @param string $field_name Should always match `->field_name` + * @param WP_REST_Request $request + * @param string $object_type Should always match `->object_type` + * @return void|WP_Error + */ + final public function update_from_request( $value, $object_data, $field_name, $request, $object_type ) { + $permission_check = $this->update_permission_check( $value, $object_data, $request ); + + if ( ! $permission_check ) { + /* translators: %s: update_permission_check() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::update_permission_check', sprintf( __( "Method '%s' must return either true or WP_Error.", 'jetpack' ), 'update_permission_check' ), 'Jetpack 6.8' ); + /* translators: %s: the name of an API response field */ + return new WP_Error( 'invalid_user_permission', sprintf( __( "You are not allowed to access the '%s' field.", 'jetpack' ), $this->field_name ) ); + } + + if ( is_wp_error( $permission_check ) ) { + return $permission_check; + } + + $updated = $this->update( $value, $object_data, $request ); + + if ( is_wp_error( $updated ) ) { + return $updated; + } + } + + /** + * Permission Check for the field's getter. Must be implemented in the inheriting class. + * + * @param mixed $object_data Whatever the endpoint would return for its response. + * @param WP_REST_Request $request + * @return true|WP_Error + */ + public function get_permission_check( $object_data, $request ) { + /* translators: %s: get_permission_check() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::get_permission_check', sprintf( __( "Method '%s' must be overridden.", 'jetpack' ), __METHOD__ ), 'Jetpack 6.8' ); + } + + /** + * The field's "raw" getter. Must be implemented in the inheriting class. + * + * @param mixed $object_data Whatever the endpoint would return for its response. + * @param WP_REST_Request $request + * @return mixed + */ + public function get( $object_data, $request ) { + /* translators: %s: get() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::get', sprintf( __( "Method '%s' must be overridden.", 'jetpack' ), __METHOD__ ), 'Jetpack 6.8' ); + } + + /** + * Permission Check for the field's setter. Must be implemented in the inheriting class. + * + * @param mixed $value The new value for the field. + * @param mixed $object_data Probably a WordPress object (e.g., WP_Post) + * @param WP_REST_Request $request + * @return true|WP_Error + */ + public function update_permission_check( $value, $object_data, $request ) { + /* translators: %s: update_permission_check() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::update_permission_check', sprintf( __( "Method '%s' must be overridden.", 'jetpack' ), __METHOD__ ), 'Jetpack 6.8' ); + } + + /** + * The field's "raw" setter. Must be implemented in the inheriting class. + * + * @param mixed $value The new value for the field. + * @param mixed $object_data Probably a WordPress object (e.g., WP_Post) + * @param WP_REST_Request $request + * @return mixed + */ + public function update( $value, $object_data, $request ) { + /* translators: %s: update() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::update', sprintf( __( "Method '%s' must be overridden.", 'jetpack' ), __METHOD__ ), 'Jetpack 6.8' ); + } + + /** + * The JSON Schema for the field + * + * @link https://json-schema.org/understanding-json-schema/ + * As of WordPress 5.0, Core currently understands: + * * type + * * string - not minLength, not maxLength, not pattern + * * integer - minimum, maximum, exclusiveMinimum, exclusiveMaximum, not multipleOf + * * number - minimum, maximum, exclusiveMinimum, exclusiveMaximum, not multipleOf + * * boolean + * * null + * * object - properties, additionalProperties, not propertyNames, not dependencies, not patternProperties, not required + * * array: only lists, not tuples - items, not minItems, not maxItems, not uniqueItems, not contains + * * enum + * * format + * * date-time + * * email + * * ip + * * uri + * As of WordPress 5.0, Core does not support: + * * Multiple type: `type: [ 'string', 'integer' ]` + * * $ref, allOf, anyOf, oneOf, not, const + * + * @return array + */ + public function get_schema() { + /* translators: %s: get_schema() */ + _doing_it_wrong( 'WPCOM_REST_API_V2_Field_Controller::get_schema', sprintf( __( "Method '%s' must be overridden.", 'jetpack' ), __METHOD__ ), 'Jetpack 6.8' ); + } + + /** + * @param array $schema + * @param string $context REST API Request context + * @return bool + */ + private function is_valid_for_context( $schema, $context ) { + return empty( $schema['context'] ) || in_array( $context, $schema['context'], true ); + } + + /** + * Removes properties that should not appear in the current + * request's context + * + * $context is a Core REST API Framework request attribute that is + * always one of: + * * view (what you see on the blog) + * * edit (what you see in an editor) + * * embed (what you see in, e.g., an oembed) + * + * Fields (and sub-fields, and sub-sub-...) can be flagged for a + * set of specific contexts via the field's schema. + * + * The Core API will filter out top-level fields with the wrong + * context, but will not recurse deeply enough into arrays/objects + * to remove all levels of sub-fields with the wrong context. + * + * This function handles that recursion. + * + * @param mixed $value + * @param array $schema + * @param string $context REST API Request context + * @return mixed Filtered $value + */ + final public function filter_response_by_context( $value, $schema, $context ) { + if ( ! $this->is_valid_for_context( $schema, $context ) ) { + // We use this intentionally odd looking WP_Error object + // internally only in this recursive function (see below + // in the `object` case). It will never be output by the REST API. + // If we return this for the top level object, Core + // correctly remove the top level object from the response + // for us. + return new WP_Error( '__wrong-context__' ); + } + + switch ( $schema['type'] ) { + case 'array': + if ( ! isset( $schema['items'] ) ) { + return $value; + } + + // Shortcircuit if we know none of the items are valid for this context. + // This would only happen in a strangely written schema. + if ( ! $this->is_valid_for_context( $schema['items'], $context ) ) { + return array(); + } + + // Recurse to prune sub-properties of each item. + foreach ( $value as $key => $item ) { + $value[ $key ] = $this->filter_response_by_context( $item, $schema['items'], $context ); + } + + return $value; + case 'object': + if ( ! isset( $schema['properties'] ) ) { + return $value; + } + + foreach ( $value as $field_name => $field_value ) { + if ( isset( $schema['properties'][ $field_name ] ) ) { + $field_value = $this->filter_response_by_context( $field_value, $schema['properties'][ $field_name ], $context ); + if ( is_wp_error( $field_value ) && '__wrong-context__' === $field_value->get_error_code() ) { + unset( $value[ $field_name ] ); + } else { + // Respect recursion that pruned sub-properties of each property. + $value[ $field_name ] = $field_value; + } + } + } + + return (object) $value; + } + + return $value; + } +} diff --git a/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-module-endpoints.php b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-module-endpoints.php index ac912269..45d10d14 100644 --- a/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-module-endpoints.php +++ b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-module-endpoints.php @@ -746,8 +746,14 @@ class Jetpack_Core_API_Data extends Jetpack_Core_API_XMLRPC_Consumer_Endpoint { case 'bing': case 'pinterest': case 'yandex': - $grouped_options = $grouped_options_current = (array) get_option( 'verification_services_codes' ); - $grouped_options[$option] = $value; + $grouped_options = $grouped_options_current = (array) get_option( 'verification_services_codes' ); + + // Extracts the content attribute from the HTML meta tag if needed + if ( preg_match( '#.*<meta name="(?:[^"]+)" content="([^"]+)" />.*#i', $value, $matches ) ) { + $grouped_options[ $option ] = $matches[1]; + } else { + $grouped_options[ $option ] = $value; + } // If option value was the same, consider it done. $updated = $grouped_options_current != $grouped_options ? update_option( 'verification_services_codes', $grouped_options ) : true; diff --git a/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-site-endpoints.php b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-site-endpoints.php index 68327f51..c8fba69c 100644 --- a/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-site-endpoints.php +++ b/plugins/jetpack/_inc/lib/core-api/class.jetpack-core-api-site-endpoints.php @@ -48,6 +48,69 @@ class Jetpack_Core_API_Site_Endpoint { } /** + * Returns the result of `/sites/%s/posts/%d/related` endpoint call. + * Results are not cached and are retrieved in real time. + * + * @since 6.7.0 + * + * @param int ID of the post to get related posts of + * + * @return array + */ + public static function get_related_posts( $api_request ) { + $params = $api_request->get_params(); + $post_id = ! empty( $params['post_id'] ) ? absint( $params['post_id'] ) : 0; + + if ( ! $post_id ) { + return new WP_Error( + 'incorrect_post_id', + esc_html__( 'You need to specify a correct ID of a post to return related posts for.', 'jetpack' ), + array( 'status' => 400 ) + ); + } + + // Make the API request + $request = sprintf( '/sites/%d/posts/%d/related', Jetpack_Options::get_option( 'id' ), $post_id ); + $request_args = array( + 'headers' => array( + 'Content-Type' => 'application/json', + ), + 'timeout' => 10, + 'method' => 'POST', + ); + $response = Jetpack_Client::wpcom_json_api_request_as_blog( $request, '1.1', $request_args ); + + // Bail if there was an error or malformed response + if ( is_wp_error( $response ) || ! is_array( $response ) || ! isset( $response['body'] ) ) { + return new WP_Error( + 'failed_to_fetch_data', + esc_html__( 'Unable to fetch the requested data.', 'jetpack' ), + array( 'status' => 400 ) + ); + } + + // Decode the results + $results = json_decode( wp_remote_retrieve_body( $response ), true ); + + $related_posts = array(); + if ( isset( $results['hits'] ) && is_array( $results['hits'] ) ) { + $related_posts_ids = array_map( array( 'Jetpack_Core_API_Site_Endpoint', 'get_related_post_id' ), $results['hits'] ); + + $related_posts_instance = Jetpack_RelatedPosts::init(); + foreach ( $related_posts_ids as $related_post_id ) { + $related_posts[] = $related_posts_instance->get_related_post_data_for_post( $related_post_id, 0, 0 ); + } + } + + return rest_ensure_response( array( + 'code' => 'success', + 'message' => esc_html__( 'Related posts retrieved successfully.', 'jetpack' ), + 'posts' => $related_posts, + ) + ); + } + + /** * Check that the current user has permissions to request information about this site. * * @since 5.1.0 @@ -57,4 +120,16 @@ class Jetpack_Core_API_Site_Endpoint { public static function can_request() { return current_user_can( 'jetpack_manage_modules' ); } + + /** + * Returns the post ID out of a related post entry from the + * `/sites/%s/posts/%d/related` WP.com endpoint. + * + * @since 6.7.0 + * + * @return int + */ + public static function get_related_post_id( $item ) { + return $item['fields']['post_id']; + } } diff --git a/plugins/jetpack/_inc/lib/core-api/load-wpcom-endpoints.php b/plugins/jetpack/_inc/lib/core-api/load-wpcom-endpoints.php new file mode 100644 index 00000000..2b26f78c --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/load-wpcom-endpoints.php @@ -0,0 +1,40 @@ +<?php + +/* + * Loader for WP REST API endpoints that are synced with WP.com. + * + * On WP.com see: + * - wp-content/mu-plugins/rest-api.php + * - wp-content/rest-api-plugins/jetpack-endpoints/ + */ + +function wpcom_rest_api_v2_load_plugin_files( $file_pattern ) { + $plugins = glob( dirname( __FILE__ ) . '/' . $file_pattern ); + + if ( ! is_array( $plugins ) ) { + return; + } + + foreach ( array_filter( $plugins, 'is_file' ) as $plugin ) { + require_once $plugin; + } +} + +// API v2 plugins: define a class, then call this function. +function wpcom_rest_api_v2_load_plugin( $class_name ) { + global $wpcom_rest_api_v2_plugins; + + if ( ! isset( $wpcom_rest_api_v2_plugins ) ) { + $_GLOBALS['wpcom_rest_api_v2_plugins'] = $wpcom_rest_api_v2_plugins = array(); + } + + if ( ! isset( $wpcom_rest_api_v2_plugins[ $class_name ] ) ) { + $wpcom_rest_api_v2_plugins[ $class_name ] = new $class_name; + } +} + +require dirname( __FILE__ ) . '/class-wpcom-rest-field-controller.php'; + +// Now load the endpoint files. +wpcom_rest_api_v2_load_plugin_files( 'wpcom-endpoints/*.php' ); +wpcom_rest_api_v2_load_plugin_files( 'wpcom-fields/*.php' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/hello.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/hello.php new file mode 100644 index 00000000..a05769b2 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/hello.php @@ -0,0 +1,22 @@ +<?php + +class WPCOM_REST_API_V2_Endpoint_Hello { + public function __construct() { + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + public function register_routes() { + register_rest_route( 'wpcom/v2', '/hello', array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_data' ), + ), + ) ); + } + + public function get_data( $request ) { + return array( 'hello' => 'world' ); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Hello' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connection-test-results.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connection-test-results.php new file mode 100644 index 00000000..86019880 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connection-test-results.php @@ -0,0 +1,117 @@ +<?php + +require_once dirname( __FILE__ ) . '/publicize-connections.php'; + +/** + * Publicize: List Connection Test Result Data + * + * All the same data as the Publicize Connections Endpoint, plus test results. + * + * @since 6.8 + */ +class WPCOM_REST_API_V2_Endpoint_List_Publicize_Connection_Test_Results extends WPCOM_REST_API_V2_Endpoint_List_Publicize_Connections { + public function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'publicize/connection-test-results'; + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Called automatically on `rest_api_init()`. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permission_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Adds the test results properties to the Connection schema. + * + * @return array + */ + public function get_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'jetpack-publicize-connection-test-results', + 'type' => 'object', + 'properties' => $this->get_connection_schema_properties() + array( + 'test_success' => array( + 'description' => __( 'Did the Publicize Connection test pass?', 'jetpack' ), + 'type' => 'boolean', + ), + 'test_message' => array( + 'description' => __( 'Publicize Connection success or error message', 'jetpack' ), + 'type' => 'string', + ), + 'can_refresh' => array( + 'description' => __( 'Can the current user refresh the Publicize Connection?', 'jetpack' ), + 'type' => 'boolean', + ), + 'refresh_text' => array( + 'description' => __( 'Message instructing the user to refresh their Connection to the Publicize Service', 'jetpack' ), + 'type' => 'string', + ), + 'refresh_url' => array( + 'description' => __( 'URL for refreshing the Connection to the Publicize Service', 'jetpack' ), + 'type' => 'string', + 'format' => 'uri', + ), + ), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * @param WP_REST_Request + * @see Publicize::get_publicize_conns_test_results() + * @return WP_REST_Response suitable for 1-page collection + */ + public function get_items( $request ) { + global $publicize; + + $items = $this->get_connections(); + + $test_results = $publicize->get_publicize_conns_test_results(); + $test_results_by_unique_id = array(); + foreach ( $test_results as $test_result ) { + $test_results_by_unique_id[ $test_result['unique_id'] ] = $test_result; + } + + $mapping = array( + 'test_success' => 'connectionTestPassed', + 'test_message' => 'connectionTestMessage', + 'can_refresh' => 'userCanRefresh', + 'refresh_text' => 'refreshText', + 'refresh_url' => 'refreshURL', + ); + + foreach ( $items as &$item ) { + $test_result = $test_results_by_unique_id[ $item['id'] ]; + + foreach ( $mapping as $field => $test_result_field ) { + $item[ $field ] = $test_result[ $test_result_field ]; + } + } + + $response = rest_ensure_response( $items ); + + $response->header( 'X-WP-Total', count( $items ) ); + $response->header( 'X-WP-TotalPages', 1 ); + + return $response; + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_List_Publicize_Connection_Test_Results' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connections.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connections.php new file mode 100644 index 00000000..f7e9b351 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-connections.php @@ -0,0 +1,186 @@ +<?php + +/** + * Publicize: List Connections + * + * [ + * { # Connnection Object. See schema for more detail. + * id: (string) Connection unique_id + * service_name: (string) Service slug + * display_name: (string) User name/display name of user/connection on Service + * global: (boolean) Is the Connection available to all users of the site? + * }, + * ... + * ] + * + * @since 6.8 + */ +class WPCOM_REST_API_V2_Endpoint_List_Publicize_Connections extends WP_REST_Controller { + /** + * Flag to help WordPress.com decide where it should look for + * Publicize data. Ignored for direct requests to Jetpack sites. + * + * @var bool $wpcom_is_wpcom_only_endpoint + */ + public $wpcom_is_wpcom_only_endpoint = true; + + public function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'publicize/connections'; + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Called automatically on `rest_api_init()`. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permission_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Helper for generating schema. Used by this endpoint and by the + * Connection Test Result endpoint. + * + * @internal + * @return array + */ + protected function get_connection_schema_properties() { + return array( + 'id' => array( + 'description' => __( 'Unique identifier for the Publicize Connection', 'jetpack' ), + 'type' => 'string', + ), + 'service_name' => array( + 'description' => __( 'Alphanumeric identifier for the Publicize Service', 'jetpack' ), + 'type' => 'string', + ), + 'display_name' => array( + 'description' => __( 'Username of the connected account', 'jetpack' ), + 'type' => 'string', + ), + 'global' => array( + 'description' => __( 'Is this connection available to all users?', 'jetpack' ), + 'type' => 'boolean', + ), + ); + } + + /** + * @return array + */ + public function get_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'jetpack-publicize-connection', + 'type' => 'object', + 'properties' => $this->get_connection_schema_properties(), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Helper for retrieving Connections. Used by this endpoint and by + * the Connection Test Result endpoint. + * + * @internal + * @return array + */ + protected function get_connections() { + global $publicize; + + $items = array(); + + foreach ( (array) $publicize->get_services( 'connected' ) as $service_name => $connections ) { + foreach ( $connections as $connection ) { + $connection_meta = $publicize->get_connection_meta( $connection ); + $connection_data = $connection_meta['connection_data']; + + $items[] = array( + 'id' => (string) $publicize->get_connection_unique_id( $connection ), + 'service_name' => $service_name, + 'display_name' => $publicize->get_display_name( $service_name, $connection ), + // We expect an integer, but do loose comparison below in case some other type is stored + 'global' => 0 == $connection_data['user_id'], + ); + } + } + + return $items; + } + + /** + * @param WP_REST_Request $request + * @return WP_REST_Response suitable for 1-page collection + */ + public function get_items( $request ) { + $items = array(); + + foreach ( $this->get_connections() as $item ) { + $items[] = $this->prepare_item_for_response( $item, $request ); + } + + $response = rest_ensure_response( $items ); + $response->header( 'X-WP-Total', count( $items ) ); + $response->header( 'X-WP-TotalPages', 1 ); + + return $response; + } + + /** + * Filters out data based on ?_fields= request parameter + * + * @param array $connection + * @param WP_REST_Request $request + * @return array filtered $connection + */ + public function prepare_item_for_response( $connection, $request ) { + if ( ! is_callable( array( $this, 'get_fields_for_response' ) ) ) { + return $connection; + } + + $fields = $this->get_fields_for_response( $request ); + + $response_data = array(); + foreach ( $connection as $field => $value ) { + if ( in_array( $field, $fields, true ) ) { + $response_data[ $field ] = $value; + } + } + + return $response_data; + } + + /** + * Verify that user can access Publicize data + * + * @return true|WP_Error + */ + public function get_items_permission_check() { + global $publicize; + + if ( $publicize->current_user_can_access_publicize_data() ) { + return true; + } + + return new WP_Error( + 'invalid_user_permission_publicize', + __( 'Sorry, you are not allowed to access Publicize data on this site.', 'jetpack' ), + array( 'status' => rest_authorization_required_code() ) + ); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_List_Publicize_Connections' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-services.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-services.php new file mode 100644 index 00000000..fb418263 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/publicize-services.php @@ -0,0 +1,159 @@ +<?php + +/** + * Publicize: List Publicize Services + * + * [ + * { # Service Object. See schema for more detail. + * name: (string) Service slug + * label: (string) Human readable label for the Service + * url: (string) Connect URL + * }, + * ... + * ] + * + * @since 6.8 + */ +class WPCOM_REST_API_V2_Endpoint_List_Publicize_Services extends WP_REST_Controller { + /** + * Flag to help WordPress.com decide where it should look for + * Publicize data. Ignored for direct requests to Jetpack sites. + * + * @var bool $wpcom_is_wpcom_only_endpoint + */ + public $wpcom_is_wpcom_only_endpoint = true; + + public function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'publicize/services'; + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Called automatically on `rest_api_init()`. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permission_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * @return array + */ + public function get_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'jetpack-publicize-service', + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'description' => __( 'Alphanumeric identifier for the Publicize Service', 'jetpack' ), + 'type' => 'string', + ), + 'label' => array( + 'description' => __( 'Human readable label for the Publicize Service', 'jetpack' ), + 'type' => 'string', + ), + 'url' => array( + 'description' => __( 'The URL used to connect to the Publicize Service', 'jetpack' ), + 'type' => 'string', + 'format' => 'uri', + ), + ), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Retrieves available Publicize Services. + * + * @see Publicize::get_available_service_data() + * + * @param WP_REST_Request $request + * @return WP_REST_Response suitable for 1-page collection + */ + public function get_items( $request ) { + global $publicize; + /** + * We need this because Publicize::get_available_service_data() uses `Jetpack_Keyring_Service_Helper` + * and `Jetpack_Keyring_Service_Helper` relies on `menu_page_url()`. + * + * We also need add_submenu_page(), as the URLs for connecting each service + * rely on the `sharing` menu subpage being present. + */ + include_once ABSPATH . 'wp-admin/includes/plugin.php'; + + // The `sharing` submenu page must exist for service connect URLs to be correct. + add_submenu_page( 'options-general.php', '', '', 'manage_options', 'sharing', '__return_empty_string' ); + + $services_data = $publicize->get_available_service_data(); + + $services = array(); + foreach ( $services_data as $service_data ) { + $services[] = $this->prepare_item_for_response( $service_data, $request ); + } + + $response = rest_ensure_response( $services ); + $response->header( 'X-WP-Total', count( $services ) ); + $response->header( 'X-WP-TotalPages', 1 ); + + return $response; + } + + /** + * Filters out data based on ?_fields= request parameter + * + * @param array $service + * @param WP_REST_Request $request + * @return array filtered $service + */ + public function prepare_item_for_response( $service, $request ) { + if ( ! is_callable( array( $this, 'get_fields_for_response' ) ) ) { + return $service; + } + + $fields = $this->get_fields_for_response( $request ); + + $response_data = array(); + foreach ( $service as $field => $value ) { + if ( in_array( $field, $fields, true ) ) { + $response_data[ $field ] = $value; + } + } + + return $response_data; + } + + /** + * Verify that user can access Publicize data + * + * @return true|WP_Error + */ + public function get_items_permission_check() { + global $publicize; + + if ( $publicize->current_user_can_access_publicize_data() ) { + return true; + } + + return new WP_Error( + 'invalid_user_permission_publicize', + __( 'Sorry, you are not allowed to access Publicize data on this site.', 'jetpack' ), + array( 'status' => rest_authorization_required_code() ) + ); + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_List_Publicize_Services' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/sites-posts-featured-media-url.php b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/sites-posts-featured-media-url.php new file mode 100644 index 00000000..4c34161c --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/sites-posts-featured-media-url.php @@ -0,0 +1,37 @@ +<?php + +/* + * Plugin Name: WPCOM Add Featured Media URL + * + * Adds `jetpack_featured_media_url` to post responses + */ + +class WPCOM_REST_API_V2_Sites_Posts_Add_Featured_Media_URL { + function __construct() { + add_action( 'rest_api_init', array( $this, 'add_featured_media_url' ) ); + } + + function add_featured_media_url() { + register_rest_field( 'post', 'jetpack_featured_media_url', + array( + 'get_callback' => array( $this, 'get_featured_media_url' ), + 'update_callback' => null, + 'schema' => null, + ) + ); + } + + function get_featured_media_url( $object, $field_name, $request ) { + $featured_media_url = ''; + $image_attributes = wp_get_attachment_image_src( + get_post_thumbnail_id( $object['id'] ), + 'full' + ); + if ( is_array( $image_attributes ) && isset( $image_attributes[0] ) ) { + $featured_media_url = (string) $image_attributes[0]; + } + return $featured_media_url; + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Sites_Posts_Add_Featured_Media_URL' ); diff --git a/plugins/jetpack/_inc/lib/core-api/wpcom-fields/post-fields-publicize-connections.php b/plugins/jetpack/_inc/lib/core-api/wpcom-fields/post-fields-publicize-connections.php new file mode 100644 index 00000000..1aa8ec86 --- /dev/null +++ b/plugins/jetpack/_inc/lib/core-api/wpcom-fields/post-fields-publicize-connections.php @@ -0,0 +1,337 @@ +<?php + +/** + * Add per-post Publicize Connection data. + * + * { # Post Object + * ... + * jetpack_publicize_connections: { # Defined below in this file. See schema for more detail. + * id: (string) Connection unique_id + * service_name: (string) Service slug + * display_name: (string) User name/display name of user/connection on Service + * enabled: (boolean) Is this connection slated to be shared to? context=edit only + * done: (boolean) Is this post (or connection) done sharing? context=edit only + * toggleable: (boolean) Can the current user change the `enabled` setting for this Connection+Post? context=edit only + * } + * ... + * meta: { # Not defined in this file. Handled in modules/publicize/publicize.php via `register_meta()` + * jetpack_publicize_message: (string) The message to use instead of the post's title when sharing. + * } + * ... + * } + * + * @since 6.8.0 + */ +class WPCOM_REST_API_V2_Post_Publicize_Connections_Field extends WPCOM_REST_API_V2_Field_Controller { + protected $object_type = 'post'; + protected $field_name = 'jetpack_publicize_connections'; + + public $memoized_updates = array(); + + /** + * Registers the jetpack_publicize_connections field. Called + * automatically on `rest_api_init()`. + */ + public function register_fields() { + $this->object_type = get_post_types_by_support( 'publicize' ); + + foreach ( $this->object_type as $post_type ) { + // Adds meta support for those post types that don't already have it. + // Only runs during REST API requests, so it doesn't impact UI. + if ( ! post_type_supports( $post_type, 'custom-fields' ) ) { + add_post_type_support( $post_type, 'custom-fields' ); + } + + add_filter( 'rest_pre_insert_' . $post_type, array( $this, 'rest_pre_insert' ), 10, 2 ); + add_action( 'rest_insert_' . $post_type, array( $this, 'rest_insert' ), 10, 3 ); + } + + parent::register_fields(); + } + + /** + * Defines data structure and what elements are visible in which contexts + */ + public function get_schema() { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'jetpack-publicize-post-connections', + 'type' => 'array', + 'context' => array( 'view', 'edit' ), + 'items' => $this->post_connection_schema(), + 'default' => array(), + ); + } + + private function post_connection_schema() { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'jetpack-publicize-post-connection', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the Publicize Connection', 'jetpack' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'service_name' => array( + 'description' => __( 'Alphanumeric identifier for the Publicize Service', 'jetpack' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'display_name' => array( + 'description' => __( 'Username of the connected account', 'jetpack' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'enabled' => array( + 'description' => __( 'Whether to share to this connection', 'jetpack' ), + 'type' => 'boolean', + 'context' => array( 'edit' ), + ), + 'done' => array( + 'description' => __( 'Whether Publicize has already finished sharing for this post', 'jetpack' ), + 'type' => 'boolean', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'toggleable' => array( + 'description' => __( 'Whether `enable` can be changed for this post/connection', 'jetpack' ), + 'type' => 'boolean', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + ), + ); + } + + /** + * @param int $post_id + * @return true|WP_Error + */ + function permission_check( $post_id ) { + global $publicize; + + if ( $publicize->current_user_can_access_publicize_data( $post_id ) ) { + return true; + } + + return new WP_Error( + 'invalid_user_permission_publicize', + __( 'Sorry, you are not allowed to access Publicize data for this post.', 'jetpack' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Getter permission check + * + * @param array $post_array Response data from Post Endpoint + * @return true|WP_Error + */ + function get_permission_check( $post_array, $request ) { + return $this->permission_check( isset( $post_array['id'] ) ? $post_array['id'] : 0 ); + + } + + /** + * Setter permission check + * + * @param WP_Post $post + * @return true|WP_Error + */ + public function update_permission_check( $value, $post, $request ) { + return $this->permission_check( isset( $post->ID ) ? $post->ID : 0 ); + } + + /** + * Getter: Retrieve current list of connected social accounts for a given post. + * + * @see Publicize::get_filtered_connection_data() + * + * @param array $post_array Response from Post Endpoint + * @param WP_REST_Request + * + * @return array List of connections + */ + public function get( $post_array, $request ) { + global $publicize; + + $schema = $this->post_connection_schema(); + $properties = array_keys( $schema['properties'] ); + + $connections = $publicize->get_filtered_connection_data( $post_array['id'] ); + + $output_connections = array(); + foreach ( $connections as $connection ) { + $output_connection = array(); + foreach ( $properties as $property ) { + if ( isset( $connection[ $property ] ) ) { + $output_connection[ $property ] = $connection[ $property ]; + } + } + + $output_connection['id'] = (string) $connection['unique_id']; + + $output_connections[] = $output_connection; + } + + return $output_connections; + } + + /** + * Prior to updating the post, first calculate which Services to + * Publicize to and which to skip. + * + * @param object $post Post data to insert/update. + * @param WP_REST_Request $request + * @return Filtered $post + */ + public function rest_pre_insert( $post, $request ) { + if ( ! isset( $request['jetpack_publicize_connections'] ) ) { + return $post; + } + + $permission_check = $this->update_permission_check( $request['jetpack_publicize_connections'], $post, $request ); + + if ( is_wp_error( $permission_check ) ) { + return $permission_check; + } + + // memoize + $this->get_meta_to_update( $request['jetpack_publicize_connections'], isset( $post->ID ) ? $post->ID : 0 ); + + return $post; + } + + /** + * After creating a new post, update our cached data to reflect + * the new post ID. + * + * @param WP_Post $post + * @param WP_REST_Request $request + * @param bool $is_new + */ + public function rest_insert( $post, $request, $is_new ) { + if ( ! $is_new ) { + // An existing post was edited - no need to update + // our cache - we started out knowing the correct + // post ID. + return; + } + + if ( ! isset( $request['jetpack_publicize_connections'] ) ) { + return; + } + + if ( ! isset( $this->memoized_updates[0] ) ) { + return; + } + + $this->memoized_updates[ $post->ID ] = $this->memoized_updates[0]; + unset( $this->memoized_updates[0] ); + } + + protected function get_meta_to_update( $requested_connections, $post_id = 0 ) { + global $publicize; + + if ( isset( $this->memoized_updates[$post_id] ) ) { + return $this->memoized_updates[$post_id]; + } + + $available_connections = $publicize->get_filtered_connection_data( $post_id ); + + $changed_connections = array(); + + // Build lookup mappings + $available_connections_by_unique_id = array(); + $available_connections_by_service_name = array(); + foreach ( $available_connections as $available_connection ) { + $available_connections_by_unique_id[ $available_connection['unique_id'] ] = $available_connection; + + if ( ! isset( $available_connections_by_service_name[ $available_connection['service_name'] ] ) ) { + $available_connections_by_service_name[ $available_connection['service_name'] ] = array(); + } + $available_connections_by_service_name[ $available_connection['service_name'] ][] = $available_connection; + } + + // Handle { service_name: $service_name, enabled: (bool) } + foreach ( $requested_connections as $requested_connection ) { + if ( ! isset( $requested_connection['service_name'] ) ) { + continue; + } + + if ( ! isset( $available_connections_by_service_name[ $requested_connection['service_name'] ] ) ) { + continue; + } + + foreach ( $available_connections_by_service_name[ $requested_connection['service_name'] ] as $available_connection ) { + $changed_connections[ $available_connection['unique_id'] ] = $requested_connection['enabled']; + } + } + + // Handle { id: $id, enabled: (bool) } + // These override the service_name settings + foreach ( $requested_connections as $requested_connection ) { + if ( ! isset( $requested_connection['id'] ) ) { + continue; + } + + if ( ! isset( $available_connections_by_unique_id[ $requested_connection['id'] ] ) ) { + continue; + } + + $changed_connections[ $requested_connection['id'] ] = $requested_connection['enabled']; + } + + // Set all changed connections to their new value + foreach ( $changed_connections as $unique_id => $enabled ) { + $connection = $available_connections_by_unique_id[ $unique_id ]; + + if ( $connection['done'] || ! $connection['toggleable'] ) { + continue; + } + + $available_connections_by_unique_id[ $unique_id ]['enabled'] = $enabled; + } + + $meta_to_update = array(); + // For all connections, ensure correct post_meta + foreach ( $available_connections_by_unique_id as $unique_id => $available_connection ) { + if ( $available_connection['enabled'] ) { + $meta_to_update[$publicize->POST_SKIP . $unique_id] = null; + } else { + $meta_to_update[$publicize->POST_SKIP . $unique_id] = 1; + } + } + + $this->memoized_updates[$post_id] = $meta_to_update; + + return $meta_to_update; + } + + /** + * Update the connections slated to be shared to. + * + * @param array $requested_connections + * Items are either `{ id: (string) }` or `{ service_name: (string) }` + * @param WP_Post $post + * @param WP_REST_Request + */ + public function update( $requested_connections, $post, $request ) { + foreach ( $this->get_meta_to_update( $requested_connections, $post->ID ) as $meta_key => $meta_value ) { + if ( is_null( $meta_value ) ) { + delete_post_meta( $post->ID, $meta_key ); + } else { + update_post_meta( $post->ID, $meta_key, $meta_value ); + } + } + } +} + +if ( Jetpack::is_module_active( 'publicize' ) ) { + wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Post_Publicize_Connections_Field' ); +}
\ No newline at end of file |