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 +372 -29 13.7.2 → 16.3-a.7 View file →
@@ -8,8 +8,12 @@
8 8
9 9 use Automattic\Jetpack\Connection\Client;
10 10 use Automattic\Jetpack\Connection\Manager;
11 11
12 +if ( ! defined( 'ABSPATH' ) ) {
13 + exit( 0 );
14 +}
15 +
12 16 /**
13 17 * External Media helper API.
14 18 *
15 19 * @since 8.7.0
@@ -75,17 +79,8 @@
75 79 */
76 80 private static $services_regex = '(?P<service>google_photos|openverse|pexels)';
77 81
78 82 /**
79 - * Temporary filename.
80 - *
81 - * Needed to cope with Google's very long file names.
82 - *
83 - * @var string
84 - */
85 - private $tmp_name;
86 -
87 - /**
88 83 * Constructor.
89 84 */
90 85 public function __construct() {
91 86 $this->namespace = 'wpcom/v2';
@@ -120,8 +115,12 @@
120 115 ),
121 116 'page_handle' => array(
122 117 'type' => 'string',
123 118 ),
119 + 'session_id' => array(
120 + 'description' => __( 'Session id of a service, currently only Google Photos Picker', 'jetpack' ),
121 + 'type' => 'string',
122 + ),
124 123 ),
125 124 )
126 125 );
127 126
@@ -132,9 +131,9 @@
132 131 'methods' => \WP_REST_Server::CREATABLE,
133 132 'callback' => array( $this, 'copy_external_media' ),
134 133 'permission_callback' => array( $this, 'create_item_permissions_check' ),
135 134 'args' => array(
136 - 'media' => array(
135 + 'media' => array(
137 136 'description' => __( 'Media data to copy.', 'jetpack' ),
138 137 'items' => $this->media_schema,
139 138 'required' => true,
140 139 'type' => 'array',
@@ -140,13 +139,18 @@
140 139 'type' => 'array',
141 140 'sanitize_callback' => array( $this, 'sanitize_media' ),
142 141 'validate_callback' => array( $this, 'validate_media' ),
143 142 ),
144 - 'post_id' => array(
143 + 'post_id' => array(
145 144 'description' => __( 'The post ID to attach the upload to.', 'jetpack' ),
146 145 'type' => 'number',
147 146 'minimum' => 0,
148 147 ),
148 + 'should_proxy' => array(
149 + 'description' => __( 'Whether to proxy the media request.', 'jetpack' ),
150 + 'type' => 'boolean',
151 + 'default' => false,
152 + ),
149 153 ),
150 154 )
151 155 );
152 156
@@ -168,8 +172,68 @@
168 172 'callback' => array( $this, 'delete_connection' ),
169 173 'permission_callback' => array( $this, 'permission_callback' ),
170 174 )
171 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 + );
172 236 }
173 237
174 238 /**
175 239 * Checks if a given request has access to external media libraries.
@@ -210,8 +274,21 @@
210 274 array( 'status' => 400 )
211 275 );
212 276 }
213 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 +
214 291 return true;
215 292 }
216 293
217 294 /**
@@ -281,9 +358,9 @@
281 358 // Build query string to pass to wpcom endpoint.
282 359 $service_args = array_filter(
283 360 $params,
284 361 function ( $key ) {
285 - 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 );
286 363 },
287 364 ARRAY_FILTER_USE_KEY
288 365 );
289 366 if ( ! empty( $service_args ) ) {
@@ -329,20 +406,60 @@
329 406 * Saves an external media item to the media library.
330 407 *
331 408 * @param \WP_REST_Request $request Full details about the request.
332 409 * @return array|\WP_Error|mixed
333 - */
410 + **/
334 411 public function copy_external_media( \WP_REST_Request $request ) {
335 412 require_once ABSPATH . 'wp-admin/includes/file.php';
336 413 require_once ABSPATH . 'wp-admin/includes/media.php';
337 414 require_once ABSPATH . 'wp-admin/includes/image.php';
338 415
339 - $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' ) );
340 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 +
341 427 $responses = array();
428 +
342 429 foreach ( $request->get_param( 'media' ) as $item ) {
343 430 // Download file to temp dir.
344 - $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 +
345 462 if ( is_wp_error( $download_url ) ) {
346 463 $responses[] = $download_url;
347 464 continue;
348 465 }
@@ -430,33 +547,243 @@
430 547 return json_decode( wp_remote_retrieve_body( $response ), true );
431 548 }
432 549
433 550 /**
434 - * Filter callback to provide a shorter file name for google images.
551 + * Gets Google Photos Picker enabled Status.
435 552 *
436 - * @return string
553 + * @param \WP_REST_Request $request Full details about the request.
554 + * @return array|\WP_Error|mixed
437 555 */
438 - public function tmp_name() {
439 - 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 );
440 576 }
441 577
442 578 /**
443 - * Returns a download URL, dealing with Google's long file names.
579 + * Creates a new session for a service.
444 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 + *
445 746 * @param array $guid Media information.
446 - * @return string|\WP_Error
747 + * @return string|\WP_Error Path to the downloaded temporary file, or WP_Error on failure.
447 748 */
448 749 public function get_download_url( $guid ) {
449 - $this->tmp_name = $guid['name'];
450 - add_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) );
451 - $download_url = download_url( $guid['url'] );
452 - remove_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) );
750 + require_once ABSPATH . 'wp-admin/includes/file.php';
453 751
454 - if ( is_wp_error( $download_url ) ) {
455 - $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 + );
456 759 }
457 760
458 - 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;
459 786 }
460 787
461 788 /**
462 789 * Uploads media file and creates attachment object.
@@ -468,9 +795,9 @@
468 795 * @return int|\WP_Error
469 796 */
470 797 public function sideload_media( $file_name, $download_url, $post_id = 0 ) {
471 798 $file = array(
472 - 'name' => wp_basename( $file_name ),
799 + 'name' => sanitize_file_name( wp_basename( $file_name ) ),
473 800 'tmp_name' => $download_url,
474 801 );
475 802
476 803 $id = media_handle_sideload( $file, $post_id, null );
@@ -537,8 +864,24 @@
537 864 );
538 865 }
539 866
540 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;
541 884 }
542 885 }
543 886
544 887 wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_External_Media' );