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

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

2,535 lines 79.7 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 if ( isset( $info->privacy_setting ) ) {
1654 $response['privacy_setting'] = (int) $info->privacy_setting;
1655 } elseif ( isset( $metadata['videopress']['privacy_setting'] ) ) {
1656 $response['privacy_setting'] = (int) $metadata['videopress']['privacy_setting'];
1657 }
1658
1659 // Thumbnails.
1660 if ( function_exists( 'video_format_done' ) && function_exists( 'video_image_url_by_guid' ) ) {
1661 $response['thumbnails'] = array(
1662 'fmt_hd' => '',
1663 'fmt_dvd' => '',
1664 'fmt_std' => '',
1665 );
1666 foreach ( $response['thumbnails'] as $size => $thumbnail_url ) {
1667 if ( video_format_done( $info, $size ) ) {
1668 $response['thumbnails'][ $size ] = video_image_url_by_guid( $info->guid, $size );
1669 } else {
1670 unset( $response['thumbnails'][ $size ] );
1671 }
1672 }
1673 }
1674
1675 // If we didn't get VideoPress information (for some reason) then let's
1676 // not try and include it in the response.
1677 if ( isset( $info->guid ) ) {
1678 $response['videopress_guid'] = $info->guid;
1679 $response['videopress_processing_done'] = true;
1680 if ( '0000-00-00 00:00:00' === $info->finish_date_gmt ) {
1681 $response['videopress_processing_done'] = false;
1682 }
1683 }
1684 }
1685 }
1686
1687 $response['thumbnails'] = (object) $response['thumbnails'];
1688
1689 $response['meta'] = (object) array(
1690 'links' => (object) array(
1691 'self' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_item->ID ),
1692 'help' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_item->ID, 'help' ),
1693 'site' => (string) $this->links->get_site_link( $this->api->get_blog_id_for_output() ),
1694 ),
1695 );
1696
1697 // add VideoPress link to the meta.
1698 if ( isset( $response['videopress_guid'] ) ) {
1699 if ( function_exists( 'video_get_info_by_blogpostid' ) ) {
1700 $response['meta']->links->videopress = (string) $this->links->get_link( '/videos/%s', $response['videopress_guid'], '' );
1701 }
1702 }
1703
1704 if ( $media_item->post_parent > 0 ) {
1705 $response['meta']->links->parent = (string) $this->links->get_post_link( $this->api->get_blog_id_for_output(), $media_item->post_parent );
1706 }
1707
1708 return (object) $response;
1709 }
1710
1711 /**
1712 * Get a formatted taxonomy.
1713 *
1714 * @param int $taxonomy_id Taxonomy ID.
1715 * @param string $taxonomy_type Name of taxonomy.
1716 * @param string $context Context, 'edit' or 'display'.
1717 * @return object|WP_Error
1718 */
1719 public function get_taxonomy( $taxonomy_id, $taxonomy_type, $context ) {
1720
1721 $taxonomy = get_term_by( 'slug', $taxonomy_id, $taxonomy_type );
1722 // keep updating this function.
1723 if ( ! $taxonomy || is_wp_error( $taxonomy ) ) {
1724 return new WP_Error( 'unknown_taxonomy', 'Unknown taxonomy', 404 );
1725 }
1726
1727 return $this->format_taxonomy( $taxonomy, $taxonomy_type, $context );
1728 }
1729
1730 /**
1731 * Format a taxonomy.
1732 *
1733 * @param WP_Term $taxonomy Taxonomy.
1734 * @param string $taxonomy_type Name of taxonomy.
1735 * @param string $context Context, 'edit' or 'display'.
1736 * @return object|WP_Error
1737 */
1738 public function format_taxonomy( $taxonomy, $taxonomy_type, $context ) {
1739 // Permissions.
1740 switch ( $context ) {
1741 case 'edit':
1742 $tax = get_taxonomy( $taxonomy_type );
1743 if ( ! current_user_can( $tax->cap->edit_terms ) ) {
1744 return new WP_Error( 'unauthorized', 'User cannot edit taxonomy', 403 );
1745 }
1746 break;
1747 case 'display':
1748 if ( -1 === (int) get_option( 'blog_public' ) && ! current_user_can( 'read' ) ) {
1749 return new WP_Error( 'unauthorized', 'User cannot view taxonomy', 403 );
1750 }
1751 break;
1752 default:
1753 return new WP_Error( 'invalid_context', 'Invalid API CONTEXT', 400 );
1754 }
1755
1756 $response = array();
1757 $response['ID'] = (int) $taxonomy->term_id;
1758 $response['name'] = (string) $taxonomy->name;
1759 $response['slug'] = (string) $taxonomy->slug;
1760 $response['description'] = (string) $taxonomy->description;
1761 $response['post_count'] = (int) $taxonomy->count;
1762 $response['feed_url'] = get_term_feed_link( $taxonomy->term_id, $taxonomy_type );
1763
1764 if ( is_taxonomy_hierarchical( $taxonomy_type ) ) {
1765 $response['parent'] = (int) $taxonomy->parent;
1766 }
1767
1768 $response['meta'] = (object) array(
1769 'links' => (object) array(
1770 'self' => (string) $this->links->get_taxonomy_link( $this->api->get_blog_id_for_output(), $taxonomy->slug, $taxonomy_type ),
1771 'help' => (string) $this->links->get_taxonomy_link( $this->api->get_blog_id_for_output(), $taxonomy->slug, $taxonomy_type, 'help' ),
1772 'site' => (string) $this->links->get_site_link( $this->api->get_blog_id_for_output() ),
1773 ),
1774 );
1775
1776 return (object) $response;
1777 }
1778
1779 /**
1780 * Returns ISO 8601 formatted datetime: 2011-12-08T01:15:36-08:00
1781 *
1782 * @param string $date_gmt GMT datetime string.
1783 * @param string $date Optional. Used to calculate the offset from GMT.
1784 * @return string
1785 */
1786 public function format_date( $date_gmt, $date = null ) {
1787 return WPCOM_JSON_API_Date::format_date( $date_gmt, $date );
1788 }
1789
1790 /**
1791 * Parses a date string and returns the local and GMT representations
1792 * of that date & time in 'YYYY-MM-DD HH:MM:SS' format without
1793 * timezones or offsets. If the parsed datetime was not localized to a
1794 * particular timezone or offset we will assume it was given in GMT
1795 * relative to now and will convert it to local time using either the
1796 * timezone set in the options table for the blog or the GMT offset.
1797 *
1798 * @param datetime string $date_string Date to parse.
1799 *
1800 * @return array( $local_time_string, $gmt_time_string )
1801 */
1802 public function parse_date( $date_string ) {
1803 $date_string_info = date_parse( $date_string );
1804 if ( is_array( $date_string_info ) && 0 === $date_string_info['error_count'] ) {
1805 // Check if it's already localized. Can't just check is_localtime because date_parse('oppossum') returns true; WTF, PHP.
1806 if ( isset( $date_string_info['zone'] ) && true === $date_string_info['is_localtime'] ) {
1807 $dt_utc = new DateTime( $date_string );
1808 $dt_local = clone $dt_utc;
1809 $dt_utc->setTimezone( new DateTimeZone( 'UTC' ) );
1810 return array(
1811 (string) $dt_local->format( 'Y-m-d H:i:s' ),
1812 (string) $dt_utc->format( 'Y-m-d H:i:s' ),
1813 );
1814 }
1815
1816 // It's parseable but no TZ info so assume UTC.
1817 $dt_utc = new DateTime( $date_string, new DateTimeZone( 'UTC' ) );
1818 $dt_local = clone $dt_utc;
1819 } else {
1820 // Could not parse time, use now in UTC.
1821 $dt_utc = new DateTime( 'now', new DateTimeZone( 'UTC' ) );
1822 $dt_local = clone $dt_utc;
1823 }
1824
1825 $dt_local->setTimezone( wp_timezone() );
1826
1827 return array(
1828 (string) $dt_local->format( 'Y-m-d H:i:s' ),
1829 (string) $dt_utc->format( 'Y-m-d H:i:s' ),
1830 );
1831 }
1832
1833 /**
1834 * Load the functions.php file for the current theme to get its post formats, CPTs, etc.
1835 */
1836 public function load_theme_functions() {
1837 if ( false === defined( 'STYLESHEETPATH' ) ) {
1838 wp_templating_constants();
1839 }
1840
1841 // bail if we've done this already (can happen when calling /batch endpoint).
1842 if ( defined( 'REST_API_THEME_FUNCTIONS_LOADED' ) ) {
1843 return;
1844 }
1845
1846 // VIP context loading is handled elsewhere, so bail to prevent
1847 // duplicate loading. See `switch_to_blog_and_validate_user()`.
1848 if ( defined( 'WPCOM_IS_VIP_ENV' ) && WPCOM_IS_VIP_ENV ) {
1849 return;
1850 }
1851
1852 $do_check_theme =
1853 defined( 'REST_API_TEST_REQUEST' ) && REST_API_TEST_REQUEST ||
1854 defined( 'IS_WPCOM' ) && IS_WPCOM;
1855
1856 if ( $do_check_theme && ! wpcom_should_load_theme_files_on_rest_api() ) {
1857 return;
1858 }
1859
1860 define( 'REST_API_THEME_FUNCTIONS_LOADED', true );
1861
1862 // the theme info we care about is found either within functions.php or one of the jetpack files.
1863 $function_files = array( '/functions.php', '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php' );
1864
1865 $copy_dirs = array( get_template_directory() );
1866
1867 // Is this a child theme? Load the child theme's functions file.
1868 if ( get_stylesheet_directory() !== get_template_directory() && wpcom_is_child_theme() ) {
1869 foreach ( $function_files as $function_file ) {
1870 if ( file_exists( get_stylesheet_directory() . $function_file ) ) {
1871 require_once get_stylesheet_directory() . $function_file;
1872 }
1873 }
1874 $copy_dirs[] = get_stylesheet_directory();
1875 }
1876
1877 foreach ( $function_files as $function_file ) {
1878 if ( file_exists( get_template_directory() . $function_file ) ) {
1879 require_once get_template_directory() . $function_file;
1880 }
1881 }
1882
1883 // add inc/wpcom.php and/or includes/wpcom.php.
1884 wpcom_load_theme_compat_file();
1885
1886 // Enable including additional directories or files in actions to be copied.
1887 $copy_dirs = apply_filters( 'restapi_theme_action_copy_dirs', $copy_dirs );
1888
1889 // since the stuff we care about (CPTS, post formats, are usually on setup or init hooks, we want to load those).
1890 $this->copy_hooks( 'after_setup_theme', 'restapi_theme_after_setup_theme', $copy_dirs );
1891
1892 /**
1893 * Fires functions hooked onto `after_setup_theme` by the theme for the purpose of the REST API.
1894 *
1895 * The REST API does not load the theme when processing requests.
1896 * To enable theme-based functionality, the API will load the '/functions.php',
1897 * '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php files
1898 * of the theme (parent and child) and copy functions hooked onto 'after_setup_theme' within those files.
1899 *
1900 * @module json-api
1901 *
1902 * @since 3.2.0
1903 */
1904 do_action( 'restapi_theme_after_setup_theme' );
1905 $this->copy_hooks( 'init', 'restapi_theme_init', $copy_dirs );
1906
1907 /**
1908 * Fires functions hooked onto `init` by the theme for the purpose of the REST API.
1909 *
1910 * The REST API does not load the theme when processing requests.
1911 * To enable theme-based functionality, the API will load the '/functions.php',
1912 * '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php files
1913 * of the theme (parent and child) and copy functions hooked onto 'init' within those files.
1914 *
1915 * @module json-api
1916 *
1917 * @since 3.2.0
1918 */
1919 do_action( 'restapi_theme_init' );
1920 }
1921
1922 /**
1923 * Copy hook functions.
1924 *
1925 * @param string $from_hook Hook to copy from.
1926 * @param string $to_hook Hook to copy to.
1927 * @param array $base_paths Only copy hooks defined in the specified paths.
1928 */
1929 public function copy_hooks( $from_hook, $to_hook, $base_paths ) {
1930 global $wp_filter;
1931 foreach ( $wp_filter as $hook => $actions ) {
1932
1933 if ( $from_hook !== $hook ) {
1934 continue;
1935 }
1936 if ( ! has_action( $hook ) ) {
1937 continue;
1938 }
1939
1940 foreach ( $actions as $priority => $callbacks ) {
1941 foreach ( $callbacks as $callback_data ) {
1942 $callback = $callback_data['function'];
1943
1944 // use reflection api to determine filename where function is defined.
1945 $reflection = $this->get_reflection( $callback );
1946
1947 if ( false !== $reflection ) {
1948 $file_name = $reflection->getFileName();
1949 foreach ( $base_paths as $base_path ) {
1950
1951 // only copy hooks with functions which are part of the specified files.
1952 if ( 0 === strpos( $file_name, $base_path ) ) {
1953 add_action(
1954 $to_hook,
1955 $callback_data['function'],
1956 $priority,
1957 $callback_data['accepted_args']
1958 );
1959 }
1960 }
1961 }
1962 }
1963 }
1964 }
1965 }
1966
1967 /**
1968 * Get a ReflectionMethod or ReflectionFunction for the callback.
1969 *
1970 * @param callable $callback Callback.
1971 * @return ReflectionMethod|ReflectionFunction|false
1972 */
1973 public function get_reflection( $callback ) {
1974 if ( is_array( $callback ) ) {
1975 list( $class, $method ) = $callback;
1976 return new ReflectionMethod( $class, $method );
1977 }
1978
1979 if ( is_string( $callback ) && strpos( $callback, '::' ) !== false ) {
1980 list( $class, $method ) = explode( '::', $callback );
1981 return new ReflectionMethod( $class, $method );
1982 }
1983
1984 if ( method_exists( $callback, '__invoke' ) ) {
1985 return new ReflectionMethod( $callback, '__invoke' );
1986 }
1987
1988 if ( is_string( $callback ) && strpos( $callback, '::' ) === false && function_exists( $callback ) ) {
1989 return new ReflectionFunction( $callback );
1990 }
1991
1992 return false;
1993 }
1994
1995 /**
1996 * Check whether a user can view or edit a post type.
1997 *
1998 * @param string $post_type post type to check.
1999 * @param string $context 'display' or 'edit'.
2000 * @return bool
2001 */
2002 public function current_user_can_access_post_type( $post_type, $context = 'display' ) {
2003 $post_type_object = get_post_type_object( $post_type );
2004 if ( ! $post_type_object ) {
2005 return false;
2006 }
2007
2008 switch ( $context ) {
2009 case 'edit':
2010 return current_user_can( $post_type_object->cap->edit_posts );
2011 case 'display':
2012 return $post_type_object->public || current_user_can( $post_type_object->cap->read_private_posts );
2013 default:
2014 return false;
2015 }
2016 }
2017
2018 /**
2019 * Is the post type allowed?
2020 *
2021 * @param string $post_type Post type.
2022 * @return bool
2023 */
2024 public function is_post_type_allowed( $post_type ) {
2025 // if the post type is empty, that's fine, WordPress will default to post.
2026 if ( empty( $post_type ) ) {
2027 return true;
2028 }
2029
2030 // allow special 'any' type.
2031 if ( 'any' === $post_type ) {
2032 return true;
2033 }
2034
2035 // check for allowed types.
2036 if ( in_array( $post_type, $this->_get_whitelisted_post_types(), true ) ) {
2037 return true;
2038 }
2039
2040 $post_type_object = get_post_type_object( $post_type );
2041 if ( $post_type_object ) {
2042 if ( ! empty( $post_type_object->show_in_rest ) ) {
2043 return $post_type_object->show_in_rest;
2044 }
2045 if ( ! empty( $post_type_object->publicly_queryable ) ) {
2046 return $post_type_object->publicly_queryable;
2047 }
2048 }
2049
2050 return ! empty( $post_type_object->public );
2051 }
2052
2053 /**
2054 * Gets the whitelisted post types that JP should allow access to.
2055 *
2056 * @return array Whitelisted post types.
2057 */
2058 protected function _get_whitelisted_post_types() { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore -- Legacy.
2059 $allowed_types = array( 'post', 'page', 'revision' );
2060
2061 /**
2062 * Filter the post types Jetpack has access to, and can synchronize with WordPress.com.
2063 *
2064 * @module json-api
2065 *
2066 * @since 2.2.3
2067 *
2068 * @param array $allowed_types Array of whitelisted post types. Default to `array( 'post', 'page', 'revision' )`.
2069 */
2070 $allowed_types = apply_filters( 'rest_api_allowed_post_types', $allowed_types );
2071
2072 return array_unique( $allowed_types );
2073 }
2074
2075 /**
2076 * Mobile apps are allowed free video uploads, but limited to 5 minutes in length.
2077 *
2078 * @param array $media_item the media item to evaluate.
2079 *
2080 * @return bool true if the media item is a video that was uploaded via the mobile
2081 * app that is longer than 5 minutes.
2082 */
2083 public function media_item_is_free_video_mobile_upload_and_too_long( $media_item ) {
2084 if ( ! $media_item ) {
2085 return false;
2086 }
2087
2088 // Verify file is a video.
2089 $is_video = preg_match( '@^video/@', $media_item['type'] );
2090 if ( ! $is_video ) {
2091 return false;
2092 }
2093
2094 // Check if the request is from a mobile app, where we allow free video uploads at limited length.
2095 if ( ! in_array( $this->api->token_details['client_id'], VIDEOPRESS_ALLOWED_REST_API_CLIENT_IDS, true ) ) {
2096 return false;
2097 }
2098
2099 // We're only worried about free sites.
2100 require_once WP_CONTENT_DIR . '/admin-plugins/wpcom-billing.php';
2101 $current_plan = WPCOM_Store_API::get_current_plan( get_current_blog_id() );
2102 if ( ! $current_plan['is_free'] ) {
2103 return false;
2104 }
2105
2106 // Check if video is longer than 5 minutes.
2107 $video_meta = wp_read_video_metadata( $media_item['tmp_name'] );
2108 if (
2109 false !== $video_meta &&
2110 isset( $video_meta['length'] ) &&
2111 5 * MINUTE_IN_SECONDS < $video_meta['length']
2112 ) {
2113 videopress_log(
2114 'videopress_app_upload_length_block',
2115 'Mobile app upload on free site blocked because length was longer than 5 minutes.',
2116 null,
2117 null,
2118 null,
2119 null,
2120 array(
2121 'blog_id' => get_current_blog_id(),
2122 'user_id' => get_current_user_id(),
2123 )
2124 );
2125 return true;
2126 }
2127
2128 return false;
2129 }
2130
2131 /**
2132 * Handle a v1.1 media creation.
2133 *
2134 * Only one of $media_files and $media_urls should be non-empty.
2135 *
2136 * @param array $media_files File upload data.
2137 * @param array $media_urls URLs to fetch.
2138 * @param array $media_attrs Attributes corresponding to each entry in `$media_files`/`$media_urls`.
2139 * @param int|false $force_parent_id Force the parent ID, overriding `$media_attrs[]['parent_id']`.
2140 * @return array Two items:
2141 * - media_ids: IDs created, by index in `$media_files`/`$media_urls`.
2142 * - errors: Errors encountered, by index in `$media_files`/`$media_urls`.
2143 */
2144 public function handle_media_creation_v1_1( $media_files, $media_urls, $media_attrs = array(), $force_parent_id = false ) {
2145
2146 add_filter( 'upload_mimes', array( $this, 'allow_video_uploads' ) );
2147
2148 $media_ids = array();
2149 $errors = array();
2150 $user_can_upload_files = current_user_can( 'upload_files' ) || $this->api->is_authorized_with_upload_token();
2151 $media_attrs = array_values( $media_attrs ); // reset the keys.
2152 $i = 0;
2153
2154 if ( ! empty( $media_files ) ) {
2155 $this->api->trap_wp_die( 'upload_error' );
2156 foreach ( $media_files as $media_item ) {
2157 $_FILES['.api.media.item.'] = $media_item;
2158
2159 if ( ! $user_can_upload_files ) {
2160 $media_id = new WP_Error( 'unauthorized', 'User cannot upload media.', 403 );
2161 } else {
2162 if ( $this->media_item_is_free_video_mobile_upload_and_too_long( $media_item ) ) {
2163 $media_id = new WP_Error( 'upload_video_length', 'Video uploads longer than 5 minutes require a paid plan.', 400 );
2164 } else {
2165 if ( $force_parent_id ) {
2166 $parent_id = absint( $force_parent_id );
2167 } elseif ( ! empty( $media_attrs[ $i ] ) && ! empty( $media_attrs[ $i ]['parent_id'] ) ) {
2168 $parent_id = absint( $media_attrs[ $i ]['parent_id'] );
2169 } else {
2170 $parent_id = 0;
2171 }
2172 $media_id = media_handle_upload( '.api.media.item.', $parent_id );
2173 }
2174 }
2175 if ( is_wp_error( $media_id ) ) {
2176 $errors[ $i ]['file'] = $media_item['name'];
2177 $errors[ $i ]['error'] = $media_id->get_error_code();
2178 $errors[ $i ]['message'] = $media_id->get_error_message();
2179 } else {
2180 $media_ids[ $i ] = $media_id;
2181 }
2182
2183 $i++;
2184 }
2185 $this->api->trap_wp_die( null );
2186 unset( $_FILES['.api.media.item.'] );
2187 }
2188
2189 if ( ! empty( $media_urls ) ) {
2190 foreach ( $media_urls as $url ) {
2191 if ( ! $user_can_upload_files ) {
2192 $media_id = new WP_Error( 'unauthorized', 'User cannot upload media.', 403 );
2193 } else {
2194 if ( $force_parent_id ) {
2195 $parent_id = absint( $force_parent_id );
2196 } elseif ( ! empty( $media_attrs[ $i ] ) && ! empty( $media_attrs[ $i ]['parent_id'] ) ) {
2197 $parent_id = absint( $media_attrs[ $i ]['parent_id'] );
2198 } else {
2199 $parent_id = 0;
2200 }
2201 $media_id = $this->handle_media_sideload( $url, $parent_id );
2202 }
2203 if ( is_wp_error( $media_id ) ) {
2204 $errors[ $i ] = array(
2205 'file' => $url,
2206 'error' => $media_id->get_error_code(),
2207 'message' => $media_id->get_error_message(),
2208 );
2209 } elseif ( ! empty( $media_id ) ) {
2210 $media_ids[ $i ] = $media_id;
2211 }
2212
2213 $i++;
2214 }
2215 }
2216
2217 if ( ! empty( $media_attrs ) ) {
2218 foreach ( $media_ids as $index => $media_id ) {
2219 if ( empty( $media_attrs[ $index ] ) ) {
2220 continue;
2221 }
2222
2223 $attrs = $media_attrs[ $index ];
2224 $insert = array();
2225
2226 // Attributes: Title, Caption, Description.
2227
2228 if ( isset( $attrs['title'] ) ) {
2229 $insert['post_title'] = $attrs['title'];
2230 }
2231
2232 if ( isset( $attrs['caption'] ) ) {
2233 $insert['post_excerpt'] = $attrs['caption'];
2234 }
2235
2236 if ( isset( $attrs['description'] ) ) {
2237 $insert['post_content'] = $attrs['description'];
2238 }
2239
2240 if ( ! empty( $insert ) ) {
2241 $insert['ID'] = $media_id;
2242 wp_update_post( (object) $insert );
2243 }
2244
2245 // Attributes: Alt.
2246
2247 if ( isset( $attrs['alt'] ) ) {
2248 $alt = wp_strip_all_tags( $attrs['alt'], true );
2249 update_post_meta( $media_id, '_wp_attachment_image_alt', $alt );
2250 }
2251
2252 // Attributes: Artist, Album.
2253
2254 $id3_meta = array();
2255
2256 foreach ( array( 'artist', 'album' ) as $key ) {
2257 if ( isset( $attrs[ $key ] ) ) {
2258 $id3_meta[ $key ] = wp_strip_all_tags( $attrs[ $key ], true );
2259 }
2260 }
2261
2262 if ( ! empty( $id3_meta ) ) {
2263 // Before updating metadata, ensure that the item is audio.
2264 $item = $this->get_media_item_v1_1( $media_id );
2265 if ( 0 === strpos( $item->mime_type, 'audio/' ) ) {
2266 wp_update_attachment_metadata( $media_id, $id3_meta );
2267 }
2268 }
2269 }
2270 }
2271
2272 return array(
2273 'media_ids' => $media_ids,
2274 'errors' => $errors,
2275 );
2276
2277 }
2278
2279 /**
2280 * Handle a media sideload.
2281 *
2282 * @param string $url URL.
2283 * @param int $parent_post_id Parent post ID.
2284 * @param string $type Type.
2285 * @return int|WP_Error|false Media post ID, or error, or false if nothing was sideloaded.
2286 */
2287 public function handle_media_sideload( $url, $parent_post_id = 0, $type = 'any' ) {
2288 if ( ! function_exists( 'download_url' ) || ! function_exists( 'media_handle_sideload' ) ) {
2289 return false;
2290 }
2291
2292 // if we didn't get a URL, let's bail.
2293 $parsed = wp_parse_url( $url );
2294 if ( empty( $parsed ) ) {
2295 return false;
2296 }
2297
2298 $tmp = download_url( $url );
2299 if ( is_wp_error( $tmp ) ) {
2300 return $tmp;
2301 }
2302
2303 // First check to see if we get a mime-type match by file, otherwise, check to
2304 // see if WordPress supports this file as an image. If neither, then it is not supported.
2305 if ( ! $this->is_file_supported_for_sideloading( $tmp ) || 'image' === $type && ! file_is_displayable_image( $tmp ) ) {
2306 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2307 return new WP_Error( 'invalid_input', 'Invalid file type.', 403 );
2308 }
2309
2310 // emulate a $_FILES entry.
2311 $file_array = array(
2312 'name' => basename( wp_parse_url( $url, PHP_URL_PATH ) ),
2313 'tmp_name' => $tmp,
2314 );
2315
2316 $id = media_handle_sideload( $file_array, $parent_post_id );
2317 if ( file_exists( $tmp ) ) {
2318 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2319 }
2320
2321 if ( is_wp_error( $id ) ) {
2322 return $id;
2323 }
2324
2325 if ( ! $id || ! is_int( $id ) ) {
2326 return false;
2327 }
2328
2329 return $id;
2330 }
2331
2332 /**
2333 * Checks that the mime type of the specified file is among those in a filterable list of mime types.
2334 *
2335 * @param string $file Path to file to get its mime type.
2336 *
2337 * @return bool
2338 */
2339 protected function is_file_supported_for_sideloading( $file ) {
2340 return jetpack_is_file_supported_for_sideloading( $file );
2341 }
2342
2343 /**
2344 * Filter for `upload_mimes`.
2345 *
2346 * @param array $mimes Allowed mime types.
2347 * @return array Allowed mime types.
2348 */
2349 public function allow_video_uploads( $mimes ) {
2350 // if we are on Jetpack, bail - Videos are already allowed.
2351 if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
2352 return $mimes;
2353 }
2354
2355 // extra check that this filter is only ever applied during REST API requests.
2356 if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
2357 return $mimes;
2358 }
2359
2360 // bail early if they already have the upgrade..
2361 if ( wpcom_site_has_videopress() ) {
2362 return $mimes;
2363 }
2364
2365 // lets whitelist to only specific clients right now.
2366 $clients_allowed_video_uploads = array();
2367 /**
2368 * Filter the list of whitelisted video clients.
2369 *
2370 * @module json-api
2371 *
2372 * @since 3.2.0
2373 *
2374 * @param array $clients_allowed_video_uploads Array of whitelisted Video clients.
2375 */
2376 $clients_allowed_video_uploads = apply_filters( 'rest_api_clients_allowed_video_uploads', $clients_allowed_video_uploads );
2377 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.
2378 return $mimes;
2379 }
2380
2381 $mime_list = wp_get_mime_types();
2382
2383 $video_exts = explode( ' ', get_site_option( 'video_upload_filetypes', false, false ) );
2384 /**
2385 * Filter the video filetypes allowed on the site.
2386 *
2387 * @module json-api
2388 *
2389 * @since 3.2.0
2390 *
2391 * @param array $video_exts Array of video filetypes allowed on the site.
2392 */
2393 $video_exts = apply_filters( 'video_upload_filetypes', $video_exts );
2394 $video_mimes = array();
2395
2396 if ( ! empty( $video_exts ) ) {
2397 foreach ( $video_exts as $ext ) {
2398 foreach ( $mime_list as $ext_pattern => $mime ) {
2399 if ( '' !== $ext && strpos( $ext_pattern, $ext ) !== false ) {
2400 $video_mimes[ $ext_pattern ] = $mime;
2401 }
2402 }
2403 }
2404
2405 $mimes = array_merge( $mimes, $video_mimes );
2406 }
2407
2408 return $mimes;
2409 }
2410
2411 /**
2412 * Is the current site multi-user?
2413 *
2414 * @return bool
2415 */
2416 public function is_current_site_multi_user() {
2417 $users = wp_cache_get( 'site_user_count', 'WPCOM_JSON_API_Endpoint' );
2418 if ( false === $users ) {
2419 $user_query = new WP_User_Query(
2420 array(
2421 'blog_id' => get_current_blog_id(),
2422 'fields' => 'ID',
2423 )
2424 );
2425 $users = (int) $user_query->get_total();
2426 wp_cache_set( 'site_user_count', $users, 'WPCOM_JSON_API_Endpoint', DAY_IN_SECONDS );
2427 }
2428 return $users > 1;
2429 }
2430
2431 /**
2432 * Whether cross-origin requests are allowed.
2433 *
2434 * @return bool
2435 */
2436 public function allows_cross_origin_requests() {
2437 return 'GET' === $this->method || $this->allow_cross_origin_request;
2438 }
2439
2440 /**
2441 * Whether unauthorized requests are allowed.
2442 *
2443 * @param string $origin Origin.
2444 * @param string[] $complete_access_origins Access origins.
2445 * @return bool
2446 */
2447 public function allows_unauthorized_requests( $origin, $complete_access_origins ) {
2448 return 'GET' === $this->method || ( $this->allow_unauthorized_request && in_array( $origin, $complete_access_origins, true ) );
2449 }
2450
2451 /**
2452 * Whether this endpoint accepts site based authentication for the current request.
2453 *
2454 * @since 9.1.0
2455 *
2456 * @return bool true, if Jetpack blog token is used and `allow_jetpack_site_auth` is true,
2457 * false otherwise.
2458 */
2459 public function accepts_site_based_authentication() {
2460 return $this->allow_jetpack_site_auth &&
2461 $this->api->is_jetpack_authorized_for_site();
2462 }
2463
2464 /**
2465 * Get platform.
2466 *
2467 * @return WPORG_Platform
2468 */
2469 public function get_platform() {
2470 return wpcom_get_sal_platform( $this->api->token_details );
2471 }
2472
2473 /**
2474 * Allows the endpoint to perform logic to allow it to decide whether-or-not it should force a
2475 * response from the WPCOM API, or potentially go to the Jetpack blog.
2476 *
2477 * Override this method if you want to do something different.
2478 *
2479 * @param int $blog_id Blog ID.
2480 * @return bool
2481 */
2482 public function force_wpcom_request( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
2483 return false;
2484 }
2485
2486 /**
2487 * Get an array of all valid AMP origins for a blog's siteurl.
2488 *
2489 * @param string $siteurl Origin url of the API request.
2490 * @return array
2491 */
2492 public function get_amp_cache_origins( $siteurl ) {
2493 $host = wp_parse_url( $siteurl, PHP_URL_HOST );
2494
2495 /*
2496 * From AMP docs:
2497 * "When possible, the Google AMP Cache will create a subdomain for each AMP document's domain by first converting it
2498 * from IDN (punycode) to UTF-8. The caches replaces every - (dash) with -- (2 dashes) and replace every . (dot) with
2499 * - (dash). For example, pub.com will map to pub-com.cdn.ampproject.org."
2500 */
2501 if ( function_exists( 'idn_to_utf8' ) ) {
2502 // The third parameter is set explicitly to prevent issues with newer PHP versions compiled with an old ICU version.
2503 // phpcs:ignore PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003Deprecated, PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003DeprecatedRemoved
2504 $host = idn_to_utf8( $host, IDNA_DEFAULT, defined( 'INTL_IDNA_VARIANT_UTS46' ) ? INTL_IDNA_VARIANT_UTS46 : INTL_IDNA_VARIANT_2003 );
2505 }
2506 $subdomain = str_replace( array( '-', '.' ), array( '--', '-' ), $host );
2507 return array(
2508 $siteurl,
2509 // Google AMP Cache (legacy).
2510 'https://cdn.ampproject.org',
2511 // Google AMP Cache subdomain.
2512 sprintf( 'https://%s.cdn.ampproject.org', $subdomain ),
2513 // Cloudflare AMP Cache.
2514 sprintf( 'https://%s.amp.cloudflare.com', $subdomain ),
2515 // Bing AMP Cache.
2516 sprintf( 'https://%s.bing-amp.com', $subdomain ),
2517 );
2518 }
2519
2520 /**
2521 * Return endpoint response
2522 *
2523 * @param string $path ... determined by ->$path.
2524 *
2525 * @return array|WP_Error
2526 * falsy: HTTP 500, no response body
2527 * WP_Error( $error_code, $error_message, $http_status_code ): HTTP $status_code, json_encode( array( 'error' => $error_code, 'message' => $error_message ) ) response body
2528 * $data: HTTP 200, json_encode( $data ) response body
2529 */
2530 abstract public function callback( $path = '' );
2531
2532 }
2533
2534 require_once __DIR__ . '/json-endpoints.php';
2535