PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 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 All 508 releases
← All changes | jetpack_vendor/automattic/woocommerce-analytics/src/class-pixel-builder.php +339 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,339 @@
1 +<?php
2 +/**
3 + * Pixel Builder for WooCommerce Analytics
4 + *
5 + * @package automattic/woocommerce-analytics
6 + */
7 +
8 +namespace Automattic\Woocommerce_Analytics;
9 +
10 +use WP_Error;
11 +
12 +/**
13 + * Pixel Builder class - handles pixel URL construction.
14 + */
15 +class Pixel_Builder {
16 +
17 + /**
18 + * Tracks pixel URL.
19 + *
20 + * @var string
21 + */
22 + const TRACKS_PIXEL_URL = 'https://pixel.wp.com/t.gif';
23 +
24 + /**
25 + * ClickHouse pixel URL.
26 + *
27 + * @var string
28 + */
29 + const CH_PIXEL_URL = 'https://pixel.wp.com/w.gif';
30 +
31 + /**
32 + * Browser type identifier for server-side tracking.
33 + *
34 + * @var string
35 + */
36 + const BROWSER_TYPE = 'php-agent';
37 +
38 + /**
39 + * Event name regex pattern.
40 + * Format: prefix_eventname (e.g., woocommerceanalytics_checkout_started)
41 + *
42 + * @var string
43 + */
44 + const EVENT_NAME_REGEX = '/^(([a-z0-9]+)_){1}([a-z0-9_]+)$/';
45 +
46 + /**
47 + * Property name regex pattern.
48 + * Format: lowercase letters/underscores, starting with letter or underscore.
49 + *
50 + * @var string
51 + */
52 + const PROP_NAME_REGEX = '/^[a-z_][a-z0-9_]*$/';
53 +
54 + /**
55 + * Build a timestamp representing milliseconds since 1970-01-01.
56 + *
57 + * @return string A string representing a timestamp.
58 + */
59 + public static function build_timestamp() {
60 + $ts = round( microtime( true ) * 1000 );
61 + return number_format( $ts, 0, '', '' );
62 + }
63 +
64 + /**
65 + * Add request timestamp and nocache parameter to pixel URL.
66 + * Should be called just before the HTTP request.
67 + *
68 + * @param string $pixel Pixel URL.
69 + * @return string Pixel URL with request timestamp and URL terminator.
70 + */
71 + public static function add_request_timestamp_and_nocache( $pixel ) {
72 + return $pixel . '&_rt=' . self::build_timestamp() . '&_=_';
73 + }
74 +
75 + /**
76 + * Build a Tracks pixel URL from properties.
77 + *
78 + * @param array $properties Event properties.
79 + * @return string|WP_Error Pixel URL on success, WP_Error on failure.
80 + */
81 + public static function build_tracks_url( $properties ) {
82 + $validated = self::validate_and_sanitize( $properties );
83 +
84 + if ( is_wp_error( $validated ) ) {
85 + return $validated;
86 + }
87 +
88 + return self::TRACKS_PIXEL_URL . '?' . http_build_query( $validated );
89 + }
90 +
91 + /**
92 + * Build a ClickHouse pixel URL from properties.
93 + *
94 + * @param array $properties Event properties.
95 + * @return string|WP_Error Pixel URL on success, WP_Error on failure.
96 + */
97 + public static function build_ch_url( $properties ) {
98 + $validated = self::validate_and_sanitize( $properties );
99 +
100 + if ( is_wp_error( $validated ) ) {
101 + return $validated;
102 + }
103 +
104 + return self::CH_PIXEL_URL . '?' . http_build_query( $validated );
105 + }
106 +
107 + /**
108 + * Validate and sanitize event properties.
109 + *
110 + * @param array $properties Event properties.
111 + * @return array|WP_Error Validated properties on success, WP_Error on failure.
112 + */
113 + public static function validate_and_sanitize( $properties ) {
114 + // Required: event name.
115 + if ( empty( $properties['_en'] ) ) {
116 + return new WP_Error( 'invalid_event', 'A valid event must be specified via `_en`', 400 );
117 + }
118 +
119 + // Validate event name format.
120 + if ( ! self::event_name_is_valid( $properties['_en'] ) ) {
121 + return new WP_Error( 'invalid_event_name', 'A valid event name must be specified.' );
122 + }
123 +
124 + // Delete non-routable IP addresses (geoip would discard these anyway).
125 + if ( isset( $properties['_via_ip'] ) && preg_match( '/^192\.168|^10\./', $properties['_via_ip'] ) ) {
126 + unset( $properties['_via_ip'] );
127 + }
128 +
129 + // Add browser type for server-side tracking.
130 + $properties['browser_type'] = self::BROWSER_TYPE;
131 +
132 + // Ensure timestamp exists.
133 + if ( ! isset( $properties['_ts'] ) ) {
134 + $properties['_ts'] = self::build_timestamp();
135 + }
136 +
137 + // Validate property names.
138 + foreach ( array_keys( $properties ) as $key ) {
139 + if ( '_en' === $key ) {
140 + continue;
141 + }
142 + if ( ! self::prop_name_is_valid( $key ) ) {
143 + return new WP_Error( 'invalid_prop_name', 'A valid prop name must be specified: ' . $key );
144 + }
145 + }
146 +
147 + // Sanitize array values to prevent bracket notation in URL serialization.
148 + return self::sanitize_property_values( $properties );
149 + }
150 +
151 + /**
152 + * Check if event name is valid.
153 + *
154 + * @param string $name Event name.
155 + * @return bool True if valid, false otherwise.
156 + */
157 + public static function event_name_is_valid( $name ) {
158 + return (bool) preg_match( self::EVENT_NAME_REGEX, $name );
159 + }
160 +
161 + /**
162 + * Check if a property name is valid.
163 + *
164 + * @param string $name Property name.
165 + * @return bool True if valid, false otherwise.
166 + */
167 + public static function prop_name_is_valid( $name ) {
168 + return (bool) preg_match( self::PROP_NAME_REGEX, $name );
169 + }
170 +
171 + /**
172 + * Sanitize property values for URL serialization.
173 + *
174 + * Converts array values to appropriate formats to prevent http_build_query()
175 + * from creating bracket notation (e.g., prop[0], prop[1]) which violates
176 + * the property name regex.
177 + *
178 + * @param array $properties Event properties.
179 + * @return array Sanitized properties.
180 + */
181 + private static function sanitize_property_values( $properties ) {
182 + foreach ( $properties as $key => $value ) {
183 + if ( ! is_array( $value ) ) {
184 + continue;
185 + }
186 +
187 + if ( empty( $value ) ) {
188 + // Empty array becomes empty string.
189 + $properties[ $key ] = '';
190 + continue;
191 + }
192 +
193 + // Check if array is indexed (not associative) and contains only scalar values.
194 + $is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
195 + $has_scalar_only = ! array_filter(
196 + $value,
197 + function ( $item ) {
198 + return is_array( $item ) || is_object( $item );
199 + }
200 + );
201 +
202 + if ( $is_indexed_array && $has_scalar_only ) {
203 + // Indexed arrays with scalar values: join as comma string.
204 + $properties[ $key ] = implode( ',', array_map( 'strval', $value ) );
205 + continue;
206 + }
207 +
208 + // Associative arrays or nested arrays become JSON strings.
209 + $encoded = wp_json_encode( $value, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES );
210 + $properties[ $key ] = ( false === $encoded ) ? '' : $encoded;
211 + }
212 +
213 + return $properties;
214 + }
215 +
216 + /**
217 + * Check if a SOCKS proxy is configured.
218 + *
219 + * The Requests library doesn't support SOCKS proxies, so we need to fall back
220 + * to wp_remote_get() which respects WordPress proxy settings.
221 + *
222 + * @return bool True if a SOCKS proxy is configured.
223 + */
224 + private static function is_socks_proxy_configured() {
225 + if ( ! defined( 'WP_PROXY_HOST' ) || ! is_string( WP_PROXY_HOST ) || '' === WP_PROXY_HOST ) {
226 + return false;
227 + }
228 +
229 + return self::is_socks_proxy_host( (string) WP_PROXY_HOST );
230 + }
231 +
232 + /**
233 + * Check if a proxy host string indicates a SOCKS proxy.
234 + *
235 + * @param string $proxy_host The proxy host value.
236 + * @return bool True if the host indicates a SOCKS proxy.
237 + */
238 + public static function is_socks_proxy_host( $proxy_host ) {
239 + $proxy_host = strtolower( $proxy_host );
240 +
241 + return 0 === strpos( $proxy_host, 'socks5://' )
242 + || 0 === strpos( $proxy_host, 'socks4://' )
243 + || 0 === strpos( $proxy_host, 'socks://' );
244 + }
245 +
246 + /**
247 + * Send pixel requests using batched non-blocking HTTP calls.
248 + *
249 + * Uses Requests library's request_multiple() for parallel execution via curl_multi.
250 + *
251 + * @param array $pixels Array of pixel URLs to send.
252 + * @return bool True on success.
253 + */
254 + public static function send_pixels_batched( $pixels ) {
255 + if ( empty( $pixels ) ) {
256 + return true;
257 + }
258 +
259 + // Check if batching is supported.
260 + // Note: WpOrg\Requests\Requests doesn't support SOCKS proxies, so we fall back
261 + // to individual wp_remote_get() requests which respect WP_PROXY_* settings.
262 + $can_batch = ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) )
263 + || ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) );
264 + $can_batch = $can_batch && ! self::is_socks_proxy_configured();
265 +
266 + if ( ! $can_batch ) {
267 + // Fallback to individual requests.
268 + foreach ( $pixels as $pixel ) {
269 + self::send_pixel( $pixel );
270 + }
271 + return true;
272 + }
273 +
274 + // Add timestamp and nocache to all pixels.
275 + $pixels_to_send = array();
276 + foreach ( $pixels as $pixel ) {
277 + $pixels_to_send[] = self::add_request_timestamp_and_nocache( $pixel );
278 + }
279 +
280 + // Build request array for batch sending.
281 + $requests = array();
282 + $options = array(
283 + 'blocking' => false, // Non-blocking mode.
284 + 'timeout' => 1,
285 + );
286 +
287 + foreach ( $pixels_to_send as $pixel ) {
288 + $requests[] = array(
289 + 'url' => $pixel,
290 + 'headers' => array(),
291 + 'data' => array(),
292 + 'type' => 'GET',
293 + );
294 + }
295 +
296 + try {
297 + if ( class_exists( 'WpOrg\Requests\Requests' ) ) {
298 + \WpOrg\Requests\Requests::request_multiple( $requests, $options );
299 + } elseif ( class_exists( 'Requests' ) ) {
300 + \Requests::request_multiple( $requests, $options ); // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.requestsDeprecated
301 + }
302 + } catch ( \Exception $e ) {
303 + // Log error but don't break the site - tracking pixels should fail gracefully.
304 + $error_message = 'WooCommerce Analytics: Batch pixel request failed - ' . $e->getMessage();
305 + if ( function_exists( 'wc_get_logger' ) ) {
306 + wc_get_logger()->error( $error_message, array( 'source' => 'woocommerce-analytics' ) );
307 + } else {
308 + // Fallback for MU-plugin stage when WooCommerce logger is not available.
309 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
310 + error_log( $error_message );
311 + }
312 + return false;
313 + }
314 +
315 + return true;
316 + }
317 +
318 + /**
319 + * Send a single pixel request.
320 + *
321 + * @param string $pixel Pixel URL.
322 + * @return bool True on success.
323 + */
324 + public static function send_pixel( $pixel ) {
325 + $pixel = self::add_request_timestamp_and_nocache( $pixel );
326 +
327 + wp_remote_get(
328 + $pixel,
329 + array(
330 + 'blocking' => false,
331 + 'redirection' => 2,
332 + 'httpversion' => '1.1',
333 + 'timeout' => 1,
334 + )
335 + );
336 +
337 + return true;
338 + }
339 +}