PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 10.7.2
Jetpack – WP Security, Backup, Speed, & Growth v10.7.2
15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / class.json-api.php
jetpack Last commit date
3rd-party 4 years ago _inc 4 years ago css 4 years ago extensions 4 years ago images 4 years ago jetpack_vendor 4 years ago json-endpoints 3 years ago modules 1 year ago sal 4 years ago src 4 years ago vendor 4 years ago views 4 years ago CHANGELOG.md 4 years ago LICENSE.txt 5 years ago SECURITY.md 5 years ago class-jetpack-connection-status.php 5 years ago class-jetpack-pre-connection-jitms.php 4 years ago class-jetpack-recommendations-banner.php 4 years ago class-jetpack-stats-dashboard-widget.php 4 years ago class-jetpack-wizard-banner.php 5 years ago class-jetpack-xmlrpc-methods.php 5 years ago class.frame-nonce-preview.php 4 years ago class.jetpack-admin.php 4 years ago class.jetpack-affiliate.php 4 years ago class.jetpack-autoupdate.php 4 years ago class.jetpack-bbpress-json-api.compat.php 5 years ago class.jetpack-cli.php 4 years ago class.jetpack-client-server.php 4 years ago class.jetpack-connection-banner.php 4 years ago class.jetpack-data.php 5 years ago class.jetpack-gutenberg.php 4 years ago class.jetpack-heartbeat.php 4 years ago class.jetpack-idc.php 4 years ago class.jetpack-modules-list-table.php 4 years ago class.jetpack-network-sites-list-table.php 4 years ago class.jetpack-network.php 4 years ago class.jetpack-plan.php 4 years ago class.jetpack-post-images.php 4 years ago class.jetpack-twitter-cards.php 5 years ago class.jetpack-user-agent.php 4 years ago class.jetpack.php 4 years ago class.json-api-endpoints.php 3 years ago class.json-api.php 4 years ago class.photon.php 4 years ago composer.json 4 years ago functions.compat.php 5 years ago functions.cookies.php 5 years ago functions.gallery.php 6 years ago functions.global.php 4 years ago functions.opengraph.php 4 years ago functions.photon.php 4 years ago jest.config.js 4 years ago jetpack.php 1 year ago json-api-config.php 5 years ago json-endpoints.php 7 years ago load-jetpack.php 4 years ago locales.php 7 years ago readme.txt 1 year ago require-lib.php 5 years ago uninstall.php 5 years ago wpml-config.xml 10 years ago
class.json-api.php
1208 lines
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 * Token details.
35 *
36 * @var array
37 */
38 public $token_details = array();
39
40 /**
41 * Request HTTP method.
42 *
43 * @var string
44 */
45 public $method = '';
46
47 /**
48 * Request URL.
49 *
50 * @var string
51 */
52 public $url = '';
53
54 /**
55 * Path part of the request URL.
56 *
57 * @var string
58 */
59 public $path = '';
60
61 /**
62 * Version extracted from the request URL.
63 *
64 * @var string|null
65 */
66 public $version = null;
67
68 /**
69 * Parsed query data.
70 *
71 * @var array
72 */
73 public $query = array();
74
75 /**
76 * Post body, if the request is a POST.
77 *
78 * @var string|null
79 */
80 public $post_body = null;
81
82 /**
83 * Copy of `$_FILES` if the request is a POST.
84 *
85 * @var null|array
86 */
87 public $files = null;
88
89 /**
90 * Content type of the request.
91 *
92 * @var string|null
93 */
94 public $content_type = null;
95
96 /**
97 * Value of `$_SERVER['HTTP_ACCEPT']`, if any
98 *
99 * @var string
100 */
101 public $accept = '';
102
103 /**
104 * Value of `$_SERVER['HTTPS']`, or "--UNset--" if unset.
105 *
106 * @var string
107 */
108 public $_server_https; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
109
110 /**
111 * Whether to exit after serving a response.
112 *
113 * @var bool
114 */
115 public $exit = true;
116
117 /**
118 * Public API scheme.
119 *
120 * @var string
121 */
122 public $public_api_scheme = 'https';
123
124 /**
125 * Output status code.
126 *
127 * @var int
128 */
129 public $output_status_code = 200;
130
131 /**
132 * Trapped error.
133 *
134 * @var null|array
135 */
136 public $trapped_error = null;
137
138 /**
139 * Whether output has been done.
140 *
141 * @var bool
142 */
143 public $did_output = false;
144
145 /**
146 * Extra HTTP headers.
147 *
148 * @var string
149 */
150 public $extra_headers = array();
151
152 /**
153 * AMP source origin.
154 *
155 * @var string
156 */
157 public $amp_source_origin = null;
158
159 /**
160 * Initialize.
161 *
162 * @param string|null $method As for `$this->setup_inputs()`.
163 * @param string|null $url As for `$this->setup_inputs()`.
164 * @param string|null $post_body As for `$this->setup_inputs()`.
165 * @return WPCOM_JSON_API instance
166 */
167 public static function init( $method = null, $url = null, $post_body = null ) {
168 if ( ! self::$self ) {
169 $class = function_exists( 'get_called_class' ) ? get_called_class() : __CLASS__; // phpcs:ignore PHPCompatibility.PHP.NewFunctions.get_called_classFound
170 self::$self = new $class( $method, $url, $post_body );
171 }
172 return self::$self;
173 }
174
175 /**
176 * Add an endpoint.
177 *
178 * @param WPCOM_JSON_API_Endpoint $endpoint Endpoint to add.
179 */
180 public function add( WPCOM_JSON_API_Endpoint $endpoint ) {
181 // @todo Determine if anything depends on this being serialized rather than e.g. JSON.
182 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Legacy, possibly depended on elsewhere.
183 $path_versions = serialize(
184 array(
185 $endpoint->path,
186 $endpoint->min_version,
187 $endpoint->max_version,
188 )
189 );
190 if ( ! isset( $this->endpoints[ $path_versions ] ) ) {
191 $this->endpoints[ $path_versions ] = array();
192 }
193 $this->endpoints[ $path_versions ][ $endpoint->method ] = $endpoint;
194 }
195
196 /**
197 * Determine if a string is truthy.
198 *
199 * @param string $value "1", "t", and "true" (case insensitive) are falsey, everything else isn't.
200 * @return bool
201 */
202 public static function is_truthy( $value ) {
203 switch ( strtolower( (string) $value ) ) {
204 case '1':
205 case 't':
206 case 'true':
207 return true;
208 }
209
210 return false;
211 }
212
213 /**
214 * Determine if a string is falsey.
215 *
216 * @param string $value "0", "f", and "false" (case insensitive) are falsey, everything else isn't.
217 * @return bool
218 */
219 public static function is_falsy( $value ) {
220 switch ( strtolower( (string) $value ) ) {
221 case '0':
222 case 'f':
223 case 'false':
224 return true;
225 }
226
227 return false;
228 }
229
230 /**
231 * Constructor.
232 *
233 * @todo This should be private.
234 * @param string|null $method As for `$this->setup_inputs()`.
235 * @param string|null $url As for `$this->setup_inputs()`.
236 * @param string|null $post_body As for `$this->setup_inputs()`.
237 */
238 public function __construct( $method = null, $url = null, $post_body = null ) {
239 $this->setup_inputs( $method, $url, $post_body );
240 }
241
242 /**
243 * Setup inputs.
244 *
245 * @param string|null $method Request HTTP method. Fetched from `$_SERVER` if null.
246 * @param string|null $url URL requested. Determined from `$_SERVER` if null.
247 * @param string|null $post_body POST body. Read from `php://input` if null and method is POST.
248 */
249 public function setup_inputs( $method = null, $url = null, $post_body = null ) {
250 if ( is_null( $method ) ) {
251 $this->method = strtoupper( $_SERVER['REQUEST_METHOD'] );
252 } else {
253 $this->method = strtoupper( $method );
254 }
255 if ( is_null( $url ) ) {
256 $this->url = set_url_scheme( 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] );
257 } else {
258 $this->url = $url;
259 }
260
261 $parsed = wp_parse_url( $this->url );
262 if ( ! empty( $parsed['path'] ) ) {
263 $this->path = $parsed['path'];
264 }
265
266 if ( ! empty( $parsed['query'] ) ) {
267 wp_parse_str( $parsed['query'], $this->query );
268 }
269
270 if ( isset( $_SERVER['HTTP_ACCEPT'] ) && $_SERVER['HTTP_ACCEPT'] ) {
271 $this->accept = $_SERVER['HTTP_ACCEPT'];
272 }
273
274 if ( 'POST' === $this->method ) {
275 if ( is_null( $post_body ) ) {
276 $this->post_body = file_get_contents( 'php://input' );
277
278 if ( isset( $_SERVER['HTTP_CONTENT_TYPE'] ) && $_SERVER['HTTP_CONTENT_TYPE'] ) {
279 $this->content_type = $_SERVER['HTTP_CONTENT_TYPE'];
280 } elseif ( isset( $_SERVER['CONTENT_TYPE'] ) && $_SERVER['CONTENT_TYPE'] ) {
281 $this->content_type = $_SERVER['CONTENT_TYPE'];
282 } elseif ( '{' === $this->post_body[0] ) {
283 $this->content_type = 'application/json';
284 } else {
285 $this->content_type = 'application/x-www-form-urlencoded';
286 }
287
288 if ( 0 === strpos( strtolower( $this->content_type ), 'multipart/' ) ) {
289 // phpcs:ignore WordPress.Security.NonceVerification.Missing
290 $this->post_body = http_build_query( stripslashes_deep( $_POST ) );
291 $this->files = $_FILES;
292 $this->content_type = 'multipart/form-data';
293 }
294 } else {
295 $this->post_body = $post_body;
296 $this->content_type = isset( $this->post_body[0] ) && '{' === $this->post_body[0] ? 'application/json' : 'application/x-www-form-urlencoded';
297 }
298 } else {
299 $this->post_body = null;
300 $this->content_type = null;
301 }
302
303 $this->_server_https = array_key_exists( 'HTTPS', $_SERVER ) ? $_SERVER['HTTPS'] : '--UNset--';
304 }
305
306 /**
307 * Initialize.
308 *
309 * @return null|WP_Error (although this implementation always returns null)
310 */
311 public function initialize() {
312 $this->token_details['blog_id'] = Jetpack_Options::get_option( 'id' );
313 return null;
314 }
315
316 /**
317 * Checks if the current request is authorized with a blog token.
318 * This method is overridden by a child class in WPCOM.
319 *
320 * @since 9.1.0
321 *
322 * @param boolean|int $site_id The site id.
323 * @return boolean
324 */
325 public function is_jetpack_authorized_for_site( $site_id = false ) {
326 if ( ! $this->token_details ) {
327 return false;
328 }
329
330 $token_details = (object) $this->token_details;
331
332 $site_in_token = (int) $token_details->blog_id;
333
334 if ( $site_in_token < 1 ) {
335 return false;
336 }
337
338 if ( $site_id && $site_in_token !== (int) $site_id ) {
339 return false;
340 }
341
342 if ( (int) get_current_user_id() !== 0 ) {
343 // If Jetpack blog token is used, no logged-in user should exist.
344 return false;
345 }
346
347 return true;
348 }
349
350 /**
351 * Serve.
352 *
353 * @param bool $exit Whether to exit.
354 * @return string|null Content type (assuming it didn't exit), or null in certain error cases.
355 */
356 public function serve( $exit = true ) {
357 ini_set( 'display_errors', false ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Blacklisted
358
359 $this->exit = (bool) $exit;
360
361 // This was causing problems with Jetpack, but is necessary for wpcom
362 // @see https://github.com/Automattic/jetpack/pull/2603
363 // @see r124548-wpcom .
364 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
365 add_filter( 'home_url', array( $this, 'ensure_http_scheme_of_home_url' ), 10, 3 );
366 }
367
368 add_filter( 'user_can_richedit', '__return_true' );
369
370 add_filter( 'comment_edit_pre', array( $this, 'comment_edit_pre' ) );
371
372 $initialization = $this->initialize();
373 if ( 'OPTIONS' === $this->method ) {
374 /**
375 * Fires before the page output.
376 * Can be used to specify custom header options.
377 *
378 * @module json-api
379 *
380 * @since 3.1.0
381 */
382 do_action( 'wpcom_json_api_options' );
383 return $this->output( 200, '', 'text/plain' );
384 }
385
386 if ( is_wp_error( $initialization ) ) {
387 $this->output_error( $initialization );
388 return;
389 }
390
391 // Normalize path and extract API version.
392 $this->path = untrailingslashit( $this->path );
393 preg_match( '#^/rest/v(\d+(\.\d+)*)#', $this->path, $matches );
394 $this->path = substr( $this->path, strlen( $matches[0] ) );
395 $this->version = $matches[1];
396
397 $allowed_methods = array( 'GET', 'POST' );
398 $four_oh_five = false;
399
400 $is_help = preg_match( '#/help/?$#i', $this->path );
401 $matching_endpoints = array();
402
403 if ( $is_help ) {
404 $origin = get_http_origin();
405
406 if ( ! empty( $origin ) && 'GET' === $this->method ) {
407 header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
408 }
409
410 $this->path = substr( rtrim( $this->path, '/' ), 0, -5 );
411 // Show help for all matching endpoints regardless of method.
412 $methods = $allowed_methods;
413 $find_all_matching_endpoints = true;
414 // How deep to truncate each endpoint's path to see if it matches this help request.
415 $depth = substr_count( $this->path, '/' ) + 1;
416 if ( false !== stripos( $this->accept, 'javascript' ) || false !== stripos( $this->accept, 'json' ) ) {
417 $help_content_type = 'json';
418 } else {
419 $help_content_type = 'html';
420 }
421 } else {
422 if ( in_array( $this->method, $allowed_methods, true ) ) {
423 // Only serve requested method.
424 $methods = array( $this->method );
425 $find_all_matching_endpoints = false;
426 } else {
427 // We don't allow this requested method - find matching endpoints and send 405.
428 $methods = $allowed_methods;
429 $find_all_matching_endpoints = true;
430 $four_oh_five = true;
431 }
432 }
433
434 // Find which endpoint to serve.
435 $found = false;
436 foreach ( $this->endpoints as $endpoint_path_versions => $endpoints_by_method ) {
437 // @todo Determine if anything depends on this being serialized rather than e.g. JSON.
438 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Legacy, possibly depended on elsewhere.
439 $endpoint_path_versions = unserialize( $endpoint_path_versions );
440 $endpoint_path = $endpoint_path_versions[0];
441 $endpoint_min_version = $endpoint_path_versions[1];
442 $endpoint_max_version = $endpoint_path_versions[2];
443
444 // Make sure max_version is not less than min_version.
445 if ( version_compare( $endpoint_max_version, $endpoint_min_version, '<' ) ) {
446 $endpoint_max_version = $endpoint_min_version;
447 }
448
449 foreach ( $methods as $method ) {
450 if ( ! isset( $endpoints_by_method[ $method ] ) ) {
451 continue;
452 }
453
454 // Normalize.
455 $endpoint_path = untrailingslashit( $endpoint_path );
456 if ( $is_help ) {
457 // Truncate path at help depth.
458 $endpoint_path = join( '/', array_slice( explode( '/', $endpoint_path ), 0, $depth ) );
459 }
460
461 // Generate regular expression from sprintf().
462 $endpoint_path_regex = str_replace( array( '%s', '%d' ), array( '([^/?&]+)', '(\d+)' ), $endpoint_path );
463
464 if ( ! preg_match( "#^$endpoint_path_regex\$#", $this->path, $path_pieces ) ) {
465 // This endpoint does not match the requested path.
466 continue;
467 }
468
469 if ( version_compare( $this->version, $endpoint_min_version, '<' ) || version_compare( $this->version, $endpoint_max_version, '>' ) ) {
470 // This endpoint does not match the requested version.
471 continue;
472 }
473
474 $found = true;
475
476 if ( $find_all_matching_endpoints ) {
477 $matching_endpoints[] = array( $endpoints_by_method[ $method ], $path_pieces );
478 } else {
479 // The method parameters are now in $path_pieces.
480 $endpoint = $endpoints_by_method[ $method ];
481 break 2;
482 }
483 }
484 }
485
486 if ( ! $found ) {
487 return $this->output( 404, '', 'text/plain' );
488 }
489
490 if ( $four_oh_five ) {
491 $allowed_methods = array();
492 foreach ( $matching_endpoints as $matching_endpoint ) {
493 $allowed_methods[] = $matching_endpoint[0]->method;
494 }
495
496 header( 'Allow: ' . strtoupper( join( ',', array_unique( $allowed_methods ) ) ) );
497 return $this->output(
498 405,
499 array(
500 'error' => 'not_allowed',
501 'error_message' => 'Method not allowed',
502 )
503 );
504 }
505
506 if ( $is_help ) {
507 /**
508 * Fires before the API output.
509 *
510 * @since 1.9.0
511 *
512 * @param string help.
513 */
514 do_action( 'wpcom_json_api_output', 'help' );
515 $proxied = function_exists( 'wpcom_is_proxied_request' ) ? wpcom_is_proxied_request() : false;
516 if ( 'json' === $help_content_type ) {
517 $docs = array();
518 foreach ( $matching_endpoints as $matching_endpoint ) {
519 if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
520 $docs[] = call_user_func( array( $matching_endpoint[0], 'generate_documentation' ) );
521 }
522 }
523 return $this->output( 200, $docs );
524 } else {
525 status_header( 200 );
526 foreach ( $matching_endpoints as $matching_endpoint ) {
527 if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
528 call_user_func( array( $matching_endpoint[0], 'document' ) );
529 }
530 }
531 }
532 exit;
533 }
534
535 if ( $endpoint->in_testing && ! WPCOM_JSON_API__DEBUG ) {
536 return $this->output( 404, '', 'text/plain' );
537 }
538
539 /** This action is documented in class.json-api.php */
540 do_action( 'wpcom_json_api_output', $endpoint->stat );
541
542 $response = $this->process_request( $endpoint, $path_pieces );
543
544 if ( ! $response && ! is_array( $response ) ) {
545 return $this->output( 500, '', 'text/plain' );
546 } elseif ( is_wp_error( $response ) ) {
547 return $this->output_error( $response );
548 }
549
550 $output_status_code = $this->output_status_code;
551 $this->set_output_status_code();
552
553 return $this->output( $output_status_code, $response, 'application/json', $this->extra_headers );
554 }
555
556 /**
557 * Process a request.
558 *
559 * @param WPCOM_JSON_API_Endpoint $endpoint Endpoint.
560 * @param array $path_pieces Path pieces.
561 * @return array|WP_Error Return value from the endpoint's callback.
562 */
563 public function process_request( WPCOM_JSON_API_Endpoint $endpoint, $path_pieces ) {
564 $this->endpoint = $endpoint;
565 return call_user_func_array( array( $endpoint, 'callback' ), $path_pieces );
566 }
567
568 /**
569 * Output a response or error without exiting.
570 *
571 * @param int $status_code HTTP status code.
572 * @param mixed $response Response data.
573 * @param string $content_type Content type of the response.
574 */
575 public function output_early( $status_code, $response = null, $content_type = 'application/json' ) {
576 $exit = $this->exit;
577 $this->exit = false;
578 if ( is_wp_error( $response ) ) {
579 $this->output_error( $response );
580 } else {
581 $this->output( $status_code, $response, $content_type );
582 }
583 $this->exit = $exit;
584 if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
585 $this->finish_request();
586 }
587 }
588
589 /**
590 * Set output status code.
591 *
592 * @param int $code HTTP status code.
593 */
594 public function set_output_status_code( $code = 200 ) {
595 $this->output_status_code = $code;
596 }
597
598 /**
599 * Output a response.
600 *
601 * @param int $status_code HTTP status code.
602 * @param mixed $response Response data.
603 * @param string $content_type Content type of the response.
604 * @param array $extra Additional HTTP headers.
605 * @return string Content type (assuming it didn't exit).
606 */
607 public function output( $status_code, $response = null, $content_type = 'application/json', $extra = array() ) {
608 $status_code = (int) $status_code;
609
610 // In case output() was called before the callback returned.
611 if ( $this->did_output ) {
612 if ( $this->exit ) {
613 exit;
614 }
615 return $content_type;
616 }
617 $this->did_output = true;
618
619 // 400s and 404s are allowed for all origins
620 if ( 404 === $status_code || 400 === $status_code ) {
621 header( 'Access-Control-Allow-Origin: *' );
622 }
623
624 /* Add headers for form submission from <amp-form/> */
625 if ( $this->amp_source_origin ) {
626 header( 'Access-Control-Allow-Origin: ' . wp_unslash( $this->amp_source_origin ) );
627 header( 'Access-Control-Allow-Credentials: true' );
628 }
629
630 if ( is_null( $response ) ) {
631 $response = new stdClass();
632 }
633
634 if ( 'text/plain' === $content_type ||
635 'text/html' === $content_type ) {
636 status_header( (int) $status_code );
637 header( 'Content-Type: ' . $content_type );
638 foreach ( $extra as $key => $value ) {
639 header( "$key: $value" );
640 }
641 echo $response; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
642 if ( $this->exit ) {
643 exit;
644 }
645
646 return $content_type;
647 }
648
649 $response = $this->filter_fields( $response );
650
651 if ( isset( $this->query['http_envelope'] ) && self::is_truthy( $this->query['http_envelope'] ) ) {
652 $headers = array(
653 array(
654 'name' => 'Content-Type',
655 'value' => $content_type,
656 ),
657 );
658
659 foreach ( $extra as $key => $value ) {
660 $headers[] = array(
661 'name' => $key,
662 'value' => $value,
663 );
664 }
665
666 $response = array(
667 'code' => (int) $status_code,
668 'headers' => $headers,
669 'body' => $response,
670 );
671 $status_code = 200;
672 $content_type = 'application/json';
673 }
674
675 status_header( (int) $status_code );
676 header( "Content-Type: $content_type" );
677 if ( isset( $this->query['callback'] ) && is_string( $this->query['callback'] ) ) {
678 $callback = preg_replace( '/[^a-z0-9_.]/i', '', $this->query['callback'] );
679 } else {
680 $callback = false;
681 }
682
683 if ( $callback ) {
684 // Mitigate Rosetta Flash [1] by setting the Content-Type-Options: nosniff header
685 // and by prepending the JSONP response with a JS comment.
686 // [1] <https://blog.miki.it/2014/7/8/abusing-jsonp-with-rosetta-flash/index.html>.
687 echo "/**/$callback("; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- This is JSONP output, not HTML.
688
689 }
690 echo $this->json_encode( $response ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- This is JSON or JSONP output, not HTML.
691 if ( $callback ) {
692 echo ');';
693 }
694
695 if ( $this->exit ) {
696 exit;
697 }
698
699 return $content_type;
700 }
701
702 /**
703 * Serialize an error.
704 *
705 * @param WP_Error $error Error.
706 * @return array with 'status_code' and 'errors' data.
707 */
708 public static function serializable_error( $error ) {
709
710 $status_code = $error->get_error_data();
711
712 if ( is_array( $status_code ) ) {
713 $status_code = $status_code['status_code'];
714 }
715
716 if ( ! $status_code ) {
717 $status_code = 400;
718 }
719 $response = array(
720 'error' => $error->get_error_code(),
721 'message' => $error->get_error_message(),
722 );
723
724 $additional_data = $error->get_error_data( 'additional_data' );
725 if ( $additional_data ) {
726 $response['data'] = $additional_data;
727 }
728
729 return array(
730 'status_code' => $status_code,
731 'errors' => $response,
732 );
733 }
734
735 /**
736 * Output an error.
737 *
738 * @param WP_Error $error Error.
739 * @return string Content type (assuming it didn't exit).
740 */
741 public function output_error( $error ) {
742 $error_response = $this->serializable_error( $error );
743
744 return $this->output( $error_response['status_code'], $error_response['errors'] );
745 }
746
747 /**
748 * Filter fields in a response.
749 *
750 * @param array|object $response Response.
751 * @return array|object Filtered response.
752 */
753 public function filter_fields( $response ) {
754 if ( empty( $this->query['fields'] ) || ( is_array( $response ) && ! empty( $response['error'] ) ) || ! empty( $this->endpoint->custom_fields_filtering ) ) {
755 return $response;
756 }
757
758 $fields = array_map( 'trim', explode( ',', $this->query['fields'] ) );
759
760 if ( is_object( $response ) ) {
761 $response = (array) $response;
762 }
763
764 $has_filtered = false;
765 if ( is_array( $response ) && empty( $response['ID'] ) ) {
766 $keys_to_filter = array(
767 'categories',
768 'comments',
769 'connections',
770 'domains',
771 'groups',
772 'likes',
773 'media',
774 'notes',
775 'posts',
776 'services',
777 'sites',
778 'suggestions',
779 'tags',
780 'themes',
781 'topics',
782 'users',
783 );
784
785 foreach ( $keys_to_filter as $key_to_filter ) {
786 if ( ! isset( $response[ $key_to_filter ] ) || $has_filtered ) {
787 continue;
788 }
789
790 foreach ( $response[ $key_to_filter ] as $key => $values ) {
791 if ( is_object( $values ) ) {
792 if ( is_object( $response[ $key_to_filter ] ) ) {
793 // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found -- False positive.
794 $response[ $key_to_filter ]->$key = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
795 } elseif ( is_array( $response[ $key_to_filter ] ) ) {
796 $response[ $key_to_filter ][ $key ] = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
797 }
798 } elseif ( is_array( $values ) ) {
799 $response[ $key_to_filter ][ $key ] = array_intersect_key( $values, array_flip( $fields ) );
800 }
801 }
802
803 $has_filtered = true;
804 }
805 }
806
807 if ( ! $has_filtered ) {
808 if ( is_object( $response ) ) {
809 $response = (object) array_intersect_key( (array) $response, array_flip( $fields ) );
810 } elseif ( is_array( $response ) ) {
811 $response = array_intersect_key( $response, array_flip( $fields ) );
812 }
813 }
814
815 return $response;
816 }
817
818 /**
819 * Filter for `home_url`.
820 *
821 * If `$original_scheme` is null, turns an https URL to http.
822 *
823 * @param string $url The complete home URL including scheme and path.
824 * @param string $path Path relative to the home URL. Blank string if no path is specified.
825 * @param string|null $original_scheme Scheme to give the home URL context. Accepts 'http', 'https', 'relative', 'rest', or null.
826 * @return string URL.
827 */
828 public function ensure_http_scheme_of_home_url( $url, $path, $original_scheme ) {
829 if ( $original_scheme ) {
830 return $url;
831 }
832
833 return preg_replace( '#^https:#', 'http:', $url );
834 }
835
836 /**
837 * Decode HTML special characters in comment content.
838 *
839 * @param string $comment_content Comment content.
840 * @return string
841 */
842 public function comment_edit_pre( $comment_content ) {
843 return htmlspecialchars_decode( $comment_content, ENT_QUOTES );
844 }
845
846 /**
847 * JSON encode.
848 *
849 * @param mixed $data Data.
850 * @return string|false
851 */
852 public function json_encode( $data ) {
853 return wp_json_encode( $data );
854 }
855
856 /**
857 * Test if a string ends with a string.
858 *
859 * @param string $haystack String to check.
860 * @param string $needle Suffix to check.
861 * @return bool
862 */
863 public function ends_with( $haystack, $needle ) {
864 return substr( $haystack, -strlen( $needle ) ) === $needle;
865 }
866
867 /**
868 * Returns the site's blog_id in the WP.com ecosystem
869 *
870 * @return int
871 */
872 public function get_blog_id_for_output() {
873 return $this->token_details['blog_id'];
874 }
875
876 /**
877 * Returns the site's local blog_id.
878 *
879 * @param int $blog_id Blog ID.
880 * @return int
881 */
882 public function get_blog_id( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
883 return $GLOBALS['blog_id'];
884 }
885
886 /**
887 * Switch to blog and validate user.
888 *
889 * @param int $blog_id Blog ID.
890 * @param bool $verify_token_for_blog Whether to verify the token.
891 * @return int Blog ID.
892 */
893 public function switch_to_blog_and_validate_user( $blog_id = 0, $verify_token_for_blog = true ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
894 if ( $this->is_restricted_blog( $blog_id ) ) {
895 return new WP_Error( 'unauthorized', 'User cannot access this restricted blog', 403 );
896 }
897 /**
898 * If this is a private site we check for 2 things:
899 * 1. In case of user based authentication, we need to check if the logged-in user has the 'read' capability.
900 * 2. In case of site based authentication, make sure the endpoint accepts it.
901 */
902 if ( -1 === (int) get_option( 'blog_public' ) &&
903 ! current_user_can( 'read' ) &&
904 ! $this->endpoint->accepts_site_based_authentication()
905 ) {
906 return new WP_Error( 'unauthorized', 'User cannot access this private blog.', 403 );
907 }
908
909 return $blog_id;
910 }
911
912 /**
913 * Returns true if the specified blog ID is a restricted blog
914 *
915 * @param int $blog_id Blog ID.
916 * @return bool
917 */
918 public function is_restricted_blog( $blog_id ) {
919 /**
920 * Filters all REST API access and return a 403 unauthorized response for all Restricted blog IDs.
921 *
922 * @module json-api
923 *
924 * @since 3.4.0
925 *
926 * @param array $array Array of Blog IDs.
927 */
928 $restricted_blog_ids = apply_filters( 'wpcom_json_api_restricted_blog_ids', array() );
929 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.
930 }
931
932 /**
933 * Post like count.
934 *
935 * @param int $blog_id Blog ID.
936 * @param int $post_id Post ID.
937 * @return int
938 */
939 public function post_like_count( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
940 return 0;
941 }
942
943 /**
944 * Is liked?
945 *
946 * @param int $blog_id Blog ID.
947 * @param int $post_id Post ID.
948 * @return bool
949 */
950 public function is_liked( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
951 return false;
952 }
953
954 /**
955 * Is reblogged?
956 *
957 * @param int $blog_id Blog ID.
958 * @param int $post_id Post ID.
959 * @return bool
960 */
961 public function is_reblogged( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
962 return false;
963 }
964
965 /**
966 * Is following?
967 *
968 * @param int $blog_id Blog ID.
969 * @return bool
970 */
971 public function is_following( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
972 return false;
973 }
974
975 /**
976 * Add global ID.
977 *
978 * @param int $blog_id Blog ID.
979 * @param int $post_id Post ID.
980 * @return string
981 */
982 public function add_global_ID( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
983 return '';
984 }
985
986 /**
987 * Get avatar URL.
988 *
989 * @param string $email Email.
990 * @param array $avatar_size Args for `get_avatar_url()`.
991 * @return string|false
992 */
993 public function get_avatar_url( $email, $avatar_size = null ) {
994 if ( function_exists( 'wpcom_get_avatar_url' ) ) {
995 return null === $avatar_size
996 ? wpcom_get_avatar_url( $email )
997 : wpcom_get_avatar_url( $email, $avatar_size );
998 } else {
999 return null === $avatar_size
1000 ? get_avatar_url( $email )
1001 : get_avatar_url( $email, $avatar_size );
1002 }
1003 }
1004
1005 /**
1006 * Counts the number of comments on a site, excluding certain comment types.
1007 *
1008 * @param int $post_id Post ID.
1009 * @return array Array of counts, matching the output of https://developer.wordpress.org/reference/functions/get_comment_count/.
1010 */
1011 public function wp_count_comments( $post_id ) {
1012 global $wpdb;
1013 if ( 0 !== $post_id ) {
1014 return wp_count_comments( $post_id );
1015 }
1016
1017 $counts = array(
1018 'total_comments' => 0,
1019 'all' => 0,
1020 );
1021
1022 /**
1023 * Exclude certain comment types from comment counts in the REST API.
1024 *
1025 * @since 6.9.0
1026 * @module json-api
1027 *
1028 * @param array Array of comment types to exclude (default: 'order_note', 'webhook_delivery', 'review', 'action_log')
1029 */
1030 $exclude = apply_filters(
1031 'jetpack_api_exclude_comment_types_count',
1032 array( 'order_note', 'webhook_delivery', 'review', 'action_log' )
1033 );
1034
1035 if ( empty( $exclude ) ) {
1036 return wp_count_comments( $post_id );
1037 }
1038
1039 array_walk( $exclude, 'esc_sql' );
1040 $where = sprintf(
1041 "WHERE comment_type NOT IN ( '%s' )",
1042 implode( "','", $exclude )
1043 );
1044
1045 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- `$where` is built with escaping just above.
1046 $count = $wpdb->get_results(
1047 "SELECT comment_approved, COUNT(*) AS num_comments
1048 FROM $wpdb->comments
1049 {$where}
1050 GROUP BY comment_approved
1051 "
1052 );
1053 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1054
1055 $approved = array(
1056 '0' => 'moderated',
1057 '1' => 'approved',
1058 'spam' => 'spam',
1059 'trash' => 'trash',
1060 'post-trashed' => 'post-trashed',
1061 );
1062
1063 // <https://developer.wordpress.org/reference/functions/get_comment_count/#source>
1064 foreach ( $count as $row ) {
1065 if ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash', 'spam' ), true ) ) {
1066 $counts['all'] += $row->num_comments;
1067 $counts['total_comments'] += $row->num_comments;
1068 } elseif ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash' ), true ) ) {
1069 $counts['total_comments'] += $row->num_comments;
1070 }
1071 if ( isset( $approved[ $row->comment_approved ] ) ) {
1072 $counts[ $approved[ $row->comment_approved ] ] = $row->num_comments;
1073 }
1074 }
1075
1076 foreach ( $approved as $key ) {
1077 if ( empty( $counts[ $key ] ) ) {
1078 $counts[ $key ] = 0;
1079 }
1080 }
1081
1082 $counts = (object) $counts;
1083
1084 return $counts;
1085 }
1086
1087 /**
1088 * Traps `wp_die()` calls and outputs a JSON response instead.
1089 * The result is always output, never returned.
1090 *
1091 * @param string|null $error_code Call with string to start the trapping. Call with null to stop.
1092 * @param int $http_status HTTP status code, 400 by default.
1093 */
1094 public function trap_wp_die( $error_code = null, $http_status = 400 ) {
1095 // Determine the filter name; based on the conditionals inside the wp_die function.
1096 if ( wp_is_json_request() ) {
1097 $die_handler = 'wp_die_json_handler';
1098 } elseif ( wp_is_jsonp_request() ) {
1099 $die_handler = 'wp_die_jsonp_handler';
1100 } elseif ( wp_is_xml_request() ) {
1101 $die_handler = 'wp_die_xml_handler';
1102 } else {
1103 $die_handler = 'wp_die_handler';
1104 }
1105
1106 if ( is_null( $error_code ) ) {
1107 $this->trapped_error = null;
1108 // Stop trapping.
1109 remove_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1110 return;
1111 }
1112
1113 // If API called via PHP, bail: don't do our custom wp_die(). Do the normal wp_die().
1114 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1115 if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
1116 return;
1117 }
1118 } else {
1119 if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
1120 return;
1121 }
1122 }
1123
1124 $this->trapped_error = array(
1125 'status' => $http_status,
1126 'code' => $error_code,
1127 'message' => '',
1128 );
1129 // Start trapping.
1130 add_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1131 }
1132
1133 /**
1134 * Filter function for `wp_die_handler` and similar filters.
1135 *
1136 * @return callable
1137 */
1138 public function wp_die_handler_callback() {
1139 return array( $this, 'wp_die_handler' );
1140 }
1141
1142 /**
1143 * Handler for `wp_die` calls.
1144 *
1145 * @param string|WP_Error $message As for `wp_die()`.
1146 * @param string|int $title As for `wp_die()`.
1147 * @param string|array|int $args As for `wp_die()`.
1148 */
1149 public function wp_die_handler( $message, $title = '', $args = array() ) {
1150 // Allow wp_die calls to override HTTP status code...
1151 $args = wp_parse_args(
1152 $args,
1153 array(
1154 'response' => $this->trapped_error['status'],
1155 )
1156 );
1157
1158 // ... unless it's 500
1159 if ( 500 !== (int) $args['response'] ) {
1160 $this->trapped_error['status'] = $args['response'];
1161 }
1162
1163 if ( $title ) {
1164 $message = "$title: $message";
1165 }
1166
1167 $this->trapped_error['message'] = wp_kses( $message, array() );
1168
1169 switch ( $this->trapped_error['code'] ) {
1170 case 'comment_failure':
1171 if ( did_action( 'comment_duplicate_trigger' ) ) {
1172 $this->trapped_error['code'] = 'comment_duplicate';
1173 } elseif ( did_action( 'comment_flood_trigger' ) ) {
1174 $this->trapped_error['code'] = 'comment_flood';
1175 }
1176 break;
1177 }
1178
1179 // We still want to exit so that code execution stops where it should.
1180 // Attach the JSON output to the WordPress shutdown handler.
1181 add_action( 'shutdown', array( $this, 'output_trapped_error' ), 0 );
1182 exit;
1183 }
1184
1185 /**
1186 * Output the trapped error.
1187 */
1188 public function output_trapped_error() {
1189 $this->exit = false; // We're already exiting once. Don't do it twice.
1190 $this->output(
1191 $this->trapped_error['status'],
1192 (object) array(
1193 'error' => $this->trapped_error['code'],
1194 'message' => $this->trapped_error['message'],
1195 )
1196 );
1197 }
1198
1199 /**
1200 * Finish the request.
1201 */
1202 public function finish_request() {
1203 if ( function_exists( 'fastcgi_finish_request' ) ) {
1204 return fastcgi_finish_request();
1205 }
1206 }
1207 }
1208