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