Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.12% covered (warning)
74.12%
378 / 510
37.50% covered (danger)
37.50%
12 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
Image_CDN
74.12% covered (warning)
74.12%
378 / 510
37.50% covered (danger)
37.50%
12 / 32
1353.06
0.00% covered (danger)
0.00%
0 / 1
 instance
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_enabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setup
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
2.00
 enable_noresize_mode
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 filter_content_add
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 filter_content_remove
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 filter_photon_norezise_domain
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filter_photon_noresize_intermediate_sizes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filter_photon_noresize_thumbnail_urls
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 filter_photon_norezise_maybe_inject_sizes
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
110
 filter_photon_norezise_maybe_inject_sizes_api
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parse_images_from_html
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 parse_dimensions_from_filename
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 filter_the_content
78.88% covered (warning)
78.88%
127 / 161
0.00% covered (danger)
0.00%
0 / 1
180.00
 filter_the_galleries
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 filter_the_image_widget
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 filter_image_downsize
92.41% covered (success)
92.41%
73 / 79
0.00% covered (danger)
0.00%
0 / 1
39.67
 filter_srcset_array
65.57% covered (warning)
65.57%
40 / 61
0.00% covered (danger)
0.00%
0 / 1
56.74
 filter_sizes
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
8.30
 photon_should_skip_image
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate_image_url
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
9.37
 strip_image_dimensions_maybe
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 image_sizes
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
5
 find_registered_image_size
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
10
 should_rest_photon_image_downsize
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 should_rest_photon_image_downsize_override
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 should_rest_photon_image_downsize_insert_attachment
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 cleanup_rest_photon_image_downsize
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 override_image_downsize_in_rest_edit_context
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_amp_endpoint
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_supported_extensions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Class for photon functionality.
4 *
5 * @package automattic/jetpack-image-cdn
6 */
7
8namespace Automattic\Jetpack\Image_CDN;
9
10/**
11 * Class Image_CDN
12 */
13final class Image_CDN {
14
15    const PACKAGE_VERSION = '0.7.29';
16
17    /**
18     * Singleton.
19     *
20     * @var null
21     */
22    private static $instance = null;
23
24    /**
25     * Allowed extensions.
26     *
27     * @var string[] Allowed extensions must match https://code.trac.wordpress.org/browser/photon/index.php#L41
28     */
29    protected static $extensions = array(
30        'gif',
31        'jpg',
32        'jpeg',
33        'png',
34        // Jetpack assumes Photon_OpenCV backend class is being used on the server. See link in docblock.
35        'webp', // Photon_OpenCV supports webp with libwebp-*, getimageformat() returns webp
36        'heic', // Photon_OpenCV supports webp with libheif-*, getimageformat() returns jpeg so does not match docblock
37    );
38
39    /**
40     * Image sizes.
41     *
42     * Don't access this directly. Instead, use self::image_sizes() so it's actually populated with something.
43     *
44     * @var array Image sizes.
45     */
46    private static $image_sizes = null;
47
48    /**
49     * Whether Image CDN is enabled or not.
50     *
51     * This class will be instantiated if any plugin has activated image CDN module. Keeping this variable to check if module is active or not.
52     *
53     * @var bool Whether Image CDN is enabled or not.
54     */
55    private static $is_enabled = false;
56
57    /**
58     * Singleton implementation
59     *
60     * @return object
61     */
62    public static function instance() {
63        if ( ! self::$instance instanceof self ) {
64            self::$instance = new self();
65            self::$instance->setup();
66            self::$is_enabled = true;
67        }
68
69        return self::$instance;
70    }
71
72    /**
73     * Silence is golden.
74     */
75    private function __construct() {}
76
77    /**
78     * Check if image CDN is enabled as a module from Jetpack or any other plugin.
79     */
80    public static function is_enabled() {
81        return self::$is_enabled;
82    }
83
84    /**
85     * Register actions and filters, but only if basic Photon functions are available.
86     * The basic functions are found in ./functions.photon.php.
87     *
88     * @uses add_action, add_filter
89     * @return void
90     */
91    private function setup() {
92        /**
93         * Add a filter to easily apply image CDN urls without applying all `the_content` filters to any content.
94         *
95         * Since this is only applied if the module is active in Jetpack or any other plugin, it's a safe option to apply photon urls to any content.
96         */
97        add_filter( 'jetpack_image_cdn_content', array( __CLASS__, 'filter_the_content' ), 10 );
98
99        // Images in post content and galleries and widgets.
100        add_filter( 'the_content', array( __CLASS__, 'filter_the_content' ), 999999 );
101        add_filter( 'get_post_galleries', array( __CLASS__, 'filter_the_galleries' ), 999999 );
102        add_filter( 'widget_media_image_instance', array( __CLASS__, 'filter_the_image_widget' ), 999999 );
103        add_filter( 'widget_text', array( __CLASS__, 'filter_the_content' ) );
104
105        // Core image retrieval.
106        add_filter( 'image_downsize', array( $this, 'filter_image_downsize' ), 10, 3 );
107        add_filter( 'rest_request_before_callbacks', array( $this, 'should_rest_photon_image_downsize' ), 10, 3 );
108        add_action( 'rest_after_insert_attachment', array( $this, 'should_rest_photon_image_downsize_insert_attachment' ), 10, 2 );
109        add_filter( 'rest_request_after_callbacks', array( $this, 'cleanup_rest_photon_image_downsize' ) );
110
111        // Responsive image srcset substitution.
112        add_filter( 'wp_calculate_image_srcset', array( $this, 'filter_srcset_array' ), 10, 5 );
113        add_filter( 'wp_calculate_image_sizes', array( $this, 'filter_sizes' ), 1, 3 ); // Early so themes can still easily filter.
114
115        /**
116         * Allow Photon to disable uploaded images resizing and use its own resize capabilities instead.
117         *
118         * @module photon
119         *
120         * @since 7.1.0
121         *
122         * @param bool false Should Photon enable noresize mode. Default to false.
123         */
124        if ( apply_filters( 'jetpack_photon_noresize_mode', false ) ) {
125            $this->enable_noresize_mode();
126        }
127    }
128
129    /**
130     * Enables the noresize mode for Photon, allowing to avoid intermediate size files generation.
131     */
132    private function enable_noresize_mode() {
133        // The main objective of noresize mode is to disable additional resized image versions creation.
134        // This filter handles removal of additional sizes.
135        add_filter( 'intermediate_image_sizes_advanced', array( __CLASS__, 'filter_photon_noresize_intermediate_sizes' ) );
136
137        // Load the noresize srcset solution on priority of 20, allowing other plugins to set sizes earlier.
138        add_filter( 'wp_get_attachment_metadata', array( __CLASS__, 'filter_photon_norezise_maybe_inject_sizes' ), 20, 2 );
139
140        // Photonize thumbnail URLs in the API response.
141        add_filter( 'rest_api_thumbnail_size_urls', array( __CLASS__, 'filter_photon_noresize_thumbnail_urls' ) );
142
143        // This allows to assign the Photon domain to images that normally use the home URL as base.
144        add_filter( 'jetpack_photon_domain', array( __CLASS__, 'filter_photon_norezise_domain' ), 10, 2 );
145
146        add_filter( 'the_content', array( __CLASS__, 'filter_content_add' ), 0 );
147
148        // Jetpack hooks in at six nines (999999) so this filter does at seven.
149        add_filter( 'the_content', array( __CLASS__, 'filter_content_remove' ), 9999999 );
150
151        // Regular Photon operation mode filter doesn't run when is_admin(), so we need an additional filter.
152        // This is temporary until Jetpack allows more easily running these filters for is_admin().
153        if ( is_admin() ) {
154            add_filter( 'image_downsize', array( $this, 'filter_image_downsize' ), 5, 3 );
155
156            // Allows any image that gets passed to Photon to be resized via Photon.
157            add_filter( 'jetpack_photon_admin_allow_image_downsize', '__return_true' );
158        }
159    }
160
161    /**
162     * This is our catch-all to strip dimensions from intermediate images in content.
163     * Since this primarily only impacts post_content we do a little dance to add the filter early
164     * to `the_content` and then remove it later on in the same hook.
165     *
166     * @param String $content the post content.
167     * @return String the post content unchanged.
168     */
169    public static function filter_content_add( $content ) {
170        add_filter( 'jetpack_photon_pre_image_url', array( __CLASS__, 'strip_image_dimensions_maybe' ) );
171        return $content;
172    }
173
174    /**
175     * Removing the content filter that was set previously.
176     *
177     * @param String $content the post content.
178     * @return String the post content unchanged.
179     */
180    public static function filter_content_remove( $content ) {
181        remove_filter( 'jetpack_photon_pre_image_url', array( __CLASS__, 'strip_image_dimensions_maybe' ) );
182        return $content;
183    }
184
185    /**
186     * Short circuits the Photon filter to enable Photon processing for any URL.
187     *
188     * @param String $photon_url a proposed Photon URL for the media file.
189     *
190     * @return String an URL to be used for the media file.
191     */
192    public static function filter_photon_norezise_domain( $photon_url ) {
193        return $photon_url;
194    }
195
196    /**
197     * Disables intermediate sizes to disallow resizing.
198     *
199     * @return array Empty array.
200     */
201    public static function filter_photon_noresize_intermediate_sizes() {
202        return array();
203    }
204
205    /**
206     * Filter thumbnail URLS to not generate.
207     *
208     * @param array $sizes Image sizes.
209     *
210     * @return mixed
211     */
212    public static function filter_photon_noresize_thumbnail_urls( $sizes ) {
213        foreach ( $sizes as $size => $url ) {
214            $parts     = explode( '?', $url );
215            $arguments = $parts[1] ?? array();
216
217            $sizes[ $size ] = Image_CDN_Core::cdn_url( $url, wp_parse_args( $arguments ) );
218        }
219
220        return $sizes;
221    }
222
223    /**
224     * Inject image sizes to attachment metadata.
225     *
226     * @param array $data          Attachment metadata.
227     * @param int   $attachment_id Attachment's post ID.
228     *
229     * @return array Attachment metadata.
230     */
231    public static function filter_photon_norezise_maybe_inject_sizes( $data, $attachment_id ) {
232        // Can't do much if data is empty.
233        if ( empty( $data ) ) {
234            return $data;
235        }
236        $sizes_already_exist = (
237            is_array( $data )
238            && array_key_exists( 'sizes', $data )
239            && is_array( $data['sizes'] )
240            && ! empty( $data['sizes'] )
241        );
242        if ( $sizes_already_exist ) {
243            return $data;
244        }
245        // Missing some critical data we need to determine sizes, not processing.
246        if ( ! isset( $data['file'] )
247            || ! isset( $data['width'] )
248            || ! isset( $data['height'] )
249        ) {
250            return $data;
251        }
252
253        $mime_type           = get_post_mime_type( $attachment_id );
254        $attachment_is_image = preg_match( '!^image/!', $mime_type );
255
256        if ( 1 === $attachment_is_image ) {
257            $image_sizes   = new Image_CDN_Image_Sizes( $attachment_id, $data );
258            $data['sizes'] = $image_sizes->generate_sizes_meta();
259        }
260        return $data;
261    }
262
263    /**
264     * Inject image sizes to Jetpack REST API responses. This wraps the filter_photon_norezise_maybe_inject_sizes function.
265     *
266     * @param array $sizes Attachment sizes data.
267     * @param int   $attachment_id Attachment's post ID.
268     *
269     * @return array Attachment sizes array.
270     */
271    public static function filter_photon_norezise_maybe_inject_sizes_api( $sizes, $attachment_id ) {
272        return self::filter_photon_norezise_maybe_inject_sizes( wp_get_attachment_metadata( $attachment_id ), $attachment_id );
273    }
274
275    /**
276     * * IN-CONTENT IMAGE MANIPULATION FUNCTIONS
277     **/
278
279    /**
280     * Match all images and any relevant <a> tags in a block of HTML.
281     *
282     * @param string $content Some HTML.
283     * @return array An array of $images matches, where $images[0] is
284     *         an array of full matches, and the link_url, img_tag,
285     *         and img_url keys are arrays of those matches.
286     */
287    public static function parse_images_from_html( $content ) {
288        $images = array();
289
290        if ( preg_match_all( '#(?:<a[^>]*?\s+?href=["\'](?P<link_url>[^\s]+?)["\'][^>]*?>\s*)?(?P<img_tag><(?:img|amp-img|amp-anim)[^>]*?\s+?src=["\'](?P<img_url>[^\s]+?)["\'].*?>){1}(?:\s*</a>)?#is', $content, $images ) ) {
291            foreach ( $images as $key => $unused ) {
292                // Simplify the output as much as possible, mostly for confirming test results.
293                if ( is_numeric( $key ) && $key > 0 ) {
294                    unset( $images[ $key ] );
295                }
296            }
297
298            return $images;
299        }
300
301        return array();
302    }
303
304    /**
305     * Try to determine height and width from strings WP appends to resized image filenames.
306     *
307     * @param string $src The image URL.
308     * @return array An array consisting of width and height.
309     */
310    public static function parse_dimensions_from_filename( $src ) {
311        $width_height_string = array();
312
313        if ( preg_match( '#-(\d+)x(\d+)\.(?:' . implode( '|', self::$extensions ) . '){1}(?:\?.*)?$#i', $src, $width_height_string ) ) {
314            $width  = (int) $width_height_string[1];
315            $height = (int) $width_height_string[2];
316
317            if ( $width && $height ) {
318                return array( $width, $height );
319            }
320        }
321
322        return array( false, false );
323    }
324
325    /**
326     * Identify images in post content, and if images are local (uploaded to the current site), pass through Photon.
327     *
328     * @param string|mixed $content The content; should be a string but will convert to an empty string if not.
329     *
330     * @uses self::validate_image_url, apply_filters, Image_CDN_Core::cdn_url, esc_url
331     * @filter the_content
332     *
333     * @return string The content.
334     */
335    public static function filter_the_content( $content ) {
336        // Early return if content is empty or not a string.
337        if ( ! is_string( $content ) || '' === $content ) {
338            return '';
339        }
340
341        static $image_tags      = array( 'IMG', 'AMP-IMG', 'AMP-ANIM' );
342        $content_width          = null;
343        $image_sizes            = null;
344        $upload_dir             = null;
345        $processor              = new \WP_HTML_Tag_Processor( $content );
346        $nearest_preceding_href = null;
347
348        // Visit every image-containing tag in the document.
349        while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
350            /*
351             * When an image is wrapped by an A element, the goal is to modify
352             * both elements. Thus it's important to track links that may be
353             * opened before reaching any image. In normative HTML this detection
354             * is reliable enough, but it could be confused in cases where other
355             * tags implicitly close the A. If this additional reliability is
356             * required, replace the Tag Processor with the HTML Processor.
357             */
358            if ( 'A' === $processor->get_tag() ) {
359                // If this is a closing tag the attribute will be `null`.
360                $nearest_preceding_href = $processor->get_attribute( 'href' );
361                $processor->set_bookmark( 'link' );
362                continue;
363            }
364
365            // Identify image source.
366            $src_orig             = $processor->get_attribute( 'src' );
367            $src                  = $src_orig;
368            $placeholder_src      = null;
369            $placeholder_src_orig = null;
370
371            /*
372             * Only examine tags that are considered an image,
373             * with a valid src attribute.
374             * If encountering a closing tag then this is not the image being sought.
375             */
376            if (
377                $processor->is_tag_closer()
378                || ! in_array( $processor->get_tag(), $image_tags, true )
379                || ! is_string( $src )
380                || $src === ''
381            ) {
382                continue;
383            }
384
385            $processor->set_bookmark( 'image' );
386
387            /*
388             * At this point a target image has been found. Initialize the
389             * shared data and then process each image as it appears.
390             */
391            if ( null === $content_width ) {
392                $content_width = Image_CDN_Core::get_jetpack_content_width();
393                $image_sizes   = self::image_sizes();
394                $upload_dir    = wp_get_upload_dir();
395            }
396
397            /*
398             * To preserve legacy behaviors for filtering by third-party plugins,
399             * create a normalized HTML string representing the tag. This will
400             * present all attributes as double-quoted attributes and include at
401             * most one copy of each attribute, escaping all values appropriately.
402             */
403            $tag_name = strtolower( (string) $processor->get_tag() );
404            $tag      = new \WP_HTML_Tag_Processor( "<{$tag_name}>" );
405            $tag->next_tag();
406            foreach ( $processor->get_attribute_names_with_prefix( '' ) ?? array() as $name ) {
407                $tag->set_attribute( $name, $processor->get_attribute( $name ) );
408            }
409            $tag = $tag->get_updated_html();
410
411            // Default to resize, though fit may be used in certain cases where a dimension cannot be ascertained.
412            $transform = 'resize';
413
414            // Flag if we need to munge a fullsize URL.
415            $fullsize_url = false;
416
417            /**
418             * Allow specific images to be skipped by Photon.
419             *
420             * @module photon
421             *
422             * @since 2.0.3
423             *
424             * @param bool              false Should Photon ignore this image. Default to false.
425             * @param string            $src  Image URL.
426             * @param string|array|null $tag  Image Tag (Image HTML output) or array of image details for srcset.
427             */
428            if ( apply_filters( 'jetpack_photon_skip_image', false, $src, $tag ) ) {
429                continue;
430            }
431
432            $data_lazy_src      = $processor->get_attribute( 'data-lazy-src' );
433            $data_lazy_original = $processor->get_attribute( 'data-lazy-original' );
434
435            $source_type     = 'src';
436            $chosen_data_src = null;
437
438            // Prefer a URL from the `data-lazy-src` attribute.
439            if ( null === $chosen_data_src && is_string( $data_lazy_src ) && ! empty( $data_lazy_src ) ) {
440                $source_type     = 'data-lazy-src';
441                $chosen_data_src = $data_lazy_src;
442            }
443
444            // Fall back to a URL from the `data-lazy-original` attribute.
445            if ( null === $chosen_data_src && is_string( $data_lazy_original ) && ! empty( $data_lazy_original ) ) {
446                $source_type     = 'data-lazy-original';
447                $chosen_data_src = $data_lazy_original;
448            }
449
450            // Update the src if one was provided in the `data-lazy-` attributes.
451            if ( 'src' !== $source_type ) {
452                $placeholder_src_orig = $src;
453                $placeholder_src      = $placeholder_src_orig;
454                $src_orig             = $chosen_data_src;
455                $src                  = $src_orig;
456            }
457
458            // Check if image URL should be used with Photon.
459            if ( self::validate_image_url( $src ) ) {
460                $width  = $processor->get_attribute( 'width' );
461                $height = $processor->get_attribute( 'height' );
462
463                // First, check the image tag. Note we only check for pixel sizes now; HTML4 percentages have never been correctly
464                // supported, so we stopped pretending to support them in JP 9.1.0.
465                if ( ! is_string( $width ) || ! ctype_digit( $width ) ) {
466                    $width = false;
467                }
468
469                if ( ! is_string( $height ) || ! ctype_digit( $height ) ) {
470                    $height = false;
471                }
472
473                $needs_sizing = false === $width && false === $height;
474                $size         = null;
475
476                if ( $needs_sizing ) {
477                    // Find the first CSS class listed with a prefix of `size-`, e.g. `size-full-width`
478                    foreach ( $processor->class_list() ?? array() as $class_name ) {
479                        if ( str_starts_with( $class_name, 'size-' ) ) {
480                            $size = substr( $class_name, strlen( 'size-' ) );
481                            break;
482                        }
483                    }
484                }
485
486                if ( $needs_sizing && 'full' !== $size && is_string( $size ) && isset( $image_sizes[ $size ] ) && is_array( $image_sizes[ $size ] ) ) {
487                    $width     = (int) $image_sizes[ $size ]['width'];
488                    $height    = (int) $image_sizes[ $size ]['height'];
489                    $transform = $image_sizes[ $size ]['crop'] ? 'resize' : 'fit';
490                } else {
491                    unset( $size );
492                }
493
494                // WP Attachment ID, if uploaded to this site.
495                $attachment_id = null;
496                foreach ( $processor->class_list() ?? array() as $class_name ) {
497                    if ( str_starts_with( $class_name, 'wp-image-' ) ) {
498                        $attachment_id = substr( $class_name, strlen( 'wp-image-' ) );
499                        break;
500                    }
501                }
502
503                // These values have not been used for a very long time, but removing them could break something.
504                $images = array();
505                $index  = 0;
506                if (
507                    $attachment_id &&
508                    preg_match( '#^[1-9][0-9]*$#', $attachment_id ) &&
509                    is_array( $upload_dir ) &&
510                    str_starts_with( $src, $upload_dir['baseurl'] ) &&
511                    /**
512                     * Filter whether an image using an attachment ID in its class has to be uploaded to the local site to go through Photon.
513                     *
514                     * @module photon
515                     *
516                     * @since 2.0.3
517                     *
518                     * @param bool false Was the image uploaded to the local site. Default to false.
519                     * @param array $args {
520                     *   Array of image details.
521                     *
522                     *   @type $src Image URL.
523                     *   @type tag Image tag (Image HTML output).
524                     * }
525                     */
526                    apply_filters( 'jetpack_photon_image_is_local', false, compact( 'src', 'tag', 'images', 'index' ) )
527                ) {
528                    $attachment_id = (int) $attachment_id;
529                    $attachment    = get_post( $attachment_id );
530
531                    // Basic check on returned post object.
532                    if ( is_object( $attachment ) && ! is_wp_error( $attachment ) && 'attachment' === $attachment->post_type ) {
533                        $src_per_wp = wp_get_attachment_image_src( $attachment_id, $size ?? 'full' );
534
535                        if ( self::validate_image_url( $src_per_wp[0] ) ) {
536                            $src          = $src_per_wp[0];
537                            $fullsize_url = true;
538
539                            // Prevent image distortion if a detected dimension exceeds the image's natural dimensions.
540                            if ( ( false !== $width && $width > $src_per_wp[1] ) || ( false !== $height && $height > $src_per_wp[2] ) ) {
541                                $width  = false === $width ? false : min( $width, $src_per_wp[1] );
542                                $height = false === $height ? false : min( $height, $src_per_wp[2] );
543                            }
544
545                            // If no width and height are found, max out at source image's natural dimensions.
546                            // Otherwise, respect registered image sizes' cropping setting.
547                            if ( false === $width && false === $height ) {
548                                $width     = $src_per_wp[1];
549                                $height    = $src_per_wp[2];
550                                $transform = 'fit';
551                            } elseif ( isset( $size ) && is_array( $image_sizes ) && array_key_exists( $size, $image_sizes ) && isset( $image_sizes[ $size ]['crop'] ) ) {
552                                $transform = $image_sizes[ $size ]['crop'] ? 'resize' : 'fit';
553                            }
554                        }
555                    } else {
556                        unset( $attachment_id );
557                        unset( $attachment );
558                    }
559                }
560
561                // If image tag lacks width and height arguments, try to determine from strings WP appends to resized image filenames.
562                if ( false === $width && false === $height ) {
563                    list( $width, $height ) = self::parse_dimensions_from_filename( $src );
564                }
565
566                $width_orig     = $width;
567                $height_orig    = $height;
568                $transform_orig = $transform;
569
570                // If width is available, constrain to $content_width.
571                if ( false !== $width && is_numeric( $content_width ) && $width > $content_width ) {
572                    if ( false !== $height ) {
573                        $height = round( ( $content_width * $height ) / $width );
574                    }
575                    $width = $content_width;
576                }
577
578                // Set a width if none is found and $content_width is available.
579                // If width is set in this manner and height is available, use `fit` instead of `resize` to prevent skewing.
580                if ( false === $width && is_numeric( $content_width ) ) {
581                    $width = (int) $content_width;
582
583                    if ( false !== $height ) {
584                        $transform = 'fit';
585                    }
586                }
587
588                // Detect if image source is for a custom-cropped thumbnail and prevent further URL manipulation.
589                if ( ! $fullsize_url && preg_match_all( '#-e[a-z0-9]+(-\d+x\d+)?\.(' . implode( '|', self::$extensions ) . '){1}$#i', basename( $src ), $filename ) ) {
590                    $fullsize_url = true;
591                }
592
593                // Build URL, first maybe removing WP's resized string so we pass the original image to Photon.
594                if ( ! $fullsize_url && is_array( $upload_dir ) && str_starts_with( $src, $upload_dir['baseurl'] ) ) {
595                    $src = self::strip_image_dimensions_maybe( $src );
596                }
597
598                // Build array of Photon args and expose to filter before passing to Photon URL function.
599                $args = array();
600
601                if ( false !== $width && false !== $height ) {
602                    $args[ $transform ] = $width . ',' . $height;
603                } elseif ( false !== $width ) {
604                    $args['w'] = $width;
605                } elseif ( false !== $height ) {
606                    $args['h'] = $height;
607                }
608
609                /**
610                 * Filter the array of Photon arguments added to an image when it goes through Photon.
611                 * By default, only includes width and height values.
612                 *
613                 * @see https://developer.wordpress.com/docs/photon/api/
614                 *
615                 * @module photon
616                 *
617                 * @since 2.0.0
618                 * @since 0.4.7 Passes image tag name instead of full HTML of tag.
619                 *
620                 * @param array $args Array of Photon Arguments.
621                 * @param array $details {
622                 *     Array of image details.
623                 *
624                 *     @type string    $tag            Image tag (Image HTML output).
625                 *     @type string    $src            Image URL.
626                 *     @type string    $src_orig       Original Image URL.
627                 *     @type int|false $width          Image width.
628                 *     @type int|false $height         Image height.
629                 *     @type int|false $width_orig     Original image width before constrained by content_width.
630                 *     @type int|false $height_orig    Original Image height before constrained by content_width.
631                 *     @type string    $transform      Transform.
632                 *     @type string    $transform_orig Original transform before constrained by content_width.
633                 * }
634                 */
635                $args = apply_filters( 'jetpack_photon_post_image_args', $args, compact( 'tag', 'src', 'src_orig', 'width', 'height', 'width_orig', 'height_orig', 'transform', 'transform_orig' ) );
636
637                $photon_url = Image_CDN_Core::cdn_url( $src, $args );
638
639                // Modify image tag if Photon function provides a URL
640                // Ensure changes are only applied to the current image by copying and modifying the matched tag, then replacing the entire tag with our modified version.
641                if ( $src !== $photon_url ) {
642                    // If present, replace the link href with a Photoned URL for the full-size image.
643                    if ( is_string( $nearest_preceding_href ) && self::validate_image_url( $nearest_preceding_href ) ) {
644                        $processor->seek( 'link' );
645                        $processor->set_attribute( 'href', Image_CDN_Core::cdn_url( $nearest_preceding_href ) );
646                        $processor->seek( 'image' );
647                    }
648
649                    // Supplant the original source value with our Photon URL.
650                    $processor->set_attribute( 'src', $photon_url );
651
652                    // If Lazy Load is in use, pass placeholder image through Photon.
653                    if ( $placeholder_src !== null && self::validate_image_url( $placeholder_src ) ) {
654                        $placeholder_src = Image_CDN_Core::cdn_url( $placeholder_src );
655
656                        if ( $placeholder_src !== $placeholder_src_orig ) {
657                            $processor->set_attribute( $source_type, $placeholder_src );
658                        }
659                    }
660
661                    // If we are not transforming the image with resize, fit, or letterbox (lb), then we should remove
662                    // the width and height arguments (including HTML4 percentages) from the image to prevent distortion.
663                    // Even if $args['w'] and $args['h'] are present, Photon does not crop to those dimensions. Instead,
664                    // it appears to favor height.
665                    //
666                    // If we are transforming the image via one of those methods, let's update the width and height attributes.
667                    if ( empty( $args['resize'] ) && empty( $args['fit'] ) && empty( $args['lb'] ) ) {
668                        $processor->remove_attribute( 'width' );
669                        $processor->remove_attribute( 'height' );
670                    } else {
671                        $resize_args = $args['resize'] ?? false;
672                        if ( false === $resize_args ) {
673                            $resize_args = ( ! $resize_args && isset( $args['fit'] ) )
674                                ? $args['fit']
675                                : false;
676                        }
677                        if ( false === $resize_args ) {
678                            $resize_args = ( ! $resize_args && isset( $args['lb'] ) )
679                                ? $args['lb']
680                                : false;
681                        }
682
683                        list( $resize_width, $resize_height ) = explode( ',', $resize_args );
684                        $processor->set_attribute( 'width', trim( $resize_width ) );
685                        $processor->set_attribute( 'height', trim( $resize_height ) );
686                    }
687
688                    // Tag an image for dimension checking.
689                    if ( ! self::is_amp_endpoint() ) {
690                        $processor->set_attribute( 'data-recalc-dims', '1' );
691                    }
692                }
693            } elseif (
694                preg_match( '#^http(s)?://i[\d]{1}.wp.com#', $src )
695                && is_string( $nearest_preceding_href )
696                && self::validate_image_url( $nearest_preceding_href )
697            ) {
698                $processor->seek( 'link' );
699                $processor->set_attribute( 'href', Image_CDN_Core::cdn_url( $nearest_preceding_href ) );
700                $processor->seek( 'image' );
701            }
702        }
703
704        return $processor->get_updated_html();
705    }
706
707    /**
708     * Filter Core galleries
709     *
710     * @param array $galleries Gallery array.
711     *
712     * @return array
713     */
714    public static function filter_the_galleries( $galleries ) {
715        if ( empty( $galleries ) || ! is_array( $galleries ) ) {
716            return $galleries;
717        }
718
719        // Pass by reference, so we can modify them in place.
720        foreach ( $galleries as &$this_gallery ) {
721            if ( is_string( $this_gallery ) ) {
722                $this_gallery = self::filter_the_content( $this_gallery );
723            }
724        }
725        unset( $this_gallery ); // break the reference.
726
727        return $galleries;
728    }
729
730    /**
731     * Runs the image widget through photon.
732     *
733     * @param array $instance Image widget instance data.
734     * @return array
735     */
736    public static function filter_the_image_widget( $instance ) {
737        if ( ! $instance['attachment_id'] && $instance['url'] ) {
738            Image_CDN_Core::cdn_url(
739                $instance['url'],
740                array(
741                    'w' => $instance['width'],
742                    'h' => $instance['height'],
743                )
744            );
745        }
746
747        return $instance;
748    }
749
750    /**
751     * * CORE IMAGE RETRIEVAL
752     **/
753
754    /**
755     * Filter post thumbnail image retrieval, passing images through Photon
756     *
757     * @param string|bool  $image Image URL.
758     * @param int          $attachment_id Attachment ID.
759     * @param string|array $size Declared size or a size array.
760     * @uses is_admin, apply_filters, wp_get_attachment_url, self::validate_image_url, this::image_sizes, jetpack_photon_url
761     * @filter image_downsize
762     * @return string|bool
763     */
764    public function filter_image_downsize( $image, $attachment_id, $size ) {
765        // Don't foul up the admin side of things, unless a plugin wants to.
766        if ( is_admin() &&
767            /**
768             * Provide plugins a way of running Photon for images in the WordPress Dashboard (wp-admin).
769             *
770             * Note: enabling this will result in Photon URLs added to your post content, which could make migrations across domains (and off Photon) a bit more challenging.
771             *
772             * @module photon
773             *
774             * @since 4.8.0
775             *
776             * @param bool false Stop Photon from being run on the Dashboard. Default to false.
777             * @param array $args {
778             *   Array of image details.
779             *
780             *   @type $image Image URL.
781             *   @type $attachment_id Attachment ID of the image.
782             *   @type $size Image size. Can be a string (name of the image size, e.g. full) or an array of width and height.
783             * }
784             */
785            false === apply_filters( 'jetpack_photon_admin_allow_image_downsize', false, compact( 'image', 'attachment_id', 'size' ) )
786        ) {
787            return $image;
788        }
789
790        /**
791         * Provide plugins a way of preventing Photon from being applied to images retrieved from WordPress Core.
792         *
793         * @module photon
794         *
795         * @since 2.0.0
796         *
797         * @param bool false Stop Photon from being applied to the image. Default to false.
798         * @param array $args {
799         *   Array of image details.
800         *
801         *   @type $image Image URL.
802         *   @type $attachment_id Attachment ID of the image.
803         *   @type $size Image size. Can be a string (name of the image size, e.g. full) or an array of width and height.
804         * }
805         */
806        if ( apply_filters( 'jetpack_photon_override_image_downsize', false, compact( 'image', 'attachment_id', 'size' ) ) ) {
807            return $image;
808        }
809
810        // Get the image URL and proceed with Photon-ification if successful.
811        $image_url = wp_get_attachment_url( $attachment_id );
812
813        // Set this to true later when we know we have size meta.
814        $has_size_meta = false;
815
816        if ( $image_url ) {
817            // Check if image URL should be used with Photon.
818            if ( ! self::validate_image_url( $image_url ) ) {
819                return $image;
820            }
821
822            $intermediate = true; // For the fourth array item returned by the image_downsize filter.
823
824            $registered_size = self::find_registered_image_size( $size );
825
826            // If an image is requested with a size known to WordPress, use that size's settings with Photon.
827            if ( $registered_size ) {
828                $size       = $registered_size;
829                $image_args = self::image_sizes();
830                $image_args = $image_args[ $size ];
831
832                $photon_args = array();
833
834                $image_meta = image_get_intermediate_size( $attachment_id, $size );
835
836                // 'full' is a special case: We need consistent data regardless of the requested size.
837                if ( 'full' === $size ) {
838                    $image_meta   = wp_get_attachment_metadata( $attachment_id );
839                    $intermediate = false;
840                } elseif ( ! $image_meta ) {
841                    // If we still don't have any image meta at this point, it's probably from a custom thumbnail size
842                    // for an image that was uploaded before the custom image was added to the theme.  Try to determine the size manually.
843                    $image_meta = wp_get_attachment_metadata( $attachment_id );
844
845                    if ( isset( $image_meta['width'] ) && isset( $image_meta['height'] ) ) {
846                        $image_resized = image_resize_dimensions( $image_meta['width'], $image_meta['height'], $image_args['width'], $image_args['height'], $image_args['crop'] );
847                        if ( $image_resized ) { // This could be false when the requested image size is larger than the full-size image.
848                            $image_meta['width']  = $image_resized[6];
849                            $image_meta['height'] = $image_resized[7];
850                        }
851                    }
852                }
853
854                if ( isset( $image_meta['width'] ) && isset( $image_meta['height'] ) ) {
855                    $image_args['width']  = (int) $image_meta['width'];
856                    $image_args['height'] = (int) $image_meta['height'];
857
858                    list( $image_args['width'], $image_args['height'] ) = image_constrain_size_for_editor( $image_args['width'], $image_args['height'], $size, 'display' );
859                    $has_size_meta                                      = true;
860                }
861
862                // Expose determined arguments to a filter before passing to Photon.
863                $transform = $image_args['crop'] ? 'resize' : 'fit';
864
865                // Check specified image dimensions and account for possible zero values; photon fails to resize if a dimension is zero.
866                if ( 0 === $image_args['width'] || 0 === $image_args['height'] ) {
867                    if ( 0 === $image_args['width'] && 0 < $image_args['height'] ) {
868                        $photon_args['h'] = $image_args['height'];
869                    } elseif ( 0 === $image_args['height'] && 0 < $image_args['width'] ) {
870                        $photon_args['w'] = $image_args['width'];
871                    }
872                } else {
873                    $image_meta = wp_get_attachment_metadata( $attachment_id );
874                    if ( ( 'resize' === $transform ) && $image_meta ) {
875                        if ( isset( $image_meta['width'] ) && isset( $image_meta['height'] ) ) {
876                            // Lets make sure that we don't upscale images since wp never upscales them as well.
877                            $smaller_width  = ( ( $image_meta['width'] < $image_args['width'] ) ? $image_meta['width'] : $image_args['width'] );
878                            $smaller_height = ( ( $image_meta['height'] < $image_args['height'] ) ? $image_meta['height'] : $image_args['height'] );
879
880                            $photon_args[ $transform ] = $smaller_width . ',' . $smaller_height;
881                        }
882                    } else {
883                        $photon_args[ $transform ] = $image_args['width'] . ',' . $image_args['height'];
884                    }
885                }
886
887                /**
888                 * Filter the Photon Arguments added to an image when going through Photon, when that image size is a string.
889                 * Image size will be a string (e.g. "full", "medium") when it is known to WordPress.
890                 *
891                 * @module photon
892                 *
893                 * @since 2.0.0
894                 *
895                 * @param array $photon_args Array of Photon arguments.
896                 * @param array $args {
897                 *   Array of image details.
898                 *
899                 *   @type array $image_args Array of Image arguments (width, height, crop).
900                 *   @type string $image_url Image URL.
901                 *   @type int $attachment_id Attachment ID of the image.
902                 *   @type string|int $size Image size. Can be a string (name of the image size, e.g. full) or an integer.
903                 *   @type string $transform Value can be resize or fit.
904                 *                    @see https://developer.wordpress.com/docs/photon/api
905                 * }
906                 */
907                $photon_args = apply_filters( 'jetpack_photon_image_downsize_string', $photon_args, compact( 'image_args', 'image_url', 'attachment_id', 'size', 'transform' ) );
908
909                // Generate Photon URL.
910                $image = array(
911                    Image_CDN_Core::cdn_url( $image_url, $photon_args ),
912                    $has_size_meta ? $image_args['width'] : false,
913                    $has_size_meta ? $image_args['height'] : false,
914                    $intermediate,
915                );
916            } elseif ( is_array( $size ) ) {
917                // Pull width and height values from the provided array, if possible.
918                $width  = isset( $size[0] ) ? (int) $size[0] : false;
919                $height = isset( $size[1] ) ? (int) $size[1] : false;
920
921                // Don't bother if necessary parameters aren't passed.
922                if ( ! $width || ! $height ) {
923                    return $image;
924                }
925
926                $image_meta = wp_get_attachment_metadata( $attachment_id );
927                if ( isset( $image_meta['width'] ) && isset( $image_meta['height'] ) ) {
928                    $image_resized = image_resize_dimensions( $image_meta['width'], $image_meta['height'], $width, $height );
929
930                    if ( $image_resized ) { // This could be false when the requested image size is larger than the full-size image.
931                        $width  = $image_resized[6];
932                        $height = $image_resized[7];
933                    } else {
934                        $width  = $image_meta['width'];
935                        $height = $image_meta['height'];
936                    }
937
938                    $has_size_meta = true;
939                }
940
941                list( $width, $height ) = image_constrain_size_for_editor( $width, $height, $size );
942
943                // Expose arguments to a filter before passing to Photon.
944                $photon_args = array(
945                    'fit' => $width . ',' . $height,
946                );
947
948                /**
949                 * Filter the Photon Arguments added to an image when going through Photon,
950                 * when the image size is an array of height and width values.
951                 *
952                 * @module photon
953                 *
954                 * @since 2.0.0
955                 *
956                 * @param array $photon_args Array of Photon arguments.
957                 * @param array $args {
958                 *   Array of image details.
959                 *
960                 *   @type $width Image width.
961                 *   @type height Image height.
962                 *   @type $image_url Image URL.
963                 *   @type $attachment_id Attachment ID of the image.
964                 * }
965                 */
966                $photon_args = apply_filters( 'jetpack_photon_image_downsize_array', $photon_args, compact( 'width', 'height', 'image_url', 'attachment_id' ) );
967
968                // Generate Photon URL.
969                $image = array(
970                    Image_CDN_Core::cdn_url( $image_url, $photon_args ),
971                    $has_size_meta ? $width : false,
972                    $has_size_meta ? $height : false,
973                    $intermediate,
974                );
975            }
976        }
977
978        return $image;
979    }
980
981    /**
982     * Filters an array of image `srcset` values, replacing each URL with its Photon equivalent.
983     *
984     * @param array  $sources An array of image urls and widths.
985     * @param array  $size_array The size array for srcset.
986     * @param string $image_src The image src attribute.
987     * @param array  $image_meta The image meta.
988     * @param int    $attachment_id Attachment ID.
989     *
990     * @uses self::validate_image_url, Image_CDN_Core::cdn_url
991     * @uses Image_CDN::strip_image_dimensions_maybe, Image_CDN_Core::get_jetpack_content_width
992     *
993     * @return array An array of Photon image urls and widths.
994     */
995    public function filter_srcset_array( $sources = array(), $size_array = array(), $image_src = '', $image_meta = array(), $attachment_id = 0 ) {
996        // Check if we are supposed to skip the main image.
997        if ( $this->photon_should_skip_image( $image_src ) ) {
998            return $sources;
999        }
1000
1001        if ( ! is_array( $sources ) || array() === $sources ) {
1002            return $sources;
1003        }
1004        $upload_dir = wp_get_upload_dir();
1005
1006        foreach ( $sources as $i => $source ) {
1007            if ( ! self::validate_image_url( $source['url'] ) ) {
1008                continue;
1009            }
1010
1011            /** This filter is already documented in class-image-cdn.php */
1012            if ( apply_filters( 'jetpack_photon_skip_image', false, $source['url'], $source ) ) {
1013                continue;
1014            }
1015
1016            $url                    = $source['url'];
1017            list( $width, $height ) = self::parse_dimensions_from_filename( $url );
1018
1019            // It's quicker to get the full size with the data we have already, if available.
1020            if ( ! empty( $attachment_id ) ) {
1021                $url = wp_get_attachment_url( $attachment_id );
1022            } else {
1023                $url = self::strip_image_dimensions_maybe( $url );
1024            }
1025
1026            $args = array();
1027            if ( 'w' === $source['descriptor'] ) {
1028                if ( $height && ( (int) $source['value'] === $width ) ) {
1029                    $args['resize'] = $width . ',' . $height;
1030                } else {
1031                    $args['w'] = $source['value'];
1032                }
1033            }
1034
1035            $sources[ $i ]['url'] = Image_CDN_Core::cdn_url( $url, $args );
1036        }
1037
1038        /**
1039         * At this point, $sources is the original srcset with Photonized URLs.
1040         * Now, we're going to construct additional sizes based on multiples of the content_width.
1041         * This will reduce the gap between the largest defined size and the original image.
1042         */
1043
1044        /**
1045         * Filter the multiplier Photon uses to create new srcset items.
1046         * Return false to short-circuit and bypass auto-generation.
1047         *
1048         * @module photon
1049         *
1050         * @since 4.0.4
1051         *
1052         * @param array|bool $multipliers Array of multipliers to use or false to bypass.
1053         */
1054        $multipliers = apply_filters( 'jetpack_photon_srcset_multipliers', array( 2, 3 ) );
1055        $url         = trailingslashit( $upload_dir['baseurl'] ) . $image_meta['file'];
1056
1057        if (
1058            /** Short-circuit via jetpack_photon_srcset_multipliers filter. */
1059            is_array( $multipliers )
1060            /** This filter is already documented in class-image-cdn.php */
1061            && ! apply_filters( 'jetpack_photon_skip_image', false, $url, null )
1062            /** Verify basic meta is intact. */
1063            && isset( $image_meta['width'] ) && isset( $image_meta['height'] ) && isset( $image_meta['file'] )
1064            /** Verify we have the requested width/height. */
1065            && isset( $size_array[0] ) && isset( $size_array[1] )
1066        ) {
1067
1068            $fullwidth  = $image_meta['width'];
1069            $fullheight = $image_meta['height'];
1070            $reqwidth   = $size_array[0];
1071            $reqheight  = $size_array[1];
1072
1073            $constrained_size = wp_constrain_dimensions( $fullwidth, $fullheight, $reqwidth );
1074            $expected_size    = array( $reqwidth, $reqheight );
1075
1076            if ( abs( $constrained_size[0] - $expected_size[0] ) <= 1 && abs( $constrained_size[1] - $expected_size[1] ) <= 1 ) {
1077                $crop = 'soft';
1078                $base = Image_CDN_Core::get_jetpack_content_width() ? Image_CDN_Core::get_jetpack_content_width() : 1000; // Provide a default width if none set by the theme.
1079            } else {
1080                $crop = 'hard';
1081                $base = $reqwidth;
1082            }
1083
1084            $currentwidths = array_keys( $sources );
1085            $newsources    = null;
1086
1087            foreach ( $multipliers as $multiplier ) {
1088
1089                $newwidth = $base * $multiplier;
1090                foreach ( $currentwidths as $currentwidth ) {
1091                    // If a new width would be within 100 pixes of an existing one or larger than the full size image, skip.
1092                    if ( abs( $currentwidth - $newwidth ) < 50 || ( $newwidth > $fullwidth ) ) {
1093                        continue 2; // Bump out back to the $multipliers as $multiplier.
1094                    }
1095                } //end foreach ( $currentwidths as $currentwidth ){
1096
1097                if ( 'soft' === $crop ) {
1098                    $args = array(
1099                        'w' => $newwidth,
1100                    );
1101                } else { // hard crop, e.g. add_image_size( 'example', 200, 200, true ).
1102                    $args = array(
1103                        'zoom'   => $multiplier,
1104                        'resize' => $reqwidth . ',' . $reqheight,
1105                    );
1106                }
1107
1108                $newsources[ $newwidth ] = array(
1109                    'url'        => Image_CDN_Core::cdn_url( $url, $args ),
1110                    'descriptor' => 'w',
1111                    'value'      => $newwidth,
1112                );
1113            } //end foreach ( $multipliers as $multiplier )
1114            if ( is_array( $newsources ) ) {
1115                $sources = array_replace( $sources, $newsources );
1116            }
1117        } //end if isset( $image_meta['width'] ) && isset( $image_meta['file'] ) )
1118
1119        return $sources;
1120    }
1121
1122    /**
1123     * Filters an array of image `sizes` values, using $content_width instead of image's full size.
1124     *
1125     * @param array  $sizes An array of media query breakpoints.
1126     * @param array  $size  Width and height of the image.
1127     * @param string $image_url The image URL.
1128     *
1129     * @uses Jetpack::get_content_width
1130     * @return array An array of media query breakpoints.
1131     */
1132    public function filter_sizes( $sizes, $size, $image_url ) {
1133        if ( $this->photon_should_skip_image( $image_url ) ) {
1134            return $sizes;
1135        }
1136
1137        if ( ! doing_filter( 'the_content' ) ) {
1138            return $sizes;
1139        }
1140        $content_width = Image_CDN_Core::get_jetpack_content_width();
1141        if ( ! $content_width ) {
1142            $content_width = 1000;
1143        }
1144
1145        if ( ( is_array( $size ) && $size[0] < $content_width ) ) {
1146            return $sizes;
1147        }
1148
1149        return sprintf( '(max-width: %1$dpx) 100vw, %1$dpx', $content_width );
1150    }
1151
1152    /**
1153     * Whether to skip the image from being processed by Photon.
1154     *
1155     * @param string $image_url The image URL.
1156     *
1157     * @return bool Whether to skip the image.
1158     */
1159    private function photon_should_skip_image( $image_url ) {
1160        return apply_filters( 'jetpack_photon_skip_image', false, $image_url, null );
1161    }
1162
1163    /**
1164     * * GENERAL FUNCTIONS
1165     **/
1166
1167    /**
1168     * Ensure image URL is valid for Photon.
1169     * Though Photon functions address some of the URL issues, we should avoid unnecessary processing if we know early on that the image isn't supported.
1170     *
1171     * @param string $url Image URL.
1172     * @uses wp_parse_args
1173     * @return bool
1174     */
1175    protected static function validate_image_url( $url ) {
1176        $parsed_url = wp_parse_url( $url );
1177
1178        if ( ! $parsed_url ) {
1179            return false;
1180        }
1181
1182        // Parse URL and ensure needed keys exist, since the array returned by `wp_parse_url` only includes the URL components it finds.
1183        $url_info = wp_parse_args(
1184            $parsed_url,
1185            array(
1186                'scheme' => null,
1187                'host'   => null,
1188                'port'   => null,
1189                'path'   => null,
1190            )
1191        );
1192
1193        // Bail if scheme isn't http or port is set that isn't port 80.
1194        if (
1195            ( 'http' !== $url_info['scheme'] || ! in_array( $url_info['port'], array( 80, null ), true ) ) &&
1196            /**
1197             * Allow Photon to fetch images that are served via HTTPS.
1198             *
1199             * @module photon
1200             *
1201             * @since 2.4.0
1202             * @since 3.9.0 Default to false.
1203             *
1204             * @param bool $reject_https Should Photon ignore images using the HTTPS scheme. Default to false.
1205             */
1206            apply_filters( 'jetpack_photon_reject_https', false )
1207        ) {
1208            return false;
1209        }
1210
1211        // Bail if no host is found.
1212        if ( $url_info['host'] === null ) {
1213            return false;
1214        }
1215
1216        // Bail if the image already went through Photon.
1217        if ( preg_match( '#^i[\d]{1}.wp.com$#i', $url_info['host'] ) ) {
1218            return false;
1219        }
1220
1221        // Bail if no path is found.
1222        if ( $url_info['path'] === null ) {
1223            return false;
1224        }
1225
1226        // Ensure image extension is acceptable.
1227        if ( ! in_array( strtolower( pathinfo( $url_info['path'], PATHINFO_EXTENSION ) ), self::$extensions, true ) ) {
1228            return false;
1229        }
1230
1231        // If we got this far, we should have an acceptable image URL
1232        // But let folks filter to decline if they prefer.
1233        /**
1234         * Overwrite the results of the validation steps an image goes through before to be considered valid to be used by Photon.
1235         *
1236         * @module photon
1237         *
1238         * @since 3.0.0
1239         *
1240         * @param bool true Is the image URL valid and can it be used by Photon. Default to true.
1241         * @param string $url Image URL.
1242         * @param array $parsed_url Array of information about the image.
1243         */
1244        return apply_filters( 'photon_validate_image_url', true, $url, $parsed_url );
1245    }
1246
1247    /**
1248     * Checks if the file exists before it passes the file to photon.
1249     *
1250     * @param string $src The image URL.
1251     * @return string
1252     **/
1253    public static function strip_image_dimensions_maybe( $src ) {
1254        $stripped_src = $src;
1255
1256        // Build URL, first removing WP's resized string so we pass the original image to Photon.
1257        if ( preg_match( '#(-\d+x\d+)\.(' . implode( '|', self::$extensions ) . '){1}$#i', $src, $src_parts ) ) {
1258            $stripped_src = str_replace( $src_parts[1], '', $src );
1259            $upload_dir   = wp_get_upload_dir();
1260
1261            // Extracts the file path to the image minus the base url.
1262            $file_path = substr( $stripped_src, strlen( $upload_dir['baseurl'] ) );
1263
1264            if ( file_exists( $upload_dir['basedir'] . $file_path ) ) {
1265                $src = $stripped_src;
1266            }
1267        }
1268
1269        return $src;
1270    }
1271
1272    /**
1273     * Provide an array of available image sizes and corresponding dimensions.
1274     * Similar to get_intermediate_image_sizes() except that it includes image sizes' dimensions, not just their names.
1275     *
1276     * @global $wp_additional_image_sizes
1277     * @uses get_option
1278     * @return array
1279     */
1280    protected static function image_sizes() {
1281        if ( null === self::$image_sizes ) {
1282            global $_wp_additional_image_sizes;
1283
1284            // Populate an array matching the data structure of $_wp_additional_image_sizes so we have a consistent structure for image sizes.
1285            $images = array(
1286                'thumb'        => array(
1287                    'width'  => (int) get_option( 'thumbnail_size_w' ),
1288                    'height' => (int) get_option( 'thumbnail_size_h' ),
1289                    'crop'   => (bool) get_option( 'thumbnail_crop' ),
1290                ),
1291                'medium'       => array(
1292                    'width'  => (int) get_option( 'medium_size_w' ),
1293                    'height' => (int) get_option( 'medium_size_h' ),
1294                    'crop'   => false,
1295                ),
1296                'medium_large' => array(
1297                    'width'  => (int) get_option( 'medium_large_size_w' ),
1298                    'height' => (int) get_option( 'medium_large_size_h' ),
1299                    'crop'   => false,
1300                ),
1301                'large'        => array(
1302                    'width'  => (int) get_option( 'large_size_w' ),
1303                    'height' => (int) get_option( 'large_size_h' ),
1304                    'crop'   => false,
1305                ),
1306                'full'         => array(
1307                    'width'  => null,
1308                    'height' => null,
1309                    'crop'   => false,
1310                ),
1311            );
1312
1313            // Compatibility mapping as found in wp-includes/media.php.
1314            $images['thumbnail'] = $images['thumb'];
1315
1316            // Update class variable, merging in $_wp_additional_image_sizes if any are set.
1317            if ( is_array( $_wp_additional_image_sizes ) && ! empty( $_wp_additional_image_sizes ) ) {
1318                self::$image_sizes = array_merge( $images, $_wp_additional_image_sizes );
1319            } else {
1320                self::$image_sizes = $images;
1321            }
1322        }
1323
1324        return is_array( self::$image_sizes ) ? self::$image_sizes : array();
1325    }
1326
1327    /**
1328     * Find registered image size name if it exists.
1329     *
1330     * @param string|int|int[] $size Image size name if registered, or false if not.
1331     */
1332    protected static function find_registered_image_size( $size ) {
1333        $sizes = self::image_sizes();
1334
1335        // WP states that `add_image_size()` should use a string for the name, but doesn't enforce that.
1336        if ( ( is_string( $size ) || is_int( $size ) ) && array_key_exists( $size, self::image_sizes() ) ) {
1337            return $size;
1338        }
1339
1340        if ( is_array( $size ) && isset( $size[0] ) && isset( $size[1] ) ) {
1341            foreach ( $sizes as $name => $args ) {
1342                if ( $args['width'] === $size[0] && $args['height'] === $size[1] ) {
1343                    return $name;
1344                }
1345            }
1346        }
1347
1348        return false;
1349    }
1350
1351    /**
1352     * Determine if image_downsize should utilize Photon via REST API.
1353     *
1354     * The WordPress Block Editor (Gutenberg) and other REST API consumers using the wp/v2/media endpoint, especially in the "edit"
1355     * context is more akin to the is_admin usage of Photon (see filter_image_downsize). Since consumers are trying to edit content in posts,
1356     * Photon should not fire as it will fire later on display. By aborting an attempt to Photonize an image here, we
1357     * prevents issues like https://github.com/Automattic/jetpack/issues/10580 .
1358     *
1359     * To determine if we're using the wp/v2/media endpoint, we hook onto the `rest_request_before_callbacks` filter and
1360     * if determined we are using it in the edit context, we'll false out the `jetpack_photon_override_image_downsize` filter.
1361     *
1362     * @see Image_CDN::filter_image_downsize()
1363     *
1364     * @param null|\WP_Error   $response REST API response.
1365     * @param array            $endpoint_data Endpoint data. Not used, but part of the filter.
1366     * @param \WP_REST_Request $request  Request used to generate the response.
1367     *
1368     * @return null|\WP_Error The original response object without modification.
1369     */
1370    public function should_rest_photon_image_downsize( $response, $endpoint_data, $request ) {
1371        if ( ! is_a( $request, '\WP_REST_Request' ) ) {
1372            return $response; // Something odd is happening. Do nothing and return the response.
1373        }
1374
1375        if ( is_wp_error( $response ) ) {
1376            // If we're going to return an error, we don't need to do anything with Photon.
1377            return $response;
1378        }
1379
1380        $this->should_rest_photon_image_downsize_override( $request );
1381
1382        return $response;
1383    }
1384
1385    /**
1386     * Helper function to check if a WP_REST_Request is the media endpoint in the edit context.
1387     *
1388     * @param \WP_REST_Request $request The current REST request.
1389     */
1390    private function should_rest_photon_image_downsize_override( \WP_REST_Request $request ) {
1391        $route = $request->get_route();
1392
1393        if (
1394            (
1395                str_contains( $route, 'wp/v2/media' )
1396                && 'edit' === $request->get_param( 'context' )
1397            )
1398            || str_contains( $route, 'wpcom/v2/external-media/copy' )
1399            || (bool) $request->get_header( 'x-wp-api-fetch-from-editor' )
1400        ) {
1401            // Don't use `__return_true()`: Use something unique. See ::_override_image_downsize_in_rest_edit_context()
1402            // Late execution to avoid conflict with other plugins as we really don't want to run in this situation.
1403            add_filter(
1404                'jetpack_photon_override_image_downsize',
1405                array(
1406                    $this,
1407                    'override_image_downsize_in_rest_edit_context',
1408                ),
1409                999999
1410            );
1411        }
1412    }
1413
1414    /**
1415     * Brings in should_rest_photon_image_downsize for the rest_after_insert_attachment hook.
1416     *
1417     * @param \WP_Post         $attachment Inserted or updated attachment object.
1418     * @param \WP_REST_Request $request    Request object.
1419     */
1420    public function should_rest_photon_image_downsize_insert_attachment( \WP_Post $attachment, \WP_REST_Request $request ) {
1421        if ( ! is_a( $request, '\WP_REST_Request' ) ) {
1422            // Something odd is happening.
1423            return;
1424        }
1425
1426        $this->should_rest_photon_image_downsize_override( $request );
1427    }
1428
1429    /**
1430     * Remove the override we may have added in ::should_rest_photon_image_downsize()
1431     * Since ::_override_image_downsize_in_rest_edit_context() is only
1432     * every used here, we can always remove it without ever worrying
1433     * about breaking any other configuration.
1434     *
1435     * @param mixed $response REST API Response.
1436     * @return mixed Unchanged $response
1437     */
1438    public function cleanup_rest_photon_image_downsize( $response ) {
1439        remove_filter(
1440            'jetpack_photon_override_image_downsize',
1441            array(
1442                $this,
1443                'override_image_downsize_in_rest_edit_context',
1444            ),
1445            999999
1446        );
1447        return $response;
1448    }
1449
1450    /**
1451     * Used internally by ::should_rest_photon_image_downsize() to not photonize
1452     * image URLs in ?context=edit REST requests.
1453     * MUST NOT be used anywhere else.
1454     * We use a unique function instead of __return_true so that we can clean up
1455     * after ourselves without breaking anyone else's filters.
1456     *
1457     * @internal
1458     * @return true
1459     */
1460    public function override_image_downsize_in_rest_edit_context() {
1461        return true;
1462    }
1463
1464    /**
1465     * Return whether the current page is AMP.
1466     *
1467     * This is only present for the sake of WordPress.com where the Jetpack_AMP_Support
1468     * class does not yet exist. This mehod may only be called at the wp action or later.
1469     *
1470     * @return bool Whether AMP page.
1471     */
1472    private static function is_amp_endpoint() {
1473        return class_exists( '\Jetpack_AMP_Support' ) && \Jetpack_AMP_Support::is_amp_request();
1474    }
1475
1476    /**
1477     * Get the list of supported image extensions
1478     *
1479     * @return string[] Array of supported extensions
1480     */
1481    public static function get_supported_extensions() {
1482        return self::$extensions;
1483    }
1484}