874 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			874 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
<?php
 | 
						|
/**
 | 
						|
 * Class Hotlinking_Controller
 | 
						|
 *
 | 
						|
 * @link      https://github.com/googleforcreators/web-stories-wp
 | 
						|
 *
 | 
						|
 * @copyright 2021 Google LLC
 | 
						|
 * @license   https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
 | 
						|
 */
 | 
						|
 | 
						|
/**
 | 
						|
 * Copyright 2021 Google LLC
 | 
						|
 *
 | 
						|
 * Licensed under the Apache License, Version 2.0 (the "License");
 | 
						|
 * you may not use this file except in compliance with the License.
 | 
						|
 * You may obtain a copy of the License at
 | 
						|
 *
 | 
						|
 *     https://www.apache.org/licenses/LICENSE-2.0
 | 
						|
 *
 | 
						|
 * Unless required by applicable law or agreed to in writing, software
 | 
						|
 * distributed under the License is distributed on an "AS IS" BASIS,
 | 
						|
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
						|
 * See the License for the specific language governing permissions and
 | 
						|
 * limitations under the License.
 | 
						|
 */
 | 
						|
 | 
						|
declare(strict_types = 1);
 | 
						|
 | 
						|
namespace Google\Web_Stories\REST_API;
 | 
						|
 | 
						|
use Google\Web_Stories\Infrastructure\HasRequirements;
 | 
						|
use Google\Web_Stories\Media\Types;
 | 
						|
use Google\Web_Stories\Story_Post_Type;
 | 
						|
use WP_Error;
 | 
						|
use WP_Http;
 | 
						|
use WP_REST_Request;
 | 
						|
use WP_REST_Response;
 | 
						|
use WP_REST_Server;
 | 
						|
 | 
						|
/**
 | 
						|
 * Hotlinking_Controller class.
 | 
						|
 *
 | 
						|
 * API endpoint for pinging and hotlinking media URLs.
 | 
						|
 *
 | 
						|
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
 | 
						|
 *
 | 
						|
 * @phpstan-type LinkData array{
 | 
						|
 *   ext?: string,
 | 
						|
 *   file_name?: string,
 | 
						|
 *   file_size?: int,
 | 
						|
 *   mime_type?: string,
 | 
						|
 *   type?: string
 | 
						|
 * }
 | 
						|
 *
 | 
						|
 * @phpstan-type SchemaEntry array{
 | 
						|
 *   description: string,
 | 
						|
 *   type: string,
 | 
						|
 *   context: string[],
 | 
						|
 *   default?: mixed,
 | 
						|
 *   enum?: string[]
 | 
						|
 * }
 | 
						|
 *
 | 
						|
 * @phpstan-type Schema array{
 | 
						|
 *   properties: array{
 | 
						|
 *     ext?: SchemaEntry,
 | 
						|
 *     file_name?: SchemaEntry,
 | 
						|
 *     file_size?: SchemaEntry,
 | 
						|
 *     mime_type?: SchemaEntry,
 | 
						|
 *     type?: SchemaEntry
 | 
						|
 *   }
 | 
						|
 * }
 | 
						|
 * @phpstan-type URLParts array{
 | 
						|
 *   scheme?: string,
 | 
						|
 *   user?: string,
 | 
						|
 *   pass?: string,
 | 
						|
 *   host?: string,
 | 
						|
 *   port?: int,
 | 
						|
 *   path?: string,
 | 
						|
 *   query?: string
 | 
						|
 * }
 | 
						|
 */
 | 
						|
class Hotlinking_Controller extends REST_Controller implements HasRequirements {
 | 
						|
	public const PROXY_HEADERS_ALLOWLIST = [
 | 
						|
		'Content-Type',
 | 
						|
		'Cache-Control',
 | 
						|
		'Etag',
 | 
						|
		'Last-Modified',
 | 
						|
		'Content-Range',
 | 
						|
	];
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Story_Post_Type instance.
 | 
						|
	 *
 | 
						|
	 * @var Story_Post_Type Story_Post_Type instance.
 | 
						|
	 */
 | 
						|
	private Story_Post_Type $story_post_type;
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Types instance.
 | 
						|
	 *
 | 
						|
	 * @var Types Types instance.
 | 
						|
	 */
 | 
						|
	private Types $types;
 | 
						|
 | 
						|
	/**
 | 
						|
	 * File pointer resource.
 | 
						|
	 *
 | 
						|
	 * @var resource
 | 
						|
	 */
 | 
						|
	protected $stream_handle;
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Constructor.
 | 
						|
	 *
 | 
						|
	 * @param Story_Post_Type $story_post_type Story_Post_Type instance.
 | 
						|
	 * @param Types           $types Types instance.
 | 
						|
	 * @return void
 | 
						|
	 */
 | 
						|
	public function __construct( Story_Post_Type $story_post_type, Types $types ) {
 | 
						|
		$this->story_post_type = $story_post_type;
 | 
						|
		$this->types           = $types;
 | 
						|
 | 
						|
		$this->namespace = 'web-stories/v1';
 | 
						|
		$this->rest_base = 'hotlink';
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Get the list of service IDs required for this service to be registered.
 | 
						|
	 *
 | 
						|
	 * Needed because the story post type needs to be registered first.
 | 
						|
	 *
 | 
						|
	 * @since 1.13.0
 | 
						|
	 *
 | 
						|
	 * @return string[] List of required services.
 | 
						|
	 */
 | 
						|
	public static function get_requirements(): array {
 | 
						|
		return [ 'story_post_type' ];
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Registers routes for urls.
 | 
						|
	 *
 | 
						|
	 * @since 1.11.0
 | 
						|
	 *
 | 
						|
	 * @see register_rest_route()
 | 
						|
	 */
 | 
						|
	public function register_routes(): void {
 | 
						|
		register_rest_route(
 | 
						|
			$this->namespace,
 | 
						|
			'/' . $this->rest_base . '/validate',
 | 
						|
			[
 | 
						|
				[
 | 
						|
					'methods'             => WP_REST_Server::READABLE,
 | 
						|
					'callback'            => [ $this, 'parse_url' ],
 | 
						|
					'permission_callback' => [ $this, 'parse_url_permissions_check' ],
 | 
						|
					'args'                => [
 | 
						|
						'url' => [
 | 
						|
							'description'       => __( 'The URL to process.', 'web-stories' ),
 | 
						|
							'required'          => true,
 | 
						|
							'type'              => 'string',
 | 
						|
							'format'            => 'uri',
 | 
						|
							'validate_callback' => [ $this, 'validate_callback' ],
 | 
						|
							'sanitize_callback' => 'esc_url_raw',
 | 
						|
						],
 | 
						|
					],
 | 
						|
				],
 | 
						|
			]
 | 
						|
		);
 | 
						|
 | 
						|
		register_rest_route(
 | 
						|
			$this->namespace,
 | 
						|
			'/' . $this->rest_base . '/proxy',
 | 
						|
			[
 | 
						|
				[
 | 
						|
					'methods'             => WP_REST_Server::READABLE,
 | 
						|
					'callback'            => [ $this, 'proxy_url' ],
 | 
						|
					'permission_callback' => [ $this, 'parse_url_permissions_check' ],
 | 
						|
					'args'                => [
 | 
						|
						'url' => [
 | 
						|
							'description'       => __( 'The URL to process.', 'web-stories' ),
 | 
						|
							'required'          => true,
 | 
						|
							'type'              => 'string',
 | 
						|
							'format'            => 'uri',
 | 
						|
							'validate_callback' => [ $this, 'validate_callback' ],
 | 
						|
							'sanitize_callback' => 'esc_url_raw',
 | 
						|
						],
 | 
						|
					],
 | 
						|
				],
 | 
						|
			]
 | 
						|
		);
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Parses a URL to return some metadata for inserting external media.
 | 
						|
	 *
 | 
						|
	 * @SuppressWarnings(PHPMD.NPathComplexity)
 | 
						|
	 * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
 | 
						|
	 *
 | 
						|
	 * @since 1.11.0
 | 
						|
	 *
 | 
						|
	 * @param WP_REST_Request $request Full data about the request.
 | 
						|
	 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
 | 
						|
	 */
 | 
						|
	public function parse_url( WP_REST_Request $request ) {
 | 
						|
		/**
 | 
						|
		 * Requested URL.
 | 
						|
		 *
 | 
						|
		 * @var string $raw_url
 | 
						|
		 */
 | 
						|
		$raw_url = $request['url'];
 | 
						|
		$raw_url = untrailingslashit( $raw_url );
 | 
						|
 | 
						|
		$url_or_ip = $this->validate_url( $raw_url );
 | 
						|
 | 
						|
		$host = wp_parse_url( $raw_url, PHP_URL_HOST );
 | 
						|
 | 
						|
		if ( ! $url_or_ip || ! $host ) {
 | 
						|
			return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] );
 | 
						|
		}
 | 
						|
 | 
						|
		/**
 | 
						|
		 * Filters the hotlinking data TTL value.
 | 
						|
		 *
 | 
						|
		 * @since 1.11.0
 | 
						|
		 *
 | 
						|
		 * @param int    $time Time to live (in seconds). Default is 1 day.
 | 
						|
		 * @param string $url  The attempted URL.
 | 
						|
		 */
 | 
						|
		$cache_ttl = apply_filters( 'web_stories_hotlinking_url_data_cache_ttl', DAY_IN_SECONDS, $raw_url );
 | 
						|
		$cache_key = 'web_stories_url_data_' . md5( $raw_url );
 | 
						|
 | 
						|
		$data = get_transient( $cache_key );
 | 
						|
		if ( \is_string( $data ) && ! empty( $data ) ) {
 | 
						|
			/**
 | 
						|
			 * Decoded cached link data.
 | 
						|
			 *
 | 
						|
			 * @var array|null $link
 | 
						|
			 * @phpstan-var LinkData|null $link
 | 
						|
			 */
 | 
						|
			$link = json_decode( $data, true );
 | 
						|
 | 
						|
			if ( $link ) {
 | 
						|
				$response = $this->prepare_item_for_response( $link, $request );
 | 
						|
				return rest_ensure_response( $response );
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		$callback = $this->get_curl_resolve_callback( $raw_url, $url_or_ip );
 | 
						|
		add_action( 'http_api_curl', $callback );
 | 
						|
 | 
						|
		$response = wp_safe_remote_head(
 | 
						|
			$raw_url,
 | 
						|
			[
 | 
						|
				'redirection' => 0, // No redirects allowed.
 | 
						|
				'headers'     => [
 | 
						|
					'Host' => $host,
 | 
						|
				],
 | 
						|
			]
 | 
						|
		);
 | 
						|
 | 
						|
		remove_action( 'http_api_curl', $callback );
 | 
						|
 | 
						|
		if ( is_wp_error( $response ) && 'http_request_failed' === $response->get_error_code() ) {
 | 
						|
			return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] );
 | 
						|
		}
 | 
						|
 | 
						|
		if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) {
 | 
						|
			return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] );
 | 
						|
		}
 | 
						|
 | 
						|
		$headers   = wp_remote_retrieve_headers( $response );
 | 
						|
		$mime_type = $headers['content-type'];
 | 
						|
		if ( $mime_type && str_contains( $mime_type, ';' ) ) {
 | 
						|
			$pieces    = explode( ';', $mime_type );
 | 
						|
			$mime_type = array_shift( $pieces );
 | 
						|
		}
 | 
						|
		$file_size = (int) $headers['content-length'];
 | 
						|
 | 
						|
		$path = wp_parse_url( $raw_url, PHP_URL_PATH );
 | 
						|
 | 
						|
		if ( ! \is_string( $path ) ) {
 | 
						|
			return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] );
 | 
						|
		}
 | 
						|
 | 
						|
		$file_name = basename( $path );
 | 
						|
 | 
						|
		$exts = $this->types->get_file_type_exts( [ $mime_type ] );
 | 
						|
		$ext  = '';
 | 
						|
		if ( $exts ) {
 | 
						|
			$ext = end( $exts );
 | 
						|
		}
 | 
						|
 | 
						|
		$allowed_mime_types = $this->get_allowed_mime_types();
 | 
						|
		$type               = '';
 | 
						|
		foreach ( $allowed_mime_types as $key => $mime_types ) {
 | 
						|
			if ( \in_array( $mime_type, $mime_types, true ) ) {
 | 
						|
				$type = $key;
 | 
						|
				break;
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		$data = [
 | 
						|
			'ext'       => $ext,
 | 
						|
			'file_name' => $file_name,
 | 
						|
			'file_size' => $file_size,
 | 
						|
			'mime_type' => $mime_type,
 | 
						|
			'type'      => $type,
 | 
						|
		];
 | 
						|
 | 
						|
		set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl );
 | 
						|
 | 
						|
		$response = $this->prepare_item_for_response( $data, $request );
 | 
						|
 | 
						|
		return rest_ensure_response( $response );
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Parses a URL to return proxied file.
 | 
						|
	 *
 | 
						|
	 * @SuppressWarnings(PHPMD.ErrorControlOperator)
 | 
						|
	 *
 | 
						|
	 * @since 1.13.0
 | 
						|
	 *
 | 
						|
	 * @param WP_REST_Request $request Full data about the request.
 | 
						|
	 * @return WP_Error|void Proxied data on success, error otherwise.
 | 
						|
	 */
 | 
						|
	public function proxy_url( WP_REST_Request $request ) {
 | 
						|
		/**
 | 
						|
		 * Requested URL.
 | 
						|
		 *
 | 
						|
		 * @var string $raw_url
 | 
						|
		 */
 | 
						|
		$raw_url = $request['url'];
 | 
						|
		$raw_url = untrailingslashit( $raw_url );
 | 
						|
 | 
						|
		$url_or_ip = $this->validate_url( $raw_url );
 | 
						|
 | 
						|
		$host = wp_parse_url( $raw_url, PHP_URL_HOST );
 | 
						|
 | 
						|
		if ( ! $url_or_ip || ! $host ) {
 | 
						|
			return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] );
 | 
						|
		}
 | 
						|
 | 
						|
		// Remove any relevant headers already set by WP_REST_Server::serve_request() // wp_get_nocache_headers().
 | 
						|
		if ( ! headers_sent() ) {
 | 
						|
			header_remove( 'Cache-Control' );
 | 
						|
			header_remove( 'Content-Type' );
 | 
						|
			header_remove( 'Expires' );
 | 
						|
			header_remove( 'Last Modified' );
 | 
						|
		}
 | 
						|
 | 
						|
		header( 'Cache-Control: max-age=3600' );
 | 
						|
		header( 'Accept-Ranges: bytes' );
 | 
						|
 | 
						|
		$args = [
 | 
						|
			'timeout'     => 60, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
 | 
						|
			'blocking'    => false,
 | 
						|
			'headers'     => [
 | 
						|
				'Range' => $request->get_header( 'Range' ),
 | 
						|
				'Host'  => $host,
 | 
						|
			],
 | 
						|
			'redirection' => 0, // No redirects allowed.
 | 
						|
		];
 | 
						|
 | 
						|
		$callback = $this->get_curl_resolve_callback( $raw_url, $url_or_ip );
 | 
						|
		add_action( 'http_api_curl', $callback );
 | 
						|
 | 
						|
		$http      = _wp_http_get_object();
 | 
						|
		$transport = $http->_get_first_available_transport( $args, $raw_url );
 | 
						|
 | 
						|
		// When cURL is available, we might be able to use it together with fopen().
 | 
						|
		if ( 'WP_Http_Curl' === $transport ) {
 | 
						|
			// php://temp is a read-write streams that allows temporary data to be stored in a file-like wrapper.
 | 
						|
			// Other than php://memory, php://temp will use a temporary file once the amount of data stored hits a predefined limit (the default is 2 MB).
 | 
						|
			// The location of this temporary file is determined in the same way as the {@see sys_get_temp_dir()} function.
 | 
						|
			if ( WP_DEBUG ) {
 | 
						|
				$stream_handle = fopen( 'php://memory', 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
 | 
						|
			} else {
 | 
						|
				$stream_handle = @fopen( 'php://memory', 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged, Generic.PHP.NoSilencedErrors.Forbidden
 | 
						|
			}
 | 
						|
 | 
						|
			if ( $stream_handle ) {
 | 
						|
				$this->stream_handle = $stream_handle;
 | 
						|
				$this->proxy_url_curl( $raw_url, $args );
 | 
						|
				exit;
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		// If either cURL is not available or fopen() did not succeed,
 | 
						|
		// fall back to using whatever else is set up on the site,
 | 
						|
		// presumably WP_Http_Streams or still WP_Http_Curl but without streams.
 | 
						|
		unset( $args['blocking'] );
 | 
						|
		$this->proxy_url_fallback( $raw_url, $args );
 | 
						|
 | 
						|
		exit;
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Prepares response asset response.
 | 
						|
	 *
 | 
						|
	 * @since 1.11.0
 | 
						|
	 *
 | 
						|
	 * @param LinkData|false  $link    URL data value, default to false is not set.
 | 
						|
	 * @param WP_REST_Request $request Request object.
 | 
						|
	 * @return WP_REST_Response|WP_Error Response object.
 | 
						|
	 *
 | 
						|
	 * @phpstan-param LinkData $link
 | 
						|
	 */
 | 
						|
	public function prepare_item_for_response( $link, $request ) {
 | 
						|
		$fields = $this->get_fields_for_response( $request );
 | 
						|
		$schema = $this->get_item_schema();
 | 
						|
 | 
						|
		$data = [];
 | 
						|
 | 
						|
		$error = new WP_Error();
 | 
						|
		foreach ( $schema['properties'] as $field => $args ) {
 | 
						|
			if ( ! isset( $link[ $field ] ) || ! rest_is_field_included( $field, $fields ) ) {
 | 
						|
				continue;
 | 
						|
			}
 | 
						|
			$check = rest_validate_value_from_schema( $link[ $field ], $args, $field );
 | 
						|
			if ( is_wp_error( $check ) ) {
 | 
						|
				$error->add( 'rest_invalid_' . $field, $check->get_error_message(), [ 'status' => 400 ] );
 | 
						|
				continue;
 | 
						|
			}
 | 
						|
 | 
						|
			$data[ $field ] = rest_sanitize_value_from_schema( $link[ $field ], $args, $field );
 | 
						|
		}
 | 
						|
 | 
						|
		if ( $error->get_error_codes() ) {
 | 
						|
			return $error;
 | 
						|
		}
 | 
						|
 | 
						|
		/**
 | 
						|
		 * Request context.
 | 
						|
		 *
 | 
						|
		 * @var string $context
 | 
						|
		 */
 | 
						|
		$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
 | 
						|
		$data    = $this->add_additional_fields_to_object( $data, $request );
 | 
						|
		$data    = $this->filter_response_by_context( $data, $context );
 | 
						|
 | 
						|
		return rest_ensure_response( $data );
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Retrieves the link's schema, conforming to JSON Schema.
 | 
						|
	 *
 | 
						|
	 * @since 1.11.0
 | 
						|
	 *
 | 
						|
	 * @return array Item schema data.
 | 
						|
	 *
 | 
						|
	 * @phpstan-return Schema
 | 
						|
	 */
 | 
						|
	public function get_item_schema(): array {
 | 
						|
		if ( $this->schema ) {
 | 
						|
			/**
 | 
						|
			 * Schema.
 | 
						|
			 *
 | 
						|
			 * @phpstan-var Schema $schema
 | 
						|
			 */
 | 
						|
			$schema = $this->add_additional_fields_schema( $this->schema );
 | 
						|
			return $schema;
 | 
						|
		}
 | 
						|
 | 
						|
		$allowed_mime_types = $this->get_allowed_mime_types();
 | 
						|
		$types              = array_keys( $allowed_mime_types );
 | 
						|
		$allowed_mime_types = array_merge( ...array_values( $allowed_mime_types ) );
 | 
						|
		$exts               = $this->types->get_file_type_exts( $allowed_mime_types );
 | 
						|
 | 
						|
		$schema = [
 | 
						|
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
 | 
						|
			'title'      => 'link',
 | 
						|
			'type'       => 'object',
 | 
						|
			'properties' => [
 | 
						|
				'ext'       => [
 | 
						|
					'description' => __( 'File extension', 'web-stories' ),
 | 
						|
					'type'        => 'string',
 | 
						|
					'context'     => [ 'view', 'edit', 'embed' ],
 | 
						|
					'enum'        => $exts,
 | 
						|
				],
 | 
						|
				'file_name' => [
 | 
						|
					'description' => __( 'File name', 'web-stories' ),
 | 
						|
					'type'        => 'string',
 | 
						|
					'context'     => [ 'view', 'edit', 'embed' ],
 | 
						|
				],
 | 
						|
				'file_size' => [
 | 
						|
					'description' => __( 'File size', 'web-stories' ),
 | 
						|
					'type'        => 'integer',
 | 
						|
					'context'     => [ 'view', 'edit', 'embed' ],
 | 
						|
				],
 | 
						|
				'mime_type' => [
 | 
						|
					'description' => __( 'Mime type', 'web-stories' ),
 | 
						|
					'type'        => 'string',
 | 
						|
					'context'     => [ 'view', 'edit', 'embed' ],
 | 
						|
					'enum'        => $allowed_mime_types,
 | 
						|
				],
 | 
						|
				'type'      => [
 | 
						|
					'description' => __( 'Type', 'web-stories' ),
 | 
						|
					'type'        => 'string',
 | 
						|
					'context'     => [ 'view', 'edit', 'embed' ],
 | 
						|
					'enum'        => $types,
 | 
						|
				],
 | 
						|
			],
 | 
						|
		];
 | 
						|
 | 
						|
		$this->schema = $schema;
 | 
						|
 | 
						|
		/**
 | 
						|
		 * Schema.
 | 
						|
		 *
 | 
						|
		 * @phpstan-var Schema $schema
 | 
						|
		 */
 | 
						|
		$schema = $this->add_additional_fields_schema( $this->schema );
 | 
						|
		return $schema;
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Checks if current user can process urls.
 | 
						|
	 *
 | 
						|
	 * @since 1.11.0
 | 
						|
	 *
 | 
						|
	 * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
 | 
						|
	 */
 | 
						|
	public function parse_url_permissions_check() {
 | 
						|
		if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) {
 | 
						|
			return new WP_Error(
 | 
						|
				'rest_forbidden',
 | 
						|
				__( 'Sorry, you are not allowed to insert external media.', 'web-stories' ),
 | 
						|
				[ 'status' => rest_authorization_required_code() ]
 | 
						|
			);
 | 
						|
		}
 | 
						|
 | 
						|
		return true;
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Callback to validate urls.
 | 
						|
	 *
 | 
						|
	 * @since 1.11.0
 | 
						|
	 *
 | 
						|
	 * @param string $value Value to be validated.
 | 
						|
	 * @return true|WP_Error
 | 
						|
	 */
 | 
						|
	public function validate_callback( $value ) {
 | 
						|
		$url = untrailingslashit( $value );
 | 
						|
 | 
						|
		if ( empty( $url ) || ! $this->validate_url( $url ) ) {
 | 
						|
			return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] );
 | 
						|
		}
 | 
						|
 | 
						|
		$path = wp_parse_url( $url, PHP_URL_PATH );
 | 
						|
 | 
						|
		if ( ! $path ) {
 | 
						|
			return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] );
 | 
						|
		}
 | 
						|
 | 
						|
		return true;
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Returns a callback to modify the cURL configuration before the request is executed.
 | 
						|
	 *
 | 
						|
	 * @since 1.22.1
 | 
						|
	 *
 | 
						|
	 * @param string $url       URL.
 | 
						|
	 * @param string $url_or_ip URL or IP address.
 | 
						|
	 */
 | 
						|
	public function get_curl_resolve_callback( string $url, string $url_or_ip ): callable {
 | 
						|
		/**
 | 
						|
		 * CURL configuration callback.
 | 
						|
		 *
 | 
						|
		 * @param resource $handle The cURL handle returned by curl_init() (passed by reference).
 | 
						|
		 */
 | 
						|
		return static function ( $handle ) use ( $url, $url_or_ip ): void {
 | 
						|
			// Just some safeguard in case cURL is not really available,
 | 
						|
			// despite this method being run in the context of WP_Http_Curl.
 | 
						|
			if ( ! \function_exists( '\curl_setopt' ) ) {
 | 
						|
				return;
 | 
						|
			}
 | 
						|
 | 
						|
			if ( $url === $url_or_ip ) {
 | 
						|
				return;
 | 
						|
			}
 | 
						|
 | 
						|
			$host   = wp_parse_url( $url, PHP_URL_HOST );
 | 
						|
			$scheme = wp_parse_url( $url, PHP_URL_SCHEME ) ?? 'http';
 | 
						|
			$port   = wp_parse_url( $url, PHP_URL_PORT ) ?? 'http' === $scheme ? 80 : 443;
 | 
						|
 | 
						|
            // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
 | 
						|
 | 
						|
			curl_setopt(
 | 
						|
				$handle,
 | 
						|
				CURLOPT_RESOLVE,
 | 
						|
				[
 | 
						|
					"$host:$port:$url_or_ip",
 | 
						|
				]
 | 
						|
			);
 | 
						|
 | 
						|
            // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
 | 
						|
		};
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Modifies the cURL configuration before the request is executed.
 | 
						|
	 *
 | 
						|
	 * @since 1.15.0
 | 
						|
	 *
 | 
						|
	 * @param resource $handle The cURL handle returned by {@see curl_init()} (passed by reference).
 | 
						|
	 */
 | 
						|
	public function modify_curl_configuration( $handle ): void {
 | 
						|
		// Just some safeguard in case cURL is not really available,
 | 
						|
		// despite this method being run in the context of WP_Http_Curl.
 | 
						|
		if ( ! \function_exists( '\curl_setopt' ) ) {
 | 
						|
			return;
 | 
						|
		}
 | 
						|
 | 
						|
        // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
 | 
						|
 | 
						|
		curl_setopt(
 | 
						|
			$handle,
 | 
						|
			CURLOPT_FILE,
 | 
						|
			$this->stream_handle
 | 
						|
		);
 | 
						|
 | 
						|
		curl_setopt( $handle, CURLOPT_HEADERFUNCTION, [ $this, 'stream_headers' ] );
 | 
						|
 | 
						|
        // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Grabs the headers of the cURL request.
 | 
						|
	 *
 | 
						|
	 * Each header is sent individually to this callback,
 | 
						|
	 * so we take a look at each one to see if we should "forward" it.
 | 
						|
	 *
 | 
						|
	 * @since 1.15.0
 | 
						|
	 *
 | 
						|
	 * @param resource $handle  cURL handle.
 | 
						|
	 * @param string   $header cURL header.
 | 
						|
	 * @return int Header length.
 | 
						|
	 */
 | 
						|
	public function stream_headers( $handle, $header ): int {
 | 
						|
		// Parse Status-Line, the first component in the HTTP response, e.g. HTTP/1.1 200 OK.
 | 
						|
		// Extract the status code to re-send that here.
 | 
						|
		if ( str_starts_with( $header, 'HTTP/' ) ) {
 | 
						|
			$status = explode( ' ', $header );
 | 
						|
			http_response_code( (int) $status[1] );
 | 
						|
			return \strlen( $header );
 | 
						|
		}
 | 
						|
 | 
						|
		foreach ( self::PROXY_HEADERS_ALLOWLIST as $_header ) {
 | 
						|
			if ( str_starts_with( strtolower( $header ), strtolower( $_header ) . ': ' ) ) {
 | 
						|
				header( $header, true );
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		return \strlen( $header );
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Proxy a given URL via a PHP read-write stream.
 | 
						|
	 *
 | 
						|
	 * @since 1.15.0
 | 
						|
	 *
 | 
						|
	 * @param string               $url  Request URL.
 | 
						|
	 * @param array<string, mixed> $args Request args.
 | 
						|
	 *
 | 
						|
	 * @phpstan-param array{
 | 
						|
	 *   method?: string,
 | 
						|
	 *   timeout?: float,
 | 
						|
	 *   redirection?: int,
 | 
						|
	 *   httpversion?: string,
 | 
						|
	 *   user-agent?: string,
 | 
						|
	 *   reject_unsafe_urls?: bool,
 | 
						|
	 *   blocking?: bool,
 | 
						|
	 *   headers?: string|array,
 | 
						|
	 *   cookies?: array,
 | 
						|
	 *   body?: string|array,
 | 
						|
	 *   compress?: bool,
 | 
						|
	 *   decompress?: bool,
 | 
						|
	 *   sslverify?: bool,
 | 
						|
	 *   sslcertificates?: string,
 | 
						|
	 *   stream?: bool,
 | 
						|
	 *   filename?: string,
 | 
						|
	 *   limit_response_size?: int,
 | 
						|
	 * } $args
 | 
						|
	 */
 | 
						|
	private function proxy_url_curl( string $url, array $args ): void {
 | 
						|
		add_action( 'http_api_curl', [ $this, 'modify_curl_configuration' ] );
 | 
						|
		wp_safe_remote_get( $url, $args );
 | 
						|
		remove_action( 'http_api_curl', [ $this, 'modify_curl_configuration' ] );
 | 
						|
 | 
						|
		rewind( $this->stream_handle );
 | 
						|
		while ( ! feof( $this->stream_handle ) ) {
 | 
						|
			echo fread( $this->stream_handle, 1024 * 1024 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_fread
 | 
						|
		}
 | 
						|
 | 
						|
		fclose( $this->stream_handle );
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Proxy a given URL by storing in memory.
 | 
						|
	 *
 | 
						|
	 * @since 1.15.0
 | 
						|
	 *
 | 
						|
	 * @param string               $url  Request URL.
 | 
						|
	 * @param array<string, mixed> $args Request args.
 | 
						|
	 *
 | 
						|
	 * @phpstan-param array{
 | 
						|
	 *   method?: string,
 | 
						|
	 *   timeout?: float,
 | 
						|
	 *   redirection?: int,
 | 
						|
	 *   httpversion?: string,
 | 
						|
	 *   user-agent?: string,
 | 
						|
	 *   reject_unsafe_urls?: bool,
 | 
						|
	 *   blocking?: bool,
 | 
						|
	 *   headers?: string|array,
 | 
						|
	 *   cookies?: array,
 | 
						|
	 *   body?: string|array,
 | 
						|
	 *   compress?: bool,
 | 
						|
	 *   decompress?: bool,
 | 
						|
	 *   sslverify?: bool,
 | 
						|
	 *   sslcertificates?: string,
 | 
						|
	 *   stream?: bool,
 | 
						|
	 *   filename?: string,
 | 
						|
	 *   limit_response_size?: int,
 | 
						|
	 * } $args
 | 
						|
	 */
 | 
						|
	private function proxy_url_fallback( string $url, array $args ): void {
 | 
						|
		$response = wp_safe_remote_get( $url, $args );
 | 
						|
		$status   = wp_remote_retrieve_response_code( $response );
 | 
						|
 | 
						|
		if ( ! $status ) {
 | 
						|
			http_response_code( 404 );
 | 
						|
			return;
 | 
						|
		}
 | 
						|
 | 
						|
		http_response_code( (int) $status );
 | 
						|
 | 
						|
		$headers = wp_remote_retrieve_headers( $response );
 | 
						|
 | 
						|
		foreach ( self::PROXY_HEADERS_ALLOWLIST as $_header ) {
 | 
						|
			if ( isset( $headers[ $_header ] ) ) {
 | 
						|
				header( $_header . ': ' . $headers[ $_header ] );
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		echo wp_remote_retrieve_body( $response ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Validate a URL for safe use in the HTTP API.
 | 
						|
	 *
 | 
						|
	 * Like {@see wp_http_validate_url} in core, but with extra hardening
 | 
						|
	 * to avoid DNS rebinding issues.
 | 
						|
	 *
 | 
						|
	 * @SuppressWarnings(PHPMD.NPathComplexity)
 | 
						|
	 * @SuppressWarnings(PHPMD.CyclomaticComplexity)
 | 
						|
	 *
 | 
						|
	 * @since 1.22.0
 | 
						|
	 *
 | 
						|
	 * @param string $url Request URL.
 | 
						|
	 * @return string|false Original URL, resolved IP address, or false on failure.
 | 
						|
	 */
 | 
						|
	private function validate_url( string $url ) { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
 | 
						|
		if ( '' === $url || is_numeric( $url ) ) {
 | 
						|
			return false;
 | 
						|
		}
 | 
						|
 | 
						|
		$original_url = $url;
 | 
						|
		$url          = wp_kses_bad_protocol( $url, [ 'http', 'https' ] );
 | 
						|
		if ( ! $url || strtolower( $url ) !== strtolower( $original_url ) ) {
 | 
						|
			return false;
 | 
						|
		}
 | 
						|
 | 
						|
		$parsed_url = wp_parse_url( $url );
 | 
						|
		if ( ! $parsed_url || ! isset( $parsed_url['host'], $parsed_url['scheme'] ) ) {
 | 
						|
			return false;
 | 
						|
		}
 | 
						|
 | 
						|
		if ( isset( $parsed_url['user'] ) || isset( $parsed_url['pass'] ) ) {
 | 
						|
			return false;
 | 
						|
		}
 | 
						|
 | 
						|
		if ( false !== strpbrk( $parsed_url['host'], ':#?[]' ) ) {
 | 
						|
			return false;
 | 
						|
		}
 | 
						|
 | 
						|
		/**
 | 
						|
		 * Home URL.
 | 
						|
		 *
 | 
						|
		 * @var string
 | 
						|
		 */
 | 
						|
		$home_url = get_option( 'home' );
 | 
						|
 | 
						|
		$parsed_home = wp_parse_url( $home_url );
 | 
						|
 | 
						|
		if ( ! $parsed_home ) {
 | 
						|
			return false;
 | 
						|
		}
 | 
						|
 | 
						|
		$same_host = isset( $parsed_home['host'] ) && strtolower( $parsed_home['host'] ) === strtolower( $parsed_url['host'] );
 | 
						|
		$host      = trim( $parsed_url['host'], '.' );
 | 
						|
 | 
						|
		$validated_url = $url;
 | 
						|
 | 
						|
		if ( ! $same_host ) {
 | 
						|
			if ( preg_match( '#^(([1-9]?\d|1\d\d|25[0-5]|2[0-4]\d)\.){3}([1-9]?\d|1\d\d|25[0-5]|2[0-4]\d)$#', $host ) ) {
 | 
						|
				$ip = $host;
 | 
						|
			} else {
 | 
						|
				$ip = gethostbyname( $host );
 | 
						|
				if ( $ip === $host ) { // Error condition for gethostbyname().
 | 
						|
					return false;
 | 
						|
				}
 | 
						|
			}
 | 
						|
 | 
						|
			$parts = array_map( 'intval', explode( '.', $ip ) );
 | 
						|
			if (
 | 
						|
				0 === $parts[0] // 0.0.0.0/8.
 | 
						|
				||
 | 
						|
				127 === $parts[0] // 127.0.0.0/8.
 | 
						|
				||
 | 
						|
				10 === $parts[0] // 10.0.0.0/8.
 | 
						|
				||
 | 
						|
				( 172 === $parts[0] && 16 <= $parts[1] && 31 >= $parts[1] ) // 172.16.0.0/12.
 | 
						|
				||
 | 
						|
				( 192 === $parts[0] && 168 === $parts[1] ) // 192.168.0.0/16.
 | 
						|
				||
 | 
						|
				( 169 === $parts[0] && 254 === $parts[1] ) // 169.254.0.0/16.
 | 
						|
				||
 | 
						|
				// phpcs:ignore Squiz.PHP.CommentedOutCode.Found
 | 
						|
				( 100 === $parts[0] && 64 <= $parts[1] && 127 >= $parts[1] ) // Private: 100.64.0.0/10.
 | 
						|
			) {
 | 
						|
				// If host appears local, reject.
 | 
						|
				return false;
 | 
						|
			}
 | 
						|
 | 
						|
			// Use resolved IP address to avoid DNS rebinding issues.
 | 
						|
			$validated_url = $ip;
 | 
						|
		}
 | 
						|
 | 
						|
		/** This filter is documented in wp-includes/http.php */
 | 
						|
		$allowed_ports = apply_filters( 'http_allowed_safe_ports', [ 80, 443, 8080 ], $host, $url );
 | 
						|
		if (
 | 
						|
			! isset( $parsed_url['port'] ) ||
 | 
						|
			( \is_array( $allowed_ports ) && \in_array( $parsed_url['port'], $allowed_ports, true ) )
 | 
						|
		) {
 | 
						|
			return $validated_url;
 | 
						|
		}
 | 
						|
 | 
						|
		if ( $same_host && isset( $parsed_home['port'] ) && $parsed_home['port'] === $parsed_url['port'] ) {
 | 
						|
			return $validated_url;
 | 
						|
		}
 | 
						|
 | 
						|
		return false;
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * Returns a list of allowed mime types per media type (image, audio, video).
 | 
						|
	 *
 | 
						|
	 * @since 1.19.0
 | 
						|
	 *
 | 
						|
	 * @return array<string, string[]> List of allowed mime types.
 | 
						|
	 */
 | 
						|
	protected function get_allowed_mime_types(): array {
 | 
						|
		$mime_type = $this->types->get_allowed_mime_types();
 | 
						|
 | 
						|
		// Do not support hotlinking SVGs for security reasons.
 | 
						|
		unset( $mime_type['vector'] );
 | 
						|
 | 
						|
		return $mime_type;
 | 
						|
	}
 | 
						|
}
 |