use anyhow::{Context, Result}; use nostr::{FromBech32, PublicKey}; use serde::Deserialize; use std::path::PathBuf; #[derive(Debug, Deserialize)] pub struct AppConfig { pub discovery: DiscoveryConfig, pub servers: ServersConfig, pub storage: StorageConfig, pub signing: Option, pub nip46: Option, } #[derive(Debug, Deserialize)] pub struct DiscoveryConfig { pub index_relays: Vec, #[serde(default = "default_poll_interval")] pub poll_interval_secs: u64, } fn default_poll_interval() -> u64 { 300 } #[derive(Debug, Deserialize)] pub struct ServersConfig { pub known: Vec, } #[derive(Debug, Deserialize)] pub struct StorageConfig { #[serde(default = "default_mirror_dir")] pub mirror_dir: PathBuf, #[serde(default = "default_database")] pub database: PathBuf, #[serde(default = "default_health_port")] pub health_port: u16, } fn default_health_port() -> u16 { 7335 } fn default_mirror_dir() -> PathBuf { dirs::data_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("grasp-mirror") .join("repos") } fn default_database() -> PathBuf { dirs::data_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("grasp-mirror") .join("mirror.db") } #[derive(Debug, Deserialize)] pub struct SigningConfig { pub key_file: PathBuf, } #[derive(Debug, Deserialize)] pub struct Nip46Config { pub relays: Vec, #[serde(default = "default_signing_timeout")] pub signing_timeout_secs: u64, } fn default_signing_timeout() -> u64 { 604800 } pub struct ResolvedConfig { pub discovery: DiscoveryConfig, pub servers: ServersConfig, pub storage: StorageConfig, pub signing: Option, pub nip46: Option, pub npubs: Vec, } impl ResolvedConfig { pub fn load(config_path: &PathBuf) -> Result { let _ = dotenvy::dotenv(); let config_str = std::fs::read_to_string(config_path) .with_context(|| format!("failed to read config from {:?}", config_path))?; let app: AppConfig = toml::from_str(&config_str).context("failed to parse config.toml")?; let npubs = Self::load_npubs()?; std::fs::create_dir_all(&app.storage.mirror_dir) .context("failed to create mirror directory")?; if let Some(parent) = app.storage.database.parent() { std::fs::create_dir_all(parent).context("failed to create database directory")?; } Ok(Self { discovery: app.discovery, servers: app.servers, storage: app.storage, signing: app.signing, nip46: app.nip46, npubs, }) } fn load_npubs() -> Result> { let raw = std::env::var("MIRROR_NPUBS").unwrap_or_default(); let mut npubs = Vec::new(); for entry in raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) { let pk = if entry.starts_with("npub1") { PublicKey::from_bech32(entry) .with_context(|| format!("invalid npub: {}", entry))? } else { let bytes = hex::decode(entry) .with_context(|| format!("invalid hex pubkey: {}", entry))?; PublicKey::from_slice(&bytes) .with_context(|| format!("invalid pubkey bytes: {}", entry))? }; npubs.push(pk); } if npubs.is_empty() { tracing::warn!("MIRROR_NPUBS is empty — no pubkeys to mirror"); } Ok(npubs) } pub fn relay_urls(&self) -> Vec { let mut relays = self.discovery.index_relays.clone(); for server in &self.servers.known { let relay = format!( "wss://{}", server .trim_start_matches("https://") .trim_start_matches("http://") ); if !relays.contains(&relay) { relays.push(relay); } } relays } }