Completed
Push — update/api-blog-token-needed-p... ( c4ab92...ca0f9f )
by
unknown
08:19
created

class.json-api.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
defined( 'WPCOM_JSON_API__DEBUG' ) or define( 'WPCOM_JSON_API__DEBUG', false );
4
5
require_once dirname( __FILE__ ) . '/sal/class.json-api-platform.php';
6
7
class WPCOM_JSON_API {
8
	static $self = null;
9
10
	public $endpoints = array();
11
12
	public $token_details = array();
13
14
	public $method       = '';
15
	public $url          = '';
16
	public $path         = '';
17
	public $version      = null;
18
	public $query        = array();
19
	public $post_body    = null;
20
	public $files        = null;
21
	public $content_type = null;
22
	public $accept       = '';
23
24
	public $_server_https;
25
	public $exit              = true;
26
	public $public_api_scheme = 'https';
27
28
	public $output_status_code = 200;
29
30
	public $trapped_error = null;
31
	public $did_output    = false;
32
33
	public $extra_headers = array();
34
35
	public $amp_source_origin = null;
36
37
	/**
38
	 * @return WPCOM_JSON_API instance
39
	 */
40
	static function init( $method = null, $url = null, $post_body = null ) {
41
		if ( ! self::$self ) {
42
			$class      = function_exists( 'get_called_class' ) ? get_called_class() : __CLASS__; // phpcs:ignore PHPCompatibility.PHP.NewFunctions.get_called_classFound
43
			self::$self = new $class( $method, $url, $post_body );
44
		}
45
		return self::$self;
46
	}
47
48
	function add( WPCOM_JSON_API_Endpoint $endpoint ) {
49
		$path_versions = serialize(
50
			array(
51
				$endpoint->path,
52
				$endpoint->min_version,
53
				$endpoint->max_version,
54
			)
55
		);
56
		if ( ! isset( $this->endpoints[ $path_versions ] ) ) {
57
			$this->endpoints[ $path_versions ] = array();
58
		}
59
		$this->endpoints[ $path_versions ][ $endpoint->method ] = $endpoint;
60
	}
61
62
	static function is_truthy( $value ) {
63
		switch ( strtolower( (string) $value ) ) {
64
			case '1':
65
			case 't':
66
			case 'true':
67
				return true;
68
		}
69
70
		return false;
71
	}
72
73
	static function is_falsy( $value ) {
74
		switch ( strtolower( (string) $value ) ) {
75
			case '0':
76
			case 'f':
77
			case 'false':
78
				return true;
79
		}
80
81
		return false;
82
	}
83
84
	function __construct( ...$args ) {
85
		call_user_func_array( array( $this, 'setup_inputs' ), $args );
86
	}
87
88
	function setup_inputs( $method = null, $url = null, $post_body = null ) {
89
		if ( is_null( $method ) ) {
90
			$this->method = strtoupper( $_SERVER['REQUEST_METHOD'] );
91
		} else {
92
			$this->method = strtoupper( $method );
93
		}
94
		if ( is_null( $url ) ) {
95
			$this->url = set_url_scheme( 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] );
96
		} else {
97
			$this->url = $url;
98
		}
99
100
		$parsed = wp_parse_url( $this->url );
101
		if ( ! empty( $parsed['path'] ) ) {
102
			$this->path = $parsed['path'];
103
		}
104
105
		if ( ! empty( $parsed['query'] ) ) {
106
			wp_parse_str( $parsed['query'], $this->query );
107
		}
108
109
		if ( isset( $_SERVER['HTTP_ACCEPT'] ) && $_SERVER['HTTP_ACCEPT'] ) {
110
			$this->accept = $_SERVER['HTTP_ACCEPT'];
111
		}
112
113
		if ( 'POST' === $this->method ) {
114
			if ( is_null( $post_body ) ) {
115
				$this->post_body = file_get_contents( 'php://input' );
116
117
				if ( isset( $_SERVER['HTTP_CONTENT_TYPE'] ) && $_SERVER['HTTP_CONTENT_TYPE'] ) {
118
					$this->content_type = $_SERVER['HTTP_CONTENT_TYPE'];
119
				} elseif ( isset( $_SERVER['CONTENT_TYPE'] ) && $_SERVER['CONTENT_TYPE'] ) {
120
					$this->content_type = $_SERVER['CONTENT_TYPE'];
121
				} elseif ( '{' === $this->post_body[0] ) {
122
					$this->content_type = 'application/json';
123
				} else {
124
					$this->content_type = 'application/x-www-form-urlencoded';
125
				}
126
127
				if ( 0 === strpos( strtolower( $this->content_type ), 'multipart/' ) ) {
128
					$this->post_body    = http_build_query( stripslashes_deep( $_POST ) );
129
					$this->files        = $_FILES;
130
					$this->content_type = 'multipart/form-data';
131
				}
132
			} else {
133
				$this->post_body    = $post_body;
134
				$this->content_type = '{' === isset( $this->post_body[0] ) && $this->post_body[0] ? 'application/json' : 'application/x-www-form-urlencoded';
135
			}
136
		} else {
137
			$this->post_body    = null;
138
			$this->content_type = null;
139
		}
140
141
		$this->_server_https = array_key_exists( 'HTTPS', $_SERVER ) ? $_SERVER['HTTPS'] : '--UNset--';
142
	}
143
144
	function initialize() {
145
		$this->token_details['blog_id'] = Jetpack_Options::get_option( 'id' );
146
	}
147
148
	/**
149
	 * Checks if the current request is authorized with a blog token.
150
	 *
151
	 * @since 8.9.1
152
	 *
153
	 * @param  boolean|number $site_id The site id.
154
	 * @return boolean
155
	 */
156
	public function is_jetpack_authorized_for_site( $site_id = false ) {
157
		if ( ! $this->token_details ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->token_details of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
158
			return false;
159
		}
160
161
		$token_details = (object) $this->token_details;
162
163
		$site_in_token = (int) $token_details->blog_id;
164
165
		if ( $site_in_token < 1 ) {
166
			return false;
167
		}
168
169
		if ( $site_id && $site_in_token !== (int) $site_id ) {
170
			return false;
171
		}
172
173
		if ( (int) get_current_user_id() !== 0 ) {
174
			// If Jetpack blog token is used, no logged-in user should exist.
175
			return false;
176
		}
177
178
		return true;
179
	}
180
181
	function serve( $exit = true ) {
182
		ini_set( 'display_errors', false );
183
184
		$this->exit = (bool) $exit;
185
186
		// This was causing problems with Jetpack, but is necessary for wpcom
187
		// @see https://github.com/Automattic/jetpack/pull/2603
188
		// @see r124548-wpcom
189
		if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
190
			add_filter( 'home_url', array( $this, 'ensure_http_scheme_of_home_url' ), 10, 3 );
191
		}
192
193
		add_filter( 'user_can_richedit', '__return_true' );
194
195
		add_filter( 'comment_edit_pre', array( $this, 'comment_edit_pre' ) );
196
197
		$initialization = $this->initialize();
198
		if ( 'OPTIONS' == $this->method ) {
199
			/**
200
			 * Fires before the page output.
201
			 * Can be used to specify custom header options.
202
			 *
203
			 * @module json-api
204
			 *
205
			 * @since 3.1.0
206
			 */
207
			do_action( 'wpcom_json_api_options' );
208
			return $this->output( 200, '', 'text/plain' );
209
		}
210
211
		if ( is_wp_error( $initialization ) ) {
212
			$this->output_error( $initialization );
213
			return;
214
		}
215
216
		// Normalize path and extract API version
217
		$this->path = untrailingslashit( $this->path );
218
		preg_match( '#^/rest/v(\d+(\.\d+)*)#', $this->path, $matches );
219
		$this->path    = substr( $this->path, strlen( $matches[0] ) );
220
		$this->version = $matches[1];
221
222
		$allowed_methods = array( 'GET', 'POST' );
223
		$four_oh_five    = false;
224
225
		$is_help            = preg_match( '#/help/?$#i', $this->path );
226
		$matching_endpoints = array();
227
228
		if ( $is_help ) {
229
			$origin = get_http_origin();
230
231
			if ( ! empty( $origin ) && 'GET' == $this->method ) {
232
				header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
233
			}
234
235
			$this->path = substr( rtrim( $this->path, '/' ), 0, -5 );
236
			// Show help for all matching endpoints regardless of method
237
			$methods                     = $allowed_methods;
238
			$find_all_matching_endpoints = true;
239
			// How deep to truncate each endpoint's path to see if it matches this help request
240
			$depth = substr_count( $this->path, '/' ) + 1;
241
			if ( false !== stripos( $this->accept, 'javascript' ) || false !== stripos( $this->accept, 'json' ) ) {
242
				$help_content_type = 'json';
243
			} else {
244
				$help_content_type = 'html';
245
			}
246
		} else {
247
			if ( in_array( $this->method, $allowed_methods ) ) {
248
				// Only serve requested method
249
				$methods                     = array( $this->method );
250
				$find_all_matching_endpoints = false;
251
			} else {
252
				// We don't allow this requested method - find matching endpoints and send 405
253
				$methods                     = $allowed_methods;
254
				$find_all_matching_endpoints = true;
255
				$four_oh_five                = true;
256
			}
257
		}
258
259
		// Find which endpoint to serve
260
		$found = false;
261
		foreach ( $this->endpoints as $endpoint_path_versions => $endpoints_by_method ) {
262
			$endpoint_path_versions = unserialize( $endpoint_path_versions );
263
			$endpoint_path          = $endpoint_path_versions[0];
264
			$endpoint_min_version   = $endpoint_path_versions[1];
265
			$endpoint_max_version   = $endpoint_path_versions[2];
266
267
			// Make sure max_version is not less than min_version
268
			if ( version_compare( $endpoint_max_version, $endpoint_min_version, '<' ) ) {
269
				$endpoint_max_version = $endpoint_min_version;
270
			}
271
272
			foreach ( $methods as $method ) {
273
				if ( ! isset( $endpoints_by_method[ $method ] ) ) {
274
					continue;
275
				}
276
277
				// Normalize
278
				$endpoint_path = untrailingslashit( $endpoint_path );
279
				if ( $is_help ) {
280
					// Truncate path at help depth
281
					$endpoint_path = join( '/', array_slice( explode( '/', $endpoint_path ), 0, $depth ) );
282
				}
283
284
				// Generate regular expression from sprintf()
285
				$endpoint_path_regex = str_replace( array( '%s', '%d' ), array( '([^/?&]+)', '(\d+)' ), $endpoint_path );
286
287
				if ( ! preg_match( "#^$endpoint_path_regex\$#", $this->path, $path_pieces ) ) {
288
					// This endpoint does not match the requested path.
289
					continue;
290
				}
291
292
				if ( version_compare( $this->version, $endpoint_min_version, '<' ) || version_compare( $this->version, $endpoint_max_version, '>' ) ) {
293
					// This endpoint does not match the requested version.
294
					continue;
295
				}
296
297
				$found = true;
298
299
				if ( $find_all_matching_endpoints ) {
300
					$matching_endpoints[] = array( $endpoints_by_method[ $method ], $path_pieces );
301
				} else {
302
					// The method parameters are now in $path_pieces
303
					$endpoint = $endpoints_by_method[ $method ];
304
					break 2;
305
				}
306
			}
307
		}
308
309
		if ( ! $found ) {
310
			return $this->output( 404, '', 'text/plain' );
311
		}
312
313
		if ( $four_oh_five ) {
314
			$allowed_methods = array();
315
			foreach ( $matching_endpoints as $matching_endpoint ) {
316
				$allowed_methods[] = $matching_endpoint[0]->method;
317
			}
318
319
			header( 'Allow: ' . strtoupper( join( ',', array_unique( $allowed_methods ) ) ) );
320
			return $this->output(
321
				405,
322
				array(
323
					'error'         => 'not_allowed',
324
					'error_message' => 'Method not allowed',
325
				)
326
			);
327
		}
328
329
		if ( $is_help ) {
330
			/**
331
			 * Fires before the API output.
332
			 *
333
			 * @since 1.9.0
334
			 *
335
			 * @param string help.
336
			 */
337
			do_action( 'wpcom_json_api_output', 'help' );
338
			$proxied = function_exists( 'wpcom_is_proxied_request' ) ? wpcom_is_proxied_request() : false;
339
			if ( 'json' === $help_content_type ) {
340
				$docs = array();
341
				foreach ( $matching_endpoints as $matching_endpoint ) {
342
					if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
343
						$docs[] = call_user_func( array( $matching_endpoint[0], 'generate_documentation' ) );
344
					}
345
				}
346
				return $this->output( 200, $docs );
347
			} else {
348
				status_header( 200 );
349
				foreach ( $matching_endpoints as $matching_endpoint ) {
350
					if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
351
						call_user_func( array( $matching_endpoint[0], 'document' ) );
352
					}
353
				}
354
			}
355
			exit;
356
		}
357
358
		if ( $endpoint->in_testing && ! WPCOM_JSON_API__DEBUG ) {
359
			return $this->output( 404, '', 'text/plain' );
360
		}
361
362
		/** This action is documented in class.json-api.php */
363
		do_action( 'wpcom_json_api_output', $endpoint->stat );
364
365
		$response = $this->process_request( $endpoint, $path_pieces );
366
367
		if ( ! $response && ! is_array( $response ) ) {
368
			return $this->output( 500, '', 'text/plain' );
369
		} elseif ( is_wp_error( $response ) ) {
370
			return $this->output_error( $response );
371
		}
372
373
		$output_status_code = $this->output_status_code;
374
		$this->set_output_status_code();
375
376
		return $this->output( $output_status_code, $response, 'application/json', $this->extra_headers );
377
	}
378
379
	function process_request( WPCOM_JSON_API_Endpoint $endpoint, $path_pieces ) {
380
		$this->endpoint = $endpoint;
381
		return call_user_func_array( array( $endpoint, 'callback' ), $path_pieces );
382
	}
383
384
	function output_early( $status_code, $response = null, $content_type = 'application/json' ) {
385
		$exit       = $this->exit;
386
		$this->exit = false;
387
		if ( is_wp_error( $response ) ) {
388
			$this->output_error( $response );
389
		} else {
390
			$this->output( $status_code, $response, $content_type );
391
		}
392
		$this->exit = $exit;
393
		if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
394
			$this->finish_request();
395
		}
396
	}
397
398
	function set_output_status_code( $code = 200 ) {
399
		$this->output_status_code = $code;
400
	}
401
402
	function output( $status_code, $response = null, $content_type = 'application/json', $extra = array() ) {
403
		// In case output() was called before the callback returned
404
		if ( $this->did_output ) {
405
			if ( $this->exit ) {
406
				exit;
407
			}
408
			return $content_type;
409
		}
410
		$this->did_output = true;
411
412
		// 400s and 404s are allowed for all origins
413
		if ( 404 == $status_code || 400 == $status_code ) {
414
			header( 'Access-Control-Allow-Origin: *' );
415
		}
416
417
		/* Add headers for form submission from <amp-form/> */
418
		if ( $this->amp_source_origin ) {
419
			header( 'Access-Control-Allow-Origin: ' . wp_unslash( $this->amp_source_origin ) );
420
			header( 'Access-Control-Allow-Credentials: true' );
421
		}
422
423
424
		if ( is_null( $response ) ) {
425
			$response = new stdClass();
426
		}
427
428
		if ( 'text/plain' === $content_type ||
429
			'text/html' === $content_type ) {
430
			status_header( (int) $status_code );
431
			header( 'Content-Type: ' . $content_type );
432
			foreach ( $extra as $key => $value ) {
433
				header( "$key: $value" );
434
			}
435
			echo $response;
436
			if ( $this->exit ) {
437
				exit;
438
			}
439
440
			return $content_type;
441
		}
442
443
		$response = $this->filter_fields( $response );
444
445
		if ( isset( $this->query['http_envelope'] ) && self::is_truthy( $this->query['http_envelope'] ) ) {
446
			$headers = array(
447
				array(
448
					'name'  => 'Content-Type',
449
					'value' => $content_type,
450
				),
451
			);
452
453
			foreach ( $extra as $key => $value ) {
454
				$headers[] = array(
455
					'name'  => $key,
456
					'value' => $value,
457
				);
458
			}
459
460
			$response     = array(
461
				'code'    => (int) $status_code,
462
				'headers' => $headers,
463
				'body'    => $response,
464
			);
465
			$status_code  = 200;
466
			$content_type = 'application/json';
467
		}
468
469
		status_header( (int) $status_code );
470
		header( "Content-Type: $content_type" );
471
		if ( isset( $this->query['callback'] ) && is_string( $this->query['callback'] ) ) {
472
			$callback = preg_replace( '/[^a-z0-9_.]/i', '', $this->query['callback'] );
473
		} else {
474
			$callback = false;
475
		}
476
477
		if ( $callback ) {
478
			// Mitigate Rosetta Flash [1] by setting the Content-Type-Options: nosniff header
479
			// and by prepending the JSONP response with a JS comment.
480
			// [1] https://blog.miki.it/2014/7/8/abusing-jsonp-with-rosetta-flash/index.html
481
			echo "/**/$callback(";
482
483
		}
484
		echo $this->json_encode( $response );
485
		if ( $callback ) {
486
			echo ');';
487
		}
488
489
		if ( $this->exit ) {
490
			exit;
491
		}
492
493
		return $content_type;
494
	}
495
496
	public static function serializable_error( $error ) {
497
498
		$status_code = $error->get_error_data();
499
500
		if ( is_array( $status_code ) ) {
501
			$status_code = $status_code['status_code'];
502
		}
503
504
		if ( ! $status_code ) {
505
			$status_code = 400;
506
		}
507
		$response = array(
508
			'error'   => $error->get_error_code(),
509
			'message' => $error->get_error_message(),
510
		);
511
512
		if ( $additional_data = $error->get_error_data( 'additional_data' ) ) {
513
			$response['data'] = $additional_data;
514
		}
515
516
		return array(
517
			'status_code' => $status_code,
518
			'errors'      => $response,
519
		);
520
	}
521
522
	function output_error( $error ) {
523
		$error_response = $this->serializable_error( $error );
524
525
		return $this->output( $error_response['status_code'], $error_response['errors'] );
526
	}
527
528
	function filter_fields( $response ) {
529
		if ( empty( $this->query['fields'] ) || ( is_array( $response ) && ! empty( $response['error'] ) ) || ! empty( $this->endpoint->custom_fields_filtering ) ) {
530
			return $response;
531
		}
532
533
		$fields = array_map( 'trim', explode( ',', $this->query['fields'] ) );
534
535
		if ( is_object( $response ) ) {
536
			$response = (array) $response;
537
		}
538
539
		$has_filtered = false;
540
		if ( is_array( $response ) && empty( $response['ID'] ) ) {
541
			$keys_to_filter = array(
542
				'categories',
543
				'comments',
544
				'connections',
545
				'domains',
546
				'groups',
547
				'likes',
548
				'media',
549
				'notes',
550
				'posts',
551
				'services',
552
				'sites',
553
				'suggestions',
554
				'tags',
555
				'themes',
556
				'topics',
557
				'users',
558
			);
559
560
			foreach ( $keys_to_filter as $key_to_filter ) {
561
				if ( ! isset( $response[ $key_to_filter ] ) || $has_filtered ) {
562
					continue;
563
				}
564
565
				foreach ( $response[ $key_to_filter ] as $key => $values ) {
566
					if ( is_object( $values ) ) {
567
						if ( is_object( $response[ $key_to_filter ] ) ) {
568
							$response[ $key_to_filter ]->$key = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
569 View Code Duplication
						} elseif ( is_array( $response[ $key_to_filter ] ) ) {
570
							$response[ $key_to_filter ][ $key ] = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
571
						}
572 View Code Duplication
					} elseif ( is_array( $values ) ) {
573
						$response[ $key_to_filter ][ $key ] = array_intersect_key( $values, array_flip( $fields ) );
574
					}
575
				}
576
577
				$has_filtered = true;
578
			}
579
		}
580
581
		if ( ! $has_filtered ) {
582
			if ( is_object( $response ) ) {
583
				$response = (object) array_intersect_key( (array) $response, array_flip( $fields ) );
584
			} elseif ( is_array( $response ) ) {
585
				$response = array_intersect_key( $response, array_flip( $fields ) );
586
			}
587
		}
588
589
		return $response;
590
	}
591
592
	function ensure_http_scheme_of_home_url( $url, $path, $original_scheme ) {
593
		if ( $original_scheme ) {
594
			return $url;
595
		}
596
597
		return preg_replace( '#^https:#', 'http:', $url );
598
	}
599
600
	function comment_edit_pre( $comment_content ) {
601
		return htmlspecialchars_decode( $comment_content, ENT_QUOTES );
602
	}
603
604
	function json_encode( $data ) {
605
		return wp_json_encode( $data );
606
	}
607
608
	function ends_with( $haystack, $needle ) {
609
		return $needle === substr( $haystack, -strlen( $needle ) );
610
	}
611
612
	// Returns the site's blog_id in the WP.com ecosystem
613
	function get_blog_id_for_output() {
614
		return $this->token_details['blog_id'];
615
	}
616
617
	// Returns the site's local blog_id
618
	function get_blog_id( $blog_id ) {
619
		return $GLOBALS['blog_id'];
620
	}
621
622
	function switch_to_blog_and_validate_user( $blog_id = 0, $verify_token_for_blog = true ) {
623
		if ( $this->is_restricted_blog( $blog_id ) ) {
624
			return new WP_Error( 'unauthorized', 'User cannot access this restricted blog', 403 );
625
		}
626
		/**
627
		 * If this is a private site we check for 2 things:
628
		 * 1. In case of user based authentication, we need to check if the logged-in user has the 'read' capability.
629
		 * 2. In case of site based authentication, make sure the endpoint allows it and no user is logged-in.
630
		 */
631
		if ( -1 === get_option( 'blog_public' ) &&
632
			! current_user_can( 'read' ) &&
633
			! $this->endpoint->allows_site_based_authentication()
634
		) {
635
			return new WP_Error( 'unauthorized', 'User cannot access this private blog.', 403 );
636
		}
637
638
		return $blog_id;
639
	}
640
641
	// Returns true if the specified blog ID is a restricted blog
642
	function is_restricted_blog( $blog_id ) {
643
		/**
644
		 * Filters all REST API access and return a 403 unauthorized response for all Restricted blog IDs.
645
		 *
646
		 * @module json-api
647
		 *
648
		 * @since 3.4.0
649
		 *
650
		 * @param array $array Array of Blog IDs.
651
		 */
652
		$restricted_blog_ids = apply_filters( 'wpcom_json_api_restricted_blog_ids', array() );
653
		return true === in_array( $blog_id, $restricted_blog_ids );
654
	}
655
656
	function post_like_count( $blog_id, $post_id ) {
657
		return 0;
658
	}
659
660
	function is_liked( $blog_id, $post_id ) {
661
		return false;
662
	}
663
664
	function is_reblogged( $blog_id, $post_id ) {
665
		return false;
666
	}
667
668
	function is_following( $blog_id ) {
669
		return false;
670
	}
671
672
	function add_global_ID( $blog_id, $post_id ) {
673
		return '';
674
	}
675
676
	function get_avatar_url( $email, $avatar_size = null ) {
677
		if ( function_exists( 'wpcom_get_avatar_url' ) ) {
678
			return null === $avatar_size
679
				? wpcom_get_avatar_url( $email )
680
				: wpcom_get_avatar_url( $email, $avatar_size );
681
		} else {
682
			return null === $avatar_size
683
				? get_avatar_url( $email )
684
				: get_avatar_url( $email, $avatar_size );
685
		}
686
	}
687
688
	/**
689
	 * Counts the number of comments on a site, excluding certain comment types.
690
	 *
691
	 * @param $post_id int Post ID.
692
	 * @return array Array of counts, matching the output of https://developer.wordpress.org/reference/functions/get_comment_count/.
693
	 */
694
	public function wp_count_comments( $post_id ) {
695
		global $wpdb;
696
		if ( 0 !== $post_id ) {
697
			return wp_count_comments( $post_id );
698
		}
699
700
		$counts = array(
701
			'total_comments' => 0,
702
			'all'            => 0,
703
		);
704
705
		/**
706
		 * Exclude certain comment types from comment counts in the REST API.
707
		 *
708
		 * @since 6.9.0
709
		 * @module json-api
710
		 *
711
		 * @param array Array of comment types to exclude (default: 'order_note', 'webhook_delivery', 'review', 'action_log')
712
		 */
713
		$exclude = apply_filters(
714
			'jetpack_api_exclude_comment_types_count',
715
			array( 'order_note', 'webhook_delivery', 'review', 'action_log' )
716
		);
717
718
		if ( empty( $exclude ) ) {
719
			return wp_count_comments( $post_id );
720
		}
721
722
		array_walk( $exclude, 'esc_sql' );
723
		$where = sprintf(
724
			"WHERE comment_type NOT IN ( '%s' )",
725
			implode( "','", $exclude )
726
		);
727
728
		$count = $wpdb->get_results(
729
			"SELECT comment_approved, COUNT(*) AS num_comments
730
				FROM $wpdb->comments
731
				{$where}
732
				GROUP BY comment_approved
733
			"
734
		);
735
736
		$approved = array(
737
			'0'            => 'moderated',
738
			'1'            => 'approved',
739
			'spam'         => 'spam',
740
			'trash'        => 'trash',
741
			'post-trashed' => 'post-trashed',
742
		);
743
744
		// https://developer.wordpress.org/reference/functions/get_comment_count/#source
745
		foreach ( $count as $row ) {
746
			if ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash', 'spam' ), true ) ) {
747
				$counts['all']            += $row->num_comments;
748
				$counts['total_comments'] += $row->num_comments;
749
			} elseif ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash' ), true ) ) {
750
				$counts['total_comments'] += $row->num_comments;
751
			}
752
			if ( isset( $approved[ $row->comment_approved ] ) ) {
753
				$counts[ $approved[ $row->comment_approved ] ] = $row->num_comments;
754
			}
755
		}
756
757
		foreach ( $approved as $key ) {
758
			if ( empty( $counts[ $key ] ) ) {
759
				$counts[ $key ] = 0;
760
			}
761
		}
762
763
		$counts = (object) $counts;
764
765
		return $counts;
766
	}
767
768
	/**
769
	 * traps `wp_die()` calls and outputs a JSON response instead.
770
	 * The result is always output, never returned.
771
	 *
772
	 * @param string|null $error_code  Call with string to start the trapping.  Call with null to stop.
773
	 * @param int         $http_status  HTTP status code, 400 by default.
774
	 */
775
	function trap_wp_die( $error_code = null, $http_status = 400 ) {
776
		if ( is_null( $error_code ) ) {
777
			$this->trapped_error = null;
778
			// Stop trapping
779
			remove_filter( 'wp_die_handler', array( $this, 'wp_die_handler_callback' ) );
780
			return;
781
		}
782
783
		// If API called via PHP, bail: don't do our custom wp_die().  Do the normal wp_die().
784
		if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
785
			if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
786
				return;
787
			}
788
		} else {
789
			if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
790
				return;
791
			}
792
		}
793
794
		$this->trapped_error = array(
795
			'status'  => $http_status,
796
			'code'    => $error_code,
797
			'message' => '',
798
		);
799
		// Start trapping
800
		add_filter( 'wp_die_handler', array( $this, 'wp_die_handler_callback' ) );
801
	}
802
803
	function wp_die_handler_callback() {
804
		return array( $this, 'wp_die_handler' );
805
	}
806
807
	function wp_die_handler( $message, $title = '', $args = array() ) {
808
		// Allow wp_die calls to override HTTP status code...
809
		$args = wp_parse_args(
810
			$args,
811
			array(
812
				'response' => $this->trapped_error['status'],
813
			)
814
		);
815
816
		// ... unless it's 500
817
		if ( (int) $args['response'] !== 500 ) {
818
			$this->trapped_error['status'] = $args['response'];
819
		}
820
821
		if ( $title ) {
822
			$message = "$title: $message";
823
		}
824
825
		$this->trapped_error['message'] = wp_kses( $message, array() );
826
827
		switch ( $this->trapped_error['code'] ) {
828
			case 'comment_failure':
829
				if ( did_action( 'comment_duplicate_trigger' ) ) {
830
					$this->trapped_error['code'] = 'comment_duplicate';
831
				} elseif ( did_action( 'comment_flood_trigger' ) ) {
832
					$this->trapped_error['code'] = 'comment_flood';
833
				}
834
				break;
835
		}
836
837
		// We still want to exit so that code execution stops where it should.
838
		// Attach the JSON output to the WordPress shutdown handler
839
		add_action( 'shutdown', array( $this, 'output_trapped_error' ), 0 );
840
		exit;
841
	}
842
843
	function output_trapped_error() {
844
		$this->exit = false; // We're already exiting once.  Don't do it twice.
845
		$this->output(
846
			$this->trapped_error['status'],
847
			(object) array(
848
				'error'   => $this->trapped_error['code'],
849
				'message' => $this->trapped_error['message'],
850
			)
851
		);
852
	}
853
854
	function finish_request() {
855
		if ( function_exists( 'fastcgi_finish_request' ) ) {
856
			return fastcgi_finish_request();
857
		}
858
	}
859
}
860