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