Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
10.43% covered (danger)
10.43%
22 / 211
7.14% covered (danger)
7.14%
1 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Embeds
10.43% covered (danger)
10.43%
22 / 211
7.14% covered (danger)
7.14%
1 / 14
8965.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 init
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 is_enabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
12
 register_routes
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 preview
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 rate_limited
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 data
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 respond
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 player
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
72
 sanitize_html
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
10
 card
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
1640
 lookup_url
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 trusted_url
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 block
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
306
1<?php
2/**
3 * Embeds in comments.
4 *
5 * @package automattic/jetpack-comments
6 */
7
8namespace Automattic\Jetpack\Comments;
9
10use Automattic\Jetpack\IP\Utils as IP_Utils;
11use WP_Error;
12use WP_REST_Controller;
13use WP_REST_Request;
14use WP_REST_Response;
15use WP_REST_Server;
16
17/**
18 * The embed block in a comment: a URL from a provider core trusts, previewed through an open route and
19 * drawn from the same cached lookup, as a player or a card, never with the provider's script. Discovery is off at
20 * every step, so the only requests that leave go to a provider's own endpoint, never to an
21 * address a commenter typed.
22 *
23 * The route is `wpcom/v2`, registered as the identity routes are, so one definition is
24 * same-origin on self-hosted and Atomic and served through public-api on Simple.
25 */
26class Embeds extends WP_REST_Controller {
27
28    const ROUTE = 'comments/embed';
29
30    /**
31     * The instance registered without the WPCOM loader, which otherwise holds it.
32     *
33     * @var Embeds|null
34     */
35    private static $instance = null;
36
37    /**
38     * Whether the route has been hooked.
39     *
40     * @var bool
41     */
42    private static $hooked = false;
43
44    /**
45     * Wire the route onto `rest_api_init`. The loader instantiates this once.
46     */
47    public function __construct() {
48        $this->namespace = 'wpcom/v2';
49        $this->rest_base = self::ROUTE;
50
51        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
52    }
53
54    /**
55     * Register the route. Safe to call more than once.
56     *
57     * @return void
58     */
59    public static function init() {
60        if ( self::$hooked ) {
61            return;
62        }
63
64        self::$hooked = true;
65
66        if ( function_exists( 'wpcom_rest_api_v2_load_plugin' ) ) {
67            wpcom_rest_api_v2_load_plugin( self::class );
68        } else {
69            self::$instance = new self();
70        }
71    }
72
73    /**
74     * Whether the editor offers the embed block. The route is registered on every WordPress.com
75     * site, so this is also what keeps it closed where the comment form is not on.
76     *
77     * @return bool
78     */
79    public static function is_enabled() {
80        /**
81         * Offer the embed block in the Jetpack Comments editor.
82         *
83         * @since 0.5.0
84         *
85         * @param bool $enabled Whether to offer it. Default true, wherever the block editor is on.
86         */
87        return Comments::is_enabled() && Block_Editor::is_enabled() && (bool) apply_filters( 'jetpack_comments_block_editor_embeds', true );
88    }
89
90    /**
91     * Register the route.
92     *
93     * @return void
94     */
95    public function register_routes() {
96        register_rest_route(
97            $this->namespace,
98            '/' . self::ROUTE,
99            array(
100                'methods'             => WP_REST_Server::READABLE,
101                'callback'            => array( $this, 'preview' ),
102                'permission_callback' => '__return_true',
103                'args'                => array(
104                    'url' => array(
105                        'type'     => 'string',
106                        'required' => true,
107                    ),
108                ),
109            )
110        );
111    }
112
113    /**
114     * The oEmbed data for a URL, shaped as core's proxy shapes it, for the editor's preview.
115     *
116     * @param WP_REST_Request $request The request.
117     * @return WP_REST_Response|WP_Error
118     */
119    public function preview( WP_REST_Request $request ) {
120        if ( ! self::is_enabled() ) {
121            return new WP_Error( 'not_enabled', __( 'Embeds are not available on this site.', 'jetpack-comments' ), array( 'status' => 404 ) );
122        }
123
124        $url = self::trusted_url( $request->get_param( 'url' ) );
125
126        if ( ! $url ) {
127            return new WP_Error( 'oembed_invalid_url', get_status_header_desc( 404 ), array( 'status' => 404 ) );
128        }
129
130        $data = self::data( $url, false );
131
132        if ( false === $data ) {
133            if ( self::rate_limited() ) {
134                return new WP_Error( 'rate_limited', __( 'Too many requests. Please wait a moment and try again.', 'jetpack-comments' ), array( 'status' => 429 ) );
135            }
136
137            $data = self::data( $url );
138        }
139
140        if ( ! is_object( $data ) ) {
141            return new WP_Error( 'oembed_invalid_url', get_status_header_desc( 404 ), array( 'status' => 404 ) );
142        }
143
144        return self::respond( $data, $url );
145    }
146
147    /**
148     * Whether this visitor has asked for too many previews that were not already cached.
149     *
150     * @return bool
151     */
152    private static function rate_limited() {
153        // Each lookup is a request to a provider on the visitor's behalf. Thirty in ten minutes covers
154        // a reader trying links, not a script. The object cache counts atomically; a site without a
155        // persistent one, or one that cannot be reached, falls back to a transient, which a parallel burst can slip past.
156        // The address comes through the site's trusted proxy header, so one proxy is not one bucket.
157        $key   = 'embed_previews_' . md5( (string) IP_Utils::get_ip() );
158        $count = false;
159
160        if ( wp_using_ext_object_cache() ) {
161            wp_cache_add( $key, 0, 'jetpack_comments', 10 * MINUTE_IN_SECONDS );
162            $count = wp_cache_incr( $key, 1, 'jetpack_comments' );
163        }
164
165        if ( false === $count ) {
166            $count = (int) get_transient( 'jetpack_comments_' . $key ) + 1;
167            set_transient( 'jetpack_comments_' . $key, $count, 10 * MINUTE_IN_SECONDS );
168        }
169
170        return $count > 30;
171    }
172
173    /**
174     * The provider's oEmbed data for a trusted URL, shared by the preview and the comment.
175     *
176     * @param string $url   A URL from trusted_url().
177     * @param bool   $fetch Whether to ask the provider when nothing is cached.
178     * @return object|string|false The data, '' when the provider had none, or false when not cached and not fetched.
179     */
180    private static function data( $url, $fetch = true ) {
181        $cache_key = 'jetpack_comments_embed_' . md5( $url );
182        $cached    = get_transient( $cache_key );
183        $lookup    = self::lookup_url( $url );
184        $args      = array_merge( wp_embed_defaults( $lookup ), array( 'discover' => false ) );
185
186        // Fresh for a day, as core's proxy keeps its lookups, so a popular link costs one provider request.
187        /** This filter is documented in wp-includes/class-wp-oembed-controller.php */
188        $ttl   = (int) apply_filters( 'rest_oembed_ttl', DAY_IN_SECONDS, $lookup, $args );
189        $fresh = '' === $cached || ( is_object( $cached ) && (int) ( $cached->jetpack_comments_fetched ?? 0 ) > time() - $ttl );
190
191        if ( $fresh || ! $fetch ) {
192            return $cached;
193        }
194
195        $oembed = _wp_oembed_get_object();
196        $data   = $oembed->get_data( $lookup, $args );
197
198        if ( is_object( $data ) ) {
199            /** This filter is documented in wp-includes/class-wp-oembed.php */
200            $data->html = apply_filters( 'oembed_result', $oembed->data2html( $data, $lookup ), $lookup, $args );
201        } elseif ( is_object( $cached ) && '' !== self::player( $cached ) ) {
202            // A player outlives a failed refresh, as in core's embed cache. A card does not: its post may be gone.
203            $data = $cached;
204        } else {
205            // Kept an hour, so a dead link costs one provider request rather than one per page view.
206            set_transient( $cache_key, '', HOUR_IN_SECONDS );
207            return '';
208        }
209
210        // Kept past fresh, so a player is still there to fall back on when the next refresh fails.
211        $data->jetpack_comments_fetched = time();
212        set_transient( $cache_key, $data, $ttl + WEEK_IN_SECONDS );
213
214        return $data;
215    }
216
217    /**
218     * The oEmbed data as a response the browser and any cache between may keep: it is the same for every visitor.
219     *
220     * @param object $data The oEmbed data.
221     * @param string $url  The URL it was fetched for.
222     * @return WP_REST_Response|WP_Error
223     */
224    private static function respond( $data, $url ) {
225        // The editor would hand each entry to SandBox as a script of its own.
226        unset( $data->scripts, $data->jetpack_comments_fetched );
227        $player     = self::player( $data );
228        $data->card = '' === $player;
229        $data->html = $data->card ? self::card( $data, $url ) : $player;
230
231        // Nothing to show, so the comment will hold the link. Say so now.
232        if ( '' === $data->html ) {
233            return new WP_Error( 'oembed_invalid_url', get_status_header_desc( 404 ), array( 'status' => 404 ) );
234        }
235
236        $response = new WP_REST_Response( $data );
237        $response->header( 'Cache-Control', 'public, max-age=' . HOUR_IN_SECONDS );
238
239        return $response;
240    }
241
242    /**
243     * The player for the oEmbed data, or '' when it has none.
244     *
245     * @param object $data The oEmbed data.
246     * @return string
247     */
248    private static function player( $data ) {
249        $video = isset( $data->embed_product_id ) && is_scalar( $data->embed_product_id ) ? (string) $data->embed_product_id : '';
250
251        // TikTok's oEmbed sends its script, but it also documents a player that needs none.
252        if ( 'TikTok' === ( $data->provider_name ?? '' ) && ctype_digit( $video ) ) {
253            $author = isset( $data->author_name ) && is_string( $data->author_name ) && '' !== $data->author_name ? $data->author_name : 'TikTok';
254            /* translators: %s: The video's author. */
255            $title = sprintf( __( 'TikTok video by %s', 'jetpack-comments' ), $author );
256
257            return '<iframe src="https://www.tiktok.com/player/v1/' . $video . '" width="325" height="578" title="' . esc_attr( $title ) . '" allow="fullscreen" allowfullscreen></iframe>';
258        }
259
260        return self::sanitize_html( $data->html ?? '' );
261    }
262
263    /**
264     * A provider's player or photo, rebuilt as a bare iframe or img, or '' when it sent neither. Nothing else of
265     * its HTML is kept, so no script or inline handler runs first-party for every reader on a commenter's say-so.
266     *
267     * @param mixed $html What the provider sent.
268     * @return string
269     */
270    public static function sanitize_html( $html ) {
271        if ( ! is_string( $html ) ) {
272            return '';
273        }
274
275        $kept = array(
276            'IFRAME' => array( 'width', 'height', 'title', 'allow', 'allowfullscreen', 'frameborder', 'loading', 'referrerpolicy', 'sandbox' ),
277            'IMG'    => array( 'width', 'height', 'alt' ),
278        );
279
280        $sent = new \WP_HTML_Tag_Processor( $html );
281        while ( $sent->next_tag() ) {
282            $name = (string) $sent->get_tag();
283            $src  = $sent->get_attribute( 'src' );
284            $src  = is_string( $src ) ? esc_url_raw( str_starts_with( $src, '//' ) ? 'https:' . $src : $src ) : '';
285
286            if ( ! isset( $kept[ $name ] ) || 'https' !== wp_parse_url( $src, PHP_URL_SCHEME ) ) {
287                continue;
288            }
289
290            // A tag of our own, so only the attributes listed above come along.
291            $tag = new \WP_HTML_Tag_Processor( 'IFRAME' === $name ? '<iframe></iframe>' : '<img>' );
292            $tag->next_tag();
293            $tag->set_attribute( 'src', $src );
294            foreach ( $kept[ $name ] as $attribute ) {
295                $value = $sent->get_attribute( $attribute );
296                if ( null !== $value ) {
297                    $tag->set_attribute( $attribute, $value );
298                }
299            }
300
301            return $tag->get_updated_html();
302        }
303
304        return '';
305    }
306
307    /**
308     * A post a provider draws with its own script, as a card of our own markup: the brand's icon, its author,
309     * date, and text, and a link to the URL. Only text comes from the provider, so its markup never reaches the page.
310     *
311     * @param object $data The oEmbed data.
312     * @param string $url  The trusted URL the card links to.
313     * @return string The card, or '' when the provider sent no text.
314     */
315    public static function card( $data, $url ) {
316        $text  = '';
317        $after = '';
318        $last  = '';
319        $html  = new \WP_HTML_Tag_Processor( is_string( $data->html ?? null ) ? $data->html : '' );
320
321        // The post's text is the first paragraph of the provider's no-script fallback.
322        if ( $html->next_tag( 'p' ) ) {
323            while ( $html->next_token() ) {
324                $tag = $html->get_tag();
325
326                if ( '#text' === $html->get_token_type() ) {
327                    $text .= $html->get_modifiable_text();
328                } elseif ( 'BR' === $tag ) {
329                    $text .= "\n";
330                } elseif ( 'P' === $tag && $html->is_tag_closer() ) {
331                    break;
332                }
333            }
334
335            // Then the attribution, whose last link is the post's date.
336            while ( $html->next_token() ) {
337                $tag = $html->get_tag();
338
339                if ( '#text' === $html->get_token_type() ) {
340                    $after .= $html->get_modifiable_text();
341                    $last  .= $html->get_modifiable_text();
342                } elseif ( 'A' === $tag && ! $html->is_tag_closer() ) {
343                    $last = '';
344                } elseif ( 'BLOCKQUOTE' === $tag ) {
345                    break;
346                }
347            }
348        }
349
350        // Reddit's fallback has no paragraph, only the title.
351        if ( '' === trim( $text ) && isset( $data->title ) && is_string( $data->title ) ) {
352            $text = $data->title;
353        }
354
355        // A comment's card, not the whole post: X allows posts of 25,000 characters.
356        $text = trim( $text );
357        if ( mb_strlen( $text ) > 500 ) {
358            $text = rtrim( mb_substr( $text, 0, 500 ) ) . '…';
359        }
360
361        $paragraphs = '';
362        foreach ( preg_split( '/\n\s*\n/', trim( $text ) ) as $paragraph ) {
363            if ( '' !== trim( $paragraph ) ) {
364                // No raw newlines, which wpautop would turn into paragraphs of its own.
365                $paragraphs .= '<p>' . str_replace( "\n", '<br>', esc_html( trim( $paragraph ) ) ) . '</p>';
366            }
367        }
368
369        if ( '' === $paragraphs ) {
370            return '';
371        }
372
373        $provider = isset( $data->provider_name ) && is_string( $data->provider_name ) && '' !== $data->provider_name ? $data->provider_name : (string) wp_parse_url( $url, PHP_URL_HOST );
374        /* translators: %s: Where the post is from, e.g. X or Bluesky. */
375        $footer = '<a href="' . esc_url( $url ) . '" rel="nofollow ugc">' . esc_html( sprintf( __( 'View on %s', 'jetpack-comments' ), $provider ) ) . '</a>';
376
377        // Core's social link icons, in each brand's color but X's black, which a dark theme would lose.
378        $brands = array(
379            'x'              => array( 'x', 'currentColor' ),
380            'bluesky social' => array( 'bluesky', '#1185fe' ),
381            'reddit'         => array( 'reddit', '#ff4500' ),
382        );
383        $brand  = $brands[ strtolower( $provider ) ] ?? array( '', '' );
384        $icon   = '';
385        $header = array();
386
387        if ( '' !== $brand[0] && function_exists( 'block_core_social_link_get_icon' ) ) {
388            $svg = new \WP_HTML_Tag_Processor( block_core_social_link_get_icon( $brand[0] ) );
389            if ( $svg->next_tag( 'svg' ) ) {
390                $svg->set_attribute( 'style', "fill:{$brand[1]};width:1.25em;height:1.25em;vertical-align:-0.25em" );
391                $icon = $svg->get_updated_html() . ' ';
392            }
393        }
394
395        $author = isset( $data->author_name ) && is_string( $data->author_name ) ? $data->author_name : '';
396        if ( '' !== $author ) {
397            // Bluesky's name carries the handle; X's is only in its attribution.
398            if ( ! str_contains( $author, '(@' ) && preg_match( '/\(@\w+\)/', $after, $handle ) ) {
399                $author .= ' ' . $handle[0];
400            }
401
402            $profile  = isset( $data->author_url ) && is_string( $data->author_url ) ? esc_url( $data->author_url, array( 'https' ) ) : '';
403            $header[] = $profile ? '<a href="' . $profile . '" rel="nofollow ugc">' . esc_html( $author ) . '</a>' : esc_html( $author );
404        }
405
406        if ( 'reddit' === $brand[0] && preg_match( '#/r/(\w+)/#', $url, $community ) ) {
407            $header[] = esc_html( 'r/' . $community[1] );
408        }
409
410        // X writes the day out in English, Bluesky the moment in UTC.
411        $last = trim( $last );
412        $time = preg_match( '/^(?:[A-Z][a-z]+ \d{1,2}, \d{4}|\d{4}-\d\d-\d\dT[\d:.]+Z)$/', $last ) ? strtotime( $last ) : false;
413        if ( $time ) {
414            $header[] = '<time datetime="' . gmdate( 'Y-m-d', $time ) . '">' . esc_html( wp_date( get_option( 'date_format' ), $time, new \DateTimeZone( 'UTC' ) ) ) . '</time>';
415        }
416
417        $header = $icon . implode( ' &middot; ', $header );
418        $small  = 'font-size:0.875em;';
419
420        // Inline, as the players' sizing is, so the card holds its shape where the form's stylesheet is not loaded.
421        return '<blockquote class="jetpack-comments__embed-card" cite="' . esc_url( $url ) . '" style="margin:0;padding:1em;border:1px solid;' . ( $brand[1] ? "border-inline-start:4px solid {$brand[1]};" : '' ) . 'border-radius:8px;font-style:normal">'
422            . ( '' !== $header ? '<p style="margin-block-start:0;' . $small . '">' . $header . '</p>' : '' )
423            . $paragraphs
424            . '<p style="margin-block-end:0;' . $small . '">' . $footer . '</p></blockquote>';
425    }
426
427    /**
428     * The URL to ask the provider about. Core's list predates X's rename, and X's endpoint answers for both hosts.
429     *
430     * @param string $url A URL.
431     * @return string
432     */
433    private static function lookup_url( $url ) {
434        return (string) preg_replace( '#^(https?://)(?:www\.)?x\.com/#i', '$1twitter.com/', $url );
435    }
436
437    /**
438     * The URL as the site may embed it: http or https, under 2 KB, and from a provider core trusts.
439     *
440     * @param mixed $url What the commenter gave.
441     * @return string|false
442     */
443    public static function trusted_url( $url ) {
444        if ( ! self::is_enabled() || ! is_string( $url ) ) {
445            return false;
446        }
447
448        // kses writes & as &amp;, in the block's markup and in its attributes alike.
449        $url = trim( html_entity_decode( $url, ENT_QUOTES ) );
450
451        if ( '' === $url || strlen( $url ) > 2048 ) {
452            return false;
453        }
454
455        $url = esc_url_raw( $url, array( 'http', 'https' ) );
456
457        if ( '' === $url || false === _wp_oembed_get_object()->get_provider( self::lookup_url( $url ), array( 'discover' => false ) ) ) {
458            return false;
459        }
460
461        return $url;
462    }
463
464    /**
465     * The embed block as this package writes it: the URL alone, in the editor's markup. A URL the
466     * site will not embed becomes a paragraph holding the link. At render, the figure holds the
467     * embed itself, or the link where it may not.
468     *
469     * @param array     $block  Parsed embed block.
470     * @param bool|null $render Null while saving; at render, whether the comment may show the embed.
471     * @return array|null The block to keep, if any.
472     */
473    public static function block( array $block, $render = null ) {
474        $raw = isset( $block['attrs']['url'] ) && is_string( $block['attrs']['url'] ) ? $block['attrs']['url'] : '';
475        $url = self::trusted_url( $raw );
476
477        if ( ! $url ) {
478            $raw  = trim( html_entity_decode( $raw, ENT_QUOTES ) );
479            $link = esc_url_raw( $raw, array( 'http', 'https' ) );
480            $text = $link ? '<a href="' . esc_url( $link ) . '" rel="nofollow ugc">' . esc_html( $link ) . '</a>' : esc_html( $raw );
481
482            return '' === $text ? null : Block_Editor::paragraph( $text );
483        }
484
485        if ( null === $render ) {
486            $block['attrs'] = array( 'url' => $url );
487            $content        = esc_html( $url );
488        } else {
489            // The preview's own lookup, so the comment shows what the commenter saw; the delimiters are stripped after.
490            $block['attrs'] = array();
491            $content        = '';
492            $data           = $render ? self::data( $url ) : false;
493
494            if ( is_object( $data ) ) {
495                $content = self::player( $data );
496                $content = '' === $content ? self::card( $data, $url ) : $content;
497            }
498
499            if ( '' === $content ) {
500                $content = '<a href="' . esc_url( $url ) . '" rel="nofollow ugc">' . esc_html( $url ) . '</a>';
501            } else {
502                wp_enqueue_style( 'wp-block-embed' );
503
504                // Providers size the frame to the post's content width, wider than a nested comment.
505                $tags = new \WP_HTML_Tag_Processor( $content );
506                while ( $tags->next_tag( 'iframe' ) ) {
507                    // Pixels only: Spotify sends width="100%", which is no ratio.
508                    $width  = (string) $tags->get_attribute( 'width' );
509                    $height = (string) $tags->get_attribute( 'height' );
510                    $ratio  = ctype_digit( $width ) && ctype_digit( $height ) && (int) $width && (int) $height ? "aspect-ratio:$width/$height;height:auto;" : '';
511                    $style  = $tags->get_attribute( 'style' );
512                    $tags->set_attribute( 'style', "max-width:100%;$ratio" . ( is_string( $style ) ? $style : '' ) );
513                }
514                $content = $tags->get_updated_html();
515            }
516        }
517
518        $html = "\n<figure class=\"wp-block-embed\"><div class=\"wp-block-embed__wrapper\">\n$content\n</div></figure>\n";
519
520        $block['innerBlocks']  = array();
521        $block['innerHTML']    = $html;
522        $block['innerContent'] = array( $html );
523
524        return $block;
525    }
526}