PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / class.json-api-endpoints.php

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

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