diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.rs | 110 | ||||
| -rw-r--r-- | src/nostr/builder.rs | 32 | ||||
| -rw-r--r-- | src/nostr/policy/mod.rs | 4 |
3 files changed, 145 insertions, 1 deletions
diff --git a/src/config.rs b/src/config.rs index 5f8cbca..a5e4344 100644 --- a/src/config.rs +++ b/src/config.rs | |||
| @@ -244,6 +244,42 @@ impl Default for BlacklistConfig { | |||
| 244 | } | 244 | } |
| 245 | } | 245 | } |
| 246 | 246 | ||
| 247 | /// Event blacklist configuration for blocking events by author npub | ||
| 248 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 249 | pub struct EventBlacklistConfig { | ||
| 250 | /// Blacklisted npubs - events from these authors are rejected | ||
| 251 | /// | ||
| 252 | /// If empty, no events are blacklisted by author. | ||
| 253 | /// Applies to ALL event types, preventing events from reaching both the relay and purgatory. | ||
| 254 | pub blacklisted_npubs: Vec<String>, | ||
| 255 | } | ||
| 256 | |||
| 257 | impl EventBlacklistConfig { | ||
| 258 | /// Check if event blacklist is enabled (non-empty blacklist) | ||
| 259 | pub fn enabled(&self) -> bool { | ||
| 260 | !self.blacklisted_npubs.is_empty() | ||
| 261 | } | ||
| 262 | |||
| 263 | /// Check if an event author is blacklisted | ||
| 264 | /// | ||
| 265 | /// Returns Some(reason) if blacklisted, None if not blacklisted. | ||
| 266 | pub fn check(&self, npub: &str) -> Option<String> { | ||
| 267 | if self.blacklisted_npubs.contains(&npub.to_string()) { | ||
| 268 | Some(format!("Event author {} is blacklisted", npub)) | ||
| 269 | } else { | ||
| 270 | None | ||
| 271 | } | ||
| 272 | } | ||
| 273 | } | ||
| 274 | |||
| 275 | impl Default for EventBlacklistConfig { | ||
| 276 | fn default() -> Self { | ||
| 277 | Self { | ||
| 278 | blacklisted_npubs: Vec::new(), | ||
| 279 | } | ||
| 280 | } | ||
| 281 | } | ||
| 282 | |||
| 247 | /// Database backend type for the relay | 283 | /// Database backend type for the relay |
| 248 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] | 284 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] |
| 249 | #[serde(rename_all = "lowercase")] | 285 | #[serde(rename_all = "lowercase")] |
| @@ -428,6 +464,11 @@ pub struct Config { | |||
| 428 | /// Blacklist takes precedence over all whitelists (archive and repository) | 464 | /// Blacklist takes precedence over all whitelists (archive and repository) |
| 429 | #[arg(long, env = "NGIT_REPOSITORY_BLACKLIST", default_value = "")] | 465 | #[arg(long, env = "NGIT_REPOSITORY_BLACKLIST", default_value = "")] |
| 430 | pub repository_blacklist: String, | 466 | pub repository_blacklist: String, |
| 467 | |||
| 468 | /// Event blacklist: comma-separated list of npubs whose events are rejected | ||
| 469 | /// All events from these authors are blocked from both relay storage and purgatory | ||
| 470 | #[arg(long, env = "NGIT_EVENT_BLACKLIST", default_value = "")] | ||
| 471 | pub event_blacklist: String, | ||
| 431 | } | 472 | } |
| 432 | 473 | ||
| 433 | impl Config { | 474 | impl Config { |
| @@ -612,6 +653,20 @@ impl Config { | |||
| 612 | BlacklistConfig { blacklist } | 653 | BlacklistConfig { blacklist } |
| 613 | } | 654 | } |
| 614 | 655 | ||
| 656 | /// Get parsed event blacklist configuration | ||
| 657 | /// | ||
| 658 | /// This method assumes config has been validated - call Config::validate() first! | ||
| 659 | pub fn event_blacklist_config(&self) -> EventBlacklistConfig { | ||
| 660 | let blacklisted_npubs: Vec<String> = self | ||
| 661 | .event_blacklist | ||
| 662 | .split(',') | ||
| 663 | .map(|s| s.trim()) | ||
| 664 | .filter(|s| !s.is_empty()) | ||
| 665 | .map(|s| s.to_string()) | ||
| 666 | .collect(); | ||
| 667 | EventBlacklistConfig { blacklisted_npubs } | ||
| 668 | } | ||
| 669 | |||
| 615 | /// Create config for testing | 670 | /// Create config for testing |
| 616 | #[cfg(test)] | 671 | #[cfg(test)] |
| 617 | pub fn for_testing() -> Self { | 672 | pub fn for_testing() -> Self { |
| @@ -647,6 +702,7 @@ impl Config { | |||
| 647 | archive_read_only: None, | 702 | archive_read_only: None, |
| 648 | repository_whitelist: String::new(), | 703 | repository_whitelist: String::new(), |
| 649 | repository_blacklist: String::new(), | 704 | repository_blacklist: String::new(), |
| 705 | event_blacklist: String::new(), | ||
| 650 | } | 706 | } |
| 651 | } | 707 | } |
| 652 | } | 708 | } |
| @@ -1248,4 +1304,58 @@ mod tests { | |||
| 1248 | let result = config.check(&test_npub, "allowed-repo"); | 1304 | let result = config.check(&test_npub, "allowed-repo"); |
| 1249 | assert!(result.is_none()); | 1305 | assert!(result.is_none()); |
| 1250 | } | 1306 | } |
| 1307 | |||
| 1308 | #[test] | ||
| 1309 | fn test_event_blacklist_config_parsing() { | ||
| 1310 | let keys1 = Keys::generate(); | ||
| 1311 | let keys2 = Keys::generate(); | ||
| 1312 | let npub1 = keys1.public_key().to_bech32().unwrap(); | ||
| 1313 | let npub2 = keys2.public_key().to_bech32().unwrap(); | ||
| 1314 | let config = Config { | ||
| 1315 | event_blacklist: format!("{},{}", npub1, npub2), | ||
| 1316 | ..Config::for_testing() | ||
| 1317 | }; | ||
| 1318 | let event_blacklist_config = config.event_blacklist_config(); | ||
| 1319 | assert_eq!(event_blacklist_config.blacklisted_npubs.len(), 2); | ||
| 1320 | assert!(event_blacklist_config.enabled()); | ||
| 1321 | assert!(event_blacklist_config.blacklisted_npubs.contains(&npub1)); | ||
| 1322 | assert!(event_blacklist_config.blacklisted_npubs.contains(&npub2)); | ||
| 1323 | } | ||
| 1324 | |||
| 1325 | #[test] | ||
| 1326 | fn test_event_blacklist_config_empty() { | ||
| 1327 | let config = Config::for_testing(); | ||
| 1328 | let event_blacklist_config = config.event_blacklist_config(); | ||
| 1329 | assert!(event_blacklist_config.blacklisted_npubs.is_empty()); | ||
| 1330 | assert!(!event_blacklist_config.enabled()); | ||
| 1331 | } | ||
| 1332 | |||
| 1333 | #[test] | ||
| 1334 | fn test_event_blacklist_check_blacklisted() { | ||
| 1335 | let keys = Keys::generate(); | ||
| 1336 | let test_npub = keys.public_key().to_bech32().unwrap(); | ||
| 1337 | let config = EventBlacklistConfig { | ||
| 1338 | blacklisted_npubs: vec![test_npub.clone()], | ||
| 1339 | }; | ||
| 1340 | |||
| 1341 | let result = config.check(&test_npub); | ||
| 1342 | assert!(result.is_some()); | ||
| 1343 | let reason = result.unwrap(); | ||
| 1344 | assert!(reason.contains("author")); | ||
| 1345 | assert!(reason.contains(&test_npub)); | ||
| 1346 | } | ||
| 1347 | |||
| 1348 | #[test] | ||
| 1349 | fn test_event_blacklist_check_not_blacklisted() { | ||
| 1350 | let keys1 = Keys::generate(); | ||
| 1351 | let keys2 = Keys::generate(); | ||
| 1352 | let banned_npub = keys1.public_key().to_bech32().unwrap(); | ||
| 1353 | let allowed_npub = keys2.public_key().to_bech32().unwrap(); | ||
| 1354 | let config = EventBlacklistConfig { | ||
| 1355 | blacklisted_npubs: vec![banned_npub], | ||
| 1356 | }; | ||
| 1357 | |||
| 1358 | let result = config.check(&allowed_npub); | ||
| 1359 | assert!(result.is_none()); | ||
| 1360 | } | ||
| 1251 | } | 1361 | } |
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 9819e37..c2de1df 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs | |||
| @@ -56,7 +56,13 @@ impl Nip34WritePolicy { | |||
| 56 | purgatory: std::sync::Arc<crate::purgatory::Purgatory>, | 56 | purgatory: std::sync::Arc<crate::purgatory::Purgatory>, |
| 57 | config: crate::config::Config, | 57 | config: crate::config::Config, |
| 58 | ) -> Self { | 58 | ) -> Self { |
| 59 | let ctx = PolicyContext::new(&config.domain, database, git_data_path, purgatory); | 59 | let ctx = PolicyContext::new( |
| 60 | &config.domain, | ||
| 61 | database, | ||
| 62 | git_data_path, | ||
| 63 | purgatory, | ||
| 64 | config.clone(), | ||
| 65 | ); | ||
| 60 | Self { | 66 | Self { |
| 61 | announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()), | 67 | announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()), |
| 62 | state_policy: StatePolicy::new(ctx.clone()), | 68 | state_policy: StatePolicy::new(ctx.clone()), |
| @@ -66,6 +72,19 @@ impl Nip34WritePolicy { | |||
| 66 | } | 72 | } |
| 67 | } | 73 | } |
| 68 | 74 | ||
| 75 | /// Check if an event author is blacklisted | ||
| 76 | /// | ||
| 77 | /// Returns Some(reason) if blacklisted, None if not blacklisted. | ||
| 78 | fn check_event_blacklist(&self, event: &Event) -> Option<String> { | ||
| 79 | let event_blacklist = self.ctx.config.event_blacklist_config(); | ||
| 80 | if !event_blacklist.enabled() { | ||
| 81 | return None; | ||
| 82 | } | ||
| 83 | |||
| 84 | let npub = event.pubkey.to_bech32().ok()?; | ||
| 85 | event_blacklist.check(&npub) | ||
| 86 | } | ||
| 87 | |||
| 69 | /// Get a reference to the purgatory for read-only access | 88 | /// Get a reference to the purgatory for read-only access |
| 70 | pub fn purgatory(&self) -> &std::sync::Arc<crate::purgatory::Purgatory> { | 89 | pub fn purgatory(&self) -> &std::sync::Arc<crate::purgatory::Purgatory> { |
| 71 | &self.ctx.purgatory | 90 | &self.ctx.purgatory |
| @@ -474,6 +493,17 @@ impl WritePolicy for Nip34WritePolicy { | |||
| 474 | addr: &'a SocketAddr, | 493 | addr: &'a SocketAddr, |
| 475 | ) -> BoxedFuture<'a, WritePolicyResult> { | 494 | ) -> BoxedFuture<'a, WritePolicyResult> { |
| 476 | Box::pin(async move { | 495 | Box::pin(async move { |
| 496 | // Check event blacklist FIRST - it overrides everything | ||
| 497 | if let Some(reason) = self.check_event_blacklist(event) { | ||
| 498 | tracing::debug!( | ||
| 499 | event_id = %event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()), | ||
| 500 | author = %event.pubkey.to_hex(), | ||
| 501 | reason = %reason, | ||
| 502 | "Rejected event from blacklisted author" | ||
| 503 | ); | ||
| 504 | return WritePolicyResult::reject(reason); | ||
| 505 | } | ||
| 506 | |||
| 477 | // Detect if this is a synced event (from proactive sync) vs user-submitted | 507 | // Detect if this is a synced event (from proactive sync) vs user-submitted |
| 478 | // Sync uses localhost:0 as a dummy address | 508 | // Sync uses localhost:0 as a dummy address |
| 479 | let is_synced = addr.ip().is_loopback() && addr.port() == 0; | 509 | let is_synced = addr.ip().is_loopback() && addr.port() == 0; |
diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs index dc023a9..1566b6c 100644 --- a/src/nostr/policy/mod.rs +++ b/src/nostr/policy/mod.rs | |||
| @@ -32,6 +32,8 @@ pub struct PolicyContext { | |||
| 32 | pub purgatory: Arc<Purgatory>, | 32 | pub purgatory: Arc<Purgatory>, |
| 33 | /// Local relay for notifying WebSocket subscribers (set after relay creation) | 33 | /// Local relay for notifying WebSocket subscribers (set after relay creation) |
| 34 | pub local_relay: Arc<std::sync::RwLock<Option<LocalRelay>>>, | 34 | pub local_relay: Arc<std::sync::RwLock<Option<LocalRelay>>>, |
| 35 | /// Configuration reference for policy settings (includes blacklists) | ||
| 36 | pub config: crate::config::Config, | ||
| 35 | } | 37 | } |
| 36 | 38 | ||
| 37 | impl PolicyContext { | 39 | impl PolicyContext { |
| @@ -40,6 +42,7 @@ impl PolicyContext { | |||
| 40 | database: SharedDatabase, | 42 | database: SharedDatabase, |
| 41 | git_data_path: impl Into<std::path::PathBuf>, | 43 | git_data_path: impl Into<std::path::PathBuf>, |
| 42 | purgatory: Arc<Purgatory>, | 44 | purgatory: Arc<Purgatory>, |
| 45 | config: crate::config::Config, | ||
| 43 | ) -> Self { | 46 | ) -> Self { |
| 44 | Self { | 47 | Self { |
| 45 | domain: domain.into(), | 48 | domain: domain.into(), |
| @@ -47,6 +50,7 @@ impl PolicyContext { | |||
| 47 | git_data_path: git_data_path.into(), | 50 | git_data_path: git_data_path.into(), |
| 48 | purgatory, | 51 | purgatory, |
| 49 | local_relay: Arc::new(std::sync::RwLock::new(None)), | 52 | local_relay: Arc::new(std::sync::RwLock::new(None)), |
| 53 | config, | ||
| 50 | } | 54 | } |
| 51 | } | 55 | } |
| 52 | 56 | ||