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

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

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