use anyhow::Result; use atproto_identity::key::{KeyData, identify_key, to_public}; use axum_extra::extract::cookie::Key; use base64::{Engine as _, engine::general_purpose}; use crate::config_errors::ConfigError; #[derive(Clone)] pub struct HttpPort(u16); #[derive(Clone)] pub struct HttpCookieKey(Key); #[derive(Clone)] pub struct EmailSecretKey(Vec); #[derive(Clone)] pub struct CertificateBundles(Vec); #[derive(Clone)] pub struct OAuthClientCredentialsKey(KeyData); #[derive(Clone)] pub struct AdminDIDs(Vec); #[derive(Clone)] pub struct DnsNameservers(Vec); #[derive(Clone)] pub struct ServiceKey(KeyData); #[derive(Clone)] pub struct Config { pub version: String, pub http_port: HttpPort, pub http_cookie_key: HttpCookieKey, pub http_static_path: String, pub external_base: String, pub certificate_bundles: CertificateBundles, pub user_agent: String, pub database_url: String, pub plc_hostname: String, pub tap_hostname: String, pub tap_password: Option, pub redis_url: String, pub admin_dids: AdminDIDs, pub dns_nameservers: DnsNameservers, pub oauth_client_credentials_key: OAuthClientCredentialsKey, pub enable_tap: bool, pub content_storage: String, pub service_key: ServiceKey, pub enable_opensearch: bool, pub enable_task_opensearch: bool, pub opensearch_endpoint: Option, pub smtp_credentials: Option, pub email_secret_key: EmailSecretKey, pub facets_mentions_max: usize, pub facets_tags_max: usize, pub facets_links_max: usize, pub facets_max: usize, /// Optional MCP JWT signing key (defaults to http_cookie_key if not set) pub mcp_jwt_key: Option, } impl Config { pub fn new() -> Result { let http_port: HttpPort = default_env("HTTP_PORT", "8080").try_into()?; let http_cookie_key: HttpCookieKey = require_env("HTTP_COOKIE_KEY").and_then(|value| value.try_into())?; let http_static_path = default_env("HTTP_STATIC_PATH", "static"); let external_base = require_env("EXTERNAL_BASE")?; let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?; let default_user_agent = format!("smokesignal ({}; +https://smokesignal.events/)", version()?); let user_agent = default_env("USER_AGENT", &default_user_agent); let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); let tap_hostname = default_env("TAP_HOSTNAME", "localhost:2480"); let tap_password_str = optional_env("TAP_PASSWORD"); let tap_password = if tap_password_str.is_empty() { None } else { Some(tap_password_str) }; let database_url = default_env("DATABASE_URL", "sqlite://development.db"); let redis_url = default_env("REDIS_URL", "redis://valkey:6379/0"); let admin_dids: AdminDIDs = optional_env("ADMIN_DIDS").try_into()?; let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; // Parse OAuth client credentials key for AT Protocol OAuth let oauth_client_credentials_key: OAuthClientCredentialsKey = require_env("OAUTH_CLIENT_CREDENTIALS_KEY").and_then(|value| value.try_into())?; let enable_tap = parse_bool_env("ENABLE_TAP", true); let content_storage = require_env("CONTENT_STORAGE")?; let service_key: ServiceKey = require_env("SERVICE_KEY")?.try_into()?; // Parse OpenSearch configuration let enable_opensearch = parse_bool_env("ENABLE_OPENSEARCH", false); let enable_task_opensearch = parse_bool_env("ENABLE_TASK_OPENSEARCH", false); let opensearch_endpoint = if enable_opensearch { Some(require_env("OPENSEARCH_ENDPOINT")?) } else { let endpoint = optional_env("OPENSEARCH_ENDPOINT"); if endpoint.is_empty() { None } else { Some(endpoint) } }; // Parse SMTP configuration (optional) let smtp_credentials_str = optional_env("SMTP_CREDENTIALS"); let smtp_credentials = if smtp_credentials_str.is_empty() { None } else { Some(smtp_credentials_str) }; // Parse email secret key (required for email confirmation tokens) let email_secret_key: EmailSecretKey = require_env("EMAIL_SECRET_KEY").and_then(|value| value.try_into())?; // Parse facet limit configuration let facets_mentions_max = default_env("FACETS_MENTIONS_MAX", "5") .parse::() .unwrap_or(5); let facets_tags_max = default_env("FACETS_TAGS_MAX", "5") .parse::() .unwrap_or(5); let facets_links_max = default_env("FACETS_LINKS_MAX", "5") .parse::() .unwrap_or(5); let facets_max = default_env("FACETS_MAX", "10") .parse::() .unwrap_or(10); // Parse optional MCP JWT key (defaults to http_cookie_key if not set) let mcp_jwt_key_str = optional_env("MCP_JWT_KEY"); let mcp_jwt_key = if mcp_jwt_key_str.is_empty() { None } else { Some(mcp_jwt_key_str) }; Ok(Self { version: version()?, http_port, http_static_path, external_base, certificate_bundles, user_agent, plc_hostname, tap_hostname, tap_password, database_url, http_cookie_key, redis_url, admin_dids, dns_nameservers, oauth_client_credentials_key, enable_tap, content_storage, service_key, enable_opensearch, enable_task_opensearch, opensearch_endpoint, smtp_credentials, email_secret_key, facets_mentions_max, facets_tags_max, facets_links_max, facets_max, mcp_jwt_key, }) } /// Returns the OAuth client credentials key as (public_key_did, key_data) pair pub fn select_oauth_signing_key(&self) -> Result<(String, KeyData)> { let key_data = self.oauth_client_credentials_key.as_ref().clone(); let public_key = to_public(&key_data)?; Ok((public_key.to_string(), key_data)) } /// Check if a DID is in the admin allow list pub fn is_admin(&self, did: &str) -> bool { self.admin_dids.as_ref().contains(&did.to_string()) } /// Returns the OAuth scope string with the external_base interpolated pub fn oauth_scope(&self) -> String { format!( "atproto include:community.lexicon.calendar.authFull?aud=did:web:{}#smokesignal include:events.smokesignal.authFull include:app.bsky.authCreatePosts blob:*/* account:email", self.external_base ) } } fn require_env(name: &str) -> Result { std::env::var(name).map_err(|_| ConfigError::EnvVarRequired(name.to_string()).into()) } fn optional_env(name: &str) -> String { std::env::var(name).unwrap_or("".to_string()) } fn default_env(name: &str, default_value: &str) -> String { std::env::var(name).unwrap_or(default_value.to_string()) } fn parse_bool_env(name: &str, default: bool) -> bool { match std::env::var(name) { Ok(value) => matches!(value.to_lowercase().as_str(), "true" | "ok" | "1"), Err(_) => default, } } pub fn version() -> Result { option_env!("GIT_HASH") .or(option_env!("CARGO_PKG_VERSION")) .map(|val| val.to_string()) .ok_or(ConfigError::VersionNotSet.into()) } impl TryFrom for HttpPort { type Error = anyhow::Error; fn try_from(value: String) -> Result { if value.is_empty() { Ok(Self(80)) } else { value .parse::() .map(Self) .map_err(|err| ConfigError::PortParsingFailed(err).into()) } } } impl AsRef for HttpPort { fn as_ref(&self) -> &u16 { &self.0 } } impl TryFrom for HttpCookieKey { type Error = anyhow::Error; fn try_from(value: String) -> Result { let mut decoded_key: [u8; 66] = [0; 66]; general_purpose::STANDARD_NO_PAD .decode_slice(value, &mut decoded_key) .map_err(|err| anyhow::Error::from(ConfigError::CookieKeyDecodeFailed(err)))?; Key::try_from(&decoded_key[..64]) .map_err(|_| anyhow::Error::from(ConfigError::CookieKeyProcessFailed)) .map(Self) } } impl AsRef for HttpCookieKey { fn as_ref(&self) -> &Key { &self.0 } } impl TryFrom for EmailSecretKey { type Error = anyhow::Error; fn try_from(value: String) -> Result { // Decode hex string to bytes let decoded = hex::decode(&value).map_err(ConfigError::EmailSecretKeyDecodeFailed)?; // Require at least 32 bytes (256 bits) for security if decoded.len() < 32 { return Err(ConfigError::EmailSecretKeyTooShort(decoded.len()).into()); } Ok(Self(decoded)) } } impl AsRef<[u8]> for EmailSecretKey { fn as_ref(&self) -> &[u8] { &self.0 } } impl TryFrom for ServiceKey { type Error = anyhow::Error; fn try_from(value: String) -> Result { identify_key(&value) .map(ServiceKey) .map_err(|err| err.into()) } } impl AsRef for ServiceKey { fn as_ref(&self) -> &KeyData { &self.0 } } impl TryFrom for CertificateBundles { type Error = anyhow::Error; fn try_from(value: String) -> Result { Ok(Self( value .split(';') .filter_map(|s| { if s.is_empty() { None } else { Some(s.to_string()) } }) .collect::>(), )) } } impl AsRef> for CertificateBundles { fn as_ref(&self) -> &Vec { &self.0 } } impl TryFrom for OAuthClientCredentialsKey { type Error = anyhow::Error; fn try_from(value: String) -> Result { if value.is_empty() { return Err(ConfigError::EmptySigningKeys.into()); } identify_key(&value) .map(OAuthClientCredentialsKey) .map_err(|err| err.into()) } } impl AsRef for OAuthClientCredentialsKey { fn as_ref(&self) -> &KeyData { &self.0 } } impl AsRef> for AdminDIDs { fn as_ref(&self) -> &Vec { &self.0 } } impl TryFrom for AdminDIDs { type Error = anyhow::Error; fn try_from(value: String) -> Result { // Allow empty value for no admins if value.is_empty() { return Ok(Self(Vec::new())); } let admin_dids = value .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect::>(); Ok(Self(admin_dids)) } } impl AsRef> for DnsNameservers { fn as_ref(&self) -> &Vec { &self.0 } } impl TryFrom for DnsNameservers { type Error = anyhow::Error; fn try_from(value: String) -> Result { // Allow empty value for default DNS configuration if value.is_empty() { return Ok(Self(Vec::new())); } let nameservers = value .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| { s.parse::() .map_err(|e| ConfigError::NameserverParsingFailed(s.to_string(), e)) }) .collect::, ConfigError>>()?; Ok(Self(nameservers)) } }