normalise_referrer_for_storage( $raw_referrer, $settings ); $request_path = $this->get_request_path( $settings ); $bot_match = ! empty( $user_agent ) ? AI_O_Optimiser_Bots::match( $user_agent ) : null; /* * Captured on shutdown, when the status this request actually answered * with is settled. Without it every hit counts the same, so a crawler * walking a list of URLs that no longer exist reads as a crawler reading * the site. Both are free to obtain and neither is personal data. */ $response_context = array( 'response_code' => function_exists( 'http_response_code' ) ? http_response_code() : null, 'request_method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '', ); if ( null === $bot_match && ! empty( $settings['near_miss_capture_enabled'] ) && AI_O_Optimiser_Bots::looks_ai_like( $user_agent ) ) { AI_O_Optimiser_Bots::record_near_miss( $user_agent, $request_path ); } $guidance_match = class_exists( 'AI_O_Optimiser_LLMS' ) ? AI_O_Optimiser_LLMS::match_tracked_guidance_path( $request_path ) : null; if ( ! empty( $settings['llms_track_hits'] ) && null !== $guidance_match ) { $this->flush_response_if_possible(); $this->log_row( array( 'hit_time' => current_time( 'mysql', true ), 'source_type' => 'guidance', 'source_slug' => $guidance_match['slug'], 'source_label' => $guidance_match['label'], 'bot_slug' => null !== $bot_match ? $bot_match['slug'] : '', 'bot_label' => null !== $bot_match ? $bot_match['label'] : '', 'request_uri' => $request_path, 'referrer' => $referrer, 'ip_hash' => $this->hash_ip(), 'user_agent' => $user_agent, ) + $response_context ); return; } if ( ! empty( $settings['enabled'] ) && null !== $bot_match ) { /* * Flushed before verification, not after. A reverse-DNS check can * take a second or two, and no visitor should wait on it -- the * response has already left by the time this runs. */ $this->flush_response_if_possible(); $this->log_row( array( 'hit_time' => current_time( 'mysql', true ), 'source_type' => 'crawler', 'source_slug' => $bot_match['slug'], 'source_label' => $bot_match['label'], 'bot_slug' => $bot_match['slug'], 'bot_label' => $bot_match['label'], 'request_uri' => $request_path, 'referrer' => $referrer, 'ip_hash' => $this->hash_ip(), 'user_agent' => $user_agent, 'verified' => $this->verify_identity( $bot_match['slug'] ), ) + $response_context ); return; } if ( empty( $settings['enable_referrals'] ) ) { return; } $referrer_match = $this->match_referral( $referer_header, $raw_referrer ); if ( null === $referrer_match ) { return; } $this->flush_response_if_possible(); $this->log_row( array( 'hit_time' => current_time( 'mysql', true ), 'source_type' => 'referral', 'source_slug' => $referrer_match['slug'], 'source_label' => $referrer_match['label'], 'bot_slug' => '', 'bot_label' => '', 'request_uri' => $request_path, 'referrer' => $this->referral_evidence( $referrer, $referrer_match ), 'ip_hash' => $this->hash_ip(), 'user_agent' => $user_agent, ) + $response_context ); } /** * Identify the assistant that sent this visit, from whichever signal it left. * * Checked in descending order of confidence. A referrer host is the assistant * naming itself. An app package is the same thing from a native app. A * campaign parameter is the assistant labelling the link it handed out, which * survives when the browser sends no referrer at all -- and is the only signal * left for a growing share of assistant traffic. * * @param string $referer_header Unsanitised Referer header. * @param string $raw_referrer The same header after esc_url_raw(). * @return array|null */ private function match_referral( $referer_header, $raw_referrer ) { ai_o_optimiser_visibility_load_referrers(); if ( '' !== $raw_referrer ) { $match = AI_O_Optimiser_Referrers::match( $raw_referrer ); if ( null !== $match ) { return $match; } } if ( '' !== $referer_header ) { $match = AI_O_Optimiser_Referrers::match_app_referrer( $referer_header ); if ( null !== $match ) { return $match; } } /* * Read from the request URI directly rather than $_GET, and before * strip_query_strings has any say: that setting governs what is written * to the log, not what the plugin is allowed to look at while the request * is in front of it. */ $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; return '' !== $uri ? AI_O_Optimiser_Referrers::match_request_uri( $uri ) : null; } /** * What to record in the referrer column for one referral. * * A host match stores the referrer as before, subject to the storage setting. * The other two signals have no referrer host to store, so the signal itself * is recorded instead -- otherwise the row would claim an assistant sent the * visit while showing nothing about how that was known. * * @param string $referrer Normalised referrer for storage. * @param array $match Referral match. * @return string */ private function referral_evidence( $referrer, $match ) { $signal = isset( $match['signal'] ) ? $match['signal'] : 'referrer'; if ( 'referrer' === $signal ) { return $referrer; } if ( 'app' === $signal ) { return 'app://' . $match['slug']; } return isset( $match['signal_value'] ) ? $this->safe_substr( '?' . $match['signal_value'], 0, 200 ) : '?utm_source'; } private function log_row( $row ) { ai_o_optimiser_core_load_db(); $insert_id = AI_O_Optimiser_DB::insert( $row ); if ( $insert_id ) { ai_o_optimiser_visibility_load_ga4(); AI_O_Optimiser_GA4::maybe_export( $row ); } } private function flush_response_if_possible() { if ( function_exists( 'fastcgi_finish_request' ) ) { fastcgi_finish_request(); } } private function get_request_path( $settings ) { $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; if ( ! empty( $settings['strip_query_strings'] ) ) { $path = wp_parse_url( $uri, PHP_URL_PATH ); $uri = $path ? $path : strtok( $uri, '?' ); } $uri = '/' . ltrim( (string) $uri, '/' ); return $this->safe_substr( $uri, 0, 500 ); } private function normalise_referrer_for_storage( $referrer, $settings ) { $referrer = esc_url_raw( (string) $referrer ); if ( '' === $referrer ) { return ''; } $mode = isset( $settings['referrer_storage'] ) ? sanitize_key( $settings['referrer_storage'] ) : 'domain'; if ( 'full' === $mode ) { return $this->safe_substr( $referrer, 0, 500 ); } $host = wp_parse_url( $referrer, PHP_URL_HOST ); return $host ? $this->safe_substr( strtolower( sanitize_text_field( $host ) ), 0, 200 ) : ''; } private function safe_substr( $text, $start, $length ) { $text = (string) $text; return function_exists( 'mb_substr' ) ? mb_substr( $text, $start, $length ) : substr( $text, $start, $length ); } /** * Whether this request proved it came from the vendor it named. * * The address is resolved here and used immediately; only its hash is ever * written. That is why the check has to happen while the request is in front * of the tracker -- a later pass over the stored rows would have nothing but * hashes to work from. * * @param string $slug Matched bot slug. * @return string Verdict, or '' when verification is off. */ private function verify_identity( $slug ) { ai_o_optimiser_visibility_load_client_ip(); if ( ! AI_O_Optimiser_Bot_Verification::enabled() ) { return ''; } return AI_O_Optimiser_Bot_Verification::verdict( $slug, AI_O_Optimiser_Client_IP::resolve() ); } private function hash_ip() { ai_o_optimiser_visibility_load_client_ip(); $ip = AI_O_Optimiser_Client_IP::resolve(); if ( empty( $ip ) ) { return ''; } $salt = ( defined( 'AUTH_SALT' ) && AUTH_SALT && false === strpos( AUTH_SALT, 'put your unique phrase here' ) ) ? AUTH_SALT : wp_salt( 'auth' ); return hash( 'sha256', $ip . $salt ); } }