'POST', 'callback' => array( $this, 'hit' ), 'permission_callback' => '__return_true', 'args' => array( 'path' => array( 'type' => 'string', 'default' => '/', 'sanitize_callback' => array( __CLASS__, 'clean_path' ) ), 'referrer' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => array( __CLASS__, 'clean_url' ) ), 'screen_width' => array( 'type' => 'integer', 'minimum' => 0, 'maximum' => 20000, 'default' => 0 ), 'utm_source' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => array( __CLASS__, 'clean_short' ) ), 'utm_medium' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => array( __CLASS__, 'clean_short' ) ), 'utm_campaign' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => array( __CLASS__, 'clean_short' ) ), ), ) ); register_rest_route( self::NS, '/dur', array( 'methods' => 'POST', 'callback' => array( $this, 'dur' ), 'permission_callback' => '__return_true', 'args' => array( 'seconds' => array( 'type' => 'integer', 'minimum' => 0, 'maximum' => 32767, 'default' => 0 ), ), ) ); register_rest_route( self::NS, '/exit', array( 'methods' => 'POST', 'callback' => array( $this, 'exit_link' ), 'permission_callback' => '__return_true', 'args' => array( 'path' => array( 'type' => 'string', 'default' => '/', 'sanitize_callback' => array( __CLASS__, 'clean_path' ) ), 'target' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => array( __CLASS__, 'clean_url' ) ), ), ) ); } public function hit( WP_REST_Request $r ): WP_REST_Response { if ( Saddle_Analytics_Migrate::blocking() || self::opted_out() ) { return $this->done(); // Tables mid-move (Phase 35), or the browser asked not to be tracked. } try { $cookies = Saddle_Analytics_Identity::cookie_mode(); if ( $this->limited() ) { // Identity survives throttling: a brand-new visitor still gets the // cookie pair, so their next un-throttled hit attributes correctly. // Cookieless needs nothing — the next hit hashes to the same id. if ( $cookies ) { Saddle_Analytics_Cookie::send( Saddle_Analytics_Cookie::visitor() ?? Saddle_Analytics_Step_Visitor::new_id(), Saddle_Analytics_Cookie::session() ?? Saddle_Analytics_Step_Visitor::new_id() ); } } else { $hit = $this->pipeline->run( $this->payload( $r ) ); if ( ! $hit->dropped() ) { $this->save->hit( $hit ); if ( $cookies ) { Saddle_Analytics_Cookie::send( $hit->visitor_id, $hit->session_id ); } else { Saddle_Analytics_Cookie::expire(); } Saddle_Analytics_Legacy::expire_cookies(); // One release (Phase 37). } else { // A JS-running bot that reached the beacon still counts in // the crawler rollup (Phase 18); humans-only paths above. $this->crawlers?->record_drop( (string) $hit->drop_reason ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'saddle-analytics: dropped hit (' . $hit->drop_reason . ')' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } } } catch ( Throwable $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'saddle-analytics: hit failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } return $this->done(); } public function dur( WP_REST_Request $r ): WP_REST_Response { if ( Saddle_Analytics_Migrate::blocking() ) { return $this->done(); } try { $current = $this->limited() ? null : Saddle_Analytics_Identity::current_session( self::ua(), self::host() ); if ( $current ) { $this->save->duration( $current[0], $current[1], (int) $r['seconds'] ); } } catch ( Throwable $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'saddle-analytics: dur failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } return $this->done(); } public function exit_link( WP_REST_Request $r ): WP_REST_Response { if ( Saddle_Analytics_Migrate::blocking() ) { return $this->done(); } try { // Exit events never create identity; they need a live session to exist. if ( '' !== $r['target'] && ! $this->limited() && Saddle_Analytics_Identity::current_session( self::ua(), self::host() ) ) { $p = $this->payload( $r ); $p->event_type = Saddle_Analytics_Event::EXIT_LINK; $p->target = $r['target']; $hit = $this->pipeline->run( $p ); if ( ! $hit->dropped() ) { $this->save->hit( $hit ); } } } catch ( Throwable $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'saddle-analytics: exit failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } return $this->done(); } private function payload( WP_REST_Request $r ): Saddle_Analytics_Payload { $p = new Saddle_Analytics_Payload(); $p->path = (string) ( $r['path'] ?? '/' ); $p->referrer = (string) ( $r['referrer'] ?? '' ); $p->utm = array( 'source' => (string) ( $r['utm_source'] ?? '' ), 'medium' => (string) ( $r['utm_medium'] ?? '' ), 'campaign' => (string) ( $r['utm_campaign'] ?? '' ), ); $p->screen_width = (int) ( $r['screen_width'] ?? 0 ) ?: null; $p->ua = self::ua(); $p->cookie_visitor = Saddle_Analytics_Cookie::visitor(); $p->cookie_session = Saddle_Analytics_Cookie::session(); $p->country = self::country(); $p->ts = gmdate( 'Y-m-d H:i:s' ); $p->is_admin_user = is_user_logged_in() && current_user_can( 'manage_options' ) && apply_filters( 'saddle_analytics_exclude_admins', ! Saddle_Analytics_Settings::get( 'track_admins' ) ); $p->is_cron = wp_doing_cron(); $p->home_host = self::host(); Saddle_Analytics_Identity::apply( $p, Saddle_Analytics_Identity::request_ip() ); Saddle_Analytics_Legacy::adopt_cookies( $p ); // One release (Phase 37). return $p; } private static function ua(): string { return Saddle_Analytics_Identity::server( 'HTTP_USER_AGENT' ); } /** Cloudflare's country header — trusted only when Cloudflare actually sent the request. */ private static function country(): ?string { $cc = strtoupper( substr( Saddle_Analytics_Identity::server( 'HTTP_CF_IPCOUNTRY' ), 0, 2 ) ); return ( 1 === preg_match( '/^[A-Z]{2}$/', $cc ) && Saddle_Analytics_Identity::via_cloudflare() ) ? $cc : null; } /** The browser asked not to be tracked (Sec-GPC / DNT) — the server-side backstop to tracker.js. */ private static function opted_out(): bool { return '1' === Saddle_Analytics_Identity::server( 'HTTP_SEC_GPC' ) || '1' === Saddle_Analytics_Identity::server( 'HTTP_DNT' ); } private static function host(): string { return (string) wp_parse_url( home_url(), PHP_URL_HOST ); } /** * Per-client rate limit in fixed one-minute windows: the window is part of * the key, so a busy client is never locked out beyond the current minute. * IPv6 clients are bucketed per /64 (one household or phone), IPv4 per * address. The key is a keyed hash (wp_hash, secret from wp-config) — never * the IP, never a plain md5 a table of the IPv4 space could reverse. */ private function limited(): bool { $ip = Saddle_Analytics_Identity::request_ip(); if ( str_contains( $ip, ':' ) && false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { $ip = substr( bin2hex( (string) inet_pton( $ip ) ), 0, 16 ); // The /64 prefix. } $key = 'saddle_analytics_rl_' . substr( wp_hash( $ip ), 0, 24 ) . '_' . (int) floor( time() / MINUTE_IN_SECONDS ); $count = (int) get_transient( $key ); if ( $count >= (int) apply_filters( 'saddle_analytics_rate_limit', 120 ) ) { return true; } set_transient( $key, $count + 1, 2 * MINUTE_IN_SECONDS ); return false; } private function done(): WP_REST_Response { $response = new WP_REST_Response( null, 204 ); $response->header( 'Cache-Control', 'no-store' ); return $response; } /** * Beacons never 400 either: WP core rejects malformed JSON / bad args * before the callback runs, so convert any pre-callback error on our * routes into the same silent 204 (rule 4). */ public function never_error( $response, $handler, $request ) { $route = (string) $request->get_route(); foreach ( array( '/hit', '/dur', '/exit' ) as $beacon ) { if ( '/' . self::NS . $beacon === $route && is_wp_error( $response ) ) { return $this->done(); } } return $response; } /** Front-end tracker: footer, defer, vanilla, <2KB. */ public function enqueue_tracker(): void { if ( Saddle_Analytics_Migrate::blocking() ) { return; } // The beacon carries no REST nonce, so /hit always sees a logged-out user: // the page render is the only place that knows it is an admin (and page // caches skip logged-in users, so this is never cached for visitors). if ( current_user_can( 'manage_options' ) && apply_filters( 'saddle_analytics_exclude_admins', ! Saddle_Analytics_Settings::get( 'track_admins' ) ) ) { return; } wp_enqueue_script( 'saddle-analytics-tracker', SADDLE_ANALYTICS_URL . 'assets/tracker.js', array(), SADDLE_ANALYTICS_VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); wp_add_inline_script( 'saddle-analytics-tracker', 'window.saddleAnalyticsCfg=' . wp_json_encode( array( 'endpoint' => esc_url_raw( rest_url( self::NS . '/' ) ) ) ) . ';', 'before' ); } /** * Browsers send the path percent-encoded, and sanitize_text_field() deletes * every %XX, so decode first or a Bengali or Cyrillic slug is stored as "/". * The query and fragment go before decoding, so an encoded "?" stays in the path. * 4096 encoded bytes hold 255 characters of any script; a character cut at * that edge is dropped, not allowed to void the whole path. */ public static function clean_path( $value ): string { $value = substr( (string) $value, 0, 4096 ); $path = rawurldecode( substr( $value, 0, strcspn( $value, '?#' ) ) ); $path = '/' . ltrim( sanitize_text_field( wp_check_invalid_utf8( $path, true ) ), '/' ); return mb_substr( $path, 0, 255 ); } public static function clean_url( $value ): string { return substr( esc_url_raw( substr( (string) $value, 0, 2048 ) ), 0, 255 ); } /** Cut by character, not byte: a split multibyte character makes the upsert fail. */ public static function clean_short( $value ): string { return mb_substr( sanitize_text_field( mb_substr( (string) $value, 0, 1024 ) ), 0, 100 ); } }