prepare(). The sniffs below cannot see that through the interpolated table names. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter // Receives tracking requests (pageviews, leave, back and events) at /?footloom_collect=. // They are answered on plugins_loaded and the request ends there, before the theme, init hooks, // rewrite rules and REST API load, which keeps each request cheap even on busy sites. class Collector { public static function register() { add_action( 'plugins_loaded', array( self::class, 'handle' ), 1 ); } public static function handle() { // Public tracking beacon sent from cached front-end pages, so it cannot carry a nonce. // Requests are checked by visitor id and page key instead. // phpcs:ignore WordPress.Security.NonceVerification.Recommended $action = isset( $_GET['footloom_collect'] ) ? sanitize_key( wp_unslash( $_GET['footloom_collect'] ) ) : ''; $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; if ( '' === $action || 'POST' !== $method ) { return; } // Read the raw body so sendBeacon (which posts as text/plain) works too. $data = json_decode( (string) file_get_contents( 'php://input' ), true ); $data = is_array( $data ) ? $data : array(); $uid = isset( $data['uid'] ) ? (string) $data['uid'] : ''; $result = array( 'ok' => false ); if ( ! self::should_ignore( $uid ) ) { switch ( $action ) { case 'hit': $result = self::hit( $data, $uid ); break; case 'leave': $result = self::leave( $data, $uid ); break; case 'back': $result = self::back( $data, $uid ); break; case 'event': $result = self::event( $data, $uid ); break; } } header( 'Content-Type: application/json; charset=utf-8' ); header( 'Cache-Control: no-store' ); header( 'X-Robots-Tag: noindex' ); echo wp_json_encode( $result ); exit; } // Prepared INSERT. Unlike $wpdb->insert() it skips the per-table "SHOW FULL COLUMNS" lookup, // saving a query on every tracking request. $wpdb->query() still checks non-ASCII text itself. public static function insert( $table, $row ) { global $wpdb; $columns = array(); $values = array(); $args = array(); foreach ( $row as $column => $value ) { $columns[] = "`$column`"; if ( null === $value ) { $values[] = 'NULL'; } else { $values[] = '%s'; $args[] = $value; } } $wpdb->query( $wpdb->prepare( "INSERT INTO $table (" . implode( ', ', $columns ) . ') VALUES (' . implode( ', ', $values ) . ')', $args ) ); return (int) $wpdb->insert_id; } // Returns a trimmed string field from the request data, or null when empty. public static function field( $data, $key, $max ) { return isset( $data[ $key ] ) && is_scalar( $data[ $key ] ) ? self::text( $data[ $key ], $max ) : null; } // True when the current visitor is an administrator and administrators are not tracked. // Tracking requests are handled before WordPress sets the current user, so there the login // cookie is validated directly. Visitors without a login cookie cost nothing extra. public static function is_excluded_user() { if ( ! get_option( 'footloom_exclude_admins', 1 ) ) { return false; } if ( did_action( 'init' ) ) { return current_user_can( 'manage_options' ); } if ( empty( $_COOKIE[ LOGGED_IN_COOKIE ] ) ) { return false; } $user_id = wp_validate_auth_cookie( '', 'logged_in' ); return $user_id && user_can( $user_id, 'manage_options' ); } // Bots, excluded administrators (cached pages can still carry the script) and bad visitor ids are ignored. public static function should_ignore( $uid ) { if ( ! preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $uid ) ) { return true; } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( self::is_bot( $ua ) ) { return true; } return self::is_excluded_user(); } public static function is_bot( $ua ) { return '' === $ua || (bool) preg_match( '/bot|crawl|spider|slurp|headless|lighthouse|pingdom|facebookexternalhit|preview|curl|wget|python|monitor/i', $ua ); } public static function parse_ua( $ua ) { if ( preg_match( '/iPad|Tablet|Android(?!.*Mobile)/i', $ua ) ) { $device = 'tablet'; } elseif ( preg_match( '/Mobi|iPhone|Android/i', $ua ) ) { $device = 'mobile'; } else { $device = 'desktop'; } $browsers = array( 'Edge' => '/Edg\//', 'Opera' => '/OPR\/|Opera/', 'Samsung Internet' => '/SamsungBrowser/', 'Firefox' => '/Firefox|FxiOS/', 'Chrome' => '/Chrome|CriOS/', 'Safari' => '/Safari/', ); $browser = 'Other'; foreach ( $browsers as $name => $pattern ) { if ( preg_match( $pattern, $ua ) ) { $browser = $name; break; } } $systems = array( 'Windows' => '/Windows/', 'iOS' => '/iPhone|iPad|iPod/', 'macOS' => '/Mac OS X|Macintosh/', 'Android' => '/Android/', 'ChromeOS' => '/CrOS/', 'Linux' => '/Linux/', ); $os = 'Other'; foreach ( $systems as $name => $pattern ) { if ( preg_match( $pattern, $ua ) ) { $os = $name; break; } } return array( $device, $browser, $os ); } public static function text( $value, $max ) { $value = sanitize_text_field( (string) $value ); return '' === $value ? null : mb_substr( $value, 0, $max ); } // Returns the external referrer host, or an empty string for direct and internal traffic. public static function referrer_host( $referrer ) { $host = strtolower( (string) wp_parse_url( $referrer, PHP_URL_HOST ) ); $host = preg_replace( '/^www\./', '', $host ); $site = preg_replace( '/^www\./', '', strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ); return $host === $site ? '' : mb_substr( $host, 0, 191 ); } // Returns the query string without tracking parameters, e.g. "?upgrade_plan=free". public static function clean_query( $query ) { // Works on the raw pairs so encoded values like redirect_url=https%3A%2F%2F... stay exactly as visited. $pairs = array_filter( explode( '&', ltrim( (string) $query, '?' ) ), function ( $pair ) { $key = strtolower( explode( '=', $pair )[0] ); return '' !== $key && 0 !== strpos( $key, 'utm_' ) && ! in_array( $key, array( 'fbclid', 'gclid', 'msclkid', '_ga', 'mc_cid', 'mc_eid' ), true ); } ); $query = preg_replace( '/[\x00-\x1F\x7F<>"\s]/', '', implode( '&', $pairs ) ); return '' === $query ? '' : mb_substr( '?' . $query, 0, 255 ); } // The browser makes a random key for each page load, so leave, back and event beacons can // point at the pageview without waiting for a reply from the server. public static function view_key( $data ) { $key = isset( $data['key'] ) ? (string) $data['key'] : ''; return preg_match( '/^[0-9a-f]{16}$/', $key ) ? $key : ''; } // Builds an event row from beacon data, or returns null when the name is empty. public static function event_row( $event, $session_id, $pageview_id, $now ) { $name = mb_substr( preg_replace( '/[^a-z0-9_]/', '', strtolower( (string) self::field( $event, 'name', 200 ) ) ), 0, 100 ); if ( '' === $name ) { return null; } $value = $event['value'] ?? null; return array( 'session_id' => $session_id, 'pageview_id' => $pageview_id, 'name' => $name, 'label' => self::field( $event, 'label', 255 ), 'value' => is_numeric( $value ) ? max( -9999999999, min( 9999999999, (float) $value ) ) : null, 'created_at' => $now, ); } // Records a pageview. A repeat pageview in an open visit costs 3 queries. public static function hit( $data, $uid ) { global $wpdb; $key = self::view_key( $data ); if ( '' === $key ) { return array( 'ok' => false ); } $now = gmdate( 'Y-m-d H:i:s' ); $path = mb_substr( '/' . ltrim( (string) self::field( $data, 'path', 255 ), '/' ), 0, 255 ); $visitors = Plugin::table( 'visitors' ); $sessions = Plugin::table( 'sessions' ); // One query finds the visitor and their open visit, if any. $find = $wpdb->prepare( "SELECT v.id, v.session_count, (SELECT s.id FROM $sessions s WHERE s.visitor_id = v.id AND s.last_seen_at > %s ORDER BY s.last_seen_at DESC LIMIT 1) AS session_id FROM $visitors v WHERE v.uid = %s", gmdate( 'Y-m-d H:i:s', time() - Plugin::SESSION_TIMEOUT ), $uid ); $visitor = $wpdb->get_row( $find ); if ( ! $visitor ) { // INSERT IGNORE handles two tabs opening at the same moment. $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO $visitors (uid, first_seen_at, last_seen_at, session_count) VALUES (%s, %s, %s, 0)", $uid, $now, $now ) ); $visitor = $wpdb->get_row( $find ); } if ( ! $visitor ) { return array( 'ok' => false ); } $session_id = (int) $visitor->session_id; if ( $session_id ) { // A new page means they are here, even if the previous page just said it was leaving. $wpdb->query( $wpdb->prepare( "UPDATE $sessions SET last_seen_at = %s, exit_path = %s, pageview_count = pageview_count + 1, last_view_key = %s, left_at = NULL WHERE id = %d", $now, $path, $key, $session_id ) ); } else { $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; list( $device, $browser, $os ) = self::parse_ua( $ua ); $referrer = isset( $data['referrer'] ) && is_string( $data['referrer'] ) ? $data['referrer'] : ''; $referrer_host = self::referrer_host( $referrer ); $session_id = self::insert( $sessions, array( 'visitor_id' => $visitor->id, 'is_new' => 0 === (int) $visitor->session_count ? 1 : 0, 'started_at' => $now, 'last_seen_at' => $now, 'entry_path' => $path, 'exit_path' => $path, 'referrer_host' => $referrer_host, 'referrer_url' => '' === $referrer_host ? '' : mb_substr( esc_url_raw( $referrer ), 0, 255 ), 'utm_source' => self::field( $data, 'utm_source', 100 ), 'utm_medium' => self::field( $data, 'utm_medium', 100 ), 'utm_campaign' => self::field( $data, 'utm_campaign', 100 ), 'country' => Geo::country(), 'device' => $device, 'browser' => $browser, 'os' => $os, 'screen_width' => min( 65535, absint( $data['screen_width'] ?? 0 ) ) ?: null, 'language' => mb_substr( preg_replace( '/[^A-Za-z-]/', '', (string) self::field( $data, 'language', 20 ) ), 0, 10 ), 'pageview_count' => 1, 'last_view_key' => $key, ) ); // The visitor row is only touched when a visit starts. $wpdb->query( $wpdb->prepare( "UPDATE $visitors SET last_seen_at = %s, session_count = session_count + 1 WHERE id = %d", $now, $visitor->id ) ); } $search_term = self::field( $data, 'search_term', 191 ); $pageview_id = self::insert( Plugin::table( 'pageviews' ), array( 'session_id' => $session_id, 'path' => $path, 'query' => self::clean_query( isset( $data['query'] ) && is_string( $data['query'] ) ? $data['query'] : '' ), 'view_key' => $key, 'title' => (string) self::field( $data, 'title', 255 ), 'post_id' => absint( $data['post_id'] ?? 0 ) ?: null, 'is_404' => empty( $data['is_404'] ) ? 0 : 1, 'search_term' => $search_term, 'search_results' => null === $search_term ? null : min( 65535, absint( $data['search_results'] ?? 0 ) ), 'created_at' => $now, ) ); // Events that fired as the page loaded (page visit rules) come with the pageview, so they can // never reach the server before the pageview they belong to. if ( $pageview_id && ! empty( $data['events'] ) && is_array( $data['events'] ) ) { foreach ( array_slice( $data['events'], 0, 10 ) as $event ) { $row = is_array( $event ) ? self::event_row( $event, $session_id, $pageview_id, $now ) : null; if ( $row ) { self::insert( Plugin::table( 'events' ), $row ); } } } return array( 'ok' => (bool) $pageview_id ); } // Sent when the page is hidden or closed. Records visible time on the page and marks the visit // as left, so the visitor drops off the live view right away. One query, which also checks the // pageview belongs to this visitor. public static function leave( $data, $uid ) { global $wpdb; $key = self::view_key( $data ); if ( '' === $key ) { return array( 'ok' => false ); } $now = gmdate( 'Y-m-d H:i:s' ); $open = gmdate( 'Y-m-d H:i:s', time() - Plugin::SESSION_TIMEOUT ); // Only the visitor's most recently active page can mark the visit as left, so a late beacon // from the previous page cannot hide someone who has already moved on to the next one. // last_seen_at only moves forward while the visit is still open, so a tab left idle for hours // and closed later does not stretch the visit. $updated = $wpdb->query( $wpdb->prepare( 'UPDATE ' . Plugin::table( 'pageviews' ) . ' p JOIN ' . Plugin::table( 'sessions' ) . ' s ON s.id = p.session_id JOIN ' . Plugin::table( 'visitors' ) . ' v ON v.id = s.visitor_id SET p.duration = GREATEST(p.duration, %d), s.left_at = IF(s.last_view_key = p.view_key, %s, s.left_at), s.last_seen_at = IF(s.last_seen_at > %s, %s, s.last_seen_at) WHERE p.view_key = %s AND v.uid = %s', min( DAY_IN_SECONDS, absint( $data['duration'] ?? 0 ) ), $now, $open, $now, $key, $uid ) ); return array( 'ok' => (bool) $updated ); } // Sent when the visitor comes back to a hidden tab. Puts them back in the live view. public static function back( $data, $uid ) { global $wpdb; $key = self::view_key( $data ); if ( '' === $key ) { return array( 'ok' => false ); } $now = gmdate( 'Y-m-d H:i:s' ); $open = gmdate( 'Y-m-d H:i:s', time() - Plugin::SESSION_TIMEOUT ); // Only while the visit is still open. After 30 idle minutes their next click starts a new visit. $updated = $wpdb->query( $wpdb->prepare( 'UPDATE ' . Plugin::table( 'pageviews' ) . ' p JOIN ' . Plugin::table( 'sessions' ) . ' s ON s.id = p.session_id JOIN ' . Plugin::table( 'visitors' ) . ' v ON v.id = s.visitor_id SET s.left_at = NULL, s.last_view_key = p.view_key, s.last_seen_at = %s WHERE p.view_key = %s AND v.uid = %s AND s.last_seen_at > %s', $now, $key, $uid, $open ) ); return array( 'ok' => (bool) $updated ); } public static function event( $data, $uid ) { global $wpdb; $key = self::view_key( $data ); if ( '' === $key ) { return array( 'ok' => false ); } $sessions = Plugin::table( 'sessions' ); $events = Plugin::table( 'events' ); // One query checks ownership and the per-page event cap. $page = $wpdb->get_row( $wpdb->prepare( 'SELECT p.id, p.session_id, (SELECT COUNT(*) FROM ' . $events . ' e WHERE e.pageview_id = p.id) AS event_count FROM ' . Plugin::table( 'pageviews' ) . " p JOIN $sessions s ON s.id = p.session_id JOIN " . Plugin::table( 'visitors' ) . ' v ON v.id = s.visitor_id WHERE p.view_key = %s AND v.uid = %s', $key, $uid ) ); if ( ! $page || $page->event_count >= 50 ) { return array( 'ok' => false ); } $now = gmdate( 'Y-m-d H:i:s' ); $row = self::event_row( $data, $page->session_id, $page->id, $now ); if ( ! $row ) { return array( 'ok' => false ); } self::insert( $events, $row ); $wpdb->query( $wpdb->prepare( "UPDATE $sessions SET last_seen_at = IF(last_seen_at > %s, %s, last_seen_at) WHERE id = %d", gmdate( 'Y-m-d H:i:s', time() - Plugin::SESSION_TIMEOUT ), $now, $page->session_id ) ); return array( 'ok' => true ); } }