Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.67% covered (success)
91.67%
66 / 72
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Main
91.67% covered (success)
91.67%
66 / 72
71.43% covered (warning)
71.43%
5 / 7
34.67
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 jetpack_is_dnt_enabled
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 map_meta_caps
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 template_redirect
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 hide_smile_css
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 should_track
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
16.43
1<?php
2/**
3 * Stats Main
4 *
5 * @package automattic/jetpack-stats
6 */
7
8namespace Automattic\Jetpack\Stats;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Constants;
12use Automattic\Jetpack\IP\Utils as IP_Utils;
13use Automattic\Jetpack\Modules;
14use Automattic\Jetpack\Stats\Abilities\Stats_Abilities;
15use Automattic\Jetpack\Status;
16use Automattic\Jetpack\Status\Visitor;
17use WP_User;
18
19/**
20 * Stats Main class.
21 *
22 * Entrypoint for Stats.
23 *
24 * @since 0.1.0
25 */
26class Main {
27    /**
28     * Stats version.
29     * Mostly needed for backwards compatibility.
30     */
31    const STATS_VERSION = '9';
32
33    /**
34     * Singleton Main instance.
35     *
36     * @var Main
37     **/
38    private static $instance = null;
39
40    /**
41     * Initializer.
42     * Used to configure the stats package, eg when called via the Config package.
43     *
44     * @return object
45     */
46    public static function init() {
47        if ( null === self::$instance ) {
48            self::$instance = new Main();
49        }
50
51        return self::$instance;
52    }
53
54    /**
55     * Class constructor.
56     *
57     * @return void
58     */
59    private function __construct() {
60        /**
61         * This avoids conflicts when running Stats package with older versions of the Jetpack plugin.
62         *
63         * On JP version 11.5-a.2 the hooks below were removed from the Jetpack plugin and it is safe
64         * to register them in the Stats package.
65         */
66        $jp_plugin_version = Constants::get_constant( 'JETPACK__VERSION' );
67        if ( $jp_plugin_version && version_compare( $jp_plugin_version, '11.5-a.2', '<' ) ) {
68            return;
69        }
70        // Generate the tracking code after wp() has queried for posts.
71        add_action( 'template_redirect', array( __CLASS__, 'template_redirect' ), 1 );
72
73        // Not on template_redirect: the AMP plugin reads its sanitizers on `wp`, which runs first.
74        add_filter( 'amp_content_sanitizers', array( Tracking_Pixel::class, 'add_amp_consent_sanitizer' ) );
75
76        add_action( 'wp_enqueue_scripts', array( __CLASS__, 'hide_smile_css' ) );
77
78        // Map stats caps.
79        add_filter( 'map_meta_cap', array( __CLASS__, 'map_meta_caps' ), 10, 3 );
80
81        XMLRPC_Provider::init();
82
83        /*
84         * REST_Provider only registers its routes on REST init, so defer
85         * constructing it (and autoloading the class) until a REST request is
86         * served. A closure is used because rest_api_init passes the REST server
87         * to callbacks, which would otherwise be read as REST_Provider::init()'s
88         * $new_instance argument.
89         */
90        add_action(
91            'rest_api_init',
92            static function () {
93                REST_Provider::init();
94            },
95            0
96        );
97        Transient_Cleanup::init();
98
99        // Clean up transient cron on module deactivation.
100        add_action( 'jetpack_deactivate_module_stats', array( Transient_Cleanup::class, 'unschedule_cleanup' ) );
101
102        // Set up package version hook.
103        add_filter( 'jetpack_package_versions', __NAMESPACE__ . '\Package_Version::send_package_version_to_tracker' );
104
105        // Register WP Abilities API surface. Gated behind the
106        // `jetpack_wp_abilities_enabled` filter inside Registrar::init(),
107        // which defaults to false â€” so this call is safe to make unconditionally
108        // and still opt-in per-site until the flag is flipped.
109        Stats_Abilities::init();
110    }
111
112    /**
113     * Checks if filter is set and dnt is enabled.
114     *
115     * @return bool
116     */
117    public static function jetpack_is_dnt_enabled() {
118        /**
119         * Filter the option which decides honor DNT or not.
120         *
121         * @module stats
122         * @since-jetpack 6.1.0
123         *
124         * @param bool false Honors DNT for clients who don't want to be tracked. Defaults to false. Set to true to enable.
125         */
126        if ( false === apply_filters( 'jetpack_honor_dnt_header_for_stats', false ) ) {
127            return false;
128        }
129
130        foreach ( $_SERVER as $name => $value ) {
131            if ( 'http_dnt' === strtolower( $name ) && 1 === (int) $value ) {
132                return true;
133            }
134        }
135
136        return false;
137    }
138
139    /**
140     * Maps view_stats cap to read cap as needed.
141     *
142     * @access public
143     * @param mixed $caps Caps.
144     * @param mixed $cap Cap.
145     * @param mixed $user_id User ID.
146     * @return array Possibly mapped capabilities for meta capability.
147     */
148    public static function map_meta_caps( $caps, $cap, $user_id ) {
149        // Map view_stats to exists.
150        if ( 'view_stats' === $cap ) {
151            $user        = new WP_User( $user_id );
152            $stats_roles = Options::get_option( 'roles' );
153
154            // Is any of the user's roles in the available stats roles?
155            if ( is_array( $stats_roles ) && ! empty( array_intersect( $user->roles, $stats_roles ) ) ) {
156                $caps = array( 'read' );
157            }
158        }
159
160        return $caps;
161    }
162
163    /**
164     * Stats Template Redirect.
165     *
166     * @access public
167     * @return void
168     */
169    public static function template_redirect() {
170        if ( ! self::should_track() ) {
171            return;
172        }
173
174        add_action( 'wp_enqueue_scripts', array( Tracking_Pixel::class, 'enqueue_stats_script' ), 101 );
175        add_action( 'wp_footer', array( Tracking_Pixel::class, 'add_amp_pixel' ), 101 );
176
177        // Web Stories have no way to ask for consent, so they go uncounted where the WP Consent API is active.
178        if ( ! function_exists( 'wp_has_consent' ) ) {
179            add_action( 'web_stories_print_analytics', array( Tracking_Pixel::class, 'add_amp_pixel' ), 101 );
180        }
181    }
182
183    /**
184     * CSS to hide the tracking pixel smiley.
185     * It is now hidden for everyone (used to be visible if you had set the hide_smile option).
186     *
187     * @access public
188     * @return void
189     */
190    public static function hide_smile_css() {
191        if ( ! self::should_track() ) {
192            return;
193        }
194
195        wp_register_style( 'jetpack-stats', false, array(), Package_Version::PACKAGE_VERSION );
196        wp_enqueue_style( 'jetpack-stats' );
197        wp_add_inline_style( 'jetpack-stats', 'img#wpstats{display:none}' );
198    }
199
200    /**
201     * Whether we should add the tracking pixel.
202     *
203     * @return bool
204     */
205    public static function should_track() {
206        global $current_user;
207
208        // Not connected sites should not generate tracking stats.
209        if ( ! ( new Connection_Manager() )->is_connected() ) {
210            return false;
211        }
212
213        // If the stats module is disabled we should not generate tracking stats.
214        if ( ! ( new Modules() )->is_active( 'stats' ) ) {
215            return false;
216        }
217
218        // Do not generate tracking stats for feeds, robots, embeds, previews
219        // or to honour the DNT headers.
220        if (
221            is_feed()
222            || is_robots()
223            || is_embed()
224            || is_trackback()
225            || is_preview()
226            || self::jetpack_is_dnt_enabled()
227        ) {
228            return false;
229        }
230
231        // Sites in Safe Mode should not generate tracking stats.
232        $status = new Status();
233        if ( $status->in_safe_mode() ) {
234            return false;
235        }
236
237        // Should we be counting this user's views?
238        if ( ! empty( $current_user->ID ) ) {
239            $count_roles = Options::get_option( 'count_roles' );
240            if ( ! is_array( $count_roles ) || ! array_intersect( $current_user->roles, $count_roles ) ) {
241                return false;
242            }
243        }
244
245        /**
246         * Allow excluding specific IP addresses from being tracked in Stats.
247         * Note: for this to work well, visitors' IP addresses must:
248         * - be stored and returned properly in IP address headers;
249         * - not be impacted by any caching setup on your site.
250         *
251         * @module stats
252         *
253         * @since-jetpack 10.6
254         *
255         * @param array $excluded_ips An array of IP address strings to exclude from tracking.
256         */
257        $excluded_ips = (array) apply_filters( 'jetpack_stats_excluded_ips', array() );
258
259        /*
260         * Visitor::get_ip() returns a normalized address, so normalize the configured list the
261         * same way before comparing. Without this an entry written as `::ffff:203.0.113.5` or
262         * with uppercase IPv6 hex would never match, and the site owner's traffic would be
263         * counted with no indication why. Non-strings are dropped: they could never match the
264         * string get_ip() returns under the strict comparison below.
265         */
266        $excluded_ips = array_filter(
267            array_map( array( IP_Utils::class, 'clean_ip' ), array_filter( $excluded_ips, 'is_string' ) )
268        );
269
270        /*
271         * Resolving the visitor address reads request headers and, on a site with brute force
272         * protection configured, a site option, so only do it when the normalized list still
273         * holds something to compare against. The filter is unset on almost every site, which
274         * makes this the common path.
275         */
276        if ( ! empty( $excluded_ips ) ) {
277            // Should we be counting views for this IP address?
278            $current_user_ip = ( new Visitor() )->get_ip( true );
279            if ( in_array( $current_user_ip, $excluded_ips, true ) ) {
280                return false;
281            }
282        }
283
284        return true;
285    }
286}