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/jetpack-stats/src/class-tracking-pixel.php +499 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,499 @@
1 +<?php
2 +/**
3 + * Stats Tracking_Pixel
4 + *
5 + * @package automattic/jetpack-stats
6 + */
7 +
8 +namespace Automattic\Jetpack\Stats;
9 +
10 +use Jetpack_Options;
11 +use WP_Post;
12 +
13 +/**
14 + * Stats Tracking_Pixel class.
15 + *
16 + * Responsible for embedding the Stats tracking pixel.
17 + *
18 + * @since 0.1.0
19 + */
20 +class Tracking_Pixel {
21 +
22 + /**
23 + * Array name.
24 + *
25 + * @var string $array_name The 'stats' array name
26 + */
27 + const STATS_ARRAY_TO_STRING_FILTER = 'stats_array';
28 +
29 + const TRACKED_UTM_PARAMETERS = array(
30 + 'utm_id',
31 + 'utm_source',
32 + 'utm_medium',
33 + 'utm_campaign',
34 + 'utm_term',
35 + 'utm_content',
36 + 'utm_source_platform',
37 + 'utm_creative_format',
38 + 'utm_marketing_tactic',
39 + );
40 +
41 + /**
42 + * Stats Build View Data.
43 + *
44 + * @access public
45 + * @return array
46 + */
47 + public static function build_view_data() {
48 + global $wp_the_query;
49 +
50 + $blog = Jetpack_Options::get_option( 'id' );
51 + $tz = get_option( 'gmt_offset' );
52 + $v = 'ext';
53 + $blog_url = wp_parse_url( site_url() );
54 + $srv = $blog_url['host'];
55 + $is_not_post = false;
56 + if ( $wp_the_query->is_single || $wp_the_query->is_page || $wp_the_query->is_posts_page ) {
57 + // Store and reset the queried_object and queried_object_id
58 + // Otherwise, redirect_canonical() will redirect to home_url( '/' ) for show_on_front = page sites where home_url() is not all lowercase.
59 + // Repro:
60 + // 1. Set home_url = https://ExamPle.com/
61 + // 2. Set show_on_front = page
62 + // 3. Set page_on_front = something
63 + // 4. Visit https://example.com/ !
64 + $queried_object = $wp_the_query->queried_object ?? null;
65 + $queried_object_id = $wp_the_query->queried_object_id ?? null;
66 + try {
67 + $post_obj = $wp_the_query->get_queried_object();
68 + $post = $post_obj instanceof WP_Post ? $post_obj->ID : '0';
69 + } finally {
70 + $wp_the_query->queried_object = $queried_object;
71 + $wp_the_query->queried_object_id = $queried_object_id;
72 + }
73 + } else {
74 + $post = '0';
75 + $is_not_post = true;
76 + }
77 + $view_data = compact( 'v', 'blog', 'post', 'tz', 'srv' );
78 + // Batcache removes some of the UTM params from $_GET, we need to extract them from uri directly instead.
79 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're sanitizing individual params in the loop.
80 + $url_query = wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ), PHP_URL_QUERY );
81 + parse_str( (string) $url_query, $url_params );
82 + foreach ( self::TRACKED_UTM_PARAMETERS as $utm_parameter ) {
83 + if ( isset( $url_params[ $utm_parameter ] ) && is_scalar( $url_params[ $utm_parameter ] ) ) {
84 + $view_data[ $utm_parameter ] = substr( sanitize_textarea_field( wp_unslash( $url_params[ $utm_parameter ] ) ), 0, 255 );
85 + }
86 + }
87 +
88 + if ( $is_not_post ) {
89 + if ( $wp_the_query->is_home() ) {
90 + $view_data['arch_home'] = '1';
91 + } elseif ( $wp_the_query->is_search() ) {
92 + $search_term = $wp_the_query->query['s'] ?? $wp_the_query->query_vars['s'] ?? '';
93 + $view_data['arch_search'] = sanitize_text_field( $search_term );
94 + $view_data['arch_filters'] = sanitize_text_field( self::build_search_filters( $wp_the_query ) );
95 + $view_data['arch_results'] = $wp_the_query->posts ? $wp_the_query->post_count : 0;
96 + } elseif ( $wp_the_query->is_archive() ) {
97 + if ( $wp_the_query->is_date ) {
98 + $query = $wp_the_query->query;
99 + $date_parts = array_filter( array( $query['year'] ?? null, $query['monthnum'] ?? null, $query['day'] ?? null ) );
100 + $date = implode( '/', $date_parts );
101 + $view_data['arch_date'] = $date;
102 + }
103 + if ( $wp_the_query->is_category ) {
104 + $view_data['arch_cat'] = $wp_the_query->query['category_name'] ?? $wp_the_query->query_vars['category_name'] ?? '';
105 + }
106 + if ( $wp_the_query->is_tag ) {
107 + $view_data['arch_tag'] = $wp_the_query->query['tag'] ?? $wp_the_query->query_vars['tag'] ?? '';
108 + }
109 + if ( $wp_the_query->is_author ) {
110 + $view_data['arch_author'] = $wp_the_query->query['author_name'] ?? '';
111 + }
112 + if ( $wp_the_query->is_tax ) {
113 + $query = $wp_the_query->query;
114 + if ( is_array( $query ) && count( $query ) === 1 ) {
115 + $view_data[ 'arch_tax_' . array_keys( $query )[0] ] = array_values( $query )[0];
116 + }
117 + }
118 + $view_data['arch_results'] = $wp_the_query->posts ? $wp_the_query->post_count : 0;
119 + } elseif ( $wp_the_query->is_404() ) {
120 + $view_data['arch_err'] = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
121 + } else {
122 + $view_data['arch_other'] = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
123 + }
124 + }
125 + return $view_data;
126 + }
127 +
128 + /**
129 + * Collect the tracking data for a search page.
130 + *
131 + * @access private
132 + * @param \WP_Query $query The WP_Query object to parse all the filters from.
133 + * @return string The search filters in a URL query string format.
134 + */
135 + private static function build_search_filters( $query ) {
136 + $data = array(
137 + 'posts_per_page' => $query->get( 'posts_per_page' ),
138 + 'paged' => ( $query->get( 'paged' ) ) ? absint( $query->get( 'paged' ) ) : 1,
139 + 'orderby' => $query->get( 'orderby' ),
140 + 'order' => $query->get( 'order' ),
141 + );
142 +
143 + if ( $query->get( 'author_name' ) ) {
144 + $data['author_name'] = $query->get( 'author_name' );
145 + }
146 + $filters = http_build_query( $data );
147 +
148 + $the_tax_query = $query->tax_query;
149 + $terms = array();
150 + if ( ! empty( $the_tax_query->queried_terms ) && is_array( $the_tax_query->queried_terms ) ) {
151 + foreach ( $the_tax_query->queries as $tax_query ) {
152 + if ( ! is_array( $tax_query ) || ! isset( $tax_query['taxonomy'] ) ) {
153 + continue;
154 + }
155 + $taxonomy = $tax_query['taxonomy'];
156 + if ( ! isset( $terms[ $taxonomy ] ) || ! is_array( $terms[ $taxonomy ] ) ) {
157 + $terms[ $taxonomy ] = array();
158 + }
159 + $terms[ $taxonomy ] = array_merge( $terms[ $taxonomy ], $tax_query['terms'] );
160 + }
161 + }
162 + if ( ! empty( $terms ) ) {
163 + $filters .= '&terms=' . wp_json_encode( $terms, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
164 + }
165 + return $filters;
166 + }
167 +
168 + /**
169 + * Build the Stats tracking details.
170 + *
171 + * @since 0.6.0
172 + *
173 + * @access private
174 + * @param array $data Array of options about the site and page for the inline (non-AMP) tracker.
175 + * @return string
176 + */
177 + private static function build_stats_details( $data ) {
178 + $data_stats_array = self::stats_array_to_string( $data );
179 +
180 + $pushes = sprintf(
181 + '_stq.push([ "view", %1$s ]);
182 +_stq.push([ "clickTrackerInit", "%2$s", "%3$s" ]);',
183 + $data_stats_array,
184 + $data['blog'],
185 + $data['post']
186 + );
187 +
188 + // OFF (default): byte-for-byte identical to the historical output.
189 + if ( ! Options::get_option( 'honor_cookie_consent' ) ) {
190 + return "_stq = window._stq || [];\n" . $pushes;
191 + }
192 +
193 + // Fail closed when the WP Consent API plugin is active (an unavailable client-side API
194 + // means "wait", not "fire"); fail open otherwise to preserve historical tracking.
195 + return self::build_consent_gate( $pushes, ! function_exists( 'wp_has_consent' ) );
196 + }
197 +
198 + /**
199 + * Wrap the tracking pushes in a WP Consent API gate.
200 + *
201 + * The check runs in the browser because cached HTML is shared across visitors, deferred to
202 + * DOMContentLoaded (and re-run on the `wp_consent_type_defined` readiness event) so a
203 + * late-loading consent plugin is still honored. The check is idempotent.
204 + *
205 + * `_jpStatsFire.done` is set before the pushes, not after, so the gate is at-most-once even
206 + * if a push throws. Retrying can't recover: the stats sender assigns the beacon `src` before
207 + * any of its fallible DOM work, so a later exception means the view was already counted and
208 + * a replay would double-count it.
209 + *
210 + * @access private
211 + * @param string $pushes The `_stq.push(...)` statements to gate.
212 + * @param bool $fail_open Whether to fire when the client-side WP Consent API is unavailable.
213 + * @return string
214 + */
215 + private static function build_consent_gate( $pushes, $fail_open ) {
216 + $fail_open_literal = $fail_open ? 'true' : 'false';
217 +
218 + return sprintf(
219 + '_stq = window._stq || [];
220 +function _jpStatsFire() {
221 + if ( _jpStatsFire.done ) { return; }
222 + _jpStatsFire.done = true;
223 + %1$s
224 +}
225 +function _jpStatsCheck() {
226 + if ( typeof window.wp_has_consent === "function" ) {
227 + var consented;
228 + try {
229 + consented = window.wp_has_consent( "statistics" );
230 + } catch ( e ) {
231 + consented = %2$s;
232 + }
233 + if ( consented ) { _jpStatsFire(); }
234 + return;
235 + }
236 + if ( %2$s ) { _jpStatsFire(); }
237 +}
238 +document.addEventListener( "wp_listen_for_consent_change", function ( event ) {
239 + if ( event && event.detail && event.detail.statistics === "allow" ) { _jpStatsFire(); }
240 +} );
241 +document.addEventListener( "wp_consent_type_defined", _jpStatsCheck );
242 +window.addEventListener( "wp_consent_type_defined", _jpStatsCheck );
243 +if ( document.readyState === "loading" ) {
244 + document.addEventListener( "DOMContentLoaded", _jpStatsCheck, { once: true } );
245 +} else {
246 + _jpStatsCheck();
247 +}',
248 + $pushes,
249 + $fail_open_literal
250 + );
251 + }
252 +
253 + /**
254 + * Add fetchpriority="low" to the Stats script attributes.
255 + *
256 + * Reduces network contention with resources in the critical rendering path (e.g., the LCP
257 + * element image). This benefits Safari and Firefox, which don't automatically assign low
258 + * priority to async/defer scripts (unlike Chrome).
259 + *
260 + * @since 0.19.5
261 + *
262 + * @param array $attributes Script tag attributes.
263 + * @return array Modified attributes.
264 + */
265 + public static function add_low_fetchpriority( $attributes ) {
266 + // WordPress derives the tag id from the enqueue handle as "{handle}-js", so the
267 + // 'jetpack-stats' script (registered in enqueue_stats_script()) prints as
268 + // 'jetpack-stats-js'. Keep this in sync if the handle is ever renamed.
269 + if ( isset( $attributes['id'] ) && 'jetpack-stats-js' === $attributes['id'] ) {
270 + $attributes['fetchpriority'] = 'low';
271 + }
272 + return $attributes;
273 + }
274 +
275 + /**
276 + * Remove the dns-prefetch resource hint for stats.wp.com.
277 + *
278 + * WordPress automatically adds dns-prefetch hints for enqueued script hosts via
279 + * wp_dependencies_unique_hosts(). Since we're deprioritizing the stats script,
280 + * the dns-prefetch is counterproductive — it front-loads DNS resolution for a
281 + * resource we're intentionally delaying.
282 + *
283 + * @since 0.19.5
284 + *
285 + * @param array $urls Array of resource hint URLs.
286 + * @param string $relation_type The relation type (dns-prefetch, preconnect, etc.).
287 + * @return array Filtered URLs.
288 + */
289 + public static function remove_stats_dns_prefetch( $urls, $relation_type ) {
290 + if ( 'dns-prefetch' !== $relation_type ) {
291 + return $urls;
292 + }
293 +
294 + return array_filter(
295 + $urls,
296 + static function ( $url ) {
297 + // Resource hints can be arrays that carry the URL under an 'href' key.
298 + if ( is_array( $url ) ) {
299 + $candidate = ( isset( $url['href'] ) && is_string( $url['href'] ) ) ? $url['href'] : '';
300 + } elseif ( is_string( $url ) ) {
301 + $candidate = $url;
302 + } else {
303 + return true; // Unknown entry shape; leave it untouched.
304 + }
305 +
306 + // dns-prefetch entries arrive in several shapes: WordPress core emits bare
307 + // hosts ('stats.wp.com') via wp_dependencies_unique_hosts(), while other
308 + // filters may add scheme-relative ('//stats.wp.com') or full URLs. Normalize
309 + // each to a host so we drop stats.wp.com exactly without removing look-alike
310 + // hosts such as 'mystats.wp.com' or 'stats.wp.com.evil.tld'.
311 + if ( str_starts_with( $candidate, '//' ) ) {
312 + $host = wp_parse_url( 'https:' . $candidate, PHP_URL_HOST );
313 + } elseif ( str_contains( $candidate, '://' ) ) {
314 + $host = wp_parse_url( $candidate, PHP_URL_HOST );
315 + } else {
316 + $host = $candidate; // Bare host form, e.g. 'stats.wp.com'.
317 + }
318 +
319 + return ! is_string( $host ) || 'stats.wp.com' !== strtolower( $host );
320 + }
321 + );
322 + }
323 +
324 + /**
325 + * Enqueue the Stats pixel.
326 + * Do not use this function directly, it is hooked into `wp_enqueue_scripts`.
327 + *
328 + * @access public
329 + * @return void
330 + */
331 + public static function enqueue_stats_script() {
332 + if ( self::is_amp_request() ) {
333 + return;
334 + }
335 +
336 + wp_enqueue_script(
337 + 'jetpack-stats',
338 + 'https://stats.wp.com/e-' . gmdate( 'YW' ) . '.js',
339 + array(),
340 + null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- The version is set in the URL.
341 + array(
342 + 'in_footer' => true,
343 + 'strategy' => 'defer',
344 + )
345 + );
346 + add_filter( 'wp_script_attributes', array( static::class, 'add_low_fetchpriority' ) );
347 + add_filter( 'wp_resource_hints', array( static::class, 'remove_stats_dns_prefetch' ), 100, 2 );
348 +
349 + $data = self::build_view_data();
350 +
351 + /**
352 + * Filter the parameters added to the JavaScript stats tracking code.
353 + *
354 + * @module stats
355 + *
356 + * @since-jetpack 10.9
357 + *
358 + * @param array $data Array of options about the site and page you're on.
359 + */
360 + $data = (array) apply_filters( 'jetpack_stats_footer_js_data', $data );
361 +
362 + $triggers = self::build_stats_details( $data );
363 + wp_add_inline_script(
364 + 'jetpack-stats',
365 + $triggers,
366 + 'before'
367 + );
368 + }
369 +
370 + /**
371 + * Gets the tracking pixel URL for AMP output.
372 + *
373 + * @access private
374 + * @param array $data Array of data for the AMP pixel tracker.
375 + * @return string Returns the URL for the Stats tracker in an AMP scenario.
376 + */
377 + private static function get_amp_pixel_url( $data ) {
378 + /**
379 + * Filter the parameters added to the AMP pixel tracking code.
380 + *
381 + * @module stats
382 + *
383 + * @since-jetpack 10.9
384 + *
385 + * @param array $data Array of options about the site and page you're on.
386 + */
387 + $data = (array) apply_filters( 'jetpack_stats_footer_amp_data', $data );
388 +
389 + $data['host'] = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; // input var ok.
390 + $data['rand'] = 'RANDOM'; // AMP placeholder.
391 + $data['ref'] = 'DOCUMENT_REFERRER'; // AMP placeholder.
392 + $data = array_map( 'rawurlencode', $data );
393 + return add_query_arg( $data, 'https://pixel.wp.com/g.gif' );
394 + }
395 +
396 + /**
397 + * Build an AMP pixel.
398 + * Do not use this function directly, it is hooked into `wp_footer`.
399 + *
400 + * @access public
401 + * @return void
402 + */
403 + public static function add_amp_pixel() {
404 + $data = self::build_view_data();
405 + if ( ! self::is_amp_request() ) {
406 + return;
407 + }
408 +
409 + printf( '<amp-pixel src="%s"></amp-pixel>', esc_url( self::get_amp_pixel_url( $data ) ) );
410 + }
411 +
412 + /**
413 + * Stats Footer.
414 + *
415 + * @deprecated 0.6.0
416 + *
417 + * @access public
418 + * @return void
419 + */
420 + public static function add_to_footer() {
421 + _deprecated_function( __METHOD__, '0.6.0' );
422 + }
423 +
424 + /**
425 + * Gets the footer to add for the Stats tracker.
426 + *
427 + * @deprecated 0.6.0
428 + *
429 + * @access public
430 + * @param array $data Array of data for the JS stats tracker.
431 + * @return void
432 + */
433 + public static function get_footer_to_add( $data ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
434 + _deprecated_function( __METHOD__, '0.6.0' );
435 + }
436 +
437 + /**
438 + * Render the stats footer. Kept for backward compatibility on legacy AMF views.
439 + *
440 + * @deprecated 0.6.0
441 + *
442 + * @access public
443 + * @param array $data Array of data for the JS stats tracker.
444 + */
445 + public static function render_footer( $data ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
446 + _deprecated_function( __METHOD__, '0.6.0' );
447 + }
448 +
449 + /**
450 + * Render the stats footer for AMP output. Kept for backward compatibility.
451 + *
452 + * @access public
453 + * @param array $data Array of data for the AMP pixel tracker.
454 + */
455 + public static function render_amp_footer( $data ) {
456 + printf( '<amp-pixel src="%s"></amp-pixel>', esc_url( self::get_amp_pixel_url( $data ) ) );
457 + }
458 +
459 + /**
460 + * Creates the "array" string used as part of the JS tracker.
461 + *
462 + * @access private
463 + * @param array $kvs Array of options about the site and page you're on.
464 + * @return string
465 + */
466 + private static function stats_array_to_string( $kvs ) {
467 + /**
468 + * Filters the options added to the JavaScript Stats tracking code.
469 + *
470 + * @since-jetpack 1.1.0
471 + *
472 + * @param array $kvs Array of options about the site and page you're on.
473 + */
474 + $kvs = (array) apply_filters( self::STATS_ARRAY_TO_STRING_FILTER, $kvs );
475 + $kvs = array_map( 'strval', $kvs );
476 +
477 + // Encode into JSON object for direct use in JS.
478 + return wp_json_encode( $kvs, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
479 + }
480 +
481 + /**
482 + * Does the page return AMP content.
483 + *
484 + * @return bool $is_amp_request Are we on AMP view.
485 + */
486 + private static function is_amp_request() {
487 + $is_amp_request = ( function_exists( 'amp_is_request' ) && amp_is_request() );
488 + $is_amp_request = $is_amp_request || ( function_exists( 'ampforwp_is_amp_endpoint' ) && ampforwp_is_amp_endpoint() );
489 +
490 + /**
491 + * Returns true if the current request should return valid AMP content.
492 + *
493 + * @since 6.2.0
494 + *
495 + * @param boolean $is_amp_request Is this request supposed to return valid AMP content?
496 + */
497 + return apply_filters( 'jetpack_is_amp_request', $is_amp_request );
498 + }
499 +}