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
← All changes | _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-external-media.php +999 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,999 @@
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' );