tangle_indexer


git clone https://radroots.dev/git/tangle_indexer.git
Log | Files | Refs | Submodules | LICENSE

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 }