Skip to content

Commit 7e62540

Browse files
REST API: Sideload external images via a url parameter.
Extend the attachments endpoint (`POST /wp/v2/media`) to accept an optional `url` parameter. When a request supplies a `url`, `WP_REST_Attachments_Controller::create_item()` routes it through a new `create_item_from_url()` method that downloads the remote file with `download_url()` and stores it with `media_handle_sideload()`, rather than the browser fetching the bytes and posting a blob. The existing uploaded-file path is unchanged when no `url` is supplied, and the sub-size and scaling filters continue to govern derivative generation. See related Gutenberg pull request: WordPress/gutenberg#79409 and issue: WordPress/gutenberg#79407. Props swissspidy, khokansardar, westonruter. Fixes #65517. git-svn-id: https://develop.svn.wordpress.org/trunk@62659 602fd350-edb4-49c9-b593-d223f7449a82
1 parent b260b73 commit 7e62540

3 files changed

Lines changed: 585 additions & 1 deletion

File tree

src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,38 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE
248248
'default' => true,
249249
'description' => __( 'Whether to convert image formats.' ),
250250
);
251+
$args['url'] = array(
252+
'type' => 'string',
253+
'format' => 'uri',
254+
'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ),
255+
'sanitize_callback' => 'sanitize_url',
256+
'validate_callback' => static function ( $url, $request, $param ) {
257+
/*
258+
* A custom validate_callback replaces the default
259+
* rest_validate_request_arg(), so re-apply it first to keep
260+
* the schema checks (string type, uri format) enforced.
261+
*/
262+
$valid = rest_validate_request_arg( $url, $request, $param );
263+
if ( is_wp_error( $valid ) ) {
264+
return $valid;
265+
}
266+
267+
/*
268+
* Reject URLs that are not safe to request server-side. wp_http_validate_url()
269+
* enforces an HTTP(S) scheme and blocks private, local, and otherwise
270+
* disallowed hosts, guarding the sideload against SSRF.
271+
*/
272+
if ( false === wp_http_validate_url( $url ) ) {
273+
return new WP_Error(
274+
'rest_invalid_url',
275+
__( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.' ),
276+
array( 'status' => 400 )
277+
);
278+
}
279+
280+
return true;
281+
},
282+
);
251283
}
252284

253285
return $args;
@@ -406,7 +438,7 @@ public function create_item_permissions_check( $request ) {
406438
* Creates a single attachment.
407439
*
408440
* @since 4.7.0
409-
* @since 7.1.0 Added `generate_sub_sizes` and `convert_format` parameters.
441+
* @since 7.1.0 Added the `generate_sub_sizes`, `convert_format`, and `url` parameters.
410442
*
411443
* @param WP_REST_Request $request Full details about the request.
412444
* @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
@@ -434,6 +466,18 @@ public function create_item( $request ) {
434466
add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
435467
}
436468

469+
/*
470+
* When a URL is supplied instead of an uploaded file, sideload the
471+
* remote image on the server. This avoids a cross-origin browser fetch,
472+
* which fails under cross-origin isolation. The sub-size and scaling
473+
* filters applied above still govern whether derivatives are generated.
474+
*/
475+
if ( ! empty( $request['url'] ) ) {
476+
$response = $this->create_item_from_url( $request );
477+
$this->remove_client_side_media_processing_filters();
478+
return $response;
479+
}
480+
437481
$insert = $this->insert_attachment( $request );
438482

439483
if ( is_wp_error( $insert ) ) {
@@ -527,6 +571,108 @@ public function create_item( $request ) {
527571
return $response;
528572
}
529573

574+
/**
575+
* Sideloads an external image from a URL into the media library.
576+
*
577+
* Downloads the remote file on the server, avoiding a cross-origin browser
578+
* fetch that fails under cross-origin isolation. Whether sub-sizes are
579+
* generated is governed by the filters applied in create_item().
580+
*
581+
* @since 7.1.0
582+
*
583+
* @param WP_REST_Request $request Full details about the request.
584+
* @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
585+
*/
586+
protected function create_item_from_url( $request ) {
587+
// Sideloading downloads and stores a file, so require the upload capability.
588+
if ( ! current_user_can( 'upload_files' ) ) {
589+
return new WP_Error(
590+
'rest_cannot_create',
591+
__( 'Sorry, you are not allowed to upload media on this site.' ),
592+
array( 'status' => rest_authorization_required_code() )
593+
);
594+
}
595+
596+
require_once ABSPATH . 'wp-admin/includes/file.php';
597+
require_once ABSPATH . 'wp-admin/includes/media.php';
598+
require_once ABSPATH . 'wp-admin/includes/image.php';
599+
600+
$url = $request['url'];
601+
$post_id = ! empty( $request['post'] ) ? (int) $request['post'] : 0;
602+
603+
// Derive the filename from the URL path before downloading anything.
604+
$url_path = wp_parse_url( $url, PHP_URL_PATH );
605+
$filename = $url_path ? wp_basename( $url_path ) : '';
606+
if ( '' === $filename ) {
607+
return new WP_Error(
608+
'rest_invalid_url',
609+
__( 'Could not determine a filename from the provided URL.' ),
610+
array( 'status' => 400 )
611+
);
612+
}
613+
614+
/*
615+
* Only download URLs whose extension maps to an allowed image MIME type.
616+
* The sideload handler would reject other types anyway (via
617+
* wp_check_filetype_and_ext()), but checking first avoids downloading
618+
* files that can never be accepted, such as PHP scripts.
619+
*/
620+
$filetype = wp_check_filetype( $filename );
621+
if ( ! $filetype['type'] || ! str_starts_with( $filetype['type'], 'image/' ) ) {
622+
return new WP_Error(
623+
'rest_invalid_url',
624+
__( 'The provided URL does not point to a supported image file.' ),
625+
array( 'status' => 400 )
626+
);
627+
}
628+
629+
/*
630+
* Download the remote file with WordPress's HTTP API, which validates
631+
* the host and blocks requests to private or local addresses. This is
632+
* the same primitive core's media_sideload_image() relies on.
633+
*/
634+
$tmp_file = download_url( $url );
635+
if ( is_wp_error( $tmp_file ) ) {
636+
return $tmp_file;
637+
}
638+
639+
$file_array = array(
640+
'name' => $filename,
641+
'tmp_name' => $tmp_file,
642+
);
643+
644+
$attachment_id = media_handle_sideload( $file_array, $post_id );
645+
646+
if ( is_wp_error( $attachment_id ) ) {
647+
/*
648+
* media_handle_sideload() deletes the temp file on success; remove
649+
* it explicitly when the sideload fails.
650+
*/
651+
if ( file_exists( $tmp_file ) ) {
652+
wp_delete_file( $tmp_file );
653+
}
654+
return $attachment_id;
655+
}
656+
657+
$attachment = get_post( $attachment_id );
658+
659+
$request->set_param( 'context', 'edit' );
660+
661+
/*
662+
* media_handle_sideload() fires the standard insert hooks (including
663+
* wp_after_insert_post), but not the REST-specific action, so fire it
664+
* here for parity with the uploaded-file path in create_item().
665+
*/
666+
/** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */
667+
do_action( 'rest_after_insert_attachment', $attachment, $request, true );
668+
669+
$response = $this->prepare_item_for_response( $attachment, $request );
670+
$response->set_status( 201 );
671+
$response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) );
672+
673+
return $response;
674+
}
675+
530676
/**
531677
* Removes filters added for client-side media processing.
532678
*

0 commit comments

Comments
 (0)