prefix . self::TABLE_SUFFIX; } /** * Create the table if missing (idempotent, throttled with a transient so * normal requests don't pay for a schema check). Multisite-safe: each * site creates its own table lazily on first use, the same self-healing * pattern as the llms.txt rewrite rule - Pro status (and therefore first * use) can happen on any site at any time, not only at activation. */ private function maybe_create_table() { if ( get_transient( self::TABLE_FLAG_CACHE ) ) { return; } global $wpdb; $table = $this->table(); $charset_collate = $wpdb->get_charset_collate(); // Table/column identifiers can't be bound via $wpdb->prepare() placeholders; // $table is built from $wpdb->prefix + a fixed constant, never user input. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( "CREATE TABLE IF NOT EXISTS `{$table}` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, hit_type VARCHAR(10) NOT NULL, source VARCHAR(64) NOT NULL, url VARCHAR(255) NOT NULL, hit_time DATETIME NOT NULL, PRIMARY KEY (id), KEY hit_time (hit_time), KEY hit_type_source (hit_type, source) ) {$charset_collate}" ); // phpcs:enable set_transient( self::TABLE_FLAG_CACHE, 1, HOUR_IN_SECONDS ); } /** * Explicitly (re)create the table for the current site. Called on * activation so the common case needs no lazy-create on first pageview. */ public function install() { delete_transient( self::TABLE_FLAG_CACHE ); $this->maybe_create_table(); } /** * Drop the table for the current site (uninstall). */ public static function uninstall_site() { global $wpdb; $table = $wpdb->prefix . self::TABLE_SUFFIX; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is $wpdb->prefix + a fixed constant, never user input; identifiers can't use prepare() placeholders. $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); } /** * Inspect the current request and log it if it matches a known AI * crawler or a known AI-engine referrer. Cheap: one regex pass over two * headers already present on every request, no extra I/O except the * (throttled) table-exists check and the insert itself. */ public function maybe_log() { if ( is_admin() || is_robots() || is_trackback() || is_feed() ) { return; } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( false !== strpos( $ua, 'Evoka-Audit/' ) ) { return; // Don't let the plugin's own audit fetches pollute the log. } $hit_type = ''; $source = ''; foreach ( Evoka_Crawler_Controls::all_bots() as $bot ) { if ( false !== stripos( $ua, $bot ) ) { $hit_type = 'crawler'; $source = $bot; break; } } if ( '' === $hit_type ) { $referer = isset( $_SERVER['HTTP_REFERER'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; $host = $referer ? (string) wp_parse_url( $referer, PHP_URL_HOST ) : ''; if ( $host ) { foreach ( self::referrer_hosts() as $known ) { if ( $host === $known || ( strlen( $host ) > strlen( $known ) && substr( $host, -strlen( $known ) - 1 ) === '.' . $known ) ) { $hit_type = 'referral'; $source = $known; break; } } } } if ( '' === $hit_type ) { return; } $this->maybe_create_table(); global $wpdb; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $this->table(), array( 'hit_type' => $hit_type, 'source' => $source, 'url' => esc_url_raw( substr( $this->current_path(), 0, 255 ) ), 'hit_time' => current_time( 'mysql', true ), ), array( '%s', '%s', '%s', '%s' ) ); } /** * The current request's path + query, for display (never a full URL with * host, to keep the table small and avoid storing scheme/host noise). * * @return string */ private function current_path() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; return wp_strip_all_tags( $uri ); } /** * Remove rows older than the retention window. */ public function prune() { if ( ! get_transient( self::TABLE_FLAG_CACHE ) ) { return; // Table very likely doesn't exist yet; nothing to prune. } global $wpdb; $table = $this->table(); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is $wpdb->prefix + a fixed constant, never user input. $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE hit_time < %s", gmdate( 'Y-m-d H:i:s', time() - self::RETENTION_DAYS * DAY_IN_SECONDS ) ) ); // phpcs:enable } /** * Summary for the dashboard card: counts for the last 7 and 30 days, and * the top crawlers/referrers. * * @return array */ public function summary() { global $wpdb; $table = $this->table(); $since = gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ); $since7 = gmdate( 'Y-m-d H:i:s', time() - 7 * DAY_IN_SECONDS ); // $table is $wpdb->prefix + a fixed constant, never user input. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); if ( ! $exists ) { return array( 'crawler_30d' => 0, 'referral_30d' => 0, 'crawler_7d' => 0, 'referral_7d' => 0, 'by_source' => array(), 'top_urls' => array(), ); } $by_source = $wpdb->get_results( $wpdb->prepare( "SELECT hit_type, source, COUNT(*) AS n FROM `{$table}` WHERE hit_time >= %s GROUP BY hit_type, source ORDER BY n DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $since ), ARRAY_A ); $top_urls = $wpdb->get_results( $wpdb->prepare( "SELECT url, COUNT(*) AS n FROM `{$table}` WHERE hit_time >= %s GROUP BY url ORDER BY n DESC LIMIT 10", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $since ), ARRAY_A ); $crawler_30d = 0; $referral_30d = 0; foreach ( (array) $by_source as $row ) { if ( 'crawler' === $row['hit_type'] ) { $crawler_30d += (int) $row['n']; } else { $referral_30d += (int) $row['n']; } } $crawler_7d = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE hit_type = 'crawler' AND hit_time >= %s", $since7 ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $referral_7d = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE hit_type = 'referral' AND hit_time >= %s", $since7 ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:enable return array( 'crawler_30d' => $crawler_30d, 'referral_30d' => $referral_30d, 'crawler_7d' => $crawler_7d, 'referral_7d' => $referral_7d, 'by_source' => (array) $by_source, 'top_urls' => (array) $top_urls, ); } /** * A page of raw log rows, most recent first. * * @param int $limit Max rows. * @param int $offset Offset. * @return array */ public function recent( $limit = 50, $offset = 0 ) { global $wpdb; $table = $this->table(); // $table is $wpdb->prefix + a fixed constant, never user input. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); if ( ! $exists ) { return array(); } return (array) $wpdb->get_results( $wpdb->prepare( "SELECT hit_type, source, url, hit_time FROM `{$table}` ORDER BY hit_time DESC LIMIT %d OFFSET %d", $limit, $offset ), ARRAY_A ); // phpcs:enable } }