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

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