PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / _inc / lib / core-api / wpcom-endpoints / class-wpcom-rest-api-v2-endpoint-external-media.php

class-wpcom-rest-api-v2-endpoint-external-media.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3, at _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-external-media.php

1,000 lines 28.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API endpoint for the External Media.
4 *
5 * @package automattic/jetpack
6 * @since 8.7.0
7 */
8
9 use Automattic\Jetpack\Connection\Client;
10 use Automattic\Jetpack\Connection\Manager;
11 use Automattic\Jetpack\IP\Utils;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit( 0 );
15 }
16
17 /**
18 * External Media helper API.
19 *
20 * @since 8.7.0
21 */
22 class WPCOM_REST_API_V2_Endpoint_External_Media extends WP_REST_Controller {
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 /**
42 * Media argument schema for /copy endpoint.
43 *
44 * @var array
45 */
46 public $media_schema = array(
47 'type' => 'array',
48 'items' => array(
49 'type' => 'object',
50 'required' => true,
51 'properties' => array(
52 'caption' => array(
53 'type' => 'string',
54 ),
55 'guid' => array(
56 'type' => 'object',
57 'properties' => array(
58 'caption' => array(
59 'type' => 'string',
60 ),
61 'name' => array(
62 'type' => 'string',
63 ),
64 'title' => array(
65 'type' => 'string',
66 ),
67 'url' => array(
68 'format' => 'uri',
69 'type' => 'string',
70 ),
71 ),
72 ),
73 'title' => array(
74 'type' => 'string',
75 ),
76 'meta' => array(
77 'type' => 'object',
78 'additionalProperties' => false,
79 'properties' => array(
80 'vertical_id' => array(
81 'type' => 'string',
82 'format' => 'text-field',
83 ),
84 'pexels_object' => array(
85 'type' => 'object',
86 ),
87 ),
88 ),
89 ),
90 ),
91 );
92
93 /**
94 * Service regex.
95 *
96 * @var string
97 */
98 private static $services_regex = '(?P<service>google_photos|openverse|pexels)';
99
100 /**
101 * Constructor.
102 */
103 public function __construct() {
104 $this->namespace = 'wpcom/v2';
105 $this->rest_base = 'external-media';
106
107 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
108 }
109
110 /**
111 * Registers the routes for external media.
112 */
113 public function register_routes() {
114 register_rest_route(
115 $this->namespace,
116 $this->rest_base . '/list/' . self::$services_regex,
117 array(
118 'methods' => WP_REST_Server::READABLE,
119 'callback' => array( $this, 'get_external_media' ),
120 'permission_callback' => array( $this, 'permission_callback' ),
121 'args' => array(
122 'search' => array(
123 'description' => __( 'Media collection search term.', 'jetpack' ),
124 'type' => 'string',
125 ),
126 'number' => array(
127 'description' => __( 'Number of media items in the request', 'jetpack' ),
128 'type' => 'number',
129 'default' => 20,
130 ),
131 'path' => array(
132 'type' => 'string',
133 ),
134 'page_handle' => array(
135 'type' => 'string',
136 ),
137 'session_id' => array(
138 'description' => __( 'Session id of a service, currently only Google Photos Picker', 'jetpack' ),
139 'type' => 'string',
140 ),
141 ),
142 )
143 );
144
145 register_rest_route(
146 $this->namespace,
147 $this->rest_base . '/copy/' . self::$services_regex,
148 array(
149 'methods' => \WP_REST_Server::CREATABLE,
150 'callback' => array( $this, 'copy_external_media' ),
151 'permission_callback' => array( $this, 'create_item_permissions_check' ),
152 'args' => array(
153 'media' => array(
154 'description' => __( 'Media data to copy.', 'jetpack' ),
155 'items' => $this->media_schema,
156 'required' => true,
157 'type' => 'array',
158 'sanitize_callback' => array( $this, 'sanitize_media' ),
159 'validate_callback' => array( $this, 'validate_media' ),
160 ),
161 'post_id' => array(
162 'description' => __( 'The post ID to attach the upload to.', 'jetpack' ),
163 'type' => 'number',
164 'minimum' => 0,
165 ),
166 'should_proxy' => array(
167 'description' => __( 'Whether to proxy the media request.', 'jetpack' ),
168 'type' => 'boolean',
169 'default' => false,
170 ),
171 ),
172 )
173 );
174
175 register_rest_route(
176 $this->namespace,
177 $this->rest_base . '/connection/(?P<service>google_photos)',
178 array(
179 'methods' => \WP_REST_Server::READABLE,
180 'callback' => array( $this, 'get_connection_details' ),
181 'permission_callback' => array( $this, 'permission_callback' ),
182 )
183 );
184
185 register_rest_route(
186 $this->namespace,
187 $this->rest_base . '/connection/(?P<service>google_photos)',
188 array(
189 'methods' => \WP_REST_Server::DELETABLE,
190 'callback' => array( $this, 'delete_connection' ),
191 'permission_callback' => array( $this, 'permission_callback' ),
192 )
193 );
194
195 register_rest_route(
196 $this->namespace,
197 $this->rest_base . '/connection/(?P<service>google_photos)/picker_status',
198 array(
199 'methods' => \WP_REST_Server::READABLE,
200 'callback' => array( $this, 'get_picker_status' ),
201 'permission_callback' => array( $this, 'permission_callback' ),
202 )
203 );
204
205 // Add new session route, currently for Google Photos Picker only
206 register_rest_route(
207 $this->namespace,
208 $this->rest_base . '/session/(?P<service>google_photos)',
209 array(
210 'methods' => \WP_REST_Server::CREATABLE,
211 'callback' => array( $this, 'create_session' ),
212 'permission_callback' => array( $this, 'permission_callback' ),
213 )
214 );
215
216 // Get new session route, currently for Google Photos Picker only
217 register_rest_route(
218 $this->namespace,
219 $this->rest_base . '/session/(?P<service>google_photos)/(?P<session_id>.*)',
220 array(
221 'methods' => \WP_REST_Server::READABLE,
222 'callback' => array( $this, 'get_session' ),
223 'permission_callback' => array( $this, 'permission_callback' ),
224 )
225 );
226
227 // Delete session route, currently for Google Photos Picker only
228 register_rest_route(
229 $this->namespace,
230 $this->rest_base . '/session/(?P<service>google_photos)/(?P<session_id>.*)',
231 array(
232 'methods' => \WP_REST_Server::DELETABLE,
233 'callback' => array( $this, 'delete_session' ),
234 'permission_callback' => array( $this, 'permission_callback' ),
235 )
236 );
237
238 // Add new proxy route for media files
239 register_rest_route(
240 $this->namespace,
241 $this->rest_base . '/proxy/(?P<service>google_photos)',
242 array(
243 'methods' => WP_REST_Server::CREATABLE,
244 'callback' => array( $this, 'proxy_media_request' ),
245 'permission_callback' => array( $this, 'permission_callback' ),
246 'args' => array(
247 'url' => array(
248 'required' => true,
249 'type' => 'string',
250 ),
251 ),
252 )
253 );
254 }
255
256 /**
257 * Checks if a given request has access to external media libraries.
258 */
259 public function permission_callback() {
260 return current_user_can( 'upload_files' );
261 }
262
263 /**
264 * Checks if a given request has access to create an attachment.
265 *
266 * @param WP_REST_Request $request Full details about the request.
267 * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
268 */
269 public function create_item_permissions_check( $request ) {
270 if ( ! empty( $request['id'] ) ) {
271 return new WP_Error(
272 'rest_post_exists',
273 __( 'Cannot create existing post.', 'jetpack' ),
274 array( 'status' => 400 )
275 );
276 }
277
278 $post_type = get_post_type_object( 'attachment' );
279
280 if ( ! current_user_can( $post_type->cap->create_posts ) ) {
281 return new WP_Error(
282 'rest_cannot_create',
283 __( 'Sorry, you are not allowed to create posts as this user.', 'jetpack' ),
284 array( 'status' => rest_authorization_required_code() )
285 );
286 }
287
288 if ( ! current_user_can( 'upload_files' ) ) {
289 return new WP_Error(
290 'rest_cannot_create',
291 __( 'Sorry, you are not allowed to upload media on this site.', 'jetpack' ),
292 array( 'status' => 400 )
293 );
294 }
295
296 // Attaching media to a post requires the ability to edit that post, mirroring
297 // WP_REST_Attachments_Controller::create_item_permissions_check(). Without this
298 // check any user with upload_files could parent an attachment to a post they
299 // cannot edit.
300 $post_id = (int) $request->get_param( 'post_id' );
301 if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
302 return new WP_Error(
303 'rest_cannot_edit',
304 __( 'Sorry, you are not allowed to upload media to this post.', 'jetpack' ),
305 array( 'status' => rest_authorization_required_code() )
306 );
307 }
308
309 return true;
310 }
311
312 /**
313 * Sanitization callback for media parameter.
314 *
315 * @param array $param Media parameter.
316 * @return true|\WP_Error
317 */
318 public function sanitize_media( $param ) {
319 $param = $this->prepare_media_param( $param );
320
321 return rest_sanitize_value_from_schema( $param, $this->media_schema );
322 }
323
324 /**
325 * Validation callback for media parameter.
326 *
327 * @param array $param Media parameter.
328 * @return true|\WP_Error
329 */
330 public function validate_media( $param ) {
331 $param = $this->prepare_media_param( $param );
332
333 return rest_validate_value_from_schema( $param, $this->media_schema, 'media' );
334 }
335
336 /**
337 * Decodes guid json and sets parameter defaults.
338 *
339 * @param array $param Media parameter.
340 * @return array
341 */
342 private function prepare_media_param( $param ) {
343 foreach ( $param as $key => $item ) {
344 if ( ! empty( $item['guid'] ) ) {
345 $param[ $key ]['guid'] = json_decode( $item['guid'], true );
346 }
347
348 if ( empty( $param[ $key ]['caption'] ) ) {
349 $param[ $key ]['caption'] = '';
350 }
351 if ( empty( $param[ $key ]['title'] ) ) {
352 $param[ $key ]['title'] = '';
353 }
354 }
355
356 return $param;
357 }
358
359 /**
360 * Retrieves media items from external libraries.
361 *
362 * @param \WP_REST_Request $request Full details about the request.
363 * @return array|\WP_Error|mixed
364 */
365 public function get_external_media( \WP_REST_Request $request ) {
366 $params = $request->get_params();
367 $wpcom_path = sprintf( '/meta/external-media/%s', rawurlencode( $params['service'] ) );
368
369 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
370 $request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
371 $request->set_query_params( $params );
372
373 return rest_do_request( $request );
374 }
375
376 // Build query string to pass to wpcom endpoint.
377 $service_args = array_filter(
378 $params,
379 function ( $key ) {
380 return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter', 'session_id' ), true );
381 },
382 ARRAY_FILTER_USE_KEY
383 );
384 if ( ! empty( $service_args ) ) {
385 $wpcom_path .= '?' . http_build_query( $service_args );
386 }
387
388 $response = Client::wpcom_json_api_request_as_user( $wpcom_path );
389
390 switch ( wp_remote_retrieve_response_code( $response ) ) {
391 case 200:
392 $response = json_decode( wp_remote_retrieve_body( $response ), true );
393 break;
394
395 case 401:
396 $response = new WP_Error(
397 'authorization_required',
398 __( 'You are not connected to that service.', 'jetpack' ),
399 array( 'status' => 403 )
400 );
401 break;
402
403 case 403:
404 $error = json_decode( wp_remote_retrieve_body( $response ) );
405 $response = new WP_Error( $error->code, $error->message, $error->data );
406 break;
407
408 default:
409 if ( is_wp_error( $response ) ) {
410 $response->add_data( array( 'status' => 400 ) );
411 break;
412 }
413 $response = new WP_Error(
414 'rest_request_error',
415 __( 'An unknown error has occurred. Please try again later.', 'jetpack' ),
416 array( 'status' => wp_remote_retrieve_response_code( $response ) )
417 );
418 }
419
420 return $response;
421 }
422
423 /**
424 * Saves an external media item to the media library.
425 *
426 * @param \WP_REST_Request $request Full details about the request.
427 * @return array|\WP_Error|mixed
428 **/
429 public function copy_external_media( \WP_REST_Request $request ) {
430 require_once ABSPATH . 'wp-admin/includes/file.php';
431 require_once ABSPATH . 'wp-admin/includes/media.php';
432 require_once ABSPATH . 'wp-admin/includes/image.php';
433
434 $post_id = (int) $request->get_param( 'post_id' );
435 $should_proxy = $request->get_param( 'should_proxy' );
436 $service = rawurlencode( $request->get_param( 'service' ) );
437
438 // Fail closed: never parent an attachment to a post the caller cannot edit,
439 // even if a future change lets an unauthorized request reach this handler.
440 // The permission callback already rejects such requests with a 403.
441 if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
442 $post_id = 0;
443 }
444
445 $responses = array();
446
447 foreach ( $request->get_param( 'media' ) as $item ) {
448 // Download file to temp dir.
449 if ( $should_proxy ) {
450 $wpcom_path = sprintf( '/meta/external-media/proxy/%s', $service );
451 $wpcom_path .= '?url=' . rawurlencode( $item['guid']['url'] );
452 $download_url = wp_tempnam();
453 $response = Client::wpcom_json_api_request_as_user(
454 $wpcom_path,
455 '2',
456 array(
457 'method' => 'POST',
458 )
459 );
460
461 if ( is_wp_error( $response ) ) {
462 $responses[] = $response;
463 continue;
464 }
465 $wp_filesystem = $this->get_wp_filesystem();
466 $written = $wp_filesystem->put_contents( $download_url, wp_remote_retrieve_body( $response ) );
467
468 if ( false === $written ) {
469 $responses[] = new WP_Error(
470 'rest_upload_error',
471 __( 'Could not download media file.', 'jetpack' ),
472 array( 'status' => 400 )
473 );
474 continue;
475 }
476 } else {
477 $download_url = $this->get_download_url( $item['guid'] );
478 }
479
480 if ( is_wp_error( $download_url ) ) {
481 $responses[] = $download_url;
482 continue;
483 }
484
485 $id = $this->sideload_media( $item['guid']['name'], $download_url, $post_id );
486 if ( is_wp_error( $id ) ) {
487 $responses[] = $id;
488 continue;
489 }
490
491 $this->update_attachment_meta( $id, $item );
492
493 // Add attachment data or WP_Error.
494 $responses[] = $this->get_attachment_data( $id, $item );
495 }
496
497 return $responses;
498 }
499
500 /**
501 * Gets connection authorization details.
502 *
503 * @param \WP_REST_Request $request Full details about the request.
504 * @return array|\WP_Error|mixed
505 */
506 public function get_connection_details( \WP_REST_Request $request ) {
507 $service = $request->get_param( 'service' );
508
509 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
510 $wpcom_path = sprintf( '/meta/external-media/connection/%s', rawurlencode( $service ) );
511 $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
512 $internal_request->set_query_params( $request->get_params() );
513
514 return rest_do_request( $internal_request );
515 }
516
517 $site_id = Manager::get_site_id();
518 if ( is_wp_error( $site_id ) ) {
519 return $site_id;
520 }
521
522 $path = sprintf( '/sites/%d/external-services', $site_id );
523 $response = Client::wpcom_json_api_request_as_user( $path );
524 if ( is_wp_error( $response ) ) {
525 return $response;
526 }
527
528 $body = json_decode( wp_remote_retrieve_body( $response ) );
529 if ( ! property_exists( $body, 'services' ) || ! property_exists( $body->services, $service ) ) {
530 return new WP_Error(
531 'bad_request',
532 __( 'An error occurred. Please try again later.', 'jetpack' ),
533 array( 'status' => 400 )
534 );
535 }
536
537 return $body->services->{ $service };
538 }
539
540 /**
541 * Deletes a Google Photos connection.
542 *
543 * @param WP_REST_Request $request Full details about the request.
544 * @return array|WP_Error|WP_REST_Response
545 */
546 public function delete_connection( WP_REST_Request $request ) {
547 $service = rawurlencode( $request->get_param( 'service' ) );
548 $wpcom_path = sprintf( '/meta/external-media/connection/%s', $service );
549
550 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
551 $internal_request = new WP_REST_Request( 'DELETE', '/' . $this->namespace . $wpcom_path );
552 $internal_request->set_query_params( $request->get_params() );
553
554 return rest_do_request( $internal_request );
555 }
556
557 $response = Client::wpcom_json_api_request_as_user(
558 $wpcom_path,
559 '2',
560 array(
561 'method' => 'DELETE',
562 )
563 );
564
565 return json_decode( wp_remote_retrieve_body( $response ), true );
566 }
567
568 /**
569 * Gets Google Photos Picker enabled Status.
570 *
571 * @param \WP_REST_Request $request Full details about the request.
572 * @return array|\WP_Error|mixed
573 */
574 public function get_picker_status( \WP_REST_Request $request ) {
575 $service = $request->get_param( 'service' );
576 $wpcom_path = sprintf( '/meta/external-media/connection/%s/picker_status', rawurlencode( $service ) );
577
578 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
579 $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
580 $internal_request->set_query_params( $request->get_params() );
581
582 return rest_do_request( $internal_request );
583 }
584
585 $response = Client::wpcom_json_api_request_as_user(
586 $wpcom_path,
587 '2',
588 array(
589 'method' => 'GET',
590 )
591 );
592
593 return json_decode( wp_remote_retrieve_body( $response ), true );
594 }
595
596 /**
597 * Creates a new session for a service.
598 *
599 * @param \WP_REST_Request $request Full details about the request.
600 * @return array|\WP_Error|mixed
601 */
602 public function create_session( \WP_REST_Request $request ) {
603 $service = $request->get_param( 'service' );
604 $wpcom_path = sprintf( '/meta/external-media/session/%s', rawurlencode( $service ) );
605
606 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
607 $internal_request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
608 $internal_request->set_query_params( $request->get_params() );
609
610 return rest_do_request( $internal_request );
611 }
612
613 $response = Client::wpcom_json_api_request_as_user(
614 $wpcom_path,
615 '2',
616 array(
617 'method' => 'POST',
618 )
619 );
620
621 return json_decode( wp_remote_retrieve_body( $response ), true );
622 }
623
624 /**
625 * Gets a session for a service.
626 *
627 * @param \WP_REST_Request $request Full details about the request.
628 * @return array|\WP_Error|mixed
629 */
630 public function get_session( \WP_REST_Request $request ) {
631 $service = $request->get_param( 'service' );
632 $session_id = $request->get_param( 'session_id' );
633 $wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
634
635 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
636 $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
637 $internal_request->set_query_params( $request->get_params() );
638
639 return rest_do_request( $internal_request );
640 }
641
642 $response = Client::wpcom_json_api_request_as_user(
643 $wpcom_path,
644 '2',
645 array(
646 'method' => 'GET',
647 )
648 );
649
650 return json_decode( wp_remote_retrieve_body( $response ), true );
651 }
652
653 /**
654 * Deletes a session for a service.
655 *
656 * @param \WP_REST_Request $request Full details about the request.
657 * @return array|\WP_Error|mixed
658 */
659 public function delete_session( \WP_REST_Request $request ) {
660 $service = $request->get_param( 'service' );
661 $session_id = $request->get_param( 'session_id' );
662 $wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
663
664 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
665 $internal_request = new \WP_REST_Request( 'DELETE', '/' . $this->namespace . $wpcom_path );
666 $internal_request->set_query_params( $request->get_params() );
667
668 return rest_do_request( $internal_request );
669 }
670
671 $response = Client::wpcom_json_api_request_as_user(
672 $wpcom_path,
673 '2',
674 array(
675 'method' => 'DELETE',
676 )
677 );
678
679 return json_decode( wp_remote_retrieve_body( $response ), true );
680 }
681
682 /**
683 * Proxies media requests with proper authorization headers
684 *
685 * @param WP_REST_Request $request Full details about the request.
686 * @return WP_REST_Response|WP_Error|array Response object or WP_Error.
687 */
688 public function proxy_media_request( $request ) {
689 $params = $request->get_params();
690 $service = rawurlencode( $request->get_param( 'service' ) );
691 $wpcom_path = sprintf( '/meta/external-media/proxy/%s', $service );
692
693 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
694 $request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
695 $request->set_query_params( $params );
696
697 return rest_do_request( $request );
698
699 } else {
700 // Build query string to pass to wpcom endpoint.
701 $service_args = array_filter(
702 $params,
703 function ( $key ) {
704 return in_array( $key, array( 'url' ), true );
705 },
706 ARRAY_FILTER_USE_KEY
707 );
708
709 if ( ! empty( $service_args ) ) {
710 $wpcom_path .= '?' . http_build_query( $service_args );
711 }
712
713 $response = Client::wpcom_json_api_request_as_user(
714 $wpcom_path,
715 '2',
716 array(
717 'method' => 'POST',
718 )
719 );
720
721 $status_code = wp_remote_retrieve_response_code( $response );
722 $headers = wp_remote_retrieve_headers( $response );
723 $body = wp_remote_retrieve_body( $response );
724
725 // For non-200 responses, parse and return JSON error
726 if ( $status_code !== 200 ) {
727 $error_data = json_decode( $body, true );
728 return new \WP_REST_Response( $error_data, $status_code );
729 }
730 }
731
732 // Return binary content directly
733 $valid_headers = array(
734 'content-type',
735 'content-length',
736 'content-disposition',
737 );
738 // Set content headers
739 foreach ( $valid_headers as $header ) {
740 if ( ! empty( $headers[ $header ] ) ) {
741 header( ucwords( $header, '-' ) . ': ' . $headers[ $header ] );
742 }
743 }
744
745 // Set cache headers
746 header( 'Cache-Control: no-cache, no-store, must-revalidate' );
747 header( 'Pragma: no-cache' );
748 header( 'Expires: 0' );
749 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Media binary data
750 echo $body;
751 exit( 0 );
752 }
753
754 /**
755 * Downloads a remote media file into a temporary file for sideloading.
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 *
760 * The remote file is streamed into a randomly-named temporary file created by
761 * wp_tempnam(). The caller-supplied name is never used for the temporary file
762 * itself; it is only applied — and validated by WordPress — later, when the
763 * completed download is handed to media_handle_sideload(). This prevents a
764 * crafted name from controlling the physical path or extension of the file
765 * written to disk.
766 *
767 * @param array $guid Media information.
768 * @return string|\WP_Error Path to the downloaded temporary file, or WP_Error on failure.
769 */
770 public function get_download_url( $guid ) {
771 require_once ABSPATH . 'wp-admin/includes/file.php';
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
779 $tmp_name = wp_tempnam();
780 if ( ! $tmp_name ) {
781 return new WP_Error(
782 'rest_upload_error',
783 __( 'Could not create a temporary file.', 'jetpack' ),
784 array( 'status' => 500 )
785 );
786 }
787
788 $result = $this->stream_to_temp_file( $url, $tmp_name );
789
790 if ( is_wp_error( $result ) ) {
791 wp_delete_file( $tmp_name );
792 }
793
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 )
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;
863 }
864
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 );
898 }
899
900 /**
901 * Uploads media file and creates attachment object.
902 *
903 * @param string $file_name Name of media file.
904 * @param string $download_url Download URL.
905 * @param int $post_id The ID of the post to attach the image to.
906 *
907 * @return int|\WP_Error
908 */
909 public function sideload_media( $file_name, $download_url, $post_id = 0 ) {
910 $file = array(
911 'name' => sanitize_file_name( wp_basename( $file_name ) ),
912 'tmp_name' => $download_url,
913 );
914
915 $id = media_handle_sideload( $file, $post_id, null );
916 if ( is_wp_error( $id ) ) {
917 wp_delete_file( $file['tmp_name'] );
918 $id->add_data( array( 'status' => 400 ) );
919 }
920
921 return $id;
922 }
923
924 /**
925 * Updates attachment meta data for media item.
926 *
927 * @param int $id Attachment ID.
928 * @param array $item Media item.
929 */
930 public function update_attachment_meta( $id, $item ) {
931 $meta = wp_get_attachment_metadata( $id );
932 $meta['image_meta']['title'] = $item['title'];
933 $meta['image_meta']['caption'] = $item['caption'];
934
935 wp_update_attachment_metadata( $id, $meta );
936
937 update_post_meta( $id, '_wp_attachment_image_alt', $item['title'] );
938 wp_update_post(
939 array(
940 'ID' => $id,
941 'post_excerpt' => $item['caption'],
942 )
943 );
944
945 if ( ! empty( $item['meta'] ) ) {
946 foreach ( $item['meta'] as $meta_key => $meta_value ) {
947 update_post_meta( $id, $meta_key, $meta_value );
948 }
949 }
950 }
951
952 /**
953 * Retrieves attachment data for media item.
954 *
955 * @param int $id Attachment ID.
956 * @param array $item Media item.
957 *
958 * @return array|\WP_REST_Response Attachment data on success, WP_Error on failure.
959 */
960 public function get_attachment_data( $id, $item ) {
961 $image_src = wp_get_attachment_image_src( $id, 'full' );
962
963 if ( empty( $image_src[0] ) ) {
964 $response = new WP_Error(
965 'rest_upload_error',
966 __( 'Could not retrieve source URL.', 'jetpack' ),
967 array( 'status' => 400 )
968 );
969 } else {
970 $response = array(
971 'id' => $id,
972 'caption' => $item['caption'],
973 'alt' => $item['title'],
974 'type' => 'image',
975 'url' => $image_src[0],
976 );
977 }
978
979 return $response;
980 }
981
982 /**
983 * Get the wp filesystem.
984 *
985 * @return \WP_Filesystem_Base|null
986 */
987 private function get_wp_filesystem() {
988 global $wp_filesystem;
989
990 if ( ! isset( $wp_filesystem ) ) {
991 require_once ABSPATH . '/wp-admin/includes/file.php';
992 WP_Filesystem();
993 }
994
995 return $wp_filesystem;
996 }
997 }
998
999 wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_External_Media' );
1000