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