PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / woocommerce-analytics / src / mu-plugin / woocommerce-analytics-proxy-speed-module-template.php

woocommerce-analytics-proxy-speed-module-template.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/woocommerce-analytics/src/mu-plugin/woocommerce-analytics-proxy-speed-module-template.php

346 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 /**
3 * Plugin Name: WooCommerce Analytics - Proxy Speed Module
4 * Description: Speeds up WooCommerce Analytics' proxy by handling requests at MU-plugin stage and exiting early.
5 * Plugin URI: https://woocommerce.com
6 * Author: WooCommerce
7 * Version: {{VERSION}}
8 * Author URI: https://woocommerce.com
9 *
10 * Text Domain: woocommerce-analytics
11 *
12 * This module intercepts proxy tracking requests at the MU-plugin stage (before regular plugins load)
13 * and handles them completely, then exits. This dramatically reduces response time by avoiding
14 * the full WordPress plugin initialization.
15 */
16
17 defined( 'ABSPATH' ) || exit;
18
19 /**
20 * WooCommerce Analytics Proxy Speed Module
21 */
22 class WooCommerceAnalyticsProxySpeed {
23
24 /**
25 * Path of the proxy request.
26 *
27 * @var string
28 */
29 const PROXY_REQUEST_PATH = 'woocommerce-analytics/v1/track';
30
31 /**
32 * Autoloader path - this placeholder is replaced during installation.
33 * DO NOT MODIFY - this value is injected by the parent plugin.
34 *
35 * @var string
36 */
37 const AUTOLOADER_PATH = '{{AUTOLOADER_PATH}}';
38
39 /**
40 * Initialize the proxy speed module.
41 *
42 * @return void
43 */
44 public function init() {
45 // Only intercept POST requests to the proxy endpoint.
46 if ( ! $this->is_proxy_request() ) {
47 return;
48 }
49
50 // If autoloader failed, let WordPress continue loading
51 // and fallback to the regular REST API.
52 if ( ! $this->load_autoloader() ) {
53 return;
54 }
55
56 // Unauthorized means "do not accelerate", not "do not serve". Only the REST
57 // route can tell a disabled feature from a module whose authorization was
58 // revoked while the feature stays on, so fall through and let it answer.
59 if ( ! $this->is_authorized() ) {
60 return;
61 }
62
63 // Handle the request completely and exit.
64 $this->handle_proxy_request();
65 exit;
66 }
67
68 /**
69 * Check if current request is a proxy request.
70 *
71 * Self-contained on purpose: init() calls this before load_autoloader(), so no
72 * package class exists yet to delegate to.
73 *
74 * @return bool
75 */
76 private function is_proxy_request() {
77 if ( 'POST' !== $this->get_request_method() ) {
78 return false;
79 }
80
81 $path = $this->get_request_path();
82
83 if ( '' === $path ) {
84 return false;
85 }
86
87 $normalized_path = rtrim( $path, '/' );
88 $proxy_suffix = '/' . ltrim( self::PROXY_REQUEST_PATH, '/' );
89
90 if ( strlen( $normalized_path ) < strlen( $proxy_suffix ) ) {
91 return false;
92 }
93
94 return substr( $normalized_path, -strlen( $proxy_suffix ) ) === $proxy_suffix;
95 }
96
97 /**
98 * Load the autoloader.
99 *
100 * At MU-plugin stage, plugins haven't loaded yet, so we bootstrap
101 * the autoloader directly using the path injected during installation.
102 *
103 * @return bool True if autoloader loaded and classes are available.
104 */
105 private function load_autoloader() {
106 $autoload_path = self::AUTOLOADER_PATH;
107
108 // Validate the path was properly injected (not still a placeholder).
109 if ( strpos( $autoload_path, '{{' ) !== false ) {
110 error_log( 'WooCommerce Analytics Proxy Speed Module: Autoloader path placeholder was not replaced during installation.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
111 return false;
112 }
113
114 if ( ! file_exists( $autoload_path ) ) {
115 error_log( 'WooCommerce Analytics Proxy Speed Module: Autoloader file not found at: ' . $autoload_path ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
116 return false;
117 }
118
119 try {
120 require_once $autoload_path;
121 } catch ( \Throwable $e ) {
122 error_log( 'WooCommerce Analytics Proxy Speed Module: Failed to load autoloader: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
123 return false;
124 }
125
126 if ( ! class_exists( '\Automattic\Woocommerce_Analytics\WC_Analytics_Tracking' ) ) {
127 error_log( 'WooCommerce Analytics Proxy Speed Module: WC_Analytics_Tracking class not found after loading autoloader.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
128 return false;
129 }
130
131 // The autoloader resolves the highest version across active plugins, which can
132 // be older than the one that wrote this file. Fall back rather than fatal.
133 if ( ! method_exists( '\Automattic\Woocommerce_Analytics\WC_Analytics_Tracking', 'record_client_event' ) ) {
134 error_log( 'WooCommerce Analytics Proxy Speed Module: the loaded WC_Analytics_Tracking predates record_client_event().' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
135 return false;
136 }
137
138 // Avoid a 500 when an older package lacks the bound constant.
139 if ( ! defined( '\Automattic\Woocommerce_Analytics\WC_Analytics_Tracking::MAX_CLIENT_EVENTS_PER_REQUEST' ) ) {
140 error_log( 'WooCommerce Analytics Proxy Speed Module: the loaded WC_Analytics_Tracking predates the client input bounds.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
141 return false;
142 }
143
144 // Same skew, other class: process_proxy_request() reads this to decide whether
145 // to serve, and an older package lacking it throws where nothing can fall back.
146 if ( ! defined( '\Automattic\Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION' ) ) {
147 error_log( 'WooCommerce Analytics Proxy Speed Module: the loaded Woocommerce_Analytics predates the speed module authorization option.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
148 return false;
149 }
150
151 return true;
152 }
153
154 /**
155 * Whether this module may serve the request.
156 *
157 * Features::is_proxy_tracking_enabled() cannot be used here: no plugin has
158 * registered that filter this early, so it reads false everywhere. Only an
159 * explicit yes serves, so a network-wide module file cannot answer for a site
160 * that never authorized it.
161 *
162 * @return bool
163 */
164 private function is_authorized() {
165 return 'yes' === get_option( \Automattic\Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION );
166 }
167
168 /**
169 * Handle the proxy request completely.
170 *
171 * Processes the events and sends the response without loading
172 * regular WordPress plugins.
173 *
174 * @return void
175 */
176 private function handle_proxy_request() {
177 if ( ! headers_sent() ) {
178 header( 'Content-Type: application/json; charset=utf-8' );
179 header( 'Cache-Control: no-cache, must-revalidate' );
180 }
181
182 try {
183 $this->process_proxy_request();
184 } catch ( \Throwable $e ) {
185 error_log( 'WooCommerce Analytics Proxy Speed Module: Uncaught error in handle_proxy_request: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
186 $this->send_json_response(
187 array(
188 'success' => false,
189 'error' => 'Internal server error while processing analytics events.',
190 ),
191 500
192 );
193 }
194 }
195
196 /**
197 * Process the proxy request body: parse events, record them, and send the response.
198 *
199 * @return void
200 */
201 private function process_proxy_request() {
202 // Apply magic quotes to superglobals ($_GET, $_POST, $_COOKIE, $_REQUEST) for compatibility with the regular API flow.
203 if ( function_exists( 'wp_magic_quotes' ) ) {
204 wp_magic_quotes();
205 }
206
207 $body = file_get_contents( 'php://input' );
208 if ( empty( $body ) ) {
209 $this->send_json_response(
210 array(
211 'success' => false,
212 'error' => 'Empty request body',
213 ),
214 400
215 );
216 return;
217 }
218
219 $events = json_decode( $body, true );
220 if ( json_last_error() !== JSON_ERROR_NONE ) {
221 $this->send_json_response(
222 array(
223 'success' => false,
224 'error' => 'Invalid JSON',
225 ),
226 400
227 );
228 return;
229 }
230
231 // Normalize: wrap a single event object or unexpected scalar in an array.
232 if ( ! is_array( $events ) || isset( $events['event_name'] ) ) {
233 $events = array( $events );
234 }
235
236 // Use the same batch limit as the REST controller.
237 $max_events = \Automattic\Woocommerce_Analytics\WC_Analytics_Tracking::MAX_CLIENT_EVENTS_PER_REQUEST;
238 if ( count( $events ) > $max_events ) {
239 $events = array_slice( $events, 0, $max_events, true );
240 }
241
242 $results = array();
243 $has_errors = false;
244
245 foreach ( $events as $index => $event ) {
246 if ( empty( $event ) || ! is_array( $event ) ) {
247 $results[ $index ] = array(
248 'success' => false,
249 'error' => 'Invalid event format',
250 );
251 $has_errors = true;
252 continue;
253 }
254
255 $event_name = $event['event_name'] ?? null;
256 $properties = $event['properties'] ?? array();
257
258 if ( ! $event_name || ! is_string( $event_name ) || ! is_array( $properties ) ) {
259 $results[ $index ] = array(
260 'success' => false,
261 'error' => 'Missing event_name or invalid properties',
262 );
263 $has_errors = true;
264 continue;
265 }
266
267 $result = \Automattic\Woocommerce_Analytics\WC_Analytics_Tracking::record_client_event( $event_name, $properties );
268
269 if ( is_wp_error( $result ) ) {
270 $results[ $index ] = array(
271 'success' => false,
272 'error' => $result->get_error_message(),
273 );
274 $has_errors = true;
275 continue;
276 }
277
278 $results[ $index ] = array( 'success' => true );
279 }
280
281 \Automattic\Woocommerce_Analytics\WC_Analytics_Tracking::send_batched_pixels();
282
283 $this->send_json_response(
284 array(
285 'success' => ! $has_errors,
286 'results' => $results,
287 'is_proxy_speed_module' => true,
288 ),
289 $has_errors ? 207 : 200
290 );
291 }
292
293 /**
294 * Send a JSON response.
295 *
296 * @param array $data Response data.
297 * @param int $status_code HTTP status code.
298 * @return void
299 */
300 private function send_json_response( $data, $status_code = 200 ) {
301 http_response_code( $status_code );
302 echo wp_json_encode( $data, JSON_UNESCAPED_SLASHES );
303 }
304
305 /**
306 * Helper method to retrieve the request path.
307 *
308 * Extracts and validates the path component from $_SERVER['REQUEST_URI']
309 * for safe internal matching.
310 *
311 * @return string The validated path, or empty string on failure.
312 */
313 private function get_request_path() {
314 $raw_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
315
316 if ( ! is_string( $raw_uri ) ) {
317 return '';
318 }
319
320 // Extract just the path component to avoid matching against query strings, etc.
321 $path = wp_parse_url( $raw_uri, PHP_URL_PATH );
322
323 if ( ! is_string( $path ) ) {
324 return '';
325 }
326
327 // Ensure the path contains only expected URL path characters.
328 if ( preg_match( '/[^A-Za-z0-9\-._~\/]/', $path ) ) {
329 return '';
330 }
331
332 return $path;
333 }
334
335 /**
336 * Helper method to get request method.
337 *
338 * @return string
339 */
340 private function get_request_method() {
341 return isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
342 }
343 }
344
345 ( new WooCommerceAnalyticsProxySpeed() )->init();
346