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