PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
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 13.7.2 13.8.3 All 506 releases
← All changes | _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-external-media.php +395 -33 13.0.2 → 16.3-a.7 View file →
@@ -6,9 +6,14 @@
6 6 * @since 8.7.0
7 7 */
8 8
9 9 use Automattic\Jetpack\Connection\Client;
10 +use Automattic\Jetpack\Connection\Manager;
10 11
12 +if ( ! defined( 'ABSPATH' ) ) {
13 + exit( 0 );
14 +}
15 +
11 16 /**
12 17 * External Media helper API.
13 18 *
14 19 * @since 8.7.0
@@ -74,17 +79,8 @@
74 79 */
75 80 private static $services_regex = '(?P<service>google_photos|openverse|pexels)';
76 81
77 82 /**
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 83 * Constructor.
88 84 */
89 85 public function __construct() {
90 86 $this->namespace = 'wpcom/v2';
@@ -119,8 +115,12 @@
119 115 ),
120 116 'page_handle' => array(
121 117 'type' => 'string',
122 118 ),
119 + 'session_id' => array(
120 + 'description' => __( 'Session id of a service, currently only Google Photos Picker', 'jetpack' ),
121 + 'type' => 'string',
122 + ),
123 123 ),
124 124 )
125 125 );
126 126
@@ -131,9 +131,9 @@
131 131 'methods' => \WP_REST_Server::CREATABLE,
132 132 'callback' => array( $this, 'copy_external_media' ),
133 133 'permission_callback' => array( $this, 'create_item_permissions_check' ),
134 134 'args' => array(
135 - 'media' => array(
135 + 'media' => array(
136 136 'description' => __( 'Media data to copy.', 'jetpack' ),
137 137 'items' => $this->media_schema,
138 138 'required' => true,
139 139 'type' => 'array',
@@ -139,13 +139,18 @@
139 139 'type' => 'array',
140 140 'sanitize_callback' => array( $this, 'sanitize_media' ),
141 141 'validate_callback' => array( $this, 'validate_media' ),
142 142 ),
143 - 'post_id' => array(
143 + 'post_id' => array(
144 144 'description' => __( 'The post ID to attach the upload to.', 'jetpack' ),
145 145 'type' => 'number',
146 146 'minimum' => 0,
147 147 ),
148 + 'should_proxy' => array(
149 + 'description' => __( 'Whether to proxy the media request.', 'jetpack' ),
150 + 'type' => 'boolean',
151 + 'default' => false,
152 + ),
148 153 ),
149 154 )
150 155 );
151 156
@@ -167,8 +172,68 @@
167 172 'callback' => array( $this, 'delete_connection' ),
168 173 'permission_callback' => array( $this, 'permission_callback' ),
169 174 )
170 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 + );
171 236 }
172 237
173 238 /**
174 239 * Checks if a given request has access to external media libraries.
@@ -209,8 +274,21 @@
209 274 array( 'status' => 400 )
210 275 );
211 276 }
212 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 +
213 291 return true;
214 292 }
215 293
216 294 /**
@@ -280,9 +358,9 @@
280 358 // Build query string to pass to wpcom endpoint.
281 359 $service_args = array_filter(
282 360 $params,
283 361 function ( $key ) {
284 - return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter' ), true );
362 + return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter', 'session_id' ), true );
285 363 },
286 364 ARRAY_FILTER_USE_KEY
287 365 );
288 366 if ( ! empty( $service_args ) ) {
@@ -328,20 +406,60 @@
328 406 * Saves an external media item to the media library.
329 407 *
330 408 * @param \WP_REST_Request $request Full details about the request.
331 409 * @return array|\WP_Error|mixed
332 - */
410 + **/
333 411 public function copy_external_media( \WP_REST_Request $request ) {
334 412 require_once ABSPATH . 'wp-admin/includes/file.php';
335 413 require_once ABSPATH . 'wp-admin/includes/media.php';
336 414 require_once ABSPATH . 'wp-admin/includes/image.php';
337 415
338 - $post_id = $request->get_param( 'post_id' );
416 + $post_id = (int) $request->get_param( 'post_id' );
417 + $should_proxy = $request->get_param( 'should_proxy' );
418 + $service = rawurlencode( $request->get_param( 'service' ) );
339 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 +
340 427 $responses = array();
428 +
341 429 foreach ( $request->get_param( 'media' ) as $item ) {
342 430 // Download file to temp dir.
343 - $download_url = $this->get_download_url( $item['guid'] );
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 +
344 462 if ( is_wp_error( $download_url ) ) {
345 463 $responses[] = $download_url;
346 464 continue;
347 465 }
@@ -367,12 +485,12 @@
367 485 * @param \WP_REST_Request $request Full details about the request.
368 486 * @return array|\WP_Error|mixed
369 487 */
370 488 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 );
489 + $service = $request->get_param( 'service' );
373 490
374 491 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
492 + $wpcom_path = sprintf( '/meta/external-media/connection/%s', rawurlencode( $service ) );
375 493 $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
376 494 $internal_request->set_query_params( $request->get_params() );
377 495
378 496 return rest_do_request( $internal_request );
@@ -377,11 +495,29 @@
377 495
378 496 return rest_do_request( $internal_request );
379 497 }
380 498
381 - $response = Client::wpcom_json_api_request_as_user( $wpcom_path );
499 + $site_id = Manager::get_site_id();
500 + if ( is_wp_error( $site_id ) ) {
501 + return $site_id;
502 + }
382 503
383 - return json_decode( wp_remote_retrieve_body( $response ), true );
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 };
384 520 }
385 521
386 522 /**
387 523 * Deletes a Google Photos connection.
@@ -411,33 +547,243 @@
411 547 return json_decode( wp_remote_retrieve_body( $response ), true );
412 548 }
413 549
414 550 /**
415 - * Filter callback to provide a shorter file name for google images.
551 + * Gets Google Photos Picker enabled Status.
416 552 *
417 - * @return string
553 + * @param \WP_REST_Request $request Full details about the request.
554 + * @return array|\WP_Error|mixed
418 555 */
419 - public function tmp_name() {
420 - return $this->tmp_name;
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 );
421 576 }
422 577
423 578 /**
424 - * Returns a download URL, dealing with Google's long file names.
579 + * Creates a new session for a service.
425 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 + *
426 746 * @param array $guid Media information.
427 - * @return string|\WP_Error
747 + * @return string|\WP_Error Path to the downloaded temporary file, or WP_Error on failure.
428 748 */
429 749 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' ) );
750 + require_once ABSPATH . 'wp-admin/includes/file.php';
434 751
435 - if ( is_wp_error( $download_url ) ) {
436 - $download_url->add_data( array( 'status' => 400 ) );
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 + );
437 759 }
438 760
439 - return $download_url;
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;
440 786 }
441 787
442 788 /**
443 789 * Uploads media file and creates attachment object.
@@ -449,9 +795,9 @@
449 795 * @return int|\WP_Error
450 796 */
451 797 public function sideload_media( $file_name, $download_url, $post_id = 0 ) {
452 798 $file = array(
453 - 'name' => wp_basename( $file_name ),
799 + 'name' => sanitize_file_name( wp_basename( $file_name ) ),
454 800 'tmp_name' => $download_url,
455 801 );
456 802
457 803 $id = media_handle_sideload( $file, $post_id, null );
@@ -518,8 +864,24 @@
518 864 );
519 865 }
520 866
521 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;
522 884 }
523 885 }
524 886
525 887 wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_External_Media' );