audit.rs (12306B)
1 #![cfg(feature = "audit")] 2 3 use std::collections::HashSet; 4 use std::sync::RwLock; 5 6 use crate::utils::nostr::{normalize_nip05, public_key_to_npub}; 7 use once_cell::sync::Lazy; 8 use regex::Regex; 9 use tracing::info; 10 11 use crate::domain::resolvers::profile::ProfileResolver; 12 use crate::relay::event::RelayIndexerEvent; 13 use radroots_events::comment::RadrootsCommentEventIndex; 14 use radroots_events::listing::RadrootsListingEventIndex; 15 use radroots_events::profile::RadrootsProfileEventIndex; 16 17 #[derive(Clone, Debug)] 18 pub struct AuditFilter { 19 pub enabled: bool, 20 pub kinds: Option<HashSet<u64>>, 21 pub authors: HashSet<String>, 22 pub npubs: HashSet<String>, 23 pub nip05_full: HashSet<String>, 24 pub nip05_local: HashSet<String>, 25 pub content_re: Option<Regex>, 26 pub created_at_min: Option<u32>, 27 pub created_at_max: Option<u32>, 28 } 29 30 impl Default for AuditFilter { 31 fn default() -> Self { 32 Self { 33 enabled: false, 34 kinds: None, 35 authors: HashSet::new(), 36 npubs: HashSet::new(), 37 nip05_full: HashSet::new(), 38 nip05_local: HashSet::new(), 39 content_re: None, 40 created_at_min: None, 41 created_at_max: None, 42 } 43 } 44 } 45 46 impl AuditFilter { 47 pub fn from_env() -> Self { 48 let mut f = Self::default(); 49 50 f.enabled = std::env::var("AUDIT_ENABLED") 51 .map(|v| v.eq_ignore_ascii_case("true") || v == "1") 52 .unwrap_or(false); 53 54 if let Ok(v) = std::env::var("AUDIT_KINDS") { 55 let set = v 56 .split(',') 57 .filter_map(|s| s.trim().parse::<u64>().ok()) 58 .collect::<HashSet<_>>(); 59 if !set.is_empty() { 60 f.kinds = Some(set); 61 } 62 } 63 64 let parse_set = |key: &str| -> HashSet<String> { 65 std::env::var(key) 66 .ok() 67 .map(|s| { 68 s.split(',') 69 .map(|x| x.trim().to_lowercase()) 70 .filter(|x| !x.is_empty()) 71 .collect() 72 }) 73 .unwrap_or_default() 74 }; 75 76 f.authors = parse_set("AUDIT_AUTHORS"); 77 f.npubs = parse_set("AUDIT_NPUBS"); 78 f.nip05_full = parse_set("AUDIT_NIP05"); 79 f.nip05_local = parse_set("AUDIT_NIP05_LOCAL"); 80 81 if let Ok(rx) = std::env::var("AUDIT_CONTENT_RE") { 82 if !rx.trim().is_empty() { 83 if let Ok(re) = Regex::new(&format!("(?i){}", rx)) { 84 f.content_re = Some(re); 85 } 86 } 87 } 88 89 f.created_at_min = std::env::var("AUDIT_CREATED_AT_MIN") 90 .ok() 91 .and_then(|s| s.parse().ok()); 92 f.created_at_max = std::env::var("AUDIT_CREATED_AT_MAX") 93 .ok() 94 .and_then(|s| s.parse().ok()); 95 96 f 97 } 98 } 99 100 #[derive(Clone)] 101 struct AuditState { 102 filter: AuditFilter, 103 resolver: Option<ProfileResolver>, 104 } 105 106 static STATE: Lazy<RwLock<AuditState>> = Lazy::new(|| { 107 RwLock::new(AuditState { 108 filter: AuditFilter::from_env(), 109 resolver: None, 110 }) 111 }); 112 113 pub fn reload_from_env() { 114 if let Ok(mut w) = STATE.write() { 115 w.filter = AuditFilter::from_env(); 116 } 117 } 118 119 pub fn set_profile_resolver(resolver: ProfileResolver) { 120 if let Ok(mut w) = STATE.write() { 121 w.resolver = Some(resolver); 122 } 123 } 124 125 fn nip05_parts_from_metadata(nip05: &str) -> (String, String) { 126 let (full, local, _) = normalize_nip05(nip05); 127 (full, local) 128 } 129 130 fn should_log( 131 author_hex: &str, 132 kind_u64: u64, 133 created_at: u32, 134 content: &str, 135 npub_opt: Option<String>, 136 nip05_full_opt: Option<String>, 137 nip05_local_opt: Option<String>, 138 ) -> bool { 139 let filter = STATE.read().ok().map(|s| s.filter.clone()); 140 let Some(f) = filter else { 141 return false; 142 }; 143 if !f.enabled { 144 return false; 145 } 146 147 if let Some(kinds) = &f.kinds { 148 if !kinds.contains(&kind_u64) { 149 return false; 150 } 151 } 152 153 if !f.authors.is_empty() && !f.authors.contains(&author_hex.to_lowercase()) { 154 return false; 155 } 156 157 if !f.npubs.is_empty() { 158 let pass = npub_opt 159 .as_ref() 160 .map(|n| f.npubs.contains(&n.to_lowercase())) 161 .unwrap_or(false); 162 if !pass { 163 return false; 164 } 165 } 166 167 if !f.nip05_full.is_empty() { 168 let pass = nip05_full_opt 169 .as_ref() 170 .map(|n| f.nip05_full.contains(&n.to_lowercase())) 171 .unwrap_or(false); 172 if !pass { 173 return false; 174 } 175 } 176 177 if !f.nip05_local.is_empty() { 178 let pass = nip05_local_opt 179 .as_ref() 180 .map(|n| f.nip05_local.contains(&n.to_lowercase())) 181 .unwrap_or(false); 182 if !pass { 183 return false; 184 } 185 } 186 187 if let Some(min) = f.created_at_min { 188 if created_at < min { 189 return false; 190 } 191 } 192 if let Some(max) = f.created_at_max { 193 if created_at > max { 194 return false; 195 } 196 } 197 198 if let Some(re) = &f.content_re { 199 if !re.is_match(content) { 200 return false; 201 } 202 } 203 204 true 205 } 206 207 #[inline] 208 pub fn log_indexer_event(idx: &RelayIndexerEvent) { 209 let need_npub = STATE 210 .read() 211 .ok() 212 .map(|s| !s.filter.npubs.is_empty()) 213 .unwrap_or(false); 214 let npub_opt = if need_npub { 215 public_key_to_npub(&idx.author).ok() 216 } else { 217 None 218 }; 219 220 let (need_full, need_local) = STATE 221 .read() 222 .ok() 223 .map(|s| { 224 ( 225 !s.filter.nip05_full.is_empty(), 226 !s.filter.nip05_local.is_empty(), 227 ) 228 }) 229 .unwrap_or((false, false)); 230 231 let (nip05_full_opt, nip05_local_opt) = if need_full || need_local { 232 if let Ok(s) = STATE.read() { 233 if let Some(res) = s.resolver.as_ref() { 234 let full = if need_full { 235 res.nip05_full_for_author(&idx.author) 236 .map(|s| s.to_string()) 237 } else { 238 None 239 }; 240 let local = if need_local { 241 res.nip05_local_for_author(&idx.author) 242 .map(|s| s.to_string()) 243 } else { 244 None 245 }; 246 (full, local) 247 } else { 248 (None, None) 249 } 250 } else { 251 (None, None) 252 } 253 } else { 254 (None, None) 255 }; 256 257 if !should_log( 258 &idx.author, 259 idx.kind.as_u64(), 260 idx.created_at, 261 &idx.content, 262 npub_opt, 263 nip05_full_opt, 264 nip05_local_opt, 265 ) { 266 return; 267 } 268 269 let tags_json = 270 serde_json::to_string(&idx.tags).unwrap_or_else(|_| "Error serializing tags".into()); 271 info!( 272 target: "audit", 273 kind = idx.kind.as_u64(), 274 id = %idx.id, 275 author = %idx.author, 276 created_at = idx.created_at, 277 tags = %tags_json, 278 content = %idx.content, 279 "AUDIT: relay indexer event" 280 ); 281 } 282 283 #[inline] 284 pub fn log_profile_event(evt: &RadrootsProfileEventIndex) { 285 let (nip05_full_opt, nip05_local_opt) = evt 286 .metadata 287 .profile 288 .nip05 289 .as_ref() 290 .map(|n| { 291 let (full, local) = nip05_parts_from_metadata(n); 292 (Some(full), Some(local)) 293 }) 294 .unwrap_or((None, None)); 295 296 let need_npub = STATE 297 .read() 298 .ok() 299 .map(|s| !s.filter.npubs.is_empty()) 300 .unwrap_or(false); 301 let npub_opt = if need_npub { 302 public_key_to_npub(&evt.event.author).ok() 303 } else { 304 None 305 }; 306 307 if !should_log( 308 &evt.event.author, 309 u64::from(evt.event.kind), 310 evt.event.created_at, 311 &evt.event.content, 312 npub_opt, 313 nip05_full_opt, 314 nip05_local_opt, 315 ) { 316 return; 317 } 318 319 if let Ok(json) = serde_json::to_string(evt) { 320 info!( 321 target = "audit", 322 kind = evt.event.kind, 323 id = %evt.event.id, 324 author = %evt.event.author, 325 created_at = evt.event.created_at, 326 processed_json = %json, 327 "AUDIT: processed metadata" 328 ); 329 } 330 } 331 332 #[inline] 333 pub fn log_listing_event(evt: &RadrootsListingEventIndex) { 334 let need_npub = STATE 335 .read() 336 .ok() 337 .map(|s| !s.filter.npubs.is_empty()) 338 .unwrap_or(false); 339 let npub_opt = if need_npub { 340 public_key_to_npub(&evt.event.author).ok() 341 } else { 342 None 343 }; 344 345 let (need_full, need_local) = STATE 346 .read() 347 .ok() 348 .map(|s| { 349 ( 350 !s.filter.nip05_full.is_empty(), 351 !s.filter.nip05_local.is_empty(), 352 ) 353 }) 354 .unwrap_or((false, false)); 355 356 let (nip05_full_opt, nip05_local_opt) = if need_full || need_local { 357 if let Ok(s) = STATE.read() { 358 if let Some(res) = s.resolver.as_ref() { 359 let full = if need_full { 360 res.nip05_full_for_author(&evt.event.author) 361 .map(|s| s.to_string()) 362 } else { 363 None 364 }; 365 let local = if need_local { 366 res.nip05_local_for_author(&evt.event.author) 367 .map(|s| s.to_string()) 368 } else { 369 None 370 }; 371 (full, local) 372 } else { 373 (None, None) 374 } 375 } else { 376 (None, None) 377 } 378 } else { 379 (None, None) 380 }; 381 382 if !should_log( 383 &evt.event.author, 384 evt.event.kind as u64, 385 evt.event.created_at, 386 &evt.event.content, 387 npub_opt, 388 nip05_full_opt, 389 nip05_local_opt, 390 ) { 391 return; 392 } 393 394 if let Ok(json) = serde_json::to_string(evt) { 395 info!( 396 target = "audit", 397 kind = evt.event.kind, 398 id = %evt.event.id, 399 author = %evt.event.author, 400 created_at = evt.event.created_at, 401 processed_json = %json, 402 "AUDIT: processed listing" 403 ); 404 } 405 } 406 407 #[inline] 408 pub fn log_comment_event(evt: &RadrootsCommentEventIndex) { 409 let need_npub = STATE 410 .read() 411 .ok() 412 .map(|s| !s.filter.npubs.is_empty()) 413 .unwrap_or(false); 414 let npub_opt = if need_npub { 415 public_key_to_npub(&evt.event.author).ok() 416 } else { 417 None 418 }; 419 420 let (need_full, need_local) = STATE 421 .read() 422 .ok() 423 .map(|s| { 424 ( 425 !s.filter.nip05_full.is_empty(), 426 !s.filter.nip05_local.is_empty(), 427 ) 428 }) 429 .unwrap_or((false, false)); 430 431 let (nip05_full_opt, nip05_local_opt) = if need_full || need_local { 432 if let Ok(s) = STATE.read() { 433 if let Some(res) = s.resolver.as_ref() { 434 let full = if need_full { 435 res.nip05_full_for_author(&evt.event.author) 436 .map(|s| s.to_string()) 437 } else { 438 None 439 }; 440 let local = if need_local { 441 res.nip05_local_for_author(&evt.event.author) 442 .map(|s| s.to_string()) 443 } else { 444 None 445 }; 446 (full, local) 447 } else { 448 (None, None) 449 } 450 } else { 451 (None, None) 452 } 453 } else { 454 (None, None) 455 }; 456 457 if !should_log( 458 &evt.event.author, 459 evt.event.kind as u64, 460 evt.event.created_at, 461 &evt.event.content, 462 npub_opt, 463 nip05_full_opt, 464 nip05_local_opt, 465 ) { 466 return; 467 } 468 469 if let Ok(json) = serde_json::to_string(evt) { 470 info!( 471 target = "audit", 472 kind = evt.event.kind, 473 id = %evt.event.id, 474 author = %evt.event.author, 475 created_at = evt.event.created_at, 476 processed_json = %json, 477 "AUDIT: processed comment" 478 ); 479 } 480 }