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

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