Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.23% covered (warning)
50.23%
218 / 434
31.82% covered (danger)
31.82%
7 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_External_Media
50.58% covered (warning)
50.58%
218 / 431
31.82% covered (danger)
31.82%
7 / 22
679.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 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
25.00% covered (danger)
25.00%
5 / 20
0.00% covered (danger)
0.00%
0 / 1
10.75
 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.10% covered (danger)
38.10%
16 / 42
0.00% covered (danger)
0.00%
0 / 1
18.62
 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
 tmp_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_download_url
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 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;
11
12if ( ! defined( 'ABSPATH' ) ) {
13    exit( 0 );
14}
15
16/**
17 * External Media helper API.
18 *
19 * @since 8.7.0
20 */
21class WPCOM_REST_API_V2_Endpoint_External_Media extends WP_REST_Controller {
22
23    /**
24     * Media argument schema for /copy endpoint.
25     *
26     * @var array
27     */
28    public $media_schema = array(
29        'type'  => 'array',
30        'items' => array(
31            'type'       => 'object',
32            'required'   => true,
33            'properties' => array(
34                'caption' => array(
35                    'type' => 'string',
36                ),
37                'guid'    => array(
38                    'type'       => 'object',
39                    'properties' => array(
40                        'caption' => array(
41                            'type' => 'string',
42                        ),
43                        'name'    => array(
44                            'type' => 'string',
45                        ),
46                        'title'   => array(
47                            'type' => 'string',
48                        ),
49                        'url'     => array(
50                            'format' => 'uri',
51                            'type'   => 'string',
52                        ),
53                    ),
54                ),
55                'title'   => array(
56                    'type' => 'string',
57                ),
58                'meta'    => array(
59                    'type'                 => 'object',
60                    'additionalProperties' => false,
61                    'properties'           => array(
62                        'vertical_id'   => array(
63                            'type'   => 'string',
64                            'format' => 'text-field',
65                        ),
66                        'pexels_object' => array(
67                            'type' => 'object',
68                        ),
69                    ),
70                ),
71            ),
72        ),
73    );
74
75    /**
76     * Service regex.
77     *
78     * @var string
79     */
80    private static $services_regex = '(?P<service>google_photos|openverse|pexels)';
81
82    /**
83     * Temporary filename.
84     *
85     * Needed to cope with Google's very long file names.
86     *
87     * @var string
88     */
89    private $tmp_name;
90
91    /**
92     * Constructor.
93     */
94    public function __construct() {
95        $this->namespace = 'wpcom/v2';
96        $this->rest_base = 'external-media';
97
98        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
99    }
100
101    /**
102     * Registers the routes for external media.
103     */
104    public function register_routes() {
105        register_rest_route(
106            $this->namespace,
107            $this->rest_base . '/list/' . self::$services_regex,
108            array(
109                'methods'             => WP_REST_Server::READABLE,
110                'callback'            => array( $this, 'get_external_media' ),
111                'permission_callback' => array( $this, 'permission_callback' ),
112                'args'                => array(
113                    'search'      => array(
114                        'description' => __( 'Media collection search term.', 'jetpack' ),
115                        'type'        => 'string',
116                    ),
117                    'number'      => array(
118                        'description' => __( 'Number of media items in the request', 'jetpack' ),
119                        'type'        => 'number',
120                        'default'     => 20,
121                    ),
122                    'path'        => array(
123                        'type' => 'string',
124                    ),
125                    'page_handle' => array(
126                        'type' => 'string',
127                    ),
128                    'session_id'  => array(
129                        'description' => __( 'Session id of a service, currently only Google Photos Picker', 'jetpack' ),
130                        'type'        => 'string',
131                    ),
132                ),
133            )
134        );
135
136        register_rest_route(
137            $this->namespace,
138            $this->rest_base . '/copy/' . self::$services_regex,
139            array(
140                'methods'             => \WP_REST_Server::CREATABLE,
141                'callback'            => array( $this, 'copy_external_media' ),
142                'permission_callback' => array( $this, 'create_item_permissions_check' ),
143                'args'                => array(
144                    'media'        => array(
145                        'description'       => __( 'Media data to copy.', 'jetpack' ),
146                        'items'             => $this->media_schema,
147                        'required'          => true,
148                        'type'              => 'array',
149                        'sanitize_callback' => array( $this, 'sanitize_media' ),
150                        'validate_callback' => array( $this, 'validate_media' ),
151                    ),
152                    'post_id'      => array(
153                        'description' => __( 'The post ID to attach the upload to.', 'jetpack' ),
154                        'type'        => 'number',
155                        'minimum'     => 0,
156                    ),
157                    'should_proxy' => array(
158                        'description' => __( 'Whether to proxy the media request.', 'jetpack' ),
159                        'type'        => 'boolean',
160                        'default'     => false,
161                    ),
162                ),
163            )
164        );
165
166        register_rest_route(
167            $this->namespace,
168            $this->rest_base . '/connection/(?P<service>google_photos)',
169            array(
170                'methods'             => \WP_REST_Server::READABLE,
171                'callback'            => array( $this, 'get_connection_details' ),
172                'permission_callback' => array( $this, 'permission_callback' ),
173            )
174        );
175
176        register_rest_route(
177            $this->namespace,
178            $this->rest_base . '/connection/(?P<service>google_photos)',
179            array(
180                'methods'             => \WP_REST_Server::DELETABLE,
181                'callback'            => array( $this, 'delete_connection' ),
182                'permission_callback' => array( $this, 'permission_callback' ),
183            )
184        );
185
186        register_rest_route(
187            $this->namespace,
188            $this->rest_base . '/connection/(?P<service>google_photos)/picker_status',
189            array(
190                'methods'             => \WP_REST_Server::READABLE,
191                'callback'            => array( $this, 'get_picker_status' ),
192                'permission_callback' => array( $this, 'permission_callback' ),
193            )
194        );
195
196        // Add new session route, currently for Google Photos Picker only
197        register_rest_route(
198            $this->namespace,
199            $this->rest_base . '/session/(?P<service>google_photos)',
200            array(
201                'methods'             => \WP_REST_Server::CREATABLE,
202                'callback'            => array( $this, 'create_session' ),
203                'permission_callback' => array( $this, 'permission_callback' ),
204            )
205        );
206
207        // Get new session route, currently for Google Photos Picker only
208        register_rest_route(
209            $this->namespace,
210            $this->rest_base . '/session/(?P<service>google_photos)/(?P<session_id>.*)',
211            array(
212                'methods'             => \WP_REST_Server::READABLE,
213                'callback'            => array( $this, 'get_session' ),
214                'permission_callback' => array( $this, 'permission_callback' ),
215            )
216        );
217
218        // Delete session route, currently for Google Photos Picker only
219        register_rest_route(
220            $this->namespace,
221            $this->rest_base . '/session/(?P<service>google_photos)/(?P<session_id>.*)',
222            array(
223                'methods'             => \WP_REST_Server::DELETABLE,
224                'callback'            => array( $this, 'delete_session' ),
225                'permission_callback' => array( $this, 'permission_callback' ),
226            )
227        );
228
229        // Add new proxy route for media files
230        register_rest_route(
231            $this->namespace,
232            $this->rest_base . '/proxy/(?P<service>google_photos)',
233            array(
234                'methods'             => WP_REST_Server::CREATABLE,
235                'callback'            => array( $this, 'proxy_media_request' ),
236                'permission_callback' => array( $this, 'permission_callback' ),
237                'args'                => array(
238                    'url' => array(
239                        'required' => true,
240                        'type'     => 'string',
241                    ),
242                ),
243            )
244        );
245    }
246
247    /**
248     * Checks if a given request has access to external media libraries.
249     */
250    public function permission_callback() {
251        return current_user_can( 'upload_files' );
252    }
253
254    /**
255     * Checks if a given request has access to create an attachment.
256     *
257     * @param WP_REST_Request $request Full details about the request.
258     * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
259     */
260    public function create_item_permissions_check( $request ) {
261        if ( ! empty( $request['id'] ) ) {
262            return new WP_Error(
263                'rest_post_exists',
264                __( 'Cannot create existing post.', 'jetpack' ),
265                array( 'status' => 400 )
266            );
267        }
268
269        $post_type = get_post_type_object( 'attachment' );
270
271        if ( ! current_user_can( $post_type->cap->create_posts ) ) {
272            return new WP_Error(
273                'rest_cannot_create',
274                __( 'Sorry, you are not allowed to create posts as this user.', 'jetpack' ),
275                array( 'status' => rest_authorization_required_code() )
276            );
277        }
278
279        if ( ! current_user_can( 'upload_files' ) ) {
280            return new WP_Error(
281                'rest_cannot_create',
282                __( 'Sorry, you are not allowed to upload media on this site.', 'jetpack' ),
283                array( 'status' => 400 )
284            );
285        }
286
287        return true;
288    }
289
290    /**
291     * Sanitization callback for media parameter.
292     *
293     * @param array $param Media parameter.
294     * @return true|\WP_Error
295     */
296    public function sanitize_media( $param ) {
297        $param = $this->prepare_media_param( $param );
298
299        return rest_sanitize_value_from_schema( $param, $this->media_schema );
300    }
301
302    /**
303     * Validation callback for media parameter.
304     *
305     * @param array $param Media parameter.
306     * @return true|\WP_Error
307     */
308    public function validate_media( $param ) {
309        $param = $this->prepare_media_param( $param );
310
311        return rest_validate_value_from_schema( $param, $this->media_schema, 'media' );
312    }
313
314    /**
315     * Decodes guid json and sets parameter defaults.
316     *
317     * @param array $param Media parameter.
318     * @return array
319     */
320    private function prepare_media_param( $param ) {
321        foreach ( $param as $key => $item ) {
322            if ( ! empty( $item['guid'] ) ) {
323                $param[ $key ]['guid'] = json_decode( $item['guid'], true );
324            }
325
326            if ( empty( $param[ $key ]['caption'] ) ) {
327                $param[ $key ]['caption'] = '';
328            }
329            if ( empty( $param[ $key ]['title'] ) ) {
330                $param[ $key ]['title'] = '';
331            }
332        }
333
334        return $param;
335    }
336
337    /**
338     * Retrieves media items from external libraries.
339     *
340     * @param \WP_REST_Request $request Full details about the request.
341     * @return array|\WP_Error|mixed
342     */
343    public function get_external_media( \WP_REST_Request $request ) {
344        $params     = $request->get_params();
345        $wpcom_path = sprintf( '/meta/external-media/%s', rawurlencode( $params['service'] ) );
346
347        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
348            $request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
349            $request->set_query_params( $params );
350
351            return rest_do_request( $request );
352        }
353
354        // Build query string to pass to wpcom endpoint.
355        $service_args = array_filter(
356            $params,
357            function ( $key ) {
358                return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter', 'session_id' ), true );
359            },
360            ARRAY_FILTER_USE_KEY
361        );
362        if ( ! empty( $service_args ) ) {
363            $wpcom_path .= '?' . http_build_query( $service_args );
364        }
365
366        $response = Client::wpcom_json_api_request_as_user( $wpcom_path );
367
368        switch ( wp_remote_retrieve_response_code( $response ) ) {
369            case 200:
370                $response = json_decode( wp_remote_retrieve_body( $response ), true );
371                break;
372
373            case 401:
374                $response = new WP_Error(
375                    'authorization_required',
376                    __( 'You are not connected to that service.', 'jetpack' ),
377                    array( 'status' => 403 )
378                );
379                break;
380
381            case 403:
382                $error    = json_decode( wp_remote_retrieve_body( $response ) );
383                $response = new WP_Error( $error->code, $error->message, $error->data );
384                break;
385
386            default:
387                if ( is_wp_error( $response ) ) {
388                    $response->add_data( array( 'status' => 400 ) );
389                    break;
390                }
391                $response = new WP_Error(
392                    'rest_request_error',
393                    __( 'An unknown error has occurred. Please try again later.', 'jetpack' ),
394                    array( 'status' => wp_remote_retrieve_response_code( $response ) )
395                );
396        }
397
398        return $response;
399    }
400
401    /**
402     * Saves an external media item to the media library.
403     *
404     * @param \WP_REST_Request $request Full details about the request.
405     * @return array|\WP_Error|mixed
406     **/
407    public function copy_external_media( \WP_REST_Request $request ) {
408        require_once ABSPATH . 'wp-admin/includes/file.php';
409        require_once ABSPATH . 'wp-admin/includes/media.php';
410        require_once ABSPATH . 'wp-admin/includes/image.php';
411
412        $post_id      = $request->get_param( 'post_id' );
413        $should_proxy = $request->get_param( 'should_proxy' );
414        $service      = rawurlencode( $request->get_param( 'service' ) );
415
416        $responses = array();
417
418        foreach ( $request->get_param( 'media' ) as $item ) {
419            // Download file to temp dir.
420            if ( $should_proxy ) {
421                $wpcom_path   = sprintf( '/meta/external-media/proxy/%s', $service );
422                $wpcom_path  .= '?url=' . rawurlencode( $item['guid']['url'] );
423                $download_url = wp_tempnam();
424                $response     = Client::wpcom_json_api_request_as_user(
425                    $wpcom_path,
426                    '2',
427                    array(
428                        'method' => 'POST',
429                    )
430                );
431
432                if ( is_wp_error( $response ) ) {
433                    $responses[] = $response;
434                    continue;
435                }
436                $wp_filesystem = $this->get_wp_filesystem();
437                $written       = $wp_filesystem->put_contents( $download_url, wp_remote_retrieve_body( $response ) );
438
439                if ( false === $written ) {
440                    $responses[] = new WP_Error(
441                        'rest_upload_error',
442                        __( 'Could not download media file.', 'jetpack' ),
443                        array( 'status' => 400 )
444                    );
445                    continue;
446                }
447            } else {
448                $download_url = $this->get_download_url( $item['guid'] );
449            }
450
451            if ( is_wp_error( $download_url ) ) {
452                $responses[] = $download_url;
453                continue;
454            }
455
456            $id = $this->sideload_media( $item['guid']['name'], $download_url, $post_id );
457            if ( is_wp_error( $id ) ) {
458                $responses[] = $id;
459                continue;
460            }
461
462            $this->update_attachment_meta( $id, $item );
463
464            // Add attachment data or WP_Error.
465            $responses[] = $this->get_attachment_data( $id, $item );
466        }
467
468        return $responses;
469    }
470
471    /**
472     * Gets connection authorization details.
473     *
474     * @param \WP_REST_Request $request Full details about the request.
475     * @return array|\WP_Error|mixed
476     */
477    public function get_connection_details( \WP_REST_Request $request ) {
478        $service = $request->get_param( 'service' );
479
480        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
481            $wpcom_path       = sprintf( '/meta/external-media/connection/%s', rawurlencode( $service ) );
482            $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
483            $internal_request->set_query_params( $request->get_params() );
484
485            return rest_do_request( $internal_request );
486        }
487
488        $site_id = Manager::get_site_id();
489        if ( is_wp_error( $site_id ) ) {
490            return $site_id;
491        }
492
493        $path     = sprintf( '/sites/%d/external-services', $site_id );
494        $response = Client::wpcom_json_api_request_as_user( $path );
495        if ( is_wp_error( $response ) ) {
496            return $response;
497        }
498
499        $body = json_decode( wp_remote_retrieve_body( $response ) );
500        if ( ! property_exists( $body, 'services' ) || ! property_exists( $body->services, $service ) ) {
501            return new WP_Error(
502                'bad_request',
503                __( 'An error occurred. Please try again later.', 'jetpack' ),
504                array( 'status' => 400 )
505            );
506        }
507
508        return $body->services->{ $service };
509    }
510
511    /**
512     * Deletes a Google Photos connection.
513     *
514     * @param WP_REST_Request $request Full details about the request.
515     * @return array|WP_Error|WP_REST_Response
516     */
517    public function delete_connection( WP_REST_Request $request ) {
518        $service    = rawurlencode( $request->get_param( 'service' ) );
519        $wpcom_path = sprintf( '/meta/external-media/connection/%s', $service );
520
521        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
522            $internal_request = new WP_REST_Request( 'DELETE', '/' . $this->namespace . $wpcom_path );
523            $internal_request->set_query_params( $request->get_params() );
524
525            return rest_do_request( $internal_request );
526        }
527
528        $response = Client::wpcom_json_api_request_as_user(
529            $wpcom_path,
530            '2',
531            array(
532                'method' => 'DELETE',
533            )
534        );
535
536        return json_decode( wp_remote_retrieve_body( $response ), true );
537    }
538
539    /**
540     * Gets Google Photos Picker enabled Status.
541     *
542     * @param \WP_REST_Request $request Full details about the request.
543     * @return array|\WP_Error|mixed
544     */
545    public function get_picker_status( \WP_REST_Request $request ) {
546        $service    = $request->get_param( 'service' );
547        $wpcom_path = sprintf( '/meta/external-media/connection/%s/picker_status', rawurlencode( $service ) );
548
549        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
550            $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
551            $internal_request->set_query_params( $request->get_params() );
552
553            return rest_do_request( $internal_request );
554        }
555
556        $response = Client::wpcom_json_api_request_as_user(
557            $wpcom_path,
558            '2',
559            array(
560                'method' => 'GET',
561            )
562        );
563
564        return json_decode( wp_remote_retrieve_body( $response ), true );
565    }
566
567    /**
568     * Creates a new session for a service.
569     *
570     * @param \WP_REST_Request $request Full details about the request.
571     * @return array|\WP_Error|mixed
572     */
573    public function create_session( \WP_REST_Request $request ) {
574        $service    = $request->get_param( 'service' );
575        $wpcom_path = sprintf( '/meta/external-media/session/%s', rawurlencode( $service ) );
576
577        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
578            $internal_request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
579            $internal_request->set_query_params( $request->get_params() );
580
581            return rest_do_request( $internal_request );
582        }
583
584        $response = Client::wpcom_json_api_request_as_user(
585            $wpcom_path,
586            '2',
587            array(
588                'method' => 'POST',
589            )
590        );
591
592        return json_decode( wp_remote_retrieve_body( $response ), true );
593    }
594
595    /**
596     * Gets a session for a service.
597     *
598     * @param \WP_REST_Request $request Full details about the request.
599     * @return array|\WP_Error|mixed
600     */
601    public function get_session( \WP_REST_Request $request ) {
602        $service    = $request->get_param( 'service' );
603        $session_id = $request->get_param( 'session_id' );
604        $wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
605
606        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
607            $internal_request = new \WP_REST_Request( 'GET', '/' . $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' => 'GET',
618            )
619        );
620
621        return json_decode( wp_remote_retrieve_body( $response ), true );
622    }
623
624    /**
625     * Deletes 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 delete_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( 'DELETE', '/' . $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' => 'DELETE',
647            )
648        );
649
650        return json_decode( wp_remote_retrieve_body( $response ), true );
651    }
652
653    /**
654     * Proxies media requests with proper authorization headers
655     *
656     * @param WP_REST_Request $request Full details about the request.
657     * @return WP_REST_Response|WP_Error|array Response object or WP_Error.
658     */
659    public function proxy_media_request( $request ) {
660        $params     = $request->get_params();
661        $service    = rawurlencode( $request->get_param( 'service' ) );
662        $wpcom_path = sprintf( '/meta/external-media/proxy/%s', $service );
663
664        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
665            $request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
666            $request->set_query_params( $params );
667
668            return rest_do_request( $request );
669
670        } else {
671            // Build query string to pass to wpcom endpoint.
672            $service_args = array_filter(
673                $params,
674                function ( $key ) {
675                    return in_array( $key, array( 'url' ), true );
676                },
677                ARRAY_FILTER_USE_KEY
678            );
679
680            if ( ! empty( $service_args ) ) {
681                $wpcom_path .= '?' . http_build_query( $service_args );
682            }
683
684            $response = Client::wpcom_json_api_request_as_user(
685                $wpcom_path,
686                '2',
687                array(
688                    'method' => 'POST',
689                )
690            );
691
692            $status_code = wp_remote_retrieve_response_code( $response );
693            $headers     = wp_remote_retrieve_headers( $response );
694            $body        = wp_remote_retrieve_body( $response );
695
696            // For non-200 responses, parse and return JSON error
697            if ( $status_code !== 200 ) {
698                $error_data = json_decode( $body, true );
699                return new \WP_REST_Response( $error_data, $status_code );
700            }
701        }
702
703        // Return binary content directly
704        $valid_headers = array(
705            'content-type',
706            'content-length',
707            'content-disposition',
708        );
709        // Set content headers
710        foreach ( $valid_headers as $header ) {
711            if ( ! empty( $headers[ $header ] ) ) {
712                header( ucwords( $header, '-' ) . ': ' . $headers[ $header ] );
713            }
714        }
715
716        // Set cache headers
717        header( 'Cache-Control: no-cache, no-store, must-revalidate' );
718        header( 'Pragma: no-cache' );
719        header( 'Expires: 0' );
720        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Media binary data
721        echo $body;
722        exit( 0 );
723    }
724
725    /**
726     * Filter callback to provide a shorter file name for google images.
727     *
728     * @return string
729     */
730    public function tmp_name() {
731        return $this->tmp_name;
732    }
733
734    /**
735     * Returns a download URL, dealing with Google's long file names.
736     *
737     * @param array $guid Media information.
738     * @return string|\WP_Error
739     */
740    public function get_download_url( $guid ) {
741        $this->tmp_name = $guid['name'];
742        add_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) );
743        $download_url = download_url( $guid['url'] );
744        remove_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) );
745
746        if ( is_wp_error( $download_url ) ) {
747            $download_url->add_data( array( 'status' => 400 ) );
748        }
749
750        return $download_url;
751    }
752
753    /**
754     * Uploads media file and creates attachment object.
755     *
756     * @param string $file_name    Name of media file.
757     * @param string $download_url Download URL.
758     * @param int    $post_id      The ID of the post to attach the image to.
759     *
760     * @return int|\WP_Error
761     */
762    public function sideload_media( $file_name, $download_url, $post_id = 0 ) {
763        $file = array(
764            'name'     => wp_basename( $file_name ),
765            'tmp_name' => $download_url,
766        );
767
768        $id = media_handle_sideload( $file, $post_id, null );
769        if ( is_wp_error( $id ) ) {
770            wp_delete_file( $file['tmp_name'] );
771            $id->add_data( array( 'status' => 400 ) );
772        }
773
774        return $id;
775    }
776
777    /**
778     * Updates attachment meta data for media item.
779     *
780     * @param int   $id   Attachment ID.
781     * @param array $item Media item.
782     */
783    public function update_attachment_meta( $id, $item ) {
784        $meta                          = wp_get_attachment_metadata( $id );
785        $meta['image_meta']['title']   = $item['title'];
786        $meta['image_meta']['caption'] = $item['caption'];
787
788        wp_update_attachment_metadata( $id, $meta );
789
790        update_post_meta( $id, '_wp_attachment_image_alt', $item['title'] );
791        wp_update_post(
792            array(
793                'ID'           => $id,
794                'post_excerpt' => $item['caption'],
795            )
796        );
797
798        if ( ! empty( $item['meta'] ) ) {
799            foreach ( $item['meta'] as $meta_key => $meta_value ) {
800                update_post_meta( $id, $meta_key, $meta_value );
801            }
802        }
803    }
804
805    /**
806     * Retrieves attachment data for media item.
807     *
808     * @param int   $id   Attachment ID.
809     * @param array $item Media item.
810     *
811     * @return array|\WP_REST_Response Attachment data on success, WP_Error on failure.
812     */
813    public function get_attachment_data( $id, $item ) {
814        $image_src = wp_get_attachment_image_src( $id, 'full' );
815
816        if ( empty( $image_src[0] ) ) {
817            $response = new WP_Error(
818                'rest_upload_error',
819                __( 'Could not retrieve source URL.', 'jetpack' ),
820                array( 'status' => 400 )
821            );
822        } else {
823            $response = array(
824                'id'      => $id,
825                'caption' => $item['caption'],
826                'alt'     => $item['title'],
827                'type'    => 'image',
828                'url'     => $image_src[0],
829            );
830        }
831
832        return $response;
833    }
834
835    /**
836     * Get the wp filesystem.
837     *
838     * @return \WP_Filesystem_Base|null
839     */
840    private function get_wp_filesystem() {
841        global $wp_filesystem;
842
843        if ( ! isset( $wp_filesystem ) ) {
844            require_once ABSPATH . '/wp-admin/includes/file.php';
845            WP_Filesystem();
846        }
847
848        return $wp_filesystem;
849    }
850}
851
852wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_External_Media' );