archy/core/archipelago/src/nostr_relays.rs
Dorian f07ce10b1a refactor: update dependencies and remove unused code
- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`.
- Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27.
- Removed the `backup.rs` file as it is no longer needed.
- Introduced tests for configuration and credential management.
- Enhanced the `identity` module to generate W3C compliant DID documents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:19:30 +00:00

427 lines
13 KiB
Rust

//! Nostr relay management: configure, monitor, and manage relay connections.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::debug;
const RELAYS_FILE: &str = "nostr_relays.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayConfig {
pub url: String,
pub enabled: bool,
pub added_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayStatus {
pub url: String,
pub connected: bool,
pub enabled: bool,
pub added_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RelayStore {
pub relays: Vec<RelayConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayStats {
pub total_relays: usize,
pub connected_count: usize,
pub enabled_count: usize,
}
/// Default relays seeded on first use.
const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
"wss://relay.snort.social",
"wss://nostr.wine",
"wss://relay.nostr.info",
"wss://nostr-pub.wellorder.net",
"wss://relay.current.fyi",
];
pub async fn load_relays(data_dir: &Path) -> Result<RelayStore> {
let path = data_dir.join(RELAYS_FILE);
if !path.exists() {
// Seed with defaults on first load
let store = seed_defaults();
save_relays(data_dir, &store).await?;
return Ok(store);
}
let data = fs::read_to_string(&path)
.await
.context("Reading relay store")?;
serde_json::from_str(&data).context("Parsing relay store")
}
pub async fn save_relays(data_dir: &Path, store: &RelayStore) -> Result<()> {
let path = data_dir.join(RELAYS_FILE);
let data = serde_json::to_string_pretty(store)?;
fs::write(&path, data).await.context("Writing relay store")
}
fn seed_defaults() -> RelayStore {
let now = chrono::Utc::now().to_rfc3339();
RelayStore {
relays: DEFAULT_RELAYS
.iter()
.map(|url| RelayConfig {
url: url.to_string(),
enabled: true,
added_at: now.clone(),
})
.collect(),
}
}
/// List all relays with connection status.
pub async fn list_relays(data_dir: &Path) -> Result<Vec<RelayStatus>> {
let store = load_relays(data_dir).await?;
let statuses: Vec<RelayStatus> = store
.relays
.into_iter()
.map(|r| {
// Connection check: try a quick TCP probe to the relay
// For now, report enabled relays as connected (actual connectivity
// is tested via the Nostr SDK when publishing/subscribing)
RelayStatus {
url: r.url,
connected: r.enabled,
enabled: r.enabled,
added_at: r.added_at,
}
})
.collect();
Ok(statuses)
}
/// Add a new relay.
pub async fn add_relay(data_dir: &Path, url: &str) -> Result<RelayConfig> {
let mut store = load_relays(data_dir).await?;
let normalized = normalize_relay_url(url)?;
if store.relays.iter().any(|r| r.url == normalized) {
return Err(anyhow::anyhow!("Relay already exists: {}", normalized));
}
let config = RelayConfig {
url: normalized,
enabled: true,
added_at: chrono::Utc::now().to_rfc3339(),
};
debug!(url = %config.url, "Added relay");
store.relays.push(config.clone());
save_relays(data_dir, &store).await?;
Ok(config)
}
/// Remove a relay.
pub async fn remove_relay(data_dir: &Path, url: &str) -> Result<()> {
let mut store = load_relays(data_dir).await?;
let original_len = store.relays.len();
store.relays.retain(|r| r.url != url);
if store.relays.len() == original_len {
return Err(anyhow::anyhow!("Relay not found: {}", url));
}
save_relays(data_dir, &store).await
}
/// Toggle relay enabled/disabled.
pub async fn toggle_relay(data_dir: &Path, url: &str, enabled: bool) -> Result<()> {
let mut store = load_relays(data_dir).await?;
let relay = store
.relays
.iter_mut()
.find(|r| r.url == url)
.ok_or_else(|| anyhow::anyhow!("Relay not found: {}", url))?;
relay.enabled = enabled;
save_relays(data_dir, &store).await
}
/// Get aggregate stats.
pub async fn get_stats(data_dir: &Path) -> Result<RelayStats> {
let store = load_relays(data_dir).await?;
let enabled_count = store.relays.iter().filter(|r| r.enabled).count();
Ok(RelayStats {
total_relays: store.relays.len(),
connected_count: enabled_count,
enabled_count,
})
}
/// Normalize a relay URL (ensure wss:// prefix).
fn normalize_relay_url(url: &str) -> Result<String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(anyhow::anyhow!("Relay URL cannot be empty"));
}
if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") {
Ok(trimmed.to_string())
} else {
Ok(format!("wss://{}", trimmed))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_normalize_relay_url_with_wss() {
let result = normalize_relay_url("wss://relay.damus.io").unwrap();
assert_eq!(result, "wss://relay.damus.io");
}
#[test]
fn test_normalize_relay_url_with_ws() {
let result = normalize_relay_url("ws://relay.example.com").unwrap();
assert_eq!(result, "ws://relay.example.com");
}
#[test]
fn test_normalize_relay_url_without_scheme() {
let result = normalize_relay_url("relay.example.com").unwrap();
assert_eq!(result, "wss://relay.example.com");
}
#[test]
fn test_normalize_relay_url_trims_whitespace() {
let result = normalize_relay_url(" wss://relay.example.com ").unwrap();
assert_eq!(result, "wss://relay.example.com");
}
#[test]
fn test_normalize_relay_url_empty_errors() {
let result = normalize_relay_url("");
assert!(result.is_err());
let result = normalize_relay_url(" ");
assert!(result.is_err());
}
#[test]
fn test_seed_defaults_has_expected_count() {
let store = seed_defaults();
assert_eq!(store.relays.len(), DEFAULT_RELAYS.len());
}
#[test]
fn test_seed_defaults_all_enabled() {
let store = seed_defaults();
assert!(store.relays.iter().all(|r| r.enabled));
}
#[test]
fn test_seed_defaults_urls_match() {
let store = seed_defaults();
let urls: Vec<&str> = store.relays.iter().map(|r| r.url.as_str()).collect();
for expected in DEFAULT_RELAYS {
assert!(urls.contains(expected), "Missing default relay: {}", expected);
}
}
#[test]
fn test_seed_defaults_all_have_timestamps() {
let store = seed_defaults();
for relay in &store.relays {
assert!(!relay.added_at.is_empty());
// Should be a valid RFC3339 timestamp
assert!(chrono::DateTime::parse_from_rfc3339(&relay.added_at).is_ok());
}
}
#[tokio::test]
async fn test_load_relays_seeds_defaults_on_first_load() {
let tmp = TempDir::new().unwrap();
let store = load_relays(tmp.path()).await.unwrap();
assert_eq!(store.relays.len(), DEFAULT_RELAYS.len());
// The file should now exist after seeding
assert!(tmp.path().join(RELAYS_FILE).exists());
}
#[tokio::test]
async fn test_save_and_load_relays_roundtrip() {
let tmp = TempDir::new().unwrap();
let store = RelayStore {
relays: vec![
RelayConfig {
url: "wss://test.relay.one".to_string(),
enabled: true,
added_at: "2025-01-01T00:00:00Z".to_string(),
},
RelayConfig {
url: "wss://test.relay.two".to_string(),
enabled: false,
added_at: "2025-01-02T00:00:00Z".to_string(),
},
],
};
save_relays(tmp.path(), &store).await.unwrap();
let loaded = load_relays(tmp.path()).await.unwrap();
assert_eq!(loaded.relays.len(), 2);
assert_eq!(loaded.relays[0].url, "wss://test.relay.one");
assert!(loaded.relays[0].enabled);
assert_eq!(loaded.relays[1].url, "wss://test.relay.two");
assert!(!loaded.relays[1].enabled);
}
#[tokio::test]
async fn test_add_relay() {
let tmp = TempDir::new().unwrap();
// Seed defaults first
let _ = load_relays(tmp.path()).await.unwrap();
let config = add_relay(tmp.path(), "wss://new.relay.test").await.unwrap();
assert_eq!(config.url, "wss://new.relay.test");
assert!(config.enabled);
let store = load_relays(tmp.path()).await.unwrap();
assert_eq!(store.relays.len(), DEFAULT_RELAYS.len() + 1);
}
#[tokio::test]
async fn test_add_relay_normalizes_url() {
let tmp = TempDir::new().unwrap();
let _ = load_relays(tmp.path()).await.unwrap();
let config = add_relay(tmp.path(), "bare.relay.test").await.unwrap();
assert_eq!(config.url, "wss://bare.relay.test");
}
#[tokio::test]
async fn test_add_duplicate_relay_errors() {
let tmp = TempDir::new().unwrap();
let _ = load_relays(tmp.path()).await.unwrap();
// Adding a default relay again should fail
let result = add_relay(tmp.path(), "wss://relay.damus.io").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_remove_relay() {
let tmp = TempDir::new().unwrap();
let _ = load_relays(tmp.path()).await.unwrap();
let initial_count = DEFAULT_RELAYS.len();
remove_relay(tmp.path(), "wss://relay.damus.io").await.unwrap();
let store = load_relays(tmp.path()).await.unwrap();
assert_eq!(store.relays.len(), initial_count - 1);
assert!(!store.relays.iter().any(|r| r.url == "wss://relay.damus.io"));
}
#[tokio::test]
async fn test_remove_nonexistent_relay_errors() {
let tmp = TempDir::new().unwrap();
let _ = load_relays(tmp.path()).await.unwrap();
let result = remove_relay(tmp.path(), "wss://nonexistent.relay").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_toggle_relay_disable() {
let tmp = TempDir::new().unwrap();
let _ = load_relays(tmp.path()).await.unwrap();
toggle_relay(tmp.path(), "wss://relay.damus.io", false).await.unwrap();
let store = load_relays(tmp.path()).await.unwrap();
let relay = store.relays.iter().find(|r| r.url == "wss://relay.damus.io").unwrap();
assert!(!relay.enabled);
}
#[tokio::test]
async fn test_toggle_relay_enable() {
let tmp = TempDir::new().unwrap();
let _ = load_relays(tmp.path()).await.unwrap();
// Disable first, then re-enable
toggle_relay(tmp.path(), "wss://relay.damus.io", false).await.unwrap();
toggle_relay(tmp.path(), "wss://relay.damus.io", true).await.unwrap();
let store = load_relays(tmp.path()).await.unwrap();
let relay = store.relays.iter().find(|r| r.url == "wss://relay.damus.io").unwrap();
assert!(relay.enabled);
}
#[tokio::test]
async fn test_toggle_nonexistent_relay_errors() {
let tmp = TempDir::new().unwrap();
let _ = load_relays(tmp.path()).await.unwrap();
let result = toggle_relay(tmp.path(), "wss://no.such.relay", true).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_stats() {
let tmp = TempDir::new().unwrap();
let _ = load_relays(tmp.path()).await.unwrap();
let stats = get_stats(tmp.path()).await.unwrap();
assert_eq!(stats.total_relays, DEFAULT_RELAYS.len());
assert_eq!(stats.enabled_count, DEFAULT_RELAYS.len());
assert_eq!(stats.connected_count, stats.enabled_count);
}
#[tokio::test]
async fn test_get_stats_after_disable() {
let tmp = TempDir::new().unwrap();
let _ = load_relays(tmp.path()).await.unwrap();
toggle_relay(tmp.path(), "wss://relay.damus.io", false).await.unwrap();
toggle_relay(tmp.path(), "wss://nos.lol", false).await.unwrap();
let stats = get_stats(tmp.path()).await.unwrap();
assert_eq!(stats.total_relays, DEFAULT_RELAYS.len());
assert_eq!(stats.enabled_count, DEFAULT_RELAYS.len() - 2);
}
#[tokio::test]
async fn test_list_relays() {
let tmp = TempDir::new().unwrap();
let statuses = list_relays(tmp.path()).await.unwrap();
assert_eq!(statuses.len(), DEFAULT_RELAYS.len());
// All default relays should be enabled and "connected"
for status in &statuses {
assert!(status.enabled);
assert!(status.connected);
assert!(status.url.starts_with("wss://"));
}
}
#[test]
fn test_relay_store_default_is_empty() {
let store = RelayStore::default();
assert!(store.relays.is_empty());
}
#[test]
fn test_relay_config_serialization() {
let config = RelayConfig {
url: "wss://test.relay".to_string(),
enabled: true,
added_at: "2025-01-01T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: RelayConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.url, config.url);
assert_eq!(parsed.enabled, config.enabled);
assert_eq!(parsed.added_at, config.added_at);
}
}