class-wp-rest-pattern-directory-controller.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. <?php
  2. /**
  3. * Block Pattern Directory REST API: WP_REST_Pattern_Directory_Controller class
  4. *
  5. * @package WordPress
  6. * @subpackage REST_API
  7. * @since 5.8.0
  8. */
  9. /**
  10. * Controller which provides REST endpoint for block patterns.
  11. *
  12. * This simply proxies the endpoint at http://api.wordpress.org/patterns/1.0/. That isn't necessary for
  13. * functionality, but is desired for privacy. It prevents api.wordpress.org from knowing the user's IP address.
  14. *
  15. * @since 5.8.0
  16. *
  17. * @see WP_REST_Controller
  18. */
  19. class WP_REST_Pattern_Directory_Controller extends WP_REST_Controller {
  20. /**
  21. * Constructs the controller.
  22. *
  23. * @since 5.8.0
  24. */
  25. public function __construct() {
  26. $this->namespace = 'wp/v2';
  27. $this->rest_base = 'pattern-directory';
  28. }
  29. /**
  30. * Registers the necessary REST API routes.
  31. *
  32. * @since 5.8.0
  33. */
  34. public function register_routes() {
  35. register_rest_route(
  36. $this->namespace,
  37. '/' . $this->rest_base . '/patterns',
  38. array(
  39. array(
  40. 'methods' => WP_REST_Server::READABLE,
  41. 'callback' => array( $this, 'get_items' ),
  42. 'permission_callback' => array( $this, 'get_items_permissions_check' ),
  43. 'args' => $this->get_collection_params(),
  44. ),
  45. 'schema' => array( $this, 'get_public_item_schema' ),
  46. )
  47. );
  48. }
  49. /**
  50. * Checks whether a given request has permission to view the local block pattern directory.
  51. *
  52. * @since 5.8.0
  53. *
  54. * @param WP_REST_Request $request Full details about the request.
  55. * @return true|WP_Error True if the request has permission, WP_Error object otherwise.
  56. */
  57. public function get_items_permissions_check( $request ) {
  58. if ( current_user_can( 'edit_posts' ) ) {
  59. return true;
  60. }
  61. foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
  62. if ( current_user_can( $post_type->cap->edit_posts ) ) {
  63. return true;
  64. }
  65. }
  66. return new WP_Error(
  67. 'rest_pattern_directory_cannot_view',
  68. __( 'Sorry, you are not allowed to browse the local block pattern directory.' ),
  69. array( 'status' => rest_authorization_required_code() )
  70. );
  71. }
  72. /**
  73. * Search and retrieve block patterns metadata
  74. *
  75. * @since 5.8.0
  76. * @since 6.0.0 Added 'slug' to request.
  77. *
  78. * @param WP_REST_Request $request Full details about the request.
  79. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
  80. */
  81. public function get_items( $request ) {
  82. /*
  83. * Include an unmodified `$wp_version`, so the API can craft a response that's tailored to
  84. * it. Some plugins modify the version in a misguided attempt to improve security by
  85. * obscuring the version, which can cause invalid requests.
  86. */
  87. require ABSPATH . WPINC . '/version.php';
  88. $query_args = array(
  89. 'locale' => get_user_locale(),
  90. 'wp-version' => $wp_version,
  91. );
  92. $category_id = $request['category'];
  93. $keyword_id = $request['keyword'];
  94. $search_term = $request['search'];
  95. $slug = $request['slug'];
  96. if ( $category_id ) {
  97. $query_args['pattern-categories'] = $category_id;
  98. }
  99. if ( $keyword_id ) {
  100. $query_args['pattern-keywords'] = $keyword_id;
  101. }
  102. if ( $search_term ) {
  103. $query_args['search'] = $search_term;
  104. }
  105. if ( $slug ) {
  106. $query_args['slug'] = $slug;
  107. }
  108. $transient_key = $this->get_transient_key( $query_args );
  109. /*
  110. * Use network-wide transient to improve performance. The locale is the only site
  111. * configuration that affects the response, and it's included in the transient key.
  112. */
  113. $raw_patterns = get_site_transient( $transient_key );
  114. if ( ! $raw_patterns ) {
  115. $api_url = 'http://api.wordpress.org/patterns/1.0/?' . build_query( $query_args );
  116. if ( wp_http_supports( array( 'ssl' ) ) ) {
  117. $api_url = set_url_scheme( $api_url, 'https' );
  118. }
  119. /*
  120. * Default to a short TTL, to mitigate cache stampedes on high-traffic sites.
  121. * This assumes that most errors will be short-lived, e.g., packet loss that causes the
  122. * first request to fail, but a follow-up one will succeed. The value should be high
  123. * enough to avoid stampedes, but low enough to not interfere with users manually
  124. * re-trying a failed request.
  125. */
  126. $cache_ttl = 5;
  127. $wporg_response = wp_remote_get( $api_url );
  128. $raw_patterns = json_decode( wp_remote_retrieve_body( $wporg_response ) );
  129. if ( is_wp_error( $wporg_response ) ) {
  130. $raw_patterns = $wporg_response;
  131. } elseif ( ! is_array( $raw_patterns ) ) {
  132. // HTTP request succeeded, but response data is invalid.
  133. $raw_patterns = new WP_Error(
  134. 'pattern_api_failed',
  135. sprintf(
  136. /* translators: %s: Support forums URL. */
  137. __( 'An unexpected error occurred. Something may be wrong with WordPress.org or this server&#8217;s configuration. If you continue to have problems, please try the <a href="%s">support forums</a>.' ),
  138. __( 'https://wordpress.org/support/forums/' )
  139. ),
  140. array(
  141. 'response' => wp_remote_retrieve_body( $wporg_response ),
  142. )
  143. );
  144. } else {
  145. // Response has valid data.
  146. $cache_ttl = HOUR_IN_SECONDS;
  147. }
  148. set_site_transient( $transient_key, $raw_patterns, $cache_ttl );
  149. }
  150. if ( is_wp_error( $raw_patterns ) ) {
  151. $raw_patterns->add_data( array( 'status' => 500 ) );
  152. return $raw_patterns;
  153. }
  154. $response = array();
  155. if ( $raw_patterns ) {
  156. foreach ( $raw_patterns as $pattern ) {
  157. $response[] = $this->prepare_response_for_collection(
  158. $this->prepare_item_for_response( $pattern, $request )
  159. );
  160. }
  161. }
  162. return new WP_REST_Response( $response );
  163. }
  164. /**
  165. * Prepare a raw block pattern before it gets output in a REST API response.
  166. *
  167. * @since 5.8.0
  168. * @since 5.9.0 Renamed `$raw_pattern` to `$item` to match parent class for PHP 8 named parameter support.
  169. *
  170. * @param object $item Raw pattern from api.wordpress.org, before any changes.
  171. * @param WP_REST_Request $request Request object.
  172. * @return WP_REST_Response
  173. */
  174. public function prepare_item_for_response( $item, $request ) {
  175. // Restores the more descriptive, specific name for use within this method.
  176. $raw_pattern = $item;
  177. $prepared_pattern = array(
  178. 'id' => absint( $raw_pattern->id ),
  179. 'title' => sanitize_text_field( $raw_pattern->title->rendered ),
  180. 'content' => wp_kses_post( $raw_pattern->pattern_content ),
  181. 'categories' => array_map( 'sanitize_title', $raw_pattern->category_slugs ),
  182. 'keywords' => array_map( 'sanitize_text_field', explode( ',', $raw_pattern->meta->wpop_keywords ) ),
  183. 'description' => sanitize_text_field( $raw_pattern->meta->wpop_description ),
  184. 'viewport_width' => absint( $raw_pattern->meta->wpop_viewport_width ),
  185. );
  186. $prepared_pattern = $this->add_additional_fields_to_object( $prepared_pattern, $request );
  187. $response = new WP_REST_Response( $prepared_pattern );
  188. /**
  189. * Filters the REST API response for a block pattern.
  190. *
  191. * @since 5.8.0
  192. *
  193. * @param WP_REST_Response $response The response object.
  194. * @param object $raw_pattern The unprepared block pattern.
  195. * @param WP_REST_Request $request The request object.
  196. */
  197. return apply_filters( 'rest_prepare_block_pattern', $response, $raw_pattern, $request );
  198. }
  199. /**
  200. * Retrieves the block pattern's schema, conforming to JSON Schema.
  201. *
  202. * @since 5.8.0
  203. *
  204. * @return array Item schema data.
  205. */
  206. public function get_item_schema() {
  207. if ( $this->schema ) {
  208. return $this->add_additional_fields_schema( $this->schema );
  209. }
  210. $this->schema = array(
  211. '$schema' => 'http://json-schema.org/draft-04/schema#',
  212. 'title' => 'pattern-directory-item',
  213. 'type' => 'object',
  214. 'properties' => array(
  215. 'id' => array(
  216. 'description' => __( 'The pattern ID.' ),
  217. 'type' => 'integer',
  218. 'minimum' => 1,
  219. 'context' => array( 'view', 'edit', 'embed' ),
  220. ),
  221. 'title' => array(
  222. 'description' => __( 'The pattern title, in human readable format.' ),
  223. 'type' => 'string',
  224. 'minLength' => 1,
  225. 'context' => array( 'view', 'edit', 'embed' ),
  226. ),
  227. 'content' => array(
  228. 'description' => __( 'The pattern content.' ),
  229. 'type' => 'string',
  230. 'minLength' => 1,
  231. 'context' => array( 'view', 'edit', 'embed' ),
  232. ),
  233. 'categories' => array(
  234. 'description' => __( "The pattern's category slugs." ),
  235. 'type' => 'array',
  236. 'uniqueItems' => true,
  237. 'items' => array( 'type' => 'string' ),
  238. 'context' => array( 'view', 'edit', 'embed' ),
  239. ),
  240. 'keywords' => array(
  241. 'description' => __( "The pattern's keywords." ),
  242. 'type' => 'array',
  243. 'uniqueItems' => true,
  244. 'items' => array( 'type' => 'string' ),
  245. 'context' => array( 'view', 'edit', 'embed' ),
  246. ),
  247. 'description' => array(
  248. 'description' => __( 'A description of the pattern.' ),
  249. 'type' => 'string',
  250. 'minLength' => 1,
  251. 'context' => array( 'view', 'edit', 'embed' ),
  252. ),
  253. 'viewport_width' => array(
  254. 'description' => __( 'The preferred width of the viewport when previewing a pattern, in pixels.' ),
  255. 'type' => 'integer',
  256. 'context' => array( 'view', 'edit', 'embed' ),
  257. ),
  258. ),
  259. );
  260. return $this->add_additional_fields_schema( $this->schema );
  261. }
  262. /**
  263. * Retrieves the search parameters for the block pattern's collection.
  264. *
  265. * @since 5.8.0
  266. *
  267. * @return array Collection parameters.
  268. */
  269. public function get_collection_params() {
  270. $query_params = parent::get_collection_params();
  271. // Pagination is not supported.
  272. unset( $query_params['page'] );
  273. unset( $query_params['per_page'] );
  274. $query_params['search']['minLength'] = 1;
  275. $query_params['context']['default'] = 'view';
  276. $query_params['category'] = array(
  277. 'description' => __( 'Limit results to those matching a category ID.' ),
  278. 'type' => 'integer',
  279. 'minimum' => 1,
  280. );
  281. $query_params['keyword'] = array(
  282. 'description' => __( 'Limit results to those matching a keyword ID.' ),
  283. 'type' => 'integer',
  284. 'minimum' => 1,
  285. );
  286. $query_params['slug'] = array(
  287. 'description' => __( 'Limit results to those matching a pattern (slug).' ),
  288. 'type' => 'array',
  289. );
  290. /**
  291. * Filter collection parameters for the block pattern directory controller.
  292. *
  293. * @since 5.8.0
  294. *
  295. * @param array $query_params JSON Schema-formatted collection parameters.
  296. */
  297. return apply_filters( 'rest_pattern_directory_collection_params', $query_params );
  298. }
  299. /*
  300. * Include a hash of the query args, so that different requests are stored in
  301. * separate caches.
  302. *
  303. * MD5 is chosen for its speed, low-collision rate, universal availability, and to stay
  304. * under the character limit for `_site_transient_timeout_{...}` keys.
  305. *
  306. * @link https://stackoverflow.com/questions/3665247/fastest-hash-for-non-cryptographic-uses
  307. *
  308. * @since 6.0.0
  309. *
  310. * @param array $query_args Query arguments to generate a transient key from.
  311. * @return string Transient key.
  312. */
  313. protected function get_transient_key( $query_args ) {
  314. if ( isset( $query_args['slug'] ) ) {
  315. // This is an additional precaution because the "sort" function expects an array.
  316. $query_args['slug'] = wp_parse_list( $query_args['slug'] );
  317. // Empty arrays should not affect the transient key.
  318. if ( empty( $query_args['slug'] ) ) {
  319. unset( $query_args['slug'] );
  320. } else {
  321. // Sort the array so that the transient key doesn't depend on the order of slugs.
  322. sort( $query_args['slug'] );
  323. }
  324. }
  325. return 'wp_remote_block_patterns_' . md5( serialize( $query_args ) );
  326. }
  327. }