is_countable_request() ) { return; } $crawls = (bool) Options::get( 'citations.track_crawls', true ); $referrals = (bool) Options::get( 'citations.track_referrals', true ); if ( ! $crawls && ! $referrals ) { return; } $url = $this->current_url(); if ( '' === $url ) { return; } $post_id = is_singular() ? (int) get_queried_object_id() : 0; $post_id = $post_id > 0 ? $post_id : null; if ( $crawls && $this->track_crawl( $url, $post_id ) ) { return; } if ( $referrals ) { $this->track_referral( $url, $post_id ); } } /** * Loads the beacon script, for visitors only. * * The engine hosts and tags travel with it as inline configuration, so the * browser can decide whether there is anything to report before it opens a * connection: a page nobody was referred to sends nothing at all. * * @return void */ public function enqueue_beacon(): void { if ( ! (bool) Options::get( 'citations.track_referrals', true ) || current_user_can( 'edit_posts' ) ) { return; } wp_enqueue_script( self::BEACON_SCRIPT, ACID_AEO_URL . 'assets/public/beacon.js', [], ACID_AEO_VERSION, [ 'in_footer' => true, 'strategy' => 'defer', ] ); wp_add_inline_script( self::BEACON_SCRIPT, 'window.acidAeoBeacon = ' . wp_json_encode( [ 'endpoint' => rest_url( self::REST_NAMESPACE . '/beacon' ), 'hosts' => Engines::host_list( $this->extra_hosts() ), 'utm' => Engines::utm_list(), ] ) . ';', 'before' ); } /** * Registers the public beacon route. * * Public by necessity: the callers are visitors, and a nonce would be * cached along with the page it was printed into. What replaces it is that * the route accepts nothing it does not verify — the URL has to belong to * this site, the referrer has to match a known engine — writes no input * back into the response, and is rate limited per IP hash. * * Every argument carries `rest_validate_request_arg` as well as a * sanitizer, because WordPress skips the schema's own type check as soon as * an explicit `sanitize_callback` is set: without it a caller could send * `u[]=x` and hand an array to a function expecting a string. The * sanitizers refuse anything that is not a string on their own too, so * neither half depends on the other. * * @return void */ public function register_routes(): void { register_rest_route( self::REST_NAMESPACE, '/beacon', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'handle_beacon' ], 'permission_callback' => '__return_true', 'args' => [ 'u' => [ 'type' => 'string', 'required' => true, 'maxLength' => self::URL_MAX, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => [ self::class, 'sanitize_url_arg' ], ], 's' => [ 'type' => 'string', 'required' => false, 'default' => '', 'maxLength' => 100, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => [ self::class, 'sanitize_text_arg' ], ], 'r' => [ 'type' => 'string', 'required' => false, 'default' => '', 'maxLength' => self::URL_MAX, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => [ self::class, 'sanitize_url_arg' ], ], ], ] ); } /** * Sanitizes one URL argument of the beacon route. * * Anything that is not a string — an array from `u[]=x`, an object from a * JSON body — becomes an empty string rather than a `TypeError`, which is * what `esc_url_raw()` would answer with. * * @param mixed $value Raw argument value. * @return string Sanitized URL, or an empty string. */ public static function sanitize_url_arg( $value ): string { return is_string( $value ) ? (string) esc_url_raw( $value ) : ''; } /** * Sanitizes one text argument of the beacon route. * * @param mixed $value Raw argument value. * @return string Sanitized text, or an empty string. */ public static function sanitize_text_arg( $value ): string { return is_string( $value ) ? sanitize_text_field( $value ) : ''; } /** * Records one referral reported by the beacon. * * Answers with a status and nothing else. `201` means the event was * written, `204` that there was nothing to write — a URL that is not ours, * a referrer no engine claims, a visit `track()` has already counted, or * the tracking switched off — and `429` * that this IP hash has already sent its minute's worth. None of them * repeats a single character the caller sent. * * @param WP_REST_Request $request The request. * @return WP_REST_Response Empty response carrying the status. */ public function handle_beacon( WP_REST_Request $request ): WP_REST_Response { if ( ! (bool) Options::get( 'citations.enabled', true ) || ! (bool) Options::get( 'citations.track_referrals', true ) ) { return new WP_REST_Response( null, 204 ); } $url = self::clean_url( (string) $request->get_param( 'u' ) ); if ( ! $this->is_own_url( $url ) ) { return new WP_REST_Response( null, 204 ); } $engine = Engines::match_referrer( (string) $request->get_param( 'r' ), [ 'utm_source' => (string) $request->get_param( 's' ), '_own_host' => $this->own_host(), ], $this->extra_hosts() ); if ( null === $engine ) { return new WP_REST_Response( null, 204 ); } $ip_hash = self::ip_hash(); if ( $this->is_rate_limited( $ip_hash ) ) { return new WP_REST_Response( null, 429 ); } if ( $this->is_repeat_referral( $ip_hash, $url ) ) { return new WP_REST_Response( null, 204 ); } $post_id = (int) url_to_postid( $url ); Store::insert( 'referral', $engine, $url, $post_id > 0 ? $post_id : null, $ip_hash ); return new WP_REST_Response( null, 201 ); } /** * Audit checks contributed by this module. * * @return array Checks. */ public function checks(): array { return [ $this->table_check(), $this->beacon_check(), ]; } /** * Whether the table the events are written to exists. * * Without it every crawl and every referral is dropped on the floor, and * the dashboard shows a confident zero. * * @return Check The finding. */ private function table_check(): Check { global $wpdb; $id = 'citations_table'; $label = __( 'Citation events table', 'acid-aeo' ); $table = Store::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Asking whether the plugin's own table exists; there is no WordPress API for it, and a cached answer would hide the very problem this looks for. $found = (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); if ( $found === $table ) { return Check::pass( $id, $this->slug(), $label, __( 'The events table exists, so crawls and referrals are being recorded.', 'acid-aeo' ), Check::WEIGHT_CRITICAL ); } return Check::fail( $id, $this->slug(), $label, __( 'The events table is missing, so every crawl and referral is discarded. Deactivate and reactivate the plugin to create it.', 'acid-aeo' ), Check::WEIGHT_CRITICAL ); } /** * Whether a page cache is quietly hiding the crawler traffic. * * Server side tracking runs while WordPress builds the page. A full page * cache answers without building anything, so the visits that matter most * are exactly the ones never counted — unless the beacon is on. * * @return Check The finding. */ private function beacon_check(): Check { $id = 'citations_beacon_with_cache'; $label = __( 'Tracking survives the page cache', 'acid-aeo' ); $cache = Environment::detect()['cache_plugin'] ?? null; if ( ! is_array( $cache ) ) { return Check::pass( $id, $this->slug(), $label, __( 'No page cache plugin was detected, so server side tracking sees every request.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } if ( (bool) Options::get( 'citations.beacon', false ) ) { return Check::pass( $id, $this->slug(), $label, sprintf( /* translators: %s: cache plugin name. */ __( '%s is active and the beacon is on, so referrals are still recorded from cached pages.', 'acid-aeo' ), (string) ( $cache['name'] ?? '' ) ), Check::WEIGHT_IMPORTANT ); } return Check::warn( $id, $this->slug(), $label, sprintf( /* translators: %s: cache plugin name. */ __( '%s serves pages without running WordPress, so referrals from cached pages are never counted. Switch on the beacon to record them in the browser instead.', 'acid-aeo' ), (string) ( $cache['name'] ?? '' ) ), Check::WEIGHT_IMPORTANT, 'enable_beacon' ); } /** * Adds this module's paragraph to the site's privacy policy draft. * * @return void */ public function register_privacy_content(): void { $days = (int) Options::get( 'citations.retention_days', 90 ); $text = implode( "\n\n", [ __( 'This site keeps a record of visits from AI crawlers, and of visitors who arrive from an AI assistant such as ChatGPT, Perplexity, Claude or Gemini.', 'acid-aeo' ), __( 'Each record holds the address of the page that was visited, the name of the crawler or assistant, the time of the visit, and a shortened one-way hash of the visitor IP address salted with a value that changes every day. The hash is used to limit how often the same visitor can be recorded; it is not reversible, it cannot be linked to a person, and it cannot be followed from one day to the next. No cookie is set and no other personal data is stored.', 'acid-aeo' ), sprintf( /* translators: %d: number of days records are kept. */ _n( 'Records are deleted automatically after %d day.', 'Records are deleted automatically after %d days.', $days, 'acid-aeo' ), $days ), __( 'The records are used only to produce aggregate statistics about which pages AI systems read and cite.', 'acid-aeo' ), ] ); wp_add_privacy_policy_content( 'Acid AEO', wp_kses_post( wpautop( $text ) ) ); } /** * Whether this request is a visitor or a crawler reading a page. * * @return bool True when the request is worth counting. */ private function is_countable_request(): bool { if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return false; } if ( is_feed() || is_preview() || is_embed() || is_404() ) { return false; } return ! current_user_can( 'edit_posts' ); } /** * Records a crawl, when the user agent belongs to a known AI crawler. * * One crawler and one URL make at most one event a minute: a crawler that * retries, follows a redirect back or fetches a page for its images would * otherwise write a row per request, and a burst says nothing a single line * does not already say. * * @param string $url URL being requested. * @param int|null $post_id Post the URL resolves to, when it is a single post. * @return bool True when the request came from a known crawler, whether or not it was written. */ private function track_crawl( string $url, ?int $post_id ): bool { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading a request header to identify a crawler; nothing is changed and no form is involved. $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( '' === $user_agent ) { return false; } $bot = Registry::match_user_agent( $user_agent ); if ( null === $bot ) { return false; } $key = 'acid_aeo_rl_' . md5( $bot . '|' . $url ); if ( false === get_transient( $key ) ) { set_transient( $key, 1, self::CRAWL_INTERVAL ); Store::insert( 'crawl', $bot, $url, $post_id, self::ip_hash() ); } return true; } /** * Records a referral, when the visitor came from a known AI assistant. * * @param string $url URL being requested. * @param int|null $post_id Post the URL resolves to, when it is a single post. * @return void */ private function track_referral( string $url, ?int $post_id ): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the referrer and the campaign tag of a page view; nothing is changed and no form is involved. $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the referrer and the campaign tag of a page view; nothing is changed and no form is involved. $utm = isset( $_GET['utm_source'] ) ? sanitize_text_field( wp_unslash( $_GET['utm_source'] ) ) : ''; if ( '' === $referer && '' === $utm ) { return; } $engine = Engines::match_referrer( $referer, [ 'utm_source' => $utm, '_own_host' => $this->own_host(), ], $this->extra_hosts() ); if ( null === $engine ) { return; } $ip_hash = self::ip_hash(); if ( ! $this->is_repeat_referral( $ip_hash, $url ) ) { Store::insert( 'referral', $engine, $url, $post_id, $ip_hash ); } } /** * Whether this visitor already had this referral counted a moment ago. * * Both halves of the module can see the same arrival: on a page a cache did * not serve, PHP matches the referrer and so does the beacon a moment later, * and one visit would become two rows. The first of them claims the visitor * and the URL for a minute, which is far longer than the gap between the two * and far shorter than a second, real visit from the same assistant. * * @param string $ip_hash Hash identifying the visitor for today. * @param string $url URL that was visited. * @return bool True when this referral has already been recorded. */ private function is_repeat_referral( string $ip_hash, string $url ): bool { $key = 'acid_aeo_ref_' . md5( $ip_hash . '|' . $url ); if ( false !== get_transient( $key ) ) { return true; } set_transient( $key, 1, self::REFERRAL_INTERVAL ); return false; } /** * The URL of the current request. * * Built from the scheme, host and port of `home_url()` plus the raw request * path, rather than from the `Host` header a client controls, and rather * than `home_url( add_query_arg( [] ) )` — which repeats the directory of a * site installed in a subdirectory, because the request path already * carries it. * * A request path that is not a path — the absolute form a proxy may pass * through — is refused rather than pasted onto the site's host, which would * store a URL that never existed. * * @return string Absolute URL, or an empty string when the request has no usable path. */ private function current_url(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the path of the current page view; nothing is changed and no form is involved. $path = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; if ( '' === $path || ! str_starts_with( $path, '/' ) ) { return ''; } $home = wp_parse_url( (string) home_url( '/' ) ); if ( ! is_array( $home ) || empty( $home['host'] ) || empty( $home['scheme'] ) ) { return ''; } $port = isset( $home['port'] ) ? ':' . (int) $home['port'] : ''; return (string) $home['scheme'] . '://' . (string) $home['host'] . $port . $path; } /** * Cuts a reported URL down to what may be stored. * * The fragment goes first: it never reaches a server, it is not part of the * page that was cited, and keeping it would file one page under as many * URLs as it has anchors. What is left is cut to the width of the column * it is headed for, before anything else looks it up. * * @param string $url URL as it was reported. * @return string URL without its fragment, at most `URL_MAX` characters. */ private static function clean_url( string $url ): string { $url = explode( '#', $url, 2 )[0]; if ( function_exists( 'mb_substr' ) ) { return mb_strlen( $url ) > self::URL_MAX ? mb_substr( $url, 0, self::URL_MAX ) : $url; } return strlen( $url ) > self::URL_MAX ? substr( $url, 0, self::URL_MAX ) : $url; } /** * Whether a URL belongs to this site. * * The scheme has to be `http` or `https`, the host has to be the site's own * — not a subdomain of it and not a lookalike — and the path has to sit * inside the site's own directory, so a beacon call cannot make up a URL on * somebody else's domain. The scheme is checked but not compared against * the site's: a site reachable over both answers on both. * * @param string $url URL to test. * @return bool True when the URL is one of this site's own pages. */ private function is_own_url( string $url ): bool { if ( '' === $url ) { return false; } $parts = wp_parse_url( $url ); $home = wp_parse_url( (string) home_url( '/' ) ); if ( ! is_array( $parts ) || ! is_array( $home ) || empty( $parts['host'] ) || empty( $home['host'] ) ) { return false; } $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; if ( 'http' !== $scheme && 'https' !== $scheme ) { return false; } if ( strtolower( (string) $parts['host'] ) !== strtolower( (string) $home['host'] ) ) { return false; } $base = isset( $home['path'] ) ? untrailingslashit( (string) $home['path'] ) : ''; $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/'; return '' === $base || $path === $base || str_starts_with( $path, $base . '/' ); } /** * This site's own host. * * @return string Host of `home_url()`, lowercase. */ private function own_host(): string { return Engines::normalize_host( (string) home_url( '/' ) ); } /** * The extra engine hosts a site added in its settings. * * One entry per line, either `host` or `host=slug`. Without a slug the host * becomes one, so the events of a self-hosted assistant still group * together in the reports. * * @return array Host => engine slug. */ private function extra_hosts(): array { $stored = Options::get( 'citations.engines_extra', [] ); $extra = []; foreach ( is_array( $stored ) ? $stored : [] as $entry ) { $entry = trim( (string) $entry ); if ( '' === $entry ) { continue; } $slug = ''; if ( str_contains( $entry, '=' ) ) { [ $entry, $slug ] = explode( '=', $entry, 2 ); } $host = Engines::normalize_host( $entry ); if ( '' === $host ) { continue; } $slug = sanitize_key( $slug ); $extra[ $host ] = '' !== $slug ? $slug : sanitize_key( str_replace( '.', '-', $host ) ); } return $extra; } /** * Whether this IP hash has used up its minute of beacon calls. * * @param string $ip_hash Hash identifying the caller for today. * @return bool True when the call should be refused. */ private function is_rate_limited( string $ip_hash ): bool { $key = 'acid_aeo_beacon_' . $ip_hash; $count = (int) get_transient( $key ); if ( $count >= self::BEACON_LIMIT ) { return true; } set_transient( $key, $count + 1, MINUTE_IN_SECONDS ); return false; } /** * A visitor's IP address, hashed beyond recovery. * * SHA-256 over the address, the site's nonce salt and today's UTC date, * cut to 16 characters. The salt keeps the hash from being reproduced * without the site's own secrets, the date makes yesterday's hash useless * for recognising the same visitor today, and the truncation means several * addresses share one value by design. * * @return string 16 character hash. */ public static function ip_hash(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the connecting address to build a one-way hash; nothing is changed and no form is involved. $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) : ''; /** * Filters the visitor address used for citation de-duplication and * the beacon's rate limit. * * Behind a proxy or CDN `REMOTE_ADDR` is the proxy, so every visitor * shares one bucket. Return the real client address here, for example * from `HTTP_CF_CONNECTING_IP` after checking the request came from * the CDN. The address is hashed immediately and never stored. * * @param string $remote The address WordPress saw. */ $ip = (string) apply_filters( 'acid_aeo_client_ip', $remote ); return substr( hash( 'sha256', $ip . wp_salt( 'nonce' ) . gmdate( 'Y-m-d' ) ), 0, 16 ); } }