PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.14
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.14
1.4.15 1.4.14 1.4.13 1.4.12 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.54 1.3.53 1.3.52 1.3.51 1.3.50 1.3.49 1.3.48 1.3.47 1.3.46 All 178 releases
← All changes | engine/OutputBuffer.php +112 -29 1.4.21.4.14 View file →
@@ -7,19 +7,34 @@
7 7
8 8 namespace Disco\Engine;
9 9
10 10 /**
11 - * Prevents PHP debug notices/warnings from corrupting REST and AJAX JSON responses.
11 + * Prevents stray output from corrupting REST and AJAX JSON responses.
12 12 *
13 - * When WP_DEBUG and WP_DEBUG_DISPLAY are both true, notices from plugins (e.g.
14 - * early textdomain loading) are printed before the JSON body, breaking JSON
15 - * parsing in the browser. We start an output buffer before plugins_loaded fires
16 - * so all stray output is captured, then discard it cleanly before the response
17 - * is sent. AJAX handlers call clean() manually before wp_send_json_*.
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.
18 28 */
19 29 class OutputBuffer {
20 30
21 31 /**
32 + * REST route prefix owned by this plugin.
33 + */
34 + private const ROUTE_PREFIX = '/disco/';
35 +
36 + /**
22 37 * Start output buffering for REST/AJAX requests.
23 38 * Call this immediately after the Composer autoload is required in disco.php.
24 39 */
25 40 public static function start(): void {
@@ -37,24 +52,25 @@
37 52
38 53 if ( self::is_rest_request() ) {
39 54 add_filter( 'rest_pre_serve_request', array( __CLASS__, 'handle_rest' ), 1, 4 );
40 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 + }
41 61 }
42 62
43 63 /**
44 64 * Hooked on rest_pre_serve_request at priority 1.
45 65 *
46 - * Strategy:
47 - * Level N = whatever PHP had before disco.php loaded (DISCO_OB_LEVEL)
48 - * Level N+1 = our buffer, containing stray debug notices
49 - * Level N+2 = WordPress's dispatch buffer (if WP uses ob_start in serve_request)
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.
50 70 *
51 - * We pop WP's dispatch buffer, discard our stray-output buffer, open a fresh
52 - * buffer, and return false so WP echoes the JSON body into the clean buffer.
53 - * WP's ob_get_clean() then collects only the actual JSON response.
54 - *
55 - * @param bool $served Whether the request has already been served.
56 - * @param \WP_REST_Response $result The response object.
71 + * @param bool $served Whether the request has already been served.
72 + * @param \WP_HTTP_Response $result The response object.
57 73 * @param \WP_REST_Request $request The current REST request.
58 74 * @param \WP_REST_Server $server The REST server instance.
59 75 * @return bool
60 76 */
@@ -59,33 +75,77 @@
59 75 * @return bool
60 76 */
61 77 public static function handle_rest( $served, $result, $request, $server ): bool {
62 78 if ( $served ) {
63 - return $served;
79 + return true;
64 80 }
65 81
66 - $initial_level = DISCO_OB_LEVEL;
82 + // Drop everything buffered so far (notices printed while WP booted/dispatched).
83 + self::clean();
67 84
68 - // Pop WP's dispatch buffer (level N+2) if it exists, preserving its content.
69 - $wp_dispatch = ob_get_level() > ( $initial_level + 1 ) ? (string) ob_get_clean() : '';
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 + }
70 92
71 - // Discard our stray-notices buffer (level N+1).
72 - if ( ob_get_level() > $initial_level ) {
73 - ob_end_clean();
93 + if ( 'HEAD' === $request->get_method() ) {
94 + self::seal();
95 +
96 + return true;
74 97 }
75 98
76 - // Open a fresh, clean buffer at the level WP expects for its ob_get_clean().
77 - ob_start();
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 );
78 101
79 - // Restore any output WP produced during dispatch (almost always empty).
80 - if ( '' !== $wp_dispatch ) {
81 - echo $wp_dispatch; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
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;
82 112 }
83 113
84 - return false; // Let WP echo the JSON body and collect it via ob_get_clean().
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;
85 132 }
86 133
87 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 + /**
88 148 * Discard all output buffered above DISCO_OB_LEVEL.
89 149 * Call this in AJAX handlers before wp_send_json_* to strip stray debug output.
90 150 */
91 151 public static function clean(): void {
@@ -94,8 +154,31 @@
94 154 }
95 155 while ( ob_get_level() > DISCO_OB_LEVEL ) {
96 156 ob_end_clean();
97 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 );
98 181 }
99 182
100 183 private static function is_api_request(): bool {
101 184 return self::is_ajax_request() || self::is_rest_request();