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