PluginProbe
Gutenberg / 12.6.0
Gutenberg v12.6.0
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / class-wp-rest-url-details-controller.php

class-wp-rest-url-details-controller.php in Gutenberg 12.6.0, at lib/class-wp-rest-url-details-controller.php

592 lines 19.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API: WP_REST_URL_Details_Controller class
4 *
5 * @package Gutenberg
6 */
7
8 /**
9 * Controller which provides REST endpoint for retrieving information
10 * from a remote site's HTML response.
11 *
12 * @since 5.?.0
13 *
14 * @see WP_REST_Controller
15 */
16 class WP_REST_URL_Details_Controller extends WP_REST_Controller {
17
18 /**
19 * Constructs the controller.
20 */
21 public function __construct() {
22 $this->namespace = 'wp-block-editor/v1';
23 $this->rest_base = 'url-details';
24 }
25
26 /**
27 * Registers the necessary REST API routes.
28 */
29 public function register_routes() {
30 register_rest_route(
31 $this->namespace,
32 '/' . $this->rest_base,
33 array(
34 array(
35 'methods' => WP_REST_Server::READABLE,
36 'callback' => array( $this, 'parse_url_details' ),
37 'args' => array(
38 'url' => array(
39 'required' => true,
40 'description' => __( 'The URL to process.', 'gutenberg' ),
41 'validate_callback' => 'wp_http_validate_url',
42 'sanitize_callback' => 'esc_url_raw',
43 'type' => 'string',
44 'format' => 'uri',
45 ),
46 ),
47 'permission_callback' => array( $this, 'permissions_check' ),
48 'schema' => array( $this, 'get_public_item_schema' ),
49 ),
50 )
51 );
52 }
53
54 /**
55 * Get the schema for the endpoint.
56 *
57 * @return array the schema.
58 */
59 public function get_item_schema() {
60 if ( $this->schema ) {
61 return $this->add_additional_fields_schema( $this->schema );
62 }
63
64 $schema = array(
65 '$schema' => 'http://json-schema.org/draft-04/schema#',
66 'title' => 'url-details',
67 'type' => 'object',
68 'properties' => array(
69 'title' => array(
70 'description' => __( 'The contents of the <title> element from the URL.', 'gutenberg' ),
71 'type' => 'string',
72 'context' => array( 'view', 'edit', 'embed' ),
73 'readonly' => true,
74 ),
75 'icon' => array(
76 'description' => __( 'The favicon image link of the <link rel="icon"> element from the URL.', 'gutenberg' ),
77 'type' => 'string',
78 'format' => 'uri',
79 'context' => array( 'view', 'edit', 'embed' ),
80 'readonly' => true,
81 ),
82 'description' => array(
83 'description' => __( 'The content of the <meta name="description"> element from the URL.', 'gutenberg' ),
84 'type' => 'string',
85 'context' => array( 'view', 'edit', 'embed' ),
86 'readonly' => true,
87 ),
88 'image' => array(
89 'description' => __( 'The OG image link of the <meta property="og:image"> or <meta property="og:image:url"> element from the URL.', 'gutenberg' ),
90 'type' => 'string',
91 'format' => 'uri',
92 'context' => array( 'view', 'edit', 'embed' ),
93 'readonly' => true,
94 ),
95 ),
96 );
97
98 $this->schema = $schema;
99
100 return $this->add_additional_fields_schema( $this->schema );
101 }
102
103 /**
104 * Retrieves the contents of the <title> tag from the HTML
105 * response.
106 *
107 * @param WP_REST_REQUEST $request Full details about the request.
108 * @return WP_REST_Response|WP_Error The parsed details as a response object, or an error.
109 */
110 public function parse_url_details( $request ) {
111 $url = untrailingslashit( $request['url'] );
112
113 if ( empty( $url ) ) {
114 return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'gutenberg' ), array( 'status' => 404 ) );
115 }
116
117 // Transient per URL.
118 $cache_key = $this->build_cache_key_for_url( $url );
119
120 // Attempt to retrieve cached response.
121 $cached_response = $this->get_cache( $cache_key );
122
123 if ( ! empty( $cached_response ) ) {
124 $remote_url_response = $cached_response;
125 } else {
126 $remote_url_response = $this->get_remote_url( $url );
127
128 // Exit if we don't have a valid body or it's empty.
129 if ( is_wp_error( $remote_url_response ) || empty( $remote_url_response ) ) {
130 return $remote_url_response;
131 }
132
133 // Cache the valid response.
134 $this->set_cache( $cache_key, $remote_url_response );
135 }
136
137 $html_head = $this->get_document_head( $remote_url_response );
138 $meta_elements = $this->get_meta_with_content_elements( $html_head );
139
140 $data = $this->add_additional_fields_to_object(
141 array(
142 'title' => $this->get_title( $html_head ),
143 'icon' => $this->get_icon( $html_head, $url ),
144 'description' => $this->get_description( $meta_elements ),
145 'image' => $this->get_image( $meta_elements, $url ),
146 ),
147 $request
148 );
149
150 // Wrap the data in a response object.
151 $response = rest_ensure_response( $data );
152
153 /**
154 * Filters the URL data for the response.
155 *
156 * @param WP_REST_Response $response The response object.
157 * @param string $url The requested URL.
158 * @param WP_REST_Request $request Request object.
159 * @param array $remote_url_response HTTP response body from the remote URL.
160 */
161 return apply_filters( 'rest_prepare_url_details', $response, $url, $request, $remote_url_response );
162 }
163
164 /**
165 * Checks whether a given request has permission to read remote urls.
166 *
167 * @return WP_Error|bool True if the request has access, or WP_Error object.
168 */
169 public function permissions_check() {
170 if ( current_user_can( 'edit_posts' ) ) {
171 return true;
172 }
173
174 foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
175 if ( current_user_can( $post_type->cap->edit_posts ) ) {
176 return true;
177 }
178 }
179
180 return new WP_Error(
181 'rest_cannot_view_url_details',
182 __( 'Sorry, you are not allowed to process remote urls.', 'gutenberg' ),
183 array( 'status' => rest_authorization_required_code() )
184 );
185 }
186
187 /**
188 * Retrieves the document title from a remote URL.
189 *
190 * @param string $url The website url whose HTML we want to access.
191 * @return string|WP_Error The HTTP response from the remote URL, or an error.
192 */
193 private function get_remote_url( $url ) {
194
195 // Provide a modified UA string to workaround web properties which block WordPress "Pingbacks".
196 // Why? The UA string used for pingback requests contains `WordPress/` which is very similar
197 // to that used as the default UA string by the WP HTTP API. Therefore requests from this
198 // REST endpoint are being unintentionally blocked as they are misidentified as pingback requests.
199 // By slightly modifying the UA string, but still retaining the "WordPress" identification (via "WP")
200 // we are able to work around this issue.
201 // Example UA string: `WP-URLDetails/5.9-alpha-51389 (+http://localhost:8888)`.
202 $modified_user_agent = 'WP-URLDetails/' . get_bloginfo( 'version' ) . ' (+' . get_bloginfo( 'url' ) . ')';
203
204 $args = array(
205 'limit_response_size' => 150 * KB_IN_BYTES,
206 'user-agent' => $modified_user_agent,
207 );
208
209 /**
210 * Filters the HTTP request args for URL data retrieval.
211 *
212 * Can be used to adjust response size limit and other WP_Http::request args.
213 *
214 * @param array $args Arguments used for the HTTP request
215 * @param string $url The attempted URL.
216 */
217 $args = apply_filters( 'rest_url_details_http_request_args', $args, $url );
218
219 $response = wp_safe_remote_get(
220 $url,
221 $args
222 );
223
224 if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) {
225 // Not saving the error response to cache since the error might be temporary.
226 return new WP_Error( 'no_response', __( 'URL not found. Response returned a non-200 status code for this URL.', 'gutenberg' ), array( 'status' => WP_Http::NOT_FOUND ) );
227 }
228
229 $remote_body = wp_remote_retrieve_body( $response );
230
231 if ( empty( $remote_body ) ) {
232 return new WP_Error( 'no_content', __( 'Unable to retrieve body from response at this URL.', 'gutenberg' ), array( 'status' => WP_Http::NOT_FOUND ) );
233 }
234
235 return $remote_body;
236 }
237
238 /**
239 * Parses the <title> contents from the provided HTML
240 *
241 * @param string $html The HTML from the remote website at URL.
242 * @return string The title tag contents on success, or an empty string.
243 */
244 private function get_title( $html ) {
245 $pattern = '#<title[^>]*>(.*?)<\s*/\s*title>#is';
246 preg_match( $pattern, $html, $match_title );
247
248 $title = ! empty( $match_title[1] ) && is_string( $match_title[1] ) ? trim( $match_title[1] ) : '';
249
250 if ( empty( $title ) ) {
251 return '';
252 }
253
254 return $this->prepare_metadata_for_output( $title );
255 }
256
257 /**
258 * Parses the site icon from the provided HTML
259 *
260 * @param string $html The HTML from the remote website at URL.
261 * @param string $url The target website URL.
262 * @return string The icon URI on success, or an empty string.
263 */
264 private function get_icon( $html, $url ) {
265 // Grab the icon's link element.
266 $pattern = '#<link\s[^>]*rel=(?:[\"\']??)\s*(?:icon|shortcut icon|icon shortcut)\s*(?:[\"\']??)[^>]*\/?>#isU';
267 preg_match( $pattern, $html, $element );
268 $element = ! empty( $element[0] ) && is_string( $element[0] ) ? trim( $element[0] ) : '';
269 if ( empty( $element ) ) {
270 return '';
271 }
272
273 // Get the icon's href value.
274 $pattern = '#href=([\"\']??)([^\" >]*?)\\1[^>]*#isU';
275 preg_match( $pattern, $element, $icon );
276 $icon = ! empty( $icon[2] ) && is_string( $icon[2] ) ? trim( $icon[2] ) : '';
277 if ( empty( $icon ) ) {
278 return '';
279 }
280
281 // If the icon is a data URL, return it.
282 $parsed_icon = parse_url( $icon );
283 if ( isset( $parsed_icon['schema'] ) && 'data' === $parsed_icon['scheme'] ) {
284 return $icon;
285 }
286
287 // Attempt to convert relative URLs to absolute.
288 $parsed_url = parse_url( $url );
289 $root_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . '/';
290 $icon = WP_Http::make_absolute_url( $icon, $root_url );
291
292 return $icon;
293 }
294
295 /**
296 * Parses the meta description from the provided HTML.
297 *
298 * @param array $meta_elements {
299 * A multi-dimensional indexed array on success, or empty array.
300 *
301 * @type string[] 0 Meta elements with a content attribute.
302 * @type string[] 1 Content attribute's opening quotation mark.
303 * @type string[] 2 Content attribute's value for each meta element.
304 * }
305 * @return string The meta description contents on success, or an empty string.
306 */
307 private function get_description( $meta_elements ) {
308 // Bail out if there are no meta elements.
309 if ( empty( $meta_elements[0] ) ) {
310 return '';
311 }
312
313 $description = $this->get_metadata_from_meta_element( $meta_elements, 'name', '(?:description|og:description)' );
314
315 // Bail out if description not found.
316 if ( '' === $description ) {
317 return '';
318 }
319
320 return $this->prepare_metadata_for_output( $description );
321 }
322
323 /**
324 * Parses the Open Graph Image from the provided HTML.
325 *
326 * See: https://ogp.me/.
327 *
328 * @param array $meta_elements {
329 * A multi-dimensional indexed array on success, or empty array.
330 *
331 * @type string[] 0 Meta elements with a content attribute.
332 * @type string[] 1 Content attribute's opening quotation mark.
333 * @type string[] 2 Content attribute's value for each meta element.
334 * }
335 * @param string $url The target website URL.
336 * @return string The OG image on success, or empty string.
337 */
338 private function get_image( $meta_elements, $url ) {
339 $image = $this->get_metadata_from_meta_element( $meta_elements, 'property', '(?:og:image|og:image:url)' );
340
341 // Bail out if image not found.
342 if ( '' === $image ) {
343 return '';
344 }
345
346 // Attempt to convert relative URLs to absolute.
347 $parsed_url = parse_url( $url );
348 $root_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . '/';
349 $image = WP_Http::make_absolute_url( $image, $root_url );
350
351 return $image;
352 }
353
354 /**
355 * Prepare the metadata by:
356 *
357 * - stripping all HTML tags and tag entities
358 * - converting non-tag entities into characters.
359 *
360 * @param string $metadata The metadata content to prepare.
361 * @return string The prepared metadata.
362 */
363 private function prepare_metadata_for_output( $metadata ) {
364 $metadata = html_entity_decode( $metadata, ENT_QUOTES, get_bloginfo( 'charset' ) );
365 $metadata = wp_strip_all_tags( $metadata );
366 return $metadata;
367 }
368
369 /**
370 * Utility function to build cache key for a given URL.
371 *
372 * @param string $url The URL for which to build a cache key.
373 * @return string The cache key.
374 */
375 private function build_cache_key_for_url( $url ) {
376 return 'g_url_details_response_' . md5( $url );
377 }
378
379 /**
380 * Utility function to retrieve a value from the cache at a given key.
381 *
382 * @param string $key The cache key.
383 * @return mixed The value from the cache.
384 */
385 private function get_cache( $key ) {
386 return get_site_transient( $key );
387 }
388
389 /**
390 * Utility function to cache a given data set at a given cache key.
391 *
392 * @param string $key The cache key under which to store the value.
393 * @param string $data The data to be stored at the given cache key.
394 * @return bool True when transient set, or false.
395 */
396 private function set_cache( $key, $data = '' ) {
397 $ttl = HOUR_IN_SECONDS;
398
399 /**
400 * Filters the cache expiration.
401 *
402 * Can be used to adjust the time until expiration in seconds for the cache
403 * of the data retrieved for the given URL.
404 *
405 * @param int $ttl the time until cache expiration in seconds.
406 */
407 $cache_expiration = apply_filters( 'rest_url_details_cache_expiration', $ttl );
408
409 return set_site_transient( $key, $data, $cache_expiration );
410 }
411
412 /**
413 * Retrieves the `<head>` section.
414 *
415 * @param string $html The string of HTML to parse.
416 * @return string The `<head>..</head>` section on success, or original HTML.
417 */
418 private function get_document_head( $html ) {
419 $head_html = $html;
420
421 // Find the opening `<head>` tag.
422 $head_start = strpos( $html, '<head' );
423 if ( false === $head_start ) {
424 // Didn't find it. Return the original HTML.
425 return $html;
426 }
427
428 // Find the closing `</head>` tag.
429 $head_end = strpos( $head_html, '</head>' );
430 if ( false === $head_end ) {
431 // Didn't find it. Find the opening `<body>` tag.
432 $head_end = strpos( $head_html, '<body' );
433
434 // Didn't find it. Return the original HTML.
435 if ( false === $head_end ) {
436 return $html;
437 }
438 }
439
440 // Extract the HTML from opening tag to the closing tag. Then add the closing tag.
441 $head_html = substr( $head_html, $head_start, $head_end );
442 $head_html .= '</head>';
443
444 return $head_html;
445 }
446
447 /**
448 * Gets all the <meta> elements that have a `content` attribute.
449 *
450 * @param string $html The string of HTML to be parsed.
451 * @return array {
452 * A multi-dimensional indexed array on success, or empty array.
453 *
454 * @type string[] 0 Meta elements with a content attribute.
455 * @type string[] 1 Content attribute's opening quotation mark.
456 * @type string[] 2 Content attribute's value for each meta element.
457 * }
458 */
459 private function get_meta_with_content_elements( $html ) {
460 /*
461 * Parse all meta elements with a content attribute.
462 *
463 * Why first search for the content attribute rather than directly searching for name=description element?
464 * tl;dr The content attribute's value will be truncated when it contains a > symbol.
465 *
466 * The content attribute's value (i.e. the description to get) can have HTML in it and be well-formed as
467 * it's a string to the browser. Imagine what happens when attempting to match for the name=description
468 * first. Hmm, if a > or /> symbol is in the content attribute's value, then it terminates the match
469 * as the element's closing symbol. But wait, it's in the content attribute and is not the end of the
470 * element. This is a limitation of using regex. It can't determine "wait a minute this is inside of quotation".
471 * If this happens, what gets matched is not the entire element or all of the content.
472 *
473 * Why not search for the name=description and then content="(.*)"?
474 * The attribute order could be opposite. Plus, additional attributes may exist including being between
475 * the name and content attributes.
476 *
477 * Why not lookahead?
478 * Lookahead is not constrained to stay within the element. The first <meta it finds may not include
479 * the name or content, but rather could be from a different element downstream.
480 */
481 $pattern = '#<meta\s' .
482
483 /*
484 * Allows for additional attributes before the content attribute.
485 * Searches for anything other than > symbol.
486 */
487 '[^>]*' .
488
489 /*
490 * Find the content attribute. When found, capture its value (.*).
491 *
492 * Allows for (a) single or double quotes and (b) whitespace in the value.
493 *
494 * Why capture the opening quotation mark, i.e. (["\']), and then backreference,
495 * i.e \1, for the closing quotation mark?
496 * To ensure the closing quotation mark matches the opening one. Why? Attribute values
497 * can contain quotation marks, such as an apostrophe in the content.
498 */
499 'content=(["\']??)(.*)\1' .
500
501 /*
502 * Allows for additional attributes after the content attribute.
503 * Searches for anything other than > symbol.
504 */
505 '[^>]*' .
506
507 /*
508 * \/?> searches for the closing > symbol, which can be in either /> or > format.
509 * # ends the pattern.
510 */
511 '\/?>#' .
512
513 /*
514 * These are the options:
515 * - i : case insensitive
516 * - s : allows newline characters for the . match (needed for multiline elements)
517 * - U means non-greedy matching
518 */
519 'isU';
520
521 preg_match_all( $pattern, $html, $elements );
522
523 return $elements;
524 }
525
526 /**
527 * Gets the metadata from a target meta element.
528 *
529 * @param array $meta_elements {
530 * A multi-dimensional indexed array on success, or empty array.
531 *
532 * @type string[] 0 Meta elements with a content attribute.
533 * @type string[] 1 Content attribute's opening quotation mark.
534 * @type string[] 2 Content attribute's value for each meta element.
535 * }
536 * @param string $attr Attribute that identifies the element with the target metadata.
537 * @param string $attr_value The attribute's value that identifies the element with the target metadata.
538 * @return string The metadata on success, or an empty string.
539 */
540 private function get_metadata_from_meta_element( $meta_elements, $attr, $attr_value ) {
541 // Bail out if there are no meta elements.
542 if ( empty( $meta_elements[0] ) ) {
543 return '';
544 }
545
546 $metadata = '';
547 $pattern = '#' .
548
549 /*
550 * Target this attribute and value to find the metadata element.
551 *
552 * Allows for (a) no, single, double quotes and (b) whitespace in the value.
553 *
554 * Why capture the opening quotation mark, i.e. (["\']), and then backreference,
555 * i.e \1, for the closing quotation mark?
556 * To ensure the closing quotation mark matches the opening one. Why? Attribute values
557 * can contain quotation marks, such as an apostrophe in the content.
558 */
559 $attr . '=([\"\']??)\s*' . $attr_value . '\s*\1' .
560
561 /*
562 * These are the options:
563 * - i : case insensitive
564 * - s : allows newline characters for the . match (needed for multiline elements)
565 * - U means non-greedy matching
566 */
567 '#isU';
568
569 // Find the metdata element.
570 foreach ( $meta_elements[0] as $index => $element ) {
571 preg_match( $pattern, $element, $match );
572
573 // This is not the metadata element. Skip it.
574 if ( empty( $match ) ) {
575 continue;
576 }
577
578 /*
579 * Found the metadata element.
580 * Get the metadata from its matching content array.
581 */
582 if ( isset( $meta_elements[2][ $index ] ) && is_string( $meta_elements[2][ $index ] ) ) {
583 $metadata = trim( $meta_elements[2][ $index ] );
584 }
585
586 break;
587 }
588
589 return $metadata;
590 }
591 }
592