← All changes
|
_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-external-media.php
+508
-34
12.4.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' ); |