Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.43% covered (success)
91.43%
64 / 70
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Main
91.43% covered (success)
91.43%
64 / 70
71.43% covered (warning)
71.43%
5 / 7
33.69
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%
18 / 18
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
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 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        add_action( 'wp_enqueue_scripts', array( __CLASS__, 'hide_smile_css' ) );
74
75        // Map stats caps.
76        add_filter( 'map_meta_cap', array( __CLASS__, 'map_meta_caps' ), 10, 3 );
77
78        XMLRPC_Provider::init();
79
80        /*
81         * REST_Provider only registers its routes on REST init, so defer
82         * constructing it (and autoloading the class) until a REST request is
83         * served. A closure is used because rest_api_init passes the REST server
84         * to callbacks, which would otherwise be read as REST_Provider::init()'s
85         * $new_instance argument.
86         */
87        add_action(
88            'rest_api_init',
89            static function () {
90                REST_Provider::init();
91            },
92            0
93        );
94        Transient_Cleanup::init();
95
96        // Clean up transient cron on module deactivation.
97        add_action( 'jetpack_deactivate_module_stats', array( Transient_Cleanup::class, 'unschedule_cleanup' ) );
98
99        // Set up package version hook.
100        add_filter( 'jetpack_package_versions', __NAMESPACE__ . '\Package_Version::send_package_version_to_tracker' );
101
102        // Register WP Abilities API surface. Gated behind the
103        // `jetpack_wp_abilities_enabled` filter inside Registrar::init(),
104        // which defaults to false â€” so this call is safe to make unconditionally
105        // and still opt-in per-site until the flag is flipped.
106        Stats_Abilities::init();
107    }
108
109    /**
110     * Checks if filter is set and dnt is enabled.
111     *
112     * @return bool
113     */
114    public static function jetpack_is_dnt_enabled() {
115        /**
116         * Filter the option which decides honor DNT or not.
117         *
118         * @module stats
119         * @since-jetpack 6.1.0
120         *
121         * @param bool false Honors DNT for clients who don't want to be tracked. Defaults to false. Set to true to enable.
122         */
123        if ( false === apply_filters( 'jetpack_honor_dnt_header_for_stats', false ) ) {
124            return false;
125        }
126
127        foreach ( $_SERVER as $name => $value ) {
128            if ( 'http_dnt' === strtolower( $name ) && 1 === (int) $value ) {
129                return true;
130            }
131        }
132
133        return false;
134    }
135
136    /**
137     * Maps view_stats cap to read cap as needed.
138     *
139     * @access public
140     * @param mixed $caps Caps.
141     * @param mixed $cap Cap.
142     * @param mixed $user_id User ID.
143     * @return array Possibly mapped capabilities for meta capability.
144     */
145    public static function map_meta_caps( $caps, $cap, $user_id ) {
146        // Map view_stats to exists.
147        if ( 'view_stats' === $cap ) {
148            $user        = new WP_User( $user_id );
149            $stats_roles = Options::get_option( 'roles' );
150
151            // Is any of the user's roles in the available stats roles?
152            if ( is_array( $stats_roles ) && ! empty( array_intersect( $user->roles, $stats_roles ) ) ) {
153                $caps = array( 'read' );
154            }
155        }
156
157        return $caps;
158    }
159
160    /**
161     * Stats Template Redirect.
162     *
163     * @access public
164     * @return void
165     */
166    public static function template_redirect() {
167        if ( ! self::should_track() ) {
168            return;
169        }
170
171        add_action( 'wp_enqueue_scripts', array( Tracking_Pixel::class, 'enqueue_stats_script' ), 101 );
172        add_action( 'wp_footer', array( Tracking_Pixel::class, 'add_amp_pixel' ), 101 );
173        add_action( 'web_stories_print_analytics', array( Tracking_Pixel::class, 'add_amp_pixel' ), 101 );
174    }
175
176    /**
177     * CSS to hide the tracking pixel smiley.
178     * It is now hidden for everyone (used to be visible if you had set the hide_smile option).
179     *
180     * @access public
181     * @return void
182     */
183    public static function hide_smile_css() {
184        if ( ! self::should_track() ) {
185            return;
186        }
187
188        wp_register_style( 'jetpack-stats', false, array(), Package_Version::PACKAGE_VERSION );
189        wp_enqueue_style( 'jetpack-stats' );
190        wp_add_inline_style( 'jetpack-stats', 'img#wpstats{display:none}' );
191    }
192
193    /**
194     * Whether we should add the tracking pixel.
195     *
196     * @return bool
197     */
198    public static function should_track() {
199        global $current_user;
200
201        // Not connected sites should not generate tracking stats.
202        if ( ! ( new Connection_Manager() )->is_connected() ) {
203            return false;
204        }
205
206        // If the stats module is disabled we should not generate tracking stats.
207        if ( ! ( new Modules() )->is_active( 'stats' ) ) {
208            return false;
209        }
210
211        // Do not generate tracking stats for feeds, robots, embeds, previews
212        // or to honour the DNT headers.
213        if (
214            is_feed()
215            || is_robots()
216            || is_embed()
217            || is_trackback()
218            || is_preview()
219            || self::jetpack_is_dnt_enabled()
220        ) {
221            return false;
222        }
223
224        // Sites in Safe Mode should not generate tracking stats.
225        $status = new Status();
226        if ( $status->in_safe_mode() ) {
227            return false;
228        }
229
230        // Should we be counting this user's views?
231        if ( ! empty( $current_user->ID ) ) {
232            $count_roles = Options::get_option( 'count_roles' );
233            if ( ! is_array( $count_roles ) || ! array_intersect( $current_user->roles, $count_roles ) ) {
234                return false;
235            }
236        }
237
238        /**
239         * Allow excluding specific IP addresses from being tracked in Stats.
240         * Note: for this to work well, visitors' IP addresses must:
241         * - be stored and returned properly in IP address headers;
242         * - not be impacted by any caching setup on your site.
243         *
244         * @module stats
245         *
246         * @since-jetpack 10.6
247         *
248         * @param array $excluded_ips An array of IP address strings to exclude from tracking.
249         */
250        $excluded_ips = (array) apply_filters( 'jetpack_stats_excluded_ips', array() );
251
252        /*
253         * Visitor::get_ip() returns a normalized address, so normalize the configured list the
254         * same way before comparing. Without this an entry written as `::ffff:203.0.113.5` or
255         * with uppercase IPv6 hex would never match, and the site owner's traffic would be
256         * counted with no indication why. Non-strings are dropped: they could never match the
257         * string get_ip() returns under the strict comparison below.
258         */
259        $excluded_ips = array_filter(
260            array_map( array( IP_Utils::class, 'clean_ip' ), array_filter( $excluded_ips, 'is_string' ) )
261        );
262
263        /*
264         * Resolving the visitor address reads request headers and, on a site with brute force
265         * protection configured, a site option, so only do it when the normalized list still
266         * holds something to compare against. The filter is unset on almost every site, which
267         * makes this the common path.
268         */
269        if ( ! empty( $excluded_ips ) ) {
270            // Should we be counting views for this IP address?
271            $current_user_ip = ( new Visitor() )->get_ip( true );
272            if ( in_array( $current_user_ip, $excluded_ips, true ) ) {
273                return false;
274            }
275        }
276
277        return true;
278    }
279}