@@ -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