← All changes
|
_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-external-media.php
+130
-18
16.3-a.5
→
16.3
View file →
| @@ -7,8 +7,9 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | use Automattic\Jetpack\Connection\Client; |
| 10 | 10 | use Automattic\Jetpack\Connection\Manager; |
| 11 | +use Automattic\Jetpack\IP\Utils; | |
| 11 | 12 | |
| 12 | 13 | if ( ! defined( 'ABSPATH' ) ) { |
| 13 | 14 | exit( 0 ); |
| 14 | 15 | } |
| @@ -20,8 +21,25 @@ | ||
| 20 | 21 | */ |
| 21 | 22 | class WPCOM_REST_API_V2_Endpoint_External_Media extends WP_REST_Controller { |
| 22 | 23 | |
| 23 | 24 | /** |
| 25 | + * Maximum number of redirect hops to follow when downloading a media file. | |
| 26 | + * | |
| 27 | + * Matches WordPress's default `redirection` limit, so media URLs that redirect | |
| 28 | + * to a CDN keep resolving exactly as before. | |
| 29 | + * | |
| 30 | + * @var int | |
| 31 | + */ | |
| 32 | + const MAX_REDIRECTS = 5; | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * Seconds a media download may take, redirects included. | |
| 36 | + * | |
| 37 | + * @var int | |
| 38 | + */ | |
| 39 | + const DOWNLOAD_TIMEOUT = 300; | |
| 40 | + | |
| 41 | + /** | |
| 24 | 42 | * Media argument schema for /copy endpoint. |
| 25 | 43 | * |
| 26 | 44 | * @var array |
| 27 | 45 | */ |
| @@ -735,8 +753,11 @@ | ||
| 735 | 753 | |
| 736 | 754 | /** |
| 737 | 755 | * Downloads a remote media file into a temporary file for sideloading. |
| 738 | 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 | + * | |
| 739 | 760 | * The remote file is streamed into a randomly-named temporary file created by |
| 740 | 761 | * wp_tempnam(). The caller-supplied name is never used for the temporary file |
| 741 | 762 | * itself; it is only applied — and validated by WordPress — later, when the |
| 742 | 763 | * completed download is handed to media_handle_sideload(). This prevents a |
| @@ -748,8 +769,14 @@ | ||
| 748 | 769 | */ |
| 749 | 770 | public function get_download_url( $guid ) { |
| 750 | 771 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 751 | 772 | |
| 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(); | |
| 777 | + } | |
| 778 | + | |
| 752 | 779 | $tmp_name = wp_tempnam(); |
| 753 | 780 | if ( ! $tmp_name ) { |
| 754 | 781 | return new WP_Error( |
| 755 | 782 | 'rest_upload_error', |
| @@ -757,33 +784,118 @@ | ||
| 757 | 784 | array( 'status' => 500 ) |
| 758 | 785 | ); |
| 759 | 786 | } |
| 760 | 787 | |
| 761 | - $response = wp_safe_remote_get( | |
| 762 | - $guid['url'], | |
| 763 | - array( | |
| 764 | - 'timeout' => 300, | |
| 765 | - 'stream' => true, | |
| 766 | - 'filename' => $tmp_name, | |
| 767 | - ) | |
| 768 | - ); | |
| 788 | + $result = $this->stream_to_temp_file( $url, $tmp_name ); | |
| 769 | 789 | |
| 770 | - if ( is_wp_error( $response ) ) { | |
| 790 | + if ( is_wp_error( $result ) ) { | |
| 771 | 791 | wp_delete_file( $tmp_name ); |
| 772 | - $response->add_data( array( 'status' => 400 ) ); | |
| 773 | - return $response; | |
| 774 | 792 | } |
| 775 | 793 | |
| 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 ) | |
| 794 | + return $result; | |
| 795 | + } | |
| 796 | + | |
| 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 | + ) | |
| 782 | 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; | |
| 783 | 863 | } |
| 784 | 864 | |
| 785 | - return $tmp_name; | |
| 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 | + ); | |
| 786 | 898 | } |
| 787 | 899 | |
| 788 | 900 | /** |
| 789 | 901 | * Uploads media file and creates attachment object. |