diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 00000000..0e79e4bc --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,85 @@ +use crate::globals::GLOBALS; +use crate::people::Person; +use crate::profile::Profile; +use nostr_types::{Event, EventKind}; +use rhai::{AST, Engine, Scope}; +use std::fs; + +#[derive(Clone, Copy, Debug)] +pub enum EventFilterAction { + Deny, + Allow, + MuteAuthor, +} + +pub fn load_script(engine: &Engine) -> Option { + let profile = match Profile::current() { + Ok(profile) => profile, + Err(e) => { + tracing::error!("Profile failed: {}", e); + return None; + }, + }; + + let mut path = profile.profile_dir.clone(); + path.push("filter.rhai"); + + let script = match fs::read_to_string(&path) { + Ok(script) => script, + Err(e) => { + tracing::info!("No spam filter: {}", e); + return None; + }, + }; + + let ast = match engine.compile(script) { + Ok(ast) => ast, + Err(e) => { + tracing::error!("Failed to compile spam filter: {}", e); + return None; + } + }; + + tracing::info!("Spam filter loaded."); + + Some(ast) +} + +pub fn filter(event: Event, author: Option) -> EventFilterAction { + let ast = match &GLOBALS.filter { + Some(ast) => ast, + None => return EventFilterAction::Allow + }; + + let mut scope = Scope::new(); + scope.push("id", event.id.as_hex_string()); + scope.push("pubkey", event.pubkey.as_hex_string()); + scope.push("kind", >::into(event.kind)); + // FIXME tags + scope.push("content", event.content.clone()); + scope.push( + "nip05valid", + match author { + Some(a) => a.nip05_valid, + None => false, + }, + ); + + match GLOBALS.filter_engine.call_fn::(&mut scope, &ast, "filter", ()) { + Ok(action) => match action { + 0 => { + tracing::info!("SPAM FILTER BLOCKING EVENT {}", event.id.as_hex_string()); + EventFilterAction::Deny + } + 1 => EventFilterAction::Allow, + 2 => EventFilterAction::MuteAuthor, + _ => EventFilterAction::Allow, + }, + Err(ear) => { + tracing::error!("{}", ear); + EventFilterAction::Allow + } + } +} + +// Only call the filter if the author isn't followed diff --git a/src/globals.rs b/src/globals.rs index b58fef4a..9679f977 100644 --- a/src/globals.rs +++ b/src/globals.rs @@ -14,6 +14,7 @@ use gossip_relay_picker::RelayPicker; use nostr_types::{Event, Id, PayRequestData, Profile, PublicKey, RelayUrl, UncheckedUrl}; use parking_lot::RwLock as PRwLock; use regex::Regex; +use rhai::{AST, Engine}; use std::collections::HashSet; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize}; use tokio::sync::{broadcast, mpsc, Mutex, RwLock}; @@ -115,6 +116,10 @@ pub struct Globals { /// Events Processed pub events_processed: AtomicU32, + + /// Filter + pub filter_engine: Engine, + pub filter: Option, } lazy_static! { @@ -131,6 +136,9 @@ lazy_static! { Err(e) => panic!("{e}") }; + let filter_engine = Engine::new(); + let filter = crate::filter::load_script(&filter_engine); + Globals { first_run: AtomicBool::new(false), to_minions, @@ -161,6 +169,8 @@ lazy_static! { hashtag_regex: Regex::new(r"(?:^|\W)(#[\w\p{Extended_Pictographic}]+)(?:$|\W)").unwrap(), storage, events_processed: AtomicU32::new(0), + filter_engine, + filter, } }; } diff --git a/src/main.rs b/src/main.rs index dc43c0bb..1b9920ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ mod delegation; mod error; mod feed; mod fetcher; +mod filter; mod globals; mod media; mod nip05; diff --git a/src/people.rs b/src/people.rs index fd07536a..f0b1f39f 100644 --- a/src/people.rs +++ b/src/people.rs @@ -215,6 +215,7 @@ impl People { }); } + // FIXME this is expensive pub fn get_followed_pubkeys(&self) -> Vec { if let Ok(vec) = GLOBALS.storage.filter_people(|p| p.followed) { vec.iter().map(|p| p.pubkey).collect() @@ -223,6 +224,11 @@ impl People { } } + // FIXME this is expensive + pub fn is_followed(&self, pubkey: &PublicKey) -> bool { + self.get_followed_pubkeys().contains(pubkey) + } + pub fn get_followed_pubkeys_needing_relay_lists( &self, among_these: &[PublicKey], diff --git a/src/process.rs b/src/process.rs index 0d5389d9..92e73c08 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1,5 +1,6 @@ use crate::comms::ToOverlordMessage; use crate::error::Error; +use crate::filter::EventFilterAction; use crate::globals::GLOBALS; use crate::person_relay::PersonRelay; use nostr_types::{ @@ -27,6 +28,22 @@ pub async fn process_new_event( GLOBALS.events_processed.fetch_add(1, Ordering::SeqCst); + // Spam filter (displayable and author is not followed) + if event.kind.is_feed_displayable() && !GLOBALS.people.is_followed(&event.pubkey) { + let author = GLOBALS.storage.read_person(&event.pubkey)?; + match crate::filter::filter(event.clone(), author) { + EventFilterAction::Allow => {} + EventFilterAction::Deny => { + tracing::info!("SPAM FILTER: Filtered out event {}", event.id.as_hex_string()); + return Ok(()); + }, + EventFilterAction::MuteAuthor => { + GLOBALS.people.mute(&event.pubkey, true)?; + return Ok(()); + } + } + } + // Determine if we already had this event let duplicate = GLOBALS.storage.has_event(event.id)?; if duplicate { @@ -144,14 +161,6 @@ pub async fn process_new_event( } } - // Save event relationships (whether from a relay or not) - let invalid_ids = GLOBALS - .storage - .process_relationships_of_event(event, None)?; - - // Invalidate UI events indicated by those relationships - GLOBALS.ui_notes_to_invalidate.write().extend(&invalid_ids); - // Save event_hashtags if seen_on.is_some() { let hashtags = event.hashtags(); @@ -160,6 +169,14 @@ pub async fn process_new_event( } } + // Save event relationships (whether from a relay or not) + let invalid_ids = GLOBALS + .storage + .process_relationships_of_event(event, None)?; + + // Invalidate UI events indicated by those relationships + GLOBALS.ui_notes_to_invalidate.write().extend(&invalid_ids); + // If metadata, update person if event.kind == EventKind::Metadata { let metadata: Metadata = serde_json::from_str(&event.content)?;