roles, (array) $settings['exclude_roles'] ) ) { return false; } } /** * Filters whether the current page view is tracked. * * @param bool $track Whether to output the tracker. */ return (bool) apply_filters( 'visitrix_should_track', true ); } /** * Registers the early beacon handler. Must run when the plugin file is loaded, * before plugins_loaded fires: beacons are handled right after plugins load, * without the theme, init hooks or the REST API bootstrap. */ public static function register_early() { if ( self::is_collect_request() ) { add_action( 'plugins_loaded', array( __CLASS__, 'collect_early' ), 0 ); } } /** * Lightweight collector URL (a POST to the site front page with ?visitrix_collect=1). * * @return string */ public static function collect_url() { /** * Filters the URL the tracker sends data to. * * @param string $url Collector URL. */ return (string) apply_filters( 'visitrix_collect_url', add_query_arg( 'visitrix_collect', '1', home_url( '/' ) ) ); } /** * Whether the current request is a tracker beacon. * * @return bool */ private static function is_collect_request() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public anonymous beacon, no state change for users. return isset( $_GET['visitrix_collect'], $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD']; } /** * Handles a beacon on plugins_loaded and stops: answers 204 without loading the theme. */ public static function collect_early() { Visitrix_DB::maybe_upgrade(); $body = (string) file_get_contents( 'php://input', false, null, 0, 8193 ); $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : ''; self::process( $body, $ua, $origin ); if ( ! headers_sent() ) { status_header( 204 ); header( 'Cache-Control: no-store, max-age=0' ); header( 'X-Robots-Tag: noindex' ); } exit; } /** * Enqueues the tracker script (~2 KB gzipped). */ public static function enqueue() { if ( ! self::should_track() ) { return; } $settings = Visitrix_Settings::get(); wp_enqueue_script( 'visitrix-tracker', VISITRIX_URL . 'assets/js/' . ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? 'tracker.js' : 'tracker.min.js' ), array(), VISITRIX_VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); wp_add_inline_script( 'visitrix-tracker', 'var visitrixCfg = ' . wp_json_encode( array( 'endpoint' => esc_url_raw( self::collect_url() ), 'dnt' => (int) $settings['respect_dnt'], 'mode' => $settings['id_mode'], 'consent' => (int) $settings['consent_api'], 'post' => is_singular() ? (int) get_queried_object_id() : 0, ) ) . ';', 'before' ); } /** * REST callback, kept for pages cached before 1.2.1. Always answers 204. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public static function collect( WP_REST_Request $request ) { self::process( $request->get_body(), (string) $request->get_header( 'user_agent' ), (string) $request->get_header( 'origin' ) ); return new WP_REST_Response( null, 204 ); } /** * Stores a pageview or a ping. * * @param string $body Raw JSON payload. * @param string $ua User agent. * @param string $origin Origin header. */ private static function process( $body, $ua, $origin ) { $settings = Visitrix_Settings::get(); if ( empty( $settings['enabled'] ) || ! self::origin_ok( $origin ) || '1' === self::cookie( self::IGNORE_COOKIE ) ) { return; } $ua = substr( $ua, 0, 512 ); if ( Visitrix_UA::is_bot( $ua ) || strlen( $body ) > 8192 ) { return; } $data = json_decode( $body, true ); if ( ! is_array( $data ) ) { return; } $type = isset( $data['t'] ) ? $data['t'] : ''; if ( 'pv' !== $type && 'ping' !== $type ) { return; } $mode = self::mode( $settings, $data ); if ( Visitrix_Storage::is_clickhouse() ) { self::process_events( $type, $data, $mode, $ua, $settings ); return; } if ( 'a' === $mode ) { $visitor_key = self::anonymous_key( $ua ); $visit = self::recent_visit( $visitor_key ); $referrer = self::referrer( $data ); // A different external source starts a new visit. if ( $visit && 'pv' === $type && '' !== $referrer && $referrer !== $visit->referrer && self::is_external( $referrer ) ) { $visit = null; } $visit_key = $visit ? $visit->visit_key : md5( $visitor_key . microtime() . wp_rand() ); } else { $visit_key = self::key( $data, 's' ); if ( '' === $visit_key ) { return; } $visitor_key = self::key( $data, 'v' ); if ( 'c' === $mode ) { $from_cookie = self::key( array( 'k' => self::cookie( self::COOKIE ) ), 'k' ); $visitor_key = '' !== $from_cookie ? $from_cookie : $visitor_key; if ( '' === $visitor_key ) { $visitor_key = md5( $visit_key . 'visitor' ); } self::set_visitor_cookie( $visitor_key ); } $visit = self::find_visit( $visit_key ); } if ( 'ping' === $type ) { if ( $visit ) { self::touch( (int) $visit->id, 0 ); if ( ! empty( $data['e'] ) ) { self::engage( (int) $visit->id ); } self::page_events( (int) $visit->id, $data ); } return; } if ( $visit ) { // A second pageview is an interaction too. self::touch( (int) $visit->id, 1 ); self::engage( (int) $visit->id ); $visit_id = (int) $visit->id; } else { if ( '' === $visitor_key ) { $visitor_key = md5( $visit_key . 'visitor' ); } $visit_id = self::create_visit( $visit_key, $visitor_key, $mode, $data, $ua ); if ( ! $visit_id ) { return; } if ( empty( $settings['bot_filter'] ) ) { self::engage( $visit_id ); } } self::add_hit( $visit_id, $data ); } /** * ClickHouse storage: the beacon becomes events (pageview, ping, engagement, goals); visits are assembled by views. * * @param string $type pv|ping. * @param array $data Payload. * @param string $mode s|c|a. * @param string $ua User agent. * @param array $settings Settings. */ private static function process_events( $type, $data, $mode, $ua, $settings ) { $db = Visitrix_DB::db(); if ( ! $db instanceof Visitrix_ClickHouse_DB ) { return; } if ( 'a' === $mode ) { $visitor_key = self::anonymous_key( $ua ); $visit = Visitrix_ClickHouse::recent_visit( $db, $visitor_key, time() - self::VISIT_TIMEOUT ); $referrer = self::referrer( $data ); if ( $visit && 'pv' === $type && '' !== $referrer && $referrer !== $visit['referrer'] && self::is_external( $referrer ) ) { $visit = null; } if ( ! $visit && 'ping' === $type ) { return; } $visit_key = $visit ? $visit['visit_key'] : md5( $visitor_key . microtime() . wp_rand() ); } else { $visit_key = self::key( $data, 's' ); if ( '' === $visit_key ) { return; } $visitor_key = self::key( $data, 'v' ); if ( 'c' === $mode ) { $from_cookie = self::key( array( 'k' => self::cookie( self::COOKIE ) ), 'k' ); $visitor_key = '' !== $from_cookie ? $from_cookie : $visitor_key; if ( '' === $visitor_key ) { $visitor_key = md5( $visit_key . 'visitor' ); } self::set_visitor_cookie( $visitor_key ); } if ( '' === $visitor_key ) { $visitor_key = md5( $visit_key . 'visitor' ); } } $base = array( 'visit_key' => $visit_key, 'visitor_key' => $visitor_key, 'id_type' => $mode, ); $rows = array(); if ( 'pv' === $type ) { $referrer = self::referrer( $data ); $params = array(); if ( isset( $data['q'] ) && is_string( $data['q'] ) ) { wp_parse_str( ltrim( substr( $data['q'], 0, 1024 ), '?' ), $params ); } $utm = array(); foreach ( array( 'utm_source', 'utm_medium', 'utm_campaign' ) as $tag ) { $utm[ $tag ] = isset( $params[ $tag ] ) && is_string( $params[ $tag ] ) ? mb_substr( sanitize_text_field( $params[ $tag ] ), 0, 100 ) : ''; } $source = Visitrix_Sources::classify( $referrer, $params ); $client = Visitrix_UA::parse( $ua, isset( $data['tp'] ) ? absint( $data['tp'] ) : 0 ); $path = self::clean_path( isset( $data['u'] ) ? $data['u'] : '' ); // Every pageview carries the visit attributes; the view takes them from the first one. $rows[] = Visitrix_ClickHouse::row( $base + $utm + array( 'kind' => 'pv', 'source_type' => $source['type'], 'source_name' => mb_substr( $source['name'], 0, 100 ), 'referrer' => mb_substr( $referrer, 0, 255 ), 'landing' => $path, 'device' => $client['device'], 'browser' => $client['browser'], 'os' => $client['os'], 'path' => $path, 'title' => isset( $data['ti'] ) && is_string( $data['ti'] ) ? mb_substr( sanitize_text_field( $data['ti'] ), 0, 255 ) : '', 'post_id' => isset( $data['p'] ) ? absint( $data['p'] ) : 0, ) ); if ( empty( $settings['bot_filter'] ) ) { $rows[] = Visitrix_ClickHouse::row( $base + array( 'kind' => 'engage' ) ); } } else { $rows[] = Visitrix_ClickHouse::row( $base + array( 'kind' => 'ping', 'scroll' => isset( $data['sd'] ) ? min( 100, absint( $data['sd'] ) ) : 0 ) ); if ( ! empty( $data['e'] ) ) { $rows[] = Visitrix_ClickHouse::row( $base + array( 'kind' => 'engage' ) ); } $goals = isset( $data['g'] ) && is_array( $data['g'] ) ? array_slice( $data['g'], 0, 20 ) : array(); foreach ( $goals as $item ) { if ( ! is_array( $item ) || ! isset( $item[0], $item[1] ) || ! in_array( $item[0], self::GOAL_TYPES, true ) || ! is_scalar( $item[1] ) ) { continue; } $rows[] = Visitrix_ClickHouse::row( $base + array( 'kind' => 'goal', 'goal_type' => $item[0], 'goal_target' => mb_substr( sanitize_text_field( (string) $item[1] ), 0, 200 ), ) ); } } $db->insert_rows( Visitrix_ClickHouse::events( $db ), $rows ); } /** * Effective identification mode. The browser may downgrade (no consent), never upgrade. * * @param array $settings Settings. * @param array $data Payload. * @return string s|c|a */ private static function mode( $settings, $data ) { $client = isset( $data['m'] ) ? $data['m'] : 's'; if ( 'anonymous' === $settings['id_mode'] || 'a' === $client ) { return 'a'; } return ( 'cookie' === $settings['id_mode'] && 'c' === $client ) ? 'c' : 's'; } /** * Rejects cross-site submissions when the browser sends an Origin header. * * @param string $origin Origin header. * @return bool */ private static function origin_ok( $origin ) { if ( '' === $origin ) { return true; } $host = Visitrix_Sources::host( $origin ); $allowed = array( Visitrix_Sources::host( home_url() ), Visitrix_Sources::host( site_url() ) ); return in_array( $host, $allowed, true ); } /** * Validated 32-char hex id from an array. * * @param array $data Payload. * @param string $field Field name. * @return string */ private static function key( $data, $field ) { return ( isset( $data[ $field ] ) && is_string( $data[ $field ] ) && preg_match( '/^[a-f0-9]{32}$/', $data[ $field ] ) ) ? $data[ $field ] : ''; } /** * Raw cookie value. * * @param string $name Cookie name. * @return string */ private static function cookie( $name ) { return isset( $_COOKIE[ $name ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ $name ] ) ) : ''; } /** * Domain for plugin cookies: shared by www and non-www versions of the site. * * @return string Empty string for a host-only cookie. */ public static function cookie_domain() { if ( defined( 'COOKIE_DOMAIN' ) && COOKIE_DOMAIN ) { return (string) COOKIE_DOMAIN; } $host = Visitrix_Sources::host( home_url() ); if ( '' === $host || false === strpos( $host, '.' ) || filter_var( $host, FILTER_VALIDATE_IP ) ) { return ''; } return '.' . $host; } /** * Sets or refreshes the first-party visitor cookie. * * @param string $visitor_key Visitor id. */ private static function set_visitor_cookie( $visitor_key ) { if ( headers_sent() ) { return; } setcookie( self::COOKIE, $visitor_key, array( 'expires' => time() + 400 * DAY_IN_SECONDS, 'path' => '/', 'domain' => self::cookie_domain(), 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ) ); } /** * Daily-rotating hash of IP + user agent. The IP itself is never stored. * * @param string $ua User agent. * @return string */ private static function anonymous_key( $ua ) { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; /** * Filters the client IP used for the anonymous daily hash (e.g. behind a proxy). * * @param string $ip REMOTE_ADDR. */ $ip = (string) apply_filters( 'visitrix_client_ip', $ip ); return md5( self::daily_salt() . '|' . $ip . '|' . $ua . '|' . Visitrix_Sources::host( home_url() ) ); } /** * Random salt that changes every day, so anonymous hashes cannot be linked across days. * * @return string */ private static function daily_salt() { $today = current_time( 'Y-m-d' ); $salt = get_option( 'visitrix_salt' ); if ( ! is_array( $salt ) || ! isset( $salt['date'], $salt['salt'] ) || $salt['date'] !== $today ) { $salt = array( 'date' => $today, 'salt' => wp_generate_password( 32, true, true ), ); update_option( 'visitrix_salt', $salt, true ); } return $salt['salt']; } /** * Visit by its key. * * @param string $visit_key Visit id. * @return object|null */ private static function find_visit( $visit_key ) { $db = Visitrix_DB::db(); return $db->get_row( $db->prepare( 'SELECT id, visit_key, referrer FROM %i WHERE visit_key = %s', Visitrix_DB::visits_table(), $visit_key ) ); } /** * Latest still-active visit of an anonymous visitor. * * @param string $visitor_key Visitor hash. * @return object|null */ private static function recent_visit( $visitor_key ) { $db = Visitrix_DB::db(); return $db->get_row( $db->prepare( 'SELECT id, visit_key, referrer FROM %i WHERE visitor_key = %s AND ts_last >= %d ORDER BY id DESC LIMIT 1', Visitrix_DB::visits_table(), $visitor_key, time() - self::VISIT_TIMEOUT ) ); } /** * Extends the visit: last activity, duration and optionally the pageview counter. * * @param int $visit_id Visit row id. * @param int $pageviews Pageviews to add. */ private static function touch( $visit_id, $pageviews ) { $db = Visitrix_DB::db(); $now = time(); $db->query( $db->prepare( 'UPDATE %i SET pageviews = LEAST(pageviews + %d, 65000), ts_last = %d, duration = LEAST(GREATEST(CAST(%d AS SIGNED) - CAST(ts_start AS SIGNED), 0), %d) WHERE id = %d AND ts_last >= %d', Visitrix_DB::visits_table(), $pageviews, $now, $now, self::MAX_DURATION, $visit_id, $now - self::VISIT_TIMEOUT ) ); } /** * Marks a visit as made by a human (first interaction) and registers the visitor. * The server, not the browser, decides whether the visitor is new. * * @param int $visit_id Visit row id. */ private static function engage( $visit_id ) { $db = Visitrix_DB::db(); $visits = Visitrix_DB::visits_table(); $changed = $db->query( $db->prepare( 'UPDATE %i SET engaged = 1 WHERE id = %d AND engaged = 0', $visits, $visit_id ) ); if ( ! $changed ) { return; } $visit = $db->get_row( $db->prepare( 'SELECT visitor_key, started_at, id_type FROM %i WHERE id = %d', $visits, $visit_id ) ); if ( ! $visit || 'a' === $visit->id_type ) { return; } $db->query( $db->prepare( 'INSERT INTO %i (visitor_key, first_seen, last_seen, visits) VALUES (%s, %s, %s, 1) ON DUPLICATE KEY UPDATE visits = visits + 1, last_seen = GREATEST(last_seen, %s)', Visitrix_DB::visitors_table(), $visit->visitor_key, $visit->started_at, $visit->started_at, $visit->started_at ) ); // MySQL reports 1 affected row for an insert, 2 for an update. if ( 1 === (int) $db->rows_affected ) { $db->query( $db->prepare( 'UPDATE %i SET is_new = 1 WHERE id = %d', $visits, $visit_id ) ); } } /** * Creates a visit on its first pageview. * * @param string $visit_key Visit id. * @param string $visitor_key Visitor id. * @param string $mode s|c|a. * @param array $data Payload. * @param string $ua User agent. * @return int Visit row id, 0 on failure. */ private static function create_visit( $visit_key, $visitor_key, $mode, $data, $ua ) { $db = Visitrix_DB::db(); $visits = Visitrix_DB::visits_table(); $now = time(); $referrer = self::referrer( $data ); $params = array(); if ( isset( $data['q'] ) && is_string( $data['q'] ) ) { wp_parse_str( ltrim( substr( $data['q'], 0, 1024 ), '?' ), $params ); } $utm = array(); foreach ( array( 'utm_source', 'utm_medium', 'utm_campaign' ) as $tag ) { $utm[ $tag ] = isset( $params[ $tag ] ) && is_string( $params[ $tag ] ) ? mb_substr( sanitize_text_field( $params[ $tag ] ), 0, 100 ) : ''; } $source = Visitrix_Sources::classify( $referrer, $params ); $client = Visitrix_UA::parse( $ua, isset( $data['tp'] ) ? absint( $data['tp'] ) : 0 ); // INSERT IGNORE: two parallel first hits of the same visit must not fail. $db->query( $db->prepare( 'INSERT IGNORE INTO %i (visit_key, visitor_key, started_at, ts_start, ts_last, duration, pageviews, is_new, engaged, id_type, source_type, source_name, referrer, landing, utm_source, utm_medium, utm_campaign, device, browser, os) VALUES (%s, %s, %s, %d, %d, 0, 1, 0, 0, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)', $visits, $visit_key, $visitor_key, current_time( 'mysql' ), $now, $now, $mode, $source['type'], mb_substr( $source['name'], 0, 100 ), mb_substr( $referrer, 0, 255 ), self::clean_path( isset( $data['u'] ) ? $data['u'] : '' ), $utm['utm_source'], $utm['utm_medium'], $utm['utm_campaign'], $client['device'], $client['browser'], $client['os'] ) ); $visit_id = (int) $db->insert_id; if ( ! $visit_id ) { $visit_id = (int) $db->get_var( $db->prepare( 'SELECT id FROM %i WHERE visit_key = %s', $visits, $visit_key ) ); } return $visit_id; } /** * Stores a pageview. * * @param int $visit_id Visit row id. * @param array $data Payload. */ private static function add_hit( $visit_id, $data ) { $db = Visitrix_DB::db(); $db->insert( Visitrix_DB::hits_table(), array( 'visit_id' => $visit_id, 'created_at' => current_time( 'mysql' ), 'path' => self::clean_path( isset( $data['u'] ) ? $data['u'] : '' ), 'title' => isset( $data['ti'] ) && is_string( $data['ti'] ) ? mb_substr( sanitize_text_field( $data['ti'] ), 0, 255 ) : '', 'post_id' => isset( $data['p'] ) ? absint( $data['p'] ) : 0, ), array( '%d', '%s', '%s', '%s', '%d' ) ); } /** Goal types collected by the tracker. */ const GOAL_TYPES = array( 'form', 'tel', 'email', 'messenger', 'download', 'outbound' ); /** * Scroll depth and goals that ride along with a ping. They belong to the visit's latest pageview. * * @param int $visit_id Visit row id. * @param array $data Payload. */ private static function page_events( $visit_id, $data ) { $db = Visitrix_DB::db(); $depth = isset( $data['sd'] ) ? min( 100, absint( $data['sd'] ) ) : 0; $goals = isset( $data['g'] ) && is_array( $data['g'] ) ? array_slice( $data['g'], 0, 20 ) : array(); if ( ! $depth && ! $goals ) { return; } $hit = $db->get_row( $db->prepare( 'SELECT id, path FROM %i WHERE visit_id = %d ORDER BY id DESC LIMIT 1', Visitrix_DB::hits_table(), $visit_id ) ); if ( $hit && $depth ) { $db->query( $db->prepare( 'UPDATE %i SET scroll = GREATEST(scroll, %d) WHERE id = %d', Visitrix_DB::hits_table(), $depth, $hit->id ) ); } foreach ( $goals as $item ) { if ( ! is_array( $item ) || ! isset( $item[0], $item[1] ) || ! in_array( $item[0], self::GOAL_TYPES, true ) || ! is_scalar( $item[1] ) ) { continue; } $db->insert( Visitrix_DB::goals_table(), array( 'visit_id' => $visit_id, 'created_at' => current_time( 'mysql' ), 'type' => $item[0], 'target' => mb_substr( sanitize_text_field( (string) $item[1] ), 0, 200 ), 'path' => $hit ? $hit->path : '', ), array( '%d', '%s', '%s', '%s', '%s' ) ); } } /** * Sanitized referrer from the payload. * * @param array $data Payload. * @return string */ private static function referrer( $data ) { return isset( $data['r'] ) && is_string( $data['r'] ) ? esc_url_raw( substr( $data['r'], 0, 1024 ) ) : ''; } /** * Whether a URL points to another site. * * @param string $url URL. * @return bool */ private static function is_external( $url ) { $host = Visitrix_Sources::host( $url ); return '' !== $host && Visitrix_Sources::host( home_url() ) !== $host; } /** * Path part of a URL, max 255 chars. * * @param mixed $url Raw value. * @return string */ private static function clean_path( $url ) { if ( ! is_string( $url ) ) { return '/'; } $path = (string) wp_parse_url( $url, PHP_URL_PATH ); $path = preg_replace( '/[^\x21-\x7E]/', '', $path ); if ( '' === $path || '/' !== $path[0] ) { $path = '/' . $path; } return substr( $path, 0, 255 ); } }