prefix . 'zire_ai_requests'; } public static function install(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $charset = $wpdb->get_charset_collate(); dbDelta("CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, requested_at datetime NOT NULL, endpoint varchar(20) NOT NULL, post_id bigint(20) unsigned NOT NULL DEFAULT 0, request_method varchar(8) NOT NULL, response_status smallint(5) unsigned NOT NULL, agent_label varchar(40) NOT NULL, PRIMARY KEY (id), KEY requested_at (requested_at), KEY post_id (post_id), KEY endpoint (endpoint), KEY agent_label (agent_label) ) {$charset};"); if (!wp_next_scheduled(self::CLEANUP_HOOK)) { wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', self::CLEANUP_HOOK); } update_option('zire_ai_activity_db_version', self::DB_VERSION, false); } public static function boot(): void { add_action('plugins_loaded', [__CLASS__, 'maybe_install'], 20); add_action(self::CLEANUP_HOOK, [__CLASS__, 'cleanup']); } public static function maybe_install(): void { if (get_option('zire_ai_activity_db_version') !== self::DB_VERSION) { self::install(); } } public static function log(string $endpoint, int $post_id, string $method, int $status): void { $settings = Zire_AI_Plugin::settings(); if (empty($settings['logging']) || is_user_logged_in() || isset($_GET['zire_ai_download'])) { return; } if (!in_array($endpoint, ['markdown', 'llms.txt'], true)) { return; } // Do not let arbitrary query-string IDs create rows. The log is an // operational signal, not a record of hostile endpoint enumeration. if ($endpoint === 'markdown' && ($post_id < 1 || !get_post($post_id))) { return; } $method = strtoupper($method); $method = in_array($method, ['GET', 'HEAD'], true) ? $method : 'OTHER'; $agent = self::agent_label( sanitize_text_field( wp_unslash( (string) ( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ) ) ); // Collapse identical activity into 15-minute observations. This keeps // a public endpoint from growing the database on every repeat request // without storing an IP address or raw user-agent. $bucket = (int) floor(time() / (15 * MINUTE_IN_SECONDS)); $key = 'zire_ai_seen_' . md5(implode('|', [$endpoint, $post_id, $method, $status, $agent, $bucket])); if (get_transient($key)) { return; } set_transient($key, 1, 16 * MINUTE_IN_SECONDS); global $wpdb; $wpdb->insert(self::table(), [ 'requested_at' => current_time('mysql', true), 'endpoint' => $endpoint, 'post_id' => max(0, $post_id), 'request_method' => $method, 'response_status' => max(100, min(599, $status)), 'agent_label' => $agent, ], ['%s', '%s', '%d', '%s', '%d', '%s']); self::maintain_size(); } public static function agent_label(string $user_agent): string { $agents = [ 'OAI-SearchBot' => 'OAI-SearchBot', 'ChatGPT-User' => 'ChatGPT-User', 'GPTBot' => 'GPTBot', 'Claude-SearchBot' => 'Claude-SearchBot', 'Claude-User' => 'Claude-User', 'ClaudeBot' => 'ClaudeBot', 'PerplexityBot' => 'PerplexityBot', 'Google-Extended' => 'Google-Extended', 'Googlebot' => 'Googlebot', 'Bingbot' => 'Bingbot', 'Applebot-Extended' => 'Applebot-Extended', 'Applebot' => 'Applebot', 'Amazonbot' => 'Amazonbot', 'Bytespider' => 'Bytespider', 'CCBot' => 'CCBot', ]; foreach ($agents as $needle => $label) { if (stripos($user_agent, $needle) !== false) { return $label; } } return preg_match('/bot|crawler|spider|slurp|fetch/i', $user_agent) ? 'Other crawler' : 'Browser / other'; } public static function cleanup(): void { global $wpdb; $days = self::retention_days(); $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS)); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE requested_at < %s', self::table(), $cutoff ) ); } public static function clear(): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', self::table() ) ); } private static function maintain_size(): void { if (get_transient('zire_ai_activity_maintenance')) { return; } set_transient('zire_ai_activity_maintenance', 1, HOUR_IN_SECONDS); self::cleanup(); global $wpdb; $table = self::table(); $cutoff_id = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i ORDER BY id DESC LIMIT 1 OFFSET 10000', $table ) ); if ($cutoff_id > 0) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id <= %d', $table, $cutoff_id ) ); } } public static function retention_days(): int { $days = (int) (Zire_AI_Plugin::settings()['retention'] ?? 30); return in_array($days, [7, 30, 90], true) ? $days : 30; } public static function count_since(int $days): int { global $wpdb; $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS)); return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE requested_at >= %s', self::table(), $cutoff ) ); } public static function last_request(): string { global $wpdb; return (string) $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(requested_at) FROM %i', self::table() ) ); } public static function last_for_page(int $post_id): string { global $wpdb; return (string) $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(requested_at) FROM %i WHERE post_id = %d AND endpoint = %s', self::table(), $post_id, 'markdown' ) ); } public static function recent(int $limit = 100, string $endpoint = '', string $agent = ''): array { global $wpdb; $limit = max(1, min(250, $limit)); $table = self::table(); if (in_array($endpoint, ['markdown', 'llms.txt'], true) && $agent !== '') { return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE endpoint = %s AND agent_label = %s ORDER BY requested_at DESC, id DESC LIMIT %d', $table, $endpoint, substr($agent, 0, 40), $limit ), ARRAY_A ); } if (in_array($endpoint, ['markdown', 'llms.txt'], true)) { return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE endpoint = %s ORDER BY requested_at DESC, id DESC LIMIT %d', $table, $endpoint, $limit ), ARRAY_A ); } if ($agent !== '') { return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE agent_label = %s ORDER BY requested_at DESC, id DESC LIMIT %d', $table, substr($agent, 0, 40), $limit ), ARRAY_A ); } return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY requested_at DESC, id DESC LIMIT %d', $table, $limit ), ARRAY_A ); } public static function top_pages(int $days = 30, int $limit = 5): array { global $wpdb; $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS)); $limit = max(1, min(20, $limit)); return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT post_id, COUNT(*) AS requests, MAX(requested_at) AS last_request FROM %i WHERE endpoint = %s AND post_id > 0 AND requested_at >= %s GROUP BY post_id ORDER BY requests DESC, last_request DESC LIMIT %d', self::table(), 'markdown', $cutoff, $limit ), ARRAY_A ); } }