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