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 +237 -27 13.2.4 → 16.3-beta View file →
@@ -41,18 +41,19 @@
41 41 /**
42 42 * Stats Build View Data.
43 43 *
44 44 * @access public
45 - * @return array.
45 + * @return array
46 46 */
47 47 public static function build_view_data() {
48 48 global $wp_the_query;
49 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'];
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;
55 56 if ( $wp_the_query->is_single || $wp_the_query->is_page || $wp_the_query->is_posts_page ) {
56 57 // Store and reset the queried_object and queried_object_id
57 58 // Otherwise, redirect_canonical() will redirect to home_url( '/' ) for show_on_front = page sites where home_url() is not all lowercase.
58 59 // Repro:
@@ -59,10 +60,10 @@
59 60 // 1. Set home_url = https://ExamPle.com/
60 61 // 2. Set show_on_front = page
61 62 // 3. Set page_on_front = something
62 63 // 4. Visit https://example.com/ !
63 - $queried_object = isset( $wp_the_query->queried_object ) ? $wp_the_query->queried_object : null;
64 - $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;
65 66 try {
66 67 $post_obj = $wp_the_query->get_queried_object();
67 68 $post = $post_obj instanceof WP_Post ? $post_obj->ID : '0';
68 69 } finally {
@@ -69,9 +70,10 @@
69 70 $wp_the_query->queried_object = $queried_object;
70 71 $wp_the_query->queried_object_id = $queried_object_id;
71 72 }
72 73 } else {
73 - $post = '0';
74 + $post = '0';
75 + $is_not_post = true;
74 76 }
75 77 $view_data = compact( 'v', 'blog', 'post', 'tz', 'srv' );
76 78 // Batcache removes some of the UTM params from $_GET, we need to extract them from uri directly instead.
77 79 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're sanitizing individual params in the loop.
@@ -77,41 +79,250 @@
77 79 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're sanitizing individual params in the loop.
78 80 $url_query = wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ), PHP_URL_QUERY );
79 81 parse_str( (string) $url_query, $url_params );
80 82 foreach ( self::TRACKED_UTM_PARAMETERS as $utm_parameter ) {
81 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- UTMs are standardized parameters coming from outside WordPress, adding nonce is not possible
82 83 if ( isset( $url_params[ $utm_parameter ] ) && is_scalar( $url_params[ $utm_parameter ] ) ) {
83 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- UTMs are standardized parameters coming from outside WordPress, adding nonce is not possible
84 84 $view_data[ $utm_parameter ] = substr( sanitize_textarea_field( wp_unslash( $url_params[ $utm_parameter ] ) ), 0, 255 );
85 85 }
86 86 }
87 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 + }
88 125 return $view_data;
89 126 }
90 127
91 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 + /**
92 169 * Build the Stats tracking details.
93 170 *
94 171 * @since 0.6.0
95 172 *
96 173 * @access private
97 - * @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.
98 175 * @return string
99 176 */
100 177 private static function build_stats_details( $data ) {
101 178 $data_stats_array = self::stats_array_to_string( $data );
102 179
103 - return sprintf(
104 - '_stq = window._stq || [];
105 -_stq.push([ "view", JSON.parse(%1$s) ]);
180 + $pushes = sprintf(
181 + '_stq.push([ "view", %1$s ]);
106 182 _stq.push([ "clickTrackerInit", "%2$s", "%3$s" ]);',
107 183 $data_stats_array,
108 184 $data['blog'],
109 185 $data['post']
110 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' ) );
111 196 }
112 197
113 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 + /**
114 325 * Enqueue the Stats pixel.
115 326 * Do not use this function directly, it is hooked into `wp_enqueue_scripts`.
116 327 *
117 328 * @access public
@@ -131,8 +342,10 @@
131 342 'in_footer' => true,
132 343 'strategy' => 'defer',
133 344 )
134 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 );
135 348
136 349 $data = self::build_view_data();
137 350
138 351 /**
@@ -149,20 +362,20 @@
149 362 $triggers = self::build_stats_details( $data );
150 363 wp_add_inline_script(
151 364 'jetpack-stats',
152 365 $triggers,
153 - 'after'
366 + 'before'
154 367 );
155 368 }
156 369
157 370 /**
158 - * Gets the stats footer for AMP output.
371 + * Gets the tracking pixel URL for AMP output.
159 372 *
160 373 * @access private
161 374 * @param array $data Array of data for the AMP pixel tracker.
162 - * @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.
163 376 */
164 - private static function get_amp_footer( $data ) {
377 + private static function get_amp_pixel_url( $data ) {
165 378 /**
166 379 * Filter the parameters added to the AMP pixel tracking code.
167 380 *
168 381 * @module stats
@@ -176,10 +389,9 @@
176 389 $data['host'] = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; // input var ok.
177 390 $data['rand'] = 'RANDOM'; // AMP placeholder.
178 391 $data['ref'] = 'DOCUMENT_REFERRER'; // AMP placeholder.
179 392 $data = array_map( 'rawurlencode', $data );
180 - $pixel_url = add_query_arg( $data, 'https://pixel.wp.com/g.gif' );
181 - return '<amp-pixel src="' . esc_url( $pixel_url ) . '"></amp-pixel>';
393 + return add_query_arg( $data, 'https://pixel.wp.com/g.gif' );
182 394 }
183 395
184 396 /**
185 397 * Build an AMP pixel.
@@ -193,10 +405,9 @@
193 405 if ( ! self::is_amp_request() ) {
194 406 return;
195 407 }
196 408
197 - $pixel = self::get_amp_footer( $data );
198 - echo $pixel; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
409 + printf( '<amp-pixel src="%s"></amp-pixel>', esc_url( self::get_amp_pixel_url( $data ) ) );
199 410 }
200 411
201 412 /**
202 413 * Stats Footer.
@@ -241,9 +452,9 @@
241 452 * @access public
242 453 * @param array $data Array of data for the AMP pixel tracker.
243 454 */
244 455 public static function render_amp_footer( $data ) {
245 - 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 ) ) );
246 457 }
247 458
248 459 /**
249 460 * Creates the "array" string used as part of the JS tracker.
@@ -262,11 +473,10 @@
262 473 */
263 474 $kvs = (array) apply_filters( self::STATS_ARRAY_TO_STRING_FILTER, $kvs );
264 475 $kvs = array_map( 'strval', $kvs );
265 476
266 - // Encode into JSON object, and then encode it into a string that's safe to embed into Javascript.
267 - // We will then use JSON.parse method in JS to read the array.
268 - return wp_json_encode( wp_json_encode( $kvs ) );
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 );
269 479 }
270 480
271 481 /**
272 482 * Does the page return AMP content.