Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.05% covered (danger)
9.05%
43 / 475
12.20% covered (danger)
12.20%
5 / 41
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_JSON_API
9.11% covered (danger)
9.11%
43 / 472
12.20% covered (danger)
12.20%
5 / 41
31758.94
0.00% covered (danger)
0.00%
0 / 1
 init
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 add
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 is_truthy
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
16.73
 is_falsy
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
27.67
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setup_inputs
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
380
 initialize
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 is_jetpack_authorized_for_site
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
6.73
 is_authorized_with_upload_token
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 serve
0.00% covered (danger)
0.00%
0 / 102
0.00% covered (danger)
0.00%
0 / 1
1640
 process_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 output_early
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 set_output_status_code
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 output
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
342
 wrap_http_envelope
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 serializable_error
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 output_error
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 filter_fields
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
380
 ensure_http_scheme_of_home_url
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 comment_edit_pre
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 json_encode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 ends_with
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_blog_id_for_output
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_blog_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 switch_to_blog_and_validate_user
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 maybe_switch_to_token_user_and_site
18.18% covered (danger)
18.18%
2 / 11
0.00% covered (danger)
0.00%
0 / 1
33.84
 is_restricted_blog
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 post_like_count
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_liked
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_reblogged
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_following
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 add_global_ID
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 comment_like_count
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_avatar_url
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 wp_count_comments
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
110
 trap_wp_die
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
132
 wp_die_handler_callback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 wp_die_handler
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 output_trapped_error
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 finish_request
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 init_locale
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * Jetpack JSON API.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Status;
9
10if ( ! defined( 'WPCOM_JSON_API__DEBUG' ) ) {
11    define( 'WPCOM_JSON_API__DEBUG', false );
12}
13
14require_once __DIR__ . '/sal/class.json-api-platform.php';
15
16/**
17 * Jetpack JSON API.
18 */
19class WPCOM_JSON_API {
20    /**
21     * Static instance.
22     *
23     * @todo This should be private.
24     * @var self|null
25     */
26    public static $self = null;
27
28    /**
29     * Registered endpoints.
30     *
31     * @var WPCOM_JSON_API_Endpoint[]
32     */
33    public $endpoints = array();
34
35    /**
36     * Endpoint being processed.
37     *
38     * @var WPCOM_JSON_API_Endpoint
39     */
40    public $endpoint = null;
41
42    /**
43     * Token details.
44     *
45     * @var array
46     */
47    public $token_details = array();
48
49    /**
50     * Request HTTP method.
51     *
52     * @var string
53     */
54    public $method = '';
55
56    /**
57     * Request URL.
58     *
59     * @var string
60     */
61    public $url = '';
62
63    /**
64     * Path part of the request URL.
65     *
66     * @var string
67     */
68    public $path = '';
69
70    /**
71     * Version extracted from the request URL.
72     *
73     * @var string|null
74     */
75    public $version = null;
76
77    /**
78     * Parsed query data.
79     *
80     * @var array
81     */
82    public $query = array();
83
84    /**
85     * Post body, if the request is a POST.
86     *
87     * @var string|null
88     */
89    public $post_body = null;
90
91    /**
92     * Copy of `$_FILES` if the request is a POST.
93     *
94     * @var null|array
95     */
96    public $files = null;
97
98    /**
99     * Content type of the request.
100     *
101     * @var string|null
102     */
103    public $content_type = null;
104
105    /**
106     * Value of `$_SERVER['HTTP_ACCEPT']`, if any
107     *
108     * @var string
109     */
110    public $accept = '';
111
112    /**
113     * Value of `$_SERVER['HTTPS']`, or "--UNset--" if unset.
114     *
115     * @var string
116     */
117    public $_server_https; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
118
119    /**
120     * Whether to exit after serving a response.
121     *
122     * @var bool
123     */
124    public $exit = true;
125
126    /**
127     * Public API scheme.
128     *
129     * @var string
130     */
131    public $public_api_scheme = 'https';
132
133    /**
134     * Output status code.
135     *
136     * @var int
137     */
138    public $output_status_code = 200;
139
140    /**
141     * Trapped error.
142     *
143     * @var null|array
144     */
145    public $trapped_error = null;
146
147    /**
148     * Whether output has been done.
149     *
150     * @var bool
151     */
152    public $did_output = false;
153
154    /**
155     * Extra HTTP headers.
156     *
157     * @var string
158     */
159    public $extra_headers = array();
160
161    /**
162     * AMP source origin.
163     *
164     * @var string
165     */
166    public $amp_source_origin = null;
167
168    /**
169     * Initialize.
170     *
171     * @param string|null $method As for `$this->setup_inputs()`.
172     * @param string|null $url As for `$this->setup_inputs()`.
173     * @param string|null $post_body As for `$this->setup_inputs()`.
174     * @return WPCOM_JSON_API instance
175     */
176    public static function init( $method = null, $url = null, $post_body = null ) {
177        if ( ! self::$self ) {
178            self::$self = new static( $method, $url, $post_body );
179        }
180        return self::$self;
181    }
182
183    /**
184     * Add an endpoint.
185     *
186     * @param WPCOM_JSON_API_Endpoint $endpoint Endpoint to add.
187     */
188    public function add( WPCOM_JSON_API_Endpoint $endpoint ) {
189        // @todo Determine if anything depends on this being serialized rather than e.g. JSON.
190        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Legacy, possibly depended on elsewhere.
191        $path_versions = serialize(
192            array(
193                $endpoint->path,
194                $endpoint->min_version,
195                $endpoint->max_version,
196            )
197        );
198        if ( ! isset( $this->endpoints[ $path_versions ] ) ) {
199            $this->endpoints[ $path_versions ] = array();
200        }
201        $this->endpoints[ $path_versions ][ $endpoint->method ] = $endpoint;
202    }
203
204    /**
205     * Determine if a string is truthy. If it's not a string, which can happen with
206     * not well-formed data coming from Jetpack sites, we still consider it a truthy value.
207     *
208     * @param mixed $value true, 1, "1", "t", and "true" (case insensitive) are truthy, everything else isn't.
209     * @return bool
210     */
211    public static function is_truthy( $value ) {
212        if ( true === $value ) {
213            return true;
214        }
215
216        if ( 1 === $value ) {
217            return true;
218        }
219
220        if ( ! is_string( $value ) ) {
221            return false;
222        }
223
224        switch ( strtolower( $value ) ) {
225            case '1':
226            case 't':
227            case 'true':
228                return true;
229        }
230
231        return false;
232    }
233
234    /**
235     * Determine if a string is falsey.
236     *
237     * @param mixed $value false, 0, "0", "f", and "false" (case insensitive) are falsey, everything else isn't.
238     * @return bool
239     */
240    public static function is_falsy( $value ) {
241        if ( false === $value ) {
242            return true;
243        }
244
245        if ( 0 === $value ) {
246            return true;
247        }
248
249        if ( ! is_string( $value ) ) {
250            return false;
251        }
252
253        switch ( strtolower( $value ) ) {
254            case '0':
255            case 'f':
256            case 'false':
257                return true;
258        }
259
260        return false;
261    }
262
263    /**
264     * Constructor.
265     *
266     * @todo This should be private.
267     * @param string|null $method As for `$this->setup_inputs()`.
268     * @param string|null $url As for `$this->setup_inputs()`.
269     * @param string|null $post_body As for `$this->setup_inputs()`.
270     */
271    public function __construct( $method = null, $url = null, $post_body = null ) {
272        $this->setup_inputs( $method, $url, $post_body );
273    }
274
275    /**
276     * Setup inputs.
277     *
278     * @param string|null $method Request HTTP method. Fetched from `$_SERVER` if null.
279     * @param string|null $url URL requested. Determined from `$_SERVER` if null.
280     * @param string|null $post_body POST body. Read from `php://input` if null and method is POST.
281     */
282    public function setup_inputs( $method = null, $url = null, $post_body = null ) {
283        if ( $method === null ) {
284            $this->method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( filter_var( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
285        } else {
286            $this->method = strtoupper( $method );
287        }
288        if ( $url === null ) {
289            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sniff misses the esc_url_raw.
290            $this->url = esc_url_raw( set_url_scheme( 'http://' . ( isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : '' ) . ( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '' ) ) );
291        } else {
292            $this->url = $url;
293        }
294
295        $parsed = wp_parse_url( $this->url );
296        if ( ! empty( $parsed['path'] ) ) {
297            $this->path = $parsed['path'];
298        }
299
300        if ( ! empty( $parsed['query'] ) ) {
301            wp_parse_str( $parsed['query'], $this->query );
302        }
303
304        if ( ! empty( $_SERVER['HTTP_ACCEPT'] ) ) {
305            $this->accept = filter_var( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
306        }
307
308        if ( 'POST' === $this->method ) {
309            if ( $post_body === null ) {
310                $this->post_body = file_get_contents( 'php://input' );
311
312                if ( ! empty( $_SERVER['HTTP_CONTENT_TYPE'] ) ) {
313                    $this->content_type = filter_var( wp_unslash( $_SERVER['HTTP_CONTENT_TYPE'] ) );
314                } elseif ( ! empty( $_SERVER['CONTENT_TYPE'] ) ) {
315                    $this->content_type = filter_var( wp_unslash( $_SERVER['CONTENT_TYPE'] ) );
316                } elseif ( isset( $this->post_body[0] ) && '{' === $this->post_body[0] ) {
317                    $this->content_type = 'application/json';
318                } else {
319                    $this->content_type = 'application/x-www-form-urlencoded';
320                }
321
322                if ( str_starts_with( strtolower( $this->content_type ), 'multipart/' ) ) {
323                    // phpcs:ignore WordPress.Security.NonceVerification.Missing
324                    $this->post_body    = http_build_query( stripslashes_deep( $_POST ) );
325                    $this->files        = $_FILES;
326                    $this->content_type = 'multipart/form-data';
327                }
328            } else {
329                $this->post_body    = $post_body;
330                $this->content_type = isset( $this->post_body[0] ) && '{' === $this->post_body[0] ? 'application/json' : 'application/x-www-form-urlencoded';
331            }
332        } else {
333            $this->post_body    = null;
334            $this->content_type = null;
335        }
336
337        $this->_server_https = array_key_exists( 'HTTPS', $_SERVER ) ? filter_var( wp_unslash( $_SERVER['HTTPS'] ) ) : '--UNset--';
338    }
339
340    /**
341     * Initialize.
342     *
343     * @return null|WP_Error (although this implementation always returns null)
344     */
345    public function initialize() {
346        $this->token_details['blog_id'] = Jetpack_Options::get_option( 'id' );
347        return null;
348    }
349
350    /**
351     * Checks if the current request is authorized with a blog token.
352     * This method is overridden by a child class in WPCOM.
353     *
354     * @since 9.1.0
355     *
356     * @param  boolean|int $site_id The site id.
357     * @return boolean
358     */
359    public function is_jetpack_authorized_for_site( $site_id = false ) {
360        if ( ! $this->token_details ) {
361            return false;
362        }
363
364        $token_details = (object) $this->token_details;
365
366        $site_in_token = (int) $token_details->blog_id;
367
368        if ( $site_in_token < 1 ) {
369            return false;
370        }
371
372        if ( $site_id && $site_in_token !== (int) $site_id ) {
373            return false;
374        }
375
376        if ( (int) get_current_user_id() !== 0 ) {
377            // If Jetpack blog token is used, no logged-in user should exist.
378            return false;
379        }
380
381        return true;
382    }
383
384    /**
385     * Checks if the current request is authorized with an upload token.
386     * This method is overridden by a child class in WPCOM.
387     *
388     * @since 13.5
389     * @return boolean
390     */
391    public function is_authorized_with_upload_token() {
392        return false;
393    }
394
395    /**
396     * Serve.
397     *
398     * @param bool $exit Whether to exit.
399     * @return string|null Content type (assuming it didn't exit), or null in certain error cases.
400     */
401    public function serve( $exit = true ) {
402        ini_set( 'display_errors', false ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Blacklisted
403
404        $this->exit = (bool) $exit;
405
406        // This was causing problems with Jetpack, but is necessary for wpcom
407        // @see https://github.com/Automattic/jetpack/pull/2603
408        // @see r124548-wpcom .
409        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
410            add_filter( 'home_url', array( $this, 'ensure_http_scheme_of_home_url' ), 10, 3 );
411        }
412
413        add_filter( 'user_can_richedit', '__return_true' );
414
415        add_filter( 'comment_edit_pre', array( $this, 'comment_edit_pre' ) );
416
417        $initialization = $this->initialize();
418        if ( 'OPTIONS' === $this->method ) {
419            /**
420             * Fires before the page output.
421             * Can be used to specify custom header options.
422             *
423             * @module json-api
424             *
425             * @since 3.1.0
426             */
427            do_action( 'wpcom_json_api_options' );
428            return $this->output( 200, '', 'text/plain' );
429        }
430
431        if ( is_wp_error( $initialization ) ) {
432            $this->output_error( $initialization );
433            return;
434        }
435
436        // Normalize path and extract API version.
437        $this->path = untrailingslashit( $this->path );
438        if ( preg_match( '#^/rest/v(\d+(\.\d+)*)#', $this->path, $matches ) ) {
439            $this->path    = substr( $this->path, strlen( $matches[0] ) );
440            $this->version = $matches[1];
441        }
442
443        $allowed_methods = array( 'GET', 'POST' );
444        $four_oh_five    = false;
445
446        $is_help            = preg_match( '#/help/?$#i', $this->path );
447        $matching_endpoints = array();
448
449        if ( $is_help ) {
450            $origin = get_http_origin();
451
452            if ( ! empty( $origin ) && 'GET' === $this->method ) {
453                header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
454            }
455
456            $this->path = substr( rtrim( $this->path, '/' ), 0, -5 );
457            // Show help for all matching endpoints regardless of method.
458            $methods                     = $allowed_methods;
459            $find_all_matching_endpoints = true;
460            // How deep to truncate each endpoint's path to see if it matches this help request.
461            $depth = substr_count( $this->path, '/' ) + 1;
462            if ( false !== stripos( $this->accept, 'javascript' ) || false !== stripos( $this->accept, 'json' ) ) {
463                $help_content_type = 'json';
464            } else {
465                $help_content_type = 'html';
466            }
467        } elseif ( in_array( $this->method, $allowed_methods, true ) ) {
468            // Only serve requested method.
469            $methods                     = array( $this->method );
470            $find_all_matching_endpoints = false;
471        } else {
472            // We don't allow this requested method - find matching endpoints and send 405.
473            $methods                     = $allowed_methods;
474            $find_all_matching_endpoints = true;
475            $four_oh_five                = true;
476        }
477
478        // Find which endpoint to serve.
479        $found       = false;
480        $path_pieces = array();
481        foreach ( $this->endpoints as $endpoint_path_versions => $endpoints_by_method ) {
482            // @todo Determine if anything depends on this being serialized rather than e.g. JSON.
483            // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Legacy, possibly depended on elsewhere.
484            $endpoint_path_versions = unserialize( $endpoint_path_versions );
485            $endpoint_path          = $endpoint_path_versions[0];
486            $endpoint_min_version   = $endpoint_path_versions[1];
487            $endpoint_max_version   = $endpoint_path_versions[2];
488
489            // Make sure max_version is not less than min_version.
490            if ( version_compare( $endpoint_max_version, $endpoint_min_version, '<' ) ) {
491                $endpoint_max_version = $endpoint_min_version;
492            }
493
494            foreach ( $methods as $method ) {
495                if ( ! isset( $endpoints_by_method[ $method ] ) ) {
496                    continue;
497                }
498
499                // Normalize.
500                $endpoint_path = untrailingslashit( $endpoint_path );
501                if ( $is_help ) {
502                    // Truncate path at help depth.
503                    // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $depth is set when $is_help is true.
504                    $endpoint_path = implode( '/', array_slice( explode( '/', $endpoint_path ), 0, $depth ) );
505                }
506
507                // Generate regular expression from sprintf().
508                $endpoint_path_regex = str_replace( array( '%s', '%d' ), array( '([^/?&]+)', '(\d+)' ), $endpoint_path );
509
510                if ( ! preg_match( "#^$endpoint_path_regex\$#", $this->path, $path_pieces ) ) {
511                    // This endpoint does not match the requested path.
512                    continue;
513                }
514
515                if ( version_compare( $this->version, $endpoint_min_version, '<' ) || version_compare( $this->version, $endpoint_max_version, '>' ) ) {
516                    // This endpoint does not match the requested version.
517                    continue;
518                }
519
520                $found = true;
521
522                if ( $find_all_matching_endpoints ) {
523                    $matching_endpoints[] = array( $endpoints_by_method[ $method ], $path_pieces );
524                } else {
525                    // The method parameters are now in $path_pieces.
526                    $endpoint = $endpoints_by_method[ $method ];
527                    break 2;
528                }
529            }
530        }
531
532        if ( ! $found ) {
533            return $this->output( 404, '', 'text/plain' );
534        }
535
536        if ( $four_oh_five ) {
537            $allowed_methods = array();
538            foreach ( $matching_endpoints as $matching_endpoint ) {
539                $allowed_methods[] = $matching_endpoint[0]->method;
540            }
541
542            header( 'Allow: ' . strtoupper( implode( ',', array_unique( $allowed_methods ) ) ) );
543            return $this->output(
544                405,
545                array(
546                    'error'         => 'not_allowed',
547                    'error_message' => 'Method not allowed',
548                )
549            );
550        }
551
552        if ( $is_help ) {
553            /**
554             * Fires before the API output.
555             *
556             * @since 1.9.0
557             *
558             * @param string help.
559             */
560            do_action( 'wpcom_json_api_output', 'help' );
561            $proxied = function_exists( 'wpcom_is_proxied_request' ) ? wpcom_is_proxied_request() : false;
562            // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $help_content_type is set when $is_help is true.
563            if ( 'json' === $help_content_type ) {
564                $docs = array();
565                foreach ( $matching_endpoints as $matching_endpoint ) {
566                    if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
567                        $docs[] = call_user_func( array( $matching_endpoint[0], 'generate_documentation' ) );
568                    }
569                }
570                return $this->output( 200, $docs );
571            } else {
572                status_header( 200 );
573                foreach ( $matching_endpoints as $matching_endpoint ) {
574                    if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
575                        call_user_func( array( $matching_endpoint[0], 'document' ) );
576                    }
577                }
578            }
579            exit( 0 );
580        }
581
582        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $endpoint is set when $find_all_matching_endpoints is false and $found is true, which is guaranteed here.
583        if ( $endpoint->in_testing && ! WPCOM_JSON_API__DEBUG ) {
584            return $this->output( 404, '', 'text/plain' );
585        }
586
587        /** This action is documented in class.json-api.php */
588        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $endpoint is set when $find_all_matching_endpoints is false and $found is true, which is guaranteed here.
589        do_action( 'wpcom_json_api_output', $endpoint->stat );
590
591        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $endpoint is set when $find_all_matching_endpoints is false and $found is true, which is guaranteed here.
592        $response = $this->process_request( $endpoint, $path_pieces );
593
594        if ( ! $response && ! is_array( $response ) ) {
595            return $this->output( 500, '', 'text/plain' );
596        } elseif ( is_wp_error( $response ) ) {
597            return $this->output_error( $response );
598        }
599
600        $output_status_code = $this->output_status_code;
601        $this->set_output_status_code();
602
603        return $this->output( $output_status_code, $response, 'application/json', $this->extra_headers );
604    }
605
606    /**
607     * Process a request.
608     *
609     * @param WPCOM_JSON_API_Endpoint $endpoint Endpoint.
610     * @param array                   $path_pieces Path pieces.
611     * @return array|WP_Error Return value from the endpoint's callback.
612     */
613    public function process_request( WPCOM_JSON_API_Endpoint $endpoint, $path_pieces ) {
614        $this->endpoint = $endpoint;
615        $this->maybe_switch_to_token_user_and_site();
616        return call_user_func_array( array( $endpoint, 'callback' ), $path_pieces );
617    }
618
619    /**
620     * Output a response or error without exiting.
621     *
622     * @param int    $status_code HTTP status code.
623     * @param mixed  $response Response data.
624     * @param string $content_type Content type of the response.
625     */
626    public function output_early( $status_code, $response = null, $content_type = 'application/json' ) {
627        $exit       = $this->exit;
628        $this->exit = false;
629        if ( is_wp_error( $response ) ) {
630            $this->output_error( $response );
631        } else {
632            $this->output( $status_code, $response, $content_type );
633        }
634        $this->exit = $exit;
635        if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
636            $this->finish_request();
637        }
638    }
639
640    /**
641     * Set output status code.
642     *
643     * @param int $code HTTP status code.
644     */
645    public function set_output_status_code( $code = 200 ) {
646        $this->output_status_code = $code;
647    }
648
649    /**
650     * Output a response.
651     *
652     * @param int    $status_code HTTP status code.
653     * @param mixed  $response Response data.
654     * @param string $content_type Content type of the response.
655     * @param array  $extra Additional HTTP headers.
656     * @return string Content type (assuming it didn't exit).
657     */
658    public function output( $status_code, $response = null, $content_type = 'application/json', $extra = array() ) {
659        $status_code = (int) $status_code;
660
661        // In case output() was called before the callback returned.
662        if ( $this->did_output ) {
663            if ( $this->exit ) {
664                exit( 0 );
665            }
666            return $content_type;
667        }
668        $this->did_output = true;
669
670        // 400s and 404s are allowed for all origins
671        if ( 404 === $status_code || 400 === $status_code ) {
672            header( 'Access-Control-Allow-Origin: *' );
673        }
674
675        /* Add headers for form submission from <amp-form/> */
676        if ( $this->amp_source_origin ) {
677            header( 'Access-Control-Allow-Origin: ' . wp_unslash( $this->amp_source_origin ) );
678            header( 'Access-Control-Allow-Credentials: true' );
679        }
680
681        if ( $response === null ) {
682            $response = new stdClass();
683        }
684
685        if ( 'text/plain' === $content_type ||
686            'text/html' === $content_type ) {
687            status_header( $status_code );
688            header( 'Content-Type: ' . $content_type );
689            foreach ( $extra as $key => $value ) {
690                header( "$key$value" );
691            }
692            echo $response; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
693            if ( $this->exit ) {
694                exit( 0 );
695            }
696
697            return $content_type;
698        }
699
700        $response = $this->filter_fields( $response );
701
702        if ( isset( $this->query['http_envelope'] ) && self::is_truthy( $this->query['http_envelope'] ) ) {
703            $response = static::wrap_http_envelope( $status_code, $response, $content_type, $extra );
704
705            $status_code  = 200;
706            $content_type = 'application/json';
707        }
708
709        status_header( $status_code );
710        header( "Content-Type: $content_type" );
711        if ( isset( $this->query['callback'] ) && is_string( $this->query['callback'] ) ) {
712            $callback = preg_replace( '/[^a-z0-9_.]/i', '', $this->query['callback'] );
713        } else {
714            $callback = false;
715        }
716
717        if ( $callback ) {
718            // Mitigate Rosetta Flash [1] by setting the Content-Type-Options: nosniff header
719            // and by prepending the JSONP response with a JS comment.
720            // [1] <https://blog.miki.it/2014/7/8/abusing-jsonp-with-rosetta-flash/index.html>.
721            echo "/**/$callback("; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- This is JSONP output, not HTML.
722
723        }
724        echo $this->json_encode( $response, JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- This is JSON or JSONP output, not HTML.
725        if ( $callback ) {
726            echo ');';
727        }
728
729        if ( $this->exit ) {
730            exit( 0 );
731        }
732
733        return $content_type;
734    }
735
736    /**
737     * Wrap JSON API response into an HTTP 200 one.
738     *
739     * @param int        $status_code HTTP status code.
740     * @param mixed      $response Response body.
741     * @param string     $content_type Content type.
742     * @param array|null $extra Extra data.
743     *
744     * @return array
745     */
746    public static function wrap_http_envelope( $status_code, $response, $content_type, $extra = null ) {
747        $headers = array(
748            array(
749                'name'  => 'Content-Type',
750                'value' => $content_type,
751            ),
752        );
753
754        if ( is_array( $extra ) ) {
755            foreach ( $extra as $key => $value ) {
756                $headers[] = array(
757                    'name'  => $key,
758                    'value' => $value,
759                );
760            }
761        }
762
763        return array(
764            'code'    => (int) $status_code,
765            'headers' => $headers,
766            'body'    => $response,
767        );
768    }
769
770    /**
771     * Serialize an error.
772     *
773     * @param WP_Error $error Error.
774     * @return array with 'status_code' and 'errors' data.
775     */
776    public static function serializable_error( $error ) {
777
778        $status_code = $error->get_error_data();
779
780        if ( is_array( $status_code ) && isset( $status_code['status_code'] ) ) {
781            $status_code = $status_code['status_code'];
782        }
783
784        if ( ! $status_code ) {
785            $status_code = 400;
786        }
787        $response = array(
788            'error'   => $error->get_error_code(),
789            'message' => $error->get_error_message(),
790        );
791
792        $additional_data = $error->get_error_data( 'additional_data' );
793        if ( $additional_data ) {
794            $response['data'] = $additional_data;
795        }
796
797        return array(
798            'status_code' => $status_code,
799            'errors'      => $response,
800        );
801    }
802
803    /**
804     * Output an error.
805     *
806     * @param WP_Error $error Error.
807     * @return string Content type (assuming it didn't exit).
808     */
809    public function output_error( $error ) {
810        $error_response = static::serializable_error( $error );
811
812        return $this->output( $error_response['status_code'], $error_response['errors'] );
813    }
814
815    /**
816     * Filter fields in a response.
817     *
818     * @param array|object $response Response.
819     * @return array|object Filtered response.
820     */
821    public function filter_fields( $response ) {
822        if ( empty( $this->query['fields'] ) || ( is_array( $response ) && ! empty( $response['error'] ) ) || ! empty( $this->endpoint->custom_fields_filtering ) ) {
823            return $response;
824        }
825
826        $fields = array_map( 'trim', explode( ',', $this->query['fields'] ) );
827
828        if ( is_object( $response ) ) {
829            $response = (array) $response;
830        }
831
832        $has_filtered = false;
833        if ( is_array( $response ) && empty( $response['ID'] ) ) {
834            $keys_to_filter = array(
835                'categories',
836                'comments',
837                'connections',
838                'domains',
839                'groups',
840                'likes',
841                'media',
842                'notes',
843                'posts',
844                'services',
845                'sites',
846                'suggestions',
847                'tags',
848                'themes',
849                'topics',
850                'users',
851            );
852
853            foreach ( $keys_to_filter as $key_to_filter ) {
854                if ( ! isset( $response[ $key_to_filter ] ) || $has_filtered ) {
855                    continue;
856                }
857
858                foreach ( $response[ $key_to_filter ] as $key => $values ) {
859                    if ( is_object( $values ) ) {
860                        if ( is_object( $response[ $key_to_filter ] ) ) {
861                            // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found -- False positive.
862                            $response[ $key_to_filter ]->$key = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
863                        } elseif ( is_array( $response[ $key_to_filter ] ) ) {
864                            $response[ $key_to_filter ][ $key ] = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
865                        }
866                    } elseif ( is_array( $values ) ) {
867                        $response[ $key_to_filter ][ $key ] = array_intersect_key( $values, array_flip( $fields ) );
868                    }
869                }
870
871                $has_filtered = true;
872            }
873        }
874
875        if ( ! $has_filtered ) {
876            if ( is_object( $response ) ) {
877                $response = (object) array_intersect_key( (array) $response, array_flip( $fields ) );
878            } elseif ( is_array( $response ) ) {
879                $response = array_intersect_key( $response, array_flip( $fields ) );
880            }
881        }
882
883        return $response;
884    }
885
886    /**
887     * Filter for `home_url`.
888     *
889     * If `$original_scheme` is null, turns an https URL to http.
890     *
891     * @param string      $url The complete home URL including scheme and path.
892     * @param string      $path Path relative to the home URL. Blank string if no path is specified.
893     * @param string|null $original_scheme Scheme to give the home URL context. Accepts 'http', 'https', 'relative', 'rest', or null.
894     * @return string URL.
895     */
896    public function ensure_http_scheme_of_home_url( $url, $path, $original_scheme ) {
897        if ( $original_scheme ) {
898            return $url;
899        }
900
901        return preg_replace( '#^https:#', 'http:', $url );
902    }
903
904    /**
905     * Decode HTML special characters in comment content.
906     *
907     * @param string $comment_content Comment content.
908     * @return string
909     */
910    public function comment_edit_pre( $comment_content ) {
911        return htmlspecialchars_decode( $comment_content, ENT_QUOTES );
912    }
913
914    /**
915     * JSON encode.
916     *
917     * @param mixed $value   The value to encode.
918     * @param int   $flags   Options to be passed to json_encode(). Default 0.
919     * @param int   $depth   Maximum depth to walk through $value. Must be greater than 0.
920     *
921     * @return string|false
922     */
923    public function json_encode( $value, $flags = 0, $depth = 512 ) {
924        return wp_json_encode( $value, $flags, $depth );
925    }
926
927    /**
928     * Test if a string ends with a string.
929     *
930     * @param string $haystack String to check.
931     * @param string $needle Suffix to check.
932     * @return bool
933     */
934    public function ends_with( $haystack, $needle ) {
935        return substr( $haystack, -strlen( $needle ) ) === $needle;
936    }
937
938    /**
939     * Returns the site's blog_id in the WP.com ecosystem
940     *
941     * @return int
942     */
943    public function get_blog_id_for_output() {
944        return $this->token_details['blog_id'];
945    }
946
947    /**
948     * Returns the site's local blog_id.
949     *
950     * @param int $blog_id Blog ID.
951     * @return int
952     */
953    public function get_blog_id( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
954        return $GLOBALS['blog_id'];
955    }
956
957    /**
958     * Switch to blog and validate user.
959     *
960     * @param int  $blog_id Blog ID.
961     * @param bool $verify_token_for_blog Whether to verify the token.
962     * @return int Blog ID.
963     */
964    public function switch_to_blog_and_validate_user( $blog_id = 0, $verify_token_for_blog = true ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
965        if ( $this->is_restricted_blog( $blog_id ) ) {
966            return new WP_Error( 'unauthorized', 'User cannot access this restricted blog', 403 );
967        }
968        /**
969         * If this is a private site we check for 2 things:
970         * 1. In case of user based authentication, we need to check if the logged-in user has the 'read' capability.
971         * 2. In case of site based authentication, make sure the endpoint accepts it.
972         */
973        if ( ( new Status() )->is_private_site() &&
974            ! current_user_can( 'read' ) &&
975            ! $this->endpoint->accepts_site_based_authentication()
976        ) {
977            return new WP_Error( 'unauthorized', 'User cannot access this private blog.', 403 );
978        }
979
980        return $blog_id;
981    }
982
983    /**
984     * Switch to a user and blog based on the current request's Jetpack token when the endpoint accepts this feature.
985     *
986     * @return void
987     */
988    protected function maybe_switch_to_token_user_and_site() {
989        if ( ! $this->endpoint->allow_jetpack_token_auth ) {
990            return;
991        }
992
993        if ( ! class_exists( 'Jetpack_Server_Version' ) ) {
994            return;
995        }
996
997        $token = Jetpack_Server_Version::get_token_from_authorization_header();
998
999        if ( ! $token || is_wp_error( $token ) ) {
1000            return;
1001        }
1002
1003        if ( get_current_user_id() !== $token->user_id ) {
1004            wp_set_current_user( $token->user_id );
1005        }
1006
1007        if ( get_current_blog_id() !== $token->blog_id ) {
1008            switch_to_blog( $token->blog_id );
1009        }
1010    }
1011
1012    /**
1013     * Returns true if the specified blog ID is a restricted blog
1014     *
1015     * @param int $blog_id Blog ID.
1016     * @return bool
1017     */
1018    public function is_restricted_blog( $blog_id ) {
1019        /**
1020         * Filters all REST API access and return a 403 unauthorized response for all Restricted blog IDs.
1021         *
1022         * @module json-api
1023         *
1024         * @since 3.4.0
1025         *
1026         * @param array $array Array of Blog IDs.
1027         */
1028        $restricted_blog_ids = apply_filters( 'wpcom_json_api_restricted_blog_ids', array() );
1029        return true === in_array( $blog_id, $restricted_blog_ids ); // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict -- I don't trust filters to return the right types.
1030    }
1031
1032    /**
1033     * Post like count.
1034     *
1035     * @param int $blog_id Blog ID.
1036     * @param int $post_id Post ID.
1037     * @return int
1038     */
1039    public function post_like_count( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1040        return 0;
1041    }
1042
1043    /**
1044     * Is liked?
1045     *
1046     * @param int $blog_id Blog ID.
1047     * @param int $post_id Post ID.
1048     * @return bool
1049     */
1050    public function is_liked( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1051        return false;
1052    }
1053
1054    /**
1055     * Is reblogged?
1056     *
1057     * @param int $blog_id Blog ID.
1058     * @param int $post_id Post ID.
1059     * @return bool
1060     */
1061    public function is_reblogged( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1062        return false;
1063    }
1064
1065    /**
1066     * Is following?
1067     *
1068     * @param int $blog_id Blog ID.
1069     * @return bool
1070     */
1071    public function is_following( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1072        return false;
1073    }
1074
1075    /**
1076     * Add global ID.
1077     *
1078     * @param int $blog_id Blog ID.
1079     * @param int $post_id Post ID.
1080     * @return string
1081     */
1082    public function add_global_ID( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
1083        return '';
1084    }
1085
1086    /**
1087     * Return a count of comment likes.
1088     * This method is overridden by a child class in WPCOM.
1089     *
1090     * @since 13.5
1091     * @return int
1092     */
1093    public function comment_like_count() {
1094        func_get_args(); // @phan-suppress-current-line PhanPluginUseReturnValueInternalKnown -- This is just here so Phan realizes the wpcom version does this.
1095        return 0;
1096    }
1097
1098    /**
1099     * Get avatar URL.
1100     *
1101     * @param string $email Email.
1102     * @param array  $args Args for `get_avatar_url()`.
1103     * @return string|false
1104     */
1105    public function get_avatar_url( $email, $args = null ) {
1106        if ( function_exists( 'wpcom_get_avatar_url' ) ) {
1107            $ret = wpcom_get_avatar_url( $email, $args['size'] ?? 96, $args['default'] ?? '', false, $args['force_default'] ?? false );
1108            return $ret ? $ret[0] : false;
1109        } else {
1110            return null === $args
1111                ? get_avatar_url( $email )
1112                : get_avatar_url( $email, $args );
1113        }
1114    }
1115
1116    /**
1117     * Counts the number of comments on a site, including certain comment types.
1118     *
1119     * @param int $post_id Post ID.
1120     * @return object The number of counts keyed by status, matching the output of https://developer.wordpress.org/reference/functions/get_comment_count/.
1121     */
1122    public function wp_count_comments( $post_id ) {
1123        global $wpdb;
1124        if ( 0 !== $post_id ) {
1125            return wp_count_comments( $post_id );
1126        }
1127
1128        $counts = array(
1129            'total_comments' => 0,
1130            'all'            => 0,
1131        );
1132
1133        /**
1134        * Exclude certain comment types from comment counts in the REST API.
1135        *
1136        * @since 6.9.0
1137        * @deprecated 11.1
1138        * @module json-api
1139        *
1140        * @param array Array of comment types to exclude (default: 'order_note', 'webhook_delivery', 'review', 'action_log')
1141        */
1142        $exclude = apply_filters_deprecated( 'jetpack_api_exclude_comment_types_count', array( 'order_note', 'webhook_delivery', 'review', 'action_log' ), 'jetpack-11.1', 'jetpack_api_include_comment_types_count' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1143
1144        /**
1145        * Include certain comment types in comment counts in the REST API.
1146        * Note: the default array of comment types includes an empty string,
1147        * to support comments posted before WP 5.5, that used an empty string as comment type.
1148        *
1149        * @since 11.1
1150        * @module json-api
1151        *
1152        * @param array Array of comment types to include (default: 'comment', 'pingback', 'trackback')
1153        */
1154        $include = apply_filters(
1155            'jetpack_api_include_comment_types_count',
1156            array( 'comment', 'pingback', 'trackback', '' )
1157        );
1158
1159        if ( empty( $include ) ) {
1160            return wp_count_comments( $post_id );
1161        }
1162
1163        // The following caching mechanism is based on what the get_comments() function uses.
1164
1165        $key          = md5( serialize( $include ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
1166        $last_changed = wp_cache_get_last_changed( 'comment' );
1167
1168        $cache_key = "wp_count_comments:$key:$last_changed";
1169        $count     = wp_cache_get( $cache_key, 'jetpack-json-api' );
1170
1171        if ( false === $count ) {
1172            array_walk( $include, 'esc_sql' );
1173            $where = sprintf(
1174                "WHERE comment_type IN ( '%s' )",
1175                implode( "','", $include )
1176            );
1177
1178            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- `$where` is built with escaping just above.
1179            $count = $wpdb->get_results(
1180                "SELECT comment_approved, COUNT(*) AS num_comments
1181                    FROM $wpdb->comments
1182                    {$where}
1183                    GROUP BY comment_approved
1184                "
1185            );
1186            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1187
1188            wp_cache_add( $cache_key, $count, 'jetpack-json-api' );
1189        }
1190
1191        $approved = array(
1192            '0'            => 'moderated',
1193            '1'            => 'approved',
1194            'spam'         => 'spam',
1195            'trash'        => 'trash',
1196            'post-trashed' => 'post-trashed',
1197        );
1198
1199        // <https://developer.wordpress.org/reference/functions/get_comment_count/#source>
1200        foreach ( $count as $row ) {
1201            if ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash', 'spam' ), true ) ) {
1202                $counts['all']            += $row->num_comments;
1203                $counts['total_comments'] += $row->num_comments;
1204            } elseif ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash' ), true ) ) {
1205                $counts['total_comments'] += $row->num_comments;
1206            }
1207            if ( isset( $approved[ $row->comment_approved ] ) ) {
1208                $counts[ $approved[ $row->comment_approved ] ] = $row->num_comments;
1209            }
1210        }
1211
1212        foreach ( $approved as $key ) {
1213            if ( empty( $counts[ $key ] ) ) {
1214                $counts[ $key ] = 0;
1215            }
1216        }
1217
1218        $counts = (object) $counts;
1219
1220        return $counts;
1221    }
1222
1223    /**
1224     * Traps `wp_die()` calls and outputs a JSON response instead.
1225     * The result is always output, never returned.
1226     *
1227     * @param string|null $error_code  Call with string to start the trapping.  Call with null to stop.
1228     * @param int         $http_status  HTTP status code, 400 by default.
1229     */
1230    public function trap_wp_die( $error_code = null, $http_status = 400 ) {
1231        // Determine the filter name; based on the conditionals inside the wp_die function.
1232        if ( wp_is_json_request() ) {
1233            $die_handler = 'wp_die_json_handler';
1234        } elseif ( wp_is_jsonp_request() ) {
1235            $die_handler = 'wp_die_jsonp_handler';
1236        } elseif ( wp_is_xml_request() ) {
1237            $die_handler = 'wp_die_xml_handler';
1238        } else {
1239            $die_handler = 'wp_die_handler';
1240        }
1241
1242        if ( $error_code === null ) {
1243            $this->trapped_error = null;
1244            // Stop trapping.
1245            remove_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1246            return;
1247        }
1248
1249        // If API called via PHP, bail: don't do our custom wp_die().  Do the normal wp_die().
1250        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1251            if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
1252                return;
1253            }
1254        } elseif ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
1255            return;
1256        }
1257
1258        $this->trapped_error = array(
1259            'status'  => $http_status,
1260            'code'    => $error_code,
1261            'message' => '',
1262        );
1263        // Start trapping.
1264        add_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1265    }
1266
1267    /**
1268     * Filter function for `wp_die_handler` and similar filters.
1269     *
1270     * @return callable
1271     */
1272    public function wp_die_handler_callback() {
1273        return array( $this, 'wp_die_handler' );
1274    }
1275
1276    /**
1277     * Handler for `wp_die` calls.
1278     *
1279     * @param string|WP_Error  $message As for `wp_die()`.
1280     * @param string|int       $title As for `wp_die()`.
1281     * @param string|array|int $args As for `wp_die()`.
1282     * @return never
1283     */
1284    public function wp_die_handler( $message, $title = '', $args = array() ) {
1285        // Allow wp_die calls to override HTTP status code...
1286        $args = wp_parse_args(
1287            $args,
1288            array(
1289                'response' => $this->trapped_error['status'],
1290            )
1291        );
1292
1293        // ... unless it's 500
1294        if ( 500 !== (int) $args['response'] ) {
1295            $this->trapped_error['status'] = $args['response'];
1296        }
1297
1298        if ( $title ) {
1299            $message = "$title$message";
1300        }
1301
1302        $this->trapped_error['message'] = wp_kses( $message, array() );
1303
1304        switch ( $this->trapped_error['code'] ) {
1305            case 'comment_failure':
1306                if ( did_action( 'comment_duplicate_trigger' ) ) {
1307                    $this->trapped_error['code'] = 'comment_duplicate';
1308                } elseif ( did_action( 'comment_flood_trigger' ) ) {
1309                    $this->trapped_error['code'] = 'comment_flood';
1310                }
1311                break;
1312        }
1313
1314        // We still want to exit so that code execution stops where it should.
1315        // Attach the JSON output to the WordPress shutdown handler.
1316        add_action( 'shutdown', array( $this, 'output_trapped_error' ), 0 );
1317        exit( 0 );
1318    }
1319
1320    /**
1321     * Output the trapped error.
1322     */
1323    public function output_trapped_error() {
1324        $this->exit = false; // We're already exiting once.  Don't do it twice.
1325        $this->output(
1326            $this->trapped_error['status'],
1327            (object) array(
1328                'error'   => $this->trapped_error['code'],
1329                'message' => $this->trapped_error['message'],
1330            )
1331        );
1332    }
1333
1334    /**
1335     * Finish the request.
1336     */
1337    public function finish_request() {
1338        if ( function_exists( 'fastcgi_finish_request' ) ) {
1339            return fastcgi_finish_request();
1340        }
1341    }
1342
1343    /**
1344     * Initialize the locale if different from 'en'.
1345     *
1346     * @param string $locale The locale to initialize.
1347     */
1348    public function init_locale( $locale ) {
1349        if ( 'en' !== $locale ) {
1350            // .org mo files are named slightly different from .com, and all we have is this the locale -- try to guess them.
1351            $new_locale = $locale;
1352            if ( str_contains( $locale, '-' ) ) {
1353                $locale_pieces = explode( '-', $locale );
1354                $new_locale    = $locale_pieces[0];
1355                $new_locale   .= ( ! empty( $locale_pieces[1] ) ) ? '_' . strtoupper( $locale_pieces[1] ) : '';
1356            } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
1357                // .com might pass 'fr' because thats what our language files are named as, where core seems
1358                // to do fr_FR - so try that if we don't think we can load the file.
1359                if ( ! file_exists( WP_LANG_DIR . '/' . $locale . '.mo' ) ) {
1360                    $new_locale = $locale . '_' . strtoupper( $locale );
1361                }
1362            }
1363
1364            if ( file_exists( WP_LANG_DIR . '/' . $new_locale . '.mo' ) ) {
1365                unload_textdomain( 'default' );
1366                load_textdomain( 'default', WP_LANG_DIR . '/' . $new_locale . '.mo' );
1367            }
1368        }
1369    }
1370}