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

2,581 lines 81.0 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 $input = trim( (string) $this->api->post_body );
415 $content_type = (string) $this->api->content_type;
416 if ( $content_type ) {
417 list ( $content_type ) = explode( ';', $content_type );
418 }
419 $content_type = trim( $content_type );
420 switch ( $content_type ) {
421 case 'application/json':
422 case 'application/x-javascript':
423 case 'text/javascript':
424 case 'text/x-javascript':
425 case 'text/x-json':
426 case 'text/json':
427 $return = json_decode( $input, true );
428
429 if ( function_exists( 'json_last_error' ) ) {
430 if ( JSON_ERROR_NONE !== json_last_error() ) { // phpcs:ignore PHPCompatibility
431 return null;
432 }
433 } elseif ( $return === null && wp_json_encode( null ) !== $input ) {
434 return null;
435 }
436
437 break;
438 case 'multipart/form-data':
439 // phpcs:ignore WordPress.Security.NonceVerification.Missing
440 $return = array_merge( stripslashes_deep( $_POST ), $_FILES );
441 break;
442 case 'application/x-www-form-urlencoded':
443 // attempt JSON first, since probably a curl command.
444 $return = json_decode( $input, true );
445
446 if ( $return === null ) {
447 wp_parse_str( $input, $return );
448 }
449
450 break;
451 default:
452 wp_parse_str( $input, $return );
453 break;
454 }
455
456 if ( isset( $this->api->query['force'] )
457 && 'secure' === $this->api->query['force']
458 && isset( $return['secure_key'] ) ) {
459 $this->api->post_body = $this->get_secure_body( $return['secure_key'] );
460 $this->api->query['force'] = false;
461 return $this->input( $return_default_values, $cast_and_filter );
462 }
463
464 if ( $cast_and_filter ) {
465 $return = $this->cast_and_filter( $return, $this->request_format, $return_default_values );
466 }
467 return $return;
468 }
469
470 /**
471 * Fetch a body via secure request.
472 *
473 * @param string $secure_key Key for the request.
474 * @return mixed|null API response, or null if the request failed.
475 */
476 protected function get_secure_body( $secure_key ) {
477 $response = Client::wpcom_json_api_request_as_blog(
478 sprintf( '/sites/%d/secure-request', Jetpack_Options::get_option( 'id' ) ),
479 '1.1',
480 array( 'method' => 'POST' ),
481 array( 'secure_key' => $secure_key )
482 );
483 if ( 200 !== $response['response']['code'] ) {
484 return null;
485 }
486 return json_decode( $response['body'], true );
487 }
488
489 /**
490 * Cast and filter data.
491 *
492 * @param mixed $data Data to cast and filter.
493 * @param array $documentation Documentation for keys in `$data` to keep and cast.
494 * @param bool $return_default_values Set default values from `$documentation` to process.
495 * @param bool $for_output See `$this->cast_and_filter_item()`.
496 * @return mixed Filtered data.
497 */
498 public function cast_and_filter( $data, $documentation, $return_default_values = false, $for_output = false ) {
499 $return_as_object = false;
500 if ( is_object( $data ) ) {
501 // @todo this should probably be a deep copy if $data can ever have nested objects
502 $data = (array) $data;
503 $return_as_object = true;
504 } elseif ( ! is_array( $data ) ) {
505 return $data;
506 }
507
508 $boolean_arg = array( 'false', 'true' );
509 $naeloob_arg = array( 'true', 'false' );
510
511 $return = array();
512
513 foreach ( $documentation as $key => $description ) {
514 if ( is_array( $description ) ) {
515 // String or boolean array keys only.
516 $whitelist = array_keys( $description );
517
518 if ( $whitelist === $boolean_arg || $whitelist === $naeloob_arg ) {
519 // Truthiness.
520 if ( isset( $data[ $key ] ) ) {
521 $return[ $key ] = (bool) WPCOM_JSON_API::is_truthy( $data[ $key ] );
522 } elseif ( $return_default_values ) {
523 $return[ $key ] = $whitelist === $naeloob_arg; // Default to true for naeloob_arg and false for boolean_arg.
524 }
525 } elseif ( isset( $data[ $key ] ) && isset( $description[ $data[ $key ] ] ) ) {
526 // String Key.
527 $return[ $key ] = (string) $data[ $key ];
528 } elseif ( $return_default_values ) {
529 // Default value.
530 $return[ $key ] = (string) current( $whitelist );
531 }
532
533 continue;
534 }
535
536 $types = $this->parse_types( $description );
537 $type = array_shift( $types );
538
539 // Explicit default - string and int only for now. Always set these reguardless of $return_default_values.
540 if ( isset( $type['default'] ) ) {
541 if ( ! isset( $data[ $key ] ) ) {
542 $data[ $key ] = $type['default'];
543 }
544 }
545
546 if ( ! isset( $data[ $key ] ) ) {
547 continue;
548 }
549
550 $this->cast_and_filter_item( $return, $type, $key, $data[ $key ], $types, $for_output );
551 }
552
553 if ( $return_as_object ) {
554 return (object) $return;
555 }
556
557 return $return;
558 }
559
560 /**
561 * Casts $value according to $type.
562 * Handles fallbacks for certain values of $type when $value is not that $type
563 * Currently, only handles fallback between string <-> array (two way), from string -> false (one way), and from object -> false (one way),
564 * and string -> object (one way)
565 *
566 * Handles "child types" - array:URL, object:category
567 * array:URL means an array of URLs
568 * object:category means a hash of categories
569 *
570 * Handles object typing - object>post means an object of type post
571 *
572 * @param array $return Array to assign the value into.
573 * @param string|array $type Type to cast.
574 * @param string|int $key Key in `$return` to assign the value to.
575 * @param mixed $value Value to cast.
576 * @param array $types Fallback types.
577 * @param bool $for_output Appears to affect formatting of 'date' types.
578 */
579 public function cast_and_filter_item( &$return, $type, $key, $value, $types = array(), $for_output = false ) {
580 if ( is_string( $type ) ) {
581 $type = compact( 'type' );
582 }
583
584 switch ( $type['type'] ) {
585 case 'false':
586 $return[ $key ] = false;
587 break;
588 case 'url':
589 if ( is_object( $value ) && isset( $value->url ) && false !== strpos( $value->url, 'https://videos.files.wordpress.com/' ) ) {
590 $value = $value->url;
591 }
592 // Check for string since esc_url_raw() expects one.
593 if ( ! is_string( $value ) ) {
594 break;
595 }
596 $return[ $key ] = (string) esc_url_raw( $value );
597 break;
598 case 'string':
599 // Fallback string -> array, or for string -> object.
600 if ( is_array( $value ) || is_object( $value ) ) {
601 if ( ! empty( $types[0] ) ) {
602 $next_type = array_shift( $types );
603 return $this->cast_and_filter_item( $return, $next_type, $key, $value, $types, $for_output );
604 }
605 }
606
607 // Fallback string -> false.
608 if ( ! is_string( $value ) ) {
609 if ( ! empty( $types[0] ) && 'false' === $types[0]['type'] ) {
610 $next_type = array_shift( $types );
611 return $this->cast_and_filter_item( $return, $next_type, $key, $value, $types, $for_output );
612 }
613 }
614 $return[ $key ] = (string) $value;
615 break;
616 case 'html':
617 $return[ $key ] = (string) $value;
618 break;
619 case 'safehtml':
620 $return[ $key ] = wp_kses( (string) $value, wp_kses_allowed_html() );
621 break;
622 case 'zip':
623 case 'media':
624 if ( is_array( $value ) ) {
625 if ( isset( $value['name'] ) && is_array( $value['name'] ) ) {
626 // It's a $_FILES array
627 // Reformat into array of $_FILES items.
628 $files = array();
629
630 foreach ( $value['name'] as $k => $v ) {
631 $files[ $k ] = array();
632 foreach ( array_keys( $value ) as $file_key ) {
633 $files[ $k ][ $file_key ] = $value[ $file_key ][ $k ];
634 }
635 }
636
637 $return[ $key ] = $files;
638 break;
639 }
640 }
641 // no break - treat as 'array'.
642 case 'array':
643 // Fallback array -> string.
644 if ( is_string( $value ) ) {
645 if ( ! empty( $types[0] ) ) {
646 $next_type = array_shift( $types );
647 return $this->cast_and_filter_item( $return, $next_type, $key, $value, $types, $for_output );
648 }
649 }
650
651 if ( isset( $type['children'] ) ) {
652 $children = array();
653 foreach ( (array) $value as $k => $child ) {
654 $this->cast_and_filter_item( $children, $type['children'], $k, $child, array(), $for_output );
655 }
656 $return[ $key ] = (array) $children;
657 break;
658 }
659
660 $return[ $key ] = (array) $value;
661 break;
662 case 'iso 8601 datetime':
663 case 'datetime':
664 // (string)s
665 $dates = $this->parse_date( (string) $value );
666 if ( $for_output ) {
667 $return[ $key ] = $this->format_date( $dates[1], $dates[0] );
668 } else {
669 list( $return[ $key ], $return[ "{$key}_gmt" ] ) = $dates;
670 }
671 break;
672 case 'float':
673 $return[ $key ] = (float) $value;
674 break;
675 case 'int':
676 case 'integer':
677 $return[ $key ] = (int) $value;
678 break;
679 case 'bool':
680 case 'boolean':
681 $return[ $key ] = (bool) WPCOM_JSON_API::is_truthy( $value );
682 break;
683 case 'object':
684 // Fallback object -> false.
685 if ( is_scalar( $value ) || $value === null ) {
686 if ( ! empty( $types[0] ) && 'false' === $types[0]['type'] ) {
687 return $this->cast_and_filter_item( $return, 'false', $key, $value, $types, $for_output );
688 }
689 }
690
691 if ( isset( $type['children'] ) ) {
692 $children = array();
693 foreach ( (array) $value as $k => $child ) {
694 $this->cast_and_filter_item( $children, $type['children'], $k, $child, array(), $for_output );
695 }
696 $return[ $key ] = (object) $children;
697 break;
698 }
699
700 if ( isset( $type['subtype'] ) ) {
701 return $this->cast_and_filter_item( $return, $type['subtype'], $key, $value, $types, $for_output );
702 }
703
704 $return[ $key ] = (object) $value;
705 break;
706 case 'post':
707 $return[ $key ] = (object) $this->cast_and_filter( $value, $this->post_object_format, false, $for_output );
708 break;
709 case 'comment':
710 $return[ $key ] = (object) $this->cast_and_filter( $value, $this->comment_object_format, false, $for_output );
711 break;
712 case 'tag':
713 case 'category':
714 $docs = array(
715 'ID' => '(int)',
716 'name' => '(string)',
717 'slug' => '(string)',
718 'description' => '(HTML)',
719 'post_count' => '(int)',
720 'feed_url' => '(string)',
721 'meta' => '(object)',
722 );
723 if ( 'category' === $type['type'] ) {
724 $docs['parent'] = '(int)';
725 }
726 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
727 break;
728 case 'post_reference':
729 case 'comment_reference':
730 $docs = array(
731 'ID' => '(int)',
732 'type' => '(string)',
733 'title' => '(string)',
734 'link' => '(URL)',
735 );
736 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
737 break;
738 case 'geo':
739 $docs = array(
740 'latitude' => '(float)',
741 'longitude' => '(float)',
742 'address' => '(string)',
743 );
744 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
745 break;
746 case 'author':
747 $docs = array(
748 'ID' => '(int)',
749 'user_login' => '(string)',
750 'login' => '(string)',
751 'email' => '(string|false)',
752 'name' => '(string)',
753 'first_name' => '(string)',
754 'last_name' => '(string)',
755 'nice_name' => '(string)',
756 'URL' => '(URL)',
757 'avatar_URL' => '(URL)',
758 'profile_URL' => '(URL)',
759 'is_super_admin' => '(bool)',
760 'roles' => '(array:string)',
761 'ip_address' => '(string|false)',
762 );
763 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
764 break;
765 case 'role':
766 $docs = array(
767 'name' => '(string)',
768 'display_name' => '(string)',
769 'capabilities' => '(object:boolean)',
770 );
771 $return[ $key ] = (object) $this->cast_and_filter( $value, $docs, false, $for_output );
772 break;
773 case 'attachment':
774 $docs = array(
775 'ID' => '(int)',
776 'URL' => '(URL)',
777 'guid' => '(string)',
778 'mime_type' => '(string)',
779 'width' => '(int)',
780 'height' => '(int)',
781 'duration' => '(int)',
782 );
783 $return[ $key ] = (object) $this->cast_and_filter(
784 $value,
785 /**
786 * Filter the documentation returned for a post attachment.
787 *
788 * @module json-api
789 *
790 * @since 1.9.0
791 *
792 * @param array $docs Array of documentation about a post attachment.
793 */
794 apply_filters( 'wpcom_json_api_attachment_cast_and_filter', $docs ),
795 false,
796 $for_output
797 );
798 break;
799 case 'metadata':
800 $docs = array(
801 'id' => '(int)',
802 'key' => '(string)',
803 'value' => '(string|false|float|int|array|object)',
804 'previous_value' => '(string)',
805 'operation' => '(string)',
806 );
807 $return[ $key ] = (object) $this->cast_and_filter(
808 $value,
809 /** This filter is documented in class.json-api-endpoints.php */
810 apply_filters( 'wpcom_json_api_attachment_cast_and_filter', $docs ),
811 false,
812 $for_output
813 );
814 break;
815 case 'plugin':
816 $docs = array(
817 'id' => '(safehtml) The plugin\'s ID',
818 'slug' => '(safehtml) The plugin\'s Slug',
819 'active' => '(boolean) The plugin status.',
820 'update' => '(object) The plugin update info.',
821 'name' => '(safehtml) The name of the plugin.',
822 'plugin_url' => '(url) Link to the plugin\'s web site.',
823 'version' => '(safehtml) The plugin version number.',
824 'description' => '(safehtml) Description of what the plugin does and/or notes from the author',
825 'author' => '(safehtml) The plugin author\'s name',
826 'author_url' => '(url) The plugin author web site address',
827 'network' => '(boolean) Whether the plugin can only be activated network wide.',
828 'autoupdate' => '(boolean) Whether the plugin is auto updated',
829 'log' => '(array:safehtml) An array of update log strings.',
830 'action_links' => '(array) An array of action links that the plugin uses.',
831 );
832 $return[ $key ] = (object) $this->cast_and_filter(
833 $value,
834 /**
835 * Filter the documentation returned for a plugin.
836 *
837 * @module json-api
838 *
839 * @since 3.1.0
840 *
841 * @param array $docs Array of documentation about a plugin.
842 */
843 apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
844 false,
845 $for_output
846 );
847 break;
848 case 'plugin_v1_2':
849 $docs = class_exists( 'Jetpack_JSON_API_Get_Plugins_v1_2_Endpoint' )
850 ? Jetpack_JSON_API_Get_Plugins_v1_2_Endpoint::$_response_format
851 : Jetpack_JSON_API_Plugins_Endpoint::$_response_format_v1_2;
852 $return[ $key ] = (object) $this->cast_and_filter(
853 $value,
854 /**
855 * Filter the documentation returned for a plugin.
856 *
857 * @module json-api
858 *
859 * @since 3.1.0
860 *
861 * @param array $docs Array of documentation about a plugin.
862 */
863 apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
864 false,
865 $for_output
866 );
867 break;
868 case 'file_mod_capabilities':
869 $docs = array(
870 'reasons_modify_files_unavailable' => '(array) The reasons why files can\'t be modified',
871 'reasons_autoupdate_unavailable' => '(array) The reasons why autoupdates aren\'t allowed',
872 'modify_files' => '(boolean) true if files can be modified',
873 'autoupdate_files' => '(boolean) true if autoupdates are allowed',
874 );
875 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
876 break;
877 case 'jetpackmodule':
878 $docs = array(
879 'id' => '(string) The module\'s ID',
880 'active' => '(boolean) The module\'s status.',
881 'name' => '(string) The module\'s name.',
882 'description' => '(safehtml) The module\'s description.',
883 'sort' => '(int) The module\'s display order.',
884 'introduced' => '(string) The Jetpack version when the module was introduced.',
885 'changed' => '(string) The Jetpack version when the module was changed.',
886 'free' => '(boolean) The module\'s Free or Paid status.',
887 'module_tags' => '(array) The module\'s tags.',
888 'override' => '(string) The module\'s override. Empty if no override, otherwise \'active\' or \'inactive\'',
889 );
890 $return[ $key ] = (object) $this->cast_and_filter(
891 $value,
892 /** This filter is documented in class.json-api-endpoints.php */
893 apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
894 false,
895 $for_output
896 );
897 break;
898 case 'sharing_button':
899 $docs = array(
900 'ID' => '(string)',
901 'name' => '(string)',
902 'URL' => '(string)',
903 'icon' => '(string)',
904 'enabled' => '(bool)',
905 'visibility' => '(string)',
906 );
907 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
908 break;
909 case 'sharing_button_service':
910 $docs = array(
911 'ID' => '(string) The service identifier',
912 'name' => '(string) The service name',
913 'class_name' => '(string) Class name for custom style sharing button elements',
914 'genericon' => '(string) The Genericon unicode character for the custom style sharing button icon',
915 'preview_smart' => '(string) An HTML snippet of a rendered sharing button smart preview',
916 'preview_smart_js' => '(string) An HTML snippet of the page-wide initialization scripts used for rendering the sharing button smart preview',
917 );
918 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
919 break;
920 case 'site_keyring':
921 $docs = array(
922 'keyring_id' => '(int) Keyring ID',
923 'service' => '(string) The service name',
924 'external_user_id' => '(string) External user id for the service',
925 );
926 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
927 break;
928 case 'taxonomy':
929 $docs = array(
930 'name' => '(string) The taxonomy slug',
931 'label' => '(string) The taxonomy human-readable name',
932 'labels' => '(object) Mapping of labels for the taxonomy',
933 'description' => '(string) The taxonomy description',
934 'hierarchical' => '(bool) Whether the taxonomy is hierarchical',
935 'public' => '(bool) Whether the taxonomy is public',
936 'capabilities' => '(object) Mapping of current user capabilities for the taxonomy',
937 );
938 $return[ $key ] = (array) $this->cast_and_filter( $value, $docs, false, $for_output );
939 break;
940 case 'visibility':
941 // This is needed to fix a bug in WPAndroid where `public: "PUBLIC"` is sent in place of `public: 1`.
942 if ( 'public' === strtolower( $value ) ) {
943 $return[ $key ] = 1;
944 } elseif ( 'private' === strtolower( $value ) ) {
945 $return[ $key ] = -1;
946 } else {
947 $return[ $key ] = (int) $value;
948 }
949 break;
950 case 'dropdown_page':
951 $return[ $key ] = (array) $this->cast_and_filter( $value, $this->dropdown_page_object_format, false, $for_output );
952 break;
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 ( new Status() )->is_private_site() &&
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 ( ( new Status() )->is_private_site() && ! 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 } elseif ( $this->media_item_is_free_video_mobile_upload_and_too_long( $media_item ) ) {
2206 $media_id = new WP_Error( 'upload_video_length', 'Video uploads longer than 5 minutes require a paid plan.', 400 );
2207 } else {
2208 if ( $force_parent_id ) {
2209 $parent_id = absint( $force_parent_id );
2210 } elseif ( ! empty( $media_attrs[ $i ] ) && ! empty( $media_attrs[ $i ]['parent_id'] ) ) {
2211 $parent_id = absint( $media_attrs[ $i ]['parent_id'] );
2212 } else {
2213 $parent_id = 0;
2214 }
2215 $media_id = media_handle_upload( '.api.media.item.', $parent_id );
2216 }
2217 if ( is_wp_error( $media_id ) ) {
2218 $errors[ $i ]['file'] = $media_item['name'];
2219 $errors[ $i ]['error'] = $media_id->get_error_code();
2220 $errors[ $i ]['message'] = $media_id->get_error_message();
2221 } else {
2222 $media_ids[ $i ] = $media_id;
2223 }
2224
2225 ++$i;
2226 }
2227 $this->api->trap_wp_die( null );
2228 unset( $_FILES['.api.media.item.'] );
2229 }
2230
2231 if ( ! empty( $media_urls ) ) {
2232 foreach ( $media_urls as $url ) {
2233 if ( ! $user_can_upload_files ) {
2234 $media_id = new WP_Error( 'unauthorized', 'User cannot upload media.', 403 );
2235 } else {
2236 if ( $force_parent_id ) {
2237 $parent_id = absint( $force_parent_id );
2238 } elseif ( ! empty( $media_attrs[ $i ] ) && ! empty( $media_attrs[ $i ]['parent_id'] ) ) {
2239 $parent_id = absint( $media_attrs[ $i ]['parent_id'] );
2240 } else {
2241 $parent_id = 0;
2242 }
2243 $media_id = $this->handle_media_sideload( $url, $parent_id );
2244 }
2245 if ( is_wp_error( $media_id ) ) {
2246 $errors[ $i ] = array(
2247 'file' => $url,
2248 'error' => $media_id->get_error_code(),
2249 'message' => $media_id->get_error_message(),
2250 );
2251 } elseif ( ! empty( $media_id ) ) {
2252 $media_ids[ $i ] = $media_id;
2253 }
2254
2255 ++$i;
2256 }
2257 }
2258
2259 if ( ! empty( $media_attrs ) ) {
2260 foreach ( $media_ids as $index => $media_id ) {
2261 if ( empty( $media_attrs[ $index ] ) ) {
2262 continue;
2263 }
2264
2265 $attrs = $media_attrs[ $index ];
2266 $insert = array();
2267
2268 // Attributes: Title, Caption, Description.
2269
2270 if ( isset( $attrs['title'] ) ) {
2271 $insert['post_title'] = $attrs['title'];
2272 }
2273
2274 if ( isset( $attrs['caption'] ) ) {
2275 $insert['post_excerpt'] = $attrs['caption'];
2276 }
2277
2278 if ( isset( $attrs['description'] ) ) {
2279 $insert['post_content'] = $attrs['description'];
2280 }
2281
2282 if ( ! empty( $insert ) ) {
2283 $insert['ID'] = $media_id;
2284 wp_update_post( (object) $insert );
2285 }
2286
2287 // Attributes: Alt.
2288
2289 if ( isset( $attrs['alt'] ) ) {
2290 $alt = wp_strip_all_tags( $attrs['alt'], true );
2291 update_post_meta( $media_id, '_wp_attachment_image_alt', $alt );
2292 }
2293
2294 // Attributes: Artist, Album.
2295
2296 $id3_meta = array();
2297
2298 foreach ( array( 'artist', 'album' ) as $key ) {
2299 if ( isset( $attrs[ $key ] ) ) {
2300 $id3_meta[ $key ] = wp_strip_all_tags( $attrs[ $key ], true );
2301 }
2302 }
2303
2304 if ( ! empty( $id3_meta ) ) {
2305 // Before updating metadata, ensure that the item is audio.
2306 $item = $this->get_media_item_v1_1( $media_id );
2307 if ( 0 === strpos( $item->mime_type, 'audio/' ) ) {
2308 wp_update_attachment_metadata( $media_id, $id3_meta );
2309 }
2310 }
2311
2312 // Attributes: Meta
2313 if ( isset( $attrs['meta'] ) && isset( $attrs['meta']['vertical_id'] ) ) {
2314 update_post_meta( $media_id, 'vertical_id', $attrs['meta']['vertical_id'] );
2315 }
2316 }
2317 }
2318
2319 return array(
2320 'media_ids' => $media_ids,
2321 'errors' => $errors,
2322 );
2323 }
2324
2325 /**
2326 * Handle a media sideload.
2327 *
2328 * @param string $url URL.
2329 * @param int $parent_post_id Parent post ID.
2330 * @param string $type Type.
2331 * @return int|WP_Error|false Media post ID, or error, or false if nothing was sideloaded.
2332 */
2333 public function handle_media_sideload( $url, $parent_post_id = 0, $type = 'any' ) {
2334 if ( ! function_exists( 'download_url' ) || ! function_exists( 'media_handle_sideload' ) ) {
2335 return false;
2336 }
2337
2338 // if we didn't get a URL, let's bail.
2339 $parsed = wp_parse_url( $url );
2340 if ( empty( $parsed ) ) {
2341 return false;
2342 }
2343
2344 $tmp = download_url( $url );
2345 if ( is_wp_error( $tmp ) ) {
2346 return $tmp;
2347 }
2348
2349 // First check to see if we get a mime-type match by file, otherwise, check to
2350 // see if WordPress supports this file as an image. If neither, then it is not supported.
2351 if ( ! $this->is_file_supported_for_sideloading( $tmp ) || 'image' === $type && ! file_is_displayable_image( $tmp ) ) {
2352 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2353 return new WP_Error( 'invalid_input', 'Invalid file type.', 403 );
2354 }
2355
2356 // emulate a $_FILES entry.
2357 $file_array = array(
2358 'name' => basename( wp_parse_url( $url, PHP_URL_PATH ) ),
2359 'tmp_name' => $tmp,
2360 );
2361
2362 $id = media_handle_sideload( $file_array, $parent_post_id );
2363 if ( file_exists( $tmp ) ) {
2364 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2365 }
2366
2367 if ( is_wp_error( $id ) ) {
2368 return $id;
2369 }
2370
2371 if ( ! $id || ! is_int( $id ) ) {
2372 return false;
2373 }
2374
2375 return $id;
2376 }
2377
2378 /**
2379 * Checks that the mime type of the specified file is among those in a filterable list of mime types.
2380 *
2381 * @param string $file Path to file to get its mime type.
2382 *
2383 * @return bool
2384 */
2385 protected function is_file_supported_for_sideloading( $file ) {
2386 return jetpack_is_file_supported_for_sideloading( $file );
2387 }
2388
2389 /**
2390 * Filter for `upload_mimes`.
2391 *
2392 * @param array $mimes Allowed mime types.
2393 * @return array Allowed mime types.
2394 */
2395 public function allow_video_uploads( $mimes ) {
2396 // if we are on Jetpack, bail - Videos are already allowed.
2397 if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
2398 return $mimes;
2399 }
2400
2401 // extra check that this filter is only ever applied during REST API requests.
2402 if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
2403 return $mimes;
2404 }
2405
2406 // bail early if they already have the upgrade..
2407 if ( wpcom_site_has_videopress() ) {
2408 return $mimes;
2409 }
2410
2411 // lets whitelist to only specific clients right now.
2412 $clients_allowed_video_uploads = array();
2413 /**
2414 * Filter the list of whitelisted video clients.
2415 *
2416 * @module json-api
2417 *
2418 * @since 3.2.0
2419 *
2420 * @param array $clients_allowed_video_uploads Array of whitelisted Video clients.
2421 */
2422 $clients_allowed_video_uploads = apply_filters( 'rest_api_clients_allowed_video_uploads', $clients_allowed_video_uploads );
2423 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.
2424 return $mimes;
2425 }
2426
2427 $mime_list = wp_get_mime_types();
2428
2429 $video_exts = explode( ' ', get_site_option( 'video_upload_filetypes', false, false ) );
2430 /**
2431 * Filter the video filetypes allowed on the site.
2432 *
2433 * @module json-api
2434 *
2435 * @since 3.2.0
2436 *
2437 * @param array $video_exts Array of video filetypes allowed on the site.
2438 */
2439 $video_exts = apply_filters( 'video_upload_filetypes', $video_exts );
2440 $video_mimes = array();
2441
2442 if ( ! empty( $video_exts ) ) {
2443 foreach ( $video_exts as $ext ) {
2444 foreach ( $mime_list as $ext_pattern => $mime ) {
2445 if ( '' !== $ext && strpos( $ext_pattern, $ext ) !== false ) {
2446 $video_mimes[ $ext_pattern ] = $mime;
2447 }
2448 }
2449 }
2450
2451 $mimes = array_merge( $mimes, $video_mimes );
2452 }
2453
2454 return $mimes;
2455 }
2456
2457 /**
2458 * Is the current site multi-user?
2459 *
2460 * @return bool
2461 */
2462 public function is_current_site_multi_user() {
2463 $users = wp_cache_get( 'site_user_count', 'WPCOM_JSON_API_Endpoint' );
2464 if ( false === $users ) {
2465 $user_query = new WP_User_Query(
2466 array(
2467 'blog_id' => get_current_blog_id(),
2468 'fields' => 'ID',
2469 )
2470 );
2471 $users = (int) $user_query->get_total();
2472 wp_cache_set( 'site_user_count', $users, 'WPCOM_JSON_API_Endpoint', DAY_IN_SECONDS );
2473 }
2474 return $users > 1;
2475 }
2476
2477 /**
2478 * Whether cross-origin requests are allowed.
2479 *
2480 * @return bool
2481 */
2482 public function allows_cross_origin_requests() {
2483 return 'GET' === $this->method || $this->allow_cross_origin_request;
2484 }
2485
2486 /**
2487 * Whether unauthorized requests are allowed.
2488 *
2489 * @param string $origin Origin.
2490 * @param string[] $complete_access_origins Access origins.
2491 * @return bool
2492 */
2493 public function allows_unauthorized_requests( $origin, $complete_access_origins ) {
2494 return 'GET' === $this->method || ( $this->allow_unauthorized_request && in_array( $origin, $complete_access_origins, true ) );
2495 }
2496
2497 /**
2498 * Whether this endpoint accepts site based authentication for the current request.
2499 *
2500 * @since 9.1.0
2501 *
2502 * @return bool true, if Jetpack blog token is used and `allow_jetpack_site_auth` is true,
2503 * false otherwise.
2504 */
2505 public function accepts_site_based_authentication() {
2506 return $this->allow_jetpack_site_auth &&
2507 $this->api->is_jetpack_authorized_for_site();
2508 }
2509
2510 /**
2511 * Get platform.
2512 *
2513 * @return WPORG_Platform
2514 */
2515 public function get_platform() {
2516 return wpcom_get_sal_platform( $this->api->token_details );
2517 }
2518
2519 /**
2520 * Allows the endpoint to perform logic to allow it to decide whether-or-not it should force a
2521 * response from the WPCOM API, or potentially go to the Jetpack blog.
2522 *
2523 * Override this method if you want to do something different.
2524 *
2525 * @param int $blog_id Blog ID.
2526 * @return bool
2527 */
2528 public function force_wpcom_request( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
2529 return false;
2530 }
2531
2532 /**
2533 * Get an array of all valid AMP origins for a blog's siteurl.
2534 *
2535 * @param string $siteurl Origin url of the API request.
2536 * @return array
2537 */
2538 public function get_amp_cache_origins( $siteurl ) {
2539 $host = wp_parse_url( $siteurl, PHP_URL_HOST );
2540
2541 /*
2542 * From AMP docs:
2543 * "When possible, the Google AMP Cache will create a subdomain for each AMP document's domain by first converting it
2544 * from IDN (punycode) to UTF-8. The caches replaces every - (dash) with -- (2 dashes) and replace every . (dot) with
2545 * - (dash). For example, pub.com will map to pub-com.cdn.ampproject.org."
2546 */
2547 if ( function_exists( 'idn_to_utf8' ) ) {
2548 // The third parameter is set explicitly to prevent issues with newer PHP versions compiled with an old ICU version.
2549 // phpcs:ignore PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003Deprecated, PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003DeprecatedRemoved
2550 $host = idn_to_utf8( $host, IDNA_DEFAULT, defined( 'INTL_IDNA_VARIANT_UTS46' ) ? INTL_IDNA_VARIANT_UTS46 : INTL_IDNA_VARIANT_2003 );
2551 }
2552 $subdomain = str_replace( array( '-', '.' ), array( '--', '-' ), $host );
2553 return array(
2554 $siteurl,
2555 // Google AMP Cache (legacy).
2556 'https://cdn.ampproject.org',
2557 // Google AMP Cache subdomain.
2558 sprintf( 'https://%s.cdn.ampproject.org', $subdomain ),
2559 // Cloudflare AMP Cache.
2560 sprintf( 'https://%s.amp.cloudflare.com', $subdomain ),
2561 // Bing AMP Cache.
2562 sprintf( 'https://%s.bing-amp.com', $subdomain ),
2563 );
2564 }
2565
2566 /**
2567 * Return endpoint response
2568 *
2569 * @param string $path ... determined by ->$path.
2570 *
2571 * @return array|WP_Error
2572 * falsy: HTTP 500, no response body
2573 * WP_Error( $error_code, $error_message, $http_status_code ): HTTP $status_code, json_encode( array( 'error' => $error_code, 'message' => $error_message ) ) response body
2574 * $data: HTTP 200, json_encode( $data ) response body
2575 */
2576 abstract public function callback( $path = '' );
2577
2578 }
2579
2580 require_once __DIR__ . '/json-endpoints.php';
2581