PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 5.1.4
Jetpack – WP Security, Backup, Speed, & Growth v5.1.4
16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 14.3.1 All 501 releases
jetpack / class.json-api.php

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

698 lines 19.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 /**
36 * @return WPCOM_JSON_API instance
37 */
38 static function init( $method = null, $url = null, $post_body = null ) {
39 if ( !self::$self ) {
40 $class = function_exists( 'get_called_class' ) ? get_called_class() : __CLASS__;
41 self::$self = new $class( $method, $url, $post_body );
42 }
43 return self::$self;
44 }
45
46 function add( WPCOM_JSON_API_Endpoint $endpoint ) {
47 $path_versions = serialize( array (
48 $endpoint->path,
49 $endpoint->min_version,
50 $endpoint->max_version,
51 ) );
52 if ( !isset( $this->endpoints[$path_versions] ) ) {
53 $this->endpoints[$path_versions] = array();
54 }
55 $this->endpoints[$path_versions][$endpoint->method] = $endpoint;
56 }
57
58 static function is_truthy( $value ) {
59 switch ( strtolower( (string) $value ) ) {
60 case '1' :
61 case 't' :
62 case 'true' :
63 return true;
64 }
65
66 return false;
67 }
68
69 static function is_falsy( $value ) {
70 switch ( strtolower( (string) $value ) ) {
71 case '0' :
72 case 'f' :
73 case 'false' :
74 return true;
75 }
76
77 return false;
78 }
79
80 function __construct() {
81 $args = func_get_args();
82 call_user_func_array( array( $this, 'setup_inputs' ), $args );
83 }
84
85 function setup_inputs( $method = null, $url = null, $post_body = null ) {
86 if ( is_null( $method ) ) {
87 $this->method = strtoupper( $_SERVER['REQUEST_METHOD'] );
88 } else {
89 $this->method = strtoupper( $method );
90 }
91 if ( is_null( $url ) ) {
92 $this->url = set_url_scheme( 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] );
93 } else {
94 $this->url = $url;
95 }
96
97 $parsed = parse_url( $this->url );
98 $this->path = $parsed['path'];
99
100 if ( !empty( $parsed['query'] ) ) {
101 wp_parse_str( $parsed['query'], $this->query );
102 }
103
104 if ( isset( $_SERVER['HTTP_ACCEPT'] ) && $_SERVER['HTTP_ACCEPT'] ) {
105 $this->accept = $_SERVER['HTTP_ACCEPT'];
106 }
107
108 if ( 'POST' === $this->method ) {
109 if ( is_null( $post_body ) ) {
110 $this->post_body = file_get_contents( 'php://input' );
111
112 if ( isset( $_SERVER['HTTP_CONTENT_TYPE'] ) && $_SERVER['HTTP_CONTENT_TYPE'] ) {
113 $this->content_type = $_SERVER['HTTP_CONTENT_TYPE'];
114 } elseif ( isset( $_SERVER['CONTENT_TYPE'] ) && $_SERVER['CONTENT_TYPE'] ) {
115 $this->content_type = $_SERVER['CONTENT_TYPE'] ;
116 } elseif ( '{' === $this->post_body[0] ) {
117 $this->content_type = 'application/json';
118 } else {
119 $this->content_type = 'application/x-www-form-urlencoded';
120 }
121
122 if ( 0 === strpos( strtolower( $this->content_type ), 'multipart/' ) ) {
123 $this->post_body = http_build_query( stripslashes_deep( $_POST ) );
124 $this->files = $_FILES;
125 $this->content_type = 'multipart/form-data';
126 }
127 } else {
128 $this->post_body = $post_body;
129 $this->content_type = '{' === isset( $this->post_body[0] ) && $this->post_body[0] ? 'application/json' : 'application/x-www-form-urlencoded';
130 }
131 } else {
132 $this->post_body = null;
133 $this->content_type = null;
134 }
135
136 $this->_server_https = array_key_exists( 'HTTPS', $_SERVER ) ? $_SERVER['HTTPS'] : '--UNset--';
137 }
138
139 function initialize() {
140 $this->token_details['blog_id'] = Jetpack_Options::get_option( 'id' );
141 }
142
143 function serve( $exit = true ) {
144 ini_set( 'display_errors', false );
145
146 $this->exit = (bool) $exit;
147
148 // This was causing problems with Jetpack, but is necessary for wpcom
149 // @see https://github.com/Automattic/jetpack/pull/2603
150 // @see r124548-wpcom
151 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
152 add_filter( 'home_url', array( $this, 'ensure_http_scheme_of_home_url' ), 10, 3 );
153 }
154
155 add_filter( 'user_can_richedit', '__return_true' );
156
157 add_filter( 'comment_edit_pre', array( $this, 'comment_edit_pre' ) );
158
159 $initialization = $this->initialize();
160 if ( 'OPTIONS' == $this->method ) {
161 /**
162 * Fires before the page output.
163 * Can be used to specify custom header options.
164 *
165 * @module json-api
166 *
167 * @since 3.1.0
168 */
169 do_action( 'wpcom_json_api_options' );
170 return $this->output( 200, '', 'text/plain' );
171 }
172
173 if ( is_wp_error( $initialization ) ) {
174 $this->output_error( $initialization );
175 return;
176 }
177
178 // Normalize path and extract API version
179 $this->path = untrailingslashit( $this->path );
180 preg_match( '#^/rest/v(\d+(\.\d+)*)#', $this->path, $matches );
181 $this->path = substr( $this->path, strlen( $matches[0] ) );
182 $this->version = $matches[1];
183
184 $allowed_methods = array( 'GET', 'POST' );
185 $four_oh_five = false;
186
187 $is_help = preg_match( '#/help/?$#i', $this->path );
188 $matching_endpoints = array();
189
190 if ( $is_help ) {
191 $origin = get_http_origin();
192
193 if ( !empty( $origin ) && 'GET' == $this->method ) {
194 header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
195 }
196
197 $this->path = substr( rtrim( $this->path, '/' ), 0, -5 );
198 // Show help for all matching endpoints regardless of method
199 $methods = $allowed_methods;
200 $find_all_matching_endpoints = true;
201 // How deep to truncate each endpoint's path to see if it matches this help request
202 $depth = substr_count( $this->path, '/' ) + 1;
203 if ( false !== stripos( $this->accept, 'javascript' ) || false !== stripos( $this->accept, 'json' ) ) {
204 $help_content_type = 'json';
205 } else {
206 $help_content_type = 'html';
207 }
208 } else {
209 if ( in_array( $this->method, $allowed_methods ) ) {
210 // Only serve requested method
211 $methods = array( $this->method );
212 $find_all_matching_endpoints = false;
213 } else {
214 // We don't allow this requested method - find matching endpoints and send 405
215 $methods = $allowed_methods;
216 $find_all_matching_endpoints = true;
217 $four_oh_five = true;
218 }
219 }
220
221 // Find which endpoint to serve
222 $found = false;
223 foreach ( $this->endpoints as $endpoint_path_versions => $endpoints_by_method ) {
224 $endpoint_path_versions = unserialize( $endpoint_path_versions );
225 $endpoint_path = $endpoint_path_versions[0];
226 $endpoint_min_version = $endpoint_path_versions[1];
227 $endpoint_max_version = $endpoint_path_versions[2];
228
229 // Make sure max_version is not less than min_version
230 if ( version_compare( $endpoint_max_version, $endpoint_min_version, '<' ) ) {
231 $endpoint_max_version = $endpoint_min_version;
232 }
233
234 foreach ( $methods as $method ) {
235 if ( !isset( $endpoints_by_method[$method] ) ) {
236 continue;
237 }
238
239 // Normalize
240 $endpoint_path = untrailingslashit( $endpoint_path );
241 if ( $is_help ) {
242 // Truncate path at help depth
243 $endpoint_path = join( '/', array_slice( explode( '/', $endpoint_path ), 0, $depth ) );
244 }
245
246 // Generate regular expression from sprintf()
247 $endpoint_path_regex = str_replace( array( '%s', '%d' ), array( '([^/?&]+)', '(\d+)' ), $endpoint_path );
248
249 if ( !preg_match( "#^$endpoint_path_regex\$#", $this->path, $path_pieces ) ) {
250 // This endpoint does not match the requested path.
251 continue;
252 }
253
254 if ( version_compare( $this->version, $endpoint_min_version, '<' ) || version_compare( $this->version, $endpoint_max_version, '>' ) ) {
255 // This endpoint does not match the requested version.
256 continue;
257 }
258
259 $found = true;
260
261 if ( $find_all_matching_endpoints ) {
262 $matching_endpoints[] = array( $endpoints_by_method[$method], $path_pieces );
263 } else {
264 // The method parameters are now in $path_pieces
265 $endpoint = $endpoints_by_method[$method];
266 break 2;
267 }
268 }
269 }
270
271 if ( !$found ) {
272 return $this->output( 404, '', 'text/plain' );
273 }
274
275 if ( $four_oh_five ) {
276 $allowed_methods = array();
277 foreach ( $matching_endpoints as $matching_endpoint ) {
278 $allowed_methods[] = $matching_endpoint[0]->method;
279 }
280
281 header( 'Allow: ' . strtoupper( join( ',', array_unique( $allowed_methods ) ) ) );
282 return $this->output( 405, array( 'error' => 'not_allowed', 'error_message' => 'Method not allowed' ) );
283 }
284
285 if ( $is_help ) {
286 /**
287 * Fires before the API output.
288 *
289 * @since 1.9.0
290 *
291 * @param string help.
292 */
293 do_action( 'wpcom_json_api_output', 'help' );
294 $proxied = function_exists( 'wpcom_is_proxied_request' ) ? wpcom_is_proxied_request() : false;
295 if ( 'json' === $help_content_type ) {
296 $docs = array();
297 foreach ( $matching_endpoints as $matching_endpoint ) {
298 if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG )
299 $docs[] = call_user_func( array( $matching_endpoint[0], 'generate_documentation' ) );
300 }
301 return $this->output( 200, $docs );
302 } else {
303 status_header( 200 );
304 foreach ( $matching_endpoints as $matching_endpoint ) {
305 if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG )
306 call_user_func( array( $matching_endpoint[0], 'document' ) );
307 }
308 }
309 exit;
310 }
311
312 if ( $endpoint->in_testing && !WPCOM_JSON_API__DEBUG ) {
313 return $this->output( 404, '', 'text/plain' );
314 }
315
316 /** This action is documented in class.json-api.php */
317 do_action( 'wpcom_json_api_output', $endpoint->stat );
318
319 $response = $this->process_request( $endpoint, $path_pieces );
320
321 if ( !$response && !is_array( $response ) ) {
322 return $this->output( 500, '', 'text/plain' );
323 } elseif ( is_wp_error( $response ) ) {
324 return $this->output_error( $response );
325 }
326
327 $output_status_code = $this->output_status_code;
328 $this->set_output_status_code();
329
330 return $this->output( $output_status_code, $response, 'application/json', $this->extra_headers );
331 }
332
333 function process_request( WPCOM_JSON_API_Endpoint $endpoint, $path_pieces ) {
334 $this->endpoint = $endpoint;
335 return call_user_func_array( array( $endpoint, 'callback' ), $path_pieces );
336 }
337
338 function output_early( $status_code, $response = null, $content_type = 'application/json' ) {
339 $exit = $this->exit;
340 $this->exit = false;
341 if ( is_wp_error( $response ) )
342 $this->output_error( $response );
343 else
344 $this->output( $status_code, $response, $content_type );
345 $this->exit = $exit;
346 if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
347 $this->finish_request();
348 }
349 }
350
351 function set_output_status_code( $code = 200 ) {
352 $this->output_status_code = $code;
353 }
354
355 function output( $status_code, $response = null, $content_type = 'application/json', $extra = array() ) {
356 // In case output() was called before the callback returned
357 if ( $this->did_output ) {
358 if ( $this->exit )
359 exit;
360 return $content_type;
361 }
362 $this->did_output = true;
363
364 // 400s and 404s are allowed for all origins
365 if ( 404 == $status_code || 400 == $status_code )
366 header( 'Access-Control-Allow-Origin: *' );
367
368 if ( is_null( $response ) ) {
369 $response = new stdClass;
370 }
371
372 if ( 'text/plain' === $content_type ) {
373 status_header( (int) $status_code );
374 header( 'Content-Type: text/plain' );
375 foreach( $extra as $key => $value ) {
376 header( "$key: $value" );
377 }
378 echo $response;
379 if ( $this->exit ) {
380 exit;
381 }
382
383 return $content_type;
384 }
385
386 $response = $this->filter_fields( $response );
387
388 if ( isset( $this->query['http_envelope'] ) && self::is_truthy( $this->query['http_envelope'] ) ) {
389 $headers = array(
390 array(
391 'name' => 'Content-Type',
392 'value' => $content_type,
393 )
394 );
395
396 foreach( $extra as $key => $value ) {
397 $headers[] = array( 'name' => $key, 'value' => $value );
398 }
399
400 $response = array(
401 'code' => (int) $status_code,
402 'headers' => $headers,
403 'body' => $response,
404 );
405 $status_code = 200;
406 $content_type = 'application/json';
407 }
408
409 status_header( (int) $status_code );
410 header( "Content-Type: $content_type" );
411 if ( isset( $this->query['callback'] ) && is_string( $this->query['callback'] ) ) {
412 $callback = preg_replace( '/[^a-z0-9_.]/i', '', $this->query['callback'] );
413 } else {
414 $callback = false;
415 }
416
417 if ( $callback ) {
418 // Mitigate Rosetta Flash [1] by setting the Content-Type-Options: nosniff header
419 // and by prepending the JSONP response with a JS comment.
420 // [1] http://miki.it/blog/2014/7/8/abusing-jsonp-with-rosetta-flash/
421 echo "/**/$callback(";
422
423 }
424 echo $this->json_encode( $response );
425 if ( $callback ) {
426 echo ");";
427 }
428
429 if ( $this->exit ) {
430 exit;
431 }
432
433 return $content_type;
434 }
435
436 public static function serializable_error ( $error ) {
437
438 $status_code = $error->get_error_data();
439
440 if ( is_array( $status_code ) )
441 $status_code = $status_code['status_code'];
442
443 if ( !$status_code ) {
444 $status_code = 400;
445 }
446 $response = array(
447 'error' => $error->get_error_code(),
448 'message' => $error->get_error_message(),
449 );
450 return array(
451 'status_code' => $status_code,
452 'errors' => $response
453 );
454 }
455
456 function output_error( $error ) {
457 $error_response = $this->serializable_error( $error );
458
459 return $this->output( $error_response[ 'status_code'], $error_response['errors'] );
460 }
461
462 function filter_fields( $response ) {
463 if ( empty( $this->query['fields'] ) || ( is_array( $response ) && ! empty( $response['error'] ) ) || ! empty( $this->endpoint->custom_fields_filtering ) )
464 return $response;
465
466 $fields = array_map( 'trim', explode( ',', $this->query['fields'] ) );
467
468 if ( is_object( $response ) ) {
469 $response = (array) $response;
470 }
471
472 $has_filtered = false;
473 if ( is_array( $response ) && empty( $response['ID'] ) ) {
474 $keys_to_filter = array(
475 'categories',
476 'comments',
477 'connections',
478 'domains',
479 'groups',
480 'likes',
481 'media',
482 'notes',
483 'posts',
484 'services',
485 'sites',
486 'suggestions',
487 'tags',
488 'themes',
489 'topics',
490 'users',
491 );
492
493 foreach ( $keys_to_filter as $key_to_filter ) {
494 if ( ! isset( $response[ $key_to_filter ] ) || $has_filtered )
495 continue;
496
497 foreach ( $response[ $key_to_filter ] as $key => $values ) {
498 if ( is_object( $values ) ) {
499 if ( is_object( $response[ $key_to_filter ] ) ) {
500 $response[ $key_to_filter ]->$key = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
501 } elseif ( is_array( $response[ $key_to_filter ] ) ) {
502 $response[ $key_to_filter ][ $key ] = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
503 }
504 } elseif ( is_array( $values ) ) {
505 $response[ $key_to_filter ][ $key ] = array_intersect_key( $values, array_flip( $fields ) );
506 }
507 }
508
509 $has_filtered = true;
510 }
511 }
512
513 if ( ! $has_filtered ) {
514 if ( is_object( $response ) ) {
515 $response = (object) array_intersect_key( (array) $response, array_flip( $fields ) );
516 } else if ( is_array( $response ) ) {
517 $response = array_intersect_key( $response, array_flip( $fields ) );
518 }
519 }
520
521 return $response;
522 }
523
524 function ensure_http_scheme_of_home_url( $url, $path, $original_scheme ) {
525 if ( $original_scheme ) {
526 return $url;
527 }
528
529 return preg_replace( '#^https:#', 'http:', $url );
530 }
531
532 function comment_edit_pre( $comment_content ) {
533 return htmlspecialchars_decode( $comment_content, ENT_QUOTES );
534 }
535
536 function json_encode( $data ) {
537 return json_encode( $data );
538 }
539
540 function ends_with( $haystack, $needle ) {
541 return $needle === substr( $haystack, -strlen( $needle ) );
542 }
543
544 // Returns the site's blog_id in the WP.com ecosystem
545 function get_blog_id_for_output() {
546 return $this->token_details['blog_id'];
547 }
548
549 // Returns the site's local blog_id
550 function get_blog_id( $blog_id ) {
551 return $GLOBALS['blog_id'];
552 }
553
554 function switch_to_blog_and_validate_user( $blog_id = 0, $verify_token_for_blog = true ) {
555 if ( $this->is_restricted_blog( $blog_id ) ) {
556 return new WP_Error( 'unauthorized', 'User cannot access this restricted blog', 403 );
557 }
558
559 if ( -1 == get_option( 'blog_public' ) && !current_user_can( 'read' ) ) {
560 return new WP_Error( 'unauthorized', 'User cannot access this private blog.', 403 );
561 }
562
563 return $blog_id;
564 }
565
566 // Returns true if the specified blog ID is a restricted blog
567 function is_restricted_blog( $blog_id ) {
568 /**
569 * Filters all REST API access and return a 403 unauthorized response for all Restricted blog IDs.
570 *
571 * @module json-api
572 *
573 * @since 3.4.0
574 *
575 * @param array $array Array of Blog IDs.
576 */
577 $restricted_blog_ids = apply_filters( 'wpcom_json_api_restricted_blog_ids', array() );
578 return true === in_array( $blog_id, $restricted_blog_ids );
579 }
580
581 function post_like_count( $blog_id, $post_id ) {
582 return 0;
583 }
584
585 function is_liked( $blog_id, $post_id ) {
586 return false;
587 }
588
589 function is_reblogged( $blog_id, $post_id ) {
590 return false;
591 }
592
593 function is_following( $blog_id ) {
594 return false;
595 }
596
597 function add_global_ID( $blog_id, $post_id ) {
598 return '';
599 }
600
601 function get_avatar_url( $email, $avatar_size = null ) {
602 if ( function_exists( 'wpcom_get_avatar_url' ) ) {
603 return null === $avatar_size
604 ? wpcom_get_avatar_url( $email )
605 : wpcom_get_avatar_url( $email, $avatar_size );
606 } else {
607 return null === $avatar_size
608 ? get_avatar_url( $email )
609 : get_avatar_url( $email, $avatar_size );
610 }
611 }
612
613 /**
614 * traps `wp_die()` calls and outputs a JSON response instead.
615 * The result is always output, never returned.
616 *
617 * @param string|null $error_code Call with string to start the trapping. Call with null to stop.
618 * @param int $http_status HTTP status code, 400 by default.
619 */
620 function trap_wp_die( $error_code = null, $http_status = 400 ) {
621 if ( is_null( $error_code ) ) {
622 $this->trapped_error = null;
623 // Stop trapping
624 remove_filter( 'wp_die_handler', array( $this, 'wp_die_handler_callback' ) );
625 return;
626 }
627
628 // If API called via PHP, bail: don't do our custom wp_die(). Do the normal wp_die().
629 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
630 if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
631 return;
632 }
633 } else {
634 if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
635 return;
636 }
637 }
638
639 $this->trapped_error = array(
640 'status' => $http_status,
641 'code' => $error_code,
642 'message' => '',
643 );
644 // Start trapping
645 add_filter( 'wp_die_handler', array( $this, 'wp_die_handler_callback' ) );
646 }
647
648 function wp_die_handler_callback() {
649 return array( $this, 'wp_die_handler' );
650 }
651
652 function wp_die_handler( $message, $title = '', $args = array() ) {
653 // Allow wp_die calls to override HTTP status code...
654 $args = wp_parse_args( $args, array(
655 'response' => $this->trapped_error['status'],
656 ) );
657
658 // ... unless it's 500 ( see http://wp.me/pMz3w-5VV )
659 if ( (int) $args['response'] !== 500 ) {
660 $this->trapped_error['status'] = $args['response'];
661 }
662
663 if ( $title ) {
664 $message = "$title: $message";
665 }
666
667 $this->trapped_error['message'] = wp_kses( $message, array() );
668
669 switch ( $this->trapped_error['code'] ) {
670 case 'comment_failure' :
671 if ( did_action( 'comment_duplicate_trigger' ) ) {
672 $this->trapped_error['code'] = 'comment_duplicate';
673 } else if ( did_action( 'comment_flood_trigger' ) ) {
674 $this->trapped_error['code'] = 'comment_flood';
675 }
676 break;
677 }
678
679 // We still want to exit so that code execution stops where it should.
680 // Attach the JSON output to the WordPress shutdown handler
681 add_action( 'shutdown', array( $this, 'output_trapped_error' ), 0 );
682 exit;
683 }
684
685 function output_trapped_error() {
686 $this->exit = false; // We're already exiting once. Don't do it twice.
687 $this->output( $this->trapped_error['status'], (object) array(
688 'error' => $this->trapped_error['code'],
689 'message' => $this->trapped_error['message'],
690 ) );
691 }
692
693 function finish_request() {
694 if ( function_exists( 'fastcgi_finish_request' ) )
695 return fastcgi_finish_request();
696 }
697 }
698