PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 12.5
Jetpack – WP Security, Backup, Speed, & Growth v12.5
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 12.5, at class.json-api-endpoints.php

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