PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-external-media.php +508 -34 12.5.2 → 16.3 View file →
@@ -6,9 +6,15 @@
6 6 * @since 8.7.0
7 7 */
8 8
9 9 use Automattic\Jetpack\Connection\Client;
10 +use Automattic\Jetpack\Connection\Manager;
11 +use Automattic\Jetpack\IP\Utils;
10 12
13 +if ( ! defined( 'ABSPATH' ) ) {
14 + exit( 0 );
15 +}
16 +
11 17 /**
12 18 * External Media helper API.
13 19 *
14 20 * @since 8.7.0
@@ -15,8 +21,25 @@
15 21 */
16 22 class WPCOM_REST_API_V2_Endpoint_External_Media extends WP_REST_Controller {
17 23
18 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 + /**
19 42 * Media argument schema for /copy endpoint.
20 43 *
21 44 * @var array
22 45 */
@@ -74,17 +97,8 @@
74 97 */
75 98 private static $services_regex = '(?P<service>google_photos|openverse|pexels)';
76 99
77 100 /**
78 - * Temporary filename.
79 - *
80 - * Needed to cope with Google's very long file names.
81 - *
82 - * @var string
83 - */
84 - private $tmp_name;
85 -
86 - /**
87 101 * Constructor.
88 102 */
89 103 public function __construct() {
90 104 $this->namespace = 'wpcom/v2';
@@ -119,8 +133,12 @@
119 133 ),
120 134 'page_handle' => array(
121 135 'type' => 'string',
122 136 ),
137 + 'session_id' => array(
138 + 'description' => __( 'Session id of a service, currently only Google Photos Picker', 'jetpack' ),
139 + 'type' => 'string',
140 + ),
123 141 ),
124 142 )
125 143 );
126 144
@@ -131,9 +149,9 @@
131 149 'methods' => \WP_REST_Server::CREATABLE,
132 150 'callback' => array( $this, 'copy_external_media' ),
133 151 'permission_callback' => array( $this, 'create_item_permissions_check' ),
134 152 'args' => array(
135 - 'media' => array(
153 + 'media' => array(
136 154 'description' => __( 'Media data to copy.', 'jetpack' ),
137 155 'items' => $this->media_schema,
138 156 'required' => true,
139 157 'type' => 'array',
@@ -139,13 +157,18 @@
139 157 'type' => 'array',
140 158 'sanitize_callback' => array( $this, 'sanitize_media' ),
141 159 'validate_callback' => array( $this, 'validate_media' ),
142 160 ),
143 - 'post_id' => array(
161 + 'post_id' => array(
144 162 'description' => __( 'The post ID to attach the upload to.', 'jetpack' ),
145 163 'type' => 'number',
146 164 'minimum' => 0,
147 165 ),
166 + 'should_proxy' => array(
167 + 'description' => __( 'Whether to proxy the media request.', 'jetpack' ),
168 + 'type' => 'boolean',
169 + 'default' => false,
170 + ),
148 171 ),
149 172 )
150 173 );
151 174
@@ -167,8 +190,68 @@
167 190 'callback' => array( $this, 'delete_connection' ),
168 191 'permission_callback' => array( $this, 'permission_callback' ),
169 192 )
170 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 + );
171 254 }
172 255
173 256 /**
174 257 * Checks if a given request has access to external media libraries.
@@ -173,9 +256,9 @@
173 256 /**
174 257 * Checks if a given request has access to external media libraries.
175 258 */
176 259 public function permission_callback() {
177 - return current_user_can( 'edit_posts' );
260 + return current_user_can( 'upload_files' );
178 261 }
179 262
180 263 /**
181 264 * Checks if a given request has access to create an attachment.
@@ -209,8 +292,21 @@
209 292 array( 'status' => 400 )
210 293 );
211 294 }
212 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 +
213 309 return true;
214 310 }
215 311
216 312 /**
@@ -280,9 +376,9 @@
280 376 // Build query string to pass to wpcom endpoint.
281 377 $service_args = array_filter(
282 378 $params,
283 379 function ( $key ) {
284 - return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter' ), true );
380 + return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter', 'session_id' ), true );
285 381 },
286 382 ARRAY_FILTER_USE_KEY
287 383 );
288 384 if ( ! empty( $service_args ) ) {
@@ -328,20 +424,60 @@
328 424 * Saves an external media item to the media library.
329 425 *
330 426 * @param \WP_REST_Request $request Full details about the request.
331 427 * @return array|\WP_Error|mixed
332 - */
428 + **/
333 429 public function copy_external_media( \WP_REST_Request $request ) {
334 430 require_once ABSPATH . 'wp-admin/includes/file.php';
335 431 require_once ABSPATH . 'wp-admin/includes/media.php';
336 432 require_once ABSPATH . 'wp-admin/includes/image.php';
337 433
338 - $post_id = $request->get_param( 'post_id' );
434 + $post_id = (int) $request->get_param( 'post_id' );
435 + $should_proxy = $request->get_param( 'should_proxy' );
436 + $service = rawurlencode( $request->get_param( 'service' ) );
339 437
438 + // Fail closed: never parent an attachment to a post the caller cannot edit,
439 + // even if a future change lets an unauthorized request reach this handler.
440 + // The permission callback already rejects such requests with a 403.
441 + if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
442 + $post_id = 0;
443 + }
444 +
340 445 $responses = array();
446 +
341 447 foreach ( $request->get_param( 'media' ) as $item ) {
342 448 // Download file to temp dir.
343 - $download_url = $this->get_download_url( $item['guid'] );
449 + if ( $should_proxy ) {
450 + $wpcom_path = sprintf( '/meta/external-media/proxy/%s', $service );
451 + $wpcom_path .= '?url=' . rawurlencode( $item['guid']['url'] );
452 + $download_url = wp_tempnam();
453 + $response = Client::wpcom_json_api_request_as_user(
454 + $wpcom_path,
455 + '2',
456 + array(
457 + 'method' => 'POST',
458 + )
459 + );
460 +
461 + if ( is_wp_error( $response ) ) {
462 + $responses[] = $response;
463 + continue;
464 + }
465 + $wp_filesystem = $this->get_wp_filesystem();
466 + $written = $wp_filesystem->put_contents( $download_url, wp_remote_retrieve_body( $response ) );
467 +
468 + if ( false === $written ) {
469 + $responses[] = new WP_Error(
470 + 'rest_upload_error',
471 + __( 'Could not download media file.', 'jetpack' ),
472 + array( 'status' => 400 )
473 + );
474 + continue;
475 + }
476 + } else {
477 + $download_url = $this->get_download_url( $item['guid'] );
478 + }
479 +
344 480 if ( is_wp_error( $download_url ) ) {
345 481 $responses[] = $download_url;
346 482 continue;
347 483 }
@@ -367,12 +503,12 @@
367 503 * @param \WP_REST_Request $request Full details about the request.
368 504 * @return array|\WP_Error|mixed
369 505 */
370 506 public function get_connection_details( \WP_REST_Request $request ) {
371 - $service = rawurlencode( $request->get_param( 'service' ) );
372 - $wpcom_path = sprintf( '/meta/external-media/connection/%s', $service );
507 + $service = $request->get_param( 'service' );
373 508
374 509 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
510 + $wpcom_path = sprintf( '/meta/external-media/connection/%s', rawurlencode( $service ) );
375 511 $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
376 512 $internal_request->set_query_params( $request->get_params() );
377 513
378 514 return rest_do_request( $internal_request );
@@ -377,11 +513,29 @@
377 513
378 514 return rest_do_request( $internal_request );
379 515 }
380 516
381 - $response = Client::wpcom_json_api_request_as_user( $wpcom_path );
517 + $site_id = Manager::get_site_id();
518 + if ( is_wp_error( $site_id ) ) {
519 + return $site_id;
520 + }
382 521
383 - return json_decode( wp_remote_retrieve_body( $response ), true );
522 + $path = sprintf( '/sites/%d/external-services', $site_id );
523 + $response = Client::wpcom_json_api_request_as_user( $path );
524 + if ( is_wp_error( $response ) ) {
525 + return $response;
526 + }
527 +
528 + $body = json_decode( wp_remote_retrieve_body( $response ) );
529 + if ( ! property_exists( $body, 'services' ) || ! property_exists( $body->services, $service ) ) {
530 + return new WP_Error(
531 + 'bad_request',
532 + __( 'An error occurred. Please try again later.', 'jetpack' ),
533 + array( 'status' => 400 )
534 + );
535 + }
536 +
537 + return $body->services->{ $service };
384 538 }
385 539
386 540 /**
387 541 * Deletes a Google Photos connection.
@@ -411,36 +565,340 @@
411 565 return json_decode( wp_remote_retrieve_body( $response ), true );
412 566 }
413 567
414 568 /**
415 - * Filter callback to provide a shorter file name for google images.
569 + * Gets Google Photos Picker enabled Status.
416 570 *
417 - * @return string
571 + * @param \WP_REST_Request $request Full details about the request.
572 + * @return array|\WP_Error|mixed
418 573 */
419 - public function tmp_name() {
420 - return $this->tmp_name;
574 + public function get_picker_status( \WP_REST_Request $request ) {
575 + $service = $request->get_param( 'service' );
576 + $wpcom_path = sprintf( '/meta/external-media/connection/%s/picker_status', rawurlencode( $service ) );
577 +
578 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
579 + $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
580 + $internal_request->set_query_params( $request->get_params() );
581 +
582 + return rest_do_request( $internal_request );
583 + }
584 +
585 + $response = Client::wpcom_json_api_request_as_user(
586 + $wpcom_path,
587 + '2',
588 + array(
589 + 'method' => 'GET',
590 + )
591 + );
592 +
593 + return json_decode( wp_remote_retrieve_body( $response ), true );
421 594 }
422 595
423 596 /**
424 - * Returns a download URL, dealing with Google's long file names.
597 + * Creates a new session for a service.
425 598 *
599 + * @param \WP_REST_Request $request Full details about the request.
600 + * @return array|\WP_Error|mixed
601 + */
602 + public function create_session( \WP_REST_Request $request ) {
603 + $service = $request->get_param( 'service' );
604 + $wpcom_path = sprintf( '/meta/external-media/session/%s', rawurlencode( $service ) );
605 +
606 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
607 + $internal_request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
608 + $internal_request->set_query_params( $request->get_params() );
609 +
610 + return rest_do_request( $internal_request );
611 + }
612 +
613 + $response = Client::wpcom_json_api_request_as_user(
614 + $wpcom_path,
615 + '2',
616 + array(
617 + 'method' => 'POST',
618 + )
619 + );
620 +
621 + return json_decode( wp_remote_retrieve_body( $response ), true );
622 + }
623 +
624 + /**
625 + * Gets a session for a service.
626 + *
627 + * @param \WP_REST_Request $request Full details about the request.
628 + * @return array|\WP_Error|mixed
629 + */
630 + public function get_session( \WP_REST_Request $request ) {
631 + $service = $request->get_param( 'service' );
632 + $session_id = $request->get_param( 'session_id' );
633 + $wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
634 +
635 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
636 + $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
637 + $internal_request->set_query_params( $request->get_params() );
638 +
639 + return rest_do_request( $internal_request );
640 + }
641 +
642 + $response = Client::wpcom_json_api_request_as_user(
643 + $wpcom_path,
644 + '2',
645 + array(
646 + 'method' => 'GET',
647 + )
648 + );
649 +
650 + return json_decode( wp_remote_retrieve_body( $response ), true );
651 + }
652 +
653 + /**
654 + * Deletes a session for a service.
655 + *
656 + * @param \WP_REST_Request $request Full details about the request.
657 + * @return array|\WP_Error|mixed
658 + */
659 + public function delete_session( \WP_REST_Request $request ) {
660 + $service = $request->get_param( 'service' );
661 + $session_id = $request->get_param( 'session_id' );
662 + $wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
663 +
664 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
665 + $internal_request = new \WP_REST_Request( 'DELETE', '/' . $this->namespace . $wpcom_path );
666 + $internal_request->set_query_params( $request->get_params() );
667 +
668 + return rest_do_request( $internal_request );
669 + }
670 +
671 + $response = Client::wpcom_json_api_request_as_user(
672 + $wpcom_path,
673 + '2',
674 + array(
675 + 'method' => 'DELETE',
676 + )
677 + );
678 +
679 + return json_decode( wp_remote_retrieve_body( $response ), true );
680 + }
681 +
682 + /**
683 + * Proxies media requests with proper authorization headers
684 + *
685 + * @param WP_REST_Request $request Full details about the request.
686 + * @return WP_REST_Response|WP_Error|array Response object or WP_Error.
687 + */
688 + public function proxy_media_request( $request ) {
689 + $params = $request->get_params();
690 + $service = rawurlencode( $request->get_param( 'service' ) );
691 + $wpcom_path = sprintf( '/meta/external-media/proxy/%s', $service );
692 +
693 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
694 + $request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
695 + $request->set_query_params( $params );
696 +
697 + return rest_do_request( $request );
698 +
699 + } else {
700 + // Build query string to pass to wpcom endpoint.
701 + $service_args = array_filter(
702 + $params,
703 + function ( $key ) {
704 + return in_array( $key, array( 'url' ), true );
705 + },
706 + ARRAY_FILTER_USE_KEY
707 + );
708 +
709 + if ( ! empty( $service_args ) ) {
710 + $wpcom_path .= '?' . http_build_query( $service_args );
711 + }
712 +
713 + $response = Client::wpcom_json_api_request_as_user(
714 + $wpcom_path,
715 + '2',
716 + array(
717 + 'method' => 'POST',
718 + )
719 + );
720 +
721 + $status_code = wp_remote_retrieve_response_code( $response );
722 + $headers = wp_remote_retrieve_headers( $response );
723 + $body = wp_remote_retrieve_body( $response );
724 +
725 + // For non-200 responses, parse and return JSON error
726 + if ( $status_code !== 200 ) {
727 + $error_data = json_decode( $body, true );
728 + return new \WP_REST_Response( $error_data, $status_code );
729 + }
730 + }
731 +
732 + // Return binary content directly
733 + $valid_headers = array(
734 + 'content-type',
735 + 'content-length',
736 + 'content-disposition',
737 + );
738 + // Set content headers
739 + foreach ( $valid_headers as $header ) {
740 + if ( ! empty( $headers[ $header ] ) ) {
741 + header( ucwords( $header, '-' ) . ': ' . $headers[ $header ] );
742 + }
743 + }
744 +
745 + // Set cache headers
746 + header( 'Cache-Control: no-cache, no-store, must-revalidate' );
747 + header( 'Pragma: no-cache' );
748 + header( 'Expires: 0' );
749 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Media binary data
750 + echo $body;
751 + exit( 0 );
752 + }
753 +
754 + /**
755 + * Downloads a remote media file into a temporary file for sideloading.
756 + *
757 + * The URL is checked against Utils::url_is_public() before the fetch and again
758 + * on every redirect hop, the same rule the resolve-redirect endpoint applies.
759 + *
760 + * The remote file is streamed into a randomly-named temporary file created by
761 + * wp_tempnam(). The caller-supplied name is never used for the temporary file
762 + * itself; it is only applied — and validated by WordPress — later, when the
763 + * completed download is handed to media_handle_sideload(). This prevents a
764 + * crafted name from controlling the physical path or extension of the file
765 + * written to disk.
766 + *
426 767 * @param array $guid Media information.
427 - * @return string|\WP_Error
768 + * @return string|\WP_Error Path to the downloaded temporary file, or WP_Error on failure.
428 769 */
429 770 public function get_download_url( $guid ) {
430 - $this->tmp_name = $guid['name'];
431 - add_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) );
432 - $download_url = download_url( $guid['url'] );
433 - remove_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) );
771 + require_once ABSPATH . 'wp-admin/includes/file.php';
434 772
435 - if ( is_wp_error( $download_url ) ) {
436 - $download_url->add_data( array( 'status' => 400 ) );
773 + $url = isset( $guid['url'] ) && is_string( $guid['url'] ) ? $guid['url'] : '';
774 +
775 + if ( ! $this->url_is_public( $url ) ) {
776 + return $this->download_failed_error();
437 777 }
438 778
439 - return $download_url;
779 + $tmp_name = wp_tempnam();
780 + if ( ! $tmp_name ) {
781 + return new WP_Error(
782 + 'rest_upload_error',
783 + __( 'Could not create a temporary file.', 'jetpack' ),
784 + array( 'status' => 500 )
785 + );
786 + }
787 +
788 + $result = $this->stream_to_temp_file( $url, $tmp_name );
789 +
790 + if ( is_wp_error( $result ) ) {
791 + wp_delete_file( $tmp_name );
792 + }
793 +
794 + return $result;
440 795 }
441 796
442 797 /**
798 + * Streams an already-validated URL into a temporary file, following redirects.
799 + *
800 + * Each hop is fetched with `redirection => 0` and re-checked with
801 + * Utils::url_is_public() before the next request. The caller owns $tmp_name and
802 + * deletes it when this returns an error.
803 + *
804 + * @param string $url Validated URL to download.
805 + * @param string $tmp_name Path of the temporary file to stream into.
806 + * @return string|\WP_Error $tmp_name on success, WP_Error on failure.
807 + */
808 + private function stream_to_temp_file( $url, $tmp_name ) {
809 + // One budget for the whole chain: WordPress used to apply the timeout across
810 + // the redirects it followed itself, and following them here must not multiply
811 + // how long a single import can hold a request open.
812 + $deadline = microtime( true ) + self::DOWNLOAD_TIMEOUT;
813 +
814 + for ( $hop = 0; $hop <= self::MAX_REDIRECTS; $hop++ ) {
815 + $remaining = (int) ceil( $deadline - microtime( true ) );
816 + if ( $remaining < 1 ) {
817 + return $this->download_failed_error();
818 + }
819 +
820 + $response = wp_safe_remote_get(
821 + $url,
822 + array(
823 + 'timeout' => $remaining,
824 + 'stream' => true,
825 + 'filename' => $tmp_name,
826 + // Do not let WordPress follow redirects for us; we validate each hop first.
827 + 'redirection' => 0,
828 + )
829 + );
830 +
831 + if ( is_wp_error( $response ) ) {
832 + return $this->download_failed_error();
833 + }
834 +
835 + $status = (int) wp_remote_retrieve_response_code( $response );
836 +
837 + if ( $status < 300 || $status >= 400 ) {
838 + return 200 === $status ? $tmp_name : $this->download_failed_error();
839 + }
840 +
841 + // Budget exhausted: stop before validating a destination we will never fetch.
842 + if ( self::MAX_REDIRECTS === $hop ) {
843 + break;
844 + }
845 +
846 + $location = wp_remote_retrieve_header( $response, 'location' );
847 +
848 + // Multiple Location headers: follow the last, as core does.
849 + if ( is_array( $location ) ) {
850 + $location = end( $location );
851 + }
852 +
853 + // Location may be relative; resolve it against the current URL.
854 + $next_url = is_string( $location ) && '' !== $location
855 + ? WP_Http::make_absolute_url( $location, $url )
856 + : '';
857 +
858 + if ( ! is_string( $next_url ) || ! $this->url_is_public( $next_url ) ) {
859 + return $this->download_failed_error();
860 + }
861 +
862 + $url = $next_url;
863 + }
864 +
865 + return $this->download_failed_error();
866 + }
867 +
868 + /**
869 + * Checks whether a URL is a public destination for a media download.
870 + *
871 + * An older jetpack-ip without url_is_public() may win the autoloader; that case
872 + * falls back to core's check, the same one wp_safe_remote_get() applies.
873 + *
874 + * @param string $url URL to check.
875 + * @return bool
876 + */
877 + private function url_is_public( $url ) {
878 + if ( method_exists( Utils::class, 'url_is_public' ) ) {
879 + return Utils::url_is_public( $url );
880 + }
881 +
882 + return (bool) wp_http_validate_url( $url );
883 + }
884 +
885 + /**
886 + * Builds the WP_Error returned when a media file cannot be downloaded.
887 + *
888 + * Every failed download shares this one generic error.
889 + *
890 + * @return WP_Error
891 + */
892 + private function download_failed_error() {
893 + return new WP_Error(
894 + 'rest_upload_error',
895 + __( 'Could not download the media file.', 'jetpack' ),
896 + array( 'status' => 400 )
897 + );
898 + }
899 +
900 + /**
443 901 * Uploads media file and creates attachment object.
444 902 *
445 903 * @param string $file_name Name of media file.
446 904 * @param string $download_url Download URL.
@@ -449,9 +907,9 @@
449 907 * @return int|\WP_Error
450 908 */
451 909 public function sideload_media( $file_name, $download_url, $post_id = 0 ) {
452 910 $file = array(
453 - 'name' => wp_basename( $file_name ),
911 + 'name' => sanitize_file_name( wp_basename( $file_name ) ),
454 912 'tmp_name' => $download_url,
455 913 );
456 914
457 915 $id = media_handle_sideload( $file, $post_id, null );
@@ -518,8 +976,24 @@
518 976 );
519 977 }
520 978
521 979 return $response;
980 + }
981 +
982 + /**
983 + * Get the wp filesystem.
984 + *
985 + * @return \WP_Filesystem_Base|null
986 + */
987 + private function get_wp_filesystem() {
988 + global $wp_filesystem;
989 +
990 + if ( ! isset( $wp_filesystem ) ) {
991 + require_once ABSPATH . '/wp-admin/includes/file.php';
992 + WP_Filesystem();
993 + }
994 +
995 + return $wp_filesystem;
522 996 }
523 997 }
524 998
525 999 wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_External_Media' );