PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.5
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 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / woocommerce-analytics / src / class-pixel-builder.php

class-pixel-builder.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.5, at jetpack_vendor/automattic/woocommerce-analytics/src/class-pixel-builder.php

340 lines 9.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
340