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

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