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

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

2,584 lines 80.8 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 $return[ $key ] = $files;
639 break;
640 }
641 }
642 // no break - treat as 'array'.
643 case 'array':
644 // Fallback array -> string.
645 if ( is_string( $value ) ) {
646 if ( ! empty( $types[0] ) ) {
647 $next_type = array_shift( $types );
648 return $this->cast_and_filter_item( $return, $next_type, $key, $value, $types, $for_output );
649 }
650 }
651
652 if ( isset( $type['children'] ) ) {
653 $children = array();
654 foreach ( (array) $value as $k => $child ) {
655 $this->cast_and_filter_item( $children, $type['children'], $k, $child, array(), $for_output );
656 }
657 $return[ $key ] = (array) $children;
658 break;
659 }
660
661 $return[ $key ] = (array) $value;
662 break;
663 case 'iso 8601 datetime':
664 case 'datetime':
665 // (string)s
666 $dates = $this->parse_date( (string) $value );
667 if ( $for_output ) {
668 $return[ $key ] = $this->format_date( $dates[1], $dates[0] );
669 } else {
670 list( $return[ $key ], $return[ "{$key}_gmt" ] ) = $dates;
671 }
672 break;
673 case 'float':
674 $return[ $key ] = (float) $value;
675 break;
676 case 'int':
677 case 'integer':
678 $return[ $key ] = (int) $value;
679 break;
680 case 'bool':
681 case 'boolean':
682 $return[ $key ] = (bool) WPCOM_JSON_API::is_truthy( $value );
683 break;
684 case 'object':
685 // Fallback object -> false.
686 if ( is_scalar( $value ) || $value === null ) {
687 if ( ! empty( $types[0] ) && 'false' === $types[0]['type'] ) {
688 return $this->cast_and_filter_item( $return, 'false', $key, $value, $types, $for_output );
689 }
690 }
691
692 if ( isset( $type['children'] ) ) {
693 $children = array();
694 foreach ( (array) $value as $k => $child ) {
695 $this->cast_and_filter_item( $children, $type['children'], $k, $child, array(), $for_output );
696 }
697 $return[ $key ] = (object) $children;
698 break;
699 }
700
701 if ( isset( $type['subtype'] ) ) {
702 return $this->cast_and_filter_item( $return, $type['subtype'], $key, $value, $types, $for_output );
703 }
704
705 $return[ $key ] = (object) $value;
706 break;
707 case 'post':
708 $return[ $key ] = (object) $this->cast_and_filter( $value, $this->post_object_format, false, $for_output );
709 break;
710 case 'comment':
711 $return[ $key ] = (object) $this->cast_and_filter( $value, $this->comment_object_format, false, $for_output );
712 break;
713 case 'tag':
714 case 'category':
715 $docs = array(
716 'ID' => '(int)',
717 'name' => '(string)',
718 'slug' => '(string)',
719 'description' => '(HTML)',
720 'post_count' => '(int)',
721 'feed_url' => '(string)',
722 'meta' => '(object)',
723 );
724 if ( 'category' === $type['type'] ) {
725 $docs['parent'] = '(int)';
726 }
727 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
728 break;
729 case 'post_reference':
730 case 'comment_reference':
731 $docs = array(
732 'ID' => '(int)',
733 'type' => '(string)',
734 'title' => '(string)',
735 'link' => '(URL)',
736 );
737 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
738 break;
739 case 'geo':
740 $docs = array(
741 'latitude' => '(float)',
742 'longitude' => '(float)',
743 'address' => '(string)',
744 );
745 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
746 break;
747 case 'author':
748 $docs = array(
749 'ID' => '(int)',
750 'user_login' => '(string)',
751 'login' => '(string)',
752 'email' => '(string|false)',
753 'name' => '(string)',
754 'first_name' => '(string)',
755 'last_name' => '(string)',
756 'nice_name' => '(string)',
757 'URL' => '(URL)',
758 'avatar_URL' => '(URL)',
759 'profile_URL' => '(URL)',
760 'is_super_admin' => '(bool)',
761 'roles' => '(array:string)',
762 'ip_address' => '(string|false)',
763 );
764 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
765 break;
766 case 'role':
767 $docs = array(
768 'name' => '(string)',
769 'display_name' => '(string)',
770 'capabilities' => '(object:boolean)',
771 );
772 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
773 break;
774 case 'attachment':
775 $docs = array(
776 'ID' => '(int)',
777 'URL' => '(URL)',
778 'guid' => '(string)',
779 'mime_type' => '(string)',
780 'width' => '(int)',
781 'height' => '(int)',
782 'duration' => '(int)',
783 );
784 $return[ $key ] = (object) $this->cast_and_filter(
785 $value,
786 /**
787 * Filter the documentation returned for a post attachment.
788 *
789 * @module json-api
790 *
791 * @since 1.9.0
792 *
793 * @param array $docs Array of documentation about a post attachment.
794 */
795 apply_filters( 'wpcom_json_api_attachment_cast_and_filter', $docs ),
796 false,
797 $for_output
798 );
799 break;
800 case 'metadata':
801 $docs = array(
802 'id' => '(int)',
803 'key' => '(string)',
804 'value' => '(string|false|float|int|array|object)',
805 'previous_value' => '(string)',
806 'operation' => '(string)',
807 );
808 $return[ $key ] = (object) $this->cast_and_filter(
809 $value,
810 /** This filter is documented in class.json-api-endpoints.php */
811 apply_filters( 'wpcom_json_api_attachment_cast_and_filter', $docs ),
812 false,
813 $for_output
814 );
815 break;
816 case 'plugin':
817 $docs = array(
818 'id' => '(safehtml) The plugin\'s ID',
819 'slug' => '(safehtml) The plugin\'s Slug',
820 'active' => '(boolean) The plugin status.',
821 'update' => '(object) The plugin update info.',
822 'name' => '(safehtml) The name of the plugin.',
823 'plugin_url' => '(url) Link to the plugin\'s web site.',
824 'version' => '(safehtml) The plugin version number.',
825 'description' => '(safehtml) Description of what the plugin does and/or notes from the author',
826 'author' => '(safehtml) The plugin author\'s name',
827 'author_url' => '(url) The plugin author web site address',
828 'network' => '(boolean) Whether the plugin can only be activated network wide.',
829 'autoupdate' => '(boolean) Whether the plugin is auto updated',
830 'log' => '(array:safehtml) An array of update log strings.',
831 'action_links' => '(array) An array of action links that the plugin uses.',
832 );
833 $return[ $key ] = (object) $this->cast_and_filter(
834 $value,
835 /**
836 * Filter the documentation returned for a plugin.
837 *
838 * @module json-api
839 *
840 * @since 3.1.0
841 *
842 * @param array $docs Array of documentation about a plugin.
843 */
844 apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
845 false,
846 $for_output
847 );
848 break;
849 case 'plugin_v1_2':
850 $docs = class_exists( 'Jetpack_JSON_API_Get_Plugins_v1_2_Endpoint' )
851 ? Jetpack_JSON_API_Get_Plugins_v1_2_Endpoint::$_response_format
852 : Jetpack_JSON_API_Plugins_Endpoint::$_response_format_v1_2;
853 $return[ $key ] = (object) $this->cast_and_filter(
854 $value,
855 /**
856 * Filter the documentation returned for a plugin.
857 *
858 * @module json-api
859 *
860 * @since 3.1.0
861 *
862 * @param array $docs Array of documentation about a plugin.
863 */
864 apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
865 false,
866 $for_output
867 );
868 break;
869 case 'file_mod_capabilities':
870 $docs = array(
871 'reasons_modify_files_unavailable' => '(array) The reasons why files can\'t be modified',
872 'reasons_autoupdate_unavailable' => '(array) The reasons why autoupdates aren\'t allowed',
873 'modify_files' => '(boolean) true if files can be modified',
874 'autoupdate_files' => '(boolean) true if autoupdates are allowed',
875 );
876 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
877 break;
878 case 'jetpackmodule':
879 $docs = array(
880 'id' => '(string) The module\'s ID',
881 'active' => '(boolean) The module\'s status.',
882 'name' => '(string) The module\'s name.',
883 'description' => '(safehtml) The module\'s description.',
884 'sort' => '(int) The module\'s display order.',
885 'introduced' => '(string) The Jetpack version when the module was introduced.',
886 'changed' => '(string) The Jetpack version when the module was changed.',
887 'free' => '(boolean) The module\'s Free or Paid status.',
888 'module_tags' => '(array) The module\'s tags.',
889 'override' => '(string) The module\'s override. Empty if no override, otherwise \'active\' or \'inactive\'',
890 );
891 $return[ $key ] = (object) $this->cast_and_filter(
892 $value,
893 /** This filter is documented in class.json-api-endpoints.php */
894 apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
895 false,
896 $for_output
897 );
898 break;
899 case 'sharing_button':
900 $docs = array(
901 'ID' => '(string)',
902 'name' => '(string)',
903 'URL' => '(string)',
904 'icon' => '(string)',
905 'enabled' => '(bool)',
906 'visibility' => '(string)',
907 );
908 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
909 break;
910 case 'sharing_button_service':
911 $docs = array(
912 'ID' => '(string) The service identifier',
913 'name' => '(string) The service name',
914 'class_name' => '(string) Class name for custom style sharing button elements',
915 'genericon' => '(string) The Genericon unicode character for the custom style sharing button icon',
916 'preview_smart' => '(string) An HTML snippet of a rendered sharing button smart preview',
917 'preview_smart_js' => '(string) An HTML snippet of the page-wide initialization scripts used for rendering the sharing button smart preview',
918 );
919 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
920 break;
921 case 'site_keyring':
922 $docs = array(
923 'keyring_id' => '(int) Keyring ID',
924 'service' => '(string) The service name',
925 'external_user_id' => '(string) External user id for the service',
926 );
927 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
928 break;
929 case 'taxonomy':
930 $docs = array(
931 'name' => '(string) The taxonomy slug',
932 'label' => '(string) The taxonomy human-readable name',
933 'labels' => '(object) Mapping of labels for the taxonomy',
934 'description' => '(string) The taxonomy description',
935 'hierarchical' => '(bool) Whether the taxonomy is hierarchical',
936 'public' => '(bool) Whether the taxonomy is public',
937 'capabilities' => '(object) Mapping of current user capabilities for the taxonomy',
938 );
939 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
940 break;
941
942 case 'visibility':
943 // This is needed to fix a bug in WPAndroid where `public: "PUBLIC"` is sent in place of `public: 1`.
944 if ( 'public' === strtolower( $value ) ) {
945 $return[ $key ] = 1;
946 } elseif ( 'private' === strtolower( $value ) ) {
947 $return[ $key ] = -1;
948 } else {
949 $return[ $key ] = (int) $value;
950 }
951 break;
952
953 default:
954 $method_name = $type['type'] . '_docs';
955 if ( method_exists( 'WPCOM_JSON_API_Jetpack_Overrides', $method_name ) ) {
956 $docs = WPCOM_JSON_API_Jetpack_Overrides::$method_name();
957 }
958
959 if ( ! empty( $docs ) ) {
960 $return[ $key ] = (object) $this->cast_and_filter(
961 $value,
962 /** This filter is documented in class.json-api-endpoints.php */
963 apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
964 false,
965 $for_output
966 );
967 } else {
968 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error, WordPress.Security.EscapeOutput.OutputNotEscaped
969 trigger_error( "Unknown API casting type {$type['type']}", E_USER_WARNING );
970 }
971 }
972 }
973
974 /**
975 * Parse types from text.
976 *
977 * @param string $text Text.
978 * @return array Types.
979 */
980 public function parse_types( $text ) {
981 if ( ! preg_match( '#^\(([^)]+)\)#', ltrim( $text ), $matches ) ) {
982 return 'none';
983 }
984
985 $types = explode( '|', strtolower( $matches[1] ) );
986 $return = array();
987 foreach ( $types as $type ) {
988 foreach ( array(
989 ':' => 'children',
990 '>' => 'subtype',
991 '=' => 'default',
992 ) as $operator => $meaning ) {
993 if ( false !== strpos( $type, $operator ) ) {
994 $item = explode( $operator, $type, 2 );
995 $return[] = array(
996 'type' => $item[0],
997 $meaning => $item[1],
998 );
999 continue 2;
1000 }
1001 }
1002 $return[] = compact( 'type' );
1003 }
1004
1005 return $return;
1006 }
1007
1008 /**
1009 * Checks if the endpoint is publicly displayable
1010 *
1011 * @return bool
1012 */
1013 public function is_publicly_documentable() {
1014 return '__do_not_document' !== $this->group && true !== $this->in_testing;
1015 }
1016
1017 /**
1018 * Auto generates documentation based on description, method, path, path_labels, and query parameters.
1019 * Echoes HTML.
1020 *
1021 * @param bool $show_description Whether to show the description.
1022 */
1023 public function document( $show_description = true ) {
1024 global $wpdb;
1025 $original_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : 'unset';
1026 unset( $GLOBALS['post'] );
1027
1028 $doc = $this->generate_documentation();
1029
1030 if ( $show_description ) :
1031 ?>
1032 <caption>
1033 <h1><?php echo wp_kses_post( $doc['method'] ); ?> <?php echo wp_kses_post( $doc['path_labeled'] ); ?></h1>
1034 <p><?php echo wp_kses_post( $doc['description'] ); ?></p>
1035 </caption>
1036
1037 <?php endif; ?>
1038
1039 <?php if ( true === $this->deprecated ) { ?>
1040 <p><strong>This endpoint is deprecated in favor of version <?php echo (float) $this->new_version; ?></strong></p>
1041 <?php } ?>
1042
1043 <section class="resource-info">
1044 <h2 id="apidoc-resource-info">Resource Information</h2>
1045
1046 <table class="api-doc api-doc-resource-parameters api-doc-resource">
1047
1048 <thead>
1049 <tr>
1050 <th class="api-index-title" scope="column">&nbsp;</th>
1051 <th class="api-index-title" scope="column">&nbsp;</th>
1052 </tr>
1053 </thead>
1054 <tbody>
1055
1056 <tr class="api-index-item">
1057 <th scope="row" class="parameter api-index-item-title">Method</th>
1058 <td class="type api-index-item-title"><?php echo wp_kses_post( $doc['method'] ); ?></td>
1059 </tr>
1060
1061 <tr class="api-index-item">
1062 <th scope="row" class="parameter api-index-item-title">URL</th>
1063 <?php
1064 $version = WPCOM_JSON_API__CURRENT_VERSION;
1065 if ( ! empty( $this->max_version ) ) {
1066 $version = $this->max_version;
1067 }
1068 ?>
1069 <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>
1070 </tr>
1071
1072 <tr class="api-index-item">
1073 <th scope="row" class="parameter api-index-item-title">Requires authentication?</th>
1074 <?php
1075 $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'] ) );
1076 ?>
1077 <td class="type api-index-item-title"><?php echo ( true === (bool) $requires_auth->requires_authentication ? 'Yes' : 'No' ); ?></td>
1078 </tr>
1079
1080 </tbody>
1081 </table>
1082
1083 </section>
1084
1085 <?php
1086
1087 foreach ( array(
1088 'path' => 'Method Parameters',
1089 'query' => 'Query Parameters',
1090 'body' => 'Request Parameters',
1091 'response' => 'Response Parameters',
1092 ) as $doc_section_key => $label ) :
1093 $doc_section = 'response' === $doc_section_key ? $doc['response']['body'] : $doc['request'][ $doc_section_key ];
1094 if ( ! $doc_section ) {
1095 continue;
1096 }
1097
1098 $param_label = strtolower( str_replace( ' ', '-', $label ) );
1099 ?>
1100
1101 <section class="<?php echo esc_attr( $param_label ); ?>">
1102
1103 <h2 id="apidoc-<?php echo esc_attr( $doc_section_key ); ?>"><?php echo wp_kses_post( $label ); ?></h2>
1104
1105 <table class="api-doc api-doc-<?php echo esc_attr( $param_label ); ?>-parameters api-doc-<?php echo esc_attr( strtolower( str_replace( ' ', '-', $doc['group'] ) ) ); ?>">
1106
1107 <thead>
1108 <tr>
1109 <th class="api-index-title" scope="column">Parameter</th>
1110 <th class="api-index-title" scope="column">Type</th>
1111 <th class="api-index-title" scope="column">Description</th>
1112 </tr>
1113 </thead>
1114 <tbody>
1115
1116 <?php foreach ( $doc_section as $key => $item ) : ?>
1117
1118 <tr class="api-index-item">
1119 <th scope="row" class="parameter api-index-item-title"><?php echo wp_kses_post( $key ); ?></th>
1120 <td class="type api-index-item-title"><?php echo wp_kses_post( $item['type'] ); // @todo auto-link? ?></td>
1121 <td class="description api-index-item-body">
1122 <?php
1123
1124 $this->generate_doc_description( $item['description'] );
1125
1126 ?>
1127 </td>
1128 </tr>
1129
1130 <?php endforeach; ?>
1131 </tbody>
1132 </table>
1133 </section>
1134 <?php endforeach; ?>
1135
1136 <?php
1137 if ( 'unset' !== $original_post ) {
1138 $GLOBALS['post'] = $original_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
1139 }
1140 }
1141
1142 /**
1143 * `preg_replace_callback` callback to add http_build_query to php content example.
1144 *
1145 * @todo Is this used anywhere?
1146 *
1147 * @param array $matches Matches.
1148 * @return string
1149 */
1150 public function add_http_build_query_to_php_content_example( $matches ) {
1151 $trimmed_match = ltrim( $matches[0] );
1152 $pad = substr( $matches[0], 0, -1 * strlen( $trimmed_match ) );
1153 $pad = ltrim( $pad, ' ' );
1154 $return = ' ' . str_replace( "\n", "\n ", $matches[0] );
1155 return " http_build_query({$return}{$pad})";
1156 }
1157
1158 /**
1159 * Recursively generates the <dl>'s to document item descriptions.
1160 * Echoes HTML.
1161 *
1162 * @param string|array $item Post data to output, or an array of key => data mappings.
1163 */
1164 public function generate_doc_description( $item ) {
1165 if ( is_array( $item ) ) :
1166 ?>
1167
1168 <dl>
1169 <?php foreach ( $item as $description_key => $description_value ) : ?>
1170
1171 <dt><?php echo wp_kses_post( $description_key . ':' ); ?></dt>
1172 <dd><?php $this->generate_doc_description( $description_value ); ?></dd>
1173
1174 <?php endforeach; ?>
1175
1176 </dl>
1177
1178 <?php
1179 else :
1180 echo wp_kses_post( $item );
1181 endif;
1182 }
1183
1184 /**
1185 * Auto generates documentation based on description, method, path, path_labels, and query parameters.
1186 * Echoes HTML.
1187 */
1188 public function generate_documentation() {
1189 $format = str_replace( '%d', '%s', $this->path );
1190 $path_labeled = $format;
1191 if ( ! empty( $this->path_labels ) ) {
1192 $path_labeled = vsprintf( $format, array_keys( $this->path_labels ) );
1193 }
1194 $boolean_arg = array( 'false', 'true' );
1195 $naeloob_arg = array( 'true', 'false' );
1196
1197 $doc = array(
1198 'description' => $this->description,
1199 'method' => $this->method,
1200 'path_format' => $this->path,
1201 'path_labeled' => $path_labeled,
1202 'group' => $this->group,
1203 'request' => array(
1204 'path' => array(),
1205 'query' => array(),
1206 'body' => array(),
1207 ),
1208 'response' => array(
1209 'body' => array(),
1210 ),
1211 );
1212
1213 foreach ( array(
1214 'path_labels' => 'path',
1215 'query' => 'query',
1216 'request_format' => 'body',
1217 'response_format' => 'body',
1218 ) as $_property => $doc_item ) {
1219 foreach ( (array) $this->$_property as $key => $description ) {
1220 if ( is_array( $description ) ) {
1221 $description_keys = array_keys( $description );
1222 if ( $boolean_arg === $description_keys || $naeloob_arg === $description_keys ) {
1223 $type = '(bool)';
1224 } else {
1225 $type = '(string)';
1226 }
1227
1228 if ( 'response_format' !== $_property ) {
1229 // hack - don't show "(default)" in response format.
1230 reset( $description );
1231 $description_key = key( $description );
1232 $description[ $description_key ] = "(default) {$description[$description_key]}";
1233 }
1234 } else {
1235 $types = $this->parse_types( $description );
1236 $type = array();
1237 $default = '';
1238
1239 if ( 'none' === $types ) {
1240 $types = array();
1241 $types[]['type'] = 'none';
1242 }
1243
1244 foreach ( $types as $type_array ) {
1245 $type[] = $type_array['type'];
1246 if ( isset( $type_array['default'] ) ) {
1247 $default = $type_array['default'];
1248 if ( 'string' === $type_array['type'] ) {
1249 $default = "'$default'";
1250 }
1251 }
1252 }
1253 $type = '(' . join( '|', $type ) . ')';
1254 list( , $description ) = explode( ')', $description, 2 );
1255 $description = trim( $description );
1256 if ( $default ) {
1257 $description .= " Default: $default.";
1258 }
1259 }
1260
1261 $item = compact( 'type', 'description' );
1262
1263 if ( 'response_format' === $_property ) {
1264 $doc['response'][ $doc_item ][ $key ] = $item;
1265 } else {
1266 $doc['request'][ $doc_item ][ $key ] = $item;
1267 }
1268 }
1269 }
1270
1271 return $doc;
1272 }
1273
1274 /**
1275 * Can the user view the post?
1276 *
1277 * @param int $post_id Post ID.
1278 * @return bool|WP_Error
1279 */
1280 public function user_can_view_post( $post_id ) {
1281 $post = get_post( $post_id );
1282 if ( ! $post || is_wp_error( $post ) ) {
1283 return false;
1284 }
1285
1286 if ( 'inherit' === $post->post_status ) {
1287 $parent_post = get_post( $post->post_parent );
1288 $post_status_obj = get_post_status_object( $parent_post->post_status );
1289 } else {
1290 $post_status_obj = get_post_status_object( $post->post_status );
1291 }
1292
1293 if ( ! $post_status_obj->public ) {
1294 if ( is_user_logged_in() ) {
1295 if ( $post_status_obj->protected ) {
1296 if ( ! current_user_can( 'edit_post', $post->ID ) ) {
1297 return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1298 }
1299 } elseif ( $post_status_obj->private ) {
1300 if ( ! current_user_can( 'read_post', $post->ID ) ) {
1301 return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1302 }
1303 } elseif ( in_array( $post->post_status, array( 'inherit', 'trash' ), true ) ) {
1304 if ( ! current_user_can( 'edit_post', $post->ID ) ) {
1305 return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1306 }
1307 } elseif ( 'auto-draft' === $post->post_status ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedElseif
1308 // allow auto-drafts.
1309 } else {
1310 return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1311 }
1312 } else {
1313 return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1314 }
1315 }
1316
1317 if (
1318 -1 === (int) get_option( 'blog_public' ) &&
1319 /**
1320 * Filter access to a specific post.
1321 *
1322 * @module json-api
1323 *
1324 * @since 3.4.0
1325 *
1326 * @param bool current_user_can( 'read_post', $post->ID ) Can the current user access the post.
1327 * @param WP_Post $post Post data.
1328 */
1329 ! apply_filters(
1330 'wpcom_json_api_user_can_view_post',
1331 current_user_can( 'read_post', $post->ID ),
1332 $post
1333 )
1334 ) {
1335 return new WP_Error(
1336 'unauthorized',
1337 'User cannot view post',
1338 array(
1339 'status_code' => 403,
1340 'error' => 'private_blog',
1341 )
1342 );
1343 }
1344
1345 if ( strlen( $post->post_password ) && ! current_user_can( 'edit_post', $post->ID ) ) {
1346 return new WP_Error(
1347 'unauthorized',
1348 'User cannot view password protected post',
1349 array(
1350 'status_code' => 403,
1351 'error' => 'password_protected',
1352 )
1353 );
1354 }
1355
1356 return true;
1357 }
1358
1359 /**
1360 * Returns author object.
1361 *
1362 * @param object $author user ID, user row, WP_User object, comment row, post row.
1363 * @param bool $show_email_and_ip output the author's email address and IP address?.
1364 *
1365 * @return object
1366 */
1367 public function get_author( $author, $show_email_and_ip = false ) {
1368 $ip_address = isset( $author->comment_author_IP ) ? $author->comment_author_IP : '';
1369
1370 if ( isset( $author->comment_author_email ) ) {
1371 $id = ( isset( $author->user_id ) && $author->user_id ) ? $author->user_id : 0;
1372 $login = '';
1373 $email = $author->comment_author_email;
1374 $name = $author->comment_author;
1375 $first_name = '';
1376 $last_name = '';
1377 $url = $author->comment_author_url;
1378 $avatar_url = $this->api->get_avatar_url( $author );
1379 $profile_url = 'https://en.gravatar.com/' . md5( strtolower( trim( $email ) ) );
1380 $nice = '';
1381 $site_id = -1;
1382
1383 // Comment author URLs and Emails are sent through wp_kses() on save, which replaces "&" with "&amp;"
1384 // "&" is the only email/URL character altered by wp_kses().
1385 foreach ( array( 'email', 'url' ) as $field ) {
1386 $$field = str_replace( '&amp;', '&', $$field );
1387 }
1388 } else {
1389 if ( isset( $author->user_id ) && $author->user_id ) {
1390 $author = $author->user_id;
1391 } elseif ( isset( $author->user_email ) ) {
1392 $author = $author->ID;
1393 } elseif ( isset( $author->post_author ) ) {
1394 // then $author is a Post Object.
1395 if ( ! $author->post_author ) {
1396 return null;
1397 }
1398 /**
1399 * Filter whether the current site is a Jetpack site.
1400 *
1401 * @module json-api
1402 *
1403 * @since 3.3.0
1404 *
1405 * @param bool false Is the current site a Jetpack site. Default to false.
1406 * @param int get_current_blog_id() Blog ID.
1407 */
1408 $is_jetpack = true === apply_filters( 'is_jetpack_site', false, get_current_blog_id() );
1409 $post_id = $author->ID;
1410 if ( $is_jetpack && ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
1411 $id = get_post_meta( $post_id, '_jetpack_post_author_external_id', true );
1412 $email = get_post_meta( $post_id, '_jetpack_author_email', true );
1413 $login = '';
1414 $name = get_post_meta( $post_id, '_jetpack_author', true );
1415 $first_name = '';
1416 $last_name = '';
1417 $url = '';
1418 $nice = '';
1419 } else {
1420 $author = $author->post_author;
1421 }
1422 }
1423
1424 if ( ! isset( $id ) ) {
1425 $user = get_user_by( 'id', $author );
1426 if ( ! $user || is_wp_error( $user ) ) {
1427 trigger_error( 'Unknown user', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
1428
1429 return null;
1430 }
1431 $id = $user->ID;
1432 $email = $user->user_email;
1433 $login = $user->user_login;
1434 $name = $user->display_name;
1435 $first_name = $user->first_name;
1436 $last_name = $user->last_name;
1437 $url = $user->user_url;
1438 $nice = $user->user_nicename;
1439 }
1440 if ( defined( 'IS_WPCOM' ) && IS_WPCOM && ! $is_jetpack ) {
1441 $active_blog = get_active_blog_for_user( $id );
1442 $site_id = $active_blog->blog_id;
1443 if ( $site_id > -1 ) {
1444 $site_visible = (
1445 -1 !== (int) $active_blog->public ||
1446 is_private_blog_user( $site_id, get_current_user_id() )
1447 );
1448 }
1449 $profile_url = "https://en.gravatar.com/{$login}";
1450 } else {
1451 $profile_url = 'https://en.gravatar.com/' . md5( strtolower( trim( $email ) ) );
1452 $site_id = -1;
1453 }
1454
1455 $avatar_url = $this->api->get_avatar_url( $email );
1456 }
1457
1458 if ( $show_email_and_ip ) {
1459 $email = (string) $email;
1460 $ip_address = (string) $ip_address;
1461 } else {
1462 $email = false;
1463 $ip_address = false;
1464 }
1465
1466 $author = array(
1467 'ID' => (int) $id,
1468 'login' => (string) $login,
1469 'email' => $email, // string|bool.
1470 'name' => (string) $name,
1471 'first_name' => (string) $first_name,
1472 'last_name' => (string) $last_name,
1473 'nice_name' => (string) $nice,
1474 'URL' => (string) esc_url_raw( $url ),
1475 'avatar_URL' => (string) esc_url_raw( $avatar_url ),
1476 'profile_URL' => (string) esc_url_raw( $profile_url ),
1477 'ip_address' => $ip_address, // string|bool.
1478 );
1479
1480 if ( $site_id > -1 ) {
1481 $author['site_ID'] = (int) $site_id;
1482 $author['site_visible'] = $site_visible;
1483 }
1484
1485 return (object) $author;
1486 }
1487
1488 /**
1489 * Get a media item.
1490 *
1491 * @param int $media_id Media post ID.
1492 * @return object|WP_Error Media item data, or WP_Error.
1493 */
1494 public function get_media_item( $media_id ) {
1495 $media_item = get_post( $media_id );
1496
1497 if ( ! $media_item || is_wp_error( $media_item ) ) {
1498 return new WP_Error( 'unknown_media', 'Unknown Media', 404 );
1499 }
1500
1501 $response = array(
1502 'id' => (string) $media_item->ID,
1503 'date' => (string) $this->format_date( $media_item->post_date_gmt, $media_item->post_date ),
1504 'parent' => $media_item->post_parent,
1505 'link' => wp_get_attachment_url( $media_item->ID ),
1506 'title' => $media_item->post_title,
1507 'caption' => $media_item->post_excerpt,
1508 'description' => $media_item->post_content,
1509 'metadata' => wp_get_attachment_metadata( $media_item->ID ),
1510 );
1511
1512 if ( defined( 'IS_WPCOM' ) && IS_WPCOM && is_array( $response['metadata'] ) && ! empty( $response['metadata']['file'] ) ) {
1513 remove_filter( '_wp_relative_upload_path', 'wpcom_wp_relative_upload_path', 10 );
1514 $response['metadata']['file'] = _wp_relative_upload_path( $response['metadata']['file'] );
1515 add_filter( '_wp_relative_upload_path', 'wpcom_wp_relative_upload_path', 10, 2 );
1516 }
1517
1518 $response['meta'] = (object) array(
1519 'links' => (object) array(
1520 'self' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_id ),
1521 'help' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_id, 'help' ),
1522 'site' => (string) $this->links->get_site_link( $this->api->get_blog_id_for_output() ),
1523 ),
1524 );
1525
1526 return (object) $response;
1527 }
1528
1529 /**
1530 * Get a v1.1 media item.
1531 *
1532 * @param int $media_id Media post ID.
1533 * @param WP_Post|null $media_item Media item.
1534 * @param string|null $file File path.
1535 * @return object|WP_Error Media item data, or WP_Error.
1536 */
1537 public function get_media_item_v1_1( $media_id, $media_item = null, $file = null ) {
1538
1539 if ( ! $media_item ) {
1540 $media_item = get_post( $media_id );
1541 }
1542
1543 if ( ! $media_item || is_wp_error( $media_item ) ) {
1544 return new WP_Error( 'unknown_media', 'Unknown Media', 404 );
1545 }
1546
1547 $attachment_file = get_attached_file( $media_item->ID );
1548
1549 $file = basename( $attachment_file ? $attachment_file : $file );
1550 $file_info = pathinfo( $file );
1551 $ext = isset( $file_info['extension'] ) ? $file_info['extension'] : null;
1552
1553 // File operations are handled differently on WordPress.com.
1554 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1555 $attachment_metadata = wp_get_attachment_metadata( $media_item->ID );
1556 $filesize = ! empty( $attachment_metadata['filesize'] )
1557 ? $attachment_metadata['filesize']
1558 : 0;
1559 } else {
1560 // For VideoPress videos, $attachment_file is the video URL.
1561 $filesize = file_exists( $attachment_file )
1562 ? filesize( $attachment_file )
1563 : 0;
1564 }
1565
1566 $response = array(
1567 'ID' => $media_item->ID,
1568 'URL' => wp_get_attachment_url( $media_item->ID ),
1569 'guid' => $media_item->guid,
1570 'date' => (string) $this->format_date( $media_item->post_date_gmt, $media_item->post_date ),
1571 'post_ID' => $media_item->post_parent,
1572 'author_ID' => (int) $media_item->post_author,
1573 'file' => $file,
1574 'mime_type' => $media_item->post_mime_type,
1575 'extension' => $ext,
1576 'title' => $media_item->post_title,
1577 'caption' => $media_item->post_excerpt,
1578 'description' => $media_item->post_content,
1579 'alt' => get_post_meta( $media_item->ID, '_wp_attachment_image_alt', true ),
1580 'icon' => wp_mime_type_icon( $media_item->ID ),
1581 'size' => size_format( (int) $filesize, 2 ),
1582 'thumbnails' => array(),
1583 );
1584
1585 if ( in_array( $ext, array( 'jpg', 'jpeg', 'png', 'gif', 'webp' ), true ) ) {
1586 $metadata = wp_get_attachment_metadata( $media_item->ID );
1587 if ( isset( $metadata['height'], $metadata['width'] ) ) {
1588 $response['height'] = $metadata['height'];
1589 $response['width'] = $metadata['width'];
1590 }
1591
1592 if ( isset( $metadata['sizes'] ) ) {
1593 /**
1594 * Filter the thumbnail sizes available for each attachment ID.
1595 *
1596 * @module json-api
1597 *
1598 * @since 3.9.0
1599 *
1600 * @param array $metadata['sizes'] Array of thumbnail sizes available for a given attachment ID.
1601 * @param string $media_id Attachment ID.
1602 */
1603 $sizes = apply_filters( 'rest_api_thumbnail_sizes', $metadata['sizes'], $media_item->ID );
1604 if ( is_array( $sizes ) ) {
1605 foreach ( $sizes as $size => $size_details ) {
1606 $response['thumbnails'][ $size ] = dirname( $response['URL'] ) . '/' . $size_details['file'];
1607 }
1608 /**
1609 * Filter the thumbnail URLs for attachment files.
1610 *
1611 * @module json-api
1612 *
1613 * @since 7.1.0
1614 *
1615 * @param array $metadata['sizes'] Array with thumbnail sizes as keys and URLs as values.
1616 */
1617 $response['thumbnails'] = apply_filters( 'rest_api_thumbnail_size_urls', $response['thumbnails'] );
1618 }
1619 }
1620
1621 if ( isset( $metadata['image_meta'] ) ) {
1622 $response['exif'] = $metadata['image_meta'];
1623 }
1624 }
1625
1626 if ( in_array( $ext, array( 'mp3', 'm4a', 'wav', 'ogg' ), true ) ) {
1627 $metadata = wp_get_attachment_metadata( $media_item->ID );
1628 $response['length'] = $metadata['length'];
1629 $response['exif'] = $metadata;
1630 }
1631
1632 $is_video = false;
1633
1634 if (
1635 in_array( $ext, array( 'ogv', 'mp4', 'mov', 'wmv', 'avi', 'mpg', '3gp', '3g2', 'm4v' ), true )
1636 || 'video/videopress' === $response['mime_type']
1637 ) {
1638 $is_video = true;
1639 }
1640
1641 if ( $is_video ) {
1642 $metadata = wp_get_attachment_metadata( $media_item->ID );
1643
1644 if ( isset( $metadata['height'], $metadata['width'] ) ) {
1645 $response['height'] = $metadata['height'];
1646 $response['width'] = $metadata['width'];
1647 }
1648
1649 if ( isset( $metadata['length'] ) ) {
1650 $response['length'] = $metadata['length'];
1651 }
1652
1653 // add VideoPress info.
1654 if ( function_exists( 'video_get_info_by_blogpostid' ) ) {
1655 $info = video_get_info_by_blogpostid( $this->api->get_blog_id_for_output(), $media_item->ID );
1656
1657 // If we failed to get VideoPress info, but it exists in the meta data (for some reason)
1658 // then let's use that.
1659 if ( false === $info && isset( $metadata['videopress'] ) ) {
1660 $info = (object) $metadata['videopress'];
1661 }
1662
1663 if ( isset( $info->rating ) ) {
1664 $response['rating'] = $info->rating;
1665 }
1666
1667 if ( isset( $info->display_embed ) ) {
1668 $response['display_embed'] = (string) (int) $info->display_embed;
1669 // If not, default to metadata (for WPCOM).
1670 } elseif ( isset( $metadata['videopress']['display_embed'] ) ) {
1671 // We convert it to int then to string so that (bool) false to become "0".
1672 $response['display_embed'] = (string) (int) $metadata['videopress']['display_embed'];
1673 }
1674
1675 if ( isset( $info->allow_download ) ) {
1676 $response['allow_download'] = (string) (int) $info->allow_download;
1677 } elseif ( isset( $metadata['videopress']['allow_download'] ) ) {
1678 // We convert it to int then to string so that (bool) false to become "0".
1679 $response['allow_download'] = (string) (int) $metadata['videopress']['allow_download'];
1680 }
1681
1682 if ( isset( $info->thumbnail_generating ) ) {
1683 $response['thumbnail_generating'] = (bool) intval( $info->thumbnail_generating );
1684 } elseif ( isset( $metadata['videopress']['thumbnail_generating'] ) ) {
1685 $response['thumbnail_generating'] = (bool) intval( $metadata['videopress']['thumbnail_generating'] );
1686 }
1687
1688 if ( isset( $info->privacy_setting ) ) {
1689 $response['privacy_setting'] = (int) $info->privacy_setting;
1690 } elseif ( isset( $metadata['videopress']['privacy_setting'] ) ) {
1691 $response['privacy_setting'] = (int) $metadata['videopress']['privacy_setting'];
1692 }
1693
1694 $thumbnail_query_data = array();
1695 if ( function_exists( 'video_is_private' ) && video_is_private( $info ) ) {
1696 $thumbnail_query_data['metadata_token'] = video_generate_auth_token( $info );
1697 }
1698
1699 // Thumbnails.
1700 if ( function_exists( 'video_format_done' ) && function_exists( 'video_image_url_by_guid' ) ) {
1701 $response['thumbnails'] = array(
1702 'fmt_hd' => '',
1703 'fmt_dvd' => '',
1704 'fmt_std' => '',
1705 );
1706 foreach ( $response['thumbnails'] as $size => $thumbnail_url ) {
1707 if ( video_format_done( $info, $size ) ) {
1708 $response['thumbnails'][ $size ] = \add_query_arg( $thumbnail_query_data, \video_image_url_by_guid( $info->guid, $size ) );
1709 } else {
1710 unset( $response['thumbnails'][ $size ] );
1711 }
1712 }
1713 }
1714
1715 if ( isset( $info->title ) ) {
1716 $response['title'] = $info->title;
1717 }
1718
1719 // If we didn't get VideoPress information (for some reason) then let's
1720 // not try and include it in the response.
1721 if ( isset( $info->guid ) ) {
1722 $response['videopress_guid'] = $info->guid;
1723 $response['videopress_processing_done'] = true;
1724 if ( '0000-00-00 00:00:00' === $info->finish_date_gmt ) {
1725 $response['videopress_processing_done'] = false;
1726 }
1727 }
1728 }
1729 }
1730
1731 $response['thumbnails'] = (object) $response['thumbnails'];
1732
1733 $response['meta'] = (object) array(
1734 'links' => (object) array(
1735 'self' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_item->ID ),
1736 'help' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_item->ID, 'help' ),
1737 'site' => (string) $this->links->get_site_link( $this->api->get_blog_id_for_output() ),
1738 ),
1739 );
1740
1741 // add VideoPress link to the meta.
1742 if ( isset( $response['videopress_guid'] ) ) {
1743 if ( function_exists( 'video_get_info_by_blogpostid' ) ) {
1744 $response['meta']->links->videopress = (string) $this->links->get_link( '/videos/%s', $response['videopress_guid'], '' );
1745 }
1746 }
1747
1748 if ( $media_item->post_parent > 0 ) {
1749 $response['meta']->links->parent = (string) $this->links->get_post_link( $this->api->get_blog_id_for_output(), $media_item->post_parent );
1750 }
1751
1752 return (object) $response;
1753 }
1754
1755 /**
1756 * Get a formatted taxonomy.
1757 *
1758 * @param int $taxonomy_id Taxonomy ID.
1759 * @param string $taxonomy_type Name of taxonomy.
1760 * @param string $context Context, 'edit' or 'display'.
1761 * @return object|WP_Error
1762 */
1763 public function get_taxonomy( $taxonomy_id, $taxonomy_type, $context ) {
1764
1765 $taxonomy = get_term_by( 'slug', $taxonomy_id, $taxonomy_type );
1766 // keep updating this function.
1767 if ( ! $taxonomy || is_wp_error( $taxonomy ) ) {
1768 return new WP_Error( 'unknown_taxonomy', 'Unknown taxonomy', 404 );
1769 }
1770
1771 return $this->format_taxonomy( $taxonomy, $taxonomy_type, $context );
1772 }
1773
1774 /**
1775 * Format a taxonomy.
1776 *
1777 * @param WP_Term $taxonomy Taxonomy.
1778 * @param string $taxonomy_type Name of taxonomy.
1779 * @param string $context Context, 'edit' or 'display'.
1780 * @return object|WP_Error
1781 */
1782 public function format_taxonomy( $taxonomy, $taxonomy_type, $context ) {
1783 // Permissions.
1784 switch ( $context ) {
1785 case 'edit':
1786 $tax = get_taxonomy( $taxonomy_type );
1787 if ( ! current_user_can( $tax->cap->edit_terms ) ) {
1788 return new WP_Error( 'unauthorized', 'User cannot edit taxonomy', 403 );
1789 }
1790 break;
1791 case 'display':
1792 if ( -1 === (int) get_option( 'blog_public' ) && ! current_user_can( 'read' ) ) {
1793 return new WP_Error( 'unauthorized', 'User cannot view taxonomy', 403 );
1794 }
1795 break;
1796 default:
1797 return new WP_Error( 'invalid_context', 'Invalid API CONTEXT', 400 );
1798 }
1799
1800 $response = array();
1801 $response['ID'] = (int) $taxonomy->term_id;
1802 $response['name'] = (string) $taxonomy->name;
1803 $response['slug'] = (string) $taxonomy->slug;
1804 $response['description'] = (string) $taxonomy->description;
1805 $response['post_count'] = (int) $taxonomy->count;
1806 $response['feed_url'] = get_term_feed_link( $taxonomy->term_id, $taxonomy_type );
1807
1808 if ( is_taxonomy_hierarchical( $taxonomy_type ) ) {
1809 $response['parent'] = (int) $taxonomy->parent;
1810 }
1811
1812 $response['meta'] = (object) array(
1813 'links' => (object) array(
1814 'self' => (string) $this->links->get_taxonomy_link( $this->api->get_blog_id_for_output(), $taxonomy->slug, $taxonomy_type ),
1815 'help' => (string) $this->links->get_taxonomy_link( $this->api->get_blog_id_for_output(), $taxonomy->slug, $taxonomy_type, 'help' ),
1816 'site' => (string) $this->links->get_site_link( $this->api->get_blog_id_for_output() ),
1817 ),
1818 );
1819
1820 return (object) $response;
1821 }
1822
1823 /**
1824 * Returns ISO 8601 formatted datetime: 2011-12-08T01:15:36-08:00
1825 *
1826 * @param string $date_gmt GMT datetime string.
1827 * @param string $date Optional. Used to calculate the offset from GMT.
1828 * @return string
1829 */
1830 public function format_date( $date_gmt, $date = null ) {
1831 return WPCOM_JSON_API_Date::format_date( $date_gmt, $date );
1832 }
1833
1834 /**
1835 * Parses a date string and returns the local and GMT representations
1836 * of that date & time in 'YYYY-MM-DD HH:MM:SS' format without
1837 * timezones or offsets. If the parsed datetime was not localized to a
1838 * particular timezone or offset we will assume it was given in GMT
1839 * relative to now and will convert it to local time using either the
1840 * timezone set in the options table for the blog or the GMT offset.
1841 *
1842 * @param datetime string $date_string Date to parse.
1843 *
1844 * @return array( $local_time_string, $gmt_time_string )
1845 */
1846 public function parse_date( $date_string ) {
1847 $date_string_info = date_parse( $date_string );
1848 if ( is_array( $date_string_info ) && 0 === $date_string_info['error_count'] ) {
1849 // Check if it's already localized. Can't just check is_localtime because date_parse('oppossum') returns true; WTF, PHP.
1850 if ( isset( $date_string_info['zone'] ) && true === $date_string_info['is_localtime'] ) {
1851 $dt_utc = new DateTime( $date_string );
1852 $dt_local = clone $dt_utc;
1853 $dt_utc->setTimezone( new DateTimeZone( 'UTC' ) );
1854 return array(
1855 (string) $dt_local->format( 'Y-m-d H:i:s' ),
1856 (string) $dt_utc->format( 'Y-m-d H:i:s' ),
1857 );
1858 }
1859
1860 // It's parseable but no TZ info so assume UTC.
1861 $dt_utc = new DateTime( $date_string, new DateTimeZone( 'UTC' ) );
1862 $dt_local = clone $dt_utc;
1863 } else {
1864 // Could not parse time, use now in UTC.
1865 $dt_utc = new DateTime( 'now', new DateTimeZone( 'UTC' ) );
1866 $dt_local = clone $dt_utc;
1867 }
1868
1869 $dt_local->setTimezone( wp_timezone() );
1870
1871 return array(
1872 (string) $dt_local->format( 'Y-m-d H:i:s' ),
1873 (string) $dt_utc->format( 'Y-m-d H:i:s' ),
1874 );
1875 }
1876
1877 /**
1878 * Load the functions.php file for the current theme to get its post formats, CPTs, etc.
1879 */
1880 public function load_theme_functions() {
1881 if ( false === defined( 'STYLESHEETPATH' ) ) {
1882 wp_templating_constants();
1883 }
1884
1885 // bail if we've done this already (can happen when calling /batch endpoint).
1886 if ( defined( 'REST_API_THEME_FUNCTIONS_LOADED' ) ) {
1887 return;
1888 }
1889
1890 // VIP context loading is handled elsewhere, so bail to prevent
1891 // duplicate loading. See `switch_to_blog_and_validate_user()`.
1892 if ( defined( 'WPCOM_IS_VIP_ENV' ) && WPCOM_IS_VIP_ENV ) {
1893 return;
1894 }
1895
1896 $do_check_theme =
1897 defined( 'REST_API_TEST_REQUEST' ) && REST_API_TEST_REQUEST ||
1898 defined( 'IS_WPCOM' ) && IS_WPCOM;
1899
1900 if ( $do_check_theme && ! wpcom_should_load_theme_files_on_rest_api() ) {
1901 return;
1902 }
1903
1904 define( 'REST_API_THEME_FUNCTIONS_LOADED', true );
1905
1906 // the theme info we care about is found either within functions.php or one of the jetpack files.
1907 $function_files = array( '/functions.php', '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php' );
1908
1909 $copy_dirs = array( get_template_directory() );
1910
1911 // Is this a child theme? Load the child theme's functions file.
1912 if ( get_stylesheet_directory() !== get_template_directory() && wpcom_is_child_theme() ) {
1913 foreach ( $function_files as $function_file ) {
1914 if ( file_exists( get_stylesheet_directory() . $function_file ) ) {
1915 require_once get_stylesheet_directory() . $function_file;
1916 }
1917 }
1918 $copy_dirs[] = get_stylesheet_directory();
1919 }
1920
1921 foreach ( $function_files as $function_file ) {
1922 if ( file_exists( get_template_directory() . $function_file ) ) {
1923 require_once get_template_directory() . $function_file;
1924 }
1925 }
1926
1927 // add inc/wpcom.php and/or includes/wpcom.php.
1928 wpcom_load_theme_compat_file();
1929
1930 // Enable including additional directories or files in actions to be copied.
1931 $copy_dirs = apply_filters( 'restapi_theme_action_copy_dirs', $copy_dirs );
1932
1933 // since the stuff we care about (CPTS, post formats, are usually on setup or init hooks, we want to load those).
1934 $this->copy_hooks( 'after_setup_theme', 'restapi_theme_after_setup_theme', $copy_dirs );
1935
1936 /**
1937 * Fires functions hooked onto `after_setup_theme` by the theme for the purpose of the REST API.
1938 *
1939 * The REST API does not load the theme when processing requests.
1940 * To enable theme-based functionality, the API will load the '/functions.php',
1941 * '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php files
1942 * of the theme (parent and child) and copy functions hooked onto 'after_setup_theme' within those files.
1943 *
1944 * @module json-api
1945 *
1946 * @since 3.2.0
1947 */
1948 do_action( 'restapi_theme_after_setup_theme' );
1949 $this->copy_hooks( 'init', 'restapi_theme_init', $copy_dirs );
1950
1951 /**
1952 * Fires functions hooked onto `init` by the theme for the purpose of the REST API.
1953 *
1954 * The REST API does not load the theme when processing requests.
1955 * To enable theme-based functionality, the API will load the '/functions.php',
1956 * '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php files
1957 * of the theme (parent and child) and copy functions hooked onto 'init' within those files.
1958 *
1959 * @module json-api
1960 *
1961 * @since 3.2.0
1962 */
1963 do_action( 'restapi_theme_init' );
1964 }
1965
1966 /**
1967 * Copy hook functions.
1968 *
1969 * @param string $from_hook Hook to copy from.
1970 * @param string $to_hook Hook to copy to.
1971 * @param array $base_paths Only copy hooks defined in the specified paths.
1972 */
1973 public function copy_hooks( $from_hook, $to_hook, $base_paths ) {
1974 global $wp_filter;
1975 foreach ( $wp_filter as $hook => $actions ) {
1976
1977 if ( $from_hook !== $hook ) {
1978 continue;
1979 }
1980 if ( ! has_action( $hook ) ) {
1981 continue;
1982 }
1983
1984 foreach ( $actions as $priority => $callbacks ) {
1985 foreach ( $callbacks as $callback_data ) {
1986 $callback = $callback_data['function'];
1987
1988 // use reflection api to determine filename where function is defined.
1989 $reflection = $this->get_reflection( $callback );
1990
1991 if ( false !== $reflection ) {
1992 $file_name = $reflection->getFileName();
1993 foreach ( $base_paths as $base_path ) {
1994
1995 // only copy hooks with functions which are part of the specified files.
1996 if ( 0 === strpos( $file_name, $base_path ) ) {
1997 add_action(
1998 $to_hook,
1999 $callback_data['function'],
2000 $priority,
2001 $callback_data['accepted_args']
2002 );
2003 }
2004 }
2005 }
2006 }
2007 }
2008 }
2009 }
2010
2011 /**
2012 * Get a ReflectionMethod or ReflectionFunction for the callback.
2013 *
2014 * @param callable $callback Callback.
2015 * @return ReflectionMethod|ReflectionFunction|false
2016 */
2017 public function get_reflection( $callback ) {
2018 if ( is_array( $callback ) ) {
2019 list( $class, $method ) = $callback;
2020 return new ReflectionMethod( $class, $method );
2021 }
2022
2023 if ( is_string( $callback ) && strpos( $callback, '::' ) !== false ) {
2024 list( $class, $method ) = explode( '::', $callback );
2025 return new ReflectionMethod( $class, $method );
2026 }
2027
2028 if ( method_exists( $callback, '__invoke' ) ) {
2029 return new ReflectionMethod( $callback, '__invoke' );
2030 }
2031
2032 if ( is_string( $callback ) && strpos( $callback, '::' ) === false && function_exists( $callback ) ) {
2033 return new ReflectionFunction( $callback );
2034 }
2035
2036 return false;
2037 }
2038
2039 /**
2040 * Check whether a user can view or edit a post type.
2041 *
2042 * @param string $post_type post type to check.
2043 * @param string $context 'display' or 'edit'.
2044 * @return bool
2045 */
2046 public function current_user_can_access_post_type( $post_type, $context = 'display' ) {
2047 $post_type_object = get_post_type_object( $post_type );
2048 if ( ! $post_type_object ) {
2049 return false;
2050 }
2051
2052 switch ( $context ) {
2053 case 'edit':
2054 return current_user_can( $post_type_object->cap->edit_posts );
2055 case 'display':
2056 return $post_type_object->public || current_user_can( $post_type_object->cap->read_private_posts );
2057 default:
2058 return false;
2059 }
2060 }
2061
2062 /**
2063 * Is the post type allowed?
2064 *
2065 * @param string $post_type Post type.
2066 * @return bool
2067 */
2068 public function is_post_type_allowed( $post_type ) {
2069 // if the post type is empty, that's fine, WordPress will default to post.
2070 if ( empty( $post_type ) ) {
2071 return true;
2072 }
2073
2074 // allow special 'any' type.
2075 if ( 'any' === $post_type ) {
2076 return true;
2077 }
2078
2079 // check for allowed types.
2080 if ( in_array( $post_type, $this->_get_whitelisted_post_types(), true ) ) {
2081 return true;
2082 }
2083
2084 $post_type_object = get_post_type_object( $post_type );
2085 if ( $post_type_object ) {
2086 if ( ! empty( $post_type_object->show_in_rest ) ) {
2087 return $post_type_object->show_in_rest;
2088 }
2089 if ( ! empty( $post_type_object->publicly_queryable ) ) {
2090 return $post_type_object->publicly_queryable;
2091 }
2092 }
2093
2094 return ! empty( $post_type_object->public );
2095 }
2096
2097 /**
2098 * Gets the whitelisted post types that JP should allow access to.
2099 *
2100 * @return array Whitelisted post types.
2101 */
2102 protected function _get_whitelisted_post_types() { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore -- Legacy.
2103 $allowed_types = array( 'post', 'page', 'revision' );
2104
2105 /**
2106 * Filter the post types Jetpack has access to, and can synchronize with WordPress.com.
2107 *
2108 * @module json-api
2109 *
2110 * @since 2.2.3
2111 *
2112 * @param array $allowed_types Array of whitelisted post types. Default to `array( 'post', 'page', 'revision' )`.
2113 */
2114 $allowed_types = apply_filters( 'rest_api_allowed_post_types', $allowed_types );
2115
2116 return array_unique( $allowed_types );
2117 }
2118
2119 /**
2120 * Mobile apps are allowed free video uploads, but limited to 5 minutes in length.
2121 *
2122 * @param array $media_item the media item to evaluate.
2123 *
2124 * @return bool true if the media item is a video that was uploaded via the mobile
2125 * app that is longer than 5 minutes.
2126 */
2127 public function media_item_is_free_video_mobile_upload_and_too_long( $media_item ) {
2128 if ( ! $media_item ) {
2129 return false;
2130 }
2131
2132 // Verify file is a video.
2133 $is_video = preg_match( '@^video/@', $media_item['type'] );
2134 if ( ! $is_video ) {
2135 return false;
2136 }
2137
2138 // Check if the request is from a mobile app, where we allow free video uploads at limited length.
2139 if ( ! in_array( $this->api->token_details['client_id'], VIDEOPRESS_ALLOWED_REST_API_CLIENT_IDS, true ) ) {
2140 return false;
2141 }
2142
2143 // We're only worried about free sites.
2144 require_once WP_CONTENT_DIR . '/admin-plugins/wpcom-billing.php';
2145 $current_plan = WPCOM_Store_API::get_current_plan( get_current_blog_id() );
2146 if ( ! $current_plan['is_free'] ) {
2147 return false;
2148 }
2149
2150 // Check if video is longer than 5 minutes.
2151 $video_meta = wp_read_video_metadata( $media_item['tmp_name'] );
2152 if (
2153 false !== $video_meta &&
2154 isset( $video_meta['length'] ) &&
2155 5 * MINUTE_IN_SECONDS < $video_meta['length']
2156 ) {
2157 videopress_log(
2158 'videopress_app_upload_length_block',
2159 'Mobile app upload on free site blocked because length was longer than 5 minutes.',
2160 null,
2161 null,
2162 null,
2163 null,
2164 array(
2165 'blog_id' => get_current_blog_id(),
2166 'user_id' => get_current_user_id(),
2167 )
2168 );
2169 return true;
2170 }
2171
2172 return false;
2173 }
2174
2175 /**
2176 * Handle a v1.1 media creation.
2177 *
2178 * Only one of $media_files and $media_urls should be non-empty.
2179 *
2180 * @param array $media_files File upload data.
2181 * @param array $media_urls URLs to fetch.
2182 * @param array $media_attrs Attributes corresponding to each entry in `$media_files`/`$media_urls`.
2183 * @param int|false $force_parent_id Force the parent ID, overriding `$media_attrs[]['parent_id']`.
2184 * @return array Two items:
2185 * - media_ids: IDs created, by index in `$media_files`/`$media_urls`.
2186 * - errors: Errors encountered, by index in `$media_files`/`$media_urls`.
2187 */
2188 public function handle_media_creation_v1_1( $media_files, $media_urls, $media_attrs = array(), $force_parent_id = false ) {
2189
2190 add_filter( 'upload_mimes', array( $this, 'allow_video_uploads' ) );
2191
2192 $media_ids = array();
2193 $errors = array();
2194 $user_can_upload_files = current_user_can( 'upload_files' ) || $this->api->is_authorized_with_upload_token();
2195 $media_attrs = array_values( $media_attrs ); // reset the keys.
2196 $i = 0;
2197
2198 if ( ! empty( $media_files ) ) {
2199 $this->api->trap_wp_die( 'upload_error' );
2200 foreach ( $media_files as $media_item ) {
2201 $_FILES['.api.media.item.'] = $media_item;
2202
2203 if ( ! $user_can_upload_files ) {
2204 $media_id = new WP_Error( 'unauthorized', 'User cannot upload media.', 403 );
2205 } else {
2206 if ( $this->media_item_is_free_video_mobile_upload_and_too_long( $media_item ) ) {
2207 $media_id = new WP_Error( 'upload_video_length', 'Video uploads longer than 5 minutes require a paid plan.', 400 );
2208 } else {
2209 if ( $force_parent_id ) {
2210 $parent_id = absint( $force_parent_id );
2211 } elseif ( ! empty( $media_attrs[ $i ] ) && ! empty( $media_attrs[ $i ]['parent_id'] ) ) {
2212 $parent_id = absint( $media_attrs[ $i ]['parent_id'] );
2213 } else {
2214 $parent_id = 0;
2215 }
2216 $media_id = media_handle_upload( '.api.media.item.', $parent_id );
2217 }
2218 }
2219 if ( is_wp_error( $media_id ) ) {
2220 $errors[ $i ]['file'] = $media_item['name'];
2221 $errors[ $i ]['error'] = $media_id->get_error_code();
2222 $errors[ $i ]['message'] = $media_id->get_error_message();
2223 } else {
2224 $media_ids[ $i ] = $media_id;
2225 }
2226
2227 $i++;
2228 }
2229 $this->api->trap_wp_die( null );
2230 unset( $_FILES['.api.media.item.'] );
2231 }
2232
2233 if ( ! empty( $media_urls ) ) {
2234 foreach ( $media_urls as $url ) {
2235 if ( ! $user_can_upload_files ) {
2236 $media_id = new WP_Error( 'unauthorized', 'User cannot upload media.', 403 );
2237 } else {
2238 if ( $force_parent_id ) {
2239 $parent_id = absint( $force_parent_id );
2240 } elseif ( ! empty( $media_attrs[ $i ] ) && ! empty( $media_attrs[ $i ]['parent_id'] ) ) {
2241 $parent_id = absint( $media_attrs[ $i ]['parent_id'] );
2242 } else {
2243 $parent_id = 0;
2244 }
2245 $media_id = $this->handle_media_sideload( $url, $parent_id );
2246 }
2247 if ( is_wp_error( $media_id ) ) {
2248 $errors[ $i ] = array(
2249 'file' => $url,
2250 'error' => $media_id->get_error_code(),
2251 'message' => $media_id->get_error_message(),
2252 );
2253 } elseif ( ! empty( $media_id ) ) {
2254 $media_ids[ $i ] = $media_id;
2255 }
2256
2257 $i++;
2258 }
2259 }
2260
2261 if ( ! empty( $media_attrs ) ) {
2262 foreach ( $media_ids as $index => $media_id ) {
2263 if ( empty( $media_attrs[ $index ] ) ) {
2264 continue;
2265 }
2266
2267 $attrs = $media_attrs[ $index ];
2268 $insert = array();
2269
2270 // Attributes: Title, Caption, Description.
2271
2272 if ( isset( $attrs['title'] ) ) {
2273 $insert['post_title'] = $attrs['title'];
2274 }
2275
2276 if ( isset( $attrs['caption'] ) ) {
2277 $insert['post_excerpt'] = $attrs['caption'];
2278 }
2279
2280 if ( isset( $attrs['description'] ) ) {
2281 $insert['post_content'] = $attrs['description'];
2282 }
2283
2284 if ( ! empty( $insert ) ) {
2285 $insert['ID'] = $media_id;
2286 wp_update_post( (object) $insert );
2287 }
2288
2289 // Attributes: Alt.
2290
2291 if ( isset( $attrs['alt'] ) ) {
2292 $alt = wp_strip_all_tags( $attrs['alt'], true );
2293 update_post_meta( $media_id, '_wp_attachment_image_alt', $alt );
2294 }
2295
2296 // Attributes: Artist, Album.
2297
2298 $id3_meta = array();
2299
2300 foreach ( array( 'artist', 'album' ) as $key ) {
2301 if ( isset( $attrs[ $key ] ) ) {
2302 $id3_meta[ $key ] = wp_strip_all_tags( $attrs[ $key ], true );
2303 }
2304 }
2305
2306 if ( ! empty( $id3_meta ) ) {
2307 // Before updating metadata, ensure that the item is audio.
2308 $item = $this->get_media_item_v1_1( $media_id );
2309 if ( 0 === strpos( $item->mime_type, 'audio/' ) ) {
2310 wp_update_attachment_metadata( $media_id, $id3_meta );
2311 }
2312 }
2313
2314 // Attributes: Meta
2315 if ( isset( $attrs['meta'] ) && isset( $attrs['meta']['vertical_id'] ) ) {
2316 update_post_meta( $media_id, 'vertical_id', $attrs['meta']['vertical_id'] );
2317 }
2318 }
2319 }
2320
2321 return array(
2322 'media_ids' => $media_ids,
2323 'errors' => $errors,
2324 );
2325
2326 }
2327
2328 /**
2329 * Handle a media sideload.
2330 *
2331 * @param string $url URL.
2332 * @param int $parent_post_id Parent post ID.
2333 * @param string $type Type.
2334 * @return int|WP_Error|false Media post ID, or error, or false if nothing was sideloaded.
2335 */
2336 public function handle_media_sideload( $url, $parent_post_id = 0, $type = 'any' ) {
2337 if ( ! function_exists( 'download_url' ) || ! function_exists( 'media_handle_sideload' ) ) {
2338 return false;
2339 }
2340
2341 // if we didn't get a URL, let's bail.
2342 $parsed = wp_parse_url( $url );
2343 if ( empty( $parsed ) ) {
2344 return false;
2345 }
2346
2347 $tmp = download_url( $url );
2348 if ( is_wp_error( $tmp ) ) {
2349 return $tmp;
2350 }
2351
2352 // First check to see if we get a mime-type match by file, otherwise, check to
2353 // see if WordPress supports this file as an image. If neither, then it is not supported.
2354 if ( ! $this->is_file_supported_for_sideloading( $tmp ) || 'image' === $type && ! file_is_displayable_image( $tmp ) ) {
2355 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2356 return new WP_Error( 'invalid_input', 'Invalid file type.', 403 );
2357 }
2358
2359 // emulate a $_FILES entry.
2360 $file_array = array(
2361 'name' => basename( wp_parse_url( $url, PHP_URL_PATH ) ),
2362 'tmp_name' => $tmp,
2363 );
2364
2365 $id = media_handle_sideload( $file_array, $parent_post_id );
2366 if ( file_exists( $tmp ) ) {
2367 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2368 }
2369
2370 if ( is_wp_error( $id ) ) {
2371 return $id;
2372 }
2373
2374 if ( ! $id || ! is_int( $id ) ) {
2375 return false;
2376 }
2377
2378 return $id;
2379 }
2380
2381 /**
2382 * Checks that the mime type of the specified file is among those in a filterable list of mime types.
2383 *
2384 * @param string $file Path to file to get its mime type.
2385 *
2386 * @return bool
2387 */
2388 protected function is_file_supported_for_sideloading( $file ) {
2389 return jetpack_is_file_supported_for_sideloading( $file );
2390 }
2391
2392 /**
2393 * Filter for `upload_mimes`.
2394 *
2395 * @param array $mimes Allowed mime types.
2396 * @return array Allowed mime types.
2397 */
2398 public function allow_video_uploads( $mimes ) {
2399 // if we are on Jetpack, bail - Videos are already allowed.
2400 if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
2401 return $mimes;
2402 }
2403
2404 // extra check that this filter is only ever applied during REST API requests.
2405 if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
2406 return $mimes;
2407 }
2408
2409 // bail early if they already have the upgrade..
2410 if ( wpcom_site_has_videopress() ) {
2411 return $mimes;
2412 }
2413
2414 // lets whitelist to only specific clients right now.
2415 $clients_allowed_video_uploads = array();
2416 /**
2417 * Filter the list of whitelisted video clients.
2418 *
2419 * @module json-api
2420 *
2421 * @since 3.2.0
2422 *
2423 * @param array $clients_allowed_video_uploads Array of whitelisted Video clients.
2424 */
2425 $clients_allowed_video_uploads = apply_filters( 'rest_api_clients_allowed_video_uploads', $clients_allowed_video_uploads );
2426 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.
2427 return $mimes;
2428 }
2429
2430 $mime_list = wp_get_mime_types();
2431
2432 $video_exts = explode( ' ', get_site_option( 'video_upload_filetypes', false, false ) );
2433 /**
2434 * Filter the video filetypes allowed on the site.
2435 *
2436 * @module json-api
2437 *
2438 * @since 3.2.0
2439 *
2440 * @param array $video_exts Array of video filetypes allowed on the site.
2441 */
2442 $video_exts = apply_filters( 'video_upload_filetypes', $video_exts );
2443 $video_mimes = array();
2444
2445 if ( ! empty( $video_exts ) ) {
2446 foreach ( $video_exts as $ext ) {
2447 foreach ( $mime_list as $ext_pattern => $mime ) {
2448 if ( '' !== $ext && strpos( $ext_pattern, $ext ) !== false ) {
2449 $video_mimes[ $ext_pattern ] = $mime;
2450 }
2451 }
2452 }
2453
2454 $mimes = array_merge( $mimes, $video_mimes );
2455 }
2456
2457 return $mimes;
2458 }
2459
2460 /**
2461 * Is the current site multi-user?
2462 *
2463 * @return bool
2464 */
2465 public function is_current_site_multi_user() {
2466 $users = wp_cache_get( 'site_user_count', 'WPCOM_JSON_API_Endpoint' );
2467 if ( false === $users ) {
2468 $user_query = new WP_User_Query(
2469 array(
2470 'blog_id' => get_current_blog_id(),
2471 'fields' => 'ID',
2472 )
2473 );
2474 $users = (int) $user_query->get_total();
2475 wp_cache_set( 'site_user_count', $users, 'WPCOM_JSON_API_Endpoint', DAY_IN_SECONDS );
2476 }
2477 return $users > 1;
2478 }
2479
2480 /**
2481 * Whether cross-origin requests are allowed.
2482 *
2483 * @return bool
2484 */
2485 public function allows_cross_origin_requests() {
2486 return 'GET' === $this->method || $this->allow_cross_origin_request;
2487 }
2488
2489 /**
2490 * Whether unauthorized requests are allowed.
2491 *
2492 * @param string $origin Origin.
2493 * @param string[] $complete_access_origins Access origins.
2494 * @return bool
2495 */
2496 public function allows_unauthorized_requests( $origin, $complete_access_origins ) {
2497 return 'GET' === $this->method || ( $this->allow_unauthorized_request && in_array( $origin, $complete_access_origins, true ) );
2498 }
2499
2500 /**
2501 * Whether this endpoint accepts site based authentication for the current request.
2502 *
2503 * @since 9.1.0
2504 *
2505 * @return bool true, if Jetpack blog token is used and `allow_jetpack_site_auth` is true,
2506 * false otherwise.
2507 */
2508 public function accepts_site_based_authentication() {
2509 return $this->allow_jetpack_site_auth &&
2510 $this->api->is_jetpack_authorized_for_site();
2511 }
2512
2513 /**
2514 * Get platform.
2515 *
2516 * @return WPORG_Platform
2517 */
2518 public function get_platform() {
2519 return wpcom_get_sal_platform( $this->api->token_details );
2520 }
2521
2522 /**
2523 * Allows the endpoint to perform logic to allow it to decide whether-or-not it should force a
2524 * response from the WPCOM API, or potentially go to the Jetpack blog.
2525 *
2526 * Override this method if you want to do something different.
2527 *
2528 * @param int $blog_id Blog ID.
2529 * @return bool
2530 */
2531 public function force_wpcom_request( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
2532 return false;
2533 }
2534
2535 /**
2536 * Get an array of all valid AMP origins for a blog's siteurl.
2537 *
2538 * @param string $siteurl Origin url of the API request.
2539 * @return array
2540 */
2541 public function get_amp_cache_origins( $siteurl ) {
2542 $host = wp_parse_url( $siteurl, PHP_URL_HOST );
2543
2544 /*
2545 * From AMP docs:
2546 * "When possible, the Google AMP Cache will create a subdomain for each AMP document's domain by first converting it
2547 * from IDN (punycode) to UTF-8. The caches replaces every - (dash) with -- (2 dashes) and replace every . (dot) with
2548 * - (dash). For example, pub.com will map to pub-com.cdn.ampproject.org."
2549 */
2550 if ( function_exists( 'idn_to_utf8' ) ) {
2551 // The third parameter is set explicitly to prevent issues with newer PHP versions compiled with an old ICU version.
2552 // phpcs:ignore PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003Deprecated, PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003DeprecatedRemoved
2553 $host = idn_to_utf8( $host, IDNA_DEFAULT, defined( 'INTL_IDNA_VARIANT_UTS46' ) ? INTL_IDNA_VARIANT_UTS46 : INTL_IDNA_VARIANT_2003 );
2554 }
2555 $subdomain = str_replace( array( '-', '.' ), array( '--', '-' ), $host );
2556 return array(
2557 $siteurl,
2558 // Google AMP Cache (legacy).
2559 'https://cdn.ampproject.org',
2560 // Google AMP Cache subdomain.
2561 sprintf( 'https://%s.cdn.ampproject.org', $subdomain ),
2562 // Cloudflare AMP Cache.
2563 sprintf( 'https://%s.amp.cloudflare.com', $subdomain ),
2564 // Bing AMP Cache.
2565 sprintf( 'https://%s.bing-amp.com', $subdomain ),
2566 );
2567 }
2568
2569 /**
2570 * Return endpoint response
2571 *
2572 * @param string $path ... determined by ->$path.
2573 *
2574 * @return array|WP_Error
2575 * falsy: HTTP 500, no response body
2576 * WP_Error( $error_code, $error_message, $http_status_code ): HTTP $status_code, json_encode( array( 'error' => $error_code, 'message' => $error_message ) ) response body
2577 * $data: HTTP 200, json_encode( $data ) response body
2578 */
2579 abstract public function callback( $path = '' );
2580
2581 }
2582
2583 require_once __DIR__ . '/json-endpoints.php';
2584