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
← All changes | jetpack_vendor/automattic/jetpack-stats/src/class-tracking-pixel.php +269 -35 12.2.3 → 16.3-beta View file →
@@ -6,9 +6,8 @@
6 6 */
7 7
8 8 namespace Automattic\Jetpack\Stats;
9 9
10 -use Automattic\Jetpack\Assets;
11 10 use Jetpack_Options;
12 11 use WP_Post;
13 12
14 13 /**
@@ -26,22 +25,35 @@
26 25 * @var string $array_name The 'stats' array name
27 26 */
28 27 const STATS_ARRAY_TO_STRING_FILTER = 'stats_array';
29 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 +
30 41 /**
31 42 * Stats Build View Data.
32 43 *
33 44 * @access public
34 - * @return array.
45 + * @return array
35 46 */
36 47 public static function build_view_data() {
37 48 global $wp_the_query;
38 49
39 - $blog = Jetpack_Options::get_option( 'id' );
40 - $tz = get_option( 'gmt_offset' );
41 - $v = 'ext';
42 - $blog_url = wp_parse_url( site_url() );
43 - $srv = $blog_url['host'];
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;
44 56 if ( $wp_the_query->is_single || $wp_the_query->is_page || $wp_the_query->is_posts_page ) {
45 57 // Store and reset the queried_object and queried_object_id
46 58 // Otherwise, redirect_canonical() will redirect to home_url( '/' ) for show_on_front = page sites where home_url() is not all lowercase.
47 59 // Repro:
@@ -48,10 +60,10 @@
48 60 // 1. Set home_url = https://ExamPle.com/
49 61 // 2. Set show_on_front = page
50 62 // 3. Set page_on_front = something
51 63 // 4. Visit https://example.com/ !
52 - $queried_object = isset( $wp_the_query->queried_object ) ? $wp_the_query->queried_object : null;
53 - $queried_object_id = isset( $wp_the_query->queried_object_id ) ? $wp_the_query->queried_object_id : null;
64 + $queried_object = $wp_the_query->queried_object ?? null;
65 + $queried_object_id = $wp_the_query->queried_object_id ?? null;
54 66 try {
55 67 $post_obj = $wp_the_query->get_queried_object();
56 68 $post = $post_obj instanceof WP_Post ? $post_obj->ID : '0';
57 69 } finally {
@@ -58,36 +70,259 @@
58 70 $wp_the_query->queried_object = $queried_object;
59 71 $wp_the_query->queried_object_id = $queried_object_id;
60 72 }
61 73 } else {
62 - $post = '0';
74 + $post = '0';
75 + $is_not_post = true;
63 76 }
64 - return compact( 'v', 'blog', 'post', 'tz', 'srv' );
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;
65 126 }
66 127
67 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 + /**
68 169 * Build the Stats tracking details.
69 170 *
70 171 * @since 0.6.0
71 172 *
72 173 * @access private
73 - * @param array $data Array of data for the AMP pixel tracker.
174 + * @param array $data Array of options about the site and page for the inline (non-AMP) tracker.
74 175 * @return string
75 176 */
76 177 private static function build_stats_details( $data ) {
77 178 $data_stats_array = self::stats_array_to_string( $data );
78 179
79 - return sprintf(
80 - '_stq = window._stq || [];
81 -_stq.push([ "view", {%1$s} ]);
180 + $pushes = sprintf(
181 + '_stq.push([ "view", %1$s ]);
82 182 _stq.push([ "clickTrackerInit", "%2$s", "%3$s" ]);',
83 183 $data_stats_array,
84 184 $data['blog'],
85 185 $data['post']
86 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' ) );
87 196 }
88 197
89 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 + /**
90 325 * Enqueue the Stats pixel.
91 326 * Do not use this function directly, it is hooked into `wp_enqueue_scripts`.
92 327 *
93 328 * @access public
@@ -102,14 +337,16 @@
102 337 'jetpack-stats',
103 338 'https://stats.wp.com/e-' . gmdate( 'YW' ) . '.js',
104 339 array(),
105 340 null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- The version is set in the URL.
106 - true
341 + array(
342 + 'in_footer' => true,
343 + 'strategy' => 'defer',
344 + )
107 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 );
108 348
109 - // Make sure the script loads asynchronously (add a defer attribute).
110 - Assets::instance()->add_async_script( 'jetpack-stats' );
111 -
112 349 $data = self::build_view_data();
113 350
114 351 /**
115 352 * Filter the parameters added to the JavaScript stats tracking code.
@@ -125,20 +362,20 @@
125 362 $triggers = self::build_stats_details( $data );
126 363 wp_add_inline_script(
127 364 'jetpack-stats',
128 365 $triggers,
129 - 'after'
366 + 'before'
130 367 );
131 368 }
132 369
133 370 /**
134 - * Gets the stats footer for AMP output.
371 + * Gets the tracking pixel URL for AMP output.
135 372 *
136 373 * @access private
137 374 * @param array $data Array of data for the AMP pixel tracker.
138 - * @return string Returns the footer to add for the Stats tracker in an AMP scenario.
375 + * @return string Returns the URL for the Stats tracker in an AMP scenario.
139 376 */
140 - private static function get_amp_footer( $data ) {
377 + private static function get_amp_pixel_url( $data ) {
141 378 /**
142 379 * Filter the parameters added to the AMP pixel tracking code.
143 380 *
144 381 * @module stats
@@ -152,10 +389,9 @@
152 389 $data['host'] = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; // input var ok.
153 390 $data['rand'] = 'RANDOM'; // AMP placeholder.
154 391 $data['ref'] = 'DOCUMENT_REFERRER'; // AMP placeholder.
155 392 $data = array_map( 'rawurlencode', $data );
156 - $pixel_url = add_query_arg( $data, 'https://pixel.wp.com/g.gif' );
157 - return '<amp-pixel src="' . esc_url( $pixel_url ) . '"></amp-pixel>';
393 + return add_query_arg( $data, 'https://pixel.wp.com/g.gif' );
158 394 }
159 395
160 396 /**
161 397 * Build an AMP pixel.
@@ -169,10 +405,9 @@
169 405 if ( ! self::is_amp_request() ) {
170 406 return;
171 407 }
172 408
173 - $pixel = self::get_amp_footer( $data );
174 - echo $pixel; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
409 + printf( '<amp-pixel src="%s"></amp-pixel>', esc_url( self::get_amp_pixel_url( $data ) ) );
175 410 }
176 411
177 412 /**
178 413 * Stats Footer.
@@ -217,9 +452,9 @@
217 452 * @access public
218 453 * @param array $data Array of data for the AMP pixel tracker.
219 454 */
220 455 public static function render_amp_footer( $data ) {
221 - print self::get_amp_footer( $data ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
456 + printf( '<amp-pixel src="%s"></amp-pixel>', esc_url( self::get_amp_pixel_url( $data ) ) );
222 457 }
223 458
224 459 /**
225 460 * Creates the "array" string used as part of the JS tracker.
@@ -235,15 +470,13 @@
235 470 * @since-jetpack 1.1.0
236 471 *
237 472 * @param array $kvs Array of options about the site and page you're on.
238 473 */
239 - $kvs = (array) apply_filters( self::STATS_ARRAY_TO_STRING_FILTER, $kvs );
240 - $kvs = array_map( 'addslashes', $kvs );
241 - $jskvs = array();
242 - foreach ( $kvs as $k => $v ) {
243 - $jskvs[] = "$k:'$v'";
244 - }
245 - return implode( ',', $jskvs );
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 );
246 479 }
247 480
248 481 /**
249 482 * Does the page return AMP content.
@@ -251,8 +484,9 @@
251 484 * @return bool $is_amp_request Are we on AMP view.
252 485 */
253 486 private static function is_amp_request() {
254 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() );
255 489
256 490 /**
257 491 * Returns true if the current request should return valid AMP content.
258 492 *