Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.13% covered (danger)
44.13%
601 / 1362
25.00% covered (danger)
25.00%
12 / 48
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_JSON_API_Endpoint
44.29% covered (danger)
44.29%
601 / 1357
25.00% covered (danger)
25.00%
12 / 48
40989.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
97.67% covered (success)
97.67%
84 / 86
0.00% covered (danger)
0.00%
0 / 1
12
 query_args
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 input
82.86% covered (warning)
82.86%
29 / 35
0.00% covered (danger)
0.00%
0 / 1
18.46
 get_secure_body
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 cast_and_filter
87.88% covered (warning)
87.88%
29 / 33
0.00% covered (danger)
0.00%
0 / 1
16.46
 cast_and_filter_item
21.05% covered (danger)
21.05%
68 / 323
0.00% covered (danger)
0.00%
0 / 1
3472.76
 parse_types
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 is_publicly_documentable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 document
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
110
 add_http_build_query_to_php_content_example
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 generate_doc_description
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 generate_documentation
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
210
 user_can_view_post
35.56% covered (danger)
35.56%
16 / 45
0.00% covered (danger)
0.00%
0 / 1
94.35
 get_author
71.56% covered (warning)
71.56%
78 / 109
0.00% covered (danger)
0.00%
0 / 1
68.49
 check_media_item_read_permission
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 get_media_item
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
7.08
 get_media_item_v1_1
51.22% covered (warning)
51.22%
63 / 123
0.00% covered (danger)
0.00%
0 / 1
638.77
 get_taxonomy
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 format_taxonomy
77.78% covered (warning)
77.78%
21 / 27
0.00% covered (danger)
0.00%
0 / 1
8.70
 format_date
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parse_date
52.63% covered (warning)
52.63%
10 / 19
0.00% covered (danger)
0.00%
0 / 1
5.70
 load_theme_functions
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
272
 copy_hooks
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
90
 get_reflection
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
72
 current_user_can_access_post_type
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
6.56
 is_post_type_allowed
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
11.82
 _get_whitelisted_post_types
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 media_item_is_free_video_mobile_upload_and_too_long
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
90
 handle_media_creation_v1_1
46.43% covered (danger)
46.43%
39 / 84
0.00% covered (danger)
0.00%
0 / 1
200.43
 handle_media_sideload
65.22% covered (warning)
65.22%
15 / 23
0.00% covered (danger)
0.00%
0 / 1
18.06
 is_file_supported_for_sideloading
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allow_video_uploads
9.52% covered (danger)
9.52%
2 / 21
0.00% covered (danger)
0.00%
0 / 1
138.17
 is_current_site_multi_user
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 allows_cross_origin_requests
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 allows_unauthorized_requests
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
12
 accepts_site_based_authentication
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_platform
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 force_wpcom_request
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_amp_cache_origins
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 create_rest_route_for_endpoint
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 rest_callback
95.74% covered (success)
95.74%
45 / 47
0.00% covered (danger)
0.00%
0 / 1
17
 rest_permission_callback
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
90
 rest_permission_callback_custom
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 build_rest_route
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 rest_route_has_tokens
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build_rest_route_regex
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 build_concrete_rest_route
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_rest_min_jp_version
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 callback
n/a
0 / 0
n/a
0 / 0
0
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * Jetpack API endpoint base class.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Connection\Client;
9use Automattic\Jetpack\Connection\Manager;
10use Automattic\Jetpack\Connection\Rest_Authentication;
11use Automattic\Jetpack\Connection\Tokens;
12use Automattic\Jetpack\Status;
13use Automattic\Jetpack\Status\Host;
14
15require_once __DIR__ . '/json-api-config.php';
16require_once __DIR__ . '/sal/class.json-api-links.php';
17require_once __DIR__ . '/sal/class.json-api-metadata.php';
18require_once __DIR__ . '/sal/class.json-api-date.php';
19
20/**
21 * Endpoint.
22 */
23abstract 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            case 'none':
1028                // A field documented without a (type) prefix has nothing to cast to, so leave it out.
1029                break;
1030            default:
1031                $method_name = $type['type'] . '_docs';
1032                if ( method_exists( 'WPCOM_JSON_API_Jetpack_Overrides', $method_name ) ) {
1033                    $docs = WPCOM_JSON_API_Jetpack_Overrides::$method_name();
1034                }
1035
1036                if ( ! empty( $docs ) ) {
1037                    $return[ $key ] = (object) $this->cast_and_filter(
1038                        $value,
1039                        /** This filter is documented in class.json-api-endpoints.php */
1040                        apply_filters( 'wpcom_json_api_plugin_cast_and_filter', $docs ),
1041                        false,
1042                        $for_output
1043                    );
1044                } else {
1045                    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error, WordPress.Security.EscapeOutput.OutputNotEscaped
1046                    trigger_error( "Unknown API casting type {$type['type']}", E_USER_WARNING );
1047                }
1048        }
1049    }
1050
1051    /**
1052     * Parse types from text.
1053     *
1054     * @param string $text Text.
1055     * @return array Types.
1056     */
1057    public function parse_types( $text ) {
1058        if ( ! preg_match( '#^\(([^)]+)\)#', ltrim( $text ), $matches ) ) {
1059            return array( array( 'type' => 'none' ) );
1060        }
1061
1062        $types  = explode( '|', strtolower( $matches[1] ) );
1063        $return = array();
1064        foreach ( $types as $type ) {
1065            foreach ( array(
1066                ':' => 'children',
1067                '>' => 'subtype',
1068                '=' => 'default',
1069            ) as $operator => $meaning ) {
1070                if ( str_contains( $type, $operator ) ) {
1071                    $item     = explode( $operator, $type, 2 );
1072                    $return[] = array(
1073                        'type'   => $item[0],
1074                        $meaning => $item[1],
1075                    );
1076                    continue 2;
1077                }
1078            }
1079            $return[] = compact( 'type' );
1080        }
1081
1082        return $return;
1083    }
1084
1085    /**
1086     * Checks if the endpoint is publicly displayable
1087     *
1088     * @return bool
1089     */
1090    public function is_publicly_documentable() {
1091        return '__do_not_document' !== $this->group && true !== $this->in_testing;
1092    }
1093
1094    /**
1095     * Auto generates documentation based on description, method, path, path_labels, and query parameters.
1096     * Echoes HTML.
1097     *
1098     * @param bool $show_description Whether to show the description.
1099     */
1100    public function document( $show_description = true ) {
1101        global $wpdb;
1102        $original_post = $GLOBALS['post'] ?? 'unset';
1103        unset( $GLOBALS['post'] );
1104
1105        $doc = $this->generate_documentation();
1106
1107        if ( $show_description ) :
1108            ?>
1109<caption>
1110    <h1><?php echo wp_kses_post( $doc['method'] ); ?> <?php echo wp_kses_post( $doc['path_labeled'] ); ?></h1>
1111    <p><?php echo wp_kses_post( $doc['description'] ); ?></p>
1112</caption>
1113
1114<?php endif; ?>
1115
1116        <?php if ( true === $this->deprecated ) { ?>
1117<p><strong>This endpoint is deprecated in favor of version <?php echo (float) $this->new_version; ?></strong></p>
1118<?php } ?>
1119
1120<section class="resource-info">
1121    <h2 id="apidoc-resource-info">Resource Information</h2>
1122
1123    <table class="api-doc api-doc-resource-parameters api-doc-resource">
1124
1125    <thead>
1126        <tr>
1127            <th class="api-index-title" scope="column">&nbsp;</th>
1128            <th class="api-index-title" scope="column">&nbsp;</th>
1129        </tr>
1130    </thead>
1131    <tbody>
1132
1133        <tr class="api-index-item">
1134            <th scope="row" class="parameter api-index-item-title">Method</th>
1135            <td class="type api-index-item-title"><?php echo wp_kses_post( $doc['method'] ); ?></td>
1136        </tr>
1137
1138        <tr class="api-index-item">
1139            <th scope="row" class="parameter api-index-item-title">URL</th>
1140            <?php
1141            $version = WPCOM_JSON_API__CURRENT_VERSION;
1142            if ( ! empty( $this->max_version ) ) {
1143                $version = $this->max_version;
1144            }
1145            ?>
1146            <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>
1147        </tr>
1148
1149        <tr class="api-index-item">
1150            <th scope="row" class="parameter api-index-item-title">Requires authentication?</th>
1151            <?php
1152            $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'] ) );
1153            ?>
1154            <td class="type api-index-item-title"><?php echo ( ! empty( $requires_auth->requires_authentication ) ? 'Yes' : 'No' ); ?></td>
1155        </tr>
1156
1157    </tbody>
1158    </table>
1159
1160</section>
1161
1162        <?php
1163
1164        foreach ( array(
1165            'path'     => 'Method Parameters',
1166            'query'    => 'Query Parameters',
1167            'body'     => 'Request Parameters',
1168            'response' => 'Response Parameters',
1169        ) as $doc_section_key => $label ) :
1170            $doc_section = 'response' === $doc_section_key ? $doc['response']['body'] : $doc['request'][ $doc_section_key ];
1171            if ( ! $doc_section ) {
1172                continue;
1173            }
1174
1175            $param_label = strtolower( str_replace( ' ', '-', $label ) );
1176            ?>
1177
1178<section class="<?php echo esc_attr( $param_label ); ?>">
1179
1180<h2 id="apidoc-<?php echo esc_attr( $doc_section_key ); ?>"><?php echo wp_kses_post( $label ); ?></h2>
1181
1182<table class="api-doc api-doc-<?php echo esc_attr( $param_label ); ?>-parameters api-doc-<?php echo esc_attr( strtolower( str_replace( ' ', '-', $doc['group'] ) ) ); ?>">
1183
1184<thead>
1185    <tr>
1186        <th class="api-index-title" scope="column">Parameter</th>
1187        <th class="api-index-title" scope="column">Type</th>
1188        <th class="api-index-title" scope="column">Description</th>
1189    </tr>
1190</thead>
1191<tbody>
1192
1193            <?php foreach ( $doc_section as $key => $item ) : ?>
1194
1195    <tr class="api-index-item">
1196        <th scope="row" class="parameter api-index-item-title"><?php echo wp_kses_post( $key ); ?></th>
1197        <td class="type api-index-item-title"><?php echo wp_kses_post( $item['type'] ); // @todo auto-link? ?></td>
1198        <td class="description api-index-item-body">
1199                <?php
1200
1201                $this->generate_doc_description( $item['description'] );
1202
1203                ?>
1204        </td>
1205    </tr>
1206
1207<?php endforeach; ?>
1208</tbody>
1209</table>
1210</section>
1211<?php endforeach; ?>
1212
1213        <?php
1214        if ( 'unset' !== $original_post ) {
1215            $GLOBALS['post'] = $original_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
1216        }
1217    }
1218
1219    /**
1220     * `preg_replace_callback` callback to add http_build_query to php content example.
1221     *
1222     * @todo Is this used anywhere?
1223     *
1224     * @param array $matches Matches.
1225     * @return string
1226     */
1227    public function add_http_build_query_to_php_content_example( $matches ) {
1228        $trimmed_match = ltrim( $matches[0] );
1229        $pad           = substr( $matches[0], 0, -1 * strlen( $trimmed_match ) );
1230        $pad           = ltrim( $pad, ' ' );
1231        $return        = '  ' . str_replace( "\n", "\n  ", $matches[0] );
1232        return " http_build_query({$return}{$pad})";
1233    }
1234
1235    /**
1236     * Recursively generates the <dl>'s to document item descriptions.
1237     * Echoes HTML.
1238     *
1239     * @param string|array $item Post data to output, or an array of key => data mappings.
1240     */
1241    public function generate_doc_description( $item ) {
1242        if ( is_array( $item ) ) :
1243            ?>
1244
1245        <dl>
1246            <?php    foreach ( $item as $description_key => $description_value ) : ?>
1247
1248            <dt><?php echo wp_kses_post( $description_key . ':' ); ?></dt>
1249            <dd><?php $this->generate_doc_description( $description_value ); ?></dd>
1250
1251            <?php    endforeach; ?>
1252
1253        </dl>
1254
1255            <?php
1256        else :
1257            echo wp_kses_post( $item );
1258        endif;
1259    }
1260
1261    /**
1262     * Auto generates documentation based on description, method, path, path_labels, and query parameters.
1263     * Echoes HTML.
1264     */
1265    public function generate_documentation() {
1266        $format       = str_replace( '%d', '%s', $this->path );
1267        $path_labeled = $format;
1268        if ( ! empty( $this->path_labels ) ) {
1269            $path_labeled = vsprintf( $format, array_keys( $this->path_labels ) );
1270        }
1271        $boolean_arg = array( 'false', 'true' );
1272        $naeloob_arg = array( 'true', 'false' );
1273
1274        $doc = array(
1275            'description'  => $this->description,
1276            'method'       => $this->method,
1277            'path_format'  => $this->path,
1278            'path_labeled' => $path_labeled,
1279            'group'        => $this->group,
1280            'request'      => array(
1281                'path'  => array(),
1282                'query' => array(),
1283                'body'  => array(),
1284            ),
1285            'response'     => array(
1286                'body' => array(),
1287            ),
1288        );
1289
1290        foreach ( array(
1291            'path_labels'     => 'path',
1292            'query'           => 'query',
1293            'request_format'  => 'body',
1294            'response_format' => 'body',
1295        ) as $_property => $doc_item ) {
1296            foreach ( (array) $this->$_property as $key => $description ) {
1297                if ( is_array( $description ) ) {
1298                    $description_keys = array_keys( $description );
1299                    if ( $boolean_arg === $description_keys || $naeloob_arg === $description_keys ) {
1300                        $type = '(bool)';
1301                    } else {
1302                        $type = '(string)';
1303                    }
1304
1305                    if ( 'response_format' !== $_property ) {
1306                        // hack - don't show "(default)" in response format.
1307                        reset( $description );
1308                        $description_key                 = key( $description );
1309                        $description[ $description_key ] = "(default) {$description[$description_key]}";
1310                    }
1311                } else {
1312                    $types   = $this->parse_types( $description );
1313                    $type    = array();
1314                    $default = '';
1315
1316                    foreach ( $types as $type_array ) {
1317                        $type[] = $type_array['type'];
1318                        if ( isset( $type_array['default'] ) ) {
1319                            $default = $type_array['default'];
1320                            if ( 'string' === $type_array['type'] ) {
1321                                $default = "'$default'";
1322                            }
1323                        }
1324                    }
1325                    $type = '(' . implode( '|', $type ) . ')';
1326                    if ( str_contains( $description, ')' ) ) {
1327                        list( , $description ) = explode( ')', $description, 2 );
1328                    }
1329                    $description = trim( $description );
1330                    if ( $default ) {
1331                        $description .= " Default: $default.";
1332                    }
1333                }
1334
1335                $item = compact( 'type', 'description' );
1336
1337                if ( 'response_format' === $_property ) {
1338                    $doc['response'][ $doc_item ][ $key ] = $item;
1339                } else {
1340                    $doc['request'][ $doc_item ][ $key ] = $item;
1341                }
1342            }
1343        }
1344
1345        return $doc;
1346    }
1347
1348    /**
1349     * Can the user view the post?
1350     *
1351     * @param int $post_id Post ID.
1352     * @return bool|WP_Error
1353     */
1354    public function user_can_view_post( $post_id ) {
1355        $post = get_post( $post_id );
1356        if ( ! $post || is_wp_error( $post ) ) {
1357            return false;
1358        }
1359
1360        if ( 'inherit' === $post->post_status ) {
1361            $parent_post     = get_post( $post->post_parent );
1362            $post_status_obj = get_post_status_object( $parent_post->post_status ?? $post->post_status );
1363        } else {
1364            $post_status_obj = get_post_status_object( $post->post_status );
1365        }
1366
1367        if ( empty( $post_status_obj->public ) ) {
1368            if ( is_user_logged_in() ) {
1369                if ( ! empty( $post_status_obj->protected ) ) {
1370                    if ( ! current_user_can( 'edit_post', $post->ID ) ) {
1371                        return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1372                    }
1373                } elseif ( ! empty( $post_status_obj->private ) ) {
1374                    if ( ! current_user_can( 'read_post', $post->ID ) ) {
1375                        return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1376                    }
1377                } elseif ( in_array( $post->post_status, array( 'inherit', 'trash' ), true ) ) {
1378                    if ( ! current_user_can( 'edit_post', $post->ID ) ) {
1379                        return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1380                    }
1381                } elseif ( 'auto-draft' === $post->post_status ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedElseif
1382                    // allow auto-drafts.
1383                } else {
1384                    return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1385                }
1386            } else {
1387                return new WP_Error( 'unauthorized', 'User cannot view post', 403 );
1388            }
1389        }
1390
1391        if (
1392            ( new Status() )->is_private_site() &&
1393            /**
1394             * Filter access to a specific post.
1395             *
1396             * @module json-api
1397             *
1398             * @since 3.4.0
1399             *
1400             * @param bool current_user_can( 'read_post', $post->ID ) Can the current user access the post.
1401             * @param WP_Post $post Post data.
1402             */
1403            ! apply_filters(
1404                'wpcom_json_api_user_can_view_post',
1405                current_user_can( 'read_post', $post->ID ),
1406                $post
1407            )
1408        ) {
1409            return new WP_Error(
1410                'unauthorized',
1411                'User cannot view post',
1412                array(
1413                    'status_code' => 403,
1414                    'error'       => 'private_blog',
1415                )
1416            );
1417        }
1418
1419        if ( strlen( $post->post_password ) && ! current_user_can( 'edit_post', $post->ID ) ) {
1420            return new WP_Error(
1421                'unauthorized',
1422                'User cannot view password protected post',
1423                array(
1424                    'status_code' => 403,
1425                    'error'       => 'password_protected',
1426                )
1427            );
1428        }
1429
1430        return true;
1431    }
1432
1433    /**
1434     * Returns author object.
1435     *
1436     * @param object $author user ID, user row, WP_User object, comment row, post row.
1437     * @param bool   $show_email_and_ip output the author's email address and IP address?.
1438     *
1439     * @return object
1440     */
1441    public function get_author( $author, $show_email_and_ip = false ) {
1442        $is_jetpack = null;
1443        $login      = null;
1444        $email      = null;
1445        $name       = null;
1446        $first_name = null;
1447        $last_name  = null;
1448        $nice       = null;
1449        $url        = null;
1450        $ip_address = $author->comment_author_IP ?? '';
1451        $site_id    = -1;
1452
1453        if ( isset( $author->comment_author_email ) ) {
1454            $id         = empty( $author->user_id ) ? 0 : (int) $author->user_id;
1455            $login      = '';
1456            $email      = $author->comment_author_email;
1457            $name       = $author->comment_author;
1458            $first_name = '';
1459            $last_name  = '';
1460            $avatar_url = $this->api->get_avatar_url( $author );
1461            $nice       = '';
1462            $url        = $author->comment_author_url;
1463            // Convert Gravatar URLs containing an email address to the hashed version.
1464            if ( preg_match( '#^https?://(?:www\.)?gravatar\.com/([^/?]+)#i', $url, $matches ) && is_email( $matches[1] ) ) {
1465                $url = 'https://gravatar.com/' . md5( strtolower( trim( $matches[1] ) ) );
1466            }
1467
1468            // Add additional user data to the response if a valid user ID is available.
1469            if ( 0 < $id ) {
1470                $user = get_user_by( 'id', $id );
1471                if ( $user instanceof WP_User ) {
1472                    $login      = $user->user_login ?? '';
1473                    $first_name = $user->first_name ?? '';
1474                    $last_name  = $user->last_name ?? '';
1475                    $nice       = $user->user_nicename ?? '';
1476                }
1477            }
1478
1479            // Comment author URLs and Emails are sent through wp_kses() on save, which replaces "&" with "&amp;"
1480            // "&" is the only email/URL character altered by wp_kses().
1481            foreach ( array( 'email', 'url' ) as $field ) {
1482                $$field = str_replace( '&amp;', '&', $$field );
1483            }
1484        } elseif ( $author instanceof WP_User || isset( $author->user_email ) ) {
1485            $author = $author->ID;
1486        } elseif ( isset( $author->user_id ) && $author->user_id ) {
1487            $author = $author->user_id;
1488        } elseif ( isset( $author->post_author ) ) {
1489            // then $author is a Post Object.
1490            if ( ! $author->post_author ) {
1491                return null;
1492            }
1493            /**
1494             * Filter whether the current site is a Jetpack site.
1495             *
1496             * @module json-api
1497             *
1498             * @since 3.3.0
1499             *
1500             * @param bool false Is the current site a Jetpack site. Default to false.
1501             * @param int get_current_blog_id() Blog ID.
1502             */
1503            $is_jetpack = true === apply_filters( 'is_jetpack_site', false, get_current_blog_id() );
1504            $post_id    = $author->ID;
1505            if ( $is_jetpack && ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
1506                $id         = get_post_meta( $post_id, '_jetpack_post_author_external_id', true );
1507                $email      = get_post_meta( $post_id, '_jetpack_author_email', true );
1508                $login      = '';
1509                $name       = get_post_meta( $post_id, '_jetpack_author', true );
1510                $first_name = '';
1511                $last_name  = '';
1512                $url        = '';
1513                $nice       = '';
1514            } else {
1515                $author = $author->post_author;
1516            }
1517        }
1518
1519        if ( ! isset( $id ) ) {
1520            $user = get_user_by( 'id', $author );
1521            if ( ! $user || is_wp_error( $user ) ) {
1522                return null;
1523            }
1524            $id         = $user->ID;
1525            $email      = $user->user_email;
1526            $login      = $user->user_login;
1527            $name       = $user->display_name;
1528            $first_name = $user->first_name;
1529            $last_name  = $user->last_name;
1530            $url        = $user->user_url;
1531            $nice       = $user->user_nicename;
1532        }
1533        if ( defined( 'IS_WPCOM' ) && IS_WPCOM && ! $is_jetpack && $id > 0 ) {
1534            /**
1535             * Allow customizing the blog ID returned with the author in WordPress.com REST API queries.
1536             *
1537             * @since 12.9
1538             *
1539             * @module json-api
1540             *
1541             * @param bool|int $active_blog  Blog ID, or false by default.
1542             * @param int      $id           User ID.
1543             */
1544            $active_blog = apply_filters( 'wpcom_api_pre_get_active_blog_author', false, $id );
1545            if ( false === $active_blog ) {
1546                $active_blog = get_active_blog_for_user( $id );
1547            }
1548            if ( ! empty( $active_blog ) ) {
1549                $site_id = $active_blog->blog_id;
1550            }
1551            if ( $site_id > - 1 ) {
1552                $site_visible = (
1553                    - 1 !== (int) $active_blog->public ||
1554                    is_private_blog_user( $site_id, get_current_user_id() )
1555                );
1556            }
1557            $profile_url = "https://gravatar.com/{$login}";
1558        } else {
1559            $profile_url = 'https://gravatar.com/' . md5( strtolower( trim( $email ) ) );
1560        }
1561
1562        if ( ! isset( $avatar_url ) ) {
1563            $avatar_url = $this->api->get_avatar_url( $email );
1564        }
1565
1566        if ( $show_email_and_ip ) {
1567            $email      = (string) $email;
1568            $ip_address = (string) $ip_address;
1569        } else {
1570            $email      = false;
1571            $ip_address = false;
1572        }
1573
1574        $author = array(
1575            'ID'          => (int) $id,
1576            'login'       => (string) $login,
1577            'email'       => $email, // string|bool.
1578            'name'        => (string) $name,
1579            'first_name'  => (string) $first_name,
1580            'last_name'   => (string) $last_name,
1581            'nice_name'   => (string) $nice,
1582            'URL'         => (string) esc_url_raw( $url ),
1583            'avatar_URL'  => (string) esc_url_raw( $avatar_url ),
1584            'profile_URL' => (string) esc_url_raw( $profile_url ),
1585            'ip_address'  => $ip_address, // string|bool.
1586        );
1587
1588        if ( $site_id > -1 ) {
1589            $author['site_ID']      = (int) $site_id;
1590            $author['site_visible'] = $site_visible ?? null;
1591        }
1592
1593        // Only include WordPress.com user data when author_wpcom_data is enabled.
1594        $args = $this->query_args();
1595
1596        if ( ! empty( $id ) && ! empty( $args['author_wpcom_data'] ) ) {
1597            if ( ( new Host() )->is_wpcom_simple() ) {
1598                $user                  = get_user_by( 'id', $id );
1599                $author['wpcom_id']    = isset( $user->ID ) ? (int) $user->ID : null;
1600                $author['wpcom_login'] = $user->user_login ?? '';
1601            } else {
1602                // If this is a Jetpack site, use the connection manager to get the user data.
1603                $wpcom_user_data = ( new Manager() )->get_connected_user_data( $id );
1604                if ( $wpcom_user_data && isset( $wpcom_user_data['ID'] ) ) {
1605                    $author['wpcom_id']    = (int) $wpcom_user_data['ID'];
1606                    $author['wpcom_login'] = $wpcom_user_data['login'] ?? '';
1607                }
1608            }
1609        }
1610
1611        return (object) $author;
1612    }
1613
1614    /**
1615     * Whether the current user may read the given media item through a media GET endpoint.
1616     *
1617     * Requires `edit_posts`, which Contributors hold, and returns only attachments. Any
1618     * other post type is reported as unknown media.
1619     *
1620     * @param int $media_id Media post ID.
1621     * @return true|WP_Error True if the item may be returned, WP_Error otherwise.
1622     */
1623    public function check_media_item_read_permission( $media_id ) {
1624        // upload_files can probably be used for other endpoints but we want contributors to be able to use media too.
1625        if ( ! current_user_can( 'edit_posts' ) ) {
1626            return new WP_Error( 'unauthorized', 'User cannot view media', 403 );
1627        }
1628
1629        $media_item = get_post( $media_id );
1630
1631        if ( $media_item && 'attachment' !== $media_item->post_type ) {
1632            return new WP_Error( 'unknown_media', 'Unknown Media', 404 );
1633        }
1634
1635        return true;
1636    }
1637
1638    /**
1639     * Get a media item.
1640     *
1641     * @param int $media_id Media post ID.
1642     * @return object|WP_Error Media item data, or WP_Error.
1643     */
1644    public function get_media_item( $media_id ) {
1645        $media_item = get_post( $media_id );
1646
1647        if ( ! $media_item || is_wp_error( $media_item ) ) {
1648            return new WP_Error( 'unknown_media', 'Unknown Media', 404 );
1649        }
1650
1651        $response = array(
1652            'id'          => (string) $media_item->ID,
1653            'date'        => (string) $this->format_date( $media_item->post_date_gmt, $media_item->post_date ),
1654            'parent'      => $media_item->post_parent,
1655            'link'        => wp_get_attachment_url( $media_item->ID ),
1656            'title'       => $media_item->post_title,
1657            'caption'     => $media_item->post_excerpt,
1658            'description' => $media_item->post_content,
1659            'metadata'    => wp_get_attachment_metadata( $media_item->ID ),
1660        );
1661
1662        if ( defined( 'IS_WPCOM' ) && IS_WPCOM && is_array( $response['metadata'] ) && ! empty( $response['metadata']['file'] ) ) {
1663            remove_filter( '_wp_relative_upload_path', 'wpcom_wp_relative_upload_path', 10 );
1664            $response['metadata']['file'] = _wp_relative_upload_path( $response['metadata']['file'] );
1665            add_filter( '_wp_relative_upload_path', 'wpcom_wp_relative_upload_path', 10, 2 );
1666        }
1667
1668        $response['meta'] = (object) array(
1669            'links' => (object) array(
1670                'self' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_id ),
1671                'help' => (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_id, 'help' ),
1672                'site' => (string) $this->links->get_site_link( $this->api->get_blog_id_for_output() ),
1673            ),
1674        );
1675
1676        return (object) $response;
1677    }
1678
1679    /**
1680     * Get a v1.1 media item.
1681     *
1682     * @param int          $media_id Media post ID.
1683     * @param WP_Post|null $media_item Media item.
1684     * @param string|null  $file File path.
1685     * @return object|WP_Error Media item data, or WP_Error.
1686     */
1687    public function get_media_item_v1_1( $media_id, $media_item = null, $file = null ) {
1688        if ( ! $media_item ) {
1689            $media_item = get_post( $media_id );
1690        }
1691
1692        if ( ! $media_item || is_wp_error( $media_item ) ) {
1693            return new WP_Error( 'unknown_media', 'Unknown Media', 404 );
1694        }
1695
1696        $attachment_file = isset( $media_item->ID ) ? get_attached_file( $media_item->ID ) : null;
1697
1698        $file      = basename( $attachment_file ? $attachment_file : $file );
1699        $file_info = pathinfo( $file );
1700        $ext       = $file_info['extension'] ?? null;
1701
1702        // File operations are handled differently on WordPress.com.
1703        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1704            $attachment_metadata = isset( $media_item->ID ) ? wp_get_attachment_metadata( $media_item->ID ) : array();
1705            $filesize            = ! empty( $attachment_metadata['filesize'] ) ? $attachment_metadata['filesize'] : 0;
1706        } else {
1707            // For VideoPress videos, $attachment_file is the video URL.
1708            $filesize = ( $attachment_file && file_exists( $attachment_file ) )
1709            ? filesize( $attachment_file )
1710            : 0;
1711        }
1712
1713        $response = array(
1714            'ID'          => $media_item->ID ?? null,
1715            'URL'         => isset( $media_item->ID ) ? wp_get_attachment_url( $media_item->ID ) : null,
1716            'guid'        => $media_item->guid ?? null,
1717            'date'        => ( isset( $media_item->post_date_gmt ) && isset( $media_item->post_date ) ) ?
1718            (string) $this->format_date( $media_item->post_date_gmt, $media_item->post_date ) : null,
1719            'post_ID'     => $media_item->post_parent ?? null,
1720            'author_ID'   => isset( $media_item->post_author ) ? (int) $media_item->post_author : null,
1721            'file'        => $file,
1722            'mime_type'   => $media_item->post_mime_type ?? null,
1723            'extension'   => $ext,
1724            'title'       => $media_item->post_title ?? '',
1725            'caption'     => $media_item->post_excerpt ?? '',
1726            'description' => $media_item->post_content ?? '',
1727            'alt'         => isset( $media_item->ID ) ? get_post_meta( $media_item->ID, '_wp_attachment_image_alt', true ) : '',
1728            'icon'        => isset( $media_item->ID ) ? wp_mime_type_icon( $media_item->ID ) : null,
1729            'size'        => size_format( (int) $filesize, 2 ),
1730            'thumbnails'  => array(),
1731        );
1732
1733        if ( in_array( $ext, array( 'jpg', 'jpeg', 'png', 'gif', 'webp' ), true ) && isset( $media_item->ID ) ) {
1734            $metadata = wp_get_attachment_metadata( $media_item->ID );
1735            if ( isset( $metadata['height'] ) ) {
1736                $response['height'] = $metadata['height'];
1737            }
1738            if ( isset( $metadata['width'] ) ) {
1739                $response['width'] = $metadata['width'];
1740            }
1741
1742            if ( isset( $metadata['sizes'] ) ) {
1743                /**
1744                 * Filter the thumbnail sizes available for each attachment ID.
1745                 *
1746                 * @module json-api
1747                 *
1748                 * @since 3.9.0
1749                 *
1750                 * @param array $metadata['sizes'] Array of thumbnail sizes available for a given attachment ID.
1751                 * @param string $media_id Attachment ID.
1752                 */
1753                $sizes = apply_filters( 'rest_api_thumbnail_sizes', $metadata['sizes'], $media_item->ID );
1754                if ( is_array( $sizes ) ) {
1755                    foreach ( $sizes as $size => $size_details ) {
1756                        if ( isset( $size_details['file'] ) ) {
1757                            $response['thumbnails'][ $size ] = dirname( $response['URL'] ) . '/' . $size_details['file'];
1758                        }
1759                    }
1760                    /**
1761                     * Filter the thumbnail URLs for attachment files.
1762                     *
1763                     * @module json-api
1764                     *
1765                     * @since 7.1.0
1766                     *
1767                     * @param array $metadata['sizes'] Array with thumbnail sizes as keys and URLs as values.
1768                     */
1769                    $response['thumbnails'] = apply_filters( 'rest_api_thumbnail_size_urls', $response['thumbnails'] );
1770                }
1771            }
1772
1773            if ( isset( $metadata['image_meta'] ) ) {
1774                $response['exif'] = $metadata['image_meta'];
1775            }
1776        }
1777
1778        if ( in_array( $ext, array( 'mp3', 'm4a', 'wav', 'ogg' ), true ) && isset( $media_item->ID ) ) {
1779            $metadata = wp_get_attachment_metadata( $media_item->ID );
1780
1781            if ( isset( $metadata['length'] ) ) {
1782                $response['length'] = $metadata['length'];
1783            }
1784            $response['exif'] = is_array( $metadata ) ? $metadata : false;
1785        }
1786
1787        $is_video = false;
1788
1789        if (
1790            in_array( $ext, array( 'ogv', 'mp4', 'mov', 'wmv', 'avi', 'mpg', '3gp', '3g2', 'm4v' ), true )
1791            || 'video/videopress' === $response['mime_type']
1792        ) {
1793            $is_video = true;
1794        }
1795
1796        if ( $is_video && isset( $media_item->ID ) ) {
1797            $metadata = wp_get_attachment_metadata( $media_item->ID );
1798
1799            if ( isset( $metadata['height'] ) ) {
1800                $response['height'] = $metadata['height'];
1801            }
1802            if ( isset( $metadata['width'] ) ) {
1803                $response['width'] = $metadata['width'];
1804            }
1805
1806            if ( isset( $metadata['length'] ) ) {
1807                $response['length'] = $metadata['length'];
1808            }
1809
1810            if ( empty( $response['length'] ) && isset( $metadata['duration'] ) ) {
1811                $response['length'] = (int) $metadata['duration'];
1812            }
1813
1814            if ( empty( $response['length'] ) && isset( $metadata['videopress']['duration'] ) ) {
1815                $response['length'] = ceil( $metadata['videopress']['duration'] / 1000 );
1816            }
1817
1818            // add VideoPress info.
1819            if ( function_exists( 'video_get_info_by_blogpostid' ) ) {
1820                $info = video_get_info_by_blogpostid( $this->api->get_blog_id_for_output(), $media_item->ID );
1821
1822                // If we failed to get VideoPress info, but it exists in the meta data (for some reason)
1823                // then let's use that.
1824                if ( false === $info && isset( $metadata['videopress'] ) ) {
1825                    $info = (object) $metadata['videopress'];
1826                }
1827
1828                if ( isset( $info->rating ) ) {
1829                    $response['rating'] = $info->rating;
1830                }
1831
1832                if ( isset( $info->display_embed ) ) {
1833                    $response['display_embed'] = (string) (int) $info->display_embed;
1834                    // If not, default to metadata (for WPCOM).
1835                } elseif ( isset( $metadata['videopress']['display_embed'] ) ) {
1836                    // We convert it to int then to string so that (bool) false to become "0".
1837                    $response['display_embed'] = (string) (int) $metadata['videopress']['display_embed'];
1838                }
1839
1840                if ( isset( $info->allow_download ) ) {
1841                    $response['allow_download'] = (string) (int) $info->allow_download;
1842                } elseif ( isset( $metadata['videopress']['allow_download'] ) ) {
1843                    // We convert it to int then to string so that (bool) false to become "0".
1844                    $response['allow_download'] = (string) (int) $metadata['videopress']['allow_download'];
1845                }
1846
1847                if ( isset( $info->thumbnail_generating ) ) {
1848                    $response['thumbnail_generating'] = (bool) intval( $info->thumbnail_generating );
1849                } elseif ( isset( $metadata['videopress']['thumbnail_generating'] ) ) {
1850                    $response['thumbnail_generating'] = (bool) intval( $metadata['videopress']['thumbnail_generating'] );
1851                }
1852
1853                if ( isset( $info->privacy_setting ) ) {
1854                    $response['privacy_setting'] = (int) $info->privacy_setting;
1855                } elseif ( isset( $metadata['videopress']['privacy_setting'] ) ) {
1856                    $response['privacy_setting'] = (int) $metadata['videopress']['privacy_setting'];
1857                }
1858
1859                $thumbnail_query_data = array();
1860                if ( ! empty( $info ) && function_exists( 'video_is_private' ) && video_is_private( $info ) ) {
1861                    $thumbnail_query_data['metadata_token'] = video_generate_auth_token( $info );
1862                }
1863
1864                // Thumbnails.
1865                if ( function_exists( 'video_format_done' ) && function_exists( 'video_image_url_by_guid' ) ) {
1866                    $response['thumbnails'] = array(
1867                        'fmt_hd'  => '',
1868                        'fmt_dvd' => '',
1869                        'fmt_std' => '',
1870                    );
1871                    foreach ( $response['thumbnails'] as $size => $thumbnail_url ) {
1872                        if ( video_format_done( $info, $size ) ) {
1873                            $response['thumbnails'][ $size ] = \add_query_arg( $thumbnail_query_data, \video_image_url_by_guid( $info->guid, $size ) );
1874                        } else {
1875                            unset( $response['thumbnails'][ $size ] );
1876                        }
1877                    }
1878                }
1879
1880                if ( isset( $info->title ) ) {
1881                    $response['title'] = $info->title;
1882                }
1883
1884                // If we didn't get VideoPress information (for some reason) then let's
1885                // not try and include it in the response.
1886                if ( isset( $info->guid ) ) {
1887                    $response['videopress_guid']            = $info->guid;
1888                    $response['videopress_processing_done'] = isset( $info->finish_date_gmt ) && '0000-00-00 00:00:00' !== $info->finish_date_gmt;
1889                }
1890            }
1891        }
1892
1893        $response['thumbnails'] = (object) $response['thumbnails'];
1894
1895        $response['meta'] = (object) array(
1896            'links' => (object) array(
1897                'self' => isset( $media_item->ID ) ? (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_item->ID ) : null,
1898                'help' => isset( $media_item->ID ) ? (string) $this->links->get_media_link( $this->api->get_blog_id_for_output(), $media_item->ID, 'help' ) : null,
1899                'site' => (string) $this->links->get_site_link( $this->api->get_blog_id_for_output() ),
1900            ),
1901        );
1902
1903        // add VideoPress link to the meta.
1904        if ( isset( $response['videopress_guid'] ) ) {
1905            if ( function_exists( 'video_get_info_by_blogpostid' ) ) {
1906                $response['meta']->links->videopress = (string) $this->links->get_link( '/videos/%s', $response['videopress_guid'], '' );
1907            }
1908        }
1909
1910        if ( isset( $media_item->post_parent ) && $media_item->post_parent > 0 ) {
1911            $response['meta']->links->parent = (string) $this->links->get_post_link( $this->api->get_blog_id_for_output(), $media_item->post_parent );
1912        }
1913
1914        return (object) $response;
1915    }
1916
1917    /**
1918     * Get a formatted taxonomy.
1919     *
1920     * @param int    $taxonomy_id Taxonomy ID.
1921     * @param string $taxonomy_type Name of taxonomy.
1922     * @param string $context Context, 'edit' or 'display'.
1923     * @return object|WP_Error
1924     */
1925    public function get_taxonomy( $taxonomy_id, $taxonomy_type, $context ) {
1926
1927        $taxonomy = get_term_by( 'slug', $taxonomy_id, $taxonomy_type );
1928        // keep updating this function.
1929        if ( ! $taxonomy || is_wp_error( $taxonomy ) ) {
1930            return new WP_Error( 'unknown_taxonomy', 'Unknown taxonomy', 404 );
1931        }
1932
1933        return $this->format_taxonomy( $taxonomy, $taxonomy_type, $context );
1934    }
1935
1936    /**
1937     * Format a taxonomy.
1938     *
1939     * @param WP_Term $taxonomy Taxonomy.
1940     * @param string  $taxonomy_type Name of taxonomy.
1941     * @param string  $context Context, 'edit' or 'display'.
1942     * @return object|WP_Error
1943     */
1944    public function format_taxonomy( $taxonomy, $taxonomy_type, $context ) {
1945        // Permissions.
1946        switch ( $context ) {
1947            case 'edit':
1948                $tax = get_taxonomy( $taxonomy_type );
1949                if ( ! current_user_can( $tax->cap->edit_terms ) ) {
1950                    return new WP_Error( 'unauthorized', 'User cannot edit taxonomy', 403 );
1951                }
1952                break;
1953            case 'display':
1954                if ( ( new Status() )->is_private_site() && ! current_user_can( 'read' ) ) {
1955                    return new WP_Error( 'unauthorized', 'User cannot view taxonomy', 403 );
1956                }
1957                break;
1958            default:
1959                return new WP_Error( 'invalid_context', 'Invalid API CONTEXT', 400 );
1960        }
1961
1962        $response                = array();
1963        $response['ID']          = (int) $taxonomy->term_id;
1964        $response['name']        = (string) $taxonomy->name;
1965        $response['slug']        = (string) $taxonomy->slug;
1966        $response['description'] = (string) $taxonomy->description;
1967        $response['post_count']  = (int) $taxonomy->count;
1968        $response['feed_url']    = get_term_feed_link( $taxonomy->term_id, $taxonomy_type );
1969
1970        if ( is_taxonomy_hierarchical( $taxonomy_type ) ) {
1971            $response['parent'] = (int) $taxonomy->parent;
1972        }
1973
1974        $response['meta'] = (object) array(
1975            'links' => (object) array(
1976                'self' => (string) $this->links->get_taxonomy_link( $this->api->get_blog_id_for_output(), $taxonomy->slug, $taxonomy_type ),
1977                'help' => (string) $this->links->get_taxonomy_link( $this->api->get_blog_id_for_output(), $taxonomy->slug, $taxonomy_type, 'help' ),
1978                'site' => (string) $this->links->get_site_link( $this->api->get_blog_id_for_output() ),
1979            ),
1980        );
1981
1982        return (object) $response;
1983    }
1984
1985    /**
1986     * Returns ISO 8601 formatted datetime: 2011-12-08T01:15:36-08:00
1987     *
1988     * @param string $date_gmt GMT datetime string.
1989     * @param string $date Optional. Used to calculate the offset from GMT.
1990     * @return string
1991     */
1992    public function format_date( $date_gmt, $date = null ) {
1993        return WPCOM_JSON_API_Date::format_date( $date_gmt, $date );
1994    }
1995
1996    /**
1997     * Parses a date string and returns the local and GMT representations
1998     * of that date & time in 'YYYY-MM-DD HH:MM:SS' format without
1999     * timezones or offsets. If the parsed datetime was not localized to a
2000     * particular timezone or offset we will assume it was given in GMT
2001     * relative to now and will convert it to local time using either the
2002     * timezone set in the options table for the blog or the GMT offset.
2003     *
2004     * @param string $date_string Date to parse.
2005     *
2006     * @return array{string,string} ( $local_time_string, $gmt_time_string )
2007     */
2008    public function parse_date( $date_string ) {
2009        $date_string_info = date_parse( $date_string );
2010        if ( 0 === $date_string_info['error_count'] ) {
2011            // Check if it's already localized. Can't just check is_localtime because date_parse('oppossum') returns true; WTF, PHP.
2012            if ( isset( $date_string_info['zone'] ) && true === $date_string_info['is_localtime'] ) {
2013                $dt_utc   = new DateTime( $date_string );
2014                $dt_local = clone $dt_utc;
2015                $dt_utc->setTimezone( new DateTimeZone( 'UTC' ) );
2016                return array(
2017                    $dt_local->format( 'Y-m-d H:i:s' ),
2018                    $dt_utc->format( 'Y-m-d H:i:s' ),
2019                );
2020            }
2021
2022            // It's parseable but no TZ info so assume UTC.
2023            $dt_utc   = new DateTime( $date_string, new DateTimeZone( 'UTC' ) );
2024            $dt_local = clone $dt_utc;
2025        } else {
2026            // Could not parse time, use now in UTC.
2027            $dt_utc   = new DateTime( 'now', new DateTimeZone( 'UTC' ) );
2028            $dt_local = clone $dt_utc;
2029        }
2030
2031        $dt_local->setTimezone( wp_timezone() );
2032
2033        return array(
2034            $dt_local->format( 'Y-m-d H:i:s' ),
2035            $dt_utc->format( 'Y-m-d H:i:s' ),
2036        );
2037    }
2038
2039    /**
2040     * Load the functions.php file for the current theme to get its post formats, CPTs, etc.
2041     */
2042    public function load_theme_functions() {
2043        if ( ! defined( 'STYLESHEETPATH' ) ) {
2044            wp_templating_constants();
2045        }
2046
2047        // bail if we've done this already (can happen when calling /batch endpoint).
2048        if ( defined( 'REST_API_THEME_FUNCTIONS_LOADED' ) ) {
2049            return;
2050        }
2051
2052        // VIP context loading is handled elsewhere, so bail to prevent
2053        // duplicate loading. See `switch_to_blog_and_validate_user()`.
2054        if ( defined( 'WPCOM_IS_VIP_ENV' ) && WPCOM_IS_VIP_ENV ) {
2055            return;
2056        }
2057
2058        $do_check_theme =
2059            defined( 'REST_API_TEST_REQUEST' ) && REST_API_TEST_REQUEST ||
2060            defined( 'IS_WPCOM' ) && IS_WPCOM;
2061
2062        if ( $do_check_theme && ! wpcom_should_load_theme_files_on_rest_api() ) {
2063            return;
2064        }
2065
2066        define( 'REST_API_THEME_FUNCTIONS_LOADED', true );
2067
2068        // the theme info we care about is found either within functions.php or one of the jetpack files.
2069        $function_files = array( '/functions.php', '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php' );
2070
2071        $copy_dirs = array( get_template_directory() );
2072
2073        // Is this a child theme? Load the child theme's functions file.
2074        if ( get_stylesheet_directory() !== get_template_directory() && wpcom_is_child_theme() ) {
2075            foreach ( $function_files as $function_file ) {
2076                if ( file_exists( get_stylesheet_directory() . $function_file ) ) {
2077                    require_once get_stylesheet_directory() . $function_file;
2078                }
2079            }
2080            $copy_dirs[] = get_stylesheet_directory();
2081        }
2082
2083        foreach ( $function_files as $function_file ) {
2084            if ( file_exists( get_template_directory() . $function_file ) ) {
2085                require_once get_template_directory() . $function_file;
2086            }
2087        }
2088
2089        // add inc/wpcom.php and/or includes/wpcom.php.
2090        wpcom_load_theme_compat_file();
2091
2092        // Enable including additional directories or files in actions to be copied.
2093        $copy_dirs = apply_filters( 'restapi_theme_action_copy_dirs', $copy_dirs );
2094
2095        // since the stuff we care about (CPTS, post formats, are usually on setup or init hooks, we want to load those).
2096        $this->copy_hooks( 'after_setup_theme', 'restapi_theme_after_setup_theme', $copy_dirs );
2097
2098        /**
2099         * Fires functions hooked onto `after_setup_theme` by the theme for the purpose of the REST API.
2100         *
2101         * The REST API does not load the theme when processing requests.
2102         * To enable theme-based functionality, the API will load the '/functions.php',
2103         * '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php files
2104         * of the theme (parent and child) and copy functions hooked onto 'after_setup_theme' within those files.
2105         *
2106         * @module json-api
2107         *
2108         * @since 3.2.0
2109         */
2110        do_action( 'restapi_theme_after_setup_theme' );
2111        $this->copy_hooks( 'init', 'restapi_theme_init', $copy_dirs );
2112
2113        /**
2114         * Fires functions hooked onto `init` by the theme for the purpose of the REST API.
2115         *
2116         * The REST API does not load the theme when processing requests.
2117         * To enable theme-based functionality, the API will load the '/functions.php',
2118         * '/inc/jetpack.compat.php', '/inc/jetpack.php', '/includes/jetpack.compat.php files
2119         * of the theme (parent and child) and copy functions hooked onto 'init' within those files.
2120         *
2121         * @module json-api
2122         *
2123         * @since 3.2.0
2124         */
2125        do_action( 'restapi_theme_init' );
2126    }
2127
2128    /**
2129     * Copy hook functions.
2130     *
2131     * @param string $from_hook Hook to copy from.
2132     * @param string $to_hook Hook to copy to.
2133     * @param array  $base_paths Only copy hooks defined in the specified paths.
2134     */
2135    public function copy_hooks( $from_hook, $to_hook, $base_paths ) {
2136        global $wp_filter;
2137        foreach ( $wp_filter as $hook => $actions ) {
2138
2139            if ( $from_hook !== $hook ) {
2140                continue;
2141            }
2142            if ( ! has_action( $hook ) ) {
2143                continue;
2144            }
2145
2146            foreach ( $actions as $priority => $callbacks ) {
2147                foreach ( $callbacks as $callback_data ) {
2148                    $callback = $callback_data['function'];
2149
2150                    // use reflection api to determine filename where function is defined.
2151                    $reflection = $this->get_reflection( $callback );
2152
2153                    if ( false !== $reflection ) {
2154                        $file_name = $reflection->getFileName();
2155                        foreach ( $base_paths as $base_path ) {
2156
2157                            // only copy hooks with functions which are part of the specified files.
2158                            if ( str_starts_with( $file_name, $base_path ) ) {
2159                                add_action(
2160                                    $to_hook,
2161                                    $callback_data['function'],
2162                                    $priority,
2163                                    $callback_data['accepted_args']
2164                                );
2165                            }
2166                        }
2167                    }
2168                }
2169            }
2170        }
2171    }
2172
2173    /**
2174     * Get a ReflectionMethod or ReflectionFunction for the callback.
2175     *
2176     * @param callable $callback Callback.
2177     * @return ReflectionMethod|ReflectionFunction|false
2178     */
2179    public function get_reflection( $callback ) {
2180        if ( is_array( $callback ) ) {
2181            list( $class, $method ) = $callback;
2182            return new ReflectionMethod( $class, $method );
2183        }
2184
2185        if ( is_string( $callback ) && strpos( $callback, '::' ) !== false ) {
2186            list( $class, $method ) = explode( '::', $callback );
2187            return new ReflectionMethod( $class, $method );
2188        }
2189
2190        if ( method_exists( $callback, '__invoke' ) ) {
2191            return new ReflectionMethod( $callback, '__invoke' );
2192        }
2193
2194        if ( is_string( $callback ) && strpos( $callback, '::' ) === false && function_exists( $callback ) ) {
2195            return new ReflectionFunction( $callback );
2196        }
2197
2198        return false;
2199    }
2200
2201    /**
2202     * Check whether a user can view or edit a post type.
2203     *
2204     * @param string $post_type post type to check.
2205     * @param string $context   'display' or 'edit'.
2206     * @return bool
2207     */
2208    public function current_user_can_access_post_type( $post_type, $context = 'display' ) {
2209        $post_type_object = get_post_type_object( $post_type );
2210        if ( ! $post_type_object ) {
2211            return false;
2212        }
2213
2214        switch ( $context ) {
2215            case 'edit':
2216                return current_user_can( $post_type_object->cap->edit_posts );
2217            case 'display':
2218                return $post_type_object->public || current_user_can( $post_type_object->cap->read_private_posts );
2219            default:
2220                return false;
2221        }
2222    }
2223
2224    /**
2225     * Is the post type allowed?
2226     *
2227     * @param string $post_type Post type.
2228     * @return bool
2229     */
2230    public function is_post_type_allowed( $post_type ) {
2231        // if the post type is empty, that's fine, WordPress will default to post.
2232        if ( empty( $post_type ) ) {
2233            return true;
2234        }
2235
2236        // allow special 'any' type.
2237        if ( 'any' === $post_type ) {
2238            return true;
2239        }
2240
2241        // check for allowed types.
2242        if ( in_array( $post_type, $this->_get_whitelisted_post_types(), true ) ) {
2243            return true;
2244        }
2245
2246        $post_type_object = get_post_type_object( $post_type );
2247        if ( $post_type_object ) {
2248            if ( ! empty( $post_type_object->show_in_rest ) ) {
2249                return $post_type_object->show_in_rest;
2250            }
2251            if ( ! empty( $post_type_object->publicly_queryable ) ) {
2252                return $post_type_object->publicly_queryable;
2253            }
2254        }
2255
2256        return ! empty( $post_type_object->public );
2257    }
2258
2259    /**
2260     * Gets the whitelisted post types that JP should allow access to.
2261     *
2262     * @return array Whitelisted post types.
2263     */
2264    protected function _get_whitelisted_post_types() { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore -- Legacy.
2265        $allowed_types = array( 'post', 'page', 'revision' );
2266
2267        /**
2268         * Filter the post types Jetpack has access to, and can synchronize with WordPress.com.
2269         *
2270         * @module json-api
2271         *
2272         * @since 2.2.3
2273         *
2274         * @param array $allowed_types Array of whitelisted post types. Default to `array( 'post', 'page', 'revision' )`.
2275         */
2276        $allowed_types = apply_filters( 'rest_api_allowed_post_types', $allowed_types );
2277
2278        return array_unique( $allowed_types );
2279    }
2280
2281    /**
2282     * Mobile apps are allowed free video uploads, but limited to 5 minutes in length.
2283     *
2284     * @param array $media_item the media item to evaluate.
2285     *
2286     * @return bool true if the media item is a video that was uploaded via the mobile
2287     * app that is longer than 5 minutes.
2288     */
2289    public function media_item_is_free_video_mobile_upload_and_too_long( $media_item ) {
2290        if ( ! $media_item ) {
2291            return false;
2292        }
2293
2294        // Verify file is a video.
2295        $is_video = preg_match( '@^video/@', $media_item['type'] );
2296        if ( ! $is_video ) {
2297            return false;
2298        }
2299
2300        // Check if the request is from a mobile app, where we allow free video uploads at limited length.
2301        if ( ! in_array( $this->api->token_details['client_id'], VIDEOPRESS_ALLOWED_REST_API_CLIENT_IDS, true ) ) {
2302            return false;
2303        }
2304
2305        // We're only worried about free sites.
2306        require_once WP_CONTENT_DIR . '/admin-plugins/wpcom-billing.php';
2307        $current_plan = WPCOM_Store_API::get_current_plan( get_current_blog_id() );
2308        if ( ! $current_plan['is_free'] ) {
2309            return false;
2310        }
2311
2312        // 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.
2313        if ( wp_parse_url( $media_item['tmp_name'], PHP_URL_SCHEME ) !== null ) {
2314            return false;
2315        }
2316
2317        // Check if video is longer than 5 minutes.
2318        $video_meta = wp_read_video_metadata( $media_item['tmp_name'] );
2319        if (
2320            false !== $video_meta &&
2321            isset( $video_meta['length'] ) &&
2322            5 * MINUTE_IN_SECONDS < $video_meta['length']
2323        ) {
2324            videopress_log(
2325                'videopress_app_upload_length_block',
2326                'Mobile app upload on free site blocked because length was longer than 5 minutes.',
2327                null,
2328                null,
2329                null,
2330                null,
2331                array(
2332                    'blog_id' => get_current_blog_id(),
2333                    'user_id' => get_current_user_id(),
2334                )
2335            );
2336            return true;
2337        }
2338
2339        return false;
2340    }
2341
2342    /**
2343     * Handle a v1.1 media creation.
2344     *
2345     * Only one of $media_files and $media_urls should be non-empty.
2346     *
2347     * @param array     $media_files File upload data.
2348     * @param array     $media_urls URLs to fetch.
2349     * @param array     $media_attrs Attributes corresponding to each entry in `$media_files`/`$media_urls`.
2350     * @param int|false $force_parent_id Force the parent ID, overriding `$media_attrs[]['parent_id']`.
2351     * @return array Two items:
2352     *  - media_ids: IDs created, by index in `$media_files`/`$media_urls`.
2353     *  - errors: Errors encountered, by index in `$media_files`/`$media_urls`.
2354     */
2355    public function handle_media_creation_v1_1( $media_files, $media_urls, $media_attrs = array(), $force_parent_id = false ) {
2356
2357        add_filter( 'upload_mimes', array( $this, 'allow_video_uploads' ) );
2358
2359        $media_ids             = array();
2360        $errors                = array();
2361        $user_can_upload_files = current_user_can( 'upload_files' ) || $this->api->is_authorized_with_upload_token();
2362        $media_attrs           = array_values( $media_attrs ); // reset the keys.
2363        $i                     = 0;
2364
2365        if ( ! empty( $media_files ) ) {
2366            $this->api->trap_wp_die( 'upload_error' );
2367            foreach ( $media_files as $media_item ) {
2368                $_FILES['.api.media.item.'] = $media_item;
2369
2370                if ( ! $user_can_upload_files ) {
2371                    $media_id = new WP_Error( 'unauthorized', 'User cannot upload media.', 403 );
2372                } elseif ( ! is_array( $media_item ) ) {
2373                    $media_id   = new WP_Error( 'invalid_input', 'Unable to process request.', 400 );
2374                    $media_item = array(
2375                        'name' => 'invalid_file',
2376                    );
2377                } elseif ( $this->media_item_is_free_video_mobile_upload_and_too_long( $media_item ) ) {
2378                    $media_id = new WP_Error( 'upload_video_length', 'Video uploads longer than 5 minutes require a paid plan.', 400 );
2379                } else {
2380                    if ( $force_parent_id ) {
2381                        $parent_id = absint( $force_parent_id );
2382                    } elseif ( ! empty( $media_attrs[ $i ] ) && ! empty( $media_attrs[ $i ]['parent_id'] ) ) {
2383                        $parent_id = absint( $media_attrs[ $i ]['parent_id'] );
2384                    } else {
2385                        $parent_id = 0;
2386                    }
2387                    $media_id = media_handle_upload( '.api.media.item.', $parent_id );
2388                }
2389                if ( is_wp_error( $media_id ) ) {
2390                    $errors[ $i ]['file']    = $media_item['name'];
2391                    $errors[ $i ]['error']   = $media_id->get_error_code();
2392                    $errors[ $i ]['message'] = $media_id->get_error_message();
2393                } else {
2394                    $media_ids[ $i ] = $media_id;
2395                }
2396
2397                ++$i;
2398            }
2399            $this->api->trap_wp_die( null );
2400            unset( $_FILES['.api.media.item.'] );
2401        }
2402
2403        if ( ! empty( $media_urls ) ) {
2404            foreach ( $media_urls as $url ) {
2405                if ( ! $user_can_upload_files ) {
2406                    $media_id = new WP_Error( 'unauthorized', 'User cannot upload media.', 403 );
2407                } else {
2408                    if ( $force_parent_id ) {
2409                        $parent_id = absint( $force_parent_id );
2410                    } elseif ( ! empty( $media_attrs[ $i ] ) && ! empty( $media_attrs[ $i ]['parent_id'] ) ) {
2411                        $parent_id = absint( $media_attrs[ $i ]['parent_id'] );
2412                    } else {
2413                        $parent_id = 0;
2414                    }
2415                    $media_id = $this->handle_media_sideload( $url, $parent_id );
2416                }
2417                if ( is_wp_error( $media_id ) ) {
2418                    $errors[ $i ] = array(
2419                        'file'    => $url,
2420                        'error'   => $media_id->get_error_code(),
2421                        'message' => $media_id->get_error_message(),
2422                    );
2423                } elseif ( ! empty( $media_id ) ) {
2424                    $media_ids[ $i ] = $media_id;
2425                }
2426
2427                ++$i;
2428            }
2429        }
2430
2431        if ( ! empty( $media_attrs ) ) {
2432            foreach ( $media_ids as $index => $media_id ) {
2433                if ( empty( $media_attrs[ $index ] ) ) {
2434                    continue;
2435                }
2436
2437                $attrs  = $media_attrs[ $index ];
2438                $insert = array();
2439
2440                // Attributes: Title, Caption, Description.
2441
2442                if ( isset( $attrs['title'] ) ) {
2443                    $insert['post_title'] = $attrs['title'];
2444                }
2445
2446                if ( isset( $attrs['caption'] ) ) {
2447                    $insert['post_excerpt'] = $attrs['caption'];
2448                }
2449
2450                if ( isset( $attrs['description'] ) ) {
2451                    $insert['post_content'] = $attrs['description'];
2452                }
2453
2454                if ( ! empty( $insert ) ) {
2455                    $insert['ID'] = $media_id;
2456                    wp_update_post( (object) $insert );
2457                }
2458
2459                // Attributes: Alt.
2460
2461                if ( isset( $attrs['alt'] ) ) {
2462                    $alt = wp_strip_all_tags( $attrs['alt'], true );
2463                    update_post_meta( $media_id, '_wp_attachment_image_alt', $alt );
2464                }
2465
2466                // Attributes: Artist, Album.
2467
2468                $id3_meta = array();
2469
2470                foreach ( array( 'artist', 'album' ) as $key ) {
2471                    if ( isset( $attrs[ $key ] ) ) {
2472                        $id3_meta[ $key ] = wp_strip_all_tags( $attrs[ $key ], true );
2473                    }
2474                }
2475
2476                if ( ! empty( $id3_meta ) ) {
2477                    // Before updating metadata, ensure that the item is audio.
2478                    $item = $this->get_media_item_v1_1( $media_id );
2479                    if ( str_starts_with( $item->mime_type, 'audio/' ) ) {
2480                        wp_update_attachment_metadata( $media_id, $id3_meta );
2481                    }
2482                }
2483
2484                // Attributes: Meta
2485                if ( isset( $attrs['meta'] ) && isset( $attrs['meta']['vertical_id'] ) ) {
2486                    update_post_meta( $media_id, 'vertical_id', $attrs['meta']['vertical_id'] );
2487                }
2488            }
2489        }
2490
2491        return array(
2492            'media_ids' => $media_ids,
2493            'errors'    => $errors,
2494        );
2495    }
2496
2497    /**
2498     * Handle a media sideload.
2499     *
2500     * @param string $url URL.
2501     * @param int    $parent_post_id Parent post ID.
2502     * @param string $type Type.
2503     * @return int|WP_Error|false Media post ID, or error, or false if nothing was sideloaded.
2504     */
2505    public function handle_media_sideload( $url, $parent_post_id = 0, $type = 'any' ) {
2506        if ( ! function_exists( 'download_url' ) || ! function_exists( 'media_handle_sideload' ) ) {
2507            return false;
2508        }
2509
2510        // if we didn't get a URL, let's bail.
2511        $parsed = wp_parse_url( $url );
2512        if ( empty( $parsed ) ) {
2513            return false;
2514        }
2515
2516        $tmp = download_url( $url );
2517        if ( is_wp_error( $tmp ) ) {
2518            return $tmp;
2519        }
2520
2521        // First check to see if we get a mime-type match by file, otherwise, check to
2522        // see if WordPress supports this file as an image. If neither, then it is not supported.
2523        if ( ! $this->is_file_supported_for_sideloading( $tmp ) || 'image' === $type && ! file_is_displayable_image( $tmp ) ) {
2524            @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2525            return new WP_Error( 'invalid_input', 'Invalid file type.', 403 );
2526        }
2527
2528        // emulate a $_FILES entry.
2529        $file_array = array(
2530            'name'     => basename( wp_parse_url( $url, PHP_URL_PATH ) ),
2531            'tmp_name' => $tmp,
2532        );
2533
2534        $id = media_handle_sideload( $file_array, $parent_post_id );
2535        if ( file_exists( $tmp ) ) {
2536            @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2537        }
2538
2539        if ( is_wp_error( $id ) ) {
2540            return $id;
2541        }
2542
2543        if ( ! $id || ! is_int( $id ) ) {
2544            return false;
2545        }
2546
2547        return $id;
2548    }
2549
2550    /**
2551     * Checks that the mime type of the specified file is among those in a filterable list of mime types.
2552     *
2553     * @param string $file Path to file to get its mime type.
2554     *
2555     * @return bool
2556     */
2557    protected function is_file_supported_for_sideloading( $file ) {
2558        return jetpack_is_file_supported_for_sideloading( $file );
2559    }
2560
2561    /**
2562     * Filter for `upload_mimes`.
2563     *
2564     * @param array $mimes Allowed mime types.
2565     * @return array Allowed mime types.
2566     */
2567    public function allow_video_uploads( $mimes ) {
2568        // if we are on Jetpack, bail - Videos are already allowed.
2569        if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
2570            return $mimes;
2571        }
2572
2573        // extra check that this filter is only ever applied during REST API requests.
2574        if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
2575            return $mimes;
2576        }
2577
2578        // bail early if they already have video upload capability.
2579        if ( wpcom_site_can_upload_videos() ) {
2580            return $mimes;
2581        }
2582
2583        // lets whitelist to only specific clients right now.
2584        $clients_allowed_video_uploads = array();
2585        /**
2586         * Filter the list of whitelisted video clients.
2587         *
2588         * @module json-api
2589         *
2590         * @since 3.2.0
2591         *
2592         * @param array $clients_allowed_video_uploads Array of whitelisted Video clients.
2593         */
2594        $clients_allowed_video_uploads = apply_filters( 'rest_api_clients_allowed_video_uploads', $clients_allowed_video_uploads );
2595        if ( ! isset( $this->api->token_details['client_id'] ) || ! in_array( $this->api->token_details['client_id'], $clients_allowed_video_uploads, true ) ) {
2596            return $mimes;
2597        }
2598
2599        $mime_list = wp_get_mime_types();
2600
2601        $video_exts = explode( ' ', get_site_option( 'video_upload_filetypes', false, false ) );
2602        /**
2603         * Filter the video filetypes allowed on the site.
2604         *
2605         * @module json-api
2606         *
2607         * @since 3.2.0
2608         *
2609         * @param array $video_exts Array of video filetypes allowed on the site.
2610         */
2611        $video_exts  = apply_filters( 'video_upload_filetypes', $video_exts );
2612        $video_mimes = array();
2613
2614        if ( ! empty( $video_exts ) ) {
2615            foreach ( $video_exts as $ext ) {
2616                foreach ( $mime_list as $ext_pattern => $mime ) {
2617                    if ( '' !== $ext && str_contains( $ext_pattern, $ext ) ) {
2618                        $video_mimes[ $ext_pattern ] = $mime;
2619                    }
2620                }
2621            }
2622
2623            $mimes = array_merge( $mimes, $video_mimes );
2624        }
2625
2626        return $mimes;
2627    }
2628
2629    /**
2630     * Is the current site multi-user?
2631     *
2632     * @return bool
2633     */
2634    public function is_current_site_multi_user() {
2635        $users = wp_cache_get( 'site_user_count', 'WPCOM_JSON_API_Endpoint' );
2636        if ( false === $users ) {
2637            $user_query = new WP_User_Query(
2638                array(
2639                    'blog_id' => get_current_blog_id(),
2640                    'fields'  => 'ID',
2641                )
2642            );
2643            $users      = (int) $user_query->get_total();
2644            wp_cache_set( 'site_user_count', $users, 'WPCOM_JSON_API_Endpoint', DAY_IN_SECONDS );
2645        }
2646        return $users > 1;
2647    }
2648
2649    /**
2650     * Whether cross-origin requests are allowed.
2651     *
2652     * @return bool
2653     */
2654    public function allows_cross_origin_requests() {
2655        return 'GET' === $this->method || $this->allow_cross_origin_request;
2656    }
2657
2658    /**
2659     * Whether unauthorized requests are allowed.
2660     *
2661     * @param string   $origin Origin.
2662     * @param string[] $complete_access_origins Access origins.
2663     * @return bool
2664     */
2665    public function allows_unauthorized_requests( $origin, $complete_access_origins ) {
2666        return 'GET' === $this->method || ( $this->allow_unauthorized_request && in_array( $origin, $complete_access_origins, true ) );
2667    }
2668
2669    /**
2670     * Whether this endpoint accepts site based authentication for the current request.
2671     *
2672     * @since 9.1.0
2673     *
2674     * @return bool true, if Jetpack blog token is used and `allow_jetpack_site_auth` is true,
2675     * false otherwise.
2676     */
2677    public function accepts_site_based_authentication() {
2678        return $this->allow_jetpack_site_auth &&
2679            $this->api->is_jetpack_authorized_for_site();
2680    }
2681
2682    /**
2683     * Get platform.
2684     *
2685     * @return WPORG_Platform
2686     */
2687    public function get_platform() {
2688        return wpcom_get_sal_platform( $this->api->token_details );
2689    }
2690
2691    /**
2692     * Allows the endpoint to perform logic to allow it to decide whether-or-not it should force a
2693     * response from the WPCOM API, or potentially go to the Jetpack blog.
2694     *
2695     * Override this method if you want to do something different.
2696     *
2697     * @param int $blog_id Blog ID.
2698     * @return bool
2699     */
2700    public function force_wpcom_request( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
2701        return false;
2702    }
2703
2704    /**
2705     * Get an array of all valid AMP origins for a blog's siteurl.
2706     *
2707     * @param string $siteurl Origin url of the API request.
2708     * @return array
2709     */
2710    public function get_amp_cache_origins( $siteurl ) {
2711        $host = wp_parse_url( $siteurl, PHP_URL_HOST );
2712
2713        /*
2714         * From AMP docs:
2715         * "When possible, the Google AMP Cache will create a subdomain for each AMP document's domain by first converting it
2716         * from IDN (punycode) to UTF-8. The caches replaces every - (dash) with -- (2 dashes) and replace every . (dot) with
2717         * - (dash). For example, pub.com will map to pub-com.cdn.ampproject.org."
2718         */
2719        if ( function_exists( 'idn_to_utf8' ) ) {
2720            // The third parameter is set explicitly to prevent issues with newer PHP versions compiled with an old ICU version.
2721            $variant = defined( 'INTL_IDNA_VARIANT_UTS46' )
2722                ? INTL_IDNA_VARIANT_UTS46
2723                // phpcs:ignore PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003Deprecated, PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003DeprecatedRemoved
2724                : INTL_IDNA_VARIANT_2003; // @phan-suppress-current-line PhanUndeclaredConstant
2725            $host = idn_to_utf8( $host, IDNA_DEFAULT, $variant );
2726        }
2727        $subdomain = str_replace( array( '-', '.' ), array( '--', '-' ), $host );
2728        return array(
2729            $siteurl,
2730            // Google AMP Cache (legacy).
2731            'https://cdn.ampproject.org',
2732            // Google AMP Cache subdomain.
2733            sprintf( 'https://%s.cdn.ampproject.org', $subdomain ),
2734            // Cloudflare AMP Cache.
2735            sprintf( 'https://%s.amp.cloudflare.com', $subdomain ),
2736            // Bing AMP Cache.
2737            sprintf( 'https://%s.bing-amp.com', $subdomain ),
2738        );
2739    }
2740
2741    /**
2742     * Register a REST route for this jsonAPI endpoint.
2743     *
2744     * @return void
2745     * @throws Exception The exception if something goes wrong.
2746     */
2747    public function create_rest_route_for_endpoint() {
2748        register_rest_route(
2749            static::REST_NAMESPACE,
2750            $this->build_rest_route_regex(),
2751            array(
2752                'methods'             => $this->method,
2753                'callback'            => array( $this, 'rest_callback' ),
2754                'permission_callback' => array( $this, 'rest_permission_callback' ),
2755            )
2756        );
2757    }
2758
2759    /**
2760     * Handle the rest call.
2761     *
2762     * @param WP_REST_Request $request The request object.
2763     *
2764     * @return mixed|WP_Error
2765     */
2766    public function rest_callback( WP_REST_Request $request ) {
2767        // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed -- Making sure random warnings don't break JSON.
2768        ini_set( 'display_errors', false );
2769
2770        $blog_id = Jetpack_Options::get_option( 'id' );
2771
2772        add_filter( 'user_can_richedit', '__return_true' );
2773        add_filter( 'comment_edit_pre', array( $this->api, 'comment_edit_pre' ) );
2774
2775        $this->api->initialize();
2776        $this->api->endpoint = $this;
2777
2778        $this->api->path    = $this->path;
2779        $this->api->version = $this->max_version;
2780
2781        $locale = $request->get_param( 'language' );
2782        if ( $locale ) {
2783            $this->api->init_locale( $locale );
2784        }
2785
2786        if ( $this->in_testing && ! WPCOM_JSON_API__DEBUG ) {
2787            return new WP_Error( 'endpoint_not_available' );
2788        }
2789
2790        $token_data = ( new Manager() )->verify_xml_rpc_signature();
2791        if ( ! $token_data || empty( $token_data['token_key'] ) || ! array_key_exists( 'user_id', $token_data ) ) {
2792            return new WP_Error( 'response_signature_error' );
2793        }
2794
2795        $token = ( new Tokens() )->get_access_token( $token_data['user_id'], $token_data['token_key'] );
2796        if ( is_wp_error( $token ) ) {
2797            return $token;
2798        }
2799        if ( ! $token ) {
2800            return new WP_Error( 'response_signature_error' );
2801        }
2802
2803        /** This action is documented in class.json-api.php */
2804        do_action( 'wpcom_json_api_output', $this->stat );
2805
2806        $response = call_user_func_array(
2807            array( $this, 'callback' ),
2808            array_values( array( $this->path, $blog_id ) + $request->get_url_params() )
2809        );
2810
2811        if ( ! $response && ! is_array( $response ) ) {
2812            // Dealing with empty non-array response.
2813            $response = new WP_Error( 'empty_response', 'Endpoint response is empty', 500 );
2814        }
2815
2816        // Mirror the XML-RPC path, which runs filter_fields() in WPCOM_JSON_API::output() before
2817        // returning, so a `fields` request yields the same keys on both transports. Endpoints may
2818        // force-add keys past `fields` for internal processors (e.g. the post type/status/password);
2819        // without this they would leak on the REST transport only.
2820        if ( ! is_wp_error( $response ) ) {
2821            $response = $this->api->filter_fields( $response );
2822        }
2823
2824        $status_code = 200;
2825
2826        if ( is_wp_error( $response ) ) {
2827            $status_code = 500;
2828
2829            if ( $response->get_error_data() && is_scalar( $response->get_error_data() )
2830                && (string) (int) $response->get_error_data() === (string) $response->get_error_data()
2831            ) {
2832                $status_code = (int) $response->get_error_data();
2833            }
2834
2835            $response = WPCOM_JSON_API::serializable_error( $response );
2836        }
2837
2838        if ( $request->get_param( 'http_envelope' ) ) {
2839            $response = WPCOM_JSON_API::wrap_http_envelope( $status_code, $response, 'application/json' );
2840        }
2841
2842        $response = wp_json_encode( $response, JSON_UNESCAPED_SLASHES );
2843
2844        $nonce = wp_generate_password( 10, false );
2845        $hmac  = hash_hmac( 'sha1', $nonce . $response, $token->secret );
2846
2847        return array(
2848            $response,
2849            (string) $nonce,
2850            $hmac,
2851        );
2852    }
2853
2854    /**
2855     * The REST endpoint should only be available for requests signed with a valid blog or user token.
2856     * Declaring it "final" so individual endpoints couldn't remove this requirement.
2857     *
2858     * If you need to add custom permissions to individual endpoints, you can override method `rest_permission_callback_custom()`.
2859     *
2860     * @see self::rest_permission_callback_custom()
2861     *
2862     * @return true|WP_Error
2863     */
2864    final public function rest_permission_callback() {
2865        $manager = new Manager( 'jetpack' );
2866        if ( ! $manager->is_connected() ) {
2867            return new WP_Error( 'site_not_connected' );
2868        }
2869
2870        if ( ( ( $this->allow_jetpack_site_auth || $this->allow_fallback_to_jetpack_blog_token ) && Rest_Authentication::is_signed_with_blog_token() )
2871            || ( get_current_user_id() && Rest_Authentication::is_signed_with_user_token() )
2872        ) {
2873            $custom_permission_result = $this->rest_permission_callback_custom();
2874
2875            // Successful custom permission check.
2876            if ( $custom_permission_result === true ) {
2877                return true;
2878            }
2879
2880            // Custom permission check errored, returning the error.
2881            if ( is_wp_error( $custom_permission_result ) ) {
2882                return $custom_permission_result;
2883            }
2884
2885            // Custom permission check failed, but didn't return a specific error. Proceed to returning the generic error.
2886        }
2887
2888        $message = esc_html__(
2889            'You do not have the correct user permissions to perform this action. Please contact your site admin if you think this is a mistake.',
2890            'jetpack'
2891        );
2892        return new WP_Error( 'rest_api_invalid_permission', $message, array( 'status' => rest_authorization_required_code() ) );
2893    }
2894
2895    /**
2896     * You can override this method in individual endpoints to add custom permission checks.
2897     * This will run on top of `rest_permission_callback()`.
2898     *
2899     * @see self::rest_permission_callback()
2900     *
2901     * @return true|WP_Error
2902     */
2903    public function rest_permission_callback_custom() {
2904        return true;
2905    }
2906
2907    /**
2908     * Build the REST endpoint URL.
2909     *
2910     * @return string
2911     */
2912    public function build_rest_route() {
2913        $version_prefix = $this->max_version ? 'v' . $this->max_version : '';
2914        return $version_prefix . $this->rest_route;
2915    }
2916
2917    /**
2918     * Whether the endpoint's rest_route carries %d/%s path-parameter tokens.
2919     *
2920     * @return bool
2921     */
2922    private function rest_route_has_tokens() {
2923        return str_contains( (string) $this->rest_route, '%' );
2924    }
2925
2926    /**
2927     * REST route with %d/%s path tokens converted to named captures, for register_rest_route().
2928     * Static (token-less) routes are returned unchanged.
2929     *
2930     * @return string
2931     */
2932    public function build_rest_route_regex() {
2933        if ( ! $this->rest_route_has_tokens() ) {
2934            return $this->build_rest_route();
2935        }
2936
2937        $index = 0;
2938        return preg_replace_callback(
2939            '/%[sd]/',
2940            function ( $matches ) use ( &$index ) {
2941                $name = 'p' . ( ++$index );
2942                return '%d' === $matches[0] ? "(?P<$name>\\d+)" : "(?P<$name>[^/]+)";
2943            },
2944            $this->build_rest_route()
2945        );
2946    }
2947
2948    /**
2949     * Concrete REST route for a single request: the real path-parameter values (from the request URL,
2950     * minus the leading site segment) substituted into the tokenized rest_route. Static routes are
2951     * returned unchanged. Used by the proxy transport.
2952     *
2953     * @param string $url Full request URL.
2954     * @return string
2955     */
2956    public function build_concrete_rest_route( $url ) {
2957        if ( ! $this->rest_route_has_tokens() ) {
2958            return $this->build_rest_route();
2959        }
2960
2961        // The request path minus its "/rest/vX.Y/sites/<site>" prefix already IS the concrete route
2962        // tail. The proxy matched this request to the endpoint's path template first, so the tail is
2963        // guaranteed to fit the pattern build_rest_route_regex() registered on the remote.
2964        $path = (string) wp_parse_url( $url, PHP_URL_PATH );
2965        $path = preg_replace( '#^/rest/v[\d.]+/sites/[^/]+#', '', $path );
2966
2967        return 'v' . $this->max_version . $path;
2968    }
2969
2970    /**
2971     * Get Jetpack Version where support for the endpoint was introduced.
2972     *
2973     * @return string
2974     */
2975    public function get_rest_min_jp_version() {
2976        return $this->rest_min_jp_version;
2977    }
2978
2979    /**
2980     * Return endpoint response
2981     *
2982     * @param string $path ... determined by ->$path.
2983     *
2984     * @return array|WP_Error
2985     *  falsy: HTTP 500, no response body
2986     *  WP_Error( $error_code, $error_message, $http_status_code ): HTTP $status_code, json_encode( array( 'error' => $error_code, 'message' => $error_message ) ) response body
2987     *  $data: HTTP 200, json_encode( $data ) response body
2988     */
2989    abstract public function callback( $path = '' );
2990}
2991
2992require_once __DIR__ . '/json-endpoints.php';