PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 11.7.1
Jetpack – WP Security, Backup, Speed, & Growth v11.7.1
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.php
class.json-api.php
1,229 lines 33.5 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 JSON API.
4 *
5 * @package automattic/jetpack
6 */
7
8 if ( ! defined( 'WPCOM_JSON_API__DEBUG' ) ) {
9 define( 'WPCOM_JSON_API__DEBUG', false );
10 }
11
12 require_once __DIR__ . '/sal/class.json-api-platform.php';
13
14 /**
15 * Jetpack JSON API.
16 */
17 class WPCOM_JSON_API {
18 /**
19 * Static instance.
20 *
21 * @todo This should be private.
22 * @var self|null
23 */
24 public static $self = null;
25
26 /**
27 * Registered endpoints.
28 *
29 * @var WPCOM_JSON_API_Endpoint[]
30 */
31 public $endpoints = array();
32
33 /**
34 * Endpoint being processed.
35 *
36 * @var WPCOM_JSON_API_Endpoint
37 */
38 public $endpoint = null;
39
40 /**
41 * Token details.
42 *
43 * @var array
44 */
45 public $token_details = array();
46
47 /**
48 * Request HTTP method.
49 *
50 * @var string
51 */
52 public $method = '';
53
54 /**
55 * Request URL.
56 *
57 * @var string
58 */
59 public $url = '';
60
61 /**
62 * Path part of the request URL.
63 *
64 * @var string
65 */
66 public $path = '';
67
68 /**
69 * Version extracted from the request URL.
70 *
71 * @var string|null
72 */
73 public $version = null;
74
75 /**
76 * Parsed query data.
77 *
78 * @var array
79 */
80 public $query = array();
81
82 /**
83 * Post body, if the request is a POST.
84 *
85 * @var string|null
86 */
87 public $post_body = null;
88
89 /**
90 * Copy of `$_FILES` if the request is a POST.
91 *
92 * @var null|array
93 */
94 public $files = null;
95
96 /**
97 * Content type of the request.
98 *
99 * @var string|null
100 */
101 public $content_type = null;
102
103 /**
104 * Value of `$_SERVER['HTTP_ACCEPT']`, if any
105 *
106 * @var string
107 */
108 public $accept = '';
109
110 /**
111 * Value of `$_SERVER['HTTPS']`, or "--UNset--" if unset.
112 *
113 * @var string
114 */
115 public $_server_https; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
116
117 /**
118 * Whether to exit after serving a response.
119 *
120 * @var bool
121 */
122 public $exit = true;
123
124 /**
125 * Public API scheme.
126 *
127 * @var string
128 */
129 public $public_api_scheme = 'https';
130
131 /**
132 * Output status code.
133 *
134 * @var int
135 */
136 public $output_status_code = 200;
137
138 /**
139 * Trapped error.
140 *
141 * @var null|array
142 */
143 public $trapped_error = null;
144
145 /**
146 * Whether output has been done.
147 *
148 * @var bool
149 */
150 public $did_output = false;
151
152 /**
153 * Extra HTTP headers.
154 *
155 * @var string
156 */
157 public $extra_headers = array();
158
159 /**
160 * AMP source origin.
161 *
162 * @var string
163 */
164 public $amp_source_origin = null;
165
166 /**
167 * Initialize.
168 *
169 * @param string|null $method As for `$this->setup_inputs()`.
170 * @param string|null $url As for `$this->setup_inputs()`.
171 * @param string|null $post_body As for `$this->setup_inputs()`.
172 * @return WPCOM_JSON_API instance
173 */
174 public static function init( $method = null, $url = null, $post_body = null ) {
175 if ( ! self::$self ) {
176 $class = function_exists( 'get_called_class' ) ? get_called_class() : __CLASS__; // phpcs:ignore PHPCompatibility.PHP.NewFunctions.get_called_classFound
177 self::$self = new $class( $method, $url, $post_body );
178 }
179 return self::$self;
180 }
181
182 /**
183 * Add an endpoint.
184 *
185 * @param WPCOM_JSON_API_Endpoint $endpoint Endpoint to add.
186 */
187 public function add( WPCOM_JSON_API_Endpoint $endpoint ) {
188 // @todo Determine if anything depends on this being serialized rather than e.g. JSON.
189 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Legacy, possibly depended on elsewhere.
190 $path_versions = serialize(
191 array(
192 $endpoint->path,
193 $endpoint->min_version,
194 $endpoint->max_version,
195 )
196 );
197 if ( ! isset( $this->endpoints[ $path_versions ] ) ) {
198 $this->endpoints[ $path_versions ] = array();
199 }
200 $this->endpoints[ $path_versions ][ $endpoint->method ] = $endpoint;
201 }
202
203 /**
204 * Determine if a string is truthy.
205 *
206 * @param string $value "1", "t", and "true" (case insensitive) are falsey, everything else isn't.
207 * @return bool
208 */
209 public static function is_truthy( $value ) {
210 switch ( strtolower( (string) $value ) ) {
211 case '1':
212 case 't':
213 case 'true':
214 return true;
215 }
216
217 return false;
218 }
219
220 /**
221 * Determine if a string is falsey.
222 *
223 * @param string $value "0", "f", and "false" (case insensitive) are falsey, everything else isn't.
224 * @return bool
225 */
226 public static function is_falsy( $value ) {
227 switch ( strtolower( (string) $value ) ) {
228 case '0':
229 case 'f':
230 case 'false':
231 return true;
232 }
233
234 return false;
235 }
236
237 /**
238 * Constructor.
239 *
240 * @todo This should be private.
241 * @param string|null $method As for `$this->setup_inputs()`.
242 * @param string|null $url As for `$this->setup_inputs()`.
243 * @param string|null $post_body As for `$this->setup_inputs()`.
244 */
245 public function __construct( $method = null, $url = null, $post_body = null ) {
246 $this->setup_inputs( $method, $url, $post_body );
247 }
248
249 /**
250 * Setup inputs.
251 *
252 * @param string|null $method Request HTTP method. Fetched from `$_SERVER` if null.
253 * @param string|null $url URL requested. Determined from `$_SERVER` if null.
254 * @param string|null $post_body POST body. Read from `php://input` if null and method is POST.
255 */
256 public function setup_inputs( $method = null, $url = null, $post_body = null ) {
257 if ( $method === null ) {
258 $this->method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( filter_var( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
259 } else {
260 $this->method = strtoupper( $method );
261 }
262 if ( $url === null ) {
263 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sniff misses the esc_url_raw.
264 $this->url = esc_url_raw( set_url_scheme( 'http://' . ( isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : '' ) . ( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '' ) ) );
265 } else {
266 $this->url = $url;
267 }
268
269 $parsed = wp_parse_url( $this->url );
270 if ( ! empty( $parsed['path'] ) ) {
271 $this->path = $parsed['path'];
272 }
273
274 if ( ! empty( $parsed['query'] ) ) {
275 wp_parse_str( $parsed['query'], $this->query );
276 }
277
278 if ( ! empty( $_SERVER['HTTP_ACCEPT'] ) ) {
279 $this->accept = filter_var( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
280 }
281
282 if ( 'POST' === $this->method ) {
283 if ( $post_body === null ) {
284 $this->post_body = file_get_contents( 'php://input' );
285
286 if ( ! empty( $_SERVER['HTTP_CONTENT_TYPE'] ) ) {
287 $this->content_type = filter_var( wp_unslash( $_SERVER['HTTP_CONTENT_TYPE'] ) );
288 } elseif ( ! empty( $_SERVER['CONTENT_TYPE'] ) ) {
289 $this->content_type = filter_var( wp_unslash( $_SERVER['CONTENT_TYPE'] ) );
290 } elseif ( '{' === $this->post_body[0] ) {
291 $this->content_type = 'application/json';
292 } else {
293 $this->content_type = 'application/x-www-form-urlencoded';
294 }
295
296 if ( 0 === strpos( strtolower( $this->content_type ), 'multipart/' ) ) {
297 // phpcs:ignore WordPress.Security.NonceVerification.Missing
298 $this->post_body = http_build_query( stripslashes_deep( $_POST ) );
299 $this->files = $_FILES;
300 $this->content_type = 'multipart/form-data';
301 }
302 } else {
303 $this->post_body = $post_body;
304 $this->content_type = isset( $this->post_body[0] ) && '{' === $this->post_body[0] ? 'application/json' : 'application/x-www-form-urlencoded';
305 }
306 } else {
307 $this->post_body = null;
308 $this->content_type = null;
309 }
310
311 $this->_server_https = array_key_exists( 'HTTPS', $_SERVER ) ? filter_var( wp_unslash( $_SERVER['HTTPS'] ) ) : '--UNset--';
312 }
313
314 /**
315 * Initialize.
316 *
317 * @return null|WP_Error (although this implementation always returns null)
318 */
319 public function initialize() {
320 $this->token_details['blog_id'] = Jetpack_Options::get_option( 'id' );
321 return null;
322 }
323
324 /**
325 * Checks if the current request is authorized with a blog token.
326 * This method is overridden by a child class in WPCOM.
327 *
328 * @since 9.1.0
329 *
330 * @param boolean|int $site_id The site id.
331 * @return boolean
332 */
333 public function is_jetpack_authorized_for_site( $site_id = false ) {
334 if ( ! $this->token_details ) {
335 return false;
336 }
337
338 $token_details = (object) $this->token_details;
339
340 $site_in_token = (int) $token_details->blog_id;
341
342 if ( $site_in_token < 1 ) {
343 return false;
344 }
345
346 if ( $site_id && $site_in_token !== (int) $site_id ) {
347 return false;
348 }
349
350 if ( (int) get_current_user_id() !== 0 ) {
351 // If Jetpack blog token is used, no logged-in user should exist.
352 return false;
353 }
354
355 return true;
356 }
357
358 /**
359 * Serve.
360 *
361 * @param bool $exit Whether to exit.
362 * @return string|null Content type (assuming it didn't exit), or null in certain error cases.
363 */
364 public function serve( $exit = true ) {
365 ini_set( 'display_errors', false ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Blacklisted
366
367 $this->exit = (bool) $exit;
368
369 // This was causing problems with Jetpack, but is necessary for wpcom
370 // @see https://github.com/Automattic/jetpack/pull/2603
371 // @see r124548-wpcom .
372 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
373 add_filter( 'home_url', array( $this, 'ensure_http_scheme_of_home_url' ), 10, 3 );
374 }
375
376 add_filter( 'user_can_richedit', '__return_true' );
377
378 add_filter( 'comment_edit_pre', array( $this, 'comment_edit_pre' ) );
379
380 $initialization = $this->initialize();
381 if ( 'OPTIONS' === $this->method ) {
382 /**
383 * Fires before the page output.
384 * Can be used to specify custom header options.
385 *
386 * @module json-api
387 *
388 * @since 3.1.0
389 */
390 do_action( 'wpcom_json_api_options' );
391 return $this->output( 200, '', 'text/plain' );
392 }
393
394 if ( is_wp_error( $initialization ) ) {
395 $this->output_error( $initialization );
396 return;
397 }
398
399 // Normalize path and extract API version.
400 $this->path = untrailingslashit( $this->path );
401 preg_match( '#^/rest/v(\d+(\.\d+)*)#', $this->path, $matches );
402 $this->path = substr( $this->path, strlen( $matches[0] ) );
403 $this->version = $matches[1];
404
405 $allowed_methods = array( 'GET', 'POST' );
406 $four_oh_five = false;
407
408 $is_help = preg_match( '#/help/?$#i', $this->path );
409 $matching_endpoints = array();
410
411 if ( $is_help ) {
412 $origin = get_http_origin();
413
414 if ( ! empty( $origin ) && 'GET' === $this->method ) {
415 header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
416 }
417
418 $this->path = substr( rtrim( $this->path, '/' ), 0, -5 );
419 // Show help for all matching endpoints regardless of method.
420 $methods = $allowed_methods;
421 $find_all_matching_endpoints = true;
422 // How deep to truncate each endpoint's path to see if it matches this help request.
423 $depth = substr_count( $this->path, '/' ) + 1;
424 if ( false !== stripos( $this->accept, 'javascript' ) || false !== stripos( $this->accept, 'json' ) ) {
425 $help_content_type = 'json';
426 } else {
427 $help_content_type = 'html';
428 }
429 } else {
430 if ( in_array( $this->method, $allowed_methods, true ) ) {
431 // Only serve requested method.
432 $methods = array( $this->method );
433 $find_all_matching_endpoints = false;
434 } else {
435 // We don't allow this requested method - find matching endpoints and send 405.
436 $methods = $allowed_methods;
437 $find_all_matching_endpoints = true;
438 $four_oh_five = true;
439 }
440 }
441
442 // Find which endpoint to serve.
443 $found = false;
444 foreach ( $this->endpoints as $endpoint_path_versions => $endpoints_by_method ) {
445 // @todo Determine if anything depends on this being serialized rather than e.g. JSON.
446 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Legacy, possibly depended on elsewhere.
447 $endpoint_path_versions = unserialize( $endpoint_path_versions );
448 $endpoint_path = $endpoint_path_versions[0];
449 $endpoint_min_version = $endpoint_path_versions[1];
450 $endpoint_max_version = $endpoint_path_versions[2];
451
452 // Make sure max_version is not less than min_version.
453 if ( version_compare( $endpoint_max_version, $endpoint_min_version, '<' ) ) {
454 $endpoint_max_version = $endpoint_min_version;
455 }
456
457 foreach ( $methods as $method ) {
458 if ( ! isset( $endpoints_by_method[ $method ] ) ) {
459 continue;
460 }
461
462 // Normalize.
463 $endpoint_path = untrailingslashit( $endpoint_path );
464 if ( $is_help ) {
465 // Truncate path at help depth.
466 $endpoint_path = join( '/', array_slice( explode( '/', $endpoint_path ), 0, $depth ) );
467 }
468
469 // Generate regular expression from sprintf().
470 $endpoint_path_regex = str_replace( array( '%s', '%d' ), array( '([^/?&]+)', '(\d+)' ), $endpoint_path );
471
472 if ( ! preg_match( "#^$endpoint_path_regex\$#", $this->path, $path_pieces ) ) {
473 // This endpoint does not match the requested path.
474 continue;
475 }
476
477 if ( version_compare( $this->version, $endpoint_min_version, '<' ) || version_compare( $this->version, $endpoint_max_version, '>' ) ) {
478 // This endpoint does not match the requested version.
479 continue;
480 }
481
482 $found = true;
483
484 if ( $find_all_matching_endpoints ) {
485 $matching_endpoints[] = array( $endpoints_by_method[ $method ], $path_pieces );
486 } else {
487 // The method parameters are now in $path_pieces.
488 $endpoint = $endpoints_by_method[ $method ];
489 break 2;
490 }
491 }
492 }
493
494 if ( ! $found ) {
495 return $this->output( 404, '', 'text/plain' );
496 }
497
498 if ( $four_oh_five ) {
499 $allowed_methods = array();
500 foreach ( $matching_endpoints as $matching_endpoint ) {
501 $allowed_methods[] = $matching_endpoint[0]->method;
502 }
503
504 header( 'Allow: ' . strtoupper( join( ',', array_unique( $allowed_methods ) ) ) );
505 return $this->output(
506 405,
507 array(
508 'error' => 'not_allowed',
509 'error_message' => 'Method not allowed',
510 )
511 );
512 }
513
514 if ( $is_help ) {
515 /**
516 * Fires before the API output.
517 *
518 * @since 1.9.0
519 *
520 * @param string help.
521 */
522 do_action( 'wpcom_json_api_output', 'help' );
523 $proxied = function_exists( 'wpcom_is_proxied_request' ) ? wpcom_is_proxied_request() : false;
524 if ( 'json' === $help_content_type ) {
525 $docs = array();
526 foreach ( $matching_endpoints as $matching_endpoint ) {
527 if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
528 $docs[] = call_user_func( array( $matching_endpoint[0], 'generate_documentation' ) );
529 }
530 }
531 return $this->output( 200, $docs );
532 } else {
533 status_header( 200 );
534 foreach ( $matching_endpoints as $matching_endpoint ) {
535 if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
536 call_user_func( array( $matching_endpoint[0], 'document' ) );
537 }
538 }
539 }
540 exit;
541 }
542
543 if ( $endpoint->in_testing && ! WPCOM_JSON_API__DEBUG ) {
544 return $this->output( 404, '', 'text/plain' );
545 }
546
547 /** This action is documented in class.json-api.php */
548 do_action( 'wpcom_json_api_output', $endpoint->stat );
549
550 $response = $this->process_request( $endpoint, $path_pieces );
551
552 if ( ! $response && ! is_array( $response ) ) {
553 return $this->output( 500, '', 'text/plain' );
554 } elseif ( is_wp_error( $response ) ) {
555 return $this->output_error( $response );
556 }
557
558 $output_status_code = $this->output_status_code;
559 $this->set_output_status_code();
560
561 return $this->output( $output_status_code, $response, 'application/json', $this->extra_headers );
562 }
563
564 /**
565 * Process a request.
566 *
567 * @param WPCOM_JSON_API_Endpoint $endpoint Endpoint.
568 * @param array $path_pieces Path pieces.
569 * @return array|WP_Error Return value from the endpoint's callback.
570 */
571 public function process_request( WPCOM_JSON_API_Endpoint $endpoint, $path_pieces ) {
572 $this->endpoint = $endpoint;
573 return call_user_func_array( array( $endpoint, 'callback' ), $path_pieces );
574 }
575
576 /**
577 * Output a response or error without exiting.
578 *
579 * @param int $status_code HTTP status code.
580 * @param mixed $response Response data.
581 * @param string $content_type Content type of the response.
582 */
583 public function output_early( $status_code, $response = null, $content_type = 'application/json' ) {
584 $exit = $this->exit;
585 $this->exit = false;
586 if ( is_wp_error( $response ) ) {
587 $this->output_error( $response );
588 } else {
589 $this->output( $status_code, $response, $content_type );
590 }
591 $this->exit = $exit;
592 if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
593 $this->finish_request();
594 }
595 }
596
597 /**
598 * Set output status code.
599 *
600 * @param int $code HTTP status code.
601 */
602 public function set_output_status_code( $code = 200 ) {
603 $this->output_status_code = $code;
604 }
605
606 /**
607 * Output a response.
608 *
609 * @param int $status_code HTTP status code.
610 * @param mixed $response Response data.
611 * @param string $content_type Content type of the response.
612 * @param array $extra Additional HTTP headers.
613 * @return string Content type (assuming it didn't exit).
614 */
615 public function output( $status_code, $response = null, $content_type = 'application/json', $extra = array() ) {
616 $status_code = (int) $status_code;
617
618 // In case output() was called before the callback returned.
619 if ( $this->did_output ) {
620 if ( $this->exit ) {
621 exit;
622 }
623 return $content_type;
624 }
625 $this->did_output = true;
626
627 // 400s and 404s are allowed for all origins
628 if ( 404 === $status_code || 400 === $status_code ) {
629 header( 'Access-Control-Allow-Origin: *' );
630 }
631
632 /* Add headers for form submission from <amp-form/> */
633 if ( $this->amp_source_origin ) {
634 header( 'Access-Control-Allow-Origin: ' . wp_unslash( $this->amp_source_origin ) );
635 header( 'Access-Control-Allow-Credentials: true' );
636 }
637
638 if ( $response === null ) {
639 $response = new stdClass();
640 }
641
642 if ( 'text/plain' === $content_type ||
643 'text/html' === $content_type ) {
644 status_header( (int) $status_code );
645 header( 'Content-Type: ' . $content_type );
646 foreach ( $extra as $key => $value ) {
647 header( "$key: $value" );
648 }
649 echo $response; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
650 if ( $this->exit ) {
651 exit;
652 }
653
654 return $content_type;
655 }
656
657 $response = $this->filter_fields( $response );
658
659 if ( isset( $this->query['http_envelope'] ) && self::is_truthy( $this->query['http_envelope'] ) ) {
660 $headers = array(
661 array(
662 'name' => 'Content-Type',
663 'value' => $content_type,
664 ),
665 );
666
667 foreach ( $extra as $key => $value ) {
668 $headers[] = array(
669 'name' => $key,
670 'value' => $value,
671 );
672 }
673
674 $response = array(
675 'code' => (int) $status_code,
676 'headers' => $headers,
677 'body' => $response,
678 );
679 $status_code = 200;
680 $content_type = 'application/json';
681 }
682
683 status_header( (int) $status_code );
684 header( "Content-Type: $content_type" );
685 if ( isset( $this->query['callback'] ) && is_string( $this->query['callback'] ) ) {
686 $callback = preg_replace( '/[^a-z0-9_.]/i', '', $this->query['callback'] );
687 } else {
688 $callback = false;
689 }
690
691 if ( $callback ) {
692 // Mitigate Rosetta Flash [1] by setting the Content-Type-Options: nosniff header
693 // and by prepending the JSONP response with a JS comment.
694 // [1] <https://blog.miki.it/2014/7/8/abusing-jsonp-with-rosetta-flash/index.html>.
695 echo "/**/$callback("; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- This is JSONP output, not HTML.
696
697 }
698 echo $this->json_encode( $response ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- This is JSON or JSONP output, not HTML.
699 if ( $callback ) {
700 echo ');';
701 }
702
703 if ( $this->exit ) {
704 exit;
705 }
706
707 return $content_type;
708 }
709
710 /**
711 * Serialize an error.
712 *
713 * @param WP_Error $error Error.
714 * @return array with 'status_code' and 'errors' data.
715 */
716 public static function serializable_error( $error ) {
717
718 $status_code = $error->get_error_data();
719
720 if ( is_array( $status_code ) ) {
721 $status_code = $status_code['status_code'];
722 }
723
724 if ( ! $status_code ) {
725 $status_code = 400;
726 }
727 $response = array(
728 'error' => $error->get_error_code(),
729 'message' => $error->get_error_message(),
730 );
731
732 $additional_data = $error->get_error_data( 'additional_data' );
733 if ( $additional_data ) {
734 $response['data'] = $additional_data;
735 }
736
737 return array(
738 'status_code' => $status_code,
739 'errors' => $response,
740 );
741 }
742
743 /**
744 * Output an error.
745 *
746 * @param WP_Error $error Error.
747 * @return string Content type (assuming it didn't exit).
748 */
749 public function output_error( $error ) {
750 $error_response = $this->serializable_error( $error );
751
752 return $this->output( $error_response['status_code'], $error_response['errors'] );
753 }
754
755 /**
756 * Filter fields in a response.
757 *
758 * @param array|object $response Response.
759 * @return array|object Filtered response.
760 */
761 public function filter_fields( $response ) {
762 if ( empty( $this->query['fields'] ) || ( is_array( $response ) && ! empty( $response['error'] ) ) || ! empty( $this->endpoint->custom_fields_filtering ) ) {
763 return $response;
764 }
765
766 $fields = array_map( 'trim', explode( ',', $this->query['fields'] ) );
767
768 if ( is_object( $response ) ) {
769 $response = (array) $response;
770 }
771
772 $has_filtered = false;
773 if ( is_array( $response ) && empty( $response['ID'] ) ) {
774 $keys_to_filter = array(
775 'categories',
776 'comments',
777 'connections',
778 'domains',
779 'groups',
780 'likes',
781 'media',
782 'notes',
783 'posts',
784 'services',
785 'sites',
786 'suggestions',
787 'tags',
788 'themes',
789 'topics',
790 'users',
791 );
792
793 foreach ( $keys_to_filter as $key_to_filter ) {
794 if ( ! isset( $response[ $key_to_filter ] ) || $has_filtered ) {
795 continue;
796 }
797
798 foreach ( $response[ $key_to_filter ] as $key => $values ) {
799 if ( is_object( $values ) ) {
800 if ( is_object( $response[ $key_to_filter ] ) ) {
801 // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found -- False positive.
802 $response[ $key_to_filter ]->$key = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
803 } elseif ( is_array( $response[ $key_to_filter ] ) ) {
804 $response[ $key_to_filter ][ $key ] = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
805 }
806 } elseif ( is_array( $values ) ) {
807 $response[ $key_to_filter ][ $key ] = array_intersect_key( $values, array_flip( $fields ) );
808 }
809 }
810
811 $has_filtered = true;
812 }
813 }
814
815 if ( ! $has_filtered ) {
816 if ( is_object( $response ) ) {
817 $response = (object) array_intersect_key( (array) $response, array_flip( $fields ) );
818 } elseif ( is_array( $response ) ) {
819 $response = array_intersect_key( $response, array_flip( $fields ) );
820 }
821 }
822
823 return $response;
824 }
825
826 /**
827 * Filter for `home_url`.
828 *
829 * If `$original_scheme` is null, turns an https URL to http.
830 *
831 * @param string $url The complete home URL including scheme and path.
832 * @param string $path Path relative to the home URL. Blank string if no path is specified.
833 * @param string|null $original_scheme Scheme to give the home URL context. Accepts 'http', 'https', 'relative', 'rest', or null.
834 * @return string URL.
835 */
836 public function ensure_http_scheme_of_home_url( $url, $path, $original_scheme ) {
837 if ( $original_scheme ) {
838 return $url;
839 }
840
841 return preg_replace( '#^https:#', 'http:', $url );
842 }
843
844 /**
845 * Decode HTML special characters in comment content.
846 *
847 * @param string $comment_content Comment content.
848 * @return string
849 */
850 public function comment_edit_pre( $comment_content ) {
851 return htmlspecialchars_decode( $comment_content, ENT_QUOTES );
852 }
853
854 /**
855 * JSON encode.
856 *
857 * @param mixed $data Data.
858 * @return string|false
859 */
860 public function json_encode( $data ) {
861 return wp_json_encode( $data );
862 }
863
864 /**
865 * Test if a string ends with a string.
866 *
867 * @param string $haystack String to check.
868 * @param string $needle Suffix to check.
869 * @return bool
870 */
871 public function ends_with( $haystack, $needle ) {
872 return substr( $haystack, -strlen( $needle ) ) === $needle;
873 }
874
875 /**
876 * Returns the site's blog_id in the WP.com ecosystem
877 *
878 * @return int
879 */
880 public function get_blog_id_for_output() {
881 return $this->token_details['blog_id'];
882 }
883
884 /**
885 * Returns the site's local blog_id.
886 *
887 * @param int $blog_id Blog ID.
888 * @return int
889 */
890 public function get_blog_id( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
891 return $GLOBALS['blog_id'];
892 }
893
894 /**
895 * Switch to blog and validate user.
896 *
897 * @param int $blog_id Blog ID.
898 * @param bool $verify_token_for_blog Whether to verify the token.
899 * @return int Blog ID.
900 */
901 public function switch_to_blog_and_validate_user( $blog_id = 0, $verify_token_for_blog = true ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
902 if ( $this->is_restricted_blog( $blog_id ) ) {
903 return new WP_Error( 'unauthorized', 'User cannot access this restricted blog', 403 );
904 }
905 /**
906 * If this is a private site we check for 2 things:
907 * 1. In case of user based authentication, we need to check if the logged-in user has the 'read' capability.
908 * 2. In case of site based authentication, make sure the endpoint accepts it.
909 */
910 if ( -1 === (int) get_option( 'blog_public' ) &&
911 ! current_user_can( 'read' ) &&
912 ! $this->endpoint->accepts_site_based_authentication()
913 ) {
914 return new WP_Error( 'unauthorized', 'User cannot access this private blog.', 403 );
915 }
916
917 return $blog_id;
918 }
919
920 /**
921 * Returns true if the specified blog ID is a restricted blog
922 *
923 * @param int $blog_id Blog ID.
924 * @return bool
925 */
926 public function is_restricted_blog( $blog_id ) {
927 /**
928 * Filters all REST API access and return a 403 unauthorized response for all Restricted blog IDs.
929 *
930 * @module json-api
931 *
932 * @since 3.4.0
933 *
934 * @param array $array Array of Blog IDs.
935 */
936 $restricted_blog_ids = apply_filters( 'wpcom_json_api_restricted_blog_ids', array() );
937 return true === in_array( $blog_id, $restricted_blog_ids ); // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict -- I don't trust filters to return the right types.
938 }
939
940 /**
941 * Post like count.
942 *
943 * @param int $blog_id Blog ID.
944 * @param int $post_id Post ID.
945 * @return int
946 */
947 public function post_like_count( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
948 return 0;
949 }
950
951 /**
952 * Is liked?
953 *
954 * @param int $blog_id Blog ID.
955 * @param int $post_id Post ID.
956 * @return bool
957 */
958 public function is_liked( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
959 return false;
960 }
961
962 /**
963 * Is reblogged?
964 *
965 * @param int $blog_id Blog ID.
966 * @param int $post_id Post ID.
967 * @return bool
968 */
969 public function is_reblogged( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
970 return false;
971 }
972
973 /**
974 * Is following?
975 *
976 * @param int $blog_id Blog ID.
977 * @return bool
978 */
979 public function is_following( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
980 return false;
981 }
982
983 /**
984 * Add global ID.
985 *
986 * @param int $blog_id Blog ID.
987 * @param int $post_id Post ID.
988 * @return string
989 */
990 public function add_global_ID( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
991 return '';
992 }
993
994 /**
995 * Get avatar URL.
996 *
997 * @param string $email Email.
998 * @param array $avatar_size Args for `get_avatar_url()`.
999 * @return string|false
1000 */
1001 public function get_avatar_url( $email, $avatar_size = null ) {
1002 if ( function_exists( 'wpcom_get_avatar_url' ) ) {
1003 return null === $avatar_size
1004 ? wpcom_get_avatar_url( $email )
1005 : wpcom_get_avatar_url( $email, $avatar_size );
1006 } else {
1007 return null === $avatar_size
1008 ? get_avatar_url( $email )
1009 : get_avatar_url( $email, $avatar_size );
1010 }
1011 }
1012
1013 /**
1014 * Counts the number of comments on a site, including certain comment types.
1015 *
1016 * @param int $post_id Post ID.
1017 * @return array Array of counts, matching the output of https://developer.wordpress.org/reference/functions/get_comment_count/.
1018 */
1019 public function wp_count_comments( $post_id ) {
1020 global $wpdb;
1021 if ( 0 !== $post_id ) {
1022 return wp_count_comments( $post_id );
1023 }
1024
1025 $counts = array(
1026 'total_comments' => 0,
1027 'all' => 0,
1028 );
1029
1030 /**
1031 * Exclude certain comment types from comment counts in the REST API.
1032 *
1033 * @since 6.9.0
1034 * @deprecated 11.1
1035 * @module json-api
1036 *
1037 * @param array Array of comment types to exclude (default: 'order_note', 'webhook_delivery', 'review', 'action_log')
1038 */
1039 $exclude = apply_filters_deprecated( 'jetpack_api_exclude_comment_types_count', array( 'order_note', 'webhook_delivery', 'review', 'action_log' ), 'jetpack-11.1', 'jetpack_api_include_comment_types_count' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1040
1041 /**
1042 * Include certain comment types in comment counts in the REST API.
1043 * Note: the default array of comment types includes an empty string,
1044 * to support comments posted before WP 5.5, that used an empty string as comment type.
1045 *
1046 * @since 11.1
1047 * @module json-api
1048 *
1049 * @param array Array of comment types to include (default: 'comment', 'pingback', 'trackback')
1050 */
1051 $include = apply_filters(
1052 'jetpack_api_include_comment_types_count',
1053 array( 'comment', 'pingback', 'trackback', '' )
1054 );
1055
1056 if ( empty( $include ) ) {
1057 return wp_count_comments( $post_id );
1058 }
1059
1060 array_walk( $include, 'esc_sql' );
1061 $where = sprintf(
1062 "WHERE comment_type IN ( '%s' )",
1063 implode( "','", $include )
1064 );
1065
1066 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- `$where` is built with escaping just above.
1067 $count = $wpdb->get_results(
1068 "SELECT comment_approved, COUNT(*) AS num_comments
1069 FROM $wpdb->comments
1070 {$where}
1071 GROUP BY comment_approved
1072 "
1073 );
1074 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1075
1076 $approved = array(
1077 '0' => 'moderated',
1078 '1' => 'approved',
1079 'spam' => 'spam',
1080 'trash' => 'trash',
1081 'post-trashed' => 'post-trashed',
1082 );
1083
1084 // <https://developer.wordpress.org/reference/functions/get_comment_count/#source>
1085 foreach ( $count as $row ) {
1086 if ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash', 'spam' ), true ) ) {
1087 $counts['all'] += $row->num_comments;
1088 $counts['total_comments'] += $row->num_comments;
1089 } elseif ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash' ), true ) ) {
1090 $counts['total_comments'] += $row->num_comments;
1091 }
1092 if ( isset( $approved[ $row->comment_approved ] ) ) {
1093 $counts[ $approved[ $row->comment_approved ] ] = $row->num_comments;
1094 }
1095 }
1096
1097 foreach ( $approved as $key ) {
1098 if ( empty( $counts[ $key ] ) ) {
1099 $counts[ $key ] = 0;
1100 }
1101 }
1102
1103 $counts = (object) $counts;
1104
1105 return $counts;
1106 }
1107
1108 /**
1109 * Traps `wp_die()` calls and outputs a JSON response instead.
1110 * The result is always output, never returned.
1111 *
1112 * @param string|null $error_code Call with string to start the trapping. Call with null to stop.
1113 * @param int $http_status HTTP status code, 400 by default.
1114 */
1115 public function trap_wp_die( $error_code = null, $http_status = 400 ) {
1116 // Determine the filter name; based on the conditionals inside the wp_die function.
1117 if ( wp_is_json_request() ) {
1118 $die_handler = 'wp_die_json_handler';
1119 } elseif ( wp_is_jsonp_request() ) {
1120 $die_handler = 'wp_die_jsonp_handler';
1121 } elseif ( wp_is_xml_request() ) {
1122 $die_handler = 'wp_die_xml_handler';
1123 } else {
1124 $die_handler = 'wp_die_handler';
1125 }
1126
1127 if ( $error_code === null ) {
1128 $this->trapped_error = null;
1129 // Stop trapping.
1130 remove_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1131 return;
1132 }
1133
1134 // If API called via PHP, bail: don't do our custom wp_die(). Do the normal wp_die().
1135 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1136 if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
1137 return;
1138 }
1139 } else {
1140 if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
1141 return;
1142 }
1143 }
1144
1145 $this->trapped_error = array(
1146 'status' => $http_status,
1147 'code' => $error_code,
1148 'message' => '',
1149 );
1150 // Start trapping.
1151 add_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1152 }
1153
1154 /**
1155 * Filter function for `wp_die_handler` and similar filters.
1156 *
1157 * @return callable
1158 */
1159 public function wp_die_handler_callback() {
1160 return array( $this, 'wp_die_handler' );
1161 }
1162
1163 /**
1164 * Handler for `wp_die` calls.
1165 *
1166 * @param string|WP_Error $message As for `wp_die()`.
1167 * @param string|int $title As for `wp_die()`.
1168 * @param string|array|int $args As for `wp_die()`.
1169 */
1170 public function wp_die_handler( $message, $title = '', $args = array() ) {
1171 // Allow wp_die calls to override HTTP status code...
1172 $args = wp_parse_args(
1173 $args,
1174 array(
1175 'response' => $this->trapped_error['status'],
1176 )
1177 );
1178
1179 // ... unless it's 500
1180 if ( 500 !== (int) $args['response'] ) {
1181 $this->trapped_error['status'] = $args['response'];
1182 }
1183
1184 if ( $title ) {
1185 $message = "$title: $message";
1186 }
1187
1188 $this->trapped_error['message'] = wp_kses( $message, array() );
1189
1190 switch ( $this->trapped_error['code'] ) {
1191 case 'comment_failure':
1192 if ( did_action( 'comment_duplicate_trigger' ) ) {
1193 $this->trapped_error['code'] = 'comment_duplicate';
1194 } elseif ( did_action( 'comment_flood_trigger' ) ) {
1195 $this->trapped_error['code'] = 'comment_flood';
1196 }
1197 break;
1198 }
1199
1200 // We still want to exit so that code execution stops where it should.
1201 // Attach the JSON output to the WordPress shutdown handler.
1202 add_action( 'shutdown', array( $this, 'output_trapped_error' ), 0 );
1203 exit;
1204 }
1205
1206 /**
1207 * Output the trapped error.
1208 */
1209 public function output_trapped_error() {
1210 $this->exit = false; // We're already exiting once. Don't do it twice.
1211 $this->output(
1212 $this->trapped_error['status'],
1213 (object) array(
1214 'error' => $this->trapped_error['code'],
1215 'message' => $this->trapped_error['message'],
1216 )
1217 );
1218 }
1219
1220 /**
1221 * Finish the request.
1222 */
1223 public function finish_request() {
1224 if ( function_exists( 'fastcgi_finish_request' ) ) {
1225 return fastcgi_finish_request();
1226 }
1227 }
1228 }
1229