PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 14.4
Jetpack – WP Security, Backup, Speed, & Growth v14.4
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 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 14.3.1 All 501 releases
jetpack / class.json-api-endpoints.php

class.json-api-endpoints.php in Jetpack – WP Security, Backup, Speed, & Growth 14.4, at class.json-api-endpoints.php

2,891 lines 90.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 /**
3 * Jetpack API endpoint base class.
4 *
5 * @package automattic/jetpack
6 */
7
8 use Automattic\Jetpack\Connection\Client;
9 use Automattic\Jetpack\Connection\Manager;
10 use Automattic\Jetpack\Connection\Rest_Authentication;
11 use Automattic\Jetpack\Connection\Tokens;
12 use Automattic\Jetpack\Status;
13 use Automattic\Jetpack\Status\Host;
14
15 require_once __DIR__ . '/json-api-config.php';
16 require_once __DIR__ . '/sal/class.json-api-links.php';
17 require_once __DIR__ . '/sal/class.json-api-metadata.php';
18 require_once __DIR__ . '/sal/class.json-api-date.php';
19
20 /**
21 * Endpoint.
22 */
23 abstract class WPCOM_JSON_API_Endpoint {
24 /**
25 * The API Object
26 *
27 * @var WPCOM_JSON_API
28 */
29 public $api;
30
31 /**
32 * The link-generating utility class
33 *
34 * @var WPCOM_JSON_API_Links
35 */
36 public $links;
37
38 /**
39 * Whether to pass wpcom user details.
40 *
41 * @var bool
42 */
43 public $pass_wpcom_user_details = false;
44
45 /**
46 * One liner.
47 *
48 * @var string
49 */
50 public $description;
51
52 /**
53 * Object Grouping For Documentation (Users, Posts, Comments)
54 *
55 * @var string
56 */
57 public $group;
58
59 /**
60 * Stats extra value to bump
61 *
62 * @var mixed
63 */
64 public $stat;
65
66 /**
67 * HTTP Method
68 *
69 * @var string
70 */
71 public $method = 'GET';
72
73 /**
74 * Minimum version of the api for which to serve this endpoint
75 *
76 * @var string
77 */
78 public $min_version = '0';
79
80 /**
81 * Maximum version of the api for which to serve this endpoint
82 *
83 * @var string
84 * @phan-suppress PhanUndeclaredConstant -- https://github.com/phan/phan/issues/4855
85 */
86 public $max_version = WPCOM_JSON_API__CURRENT_VERSION;
87
88 /**
89 * Forced endpoint environment when running on WPCOM
90 *
91 * @var string '', 'wpcom', 'secure', or 'jetpack'
92 */
93 public $force = '';
94
95 /**
96 * Whether the endpoint is deprecated
97 *
98 * @var bool
99 */
100 public $deprecated = false;
101
102 /**
103 * Version of the endpoint this endpoint is deprecated in favor of.
104 *
105 * @var string
106 * @phan-suppress PhanUndeclaredConstant -- https://github.com/phan/phan/issues/4855
107 */
108 protected $new_version = WPCOM_JSON_API__CURRENT_VERSION;
109
110 /**
111 * Whether the endpoint is only available on WordPress.com hosted blogs
112 *
113 * @var bool
114 */
115 public $jp_disabled = false;
116
117 /**
118 * Path at which to serve this endpoint: sprintf() format.
119 *
120 * @var string
121 */
122 public $path = '';
123
124 /**
125 * Identifiers to fill sprintf() formatted $path
126 *
127 * @var array
128 */
129 public $path_labels = array();
130
131 /**
132 * The REST endpoint if available.
133 *
134 * @var string
135 */
136 public $rest_route;
137
138 /**
139 * Jetpack Version in which REST support was introduced.
140 *
141 * @var string
142 */
143 public $rest_min_jp_version;
144
145 /**
146 * Accepted query parameters
147 *
148 * @var array
149 */
150 public $query = array(
151 // Parameter name.
152 'context' => array(
153 // Default value => description.
154 'display' => 'Formats the output as HTML for display. Shortcodes are parsed, paragraph tags are added, etc..',
155 // Other possible values => description.
156 'edit' => 'Formats the output for editing. Shortcodes are left unparsed, significant whitespace is kept, etc..',
157 ),
158 'http_envelope' => array(
159 'false' => '',
160 'true' => 'Some environments (like in-browser JavaScript or Flash) block or divert responses with a non-200 HTTP status code. Setting this parameter will force the HTTP status code to always be 200. The JSON response is wrapped in an "envelope" containing the "real" HTTP status code and headers.',
161 ),
162 'pretty' => array(
163 'false' => '',
164 'true' => 'Output pretty JSON',
165 ),
166 'meta' => "(string) Optional. Loads data from the endpoints found in the 'meta' part of the response. Comma-separated list. Example: meta=site,likes",
167 'fields' => '(string) Optional. Returns specified fields only. Comma-separated list. Example: fields=ID,title',
168 // Parameter name => description (default value is empty).
169 'callback' => '(string) An optional JSONP callback function.',
170 );
171
172 /**
173 * Response format
174 *
175 * @var array
176 */
177 public $response_format = array();
178
179 /**
180 * Request format
181 *
182 * @var array
183 */
184 public $request_format = array();
185
186 /**
187 * Is this endpoint still in testing phase? If so, not available to the public.
188 *
189 * @var bool
190 */
191 public $in_testing = false;
192
193 /**
194 * Is this endpoint still allowed if the site in question is flagged?
195 *
196 * @var bool
197 */
198 public $allowed_if_flagged = false;
199
200 /**
201 * Is this endpoint allowed if the site is red flagged?
202 *
203 * @var bool
204 */
205 public $allowed_if_red_flagged = false;
206
207 /**
208 * Is this endpoint allowed if the site is deleted?
209 *
210 * @var bool
211 */
212 public $allowed_if_deleted = false;
213
214 /**
215 * Version of the API
216 *
217 * @var string
218 */
219 public $version = '';
220
221 /**
222 * Example request to make
223 *
224 * @var string
225 */
226 public $example_request = '';
227
228 /**
229 * Example request data (for POST methods)
230 *
231 * @var string
232 */
233 public $example_request_data = '';
234
235 /**
236 * Example response from $example_request
237 *
238 * @var string
239 */
240 public $example_response = '';
241
242 /**
243 * OAuth2 scope required when running on WPCOM
244 *
245 * @var string
246 */
247 public $required_scope = '';
248
249 /**
250 * Set to true if the endpoint implements its own filtering instead of the standard `fields` query method
251 *
252 * @var bool
253 */
254 public $custom_fields_filtering = false;
255
256 /**
257 * Set to true if the endpoint accepts all cross origin requests. You probably should not set this flag.
258 *
259 * @var bool
260 */
261 public $allow_cross_origin_request = false;
262
263 /**
264 * Set to true if the endpoint can recieve unauthorized POST requests.
265 *
266 * @var bool
267 */
268 public $allow_unauthorized_request = false;
269
270 /**
271 * Set to true if the endpoint should accept site based (not user based) authentication.
272 *
273 * @var bool
274 */
275 public $allow_jetpack_site_auth = false;
276
277 /**
278 * Set to true if the endpoint should accept auth from an upload token.
279 *
280 * @var bool
281 */
282 public $allow_upload_token_auth = false;
283
284 /**
285 * Set to true if the endpoint should require auth from a Rewind auth token.
286 *
287 * @var bool
288 */
289 public $require_rewind_auth = false;
290
291 /**
292 * Whether this endpoint allows falling back to a blog token for making requests to remote Jetpack sites.
293 *
294 * @var bool
295 */
296 public $allow_fallback_to_jetpack_blog_token = false;
297
298 /**
299 * REST namespace.
300 */
301 const REST_NAMESPACE = 'jetpack/rest';
302
303 /**
304 * Post object format.
305 *
306 * @var array
307 */
308 public $post_object_format;
309
310 /**
311 * Comment object format.
312 *
313 * @var array
314 */
315 public $comment_object_format;
316
317 /**
318 * Dropdown page object format.
319 *
320 * @var array
321 */
322 public $dropdown_page_object_format;
323
324 /**
325 * Constructor.
326 *
327 * @param string|array|object $args Args.
328 */
329 public function __construct( $args ) {
330 $defaults = array(
331 'in_testing' => false,
332 'allowed_if_flagged' => false,
333 'allowed_if_red_flagged' => false,
334 'allowed_if_deleted' => false,
335 'description' => '',
336 'group' => '',
337 'stat' => '',
338 'method' => 'GET',
339 'path' => '/',
340 'min_version' => '0',
341 'max_version' => WPCOM_JSON_API__CURRENT_VERSION,
342 'force' => '',
343 'deprecated' => false,
344 'new_version' => WPCOM_JSON_API__CURRENT_VERSION,
345 'jp_disabled' => false,
346 'path_labels' => array(),
347 'rest_route' => null,
348 'rest_min_jp_version' => null,
349 'request_format' => array(),
350 'response_format' => array(),
351 'query_parameters' => array(),
352 'version' => 'v1',
353 'example_request' => '',
354 'example_request_data' => '',
355 'example_response' => '',
356 'required_scope' => '',
357 'pass_wpcom_user_details' => false,
358 'custom_fields_filtering' => false,
359 'allow_cross_origin_request' => false,
360 'allow_unauthorized_request' => false,
361 'allow_jetpack_site_auth' => false,
362 'allow_upload_token_auth' => false,
363 'allow_fallback_to_jetpack_blog_token' => false,
364 );
365
366 $args = wp_parse_args( $args, $defaults );
367
368 $this->in_testing = $args['in_testing'];
369
370 $this->allowed_if_flagged = $args['allowed_if_flagged'];
371 $this->allowed_if_red_flagged = $args['allowed_if_red_flagged'];
372 $this->allowed_if_deleted = $args['allowed_if_deleted'];
373
374 $this->description = $args['description'];
375 $this->group = $args['group'];
376 $this->stat = $args['stat'];
377 $this->force = $args['force'];
378 $this->jp_disabled = $args['jp_disabled'];
379
380 $this->method = $args['method'];
381 $this->path = $args['path'];
382 $this->path_labels = $args['path_labels'];
383 $this->min_version = $args['min_version'];
384 $this->max_version = $args['max_version'];
385 $this->deprecated = $args['deprecated'];
386 $this->new_version = $args['new_version'];
387
388 $this->rest_route = $args['rest_route'];
389 $this->rest_min_jp_version = $args['rest_min_jp_version'];
390
391 // Ensure max version is not less than min version.
392 if ( version_compare( $this->min_version, $this->max_version, '>' ) ) {
393 $this->max_version = $this->min_version;
394 }
395
396 $this->pass_wpcom_user_details = $args['pass_wpcom_user_details'];
397 $this->custom_fields_filtering = (bool) $args['custom_fields_filtering'];
398
399 $this->allow_cross_origin_request = (bool) $args['allow_cross_origin_request'];
400 $this->allow_unauthorized_request = (bool) $args['allow_unauthorized_request'];
401 $this->allow_jetpack_site_auth = (bool) $args['allow_jetpack_site_auth'];
402 $this->allow_upload_token_auth = (bool) $args['allow_upload_token_auth'];
403 $this->allow_fallback_to_jetpack_blog_token = (bool) $args['allow_fallback_to_jetpack_blog_token'];
404 $this->require_rewind_auth = isset( $args['require_rewind_auth'] ) ? (bool) $args['require_rewind_auth'] : false;
405
406 $this->version = $args['version'];
407
408 $this->required_scope = $args['required_scope'];
409
410 if ( $this->request_format ) {
411 $this->request_format = array_filter( array_merge( $this->request_format, $args['request_format'] ) );
412 } else {
413 $this->request_format = $args['request_format'];
414 }
415
416 if ( $this->response_format ) {
417 $this->response_format = array_filter( array_merge( $this->response_format, $args['response_format'] ) );
418 } else {
419 $this->response_format = $args['response_format'];
420 }
421
422 if ( false === $args['query_parameters'] ) {
423 $this->query = array();
424 } elseif ( is_array( $args['query_parameters'] ) ) {
425 $this->query = array_filter( array_merge( $this->query, $args['query_parameters'] ) );
426 }
427
428 $this->api = WPCOM_JSON_API::init(); // Auto-add to WPCOM_JSON_API.
429 $this->links = WPCOM_JSON_API_Links::getInstance();
430
431 /** Example Request/Response */
432
433 // Examples for endpoint documentation request.
434 $this->example_request = $args['example_request'];
435 $this->example_request_data = $args['example_request_data'];
436 $this->example_response = $args['example_response'];
437
438 $this->api->add( $this );
439
440 if ( ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) && $this->rest_route && ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) ) {
441 $this->create_rest_route_for_endpoint();
442 }
443 }
444
445 /**
446 * Get all query args. Prefill with defaults.
447 *
448 * @param bool $return_default_values Whether to include default values in the response.
449 * @param bool $cast_and_filter Whether to cast and filter input according to the documentation.
450 * @return array
451 */
452 public function query_args( $return_default_values = true, $cast_and_filter = true ) {
453 $args = array_intersect_key( $this->api->query, $this->query );
454
455 if ( ! $cast_and_filter ) {
456 return $args;
457 }
458
459 return $this->cast_and_filter( $args, $this->query, $return_default_values );
460 }
461
462 /**
463 * Get POST body data.
464 *
465 * @param bool $return_default_values Whether to include default values in the response.
466 * @param bool $cast_and_filter Whether to cast and filter input according to the documentation.
467 * @return mixed
468 */
469 public function input( $return_default_values = true, $cast_and_filter = true ) {
470 $return = null;
471 $input = trim( (string) $this->api->post_body );
472 $content_type = (string) $this->api->content_type;
473 if ( $content_type ) {
474 list ( $content_type ) = explode( ';', $content_type );
475 }
476 $content_type = trim( $content_type );
477 switch ( $content_type ) {
478 case 'application/json':
479 case 'application/x-javascript':
480 case 'text/javascript':
481 case 'text/x-javascript':
482 case 'text/x-json':
483 case 'text/json':
484 $return = json_decode( $input, true );
485
486 if ( JSON_ERROR_NONE !== json_last_error() ) {
487 return null;
488 }
489
490 break;
491 case 'multipart/form-data':
492 // phpcs:ignore WordPress.Security.NonceVerification.Missing
493 $return = array_merge( stripslashes_deep( $_POST ), $_FILES );
494 break;
495 case 'application/x-www-form-urlencoded':
496 // attempt JSON first, since probably a curl command.
497 $return = json_decode( $input, true );
498
499 if ( $return === null ) {
500 wp_parse_str( $input, $return );
501 }
502
503 break;
504 default:
505 wp_parse_str( $input, $return );
506 break;
507 }
508
509 if ( isset( $this->api->query['force'] )
510 && 'secure' === $this->api->query['force']
511 && isset( $return['secure_key'] ) ) {
512 $this->api->post_body = $this->get_secure_body( $return['secure_key'] );
513 $this->api->query['force'] = false;
514 return $this->input( $return_default_values, $cast_and_filter );
515 }
516
517 if ( $cast_and_filter ) {
518 $return = $this->cast_and_filter( $return, $this->request_format, $return_default_values );
519 }
520 return $return;
521 }
522
523 /**
524 * Fetch a body via secure request.
525 *
526 * @param string $secure_key Key for the request.
527 * @return mixed|null API response, or null if the request failed.
528 */
529 protected function get_secure_body( $secure_key ) {
530 $response = Client::wpcom_json_api_request_as_blog(
531 sprintf( '/sites/%d/secure-request', Jetpack_Options::get_option( 'id' ) ),
532 '1.1',
533 array( 'method' => 'POST' ),
534 array( 'secure_key' => $secure_key )
535 );
536 if ( 200 !== $response['response']['code'] ) {
537 return null;
538 }
539 return json_decode( $response['body'], true );
540 }
541
542 /**
543 * Cast and filter data.
544 *
545 * @param mixed $data Data to cast and filter.
546 * @param array $documentation Documentation for keys in `$data` to keep and cast.
547 * @param bool $return_default_values Set default values from `$documentation` to process.
548 * @param bool $for_output See `$this->cast_and_filter_item()`.
549 * @return mixed Filtered data.
550 */
551 public function cast_and_filter( $data, $documentation, $return_default_values = false, $for_output = false ) {
552 $return_as_object = false;
553 if ( is_object( $data ) ) {
554 // @todo this should probably be a deep copy if $data can ever have nested objects
555 $data = (array) $data;
556 $return_as_object = true;
557 } elseif ( ! is_array( $data ) ) {
558 return $data;
559 }
560
561 $boolean_arg = array( 'false', 'true' );
562 $naeloob_arg = array( 'true', 'false' );
563
564 $return = array();
565
566 foreach ( $documentation as $key => $description ) {
567 if ( is_array( $description ) ) {
568 // String or boolean array keys only.
569 $whitelist = array_keys( $description );
570
571 if ( $whitelist === $boolean_arg || $whitelist === $naeloob_arg ) {
572 // Truthiness.
573 if ( isset( $data[ $key ] ) ) {
574 $return[ $key ] = (bool) WPCOM_JSON_API::is_truthy( $data[ $key ] );
575 } elseif ( $return_default_values ) {
576 $return[ $key ] = $whitelist === $naeloob_arg; // Default to true for naeloob_arg and false for boolean_arg.
577 }
578 } elseif ( isset( $data[ $key ] ) && isset( $description[ $data[ $key ] ] ) ) {
579 // String Key.
580 $return[ $key ] = (string) $data[ $key ];
581 } elseif ( $return_default_values ) {
582 // Default value.
583 $return[ $key ] = (string) current( $whitelist );
584 }
585
586 continue;
587 }
588
589 $types = $this->parse_types( $description );
590 $type = array_shift( $types );
591
592 // Explicit default - string and int only for now. Always set these reguardless of $return_default_values.
593 if ( isset( $type['default'] ) ) {
594 if ( ! isset( $data[ $key ] ) ) {
595 $data[ $key ] = $type['default'];
596 }
597 }
598
599 if ( ! isset( $data[ $key ] ) ) {
600 continue;
601 }
602
603 $this->cast_and_filter_item( $return, $type, $key, $data[ $key ], $types, $for_output );
604 }
605
606 if ( $return_as_object ) {
607 return (object) $return;
608 }
609
610 return $return;
611 }
612
613 /**
614 * Casts $value according to $type.
615 * Handles fallbacks for certain values of $type when $value is not that $type
616 * Currently, only handles fallback between string <-> array (two way), from string -> false (one way), and from object -> false (one way),
617 * and string -> object (one way)
618 *
619 * Handles "child types" - array:URL, object:category
620 * array:URL means an array of URLs
621 * object:category means a hash of categories
622 *
623 * Handles object typing - object>post means an object of type post
624 *
625 * @param array $return Array to assign the value into.
626 * @param string|array $type Type to cast.
627 * @param string|int $key Key in `$return` to assign the value to.
628 * @param mixed $value Value to cast.
629 * @param array $types Fallback types.
630 * @param bool $for_output Appears to affect formatting of 'date' types.
631 */
632 public function cast_and_filter_item( &$return, $type, $key, $value, $types = array(), $for_output = false ) {
633 if ( is_string( $type ) ) {
634 $type = compact( 'type' );
635 }
636
637 switch ( $type['type'] ) {
638 case 'false':
639 $return[ $key ] = false;
640 break;
641 case 'url':
642 if ( is_object( $value ) && isset( $value->url ) && str_contains( $value->url, 'https://videos.files.wordpress.com/' ) ) {
643 $value = $value->url;
644 }
645 // Check for string since esc_url_raw() expects one.
646 if ( ! is_string( $value ) ) {
647 break;
648 }
649 $return[ $key ] = (string) esc_url_raw( $value );
650 break;
651 case 'string':
652 // Fallback string -> array, or for string -> object.
653 if ( is_array( $value ) || is_object( $value ) ) {
654 if ( ! empty( $types[0] ) ) {
655 $next_type = array_shift( $types );
656 return $this->cast_and_filter_item( $return, $next_type, $key, $value, $types, $for_output );
657 }
658 }
659
660 // Fallback string -> false.
661 if ( ! is_string( $value ) ) {
662 if ( ! empty( $types[0] ) && 'false' === $types[0]['type'] ) {
663 $next_type = array_shift( $types );
664 return $this->cast_and_filter_item( $return, $next_type, $key, $value, $types, $for_output );
665 }
666 if ( is_array( $value ) ) {
667 // Give up rather than setting the value to the string 'Array'.
668 break;
669 }
670 }
671 $return[ $key ] = (string) $value;
672 break;
673 case 'html':
674 $return[ $key ] = (string) $value;
675 break;
676 case 'safehtml':
677 $return[ $key ] = wp_kses( (string) $value, wp_kses_allowed_html() );
678 break;
679 case 'zip':
680 case 'media':
681 if ( is_array( $value ) ) {
682 if ( isset( $value['name'] ) && is_array( $value['name'] ) ) {
683 // It's a $_FILES array
684 // Reformat into array of $_FILES items.
685 $files = array();
686
687 foreach ( $value['name'] as $k => $v ) {
688 $files[ $k ] = array();
689 foreach ( array_keys( $value ) as $file_key ) {
690 $files[ $k ][ $file_key ] = $value[ $file_key ][ $k ];
691 }
692 }
693
694 foreach ( $files as $k => $file ) {
695 if ( ! isset( $file['tmp_name'] ) || ! is_string( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) {
696 unset( $files[ $k ] );
697 }
698 }
699 if ( $files ) {
700 $return[ $key ] = $files;
701 }
702 } elseif ( isset( $value['tmp_name'] ) && is_string( $value['tmp_name'] ) && is_uploaded_file( $value['tmp_name'] ) ) {
703 $return[ $key ] = $value;
704 }
705 }
706 break;
707 case 'array':
708 // Fallback array -> string.
709 if ( is_string( $value ) ) {
710 if ( ! empty( $types[0] ) ) {
711 $next_type = array_shift( $types );
712 return $this->cast_and_filter_item( $return, $next_type, $key, $value, $types, $for_output );
713 }
714 }
715
716 if ( isset( $type['children'] ) ) {
717 $children = array();
718 foreach ( (array) $value as $k => $child ) {
719 $this->cast_and_filter_item( $children, $type['children'], $k, $child, array(), $for_output );
720 }
721 $return[ $key ] = (array) $children;
722 break;
723 }
724
725 $return[ $key ] = (array) $value;
726 break;
727 case 'iso 8601 datetime':
728 case 'datetime':
729 // (string)s
730 $dates = $this->parse_date( (string) $value );
731 if ( $for_output ) {
732 $return[ $key ] = $this->format_date( $dates[1], $dates[0] );
733 } else {
734 list( $return[ $key ], $return[ "{$key}_gmt" ] ) = $dates;
735 }
736 break;
737 case 'float':
738 $return[ $key ] = (float) $value;
739 break;
740 case 'int':
741 case 'integer':
742 $return[ $key ] = (int) $value;
743 break;
744 case 'bool':
745 case 'boolean':
746 $return[ $key ] = (bool) WPCOM_JSON_API::is_truthy( $value );
747 break;
748 case 'object':
749 // Fallback object -> false.
750 if ( is_scalar( $value ) || $value === null ) {
751 if ( ! empty( $types[0] ) && 'false' === $types[0]['type'] ) {
752 return $this->cast_and_filter_item( $return, 'false', $key, $value, $types, $for_output );
753 }
754 }
755
756 if ( isset( $type['children'] ) ) {
757 $children = array();
758 foreach ( (array) $value as $k => $child ) {
759 $this->cast_and_filter_item( $children, $type['children'], $k, $child, array(), $for_output );
760 }
761 $return[ $key ] = (object) $children;
762 break;
763 }
764
765 if ( isset( $type['subtype'] ) ) {
766 return $this->cast_and_filter_item( $return, $type['subtype'], $key, $value, $types, $for_output );
767 }
768
769 $return[ $key ] = (object) $value;
770 break;
771 case 'post':
772 $return[ $key ] = (object) $this->cast_and_filter( $value, $this->post_object_format, false, $for_output );
773 break;
774 case 'comment':
775 $return[ $key ] = (object) $this->cast_and_filter( $value, $this->comment_object_format, false, $for_output );
776 break;
777 case 'tag':
778 case 'category':
779 $docs = array(
780 'ID' => '(int)',
781 'name' => '(string)',
782 'slug' => '(string)',
783 'description' => '(HTML)',
784 'post_count' => '(int)',
785 'feed_url' => '(string)',
786 'meta' => '(object)',
787 );
788 if ( 'category' === $type['type'] ) {
789 $docs['parent'] = '(int)';
790 }
791 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
792 break;
793 case 'post_reference':
794 case 'comment_reference':
795 $docs = array(
796 'ID' => '(int)',
797 'type' => '(string)',
798 'title' => '(string)',
799 'link' => '(URL)',
800 );
801 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
802 break;
803 case 'geo':
804 $docs = array(
805 'latitude' => '(float)',
806 'longitude' => '(float)',
807 'address' => '(string)',
808 );
809 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
810 break;
811 case 'author':
812 $docs = array(
813 'ID' => '(int)',
814 'user_login' => '(string)',
815 'login' => '(string)',
816 'email' => '(string|false)',
817 'name' => '(string)',
818 'first_name' => '(string)',
819 'last_name' => '(string)',
820 'nice_name' => '(string)',
821 'URL' => '(URL)',
822 'avatar_URL' => '(URL)',
823 'profile_URL' => '(URL)',
824 'is_super_admin' => '(bool)',
825 'roles' => '(array:string)',
826 'ip_address' => '(string|false)',
827 'wpcom_id' => '(int|null)',
828 'wpcom_login' => '(string|null)',
829 );
830 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
831 break;
832 case 'role':
833 $docs = array(
834 'name' => '(string)',
835 'display_name' => '(string)',
836 'capabilities' => '(object:boolean)',
837 );
838 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
839 break;
840 case 'attachment':
841 $docs = array(
842 'ID' => '(int)',
843 'URL' => '(URL)',
844 'guid' => '(string)',
845 'mime_type' => '(string)',
846 'width' => '(int)',
847 'height' => '(int)',
848 'duration' => '(int)',
849 );
850 $return[ $key ] = (object) $this->cast_and_filter(
851 $value,
852 /**
853 * Filter the documentation returned for a post attachment.
854 *
855 * @module json-api
856 *
857 * @since 1.9.0
858 *
859 * @param array $docs Array of documentation about a post attachment.
860 */
861 apply_filters( 'wpcom_json_api_attachment_cast_and_filter', $docs ),
862 false,
863 $for_output
864 );
865 break;
866 case 'metadata':
867 $docs = array(
868 'id' => '(int)',
869 'key' => '(string)',
870 'value' => '(string|false|float|int|array|object)',
871 'previous_value' => '(string)',
872 'operation' => '(string)',
873 );
874 $return[ $key ] = (object) $this->cast_and_filter(
875 $value,
876 /** This filter is documented in class.json-api-endpoints.php */
877 apply_filters( 'wpcom_json_api_attachment_cast_and_filter', $docs ),
878 false,
879 $for_output
880 );
881 break;
882 case 'plugin':
883 $docs = array(
884 'id' => '(safehtml) The plugin\'s ID',
885 'slug' => '(safehtml) The plugin\'s Slug',
886 'active' => '(boolean) The plugin status.',
887 'update' => '(object) The plugin update info.',
888 'name' => '(safehtml) The name of the plugin.',
889 'plugin_url' => '(url) Link to the plugin\'s web site.',
890 'version' => '(safehtml) The plugin version number.',
891 'description' => '(safehtml) Description of what the plugin does and/or notes from the author',
892 'author' => '(safehtml) The plugin author\'s name',
893 'author_url' => '(url) The plugin author web site address',
894 'network' => '(boolean) Whether the plugin can only be activated network wide.',
895 'autoupdate' => '(boolean) Whether the plugin is auto updated',
896 'log' => '(array:safehtml) An array of update log strings.',
897 'action_links' => '(array) An array of action links that the plugin uses.',
898 );
899 $return[ $key ] = (object) $this->cast_and_filter(
900 $value,
901 /**
902 * Filter the documentation returned for a plugin.
903 *
904 * @module json-api
905 *
906 * @since 3.1.0
907 *
908 * @param array $docs Array of documentation about a plugin.
909 */
910 apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
911 false,
912 $for_output
913 );
914 break;
915 case 'plugin_v1_2':
916 $docs = class_exists( 'Jetpack_JSON_API_Get_Plugins_v1_2_Endpoint' )
917 ? Jetpack_JSON_API_Get_Plugins_v1_2_Endpoint::$_response_format
918 : Jetpack_JSON_API_Plugins_Endpoint::$_response_format_v1_2;
919 $return[ $key ] = (object) $this->cast_and_filter(
920 $value,
921 /**
922 * Filter the documentation returned for a plugin.
923 *
924 * @module json-api
925 *
926 * @since 3.1.0
927 *
928 * @param array $docs Array of documentation about a plugin.
929 */
930 apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
931 false,
932 $for_output
933 );
934 break;
935 case 'file_mod_capabilities':
936 $docs = array(
937 'reasons_modify_files_unavailable' => '(array) The reasons why files can\'t be modified',
938 'reasons_autoupdate_unavailable' => '(array) The reasons why autoupdates aren\'t allowed',
939 'modify_files' => '(boolean) true if files can be modified',
940 'autoupdate_files' => '(boolean) true if autoupdates are allowed',
941 );
942 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
943 break;
944 case 'jetpackmodule':
945 $docs = array(
946 'id' => '(string) The module\'s ID',
947 'active' => '(boolean) The module\'s status.',
948 'name' => '(string) The module\'s name.',
949 'description' => '(safehtml) The module\'s description.',
950 'sort' => '(int) The module\'s display order.',
951 'introduced' => '(string) The Jetpack version when the module was introduced.',
952 'changed' => '(string) The Jetpack version when the module was changed.',
953 'free' => '(boolean) The module\'s Free or Paid status.',
954 'module_tags' => '(array) The module\'s tags.',
955 'override' => '(string) The module\'s override. Empty if no override, otherwise \'active\' or \'inactive\'',
956 );
957 $return[ $key ] = (object) $this->cast_and_filter(
958 $value,
959 /** This filter is documented in class.json-api-endpoints.php */
960 apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
961 false,
962 $for_output
963 );
964 break;
965 case 'sharing_button':
966 $docs = array(
967 'ID' => '(string)',
968 'name' => '(string)',
969 'URL' => '(string)',
970 'icon' => '(string)',
971 'enabled' => '(bool)',
972 'visibility' => '(string)',
973 );
974 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
975 break;
976 case 'sharing_button_service':
977 $docs = array(
978 'ID' => '(string) The service identifier',
979 'name' => '(string) The service name',
980 'class_name' => '(string) Class name for custom style sharing button elements',
981 'genericon' => '(string) The Genericon unicode character for the custom style sharing button icon',
982 'preview_smart' => '(string) An HTML snippet of a rendered sharing button smart preview',
983 'preview_smart_js' => '(string) An HTML snippet of the page-wide initialization scripts used for rendering the sharing button smart preview',
984 );
985 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
986 break;
987 case 'site_keyring':
988 $docs = array(
989 'keyring_id' => '(int) Keyring ID',
990 'service' => '(string) The service name',
991 'external_user_id' => '(string) External user id for the service',
992 );
993 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
994 break;
995 case 'taxonomy':
996 $docs = array(
997 'name' => '(string) The taxonomy slug',
998 'label' => '(string) The taxonomy human-readable name',
999 'labels' => '(object) Mapping of labels for the taxonomy',
1000 'description' => '(string) The taxonomy description',
1001 'hierarchical' => '(bool) Whether the taxonomy is hierarchical',
1002 'public' => '(bool) Whether the taxonomy is public',
1003 'capabilities' => '(object) Mapping of current user capabilities for the taxonomy',
1004 );
1005 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
1006 break;
1007 case 'visibility':
1008 // This is needed to fix a bug in WPAndroid where `public: "PUBLIC"` is sent in place of `public: 1`.
1009 if ( 'public' === strtolower( $value ) ) {
1010 $return[ $key ] = 1;
1011 } elseif ( 'private' === strtolower( $value ) ) {
1012 $return[ $key ] = -1;
1013 } else {
1014 $return[ $key ] = (int) $value;
1015 }
1016 break;
1017 case 'dropdown_page':
1018 $return[ $key ] = (array) $this->cast_and_filter( $value, $this->dropdown_page_object_format, false, $for_output );
1019 break;
1020 default:
1021 $method_name = $type['type'] . '_docs';
1022 if ( method_exists( 'WPCOM_JSON_API_Jetpack_Overrides', $method_name ) ) {
1023 $docs = WPCOM_JSON_API_Jetpack_Overrides::$method_name();
1024 }
1025
1026 if ( ! empty( $docs ) ) {
1027 $return[ $key ] = (object) $this->cast_and_filter(
1028 $value,
1029 /** This filter is documented in class.json-api-endpoints.php */
1030 apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
1031 false,
1032 $for_output
1033 );
1034 } else {
1035 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error, WordPress.Security.EscapeOutput.OutputNotEscaped
1036 trigger_error( "Unknown API casting type {$type['type']}", E_USER_WARNING );
1037 }
1038 }
1039 }
1040
1041 /**
1042 * Parse types from text.
1043 *
1044 * @param string $text Text.
1045 * @return array Types.
1046 */
1047 public function parse_types( $text ) {
1048 if ( ! preg_match( '#^\(([^)]+)\)#', ltrim( $text ), $matches ) ) {
1049 return 'none';
1050 }
1051
1052 $types = explode( '|', strtolower( $matches[1] ) );
1053 $return = array();
1054 foreach ( $types as $type ) {
1055 foreach ( array(
1056 ':' => 'children',
1057 '>' => 'subtype',
1058 '=' => 'default',
1059 ) as $operator => $meaning ) {
1060 if ( str_contains( $type, $operator ) ) {
1061 $item = explode( $operator, $type, 2 );
1062 $return[] = array(
1063 'type' => $item[0],
1064 $meaning => $item[1],
1065 );
1066 continue 2;
1067 }
1068 }
1069 $return[] = compact( 'type' );
1070 }
1071
1072 return $return;
1073 }
1074
1075 /**
1076 * Checks if the endpoint is publicly displayable
1077 *
1078 * @return bool
1079 */
1080 public function is_publicly_documentable() {
1081 return '__do_not_document' !== $this->group && true !== $this->in_testing;
1082 }
1083
1084 /**
1085 * Auto generates documentation based on description, method, path, path_labels, and query parameters.
1086 * Echoes HTML.
1087 *
1088 * @param bool $show_description Whether to show the description.
1089 */
1090 public function document( $show_description = true ) {
1091 global $wpdb;
1092 $original_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : 'unset';
1093 unset( $GLOBALS['post'] );
1094
1095 $doc = $this->generate_documentation();
1096
1097 if ( $show_description ) :
1098 ?>
1099 <caption>
1100 <h1><?php echo wp_kses_post( $doc['method'] ); ?> <?php echo wp_kses_post( $doc['path_labeled'] ); ?></h1>
1101 <p><?php echo wp_kses_post( $doc['description'] ); ?></p>
1102 </caption>
1103
1104 <?php endif; ?>
1105
1106 <?php if ( true === $this->deprecated ) { ?>
1107 <p><strong>This endpoint is deprecated in favor of version <?php echo (float) $this->new_version; ?></strong></p>
1108 <?php } ?>
1109
1110 <section class="resource-info">
1111 <h2 id="apidoc-resource-info">Resource Information</h2>
1112
1113 <table class="api-doc api-doc-resource-parameters api-doc-resource">
1114
1115 <thead>
1116 <tr>
1117 <th class="api-index-title" scope="column">&nbsp;</th>
1118 <th class="api-index-title" scope="column">&nbsp;</th>
1119 </tr>
1120 </thead>
1121 <tbody>
1122
1123 <tr class="api-index-item">
1124 <th scope="row" class="parameter api-index-item-title">Method</th>
1125 <td class="type api-index-item-title"><?php echo wp_kses_post( $doc['method'] ); ?></td>
1126 </tr>
1127
1128 <tr class="api-index-item">
1129 <th scope="row" class="parameter api-index-item-title">URL</th>
1130 <?php
1131 $version = WPCOM_JSON_API__CURRENT_VERSION;
1132 if ( ! empty( $this->max_version ) ) {
1133 $version = $this->max_version;
1134 }
1135 ?>
1136 <td class="type api-index-item-title">https://public-api.wordpress.com/rest/v<?php echo (float) $version; ?><?php echo wp_kses_post( $doc['path_labeled'] ); ?></td>
1137 </tr>
1138
1139 <tr class="api-index-item">
1140 <th scope="row" class="parameter api-index-item-title">Requires authentication?</th>
1141 <?php
1142 $requires_auth = $wpdb->get_row( $wpdb->prepare( 'SELECT requires_authentication FROM rest_api_documentation WHERE `version` = %s AND `path` = %s AND `method` = %s LIMIT 1', $version, untrailingslashit( $doc['path_labeled'] ), $doc['method'] ) );
1143 ?>
1144 <td class="type api-index-item-title"><?php echo ( ! empty( $requires_auth->requires_authentication ) ? 'Yes' : 'No' ); ?></td>
1145 </tr>
1146
1147 </tbody>
1148 </table>
1149
1150 </section>
1151
1152 <?php
1153
1154 foreach ( array(
1155 'path' => 'Method Parameters',
1156 'query' => 'Query Parameters',
1157 'body' => 'Request Parameters',
1158 'response' => 'Response Parameters',
1159 ) as $doc_section_key => $label ) :
1160 $doc_section = 'response' === $doc_section_key ? $doc['response']['body'] : $doc['request'][ $doc_section_key ];
1161 if ( ! $doc_section ) {
1162 continue;
1163 }
1164
1165 $param_label = strtolower( str_replace( ' ', '-', $label ) );
1166 ?>
1167
1168 <section class="<?php echo esc_attr( $param_label ); ?>">
1169
1170 <h2 id="apidoc-<?php echo esc_attr( $doc_section_key ); ?>"><?php echo wp_kses_post( $label ); ?></h2>
1171
1172 <table class="api-doc api-doc-<?php echo esc_attr( $param_label ); ?>-parameters api-doc-<?php echo esc_attr( strtolower( str_replace( ' ', '-', $doc['group'] ) ) ); ?>">
1173
1174 <thead>
1175 <tr>
1176 <th class="api-index-title" scope="column">Parameter</th>
1177 <th class="api-index-title" scope="column">Type</th>
1178 <th class="api-index-title" scope="column">Description</th>
1179 </tr>
1180 </thead>
1181 <tbody>
1182
1183 <?php foreach ( $doc_section as $key => $item ) : ?>
1184
1185 <tr class="api-index-item">
1186 <th scope="row" class="parameter api-index-item-title"><?php echo wp_kses_post( $key ); ?></th>
1187 <td class="type api-index-item-title"><?php echo wp_kses_post( $item['type'] ); // @todo auto-link? ?></td>
1188 <td class="description api-index-item-body">
1189 <?php
1190
1191 $this->generate_doc_description( $item['description'] );
1192
1193 ?>
1194 </td>
1195 </tr>
1196
1197 <?php endforeach; ?>
1198 </tbody>
1199 </table>
1200 </section>
1201 <?php endforeach; ?>
1202
1203 <?php
1204 if ( 'unset' !== $original_post ) {
1205 $GLOBALS['post'] = $original_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
1206 }
1207 }
1208
1209 /**
1210 * `preg_replace_callback` callback to add http_build_query to php content example.
1211 *
1212 * @todo Is this used anywhere?
1213 *
1214 * @param array $matches Matches.
1215 * @return string
1216 */
1217 public function add_http_build_query_to_php_content_example( $matches ) {
1218 $trimmed_match = ltrim( $matches[0] );
1219 $pad = substr( $matches[0], 0, -1 * strlen( $trimmed_match ) );
1220 $pad = ltrim( $pad, ' ' );
1221 $return = ' ' . str_replace( "\n", "\n ", $matches[0] );
1222 return " http_build_query({$return}{$pad})";
1223 }
1224
1225 /**
1226 * Recursively generates the <dl>'s to document item descriptions.
1227 * Echoes HTML.
1228 *
1229 * @param string|array $item Post data to output, or an array of key => data mappings.
1230 */
1231 public function generate_doc_description( $item ) {
1232 if ( is_array( $item ) ) :
1233 ?>
1234
1235 <dl>
1236 <?php foreach ( $item as $description_key => $description_value ) : ?>
1237
1238 <dt><?php echo wp_kses_post( $description_key . ':' ); ?></dt>
1239 <dd><?php $this->generate_doc_description( $description_value ); ?></dd>
1240
1241 <?php endforeach; ?>
1242
1243 </dl>
1244
1245 <?php
1246 else :
1247 echo wp_kses_post( $item );
1248 endif;
1249 }
1250
1251 /**
1252 * Auto generates documentation based on description, method, path, path_labels, and query parameters.
1253 * Echoes HTML.
1254 */
1255 public function generate_documentation() {
1256 $format = str_replace( '%d', '%s', $this->path );
1257 $path_labeled = $format;
1258 if ( ! empty( $this->path_labels ) ) {
1259 $path_labeled = vsprintf( $format, array_keys( $this->path_labels ) );
1260 }
1261 $boolean_arg = array( 'false', 'true' );
1262 $naeloob_arg = array( 'true', 'false' );
1263
1264 $doc = array(
1265 'description' => $this->description,
1266 'method' => $this->method,
1267 'path_format' => $this->path,
1268 'path_labeled' => $path_labeled,
1269 'group' => $this->group,
1270 'request' => array(
1271 'path' => array(),
1272 'query' => array(),
1273 'body' => array(),
1274 ),
1275 'response' => array(
1276 'body' => array(),
1277 ),
1278 );
1279
1280 foreach ( array(
1281 'path_labels' => 'path',
1282 'query' => 'query',
1283 'request_format' => 'body',
1284 'response_format' => 'body',
1285 ) as $_property => $doc_item ) {
1286 foreach ( (array) $this->$_property as $key => $description ) {
1287 if ( is_array( $description ) ) {
1288 $description_keys = array_keys( $description );
1289 if ( $boolean_arg === $description_keys || $naeloob_arg === $description_keys ) {
1290 $type = '(bool)';
1291 } else {
1292 $type = '(string)';
1293 }
1294
1295 if ( 'response_format' !== $_property ) {
1296 // hack - don't show "(default)" in response format.
1297 reset( $description );
1298 $description_key = key( $description );
1299 $description[ $description_key ] = "(default) {$description[$description_key]}";
1300 }
1301 } else {
1302 $types = $this->parse_types( $description );
1303 $type = array();
1304 $default = '';
1305
1306 if ( 'none' === $types ) {
1307 $types = array();
1308 $types[]['type'] = 'none';
1309 }
1310
1311 foreach ( $types as $type_array ) {
1312 $type[] = $type_array['type'];
1313 if ( isset( $type_array['default'] ) ) {
1314 $default = $type_array['default'];
1315 if ( 'string' === $type_array['type'] ) {
1316 $default = "'$default'";
1317 }
1318 }
1319 }
1320 $type = '(' . implode( '|', $type ) . ')';
1321 if ( str_contains( $description, ')' ) ) {
1322 list( , $description ) = explode( ')', $description, 2 );
1323 }
1324 $description = trim( $description );
1325 if ( $default ) {
1326 $description .= " Default: $default.";
1327 }
1328 }
1329
1330 $item = compact( 'type', 'description' );
1331
1332 if ( 'response_format' === $_property ) {
1333 $doc['response'][ $doc_item ][ $key ] = $item;
1334 } else {
1335 $doc['request'][ $doc_item ][ $key ] = $item;
1336 }
1337 }
1338 }
1339
1340 return $doc;
1341 }
1342
1343 /**
1344 * Can the user view the post?
1345 *
1346 * @param int $post_id Post ID.
1347 * @return bool|WP_Error
1348 */
1349 public function user_can_view_post( $post_id ) {
1350 $post = get_post( $post_id );
1351 if ( ! $post || is_wp_error( $post ) ) {
1352 return false;
1353 }
1354
1355 if ( 'inherit' === $post->post_status ) {
1356 $parent_post = get_post( $post->post_parent );
1357 $post_status_obj = get_post_status_object( $parent_post->post_status ?? $post->post_status );
1358 } else {
1359 $post_status_obj = get_post_status_object( $post->post_status );
1360 }
1361
1362 if ( empty( $post_status_obj->public ) ) {
1363 if ( is_user_logged_in() ) {
1364 if ( ! empty( $post_status_obj->protected ) ) {
1365 if ( ! current_user_can( 'edit_post', $post->ID ) ) {
1366 return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1367 }
1368 } elseif ( ! empty( $post_status_obj->private ) ) {
1369 if ( ! current_user_can( 'read_post', $post->ID ) ) {
1370 return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1371 }
1372 } elseif ( in_array( $post->post_status, array( 'inherit', 'trash' ), true ) ) {
1373 if ( ! current_user_can( 'edit_post', $post->ID ) ) {
1374 return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1375 }
1376 } elseif ( 'auto-draft' === $post->post_status ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedElseif
1377 // allow auto-drafts.
1378 } else {
1379 return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1380 }
1381 } else {
1382 return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1383 }
1384 }
1385
1386 if (
1387 ( new Status() )->is_private_site() &&
1388 /**
1389 * Filter access to a specific post.
1390 *
1391 * @module json-api
1392 *
1393 * @since 3.4.0
1394 *
1395 * @param bool current_user_can( 'read_post', $post->ID ) Can the current user access the post.
1396 * @param WP_Post $post Post data.
1397 */
1398 ! apply_filters(
1399 'wpcom_json_api_user_can_view_post',
1400 current_user_can( 'read_post', $post->ID ),
1401 $post
1402 )
1403 ) {
1404 return new WP_Error(
1405 'unauthorized',
1406 'User cannot view post',
1407 array(
1408 'status_code' => 403,
1409 'error' => 'private_blog',
1410 )
1411 );
1412 }
1413
1414 if ( strlen( $post->post_password ) && ! current_user_can( 'edit_post', $post->ID ) ) {
1415 return new WP_Error(
1416 'unauthorized',
1417 'User cannot view password protected post',
1418 array(
1419 'status_code' => 403,
1420 'error' => 'password_protected',
1421 )
1422 );
1423 }
1424
1425 return true;
1426 }
1427
1428 /**
1429 * Returns author object.
1430 *
1431 * @param object $author user ID, user row, WP_User object, comment row, post row.
1432 * @param bool $show_email_and_ip output the author's email address and IP address?.
1433 *
1434 * @return object
1435 */
1436 public function get_author( $author, $show_email_and_ip = false ) {
1437 $is_jetpack = null;
1438 $login = null;
1439 $email = null;
1440 $name = null;
1441 $first_name = null;
1442 $last_name = null;
1443 $nice = null;
1444 $url = null;
1445 $ip_address = isset( $author->comment_author_IP ) ? $author->comment_author_IP : '';
1446 $site_id = -1;
1447
1448 if ( isset( $author->comment_author_email ) ) {
1449 $id = empty( $author->user_id ) ? 0 : (int) $author->user_id;
1450 $login = '';
1451 $email = $author->comment_author_email;
1452 $name = $author->comment_author;
1453 $first_name = '';
1454 $last_name = '';
1455 $url = $author->comment_author_url;
1456 $avatar_url = $this->api->get_avatar_url( $author );
1457 $nice = '';
1458
1459 // Add additional user data to the response if a valid user ID is available.
1460 if ( 0 < $id ) {
1461 $user = get_user_by( 'id', $id );
1462 if ( $user instanceof WP_User ) {
1463 $login = $user->user_login ?? '';
1464 $first_name = $user->first_name ?? '';
1465 $last_name = $user->last_name ?? '';
1466 $nice = $user->user_nicename ?? '';
1467 } else {
1468 trigger_error( 'Unknown user', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
1469 }
1470 }
1471
1472 // Comment author URLs and Emails are sent through wp_kses() on save, which replaces "&" with "&amp;"
1473 // "&" is the only email/URL character altered by wp_kses().
1474 foreach ( array( 'email', 'url' ) as $field ) {
1475 $$field = str_replace( '&amp;', '&', $$field );
1476 }
1477 } elseif ( $author instanceof WP_User || isset( $author->user_email ) ) {
1478 $author = $author->ID;
1479 } elseif ( isset( $author->user_id ) && $author->user_id ) {
1480 $author = $author->user_id;
1481 } elseif ( isset( $author->post_author ) ) {
1482 // then $author is a Post Object.
1483 if ( ! $author->post_author ) {
1484 return null;
1485 }
1486 /**
1487 * Filter whether the current site is a Jetpack site.
1488 *
1489 * @module json-api
1490 *
1491 * @since 3.3.0
1492 *
1493 * @param bool false Is the current site a Jetpack site. Default to false.
1494 * @param int get_current_blog_id() Blog ID.
1495 */
1496 $is_jetpack = true === apply_filters( 'is_jetpack_site', false, get_current_blog_id() );
1497 $post_id = $author->ID;
1498 if ( $is_jetpack && ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
1499 $id = get_post_meta( $post_id, '_jetpack_post_author_external_id', true );
1500 $email = get_post_meta( $post_id, '_jetpack_author_email', true );
1501 $login = '';
1502 $name = get_post_meta( $post_id, '_jetpack_author', true );
1503 $first_name = '';
1504 $last_name = '';
1505 $url = '';
1506 $nice = '';
1507 } else {
1508 $author = $author->post_author;
1509 }
1510 }
1511
1512 if ( ! isset( $id ) ) {
1513 $user = get_user_by( 'id', $author );
1514 if ( ! $user || is_wp_error( $user ) ) {
1515 trigger_error( 'Unknown user', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
1516
1517 return null;
1518 }
1519 $id = $user->ID;
1520 $email = $user->user_email;
1521 $login = $user->user_login;
1522 $name = $user->display_name;
1523 $first_name = $user->first_name;
1524 $last_name = $user->last_name;
1525 $url = $user->user_url;
1526 $nice = $user->user_nicename;
1527 }
1528 if ( defined( 'IS_WPCOM' ) && IS_WPCOM && ! $is_jetpack ) {
1529 /**
1530 * Allow customizing the blog ID returned with the author in WordPress.com REST API queries.
1531 *
1532 * @since 12.9
1533 *
1534 * @module json-api
1535 *
1536 * @param bool|int $active_blog Blog ID, or false by default.
1537 * @param int $id User ID.
1538 */
1539 $active_blog = apply_filters( 'wpcom_api_pre_get_active_blog_author', false, $id );
1540 if ( false === $active_blog ) {
1541 $active_blog = get_active_blog_for_user( $id );
1542 }
1543 if ( ! empty( $active_blog ) ) {
1544 $site_id = $active_blog->blog_id;
1545 }
1546 if ( $site_id > - 1 ) {
1547 $site_visible = (
1548 - 1 !== (int) $active_blog->public ||
1549 is_private_blog_user( $site_id, get_current_user_id() )
1550 );
1551 }
1552 $profile_url = "https://gravatar.com/{$login}";
1553 } else {
1554 $profile_url = 'https://gravatar.com/' . md5( strtolower( trim( $email ) ) );
1555 }
1556
1557 if ( ! isset( $avatar_url ) ) {
1558 $avatar_url = $this->api->get_avatar_url( $email );
1559 }
1560
1561 if ( $show_email_and_ip ) {
1562 $email = (string) $email;
1563 $ip_address = (string) $ip_address;
1564 } else {
1565 $email = false;
1566 $ip_address = false;
1567 }
1568
1569 $author = array(
1570 'ID' => (int) $id,
1571 'login' => (string) $login,
1572 'email' => $email, // string|bool.
1573 'name' => (string) $name,
1574 'first_name' => (string) $first_name,
1575 'last_name' => (string) $last_name,
1576 'nice_name' => (string) $nice,
1577 'URL' => (string) esc_url_raw( $url ),
1578 'avatar_URL' => (string) esc_url_raw( $avatar_url ),
1579 'profile_URL' => (string) esc_url_raw( $profile_url ),
1580 'ip_address' => $ip_address, // string|bool.
1581 );
1582
1583 if ( $site_id > -1 ) {
1584 $author['site_ID'] = (int) $site_id;
1585 $author['site_visible'] = $site_visible;
1586 }
1587
1588 // Only include WordPress.com user data when author_wpcom_data is enabled.
1589 $args = $this->query_args();
1590
1591 if ( ! empty( $id ) && ! empty( $args['author_wpcom_data'] ) ) {
1592 if ( ( new Host() )->is_wpcom_simple() ) {
1593 $user = get_user_by( 'id', $id );
1594 $author['wpcom_id'] = isset( $user->ID ) ? (int) $user->ID : null;
1595 $author['wpcom_login'] = $user->user_login ?? '';
1596 } else {
1597 // If this is a Jetpack site, use the connection manager to get the user data.
1598 $wpcom_user_data = ( new Manager() )->get_connected_user_data( $id );
1599 if ( $wpcom_user_data && isset( $wpcom_user_data['ID'] ) ) {
1600 $author['wpcom_id'] = (int) $wpcom_user_data['ID'];
1601 $author['wpcom_login'] = $wpcom_user_data['login'] ?? '';
1602 }
1603 }
1604 }
1605
1606 return (object) $author;
1607 }
1608
1609 /**
1610 * Get a media item.
1611 *
1612 * @param int $media_id Media post ID.
1613 * @return object|WP_Error Media item data, or WP_Error.
1614 */
1615 public function get_media_item( $media_id ) {
1616 $media_item = get_post( $media_id );
1617
1618 if ( ! $media_item || is_wp_error( $media_item ) ) {
1619 return new WP_Error( 'unknown_media', 'Unknown Media', 404 );
1620 }
1621
1622 $response = array(
1623 'id' => (string) $media_item->ID,
1624 'date' => (string) $this->format_date( $media_item->post_date_gmt, $media_item->post_date ),
1625 'parent' => $media_item->post_parent,
1626 'link' => wp_get_attachment_url( $media_item->ID ),
1627 'title' => $media_item->post_title,
1628 'caption' => $media_item->post_excerpt,
1629 'description' => $media_item->post_content,
1630 'metadata' => wp_get_attachment_metadata( $media_item->ID ),
1631 );
1632
1633 if ( defined( 'IS_WPCOM' ) && IS_WPCOM && is_array( $response['metadata'] ) && ! empty( $response['metadata']['file'] ) ) {
1634 remove_filter( '_wp_relative_upload_path', 'wpcom_wp_relative_upload_path', 10 );
1635 $response['metadata']['file'] = _wp_relative_upload_path( $response['metadata']['file'] );
1636 add_filter( '_wp_relative_upload_path', 'wpcom_wp_relative_upload_path', 10, 2 );
1637 }
1638
1639 $response['meta'] = (object) array(
1640 'links' => (object) array(
1641 'self' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_id ),
1642 'help' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_id, 'help' ),
1643 'site' => (string) $this->links->get_site_link( $this->api->get_blog_id_for_output() ),
1644 ),
1645 );
1646
1647 return (object) $response;
1648 }
1649
1650 /**
1651 * Get a v1.1 media item.
1652 *
1653 * @param int $media_id Media post ID.
1654 * @param WP_Post|null $media_item Media item.
1655 * @param string|null $file File path.
1656 * @return object|WP_Error Media item data, or WP_Error.
1657 */
1658 public function get_media_item_v1_1( $media_id, $media_item = null, $file = null ) {
1659
1660 if ( ! $media_item ) {
1661 $media_item = get_post( $media_id );
1662 }
1663
1664 if ( ! $media_item || is_wp_error( $media_item ) ) {
1665 return new WP_Error( 'unknown_media', 'Unknown Media', 404 );
1666 }
1667
1668 $attachment_file = get_attached_file( $media_item->ID );
1669
1670 $file = basename( $attachment_file ? $attachment_file : $file );
1671 $file_info = pathinfo( $file );
1672 $ext = isset( $file_info['extension'] ) ? $file_info['extension'] : null;
1673
1674 // File operations are handled differently on WordPress.com.
1675 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1676 $attachment_metadata = wp_get_attachment_metadata( $media_item->ID );
1677 $filesize = ! empty( $attachment_metadata['filesize'] )
1678 ? $attachment_metadata['filesize']
1679 : 0;
1680 } else {
1681 // For VideoPress videos, $attachment_file is the video URL.
1682 $filesize = file_exists( $attachment_file )
1683 ? filesize( $attachment_file )
1684 : 0;
1685 }
1686
1687 $response = array(
1688 'ID' => $media_item->ID,
1689 'URL' => wp_get_attachment_url( $media_item->ID ),
1690 'guid' => $media_item->guid,
1691 'date' => (string) $this->format_date( $media_item->post_date_gmt, $media_item->post_date ),
1692 'post_ID' => $media_item->post_parent,
1693 'author_ID' => (int) $media_item->post_author,
1694 'file' => $file,
1695 'mime_type' => $media_item->post_mime_type,
1696 'extension' => $ext,
1697 'title' => $media_item->post_title,
1698 'caption' => $media_item->post_excerpt,
1699 'description' => $media_item->post_content,
1700 'alt' => get_post_meta( $media_item->ID, '_wp_attachment_image_alt', true ),
1701 'icon' => wp_mime_type_icon( $media_item->ID ),
1702 'size' => size_format( (int) $filesize, 2 ),
1703 'thumbnails' => array(),
1704 );
1705
1706 if ( in_array( $ext, array( 'jpg', 'jpeg', 'png', 'gif', 'webp' ), true ) ) {
1707 $metadata = wp_get_attachment_metadata( $media_item->ID );
1708 if ( isset( $metadata['height'], $metadata['width'] ) ) {
1709 $response['height'] = $metadata['height'];
1710 $response['width'] = $metadata['width'];
1711 }
1712
1713 if ( isset( $metadata['sizes'] ) ) {
1714 /**
1715 * Filter the thumbnail sizes available for each attachment ID.
1716 *
1717 * @module json-api
1718 *
1719 * @since 3.9.0
1720 *
1721 * @param array $metadata['sizes'] Array of thumbnail sizes available for a given attachment ID.
1722 * @param string $media_id Attachment ID.
1723 */
1724 $sizes = apply_filters( 'rest_api_thumbnail_sizes', $metadata['sizes'], $media_item->ID );
1725 if ( is_array( $sizes ) ) {
1726 foreach ( $sizes as $size => $size_details ) {
1727 $response['thumbnails'][ $size ] = dirname( $response['URL'] ) . '/' . $size_details['file'];
1728 }
1729 /**
1730 * Filter the thumbnail URLs for attachment files.
1731 *
1732 * @module json-api
1733 *
1734 * @since 7.1.0
1735 *
1736 * @param array $metadata['sizes'] Array with thumbnail sizes as keys and URLs as values.
1737 */
1738 $response['thumbnails'] = apply_filters( 'rest_api_thumbnail_size_urls', $response['thumbnails'] );
1739 }
1740 }
1741
1742 if ( isset( $metadata['image_meta'] ) ) {
1743 $response['exif'] = $metadata['image_meta'];
1744 }
1745 }
1746
1747 if ( in_array( $ext, array( 'mp3', 'm4a', 'wav', 'ogg' ), true ) ) {
1748 $metadata = wp_get_attachment_metadata( $media_item->ID );
1749 $response['length'] = $metadata['length'];
1750 $response['exif'] = $metadata;
1751 }
1752
1753 $is_video = false;
1754
1755 if (
1756 in_array( $ext, array( 'ogv', 'mp4', 'mov', 'wmv', 'avi', 'mpg', '3gp', '3g2', 'm4v' ), true )
1757 || 'video/videopress' === $response['mime_type']
1758 ) {
1759 $is_video = true;
1760 }
1761
1762 if ( $is_video ) {
1763 $metadata = wp_get_attachment_metadata( $media_item->ID );
1764
1765 if ( isset( $metadata['height'], $metadata['width'] ) ) {
1766 $response['height'] = $metadata['height'];
1767 $response['width'] = $metadata['width'];
1768 }
1769
1770 if ( isset( $metadata['length'] ) ) {
1771 $response['length'] = $metadata['length'];
1772 }
1773
1774 if ( empty( $response['length'] ) && isset( $metadata['duration'] ) ) {
1775 $response['length'] = (int) $metadata['duration'];
1776 }
1777
1778 if ( empty( $response['length'] ) && isset( $metadata['videopress']['duration'] ) ) {
1779 $response['length'] = ceil( $metadata['videopress']['duration'] / 1000 );
1780 }
1781
1782 // add VideoPress info.
1783 if ( function_exists( 'video_get_info_by_blogpostid' ) ) {
1784 $info = video_get_info_by_blogpostid( $this->api->get_blog_id_for_output(), $media_item->ID );
1785
1786 // If we failed to get VideoPress info, but it exists in the meta data (for some reason)
1787 // then let's use that.
1788 if ( false === $info && isset( $metadata['videopress'] ) ) {
1789 $info = (object) $metadata['videopress'];
1790 }
1791
1792 if ( isset( $info->rating ) ) {
1793 $response['rating'] = $info->rating;
1794 }
1795
1796 if ( isset( $info->display_embed ) ) {
1797 $response['display_embed'] = (string) (int) $info->display_embed;
1798 // If not, default to metadata (for WPCOM).
1799 } elseif ( isset( $metadata['videopress']['display_embed'] ) ) {
1800 // We convert it to int then to string so that (bool) false to become "0".
1801 $response['display_embed'] = (string) (int) $metadata['videopress']['display_embed'];
1802 }
1803
1804 if ( isset( $info->allow_download ) ) {
1805 $response['allow_download'] = (string) (int) $info->allow_download;
1806 } elseif ( isset( $metadata['videopress']['allow_download'] ) ) {
1807 // We convert it to int then to string so that (bool) false to become "0".
1808 $response['allow_download'] = (string) (int) $metadata['videopress']['allow_download'];
1809 }
1810
1811 if ( isset( $info->thumbnail_generating ) ) {
1812 $response['thumbnail_generating'] = (bool) intval( $info->thumbnail_generating );
1813 } elseif ( isset( $metadata['videopress']['thumbnail_generating'] ) ) {
1814 $response['thumbnail_generating'] = (bool) intval( $metadata['videopress']['thumbnail_generating'] );
1815 }
1816
1817 if ( isset( $info->privacy_setting ) ) {
1818 $response['privacy_setting'] = (int) $info->privacy_setting;
1819 } elseif ( isset( $metadata['videopress']['privacy_setting'] ) ) {
1820 $response['privacy_setting'] = (int) $metadata['videopress']['privacy_setting'];
1821 }
1822
1823 $thumbnail_query_data = array();
1824 if ( ! empty( $info ) && function_exists( 'video_is_private' ) && video_is_private( $info ) ) {
1825 $thumbnail_query_data['metadata_token'] = video_generate_auth_token( $info );
1826 }
1827
1828 // Thumbnails.
1829 if ( function_exists( 'video_format_done' ) && function_exists( 'video_image_url_by_guid' ) ) {
1830 $response['thumbnails'] = array(
1831 'fmt_hd' => '',
1832 'fmt_dvd' => '',
1833 'fmt_std' => '',
1834 );
1835 foreach ( $response['thumbnails'] as $size => $thumbnail_url ) {
1836 if ( video_format_done( $info, $size ) ) {
1837 $response['thumbnails'][ $size ] = \add_query_arg( $thumbnail_query_data, \video_image_url_by_guid( $info->guid, $size ) );
1838 } else {
1839 unset( $response['thumbnails'][ $size ] );
1840 }
1841 }
1842 }
1843
1844 if ( isset( $info->title ) ) {
1845 $response['title'] = $info->title;
1846 }
1847
1848 // If we didn't get VideoPress information (for some reason) then let's
1849 // not try and include it in the response.
1850 if ( isset( $info->guid ) ) {
1851 $response['videopress_guid'] = $info->guid;
1852 $response['videopress_processing_done'] = true;
1853 if ( '0000-00-00 00:00:00' === $info->finish_date_gmt ) {
1854 $response['videopress_processing_done'] = false;
1855 }
1856 }
1857 }
1858 }
1859
1860 $response['thumbnails'] = (object) $response['thumbnails'];
1861
1862 $response['meta'] = (object) array(
1863 'links' => (object) array(
1864 'self' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_item->ID ),
1865 'help' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_item->ID, 'help' ),
1866 'site' => (string) $this->links->get_site_link( $this->api->get_blog_id_for_output() ),
1867 ),
1868 );
1869
1870 // add VideoPress link to the meta.
1871 if ( isset( $response['videopress_guid'] ) ) {
1872 if ( function_exists( 'video_get_info_by_blogpostid' ) ) {
1873 $response['meta']->links->videopress = (string) $this->links->get_link( '/videos/%s', $response['videopress_guid'], '' );
1874 }
1875 }
1876
1877 if ( $media_item->post_parent > 0 ) {
1878 $response['meta']->links->parent = (string) $this->links->get_post_link( $this->api->get_blog_id_for_output(), $media_item->post_parent );
1879 }
1880
1881 return (object) $response;
1882 }
1883
1884 /**
1885 * Get a formatted taxonomy.
1886 *
1887 * @param int $taxonomy_id Taxonomy ID.
1888 * @param string $taxonomy_type Name of taxonomy.
1889 * @param string $context Context, 'edit' or 'display'.
1890 * @return object|WP_Error
1891 */
1892 public function get_taxonomy( $taxonomy_id, $taxonomy_type, $context ) {
1893
1894 $taxonomy = get_term_by( 'slug', $taxonomy_id, $taxonomy_type );
1895 // keep updating this function.
1896 if ( ! $taxonomy || is_wp_error( $taxonomy ) ) {
1897 return new WP_Error( 'unknown_taxonomy', 'Unknown taxonomy', 404 );
1898 }
1899
1900 return $this->format_taxonomy( $taxonomy, $taxonomy_type, $context );
1901 }
1902
1903 /**
1904 * Format a taxonomy.
1905 *
1906 * @param WP_Term $taxonomy Taxonomy.
1907 * @param string $taxonomy_type Name of taxonomy.
1908 * @param string $context Context, 'edit' or 'display'.
1909 * @return object|WP_Error
1910 */
1911 public function format_taxonomy( $taxonomy, $taxonomy_type, $context ) {
1912 // Permissions.
1913 switch ( $context ) {
1914 case 'edit':
1915 $tax = get_taxonomy( $taxonomy_type );
1916 if ( ! current_user_can( $tax->cap->edit_terms ) ) {
1917 return new WP_Error( 'unauthorized', 'User cannot edit taxonomy', 403 );
1918 }
1919 break;
1920 case 'display':
1921 if ( ( new Status() )->is_private_site() && ! current_user_can( 'read' ) ) {
1922 return new WP_Error( 'unauthorized', 'User cannot view taxonomy', 403 );
1923 }
1924 break;
1925 default:
1926 return new WP_Error( 'invalid_context', 'Invalid API CONTEXT', 400 );
1927 }
1928
1929 $response = array();
1930 $response['ID'] = (int) $taxonomy->term_id;
1931 $response['name'] = (string) $taxonomy->name;
1932 $response['slug'] = (string) $taxonomy->slug;
1933 $response['description'] = (string) $taxonomy->description;
1934 $response['post_count'] = (int) $taxonomy->count;
1935 $response['feed_url'] = get_term_feed_link( $taxonomy->term_id, $taxonomy_type );
1936
1937 if ( is_taxonomy_hierarchical( $taxonomy_type ) ) {
1938 $response['parent'] = (int) $taxonomy->parent;
1939 }
1940
1941 $response['meta'] = (object) array(
1942 'links' => (object) array(
1943 'self' => (string) $this->links->get_taxonomy_link( $this->api->get_blog_id_for_output(), $taxonomy->slug, $taxonomy_type ),
1944 'help' => (string) $this->links->get_taxonomy_link( $this->api->get_blog_id_for_output(), $taxonomy->slug, $taxonomy_type, 'help' ),
1945 'site' => (string) $this->links->get_site_link( $this->api->get_blog_id_for_output() ),
1946 ),
1947 );
1948
1949 return (object) $response;
1950 }
1951
1952 /**
1953 * Returns ISO 8601 formatted datetime: 2011-12-08T01:15:36-08:00
1954 *
1955 * @param string $date_gmt GMT datetime string.
1956 * @param string $date Optional. Used to calculate the offset from GMT.
1957 * @return string
1958 */
1959 public function format_date( $date_gmt, $date = null ) {
1960 return WPCOM_JSON_API_Date::format_date( $date_gmt, $date );
1961 }
1962
1963 /**
1964 * Parses a date string and returns the local and GMT representations
1965 * of that date & time in 'YYYY-MM-DD HH:MM:SS' format without
1966 * timezones or offsets. If the parsed datetime was not localized to a
1967 * particular timezone or offset we will assume it was given in GMT
1968 * relative to now and will convert it to local time using either the
1969 * timezone set in the options table for the blog or the GMT offset.
1970 *
1971 * @param string $date_string Date to parse.
1972 *
1973 * @return array{string,string} ( $local_time_string, $gmt_time_string )
1974 */
1975 public function parse_date( $date_string ) {
1976 $date_string_info = date_parse( $date_string );
1977 if ( is_array( $date_string_info ) && 0 === $date_string_info['error_count'] ) {
1978 // Check if it's already localized. Can't just check is_localtime because date_parse('oppossum') returns true; WTF, PHP.
1979 if ( isset( $date_string_info['zone'] ) && true === $date_string_info['is_localtime'] ) {
1980 $dt_utc = new DateTime( $date_string );
1981 $dt_local = clone $dt_utc;
1982 $dt_utc->setTimezone( new DateTimeZone( 'UTC' ) );
1983 return array(
1984 (string) $dt_local->format( 'Y-m-d H:i:s' ),
1985 (string) $dt_utc->format( 'Y-m-d H:i:s' ),
1986 );
1987 }
1988
1989 // It's parseable but no TZ info so assume UTC.
1990 $dt_utc = new DateTime( $date_string, new DateTimeZone( 'UTC' ) );
1991 $dt_local = clone $dt_utc;
1992 } else {
1993 // Could not parse time, use now in UTC.
1994 $dt_utc = new DateTime( 'now', new DateTimeZone( 'UTC' ) );
1995 $dt_local = clone $dt_utc;
1996 }
1997
1998 $dt_local->setTimezone( wp_timezone() );
1999
2000 return array(
2001 (string) $dt_local->format( 'Y-m-d H:i:s' ),
2002 (string) $dt_utc->format( 'Y-m-d H:i:s' ),
2003 );
2004 }
2005
2006 /**
2007 * Load the functions.php file for the current theme to get its post formats, CPTs, etc.
2008 */
2009 public function load_theme_functions() {
2010 if ( false === defined( 'STYLESHEETPATH' ) ) {
2011 wp_templating_constants();
2012 }
2013
2014 // bail if we've done this already (can happen when calling /batch endpoint).
2015 if ( defined( 'REST_API_THEME_FUNCTIONS_LOADED' ) ) {
2016 return;
2017 }
2018
2019 // VIP context loading is handled elsewhere, so bail to prevent
2020 // duplicate loading. See `switch_to_blog_and_validate_user()`.
2021 if ( defined( 'WPCOM_IS_VIP_ENV' ) && WPCOM_IS_VIP_ENV ) {
2022 return;
2023 }
2024
2025 $do_check_theme =
2026 defined( 'REST_API_TEST_REQUEST' ) && REST_API_TEST_REQUEST ||
2027 defined( 'IS_WPCOM' ) && IS_WPCOM;
2028
2029 if ( $do_check_theme && ! wpcom_should_load_theme_files_on_rest_api() ) {
2030 return;
2031 }
2032
2033 define( 'REST_API_THEME_FUNCTIONS_LOADED', true );
2034
2035 // the theme info we care about is found either within functions.php or one of the jetpack files.
2036 $function_files = array( '/functions.php', '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php' );
2037
2038 $copy_dirs = array( get_template_directory() );
2039
2040 // Is this a child theme? Load the child theme's functions file.
2041 if ( get_stylesheet_directory() !== get_template_directory() && wpcom_is_child_theme() ) {
2042 foreach ( $function_files as $function_file ) {
2043 if ( file_exists( get_stylesheet_directory() . $function_file ) ) {
2044 require_once get_stylesheet_directory() . $function_file;
2045 }
2046 }
2047 $copy_dirs[] = get_stylesheet_directory();
2048 }
2049
2050 foreach ( $function_files as $function_file ) {
2051 if ( file_exists( get_template_directory() . $function_file ) ) {
2052 require_once get_template_directory() . $function_file;
2053 }
2054 }
2055
2056 // add inc/wpcom.php and/or includes/wpcom.php.
2057 wpcom_load_theme_compat_file();
2058
2059 // Enable including additional directories or files in actions to be copied.
2060 $copy_dirs = apply_filters( 'restapi_theme_action_copy_dirs', $copy_dirs );
2061
2062 // since the stuff we care about (CPTS, post formats, are usually on setup or init hooks, we want to load those).
2063 $this->copy_hooks( 'after_setup_theme', 'restapi_theme_after_setup_theme', $copy_dirs );
2064
2065 /**
2066 * Fires functions hooked onto `after_setup_theme` by the theme for the purpose of the REST API.
2067 *
2068 * The REST API does not load the theme when processing requests.
2069 * To enable theme-based functionality, the API will load the '/functions.php',
2070 * '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php files
2071 * of the theme (parent and child) and copy functions hooked onto 'after_setup_theme' within those files.
2072 *
2073 * @module json-api
2074 *
2075 * @since 3.2.0
2076 */
2077 do_action( 'restapi_theme_after_setup_theme' );
2078 $this->copy_hooks( 'init', 'restapi_theme_init', $copy_dirs );
2079
2080 /**
2081 * Fires functions hooked onto `init` by the theme for the purpose of the REST API.
2082 *
2083 * The REST API does not load the theme when processing requests.
2084 * To enable theme-based functionality, the API will load the '/functions.php',
2085 * '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php files
2086 * of the theme (parent and child) and copy functions hooked onto 'init' within those files.
2087 *
2088 * @module json-api
2089 *
2090 * @since 3.2.0
2091 */
2092 do_action( 'restapi_theme_init' );
2093 }
2094
2095 /**
2096 * Copy hook functions.
2097 *
2098 * @param string $from_hook Hook to copy from.
2099 * @param string $to_hook Hook to copy to.
2100 * @param array $base_paths Only copy hooks defined in the specified paths.
2101 */
2102 public function copy_hooks( $from_hook, $to_hook, $base_paths ) {
2103 global $wp_filter;
2104 foreach ( $wp_filter as $hook => $actions ) {
2105
2106 if ( $from_hook !== $hook ) {
2107 continue;
2108 }
2109 if ( ! has_action( $hook ) ) {
2110 continue;
2111 }
2112
2113 foreach ( $actions as $priority => $callbacks ) {
2114 foreach ( $callbacks as $callback_data ) {
2115 $callback = $callback_data['function'];
2116
2117 // use reflection api to determine filename where function is defined.
2118 $reflection = $this->get_reflection( $callback );
2119
2120 if ( false !== $reflection ) {
2121 $file_name = $reflection->getFileName();
2122 foreach ( $base_paths as $base_path ) {
2123
2124 // only copy hooks with functions which are part of the specified files.
2125 if ( str_starts_with( $file_name, $base_path ) ) {
2126 add_action(
2127 $to_hook,
2128 $callback_data['function'],
2129 $priority,
2130 $callback_data['accepted_args']
2131 );
2132 }
2133 }
2134 }
2135 }
2136 }
2137 }
2138 }
2139
2140 /**
2141 * Get a ReflectionMethod or ReflectionFunction for the callback.
2142 *
2143 * @param callable $callback Callback.
2144 * @return ReflectionMethod|ReflectionFunction|false
2145 */
2146 public function get_reflection( $callback ) {
2147 if ( is_array( $callback ) ) {
2148 list( $class, $method ) = $callback;
2149 return new ReflectionMethod( $class, $method );
2150 }
2151
2152 if ( is_string( $callback ) && strpos( $callback, '::' ) !== false ) {
2153 list( $class, $method ) = explode( '::', $callback );
2154 return new ReflectionMethod( $class, $method );
2155 }
2156
2157 if ( method_exists( $callback, '__invoke' ) ) {
2158 return new ReflectionMethod( $callback, '__invoke' );
2159 }
2160
2161 if ( is_string( $callback ) && strpos( $callback, '::' ) === false && function_exists( $callback ) ) {
2162 return new ReflectionFunction( $callback );
2163 }
2164
2165 return false;
2166 }
2167
2168 /**
2169 * Check whether a user can view or edit a post type.
2170 *
2171 * @param string $post_type post type to check.
2172 * @param string $context 'display' or 'edit'.
2173 * @return bool
2174 */
2175 public function current_user_can_access_post_type( $post_type, $context = 'display' ) {
2176 $post_type_object = get_post_type_object( $post_type );
2177 if ( ! $post_type_object ) {
2178 return false;
2179 }
2180
2181 switch ( $context ) {
2182 case 'edit':
2183 return current_user_can( $post_type_object->cap->edit_posts );
2184 case 'display':
2185 return $post_type_object->public || current_user_can( $post_type_object->cap->read_private_posts );
2186 default:
2187 return false;
2188 }
2189 }
2190
2191 /**
2192 * Is the post type allowed?
2193 *
2194 * @param string $post_type Post type.
2195 * @return bool
2196 */
2197 public function is_post_type_allowed( $post_type ) {
2198 // if the post type is empty, that's fine, WordPress will default to post.
2199 if ( empty( $post_type ) ) {
2200 return true;
2201 }
2202
2203 // allow special 'any' type.
2204 if ( 'any' === $post_type ) {
2205 return true;
2206 }
2207
2208 // check for allowed types.
2209 if ( in_array( $post_type, $this->_get_whitelisted_post_types(), true ) ) {
2210 return true;
2211 }
2212
2213 $post_type_object = get_post_type_object( $post_type );
2214 if ( $post_type_object ) {
2215 if ( ! empty( $post_type_object->show_in_rest ) ) {
2216 return $post_type_object->show_in_rest;
2217 }
2218 if ( ! empty( $post_type_object->publicly_queryable ) ) {
2219 return $post_type_object->publicly_queryable;
2220 }
2221 }
2222
2223 return ! empty( $post_type_object->public );
2224 }
2225
2226 /**
2227 * Gets the whitelisted post types that JP should allow access to.
2228 *
2229 * @return array Whitelisted post types.
2230 */
2231 protected function _get_whitelisted_post_types() { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore -- Legacy.
2232 $allowed_types = array( 'post', 'page', 'revision' );
2233
2234 /**
2235 * Filter the post types Jetpack has access to, and can synchronize with WordPress.com.
2236 *
2237 * @module json-api
2238 *
2239 * @since 2.2.3
2240 *
2241 * @param array $allowed_types Array of whitelisted post types. Default to `array( 'post', 'page', 'revision' )`.
2242 */
2243 $allowed_types = apply_filters( 'rest_api_allowed_post_types', $allowed_types );
2244
2245 return array_unique( $allowed_types );
2246 }
2247
2248 /**
2249 * Mobile apps are allowed free video uploads, but limited to 5 minutes in length.
2250 *
2251 * @param array $media_item the media item to evaluate.
2252 *
2253 * @return bool true if the media item is a video that was uploaded via the mobile
2254 * app that is longer than 5 minutes.
2255 */
2256 public function media_item_is_free_video_mobile_upload_and_too_long( $media_item ) {
2257 if ( ! $media_item ) {
2258 return false;
2259 }
2260
2261 // Verify file is a video.
2262 $is_video = preg_match( '@^video/@', $media_item['type'] );
2263 if ( ! $is_video ) {
2264 return false;
2265 }
2266
2267 // Check if the request is from a mobile app, where we allow free video uploads at limited length.
2268 if ( ! in_array( $this->api->token_details['client_id'], VIDEOPRESS_ALLOWED_REST_API_CLIENT_IDS, true ) ) {
2269 return false;
2270 }
2271
2272 // We're only worried about free sites.
2273 require_once WP_CONTENT_DIR . '/admin-plugins/wpcom-billing.php';
2274 $current_plan = WPCOM_Store_API::get_current_plan( get_current_blog_id() );
2275 if ( ! $current_plan['is_free'] ) {
2276 return false;
2277 }
2278
2279 // We don't know if this is an upload or a sideload, but in either case the tmp_name should be a path, not a URL.
2280 if ( wp_parse_url( $media_item['tmp_name'], PHP_URL_SCHEME ) !== null ) {
2281 return false;
2282 }
2283
2284 // Check if video is longer than 5 minutes.
2285 $video_meta = wp_read_video_metadata( $media_item['tmp_name'] );
2286 if (
2287 false !== $video_meta &&
2288 isset( $video_meta['length'] ) &&
2289 5 * MINUTE_IN_SECONDS < $video_meta['length']
2290 ) {
2291 videopress_log(
2292 'videopress_app_upload_length_block',
2293 'Mobile app upload on free site blocked because length was longer than 5 minutes.',
2294 null,
2295 null,
2296 null,
2297 null,
2298 array(
2299 'blog_id' => get_current_blog_id(),
2300 'user_id' => get_current_user_id(),
2301 )
2302 );
2303 return true;
2304 }
2305
2306 return false;
2307 }
2308
2309 /**
2310 * Handle a v1.1 media creation.
2311 *
2312 * Only one of $media_files and $media_urls should be non-empty.
2313 *
2314 * @param array $media_files File upload data.
2315 * @param array $media_urls URLs to fetch.
2316 * @param array $media_attrs Attributes corresponding to each entry in `$media_files`/`$media_urls`.
2317 * @param int|false $force_parent_id Force the parent ID, overriding `$media_attrs[]['parent_id']`.
2318 * @return array Two items:
2319 * - media_ids: IDs created, by index in `$media_files`/`$media_urls`.
2320 * - errors: Errors encountered, by index in `$media_files`/`$media_urls`.
2321 */
2322 public function handle_media_creation_v1_1( $media_files, $media_urls, $media_attrs = array(), $force_parent_id = false ) {
2323
2324 add_filter( 'upload_mimes', array( $this, 'allow_video_uploads' ) );
2325
2326 $media_ids = array();
2327 $errors = array();
2328 $user_can_upload_files = current_user_can( 'upload_files' ) || $this->api->is_authorized_with_upload_token();
2329 $media_attrs = array_values( $media_attrs ); // reset the keys.
2330 $i = 0;
2331
2332 if ( ! empty( $media_files ) ) {
2333 $this->api->trap_wp_die( 'upload_error' );
2334 foreach ( $media_files as $media_item ) {
2335 $_FILES['.api.media.item.'] = $media_item;
2336
2337 if ( ! $user_can_upload_files ) {
2338 $media_id = new WP_Error( 'unauthorized', 'User cannot upload media.', 403 );
2339 } elseif ( ! is_array( $media_item ) ) {
2340 $media_id = new WP_Error( 'invalid_input', 'Unable to process request.', 400 );
2341 $media_item = array(
2342 'name' => 'invalid_file',
2343 );
2344 } elseif ( $this->media_item_is_free_video_mobile_upload_and_too_long( $media_item ) ) {
2345 $media_id = new WP_Error( 'upload_video_length', 'Video uploads longer than 5 minutes require a paid plan.', 400 );
2346 } else {
2347 if ( $force_parent_id ) {
2348 $parent_id = absint( $force_parent_id );
2349 } elseif ( ! empty( $media_attrs[ $i ] ) && ! empty( $media_attrs[ $i ]['parent_id'] ) ) {
2350 $parent_id = absint( $media_attrs[ $i ]['parent_id'] );
2351 } else {
2352 $parent_id = 0;
2353 }
2354 $media_id = media_handle_upload( '.api.media.item.', $parent_id );
2355 }
2356 if ( is_wp_error( $media_id ) ) {
2357 $errors[ $i ]['file'] = $media_item['name'];
2358 $errors[ $i ]['error'] = $media_id->get_error_code();
2359 $errors[ $i ]['message'] = $media_id->get_error_message();
2360 } else {
2361 $media_ids[ $i ] = $media_id;
2362 }
2363
2364 ++$i;
2365 }
2366 $this->api->trap_wp_die( null );
2367 unset( $_FILES['.api.media.item.'] );
2368 }
2369
2370 if ( ! empty( $media_urls ) ) {
2371 foreach ( $media_urls as $url ) {
2372 if ( ! $user_can_upload_files ) {
2373 $media_id = new WP_Error( 'unauthorized', 'User cannot upload media.', 403 );
2374 } else {
2375 if ( $force_parent_id ) {
2376 $parent_id = absint( $force_parent_id );
2377 } elseif ( ! empty( $media_attrs[ $i ] ) && ! empty( $media_attrs[ $i ]['parent_id'] ) ) {
2378 $parent_id = absint( $media_attrs[ $i ]['parent_id'] );
2379 } else {
2380 $parent_id = 0;
2381 }
2382 $media_id = $this->handle_media_sideload( $url, $parent_id );
2383 }
2384 if ( is_wp_error( $media_id ) ) {
2385 $errors[ $i ] = array(
2386 'file' => $url,
2387 'error' => $media_id->get_error_code(),
2388 'message' => $media_id->get_error_message(),
2389 );
2390 } elseif ( ! empty( $media_id ) ) {
2391 $media_ids[ $i ] = $media_id;
2392 }
2393
2394 ++$i;
2395 }
2396 }
2397
2398 if ( ! empty( $media_attrs ) ) {
2399 foreach ( $media_ids as $index => $media_id ) {
2400 if ( empty( $media_attrs[ $index ] ) ) {
2401 continue;
2402 }
2403
2404 $attrs = $media_attrs[ $index ];
2405 $insert = array();
2406
2407 // Attributes: Title, Caption, Description.
2408
2409 if ( isset( $attrs['title'] ) ) {
2410 $insert['post_title'] = $attrs['title'];
2411 }
2412
2413 if ( isset( $attrs['caption'] ) ) {
2414 $insert['post_excerpt'] = $attrs['caption'];
2415 }
2416
2417 if ( isset( $attrs['description'] ) ) {
2418 $insert['post_content'] = $attrs['description'];
2419 }
2420
2421 if ( ! empty( $insert ) ) {
2422 $insert['ID'] = $media_id;
2423 wp_update_post( (object) $insert );
2424 }
2425
2426 // Attributes: Alt.
2427
2428 if ( isset( $attrs['alt'] ) ) {
2429 $alt = wp_strip_all_tags( $attrs['alt'], true );
2430 update_post_meta( $media_id, '_wp_attachment_image_alt', $alt );
2431 }
2432
2433 // Attributes: Artist, Album.
2434
2435 $id3_meta = array();
2436
2437 foreach ( array( 'artist', 'album' ) as $key ) {
2438 if ( isset( $attrs[ $key ] ) ) {
2439 $id3_meta[ $key ] = wp_strip_all_tags( $attrs[ $key ], true );
2440 }
2441 }
2442
2443 if ( ! empty( $id3_meta ) ) {
2444 // Before updating metadata, ensure that the item is audio.
2445 $item = $this->get_media_item_v1_1( $media_id );
2446 if ( str_starts_with( $item->mime_type, 'audio/' ) ) {
2447 wp_update_attachment_metadata( $media_id, $id3_meta );
2448 }
2449 }
2450
2451 // Attributes: Meta
2452 if ( isset( $attrs['meta'] ) && isset( $attrs['meta']['vertical_id'] ) ) {
2453 update_post_meta( $media_id, 'vertical_id', $attrs['meta']['vertical_id'] );
2454 }
2455 }
2456 }
2457
2458 return array(
2459 'media_ids' => $media_ids,
2460 'errors' => $errors,
2461 );
2462 }
2463
2464 /**
2465 * Handle a media sideload.
2466 *
2467 * @param string $url URL.
2468 * @param int $parent_post_id Parent post ID.
2469 * @param string $type Type.
2470 * @return int|WP_Error|false Media post ID, or error, or false if nothing was sideloaded.
2471 */
2472 public function handle_media_sideload( $url, $parent_post_id = 0, $type = 'any' ) {
2473 if ( ! function_exists( 'download_url' ) || ! function_exists( 'media_handle_sideload' ) ) {
2474 return false;
2475 }
2476
2477 // if we didn't get a URL, let's bail.
2478 $parsed = wp_parse_url( $url );
2479 if ( empty( $parsed ) ) {
2480 return false;
2481 }
2482
2483 $tmp = download_url( $url );
2484 if ( is_wp_error( $tmp ) ) {
2485 return $tmp;
2486 }
2487
2488 // First check to see if we get a mime-type match by file, otherwise, check to
2489 // see if WordPress supports this file as an image. If neither, then it is not supported.
2490 if ( ! $this->is_file_supported_for_sideloading( $tmp ) || 'image' === $type && ! file_is_displayable_image( $tmp ) ) {
2491 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2492 return new WP_Error( 'invalid_input', 'Invalid file type.', 403 );
2493 }
2494
2495 // emulate a $_FILES entry.
2496 $file_array = array(
2497 'name' => basename( wp_parse_url( $url, PHP_URL_PATH ) ),
2498 'tmp_name' => $tmp,
2499 );
2500
2501 $id = media_handle_sideload( $file_array, $parent_post_id );
2502 if ( file_exists( $tmp ) ) {
2503 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2504 }
2505
2506 if ( is_wp_error( $id ) ) {
2507 return $id;
2508 }
2509
2510 if ( ! $id || ! is_int( $id ) ) {
2511 return false;
2512 }
2513
2514 return $id;
2515 }
2516
2517 /**
2518 * Checks that the mime type of the specified file is among those in a filterable list of mime types.
2519 *
2520 * @param string $file Path to file to get its mime type.
2521 *
2522 * @return bool
2523 */
2524 protected function is_file_supported_for_sideloading( $file ) {
2525 return jetpack_is_file_supported_for_sideloading( $file );
2526 }
2527
2528 /**
2529 * Filter for `upload_mimes`.
2530 *
2531 * @param array $mimes Allowed mime types.
2532 * @return array Allowed mime types.
2533 */
2534 public function allow_video_uploads( $mimes ) {
2535 // if we are on Jetpack, bail - Videos are already allowed.
2536 if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
2537 return $mimes;
2538 }
2539
2540 // extra check that this filter is only ever applied during REST API requests.
2541 if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
2542 return $mimes;
2543 }
2544
2545 // bail early if they already have the upgrade..
2546 if ( wpcom_site_has_videopress() ) {
2547 return $mimes;
2548 }
2549
2550 // lets whitelist to only specific clients right now.
2551 $clients_allowed_video_uploads = array();
2552 /**
2553 * Filter the list of whitelisted video clients.
2554 *
2555 * @module json-api
2556 *
2557 * @since 3.2.0
2558 *
2559 * @param array $clients_allowed_video_uploads Array of whitelisted Video clients.
2560 */
2561 $clients_allowed_video_uploads = apply_filters( 'rest_api_clients_allowed_video_uploads', $clients_allowed_video_uploads );
2562 if ( ! in_array( $this->api->token_details['client_id'], $clients_allowed_video_uploads ) ) { // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict -- Check what types are expected here.
2563 return $mimes;
2564 }
2565
2566 $mime_list = wp_get_mime_types();
2567
2568 $video_exts = explode( ' ', get_site_option( 'video_upload_filetypes', false, false ) );
2569 /**
2570 * Filter the video filetypes allowed on the site.
2571 *
2572 * @module json-api
2573 *
2574 * @since 3.2.0
2575 *
2576 * @param array $video_exts Array of video filetypes allowed on the site.
2577 */
2578 $video_exts = apply_filters( 'video_upload_filetypes', $video_exts );
2579 $video_mimes = array();
2580
2581 if ( ! empty( $video_exts ) ) {
2582 foreach ( $video_exts as $ext ) {
2583 foreach ( $mime_list as $ext_pattern => $mime ) {
2584 if ( '' !== $ext && str_contains( $ext_pattern, $ext ) ) {
2585 $video_mimes[ $ext_pattern ] = $mime;
2586 }
2587 }
2588 }
2589
2590 $mimes = array_merge( $mimes, $video_mimes );
2591 }
2592
2593 return $mimes;
2594 }
2595
2596 /**
2597 * Is the current site multi-user?
2598 *
2599 * @return bool
2600 */
2601 public function is_current_site_multi_user() {
2602 $users = wp_cache_get( 'site_user_count', 'WPCOM_JSON_API_Endpoint' );
2603 if ( false === $users ) {
2604 $user_query = new WP_User_Query(
2605 array(
2606 'blog_id' => get_current_blog_id(),
2607 'fields' => 'ID',
2608 )
2609 );
2610 $users = (int) $user_query->get_total();
2611 wp_cache_set( 'site_user_count', $users, 'WPCOM_JSON_API_Endpoint', DAY_IN_SECONDS );
2612 }
2613 return $users > 1;
2614 }
2615
2616 /**
2617 * Whether cross-origin requests are allowed.
2618 *
2619 * @return bool
2620 */
2621 public function allows_cross_origin_requests() {
2622 return 'GET' === $this->method || $this->allow_cross_origin_request;
2623 }
2624
2625 /**
2626 * Whether unauthorized requests are allowed.
2627 *
2628 * @param string $origin Origin.
2629 * @param string[] $complete_access_origins Access origins.
2630 * @return bool
2631 */
2632 public function allows_unauthorized_requests( $origin, $complete_access_origins ) {
2633 return 'GET' === $this->method || ( $this->allow_unauthorized_request && in_array( $origin, $complete_access_origins, true ) );
2634 }
2635
2636 /**
2637 * Whether this endpoint accepts site based authentication for the current request.
2638 *
2639 * @since 9.1.0
2640 *
2641 * @return bool true, if Jetpack blog token is used and `allow_jetpack_site_auth` is true,
2642 * false otherwise.
2643 */
2644 public function accepts_site_based_authentication() {
2645 return $this->allow_jetpack_site_auth &&
2646 $this->api->is_jetpack_authorized_for_site();
2647 }
2648
2649 /**
2650 * Get platform.
2651 *
2652 * @return WPORG_Platform
2653 */
2654 public function get_platform() {
2655 return wpcom_get_sal_platform( $this->api->token_details );
2656 }
2657
2658 /**
2659 * Allows the endpoint to perform logic to allow it to decide whether-or-not it should force a
2660 * response from the WPCOM API, or potentially go to the Jetpack blog.
2661 *
2662 * Override this method if you want to do something different.
2663 *
2664 * @param int $blog_id Blog ID.
2665 * @return bool
2666 */
2667 public function force_wpcom_request( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
2668 return false;
2669 }
2670
2671 /**
2672 * Get an array of all valid AMP origins for a blog's siteurl.
2673 *
2674 * @param string $siteurl Origin url of the API request.
2675 * @return array
2676 */
2677 public function get_amp_cache_origins( $siteurl ) {
2678 $host = wp_parse_url( $siteurl, PHP_URL_HOST );
2679
2680 /*
2681 * From AMP docs:
2682 * "When possible, the Google AMP Cache will create a subdomain for each AMP document's domain by first converting it
2683 * from IDN (punycode) to UTF-8. The caches replaces every - (dash) with -- (2 dashes) and replace every . (dot) with
2684 * - (dash). For example, pub.com will map to pub-com.cdn.ampproject.org."
2685 */
2686 if ( function_exists( 'idn_to_utf8' ) ) {
2687 // The third parameter is set explicitly to prevent issues with newer PHP versions compiled with an old ICU version.
2688 $variant = defined( 'INTL_IDNA_VARIANT_UTS46' )
2689 ? INTL_IDNA_VARIANT_UTS46
2690 // phpcs:ignore PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003Deprecated, PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003DeprecatedRemoved
2691 : INTL_IDNA_VARIANT_2003; // @phan-suppress-current-line PhanUndeclaredConstant
2692 $host = idn_to_utf8( $host, IDNA_DEFAULT, $variant );
2693 }
2694 $subdomain = str_replace( array( '-', '.' ), array( '--', '-' ), $host );
2695 return array(
2696 $siteurl,
2697 // Google AMP Cache (legacy).
2698 'https://cdn.ampproject.org',
2699 // Google AMP Cache subdomain.
2700 sprintf( 'https://%s.cdn.ampproject.org', $subdomain ),
2701 // Cloudflare AMP Cache.
2702 sprintf( 'https://%s.amp.cloudflare.com', $subdomain ),
2703 // Bing AMP Cache.
2704 sprintf( 'https://%s.bing-amp.com', $subdomain ),
2705 );
2706 }
2707
2708 /**
2709 * Register a REST route for this jsonAPI endpoint.
2710 *
2711 * @return void
2712 * @throws Exception The exception if something goes wrong.
2713 */
2714 public function create_rest_route_for_endpoint() {
2715 register_rest_route(
2716 static::REST_NAMESPACE,
2717 $this->build_rest_route(),
2718 array(
2719 'methods' => $this->method,
2720 'callback' => array( $this, 'rest_callback' ),
2721 'permission_callback' => array( $this, 'rest_permission_callback' ),
2722 )
2723 );
2724 }
2725
2726 /**
2727 * Handle the rest call.
2728 *
2729 * @param WP_REST_Request $request The request object.
2730 *
2731 * @return mixed|WP_Error
2732 */
2733 public function rest_callback( WP_REST_Request $request ) {
2734 // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed -- Making sure random warnings don't break JSON.
2735 ini_set( 'display_errors', false );
2736
2737 $blog_id = Jetpack_Options::get_option( 'id' );
2738
2739 $this->api->initialize();
2740 $this->api->endpoint = $this;
2741
2742 $locale = $request->get_param( 'language' );
2743 if ( $locale ) {
2744 $this->api->init_locale( $locale );
2745 }
2746
2747 if ( $this->in_testing && ! WPCOM_JSON_API__DEBUG ) {
2748 return new WP_Error( 'endpoint_not_available' );
2749 }
2750
2751 $token_data = ( new Manager() )->verify_xml_rpc_signature();
2752 if ( ! $token_data || empty( $token_data['token_key'] ) || ! array_key_exists( 'user_id', $token_data ) ) {
2753 return new WP_Error( 'response_signature_error' );
2754 }
2755
2756 $token = ( new Tokens() )->get_access_token( $token_data['user_id'], $token_data['token_key'] );
2757 if ( is_wp_error( $token ) ) {
2758 return $token;
2759 }
2760 if ( ! $token ) {
2761 return new WP_Error( 'response_signature_error' );
2762 }
2763
2764 /** This action is documented in class.json-api.php */
2765 do_action( 'wpcom_json_api_output', $this->stat );
2766
2767 $response = call_user_func_array(
2768 array( $this, 'callback' ),
2769 array_values( array( $this->path, $blog_id ) + $request->get_url_params() )
2770 );
2771
2772 if ( ! $response && ! is_array( $response ) ) {
2773 // Dealing with empty non-array response. Phan is wrong about it being an "impossible condition".
2774 $response = new WP_Error( 'empty_response', 'Endpoint response is empty', 500 );
2775 }
2776
2777 $status_code = 200;
2778
2779 if ( is_wp_error( $response ) ) {
2780 $status_code = 500;
2781
2782 if ( $response->get_error_data() && is_scalar( $response->get_error_data() )
2783 && (string) (int) $response->get_error_data() === (string) $response->get_error_data()
2784 ) {
2785 $status_code = (int) $response->get_error_data();
2786 }
2787
2788 $response = WPCOM_JSON_API::serializable_error( $response );
2789 }
2790
2791 if ( $request->get_param( 'http_envelope' ) ) {
2792 $response = WPCOM_JSON_API::wrap_http_envelope( $status_code, $response, 'application/json' );
2793 }
2794
2795 $response = wp_json_encode( $response );
2796
2797 $nonce = wp_generate_password( 10, false );
2798 $hmac = hash_hmac( 'sha1', $nonce . $response, $token->secret );
2799
2800 return array(
2801 $response,
2802 (string) $nonce,
2803 (string) $hmac,
2804 );
2805 }
2806
2807 /**
2808 * The REST endpoint should only be available for requests signed with a valid blog or user token.
2809 * Declaring it "final" so individual endpoints couldn't remove this requirement.
2810 *
2811 * If you need to add custom permissions to individual endpoints, you can override method `rest_permission_callback_custom()`.
2812 *
2813 * @see self::rest_permission_callback_custom()
2814 *
2815 * @return true|WP_Error
2816 */
2817 final public function rest_permission_callback() {
2818 $manager = new Manager( 'jetpack' );
2819 if ( ! $manager->is_connected() ) {
2820 return new WP_Error( 'site_not_connected' );
2821 }
2822
2823 if ( ( $this->allow_jetpack_site_auth && Rest_Authentication::is_signed_with_blog_token() ) || ( get_current_user_id() && Rest_Authentication::is_signed_with_user_token() ) ) {
2824 $custom_permission_result = $this->rest_permission_callback_custom();
2825
2826 // Successful custom permission check.
2827 if ( $custom_permission_result === true ) {
2828 return true;
2829 }
2830
2831 // Custom permission check errored, returning the error.
2832 if ( is_wp_error( $custom_permission_result ) ) {
2833 return $custom_permission_result;
2834 }
2835
2836 // Custom permission check failed, but didn't return a specific error. Proceed to returning the generic error.
2837 }
2838
2839 $message = esc_html__(
2840 'You do not have the correct user permissions to perform this action. Please contact your site admin if you think this is a mistake.',
2841 'jetpack'
2842 );
2843 return new WP_Error( 'rest_api_invalid_permission', $message, array( 'status' => rest_authorization_required_code() ) );
2844 }
2845
2846 /**
2847 * You can override this method in individual endpoints to add custom permission checks.
2848 * This will run on top of `rest_permission_callback()`.
2849 *
2850 * @see self::rest_permission_callback()
2851 *
2852 * @return true|WP_Error
2853 */
2854 public function rest_permission_callback_custom() {
2855 return true;
2856 }
2857
2858 /**
2859 * Build the REST endpoint URL.
2860 *
2861 * @return string
2862 */
2863 public function build_rest_route() {
2864 $version_prefix = $this->max_version ? 'v' . $this->max_version : '';
2865 return $version_prefix . $this->rest_route;
2866 }
2867
2868 /**
2869 * Get Jetpack Version where support for the endpoint was introduced.
2870 *
2871 * @return string
2872 */
2873 public function get_rest_min_jp_version() {
2874 return $this->rest_min_jp_version;
2875 }
2876
2877 /**
2878 * Return endpoint response
2879 *
2880 * @param string $path ... determined by ->$path.
2881 *
2882 * @return array|WP_Error
2883 * falsy: HTTP 500, no response body
2884 * WP_Error( $error_code, $error_message, $http_status_code ): HTTP $status_code, json_encode( array( 'error' => $error_code, 'message' => $error_message ) ) response body
2885 * $data: HTTP 200, json_encode( $data ) response body
2886 */
2887 abstract public function callback( $path = '' );
2888 }
2889
2890 require_once __DIR__ . '/json-endpoints.php';
2891