| 1 |
<?php // phpcs:disable |
| 2 |
/** |
| 3 |
* Disco |
| 4 |
* |
| 5 |
* @package Disco |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Disco\Engine; |
| 9 |
|
| 10 |
/** |
| 11 |
* Prevents stray output from corrupting REST and AJAX JSON responses. |
| 12 |
* |
| 13 |
* Other plugins frequently emit output during an API request: PHP notices |
| 14 |
* (e.g. "Function _load_textdomain_just_in_time was called incorrectly"), |
| 15 |
* deprecation warnings, or plain `echo` calls inside `shutdown`/`rest_post_dispatch`. |
| 16 |
* Anything printed before the JSON body, and anything printed after it, makes the |
| 17 |
* response unparseable in the browser. |
| 18 |
* |
| 19 |
* Strategy: |
| 20 |
* 1. Open an output buffer before `plugins_loaded` so nothing reaches the client early. |
| 21 |
* 2. For our own REST routes, render and send the JSON body ourselves, discarding |
| 22 |
* everything buffered up to that point. |
| 23 |
* 3. Immediately after the body is flushed, open a buffer that discards everything, |
| 24 |
* so late output (shutdown hooks, destructors, deprecations) is never appended. |
| 25 |
* |
| 26 |
* AJAX handlers call clean() before wp_send_json_* for step 2, and the |
| 27 |
* `wp_die_ajax_handler` filter performs step 3. |
| 28 |
*/ |
| 29 |
class OutputBuffer { |
| 30 |
|
| 31 |
/** |
| 32 |
* REST route prefix owned by this plugin. |
| 33 |
*/ |
| 34 |
private const ROUTE_PREFIX = '/disco/'; |
| 35 |
|
| 36 |
/** |
| 37 |
* Start output buffering for REST/AJAX requests. |
| 38 |
* Call this immediately after the Composer autoload is required in disco.php. |
| 39 |
*/ |
| 40 |
public static function start(): void { |
| 41 |
define( 'DISCO_OB_LEVEL', ob_get_level() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound |
| 42 |
|
| 43 |
if ( ! self::is_api_request() ) { |
| 44 |
return; |
| 45 |
} |
| 46 |
|
| 47 |
ob_start(); |
| 48 |
|
| 49 |
// Suppress error display for API requests so notices do not corrupt JSON. |
| 50 |
// Errors continue to be written to debug.log when WP_DEBUG_LOG is enabled. |
| 51 |
@ini_set( 'display_errors', '0' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed |
| 52 |
|
| 53 |
if ( self::is_rest_request() ) { |
| 54 |
add_filter( 'rest_pre_serve_request', array( __CLASS__, 'handle_rest' ), 1, 4 ); |
| 55 |
} |
| 56 |
|
| 57 |
if ( self::is_ajax_request() ) { |
| 58 |
// Runs after wp_send_json_* has echoed its body, before the script dies. |
| 59 |
add_filter( 'wp_die_ajax_handler', array( __CLASS__, 'handle_ajax_die' ), PHP_INT_MAX ); |
| 60 |
} |
| 61 |
} |
| 62 |
|
| 63 |
/** |
| 64 |
* Hooked on rest_pre_serve_request at priority 1. |
| 65 |
* |
| 66 |
* For Disco routes we take over serving: discard whatever stray output was |
| 67 |
* buffered, echo the JSON body, flush it, and then swallow any later output. |
| 68 |
* For third-party routes we only drop the stray output we captured and let |
| 69 |
* WordPress serve the response as usual. |
| 70 |
* |
| 71 |
* @param bool $served Whether the request has already been served. |
| 72 |
* @param \WP_HTTP_Response $result The response object. |
| 73 |
* @param \WP_REST_Request $request The current REST request. |
| 74 |
* @param \WP_REST_Server $server The REST server instance. |
| 75 |
* @return bool |
| 76 |
*/ |
| 77 |
public static function handle_rest( $served, $result, $request, $server ): bool { |
| 78 |
if ( $served ) { |
| 79 |
return true; |
| 80 |
} |
| 81 |
|
| 82 |
// Drop everything buffered so far (notices printed while WP booted/dispatched). |
| 83 |
self::clean(); |
| 84 |
|
| 85 |
if ( ! $result instanceof \WP_HTTP_Response |
| 86 |
|| ! $request instanceof \WP_REST_Request |
| 87 |
|| ! $server instanceof \WP_REST_Server |
| 88 |
|| ! self::is_disco_route( $request ) |
| 89 |
|| null !== $request->get_param( '_jsonp' ) ) { |
| 90 |
return false; // Let WordPress echo the body itself. |
| 91 |
} |
| 92 |
|
| 93 |
if ( 'HEAD' === $request->get_method() ) { |
| 94 |
self::seal(); |
| 95 |
|
| 96 |
return true; |
| 97 |
} |
| 98 |
|
| 99 |
$embed = isset( $_GET['_embed'] ) ? rest_parse_embed_param( wp_unslash( $_GET['_embed'] ) ) : false; // phpcs:ignore WordPress.Security |
| 100 |
$data = $server->response_to_data( $result, $embed ); |
| 101 |
|
| 102 |
/** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ |
| 103 |
$data = apply_filters( 'rest_pre_echo_response', $data, $server, $request ); |
| 104 |
|
| 105 |
// Filters above may have printed something; drop it before we emit the body. |
| 106 |
self::clean(); |
| 107 |
|
| 108 |
if ( null === $data || 204 === $result->get_status() ) { |
| 109 |
self::seal(); |
| 110 |
|
| 111 |
return true; |
| 112 |
} |
| 113 |
|
| 114 |
$options = ( defined( 'WP_DEBUG' ) && WP_DEBUG && $request->has_param( '_pretty' ) ) ? JSON_PRETTY_PRINT : 0; |
| 115 |
$json = wp_json_encode( $data, $options ); |
| 116 |
|
| 117 |
if ( false === $json ) { |
| 118 |
$json = (string) wp_json_encode( |
| 119 |
array( |
| 120 |
'code' => 'rest_encode_error', |
| 121 |
'message' => json_last_error_msg(), |
| 122 |
'data' => array( 'status' => 500 ), |
| 123 |
) |
| 124 |
); |
| 125 |
} |
| 126 |
|
| 127 |
echo $json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 128 |
|
| 129 |
self::seal(); |
| 130 |
|
| 131 |
return true; |
| 132 |
} |
| 133 |
|
| 134 |
/** |
| 135 |
* Hooked on wp_die_ajax_handler, which fires after wp_send_json_* echoed its body. |
| 136 |
* Seals the output so late plugin output cannot be appended to the JSON. |
| 137 |
* |
| 138 |
* @param callable $handler The wp_die handler. |
| 139 |
* @return callable |
| 140 |
*/ |
| 141 |
public static function handle_ajax_die( $handler ) { |
| 142 |
self::seal(); |
| 143 |
|
| 144 |
return $handler; |
| 145 |
} |
| 146 |
|
| 147 |
/** |
| 148 |
* Discard all output buffered above DISCO_OB_LEVEL. |
| 149 |
* Call this in AJAX handlers before wp_send_json_* to strip stray debug output. |
| 150 |
*/ |
| 151 |
public static function clean(): void { |
| 152 |
if ( ! defined( 'DISCO_OB_LEVEL' ) ) { |
| 153 |
return; |
| 154 |
} |
| 155 |
while ( ob_get_level() > DISCO_OB_LEVEL ) { |
| 156 |
ob_end_clean(); |
| 157 |
} |
| 158 |
} |
| 159 |
|
| 160 |
/** |
| 161 |
* Push the response we already echoed to the client, then open a buffer that |
| 162 |
* throws away everything written afterwards (shutdown hooks, deprecations, |
| 163 |
* destructors), so nothing can be appended to the JSON body. |
| 164 |
*/ |
| 165 |
private static function seal(): void { |
| 166 |
while ( defined( 'DISCO_OB_LEVEL' ) && ob_get_level() > DISCO_OB_LEVEL ) { |
| 167 |
ob_end_flush(); |
| 168 |
} |
| 169 |
|
| 170 |
flush(); |
| 171 |
|
| 172 |
ob_start( |
| 173 |
static function () { |
| 174 |
return ''; |
| 175 |
} |
| 176 |
); |
| 177 |
} |
| 178 |
|
| 179 |
private static function is_disco_route( \WP_REST_Request $request ): bool { |
| 180 |
return 0 === strpos( (string) $request->get_route(), self::ROUTE_PREFIX ); |
| 181 |
} |
| 182 |
|
| 183 |
private static function is_api_request(): bool { |
| 184 |
return self::is_ajax_request() || self::is_rest_request(); |
| 185 |
} |
| 186 |
|
| 187 |
private static function is_ajax_request(): bool { |
| 188 |
return defined( 'DOING_AJAX' ) && DOING_AJAX; |
| 189 |
} |
| 190 |
|
| 191 |
private static function is_rest_request(): bool { |
| 192 |
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { |
| 193 |
return true; |
| 194 |
} |
| 195 |
|
| 196 |
if ( empty( $_SERVER['REQUEST_URI'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput |
| 197 |
return false; |
| 198 |
} |
| 199 |
|
| 200 |
// REST_REQUEST is not defined yet at plugin-file load time, so we inspect |
| 201 |
// the URI directly. rest_get_url_prefix() respects custom REST base slugs. |
| 202 |
$request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput |
| 203 |
$rest_prefix = function_exists( 'rest_get_url_prefix' ) ? rest_get_url_prefix() : 'wp-json'; |
| 204 |
|
| 205 |
return false !== strpos( $request_uri, '/' . $rest_prefix . '/' ); |
| 206 |
} |
| 207 |
} |
| 208 |
|