Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.60% covered (warning)
54.60%
267 / 489
33.33% covered (danger)
33.33%
8 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_External_Media
54.94% covered (warning)
54.94%
267 / 486
33.33% covered (danger)
33.33%
8 / 24
902.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
128 / 128
100.00% covered (success)
100.00%
1 / 1
1
 permission_callback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create_item_permissions_check
44.44% covered (danger)
44.44%
12 / 27
0.00% covered (danger)
0.00%
0 / 1
12.17
 sanitize_media
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validate_media
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 prepare_media_param
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 get_external_media
45.00% covered (danger)
45.00%
18 / 40
0.00% covered (danger)
0.00%
0 / 1
22.48
 copy_external_media
38.64% covered (danger)
38.64%
17 / 44
0.00% covered (danger)
0.00%
0 / 1
27.72
 get_connection_details
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 delete_connection
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 get_picker_status
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 create_session
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 get_session
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 delete_session
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 proxy_media_request
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
56
 get_download_url
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
7.33
 stream_to_temp_file
90.32% covered (success)
90.32%
28 / 31
0.00% covered (danger)
0.00%
0 / 1
13.15
 url_is_public
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 download_failed_error
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 sideload_media
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
2.04
 update_attachment_meta
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 get_attachment_data
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_wp_filesystem
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * REST API endpoint for the External Media.
4 *
5 * @package automattic/jetpack
6 * @since 8.7.0
7 */
8
9use Automattic\Jetpack\Connection\Client;
10use Automattic\Jetpack\Connection\Manager;
11use Automattic\Jetpack\IP\Utils;
12
13if ( ! defined( 'ABSPATH' ) ) {
14    exit( 0 );
15}
16
17/**
18 * External Media helper API.
19 *
20 * @since 8.7.0
21 */
22class WPCOM_REST_API_V2_Endpoint_External_Media extends WP_REST_Controller {
23
24    /**
25     * Maximum number of redirect hops to follow when downloading a media file.
26     *
27     * Matches WordPress's default `redirection` limit, so media URLs that redirect
28     * to a CDN keep resolving exactly as before.
29     *
30     * @var int
31     */
32    const MAX_REDIRECTS = 5;
33
34    /**
35     * Seconds a media download may take, redirects included.
36     *
37     * @var int
38     */
39    const DOWNLOAD_TIMEOUT = 300;
40
41    /**
42     * Media argument schema for /copy endpoint.
43     *
44     * @var array
45     */
46    public $media_schema = array(
47        'type'  => 'array',
48        'items' => array(
49            'type'       => 'object',
50            'required'   => true,
51            'properties' => array(
52                'caption' => array(
53                    'type' => 'string',
54                ),
55                'guid'    => array(
56                    'type'       => 'object',
57                    'properties' => array(
58                        'caption' => array(
59                            'type' => 'string',
60                        ),
61                        'name'    => array(
62                            'type' => 'string',
63                        ),
64                        'title'   => array(
65                            'type' => 'string',
66                        ),
67                        'url'     => array(
68                            'format' => 'uri',
69                            'type'   => 'string',
70                        ),
71                    ),
72                ),
73                'title'   => array(
74                    'type' => 'string',
75                ),
76                'meta'    => array(
77                    'type'                 => 'object',
78                    'additionalProperties' => false,
79                    'properties'           => array(
80                        'vertical_id'   => array(
81                            'type'   => 'string',
82                            'format' => 'text-field',
83                        ),
84                        'pexels_object' => array(
85                            'type' => 'object',
86                        ),
87                    ),
88                ),
89            ),
90        ),
91    );
92
93    /**
94     * Service regex.
95     *
96     * @var string
97     */
98    private static $services_regex = '(?P<service>google_photos|openverse|pexels)';
99
100    /**
101     * Constructor.
102     */
103    public function __construct() {
104        $this->namespace = 'wpcom/v2';
105        $this->rest_base = 'external-media';
106
107        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
108    }
109
110    /**
111     * Registers the routes for external media.
112     */
113    public function register_routes() {
114        register_rest_route(
115            $this->namespace,
116            $this->rest_base . '/list/' . self::$services_regex,
117            array(
118                'methods'             => WP_REST_Server::READABLE,
119                'callback'            => array( $this, 'get_external_media' ),
120                'permission_callback' => array( $this, 'permission_callback' ),
121                'args'                => array(
122                    'search'      => array(
123                        'description' => __( 'Media collection search term.', 'jetpack' ),
124                        'type'        => 'string',
125                    ),
126                    'number'      => array(
127                        'description' => __( 'Number of media items in the request', 'jetpack' ),
128                        'type'        => 'number',
129                        'default'     => 20,
130                    ),
131                    'path'        => array(
132                        'type' => 'string',
133                    ),
134                    'page_handle' => array(
135                        'type' => 'string',
136                    ),
137                    'session_id'  => array(
138                        'description' => __( 'Session id of a service, currently only Google Photos Picker', 'jetpack' ),
139                        'type'        => 'string',
140                    ),
141                ),
142            )
143        );
144
145        register_rest_route(
146            $this->namespace,
147            $this->rest_base . '/copy/' . self::$services_regex,
148            array(
149                'methods'             => \WP_REST_Server::CREATABLE,
150                'callback'            => array( $this, 'copy_external_media' ),
151                'permission_callback' => array( $this, 'create_item_permissions_check' ),
152                'args'                => array(
153                    'media'        => array(
154                        'description'       => __( 'Media data to copy.', 'jetpack' ),
155                        'items'             => $this->media_schema,
156                        'required'          => true,
157                        'type'              => 'array',
158                        'sanitize_callback' => array( $this, 'sanitize_media' ),
159                        'validate_callback' => array( $this, 'validate_media' ),
160                    ),
161                    'post_id'      => array(
162                        'description' => __( 'The post ID to attach the upload to.', 'jetpack' ),
163                        'type'        => 'number',
164                        'minimum'     => 0,
165                    ),
166                    'should_proxy' => array(
167                        'description' => __( 'Whether to proxy the media request.', 'jetpack' ),
168                        'type'        => 'boolean',
169                        'default'     => false,
170                    ),
171                ),
172            )
173        );
174
175        register_rest_route(
176            $this->namespace,
177            $this->rest_base . '/connection/(?P<service>google_photos)',
178            array(
179                'methods'             => \WP_REST_Server::READABLE,
180                'callback'            => array( $this, 'get_connection_details' ),
181                'permission_callback' => array( $this, 'permission_callback' ),
182            )
183        );
184
185        register_rest_route(
186            $this->namespace,
187            $this->rest_base . '/connection/(?P<service>google_photos)',
188            array(
189                'methods'             => \WP_REST_Server::DELETABLE,
190                'callback'            => array( $this, 'delete_connection' ),
191                'permission_callback' => array( $this, 'permission_callback' ),
192            )
193        );
194
195        register_rest_route(
196            $this->namespace,
197            $this->rest_base . '/connection/(?P<service>google_photos)/picker_status',
198            array(
199                'methods'             => \WP_REST_Server::READABLE,
200                'callback'            => array( $this, 'get_picker_status' ),
201                'permission_callback' => array( $this, 'permission_callback' ),
202            )
203        );
204
205        // Add new session route, currently for Google Photos Picker only
206        register_rest_route(
207            $this->namespace,
208            $this->rest_base . '/session/(?P<service>google_photos)',
209            array(
210                'methods'             => \WP_REST_Server::CREATABLE,
211                'callback'            => array( $this, 'create_session' ),
212                'permission_callback' => array( $this, 'permission_callback' ),
213            )
214        );
215
216        // Get new session route, currently for Google Photos Picker only
217        register_rest_route(
218            $this->namespace,
219            $this->rest_base . '/session/(?P<service>google_photos)/(?P<session_id>.*)',
220            array(
221                'methods'             => \WP_REST_Server::READABLE,
222                'callback'            => array( $this, 'get_session' ),
223                'permission_callback' => array( $this, 'permission_callback' ),
224            )
225        );
226
227        // Delete session route, currently for Google Photos Picker only
228        register_rest_route(
229            $this->namespace,
230            $this->rest_base . '/session/(?P<service>google_photos)/(?P<session_id>.*)',
231            array(
232                'methods'             => \WP_REST_Server::DELETABLE,
233                'callback'            => array( $this, 'delete_session' ),
234                'permission_callback' => array( $this, 'permission_callback' ),
235            )
236        );
237
238        // Add new proxy route for media files
239        register_rest_route(
240            $this->namespace,
241            $this->rest_base . '/proxy/(?P<service>google_photos)',
242            array(
243                'methods'             => WP_REST_Server::CREATABLE,
244                'callback'            => array( $this, 'proxy_media_request' ),
245                'permission_callback' => array( $this, 'permission_callback' ),
246                'args'                => array(
247                    'url' => array(
248                        'required' => true,
249                        'type'     => 'string',
250                    ),
251                ),
252            )
253        );
254    }
255
256    /**
257     * Checks if a given request has access to external media libraries.
258     */
259    public function permission_callback() {
260        return current_user_can( 'upload_files' );
261    }
262
263    /**
264     * Checks if a given request has access to create an attachment.
265     *
266     * @param WP_REST_Request $request Full details about the request.
267     * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
268     */
269    public function create_item_permissions_check( $request ) {
270        if ( ! empty( $request['id'] ) ) {
271            return new WP_Error(
272                'rest_post_exists',
273                __( 'Cannot create existing post.', 'jetpack' ),
274                array( 'status' => 400 )
275            );
276        }
277
278        $post_type = get_post_type_object( 'attachment' );
279
280        if ( ! current_user_can( $post_type->cap->create_posts ) ) {
281            return new WP_Error(
282                'rest_cannot_create',
283                __( 'Sorry, you are not allowed to create posts as this user.', 'jetpack' ),
284                array( 'status' => rest_authorization_required_code() )
285            );
286        }
287
288        if ( ! current_user_can( 'upload_files' ) ) {
289            return new WP_Error(
290                'rest_cannot_create',
291                __( 'Sorry, you are not allowed to upload media on this site.', 'jetpack' ),
292                array( 'status' => 400 )
293            );
294        }
295
296        // Attaching media to a post requires the ability to edit that post, mirroring
297        // WP_REST_Attachments_Controller::create_item_permissions_check(). Without this
298        // check any user with upload_files could parent an attachment to a post they
299        // cannot edit.
300        $post_id = (int) $request->get_param( 'post_id' );
301        if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
302            return new WP_Error(
303                'rest_cannot_edit',
304                __( 'Sorry, you are not allowed to upload media to this post.', 'jetpack' ),
305                array( 'status' => rest_authorization_required_code() )
306            );
307        }
308
309        return true;
310    }
311
312    /**
313     * Sanitization callback for media parameter.
314     *
315     * @param array $param Media parameter.
316     * @return true|\WP_Error
317     */
318    public function sanitize_media( $param ) {
319        $param = $this->prepare_media_param( $param );
320
321        return rest_sanitize_value_from_schema( $param, $this->media_schema );
322    }
323
324    /**
325     * Validation callback for media parameter.
326     *
327     * @param array $param Media parameter.
328     * @return true|\WP_Error
329     */
330    public function validate_media( $param ) {
331        $param = $this->prepare_media_param( $param );
332
333        return rest_validate_value_from_schema( $param, $this->media_schema, 'media' );
334    }
335
336    /**
337     * Decodes guid json and sets parameter defaults.
338     *
339     * @param array $param Media parameter.
340     * @return array
341     */
342    private function prepare_media_param( $param ) {
343        foreach ( $param as $key => $item ) {
344            if ( ! empty( $item['guid'] ) ) {
345                $param[ $key ]['guid'] = json_decode( $item['guid'], true );
346            }
347
348            if ( empty( $param[ $key ]['caption'] ) ) {
349                $param[ $key ]['caption'] = '';
350            }
351            if ( empty( $param[ $key ]['title'] ) ) {
352                $param[ $key ]['title'] = '';
353            }
354        }
355
356        return $param;
357    }
358
359    /**
360     * Retrieves media items from external libraries.
361     *
362     * @param \WP_REST_Request $request Full details about the request.
363     * @return array|\WP_Error|mixed
364     */
365    public function get_external_media( \WP_REST_Request $request ) {
366        $params     = $request->get_params();
367        $wpcom_path = sprintf( '/meta/external-media/%s', rawurlencode( $params['service'] ) );
368
369        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
370            $request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
371            $request->set_query_params( $params );
372
373            return rest_do_request( $request );
374        }
375
376        // Build query string to pass to wpcom endpoint.
377        $service_args = array_filter(
378            $params,
379            function ( $key ) {
380                return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter', 'session_id' ), true );
381            },
382            ARRAY_FILTER_USE_KEY
383        );
384        if ( ! empty( $service_args ) ) {
385            $wpcom_path .= '?' . http_build_query( $service_args );
386        }
387
388        $response = Client::wpcom_json_api_request_as_user( $wpcom_path );
389
390        switch ( wp_remote_retrieve_response_code( $response ) ) {
391            case 200:
392                $response = json_decode( wp_remote_retrieve_body( $response ), true );
393                break;
394
395            case 401:
396                $response = new WP_Error(
397                    'authorization_required',
398                    __( 'You are not connected to that service.', 'jetpack' ),
399                    array( 'status' => 403 )
400                );
401                break;
402
403            case 403:
404                $error    = json_decode( wp_remote_retrieve_body( $response ) );
405                $response = new WP_Error( $error->code, $error->message, $error->data );
406                break;
407
408            default:
409                if ( is_wp_error( $response ) ) {
410                    $response->add_data( array( 'status' => 400 ) );
411                    break;
412                }
413                $response = new WP_Error(
414                    'rest_request_error',
415                    __( 'An unknown error has occurred. Please try again later.', 'jetpack' ),
416                    array( 'status' => wp_remote_retrieve_response_code( $response ) )
417                );
418        }
419
420        return $response;
421    }
422
423    /**
424     * Saves an external media item to the media library.
425     *
426     * @param \WP_REST_Request $request Full details about the request.
427     * @return array|\WP_Error|mixed
428     **/
429    public function copy_external_media( \WP_REST_Request $request ) {
430        require_once ABSPATH . 'wp-admin/includes/file.php';
431        require_once ABSPATH . 'wp-admin/includes/media.php';
432        require_once ABSPATH . 'wp-admin/includes/image.php';
433
434        $post_id      = (int) $request->get_param( 'post_id' );
435        $should_proxy = $request->get_param( 'should_proxy' );
436        $service      = rawurlencode( $request->get_param( 'service' ) );
437
438        // Fail closed: never parent an attachment to a post the caller cannot edit,
439        // even if a future change lets an unauthorized request reach this handler.
440        // The permission callback already rejects such requests with a 403.
441        if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
442            $post_id = 0;
443        }
444
445        $responses = array();
446
447        foreach ( $request->get_param( 'media' ) as $item ) {
448            // Download file to temp dir.
449            if ( $should_proxy ) {
450                $wpcom_path   = sprintf( '/meta/external-media/proxy/%s', $service );
451                $wpcom_path  .= '?url=' . rawurlencode( $item['guid']['url'] );
452                $download_url = wp_tempnam();
453                $response     = Client::wpcom_json_api_request_as_user(
454                    $wpcom_path,
455                    '2',
456                    array(
457                        'method' => 'POST',
458                    )
459                );
460
461                if ( is_wp_error( $response ) ) {
462                    $responses[] = $response;
463                    continue;
464                }
465                $wp_filesystem = $this->get_wp_filesystem();
466                $written       = $wp_filesystem->put_contents( $download_url, wp_remote_retrieve_body( $response ) );
467
468                if ( false === $written ) {
469                    $responses[] = new WP_Error(
470                        'rest_upload_error',
471                        __( 'Could not download media file.', 'jetpack' ),
472                        array( 'status' => 400 )
473                    );
474                    continue;
475                }
476            } else {
477                $download_url = $this->get_download_url( $item['guid'] );
478            }
479
480            if ( is_wp_error( $download_url ) ) {
481                $responses[] = $download_url;
482                continue;
483            }
484
485            $id = $this->sideload_media( $item['guid']['name'], $download_url, $post_id );
486            if ( is_wp_error( $id ) ) {
487                $responses[] = $id;
488                continue;
489            }
490
491            $this->update_attachment_meta( $id, $item );
492
493            // Add attachment data or WP_Error.
494            $responses[] = $this->get_attachment_data( $id, $item );
495        }
496
497        return $responses;
498    }
499
500    /**
501     * Gets connection authorization details.
502     *
503     * @param \WP_REST_Request $request Full details about the request.
504     * @return array|\WP_Error|mixed
505     */
506    public function get_connection_details( \WP_REST_Request $request ) {
507        $service = $request->get_param( 'service' );
508
509        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
510            $wpcom_path       = sprintf( '/meta/external-media/connection/%s', rawurlencode( $service ) );
511            $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
512            $internal_request->set_query_params( $request->get_params() );
513
514            return rest_do_request( $internal_request );
515        }
516
517        $site_id = Manager::get_site_id();
518        if ( is_wp_error( $site_id ) ) {
519            return $site_id;
520        }
521
522        $path     = sprintf( '/sites/%d/external-services', $site_id );
523        $response = Client::wpcom_json_api_request_as_user( $path );
524        if ( is_wp_error( $response ) ) {
525            return $response;
526        }
527
528        $body = json_decode( wp_remote_retrieve_body( $response ) );
529        if ( ! property_exists( $body, 'services' ) || ! property_exists( $body->services, $service ) ) {
530            return new WP_Error(
531                'bad_request',
532                __( 'An error occurred. Please try again later.', 'jetpack' ),
533                array( 'status' => 400 )
534            );
535        }
536
537        return $body->services->{ $service };
538    }
539
540    /**
541     * Deletes a Google Photos connection.
542     *
543     * @param WP_REST_Request $request Full details about the request.
544     * @return array|WP_Error|WP_REST_Response
545     */
546    public function delete_connection( WP_REST_Request $request ) {
547        $service    = rawurlencode( $request->get_param( 'service' ) );
548        $wpcom_path = sprintf( '/meta/external-media/connection/%s', $service );
549
550        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
551            $internal_request = new WP_REST_Request( 'DELETE', '/' . $this->namespace . $wpcom_path );
552            $internal_request->set_query_params( $request->get_params() );
553
554            return rest_do_request( $internal_request );
555        }
556
557        $response = Client::wpcom_json_api_request_as_user(
558            $wpcom_path,
559            '2',
560            array(
561                'method' => 'DELETE',
562            )
563        );
564
565        return json_decode( wp_remote_retrieve_body( $response ), true );
566    }
567
568    /**
569     * Gets Google Photos Picker enabled Status.
570     *
571     * @param \WP_REST_Request $request Full details about the request.
572     * @return array|\WP_Error|mixed
573     */
574    public function get_picker_status( \WP_REST_Request $request ) {
575        $service    = $request->get_param( 'service' );
576        $wpcom_path = sprintf( '/meta/external-media/connection/%s/picker_status', rawurlencode( $service ) );
577
578        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
579            $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
580            $internal_request->set_query_params( $request->get_params() );
581
582            return rest_do_request( $internal_request );
583        }
584
585        $response = Client::wpcom_json_api_request_as_user(
586            $wpcom_path,
587            '2',
588            array(
589                'method' => 'GET',
590            )
591        );
592
593        return json_decode( wp_remote_retrieve_body( $response ), true );
594    }
595
596    /**
597     * Creates a new session for a service.
598     *
599     * @param \WP_REST_Request $request Full details about the request.
600     * @return array|\WP_Error|mixed
601     */
602    public function create_session( \WP_REST_Request $request ) {
603        $service    = $request->get_param( 'service' );
604        $wpcom_path = sprintf( '/meta/external-media/session/%s', rawurlencode( $service ) );
605
606        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
607            $internal_request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
608            $internal_request->set_query_params( $request->get_params() );
609
610            return rest_do_request( $internal_request );
611        }
612
613        $response = Client::wpcom_json_api_request_as_user(
614            $wpcom_path,
615            '2',
616            array(
617                'method' => 'POST',
618            )
619        );
620
621        return json_decode( wp_remote_retrieve_body( $response ), true );
622    }
623
624    /**
625     * Gets a session for a service.
626     *
627     * @param \WP_REST_Request $request Full details about the request.
628     * @return array|\WP_Error|mixed
629     */
630    public function get_session( \WP_REST_Request $request ) {
631        $service    = $request->get_param( 'service' );
632        $session_id = $request->get_param( 'session_id' );
633        $wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
634
635        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
636            $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
637            $internal_request->set_query_params( $request->get_params() );
638
639            return rest_do_request( $internal_request );
640        }
641
642        $response = Client::wpcom_json_api_request_as_user(
643            $wpcom_path,
644            '2',
645            array(
646                'method' => 'GET',
647            )
648        );
649
650        return json_decode( wp_remote_retrieve_body( $response ), true );
651    }
652
653    /**
654     * Deletes a session for a service.
655     *
656     * @param \WP_REST_Request $request Full details about the request.
657     * @return array|\WP_Error|mixed
658     */
659    public function delete_session( \WP_REST_Request $request ) {
660        $service    = $request->get_param( 'service' );
661        $session_id = $request->get_param( 'session_id' );
662        $wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
663
664        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
665            $internal_request = new \WP_REST_Request( 'DELETE', '/' . $this->namespace . $wpcom_path );
666            $internal_request->set_query_params( $request->get_params() );
667
668            return rest_do_request( $internal_request );
669        }
670
671        $response = Client::wpcom_json_api_request_as_user(
672            $wpcom_path,
673            '2',
674            array(
675                'method' => 'DELETE',
676            )
677        );
678
679        return json_decode( wp_remote_retrieve_body( $response ), true );
680    }
681
682    /**
683     * Proxies media requests with proper authorization headers
684     *
685     * @param WP_REST_Request $request Full details about the request.
686     * @return WP_REST_Response|WP_Error|array Response object or WP_Error.
687     */
688    public function proxy_media_request( $request ) {
689        $params     = $request->get_params();
690        $service    = rawurlencode( $request->get_param( 'service' ) );
691        $wpcom_path = sprintf( '/meta/external-media/proxy/%s', $service );
692
693        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
694            $request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
695            $request->set_query_params( $params );
696
697            return rest_do_request( $request );
698
699        } else {
700            // Build query string to pass to wpcom endpoint.
701            $service_args = array_filter(
702                $params,
703                function ( $key ) {
704                    return in_array( $key, array( 'url' ), true );
705                },
706                ARRAY_FILTER_USE_KEY
707            );
708
709            if ( ! empty( $service_args ) ) {
710                $wpcom_path .= '?' . http_build_query( $service_args );
711            }
712
713            $response = Client::wpcom_json_api_request_as_user(
714                $wpcom_path,
715                '2',
716                array(
717                    'method' => 'POST',
718                )
719            );
720
721            $status_code = wp_remote_retrieve_response_code( $response );
722            $headers     = wp_remote_retrieve_headers( $response );
723            $body        = wp_remote_retrieve_body( $response );
724
725            // For non-200 responses, parse and return JSON error
726            if ( $status_code !== 200 ) {
727                $error_data = json_decode( $body, true );
728                return new \WP_REST_Response( $error_data, $status_code );
729            }
730        }
731
732        // Return binary content directly
733        $valid_headers = array(
734            'content-type',
735            'content-length',
736            'content-disposition',
737        );
738        // Set content headers
739        foreach ( $valid_headers as $header ) {
740            if ( ! empty( $headers[ $header ] ) ) {
741                header( ucwords( $header, '-' ) . ': ' . $headers[ $header ] );
742            }
743        }
744
745        // Set cache headers
746        header( 'Cache-Control: no-cache, no-store, must-revalidate' );
747        header( 'Pragma: no-cache' );
748        header( 'Expires: 0' );
749        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Media binary data
750        echo $body;
751        exit( 0 );
752    }
753
754    /**
755     * Downloads a remote media file into a temporary file for sideloading.
756     *
757     * The URL is checked against Utils::url_is_public() before the fetch and again
758     * on every redirect hop, the same rule the resolve-redirect endpoint applies.
759     *
760     * The remote file is streamed into a randomly-named temporary file created by
761     * wp_tempnam(). The caller-supplied name is never used for the temporary file
762     * itself; it is only applied â€” and validated by WordPress â€” later, when the
763     * completed download is handed to media_handle_sideload(). This prevents a
764     * crafted name from controlling the physical path or extension of the file
765     * written to disk.
766     *
767     * @param array $guid Media information.
768     * @return string|\WP_Error Path to the downloaded temporary file, or WP_Error on failure.
769     */
770    public function get_download_url( $guid ) {
771        require_once ABSPATH . 'wp-admin/includes/file.php';
772
773        $url = isset( $guid['url'] ) && is_string( $guid['url'] ) ? $guid['url'] : '';
774
775        if ( ! $this->url_is_public( $url ) ) {
776            return $this->download_failed_error();
777        }
778
779        $tmp_name = wp_tempnam();
780        if ( ! $tmp_name ) {
781            return new WP_Error(
782                'rest_upload_error',
783                __( 'Could not create a temporary file.', 'jetpack' ),
784                array( 'status' => 500 )
785            );
786        }
787
788        $result = $this->stream_to_temp_file( $url, $tmp_name );
789
790        if ( is_wp_error( $result ) ) {
791            wp_delete_file( $tmp_name );
792        }
793
794        return $result;
795    }
796
797    /**
798     * Streams an already-validated URL into a temporary file, following redirects.
799     *
800     * Each hop is fetched with `redirection => 0` and re-checked with
801     * Utils::url_is_public() before the next request. The caller owns $tmp_name and
802     * deletes it when this returns an error.
803     *
804     * @param string $url      Validated URL to download.
805     * @param string $tmp_name Path of the temporary file to stream into.
806     * @return string|\WP_Error $tmp_name on success, WP_Error on failure.
807     */
808    private function stream_to_temp_file( $url, $tmp_name ) {
809        // One budget for the whole chain: WordPress used to apply the timeout across
810        // the redirects it followed itself, and following them here must not multiply
811        // how long a single import can hold a request open.
812        $deadline = microtime( true ) + self::DOWNLOAD_TIMEOUT;
813
814        for ( $hop = 0; $hop <= self::MAX_REDIRECTS; $hop++ ) {
815            $remaining = (int) ceil( $deadline - microtime( true ) );
816            if ( $remaining < 1 ) {
817                return $this->download_failed_error();
818            }
819
820            $response = wp_safe_remote_get(
821                $url,
822                array(
823                    'timeout'     => $remaining,
824                    'stream'      => true,
825                    'filename'    => $tmp_name,
826                    // Do not let WordPress follow redirects for us; we validate each hop first.
827                    'redirection' => 0,
828                )
829            );
830
831            if ( is_wp_error( $response ) ) {
832                return $this->download_failed_error();
833            }
834
835            $status = (int) wp_remote_retrieve_response_code( $response );
836
837            if ( $status < 300 || $status >= 400 ) {
838                return 200 === $status ? $tmp_name : $this->download_failed_error();
839            }
840
841            // Budget exhausted: stop before validating a destination we will never fetch.
842            if ( self::MAX_REDIRECTS === $hop ) {
843                break;
844            }
845
846            $location = wp_remote_retrieve_header( $response, 'location' );
847
848            // Multiple Location headers: follow the last, as core does.
849            if ( is_array( $location ) ) {
850                $location = end( $location );
851            }
852
853            // Location may be relative; resolve it against the current URL.
854            $next_url = is_string( $location ) && '' !== $location
855                ? WP_Http::make_absolute_url( $location, $url )
856                : '';
857
858            if ( ! is_string( $next_url ) || ! $this->url_is_public( $next_url ) ) {
859                return $this->download_failed_error();
860            }
861
862            $url = $next_url;
863        }
864
865        return $this->download_failed_error();
866    }
867
868    /**
869     * Checks whether a URL is a public destination for a media download.
870     *
871     * An older jetpack-ip without url_is_public() may win the autoloader; that case
872     * falls back to core's check, the same one wp_safe_remote_get() applies.
873     *
874     * @param string $url URL to check.
875     * @return bool
876     */
877    private function url_is_public( $url ) {
878        if ( method_exists( Utils::class, 'url_is_public' ) ) {
879            return Utils::url_is_public( $url );
880        }
881
882        return (bool) wp_http_validate_url( $url );
883    }
884
885    /**
886     * Builds the WP_Error returned when a media file cannot be downloaded.
887     *
888     * Every failed download shares this one generic error.
889     *
890     * @return WP_Error
891     */
892    private function download_failed_error() {
893        return new WP_Error(
894            'rest_upload_error',
895            __( 'Could not download the media file.', 'jetpack' ),
896            array( 'status' => 400 )
897        );
898    }
899
900    /**
901     * Uploads media file and creates attachment object.
902     *
903     * @param string $file_name    Name of media file.
904     * @param string $download_url Download URL.
905     * @param int    $post_id      The ID of the post to attach the image to.
906     *
907     * @return int|\WP_Error
908     */
909    public function sideload_media( $file_name, $download_url, $post_id = 0 ) {
910        $file = array(
911            'name'     => sanitize_file_name( wp_basename( $file_name ) ),
912            'tmp_name' => $download_url,
913        );
914
915        $id = media_handle_sideload( $file, $post_id, null );
916        if ( is_wp_error( $id ) ) {
917            wp_delete_file( $file['tmp_name'] );
918            $id->add_data( array( 'status' => 400 ) );
919        }
920
921        return $id;
922    }
923
924    /**
925     * Updates attachment meta data for media item.
926     *
927     * @param int   $id   Attachment ID.
928     * @param array $item Media item.
929     */
930    public function update_attachment_meta( $id, $item ) {
931        $meta                          = wp_get_attachment_metadata( $id );
932        $meta['image_meta']['title']   = $item['title'];
933        $meta['image_meta']['caption'] = $item['caption'];
934
935        wp_update_attachment_metadata( $id, $meta );
936
937        update_post_meta( $id, '_wp_attachment_image_alt', $item['title'] );
938        wp_update_post(
939            array(
940                'ID'           => $id,
941                'post_excerpt' => $item['caption'],
942            )
943        );
944
945        if ( ! empty( $item['meta'] ) ) {
946            foreach ( $item['meta'] as $meta_key => $meta_value ) {
947                update_post_meta( $id, $meta_key, $meta_value );
948            }
949        }
950    }
951
952    /**
953     * Retrieves attachment data for media item.
954     *
955     * @param int   $id   Attachment ID.
956     * @param array $item Media item.
957     *
958     * @return array|\WP_REST_Response Attachment data on success, WP_Error on failure.
959     */
960    public function get_attachment_data( $id, $item ) {
961        $image_src = wp_get_attachment_image_src( $id, 'full' );
962
963        if ( empty( $image_src[0] ) ) {
964            $response = new WP_Error(
965                'rest_upload_error',
966                __( 'Could not retrieve source URL.', 'jetpack' ),
967                array( 'status' => 400 )
968            );
969        } else {
970            $response = array(
971                'id'      => $id,
972                'caption' => $item['caption'],
973                'alt'     => $item['title'],
974                'type'    => 'image',
975                'url'     => $image_src[0],
976            );
977        }
978
979        return $response;
980    }
981
982    /**
983     * Get the wp filesystem.
984     *
985     * @return \WP_Filesystem_Base|null
986     */
987    private function get_wp_filesystem() {
988        global $wp_filesystem;
989
990        if ( ! isset( $wp_filesystem ) ) {
991            require_once ABSPATH . '/wp-admin/includes/file.php';
992            WP_Filesystem();
993        }
994
995        return $wp_filesystem;
996    }
997}
998
999wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_External_Media' );