PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 12.7
Jetpack – WP Security, Backup, Speed, & Growth v12.7
16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 14.3.1 All 501 releases
jetpack / class.json-api-endpoints.php

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

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