Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.97% covered (warning)
50.97%
236 / 463
33.33% covered (danger)
33.33%
7 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_External_Media
51.30% covered (warning)
51.30%
236 / 460
33.33% covered (danger)
33.33%
7 / 21
742.96
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
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        // Attaching media to a post requires the ability to edit that post, mirroring
279        // WP_REST_Attachments_Controller::create_item_permissions_check(). Without this
280        // check any user with upload_files could parent an attachment to a post they
281        // cannot edit.
282        $post_id = (int) $request->get_param( 'post_id' );
283        if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
284            return new WP_Error(
285                'rest_cannot_edit',
286                __( 'Sorry, you are not allowed to upload media to this post.', 'jetpack' ),
287                array( 'status' => rest_authorization_required_code() )
288            );
289        }
290
291        return true;
292    }
293
294    /**
295     * Sanitization callback for media parameter.
296     *
297     * @param array $param Media parameter.
298     * @return true|\WP_Error
299     */
300    public function sanitize_media( $param ) {
301        $param = $this->prepare_media_param( $param );
302
303        return rest_sanitize_value_from_schema( $param, $this->media_schema );
304    }
305
306    /**
307     * Validation callback for media parameter.
308     *
309     * @param array $param Media parameter.
310     * @return true|\WP_Error
311     */
312    public function validate_media( $param ) {
313        $param = $this->prepare_media_param( $param );
314
315        return rest_validate_value_from_schema( $param, $this->media_schema, 'media' );
316    }
317
318    /**
319     * Decodes guid json and sets parameter defaults.
320     *
321     * @param array $param Media parameter.
322     * @return array
323     */
324    private function prepare_media_param( $param ) {
325        foreach ( $param as $key => $item ) {
326            if ( ! empty( $item['guid'] ) ) {
327                $param[ $key ]['guid'] = json_decode( $item['guid'], true );
328            }
329
330            if ( empty( $param[ $key ]['caption'] ) ) {
331                $param[ $key ]['caption'] = '';
332            }
333            if ( empty( $param[ $key ]['title'] ) ) {
334                $param[ $key ]['title'] = '';
335            }
336        }
337
338        return $param;
339    }
340
341    /**
342     * Retrieves media items from external libraries.
343     *
344     * @param \WP_REST_Request $request Full details about the request.
345     * @return array|\WP_Error|mixed
346     */
347    public function get_external_media( \WP_REST_Request $request ) {
348        $params     = $request->get_params();
349        $wpcom_path = sprintf( '/meta/external-media/%s', rawurlencode( $params['service'] ) );
350
351        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
352            $request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
353            $request->set_query_params( $params );
354
355            return rest_do_request( $request );
356        }
357
358        // Build query string to pass to wpcom endpoint.
359        $service_args = array_filter(
360            $params,
361            function ( $key ) {
362                return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter', 'session_id' ), true );
363            },
364            ARRAY_FILTER_USE_KEY
365        );
366        if ( ! empty( $service_args ) ) {
367            $wpcom_path .= '?' . http_build_query( $service_args );
368        }
369
370        $response = Client::wpcom_json_api_request_as_user( $wpcom_path );
371
372        switch ( wp_remote_retrieve_response_code( $response ) ) {
373            case 200:
374                $response = json_decode( wp_remote_retrieve_body( $response ), true );
375                break;
376
377            case 401:
378                $response = new WP_Error(
379                    'authorization_required',
380                    __( 'You are not connected to that service.', 'jetpack' ),
381                    array( 'status' => 403 )
382                );
383                break;
384
385            case 403:
386                $error    = json_decode( wp_remote_retrieve_body( $response ) );
387                $response = new WP_Error( $error->code, $error->message, $error->data );
388                break;
389
390            default:
391                if ( is_wp_error( $response ) ) {
392                    $response->add_data( array( 'status' => 400 ) );
393                    break;
394                }
395                $response = new WP_Error(
396                    'rest_request_error',
397                    __( 'An unknown error has occurred. Please try again later.', 'jetpack' ),
398                    array( 'status' => wp_remote_retrieve_response_code( $response ) )
399                );
400        }
401
402        return $response;
403    }
404
405    /**
406     * Saves an external media item to the media library.
407     *
408     * @param \WP_REST_Request $request Full details about the request.
409     * @return array|\WP_Error|mixed
410     **/
411    public function copy_external_media( \WP_REST_Request $request ) {
412        require_once ABSPATH . 'wp-admin/includes/file.php';
413        require_once ABSPATH . 'wp-admin/includes/media.php';
414        require_once ABSPATH . 'wp-admin/includes/image.php';
415
416        $post_id      = (int) $request->get_param( 'post_id' );
417        $should_proxy = $request->get_param( 'should_proxy' );
418        $service      = rawurlencode( $request->get_param( 'service' ) );
419
420        // Fail closed: never parent an attachment to a post the caller cannot edit,
421        // even if a future change lets an unauthorized request reach this handler.
422        // The permission callback already rejects such requests with a 403.
423        if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
424            $post_id = 0;
425        }
426
427        $responses = array();
428
429        foreach ( $request->get_param( 'media' ) as $item ) {
430            // Download file to temp dir.
431            if ( $should_proxy ) {
432                $wpcom_path   = sprintf( '/meta/external-media/proxy/%s', $service );
433                $wpcom_path  .= '?url=' . rawurlencode( $item['guid']['url'] );
434                $download_url = wp_tempnam();
435                $response     = Client::wpcom_json_api_request_as_user(
436                    $wpcom_path,
437                    '2',
438                    array(
439                        'method' => 'POST',
440                    )
441                );
442
443                if ( is_wp_error( $response ) ) {
444                    $responses[] = $response;
445                    continue;
446                }
447                $wp_filesystem = $this->get_wp_filesystem();
448                $written       = $wp_filesystem->put_contents( $download_url, wp_remote_retrieve_body( $response ) );
449
450                if ( false === $written ) {
451                    $responses[] = new WP_Error(
452                        'rest_upload_error',
453                        __( 'Could not download media file.', 'jetpack' ),
454                        array( 'status' => 400 )
455                    );
456                    continue;
457                }
458            } else {
459                $download_url = $this->get_download_url( $item['guid'] );
460            }
461
462            if ( is_wp_error( $download_url ) ) {
463                $responses[] = $download_url;
464                continue;
465            }
466
467            $id = $this->sideload_media( $item['guid']['name'], $download_url, $post_id );
468            if ( is_wp_error( $id ) ) {
469                $responses[] = $id;
470                continue;
471            }
472
473            $this->update_attachment_meta( $id, $item );
474
475            // Add attachment data or WP_Error.
476            $responses[] = $this->get_attachment_data( $id, $item );
477        }
478
479        return $responses;
480    }
481
482    /**
483     * Gets connection authorization details.
484     *
485     * @param \WP_REST_Request $request Full details about the request.
486     * @return array|\WP_Error|mixed
487     */
488    public function get_connection_details( \WP_REST_Request $request ) {
489        $service = $request->get_param( 'service' );
490
491        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
492            $wpcom_path       = sprintf( '/meta/external-media/connection/%s', rawurlencode( $service ) );
493            $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
494            $internal_request->set_query_params( $request->get_params() );
495
496            return rest_do_request( $internal_request );
497        }
498
499        $site_id = Manager::get_site_id();
500        if ( is_wp_error( $site_id ) ) {
501            return $site_id;
502        }
503
504        $path     = sprintf( '/sites/%d/external-services', $site_id );
505        $response = Client::wpcom_json_api_request_as_user( $path );
506        if ( is_wp_error( $response ) ) {
507            return $response;
508        }
509
510        $body = json_decode( wp_remote_retrieve_body( $response ) );
511        if ( ! property_exists( $body, 'services' ) || ! property_exists( $body->services, $service ) ) {
512            return new WP_Error(
513                'bad_request',
514                __( 'An error occurred. Please try again later.', 'jetpack' ),
515                array( 'status' => 400 )
516            );
517        }
518
519        return $body->services->{ $service };
520    }
521
522    /**
523     * Deletes a Google Photos connection.
524     *
525     * @param WP_REST_Request $request Full details about the request.
526     * @return array|WP_Error|WP_REST_Response
527     */
528    public function delete_connection( WP_REST_Request $request ) {
529        $service    = rawurlencode( $request->get_param( 'service' ) );
530        $wpcom_path = sprintf( '/meta/external-media/connection/%s', $service );
531
532        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
533            $internal_request = new WP_REST_Request( 'DELETE', '/' . $this->namespace . $wpcom_path );
534            $internal_request->set_query_params( $request->get_params() );
535
536            return rest_do_request( $internal_request );
537        }
538
539        $response = Client::wpcom_json_api_request_as_user(
540            $wpcom_path,
541            '2',
542            array(
543                'method' => 'DELETE',
544            )
545        );
546
547        return json_decode( wp_remote_retrieve_body( $response ), true );
548    }
549
550    /**
551     * Gets Google Photos Picker enabled Status.
552     *
553     * @param \WP_REST_Request $request Full details about the request.
554     * @return array|\WP_Error|mixed
555     */
556    public function get_picker_status( \WP_REST_Request $request ) {
557        $service    = $request->get_param( 'service' );
558        $wpcom_path = sprintf( '/meta/external-media/connection/%s/picker_status', rawurlencode( $service ) );
559
560        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
561            $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
562            $internal_request->set_query_params( $request->get_params() );
563
564            return rest_do_request( $internal_request );
565        }
566
567        $response = Client::wpcom_json_api_request_as_user(
568            $wpcom_path,
569            '2',
570            array(
571                'method' => 'GET',
572            )
573        );
574
575        return json_decode( wp_remote_retrieve_body( $response ), true );
576    }
577
578    /**
579     * Creates a new session for a service.
580     *
581     * @param \WP_REST_Request $request Full details about the request.
582     * @return array|\WP_Error|mixed
583     */
584    public function create_session( \WP_REST_Request $request ) {
585        $service    = $request->get_param( 'service' );
586        $wpcom_path = sprintf( '/meta/external-media/session/%s', rawurlencode( $service ) );
587
588        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
589            $internal_request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
590            $internal_request->set_query_params( $request->get_params() );
591
592            return rest_do_request( $internal_request );
593        }
594
595        $response = Client::wpcom_json_api_request_as_user(
596            $wpcom_path,
597            '2',
598            array(
599                'method' => 'POST',
600            )
601        );
602
603        return json_decode( wp_remote_retrieve_body( $response ), true );
604    }
605
606    /**
607     * Gets a session for a service.
608     *
609     * @param \WP_REST_Request $request Full details about the request.
610     * @return array|\WP_Error|mixed
611     */
612    public function get_session( \WP_REST_Request $request ) {
613        $service    = $request->get_param( 'service' );
614        $session_id = $request->get_param( 'session_id' );
615        $wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
616
617        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
618            $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
619            $internal_request->set_query_params( $request->get_params() );
620
621            return rest_do_request( $internal_request );
622        }
623
624        $response = Client::wpcom_json_api_request_as_user(
625            $wpcom_path,
626            '2',
627            array(
628                'method' => 'GET',
629            )
630        );
631
632        return json_decode( wp_remote_retrieve_body( $response ), true );
633    }
634
635    /**
636     * Deletes a session for a service.
637     *
638     * @param \WP_REST_Request $request Full details about the request.
639     * @return array|\WP_Error|mixed
640     */
641    public function delete_session( \WP_REST_Request $request ) {
642        $service    = $request->get_param( 'service' );
643        $session_id = $request->get_param( 'session_id' );
644        $wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
645
646        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
647            $internal_request = new \WP_REST_Request( 'DELETE', '/' . $this->namespace . $wpcom_path );
648            $internal_request->set_query_params( $request->get_params() );
649
650            return rest_do_request( $internal_request );
651        }
652
653        $response = Client::wpcom_json_api_request_as_user(
654            $wpcom_path,
655            '2',
656            array(
657                'method' => 'DELETE',
658            )
659        );
660
661        return json_decode( wp_remote_retrieve_body( $response ), true );
662    }
663
664    /**
665     * Proxies media requests with proper authorization headers
666     *
667     * @param WP_REST_Request $request Full details about the request.
668     * @return WP_REST_Response|WP_Error|array Response object or WP_Error.
669     */
670    public function proxy_media_request( $request ) {
671        $params     = $request->get_params();
672        $service    = rawurlencode( $request->get_param( 'service' ) );
673        $wpcom_path = sprintf( '/meta/external-media/proxy/%s', $service );
674
675        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
676            $request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
677            $request->set_query_params( $params );
678
679            return rest_do_request( $request );
680
681        } else {
682            // Build query string to pass to wpcom endpoint.
683            $service_args = array_filter(
684                $params,
685                function ( $key ) {
686                    return in_array( $key, array( 'url' ), true );
687                },
688                ARRAY_FILTER_USE_KEY
689            );
690
691            if ( ! empty( $service_args ) ) {
692                $wpcom_path .= '?' . http_build_query( $service_args );
693            }
694
695            $response = Client::wpcom_json_api_request_as_user(
696                $wpcom_path,
697                '2',
698                array(
699                    'method' => 'POST',
700                )
701            );
702
703            $status_code = wp_remote_retrieve_response_code( $response );
704            $headers     = wp_remote_retrieve_headers( $response );
705            $body        = wp_remote_retrieve_body( $response );
706
707            // For non-200 responses, parse and return JSON error
708            if ( $status_code !== 200 ) {
709                $error_data = json_decode( $body, true );
710                return new \WP_REST_Response( $error_data, $status_code );
711            }
712        }
713
714        // Return binary content directly
715        $valid_headers = array(
716            'content-type',
717            'content-length',
718            'content-disposition',
719        );
720        // Set content headers
721        foreach ( $valid_headers as $header ) {
722            if ( ! empty( $headers[ $header ] ) ) {
723                header( ucwords( $header, '-' ) . ': ' . $headers[ $header ] );
724            }
725        }
726
727        // Set cache headers
728        header( 'Cache-Control: no-cache, no-store, must-revalidate' );
729        header( 'Pragma: no-cache' );
730        header( 'Expires: 0' );
731        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Media binary data
732        echo $body;
733        exit( 0 );
734    }
735
736    /**
737     * Downloads a remote media file into a temporary file for sideloading.
738     *
739     * The remote file is streamed into a randomly-named temporary file created by
740     * wp_tempnam(). The caller-supplied name is never used for the temporary file
741     * itself; it is only applied â€” and validated by WordPress â€” later, when the
742     * completed download is handed to media_handle_sideload(). This prevents a
743     * crafted name from controlling the physical path or extension of the file
744     * written to disk.
745     *
746     * @param array $guid Media information.
747     * @return string|\WP_Error Path to the downloaded temporary file, or WP_Error on failure.
748     */
749    public function get_download_url( $guid ) {
750        require_once ABSPATH . 'wp-admin/includes/file.php';
751
752        $tmp_name = wp_tempnam();
753        if ( ! $tmp_name ) {
754            return new WP_Error(
755                'rest_upload_error',
756                __( 'Could not create a temporary file.', 'jetpack' ),
757                array( 'status' => 500 )
758            );
759        }
760
761        $response = wp_safe_remote_get(
762            $guid['url'],
763            array(
764                'timeout'  => 300,
765                'stream'   => true,
766                'filename' => $tmp_name,
767            )
768        );
769
770        if ( is_wp_error( $response ) ) {
771            wp_delete_file( $tmp_name );
772            $response->add_data( array( 'status' => 400 ) );
773            return $response;
774        }
775
776        if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
777            wp_delete_file( $tmp_name );
778            return new WP_Error(
779                'rest_upload_error',
780                __( 'Could not download the media file.', 'jetpack' ),
781                array( 'status' => 400 )
782            );
783        }
784
785        return $tmp_name;
786    }
787
788    /**
789     * Uploads media file and creates attachment object.
790     *
791     * @param string $file_name    Name of media file.
792     * @param string $download_url Download URL.
793     * @param int    $post_id      The ID of the post to attach the image to.
794     *
795     * @return int|\WP_Error
796     */
797    public function sideload_media( $file_name, $download_url, $post_id = 0 ) {
798        $file = array(
799            'name'     => sanitize_file_name( wp_basename( $file_name ) ),
800            'tmp_name' => $download_url,
801        );
802
803        $id = media_handle_sideload( $file, $post_id, null );
804        if ( is_wp_error( $id ) ) {
805            wp_delete_file( $file['tmp_name'] );
806            $id->add_data( array( 'status' => 400 ) );
807        }
808
809        return $id;
810    }
811
812    /**
813     * Updates attachment meta data for media item.
814     *
815     * @param int   $id   Attachment ID.
816     * @param array $item Media item.
817     */
818    public function update_attachment_meta( $id, $item ) {
819        $meta                          = wp_get_attachment_metadata( $id );
820        $meta['image_meta']['title']   = $item['title'];
821        $meta['image_meta']['caption'] = $item['caption'];
822
823        wp_update_attachment_metadata( $id, $meta );
824
825        update_post_meta( $id, '_wp_attachment_image_alt', $item['title'] );
826        wp_update_post(
827            array(
828                'ID'           => $id,
829                'post_excerpt' => $item['caption'],
830            )
831        );
832
833        if ( ! empty( $item['meta'] ) ) {
834            foreach ( $item['meta'] as $meta_key => $meta_value ) {
835                update_post_meta( $id, $meta_key, $meta_value );
836            }
837        }
838    }
839
840    /**
841     * Retrieves attachment data for media item.
842     *
843     * @param int   $id   Attachment ID.
844     * @param array $item Media item.
845     *
846     * @return array|\WP_REST_Response Attachment data on success, WP_Error on failure.
847     */
848    public function get_attachment_data( $id, $item ) {
849        $image_src = wp_get_attachment_image_src( $id, 'full' );
850
851        if ( empty( $image_src[0] ) ) {
852            $response = new WP_Error(
853                'rest_upload_error',
854                __( 'Could not retrieve source URL.', 'jetpack' ),
855                array( 'status' => 400 )
856            );
857        } else {
858            $response = array(
859                'id'      => $id,
860                'caption' => $item['caption'],
861                'alt'     => $item['title'],
862                'type'    => 'image',
863                'url'     => $image_src[0],
864            );
865        }
866
867        return $response;
868    }
869
870    /**
871     * Get the wp filesystem.
872     *
873     * @return \WP_Filesystem_Base|null
874     */
875    private function get_wp_filesystem() {
876        global $wp_filesystem;
877
878        if ( ! isset( $wp_filesystem ) ) {
879            require_once ABSPATH . '/wp-admin/includes/file.php';
880            WP_Filesystem();
881        }
882
883        return $wp_filesystem;
884    }
885}
886
887wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_External_Media' );