You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
874 lines
24 KiB
PHTML
874 lines
24 KiB
PHTML
8 months ago
|
<?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;
|
||
|
}
|
||
|
}
|