Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
13.89% covered (danger)
13.89%
66 / 475
17.07% covered (danger)
17.07%
7 / 41
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_JSON_API
13.98% covered (danger)
13.98%
66 / 472
17.07% covered (danger)
17.07%
7 / 41
27213.63
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
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 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
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 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_Disallowed
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        // A missing or non-numeric status resolves to 0 and defaults to 400. Valid HTTP codes, including sub-400 ones, are preserved.
779        $data        = $error->get_error_data();
780        $status_code = ( is_array( $data ) && isset( $data['status_code'] ) ) ? $data['status_code'] : $data;
781        $status_code = is_numeric( $status_code ) ? (int) $status_code : 0;
782
783        if ( ! $status_code ) {
784            $status_code = 400;
785        }
786        $response = array(
787            'error'   => $error->get_error_code(),
788            'message' => $error->get_error_message(),
789        );
790
791        $additional_data = $error->get_error_data( 'additional_data' );
792        if ( $additional_data ) {
793            $response['data'] = $additional_data;
794        }
795
796        return array(
797            'status_code' => $status_code,
798            'errors'      => $response,
799        );
800    }
801
802    /**
803     * Output an error.
804     *
805     * @param WP_Error $error Error.
806     * @return string Content type (assuming it didn't exit).
807     */
808    public function output_error( $error ) {
809        $error_response = static::serializable_error( $error );
810
811        return $this->output( $error_response['status_code'], $error_response['errors'] );
812    }
813
814    /**
815     * Filter fields in a response.
816     *
817     * @param array|object $response Response.
818     * @return array|object Filtered response.
819     */
820    public function filter_fields( $response ) {
821        if ( empty( $this->query['fields'] ) || ( is_array( $response ) && ! empty( $response['error'] ) ) || ! empty( $this->endpoint->custom_fields_filtering ) ) {
822            return $response;
823        }
824
825        $fields = array_map( 'trim', explode( ',', $this->query['fields'] ) );
826
827        if ( is_object( $response ) ) {
828            $response = (array) $response;
829        }
830
831        $has_filtered = false;
832        if ( is_array( $response ) && empty( $response['ID'] ) ) {
833            $keys_to_filter = array(
834                'categories',
835                'comments',
836                'connections',
837                'domains',
838                'groups',
839                'likes',
840                'media',
841                'notes',
842                'posts',
843                'services',
844                'sites',
845                'suggestions',
846                'tags',
847                'themes',
848                'topics',
849                'users',
850            );
851
852            foreach ( $keys_to_filter as $key_to_filter ) {
853                if ( ! isset( $response[ $key_to_filter ] ) || $has_filtered ) {
854                    continue;
855                }
856
857                foreach ( $response[ $key_to_filter ] as $key => $values ) {
858                    if ( is_object( $values ) ) {
859                        if ( is_object( $response[ $key_to_filter ] ) ) {
860                            $response[ $key_to_filter ]->$key = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
861                        } elseif ( is_array( $response[ $key_to_filter ] ) ) {
862                            $response[ $key_to_filter ][ $key ] = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
863                        }
864                    } elseif ( is_array( $values ) ) {
865                        $response[ $key_to_filter ][ $key ] = array_intersect_key( $values, array_flip( $fields ) );
866                    }
867                }
868
869                $has_filtered = true;
870            }
871        }
872
873        if ( ! $has_filtered ) {
874            if ( is_object( $response ) ) {
875                $response = (object) array_intersect_key( (array) $response, array_flip( $fields ) );
876            } elseif ( is_array( $response ) ) {
877                $response = array_intersect_key( $response, array_flip( $fields ) );
878            }
879        }
880
881        return $response;
882    }
883
884    /**
885     * Filter for `home_url`.
886     *
887     * If `$original_scheme` is null, turns an https URL to http.
888     *
889     * @param string      $url The complete home URL including scheme and path.
890     * @param string      $path Path relative to the home URL. Blank string if no path is specified.
891     * @param string|null $original_scheme Scheme to give the home URL context. Accepts 'http', 'https', 'relative', 'rest', or null.
892     * @return string URL.
893     */
894    public function ensure_http_scheme_of_home_url( $url, $path, $original_scheme ) {
895        if ( $original_scheme ) {
896            return $url;
897        }
898
899        return preg_replace( '#^https:#', 'http:', $url );
900    }
901
902    /**
903     * Decode HTML special characters in comment content.
904     *
905     * @param string $comment_content Comment content.
906     * @return string
907     */
908    public function comment_edit_pre( $comment_content ) {
909        return htmlspecialchars_decode( $comment_content, ENT_QUOTES );
910    }
911
912    /**
913     * JSON encode.
914     *
915     * @param mixed $value   The value to encode.
916     * @param int   $flags   Options to be passed to json_encode(). Default 0.
917     * @param int   $depth   Maximum depth to walk through $value. Must be greater than 0.
918     *
919     * @return string|false
920     */
921    public function json_encode( $value, $flags = 0, $depth = 512 ) {
922        return wp_json_encode( $value, $flags, $depth );
923    }
924
925    /**
926     * Test if a string ends with a string.
927     *
928     * @param string $haystack String to check.
929     * @param string $needle Suffix to check.
930     * @return bool
931     */
932    public function ends_with( $haystack, $needle ) {
933        return substr( $haystack, -strlen( $needle ) ) === $needle;
934    }
935
936    /**
937     * Returns the site's blog_id in the WP.com ecosystem
938     *
939     * @return int
940     */
941    public function get_blog_id_for_output() {
942        return $this->token_details['blog_id'];
943    }
944
945    /**
946     * Returns the site's local blog_id.
947     *
948     * @param int $blog_id Blog ID.
949     * @return int
950     */
951    public function get_blog_id( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
952        return $GLOBALS['blog_id'];
953    }
954
955    /**
956     * Switch to blog and validate user.
957     *
958     * @param int  $blog_id Blog ID.
959     * @param bool $verify_token_for_blog Whether to verify the token.
960     * @return int Blog ID.
961     */
962    public function switch_to_blog_and_validate_user( $blog_id = 0, $verify_token_for_blog = true ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
963        if ( $this->is_restricted_blog( $blog_id ) ) {
964            return new WP_Error( 'unauthorized', 'User cannot access this restricted blog', 403 );
965        }
966        /**
967         * If this is a private site we check for 2 things:
968         * 1. In case of user based authentication, we need to check if the logged-in user has the 'read' capability.
969         * 2. In case of site based authentication, make sure the endpoint accepts it.
970         */
971        if ( ( new Status() )->is_private_site() &&
972            ! current_user_can( 'read' ) &&
973            ! $this->endpoint->accepts_site_based_authentication()
974        ) {
975            return new WP_Error( 'unauthorized', 'User cannot access this private blog.', 403 );
976        }
977
978        return $blog_id;
979    }
980
981    /**
982     * Switch to a user and blog based on the current request's Jetpack token when the endpoint accepts this feature.
983     *
984     * @return void
985     */
986    protected function maybe_switch_to_token_user_and_site() {
987        if ( ! $this->endpoint->allow_jetpack_token_auth ) {
988            return;
989        }
990
991        if ( ! class_exists( 'Jetpack_Server_Version' ) ) {
992            return;
993        }
994
995        $token = Jetpack_Server_Version::get_token_from_authorization_header();
996
997        if ( ! $token || is_wp_error( $token ) ) {
998            return;
999        }
1000
1001        if ( get_current_user_id() !== $token->user_id ) {
1002            wp_set_current_user( $token->user_id );
1003        }
1004
1005        if ( get_current_blog_id() !== $token->blog_id ) {
1006            switch_to_blog( $token->blog_id );
1007        }
1008    }
1009
1010    /**
1011     * Returns true if the specified blog ID is a restricted blog
1012     *
1013     * @param int $blog_id Blog ID.
1014     * @return bool
1015     */
1016    public function is_restricted_blog( $blog_id ) {
1017        /**
1018         * Filters all REST API access and return a 403 unauthorized response for all Restricted blog IDs.
1019         *
1020         * @module json-api
1021         *
1022         * @since 3.4.0
1023         *
1024         * @param array $array Array of Blog IDs.
1025         */
1026        $restricted_blog_ids = apply_filters( 'wpcom_json_api_restricted_blog_ids', array() );
1027        return in_array( $blog_id, $restricted_blog_ids ); // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict -- I don't trust filters to return the right types.
1028    }
1029
1030    /**
1031     * Post like count.
1032     *
1033     * @param int $blog_id Blog ID.
1034     * @param int $post_id Post ID.
1035     * @return int
1036     */
1037    public function post_like_count( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1038        return 0;
1039    }
1040
1041    /**
1042     * Is liked?
1043     *
1044     * @param int $blog_id Blog ID.
1045     * @param int $post_id Post ID.
1046     * @return bool
1047     */
1048    public function is_liked( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1049        return false;
1050    }
1051
1052    /**
1053     * Is reblogged?
1054     *
1055     * @param int $blog_id Blog ID.
1056     * @param int $post_id Post ID.
1057     * @return bool
1058     */
1059    public function is_reblogged( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1060        return false;
1061    }
1062
1063    /**
1064     * Is following?
1065     *
1066     * @param int $blog_id Blog ID.
1067     * @return bool
1068     */
1069    public function is_following( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1070        return false;
1071    }
1072
1073    /**
1074     * Add global ID.
1075     *
1076     * @param int $blog_id Blog ID.
1077     * @param int $post_id Post ID.
1078     * @return string
1079     */
1080    public function add_global_ID( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
1081        return '';
1082    }
1083
1084    /**
1085     * Return a count of comment likes.
1086     * This method is overridden by a child class in WPCOM.
1087     *
1088     * @since 13.5
1089     * @return int
1090     */
1091    public function comment_like_count() {
1092        func_get_args(); // @phan-suppress-current-line PhanPluginUseReturnValueInternalKnown -- This is just here so Phan realizes the wpcom version does this.
1093        return 0;
1094    }
1095
1096    /**
1097     * Get avatar URL.
1098     *
1099     * @param string $email Email.
1100     * @param array  $args Args for `get_avatar_url()`.
1101     * @return string|false
1102     */
1103    public function get_avatar_url( $email, $args = null ) {
1104        if ( function_exists( 'wpcom_get_avatar_url' ) ) {
1105            $ret = wpcom_get_avatar_url( $email, $args['size'] ?? 96, $args['default'] ?? '', false, $args['force_default'] ?? false );
1106            return $ret ? $ret[0] : false;
1107        } else {
1108            return null === $args
1109                ? get_avatar_url( $email )
1110                : get_avatar_url( $email, $args );
1111        }
1112    }
1113
1114    /**
1115     * Counts the number of comments on a site, including certain comment types.
1116     *
1117     * @param int $post_id Post ID.
1118     * @return object The number of counts keyed by status, matching the output of https://developer.wordpress.org/reference/functions/get_comment_count/.
1119     */
1120    public function wp_count_comments( $post_id ) {
1121        global $wpdb;
1122        if ( 0 !== $post_id ) {
1123            return wp_count_comments( $post_id );
1124        }
1125
1126        $counts = array(
1127            'total_comments' => 0,
1128            'all'            => 0,
1129        );
1130
1131        /**
1132        * Exclude certain comment types from comment counts in the REST API.
1133        *
1134        * @since 6.9.0
1135        * @deprecated 11.1
1136        * @module json-api
1137        *
1138        * @param array Array of comment types to exclude (default: 'order_note', 'webhook_delivery', 'review', 'action_log')
1139        */
1140        $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
1141
1142        /**
1143        * Include certain comment types in comment counts in the REST API.
1144        * Note: the default array of comment types includes an empty string,
1145        * to support comments posted before WP 5.5, that used an empty string as comment type.
1146        *
1147        * @since 11.1
1148        * @module json-api
1149        *
1150        * @param array Array of comment types to include (default: 'comment', 'pingback', 'trackback')
1151        */
1152        $include = apply_filters(
1153            'jetpack_api_include_comment_types_count',
1154            array( 'comment', 'pingback', 'trackback', '' )
1155        );
1156
1157        if ( empty( $include ) ) {
1158            return wp_count_comments( $post_id );
1159        }
1160
1161        // The following caching mechanism is based on what the get_comments() function uses.
1162
1163        $key          = md5( serialize( $include ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
1164        $last_changed = wp_cache_get_last_changed( 'comment' );
1165
1166        $cache_key = "wp_count_comments:$key:$last_changed";
1167        $count     = wp_cache_get( $cache_key, 'jetpack-json-api' );
1168
1169        if ( false === $count ) {
1170            array_walk( $include, 'esc_sql' );
1171            $where = sprintf(
1172                "WHERE comment_type IN ( '%s' )",
1173                implode( "','", $include )
1174            );
1175
1176            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- `$where` is built with escaping just above.
1177            $count = $wpdb->get_results(
1178                "SELECT comment_approved, COUNT(*) AS num_comments
1179                    FROM $wpdb->comments
1180                    {$where}
1181                    GROUP BY comment_approved
1182                "
1183            );
1184            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1185
1186            wp_cache_add( $cache_key, $count, 'jetpack-json-api' );
1187        }
1188
1189        $approved = array(
1190            '0'            => 'moderated',
1191            '1'            => 'approved',
1192            'spam'         => 'spam',
1193            'trash'        => 'trash',
1194            'post-trashed' => 'post-trashed',
1195        );
1196
1197        // <https://developer.wordpress.org/reference/functions/get_comment_count/#source>
1198        foreach ( $count as $row ) {
1199            if ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash', 'spam' ), true ) ) {
1200                $counts['all']            += $row->num_comments;
1201                $counts['total_comments'] += $row->num_comments;
1202            } elseif ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash' ), true ) ) {
1203                $counts['total_comments'] += $row->num_comments;
1204            }
1205            if ( isset( $approved[ $row->comment_approved ] ) ) {
1206                $counts[ $approved[ $row->comment_approved ] ] = $row->num_comments;
1207            }
1208        }
1209
1210        foreach ( $approved as $key ) {
1211            if ( empty( $counts[ $key ] ) ) {
1212                $counts[ $key ] = 0;
1213            }
1214        }
1215
1216        $counts = (object) $counts;
1217
1218        return $counts;
1219    }
1220
1221    /**
1222     * Traps `wp_die()` calls and outputs a JSON response instead.
1223     * The result is always output, never returned.
1224     *
1225     * @param string|null $error_code  Call with string to start the trapping.  Call with null to stop.
1226     * @param int         $http_status  HTTP status code, 400 by default.
1227     */
1228    public function trap_wp_die( $error_code = null, $http_status = 400 ) {
1229        // Determine the filter name; based on the conditionals inside the wp_die function.
1230        if ( wp_is_json_request() ) {
1231            $die_handler = 'wp_die_json_handler';
1232        } elseif ( wp_is_jsonp_request() ) {
1233            $die_handler = 'wp_die_jsonp_handler';
1234        } elseif ( wp_is_xml_request() ) {
1235            $die_handler = 'wp_die_xml_handler';
1236        } else {
1237            $die_handler = 'wp_die_handler';
1238        }
1239
1240        if ( $error_code === null ) {
1241            $this->trapped_error = null;
1242            // Stop trapping.
1243            remove_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1244            return;
1245        }
1246
1247        // If API called via PHP, bail: don't do our custom wp_die().  Do the normal wp_die().
1248        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1249            if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
1250                return;
1251            }
1252        } elseif ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
1253            return;
1254        }
1255
1256        $this->trapped_error = array(
1257            'status'  => $http_status,
1258            'code'    => $error_code,
1259            'message' => '',
1260        );
1261        // Start trapping.
1262        add_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1263    }
1264
1265    /**
1266     * Filter function for `wp_die_handler` and similar filters.
1267     *
1268     * @return callable
1269     */
1270    public function wp_die_handler_callback() {
1271        return array( $this, 'wp_die_handler' );
1272    }
1273
1274    /**
1275     * Handler for `wp_die` calls.
1276     *
1277     * @param string|WP_Error  $message As for `wp_die()`.
1278     * @param string|int       $title As for `wp_die()`.
1279     * @param string|array|int $args As for `wp_die()`.
1280     * @return never
1281     */
1282    public function wp_die_handler( $message, $title = '', $args = array() ) {
1283        // Allow wp_die calls to override HTTP status code...
1284        $args = wp_parse_args(
1285            $args,
1286            array(
1287                'response' => $this->trapped_error['status'],
1288            )
1289        );
1290
1291        // ... unless it's 500
1292        if ( 500 !== (int) $args['response'] ) {
1293            $this->trapped_error['status'] = $args['response'];
1294        }
1295
1296        if ( $title ) {
1297            $message = "$title$message";
1298        }
1299
1300        $this->trapped_error['message'] = wp_kses( $message, array() );
1301
1302        switch ( $this->trapped_error['code'] ) {
1303            case 'comment_failure':
1304                if ( did_action( 'comment_duplicate_trigger' ) ) {
1305                    $this->trapped_error['code'] = 'comment_duplicate';
1306                } elseif ( did_action( 'comment_flood_trigger' ) ) {
1307                    $this->trapped_error['code'] = 'comment_flood';
1308                }
1309                break;
1310        }
1311
1312        // We still want to exit so that code execution stops where it should.
1313        // Attach the JSON output to the WordPress shutdown handler.
1314        add_action( 'shutdown', array( $this, 'output_trapped_error' ), 0 );
1315        exit( 0 );
1316    }
1317
1318    /**
1319     * Output the trapped error.
1320     */
1321    public function output_trapped_error() {
1322        $this->exit = false; // We're already exiting once.  Don't do it twice.
1323        $this->output(
1324            $this->trapped_error['status'],
1325            (object) array(
1326                'error'   => $this->trapped_error['code'],
1327                'message' => $this->trapped_error['message'],
1328            )
1329        );
1330    }
1331
1332    /**
1333     * Finish the request.
1334     */
1335    public function finish_request() {
1336        if ( function_exists( 'fastcgi_finish_request' ) ) {
1337            return fastcgi_finish_request();
1338        }
1339    }
1340
1341    /**
1342     * Initialize the locale if different from 'en'.
1343     *
1344     * @param string $locale The locale to initialize.
1345     */
1346    public function init_locale( $locale ) {
1347        if ( 'en' !== $locale ) {
1348            // .org mo files are named slightly different from .com, and all we have is this the locale -- try to guess them.
1349            $new_locale = $locale;
1350            if ( str_contains( $locale, '-' ) ) {
1351                $locale_pieces = explode( '-', $locale );
1352                $new_locale    = $locale_pieces[0];
1353                $new_locale   .= ( ! empty( $locale_pieces[1] ) ) ? '_' . strtoupper( $locale_pieces[1] ) : '';
1354            } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
1355                // .com might pass 'fr' because thats what our language files are named as, where core seems
1356                // to do fr_FR - so try that if we don't think we can load the file.
1357                if ( ! file_exists( WP_LANG_DIR . '/' . $locale . '.mo' ) ) {
1358                    $new_locale = $locale . '_' . strtoupper( $locale );
1359                }
1360            }
1361
1362            if ( file_exists( WP_LANG_DIR . '/' . $new_locale . '.mo' ) ) {
1363                unload_textdomain( 'default' );
1364                load_textdomain( 'default', WP_LANG_DIR . '/' . $new_locale . '.mo' );
1365            }
1366        }
1367    }
1368}