The smokesignal.events web application
at main 407 lines 12 kB view raw
1use anyhow::Result; 2use atproto_identity::key::{KeyData, identify_key, to_public}; 3use axum_extra::extract::cookie::Key; 4use base64::{Engine as _, engine::general_purpose}; 5 6use crate::config_errors::ConfigError; 7 8#[derive(Clone)] 9pub struct HttpPort(u16); 10 11#[derive(Clone)] 12pub struct HttpCookieKey(Key); 13 14#[derive(Clone)] 15pub struct EmailSecretKey(Vec<u8>); 16 17#[derive(Clone)] 18pub struct CertificateBundles(Vec<String>); 19 20#[derive(Clone)] 21pub struct OAuthClientCredentialsKey(KeyData); 22 23#[derive(Clone)] 24pub struct AdminDIDs(Vec<String>); 25 26#[derive(Clone)] 27pub struct DnsNameservers(Vec<std::net::IpAddr>); 28 29#[derive(Clone)] 30pub struct ServiceKey(KeyData); 31 32#[derive(Clone)] 33pub struct Config { 34 pub version: String, 35 pub http_port: HttpPort, 36 pub http_cookie_key: HttpCookieKey, 37 pub http_static_path: String, 38 pub external_base: String, 39 pub certificate_bundles: CertificateBundles, 40 pub user_agent: String, 41 pub database_url: String, 42 pub plc_hostname: String, 43 pub tap_hostname: String, 44 pub tap_password: Option<String>, 45 pub redis_url: String, 46 pub admin_dids: AdminDIDs, 47 pub dns_nameservers: DnsNameservers, 48 pub oauth_client_credentials_key: OAuthClientCredentialsKey, 49 pub enable_tap: bool, 50 pub content_storage: String, 51 pub service_key: ServiceKey, 52 pub enable_opensearch: bool, 53 pub enable_task_opensearch: bool, 54 pub opensearch_endpoint: Option<String>, 55 pub smtp_credentials: Option<String>, 56 pub email_secret_key: EmailSecretKey, 57 pub facets_mentions_max: usize, 58 pub facets_tags_max: usize, 59 pub facets_links_max: usize, 60 pub facets_max: usize, 61 /// Optional MCP JWT signing key (defaults to http_cookie_key if not set) 62 pub mcp_jwt_key: Option<String>, 63} 64 65impl Config { 66 pub fn new() -> Result<Self> { 67 let http_port: HttpPort = default_env("HTTP_PORT", "8080").try_into()?; 68 69 let http_cookie_key: HttpCookieKey = 70 require_env("HTTP_COOKIE_KEY").and_then(|value| value.try_into())?; 71 72 let http_static_path = default_env("HTTP_STATIC_PATH", "static"); 73 74 let external_base = require_env("EXTERNAL_BASE")?; 75 76 let certificate_bundles: CertificateBundles = 77 optional_env("CERTIFICATE_BUNDLES").try_into()?; 78 79 let default_user_agent = 80 format!("smokesignal ({}; +https://smokesignal.events/)", version()?); 81 82 let user_agent = default_env("USER_AGENT", &default_user_agent); 83 84 let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); 85 86 let tap_hostname = default_env("TAP_HOSTNAME", "localhost:2480"); 87 let tap_password_str = optional_env("TAP_PASSWORD"); 88 let tap_password = if tap_password_str.is_empty() { 89 None 90 } else { 91 Some(tap_password_str) 92 }; 93 94 let database_url = default_env("DATABASE_URL", "sqlite://development.db"); 95 96 let redis_url = default_env("REDIS_URL", "redis://valkey:6379/0"); 97 98 let admin_dids: AdminDIDs = optional_env("ADMIN_DIDS").try_into()?; 99 100 let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; 101 102 // Parse OAuth client credentials key for AT Protocol OAuth 103 let oauth_client_credentials_key: OAuthClientCredentialsKey = 104 require_env("OAUTH_CLIENT_CREDENTIALS_KEY").and_then(|value| value.try_into())?; 105 106 let enable_tap = parse_bool_env("ENABLE_TAP", true); 107 108 let content_storage = require_env("CONTENT_STORAGE")?; 109 110 let service_key: ServiceKey = require_env("SERVICE_KEY")?.try_into()?; 111 112 // Parse OpenSearch configuration 113 let enable_opensearch = parse_bool_env("ENABLE_OPENSEARCH", false); 114 let enable_task_opensearch = parse_bool_env("ENABLE_TASK_OPENSEARCH", false); 115 let opensearch_endpoint = if enable_opensearch { 116 Some(require_env("OPENSEARCH_ENDPOINT")?) 117 } else { 118 let endpoint = optional_env("OPENSEARCH_ENDPOINT"); 119 if endpoint.is_empty() { 120 None 121 } else { 122 Some(endpoint) 123 } 124 }; 125 126 // Parse SMTP configuration (optional) 127 let smtp_credentials_str = optional_env("SMTP_CREDENTIALS"); 128 let smtp_credentials = if smtp_credentials_str.is_empty() { 129 None 130 } else { 131 Some(smtp_credentials_str) 132 }; 133 134 // Parse email secret key (required for email confirmation tokens) 135 let email_secret_key: EmailSecretKey = 136 require_env("EMAIL_SECRET_KEY").and_then(|value| value.try_into())?; 137 138 // Parse facet limit configuration 139 let facets_mentions_max = default_env("FACETS_MENTIONS_MAX", "5") 140 .parse::<usize>() 141 .unwrap_or(5); 142 let facets_tags_max = default_env("FACETS_TAGS_MAX", "5") 143 .parse::<usize>() 144 .unwrap_or(5); 145 let facets_links_max = default_env("FACETS_LINKS_MAX", "5") 146 .parse::<usize>() 147 .unwrap_or(5); 148 let facets_max = default_env("FACETS_MAX", "10") 149 .parse::<usize>() 150 .unwrap_or(10); 151 152 // Parse optional MCP JWT key (defaults to http_cookie_key if not set) 153 let mcp_jwt_key_str = optional_env("MCP_JWT_KEY"); 154 let mcp_jwt_key = if mcp_jwt_key_str.is_empty() { 155 None 156 } else { 157 Some(mcp_jwt_key_str) 158 }; 159 160 Ok(Self { 161 version: version()?, 162 http_port, 163 http_static_path, 164 external_base, 165 certificate_bundles, 166 user_agent, 167 plc_hostname, 168 tap_hostname, 169 tap_password, 170 database_url, 171 http_cookie_key, 172 redis_url, 173 admin_dids, 174 dns_nameservers, 175 oauth_client_credentials_key, 176 enable_tap, 177 content_storage, 178 service_key, 179 enable_opensearch, 180 enable_task_opensearch, 181 opensearch_endpoint, 182 smtp_credentials, 183 email_secret_key, 184 facets_mentions_max, 185 facets_tags_max, 186 facets_links_max, 187 facets_max, 188 mcp_jwt_key, 189 }) 190 } 191 192 /// Returns the OAuth client credentials key as (public_key_did, key_data) pair 193 pub fn select_oauth_signing_key(&self) -> Result<(String, KeyData)> { 194 let key_data = self.oauth_client_credentials_key.as_ref().clone(); 195 let public_key = to_public(&key_data)?; 196 Ok((public_key.to_string(), key_data)) 197 } 198 199 /// Check if a DID is in the admin allow list 200 pub fn is_admin(&self, did: &str) -> bool { 201 self.admin_dids.as_ref().contains(&did.to_string()) 202 } 203 204 /// Returns the OAuth scope string with the external_base interpolated 205 pub fn oauth_scope(&self) -> String { 206 format!( 207 "atproto include:community.lexicon.calendar.authFull?aud=did:web:{}#smokesignal include:events.smokesignal.authFull include:app.bsky.authCreatePosts blob:*/* account:email", 208 self.external_base 209 ) 210 } 211} 212 213fn require_env(name: &str) -> Result<String> { 214 std::env::var(name).map_err(|_| ConfigError::EnvVarRequired(name.to_string()).into()) 215} 216 217fn optional_env(name: &str) -> String { 218 std::env::var(name).unwrap_or("".to_string()) 219} 220 221fn default_env(name: &str, default_value: &str) -> String { 222 std::env::var(name).unwrap_or(default_value.to_string()) 223} 224 225fn parse_bool_env(name: &str, default: bool) -> bool { 226 match std::env::var(name) { 227 Ok(value) => matches!(value.to_lowercase().as_str(), "true" | "ok" | "1"), 228 Err(_) => default, 229 } 230} 231 232pub fn version() -> Result<String> { 233 option_env!("GIT_HASH") 234 .or(option_env!("CARGO_PKG_VERSION")) 235 .map(|val| val.to_string()) 236 .ok_or(ConfigError::VersionNotSet.into()) 237} 238 239impl TryFrom<String> for HttpPort { 240 type Error = anyhow::Error; 241 fn try_from(value: String) -> Result<Self, Self::Error> { 242 if value.is_empty() { 243 Ok(Self(80)) 244 } else { 245 value 246 .parse::<u16>() 247 .map(Self) 248 .map_err(|err| ConfigError::PortParsingFailed(err).into()) 249 } 250 } 251} 252 253impl AsRef<u16> for HttpPort { 254 fn as_ref(&self) -> &u16 { 255 &self.0 256 } 257} 258 259impl TryFrom<String> for HttpCookieKey { 260 type Error = anyhow::Error; 261 fn try_from(value: String) -> Result<Self, Self::Error> { 262 let mut decoded_key: [u8; 66] = [0; 66]; 263 general_purpose::STANDARD_NO_PAD 264 .decode_slice(value, &mut decoded_key) 265 .map_err(|err| anyhow::Error::from(ConfigError::CookieKeyDecodeFailed(err)))?; 266 Key::try_from(&decoded_key[..64]) 267 .map_err(|_| anyhow::Error::from(ConfigError::CookieKeyProcessFailed)) 268 .map(Self) 269 } 270} 271 272impl AsRef<Key> for HttpCookieKey { 273 fn as_ref(&self) -> &Key { 274 &self.0 275 } 276} 277 278impl TryFrom<String> for EmailSecretKey { 279 type Error = anyhow::Error; 280 fn try_from(value: String) -> Result<Self, Self::Error> { 281 // Decode hex string to bytes 282 let decoded = hex::decode(&value).map_err(ConfigError::EmailSecretKeyDecodeFailed)?; 283 284 // Require at least 32 bytes (256 bits) for security 285 if decoded.len() < 32 { 286 return Err(ConfigError::EmailSecretKeyTooShort(decoded.len()).into()); 287 } 288 289 Ok(Self(decoded)) 290 } 291} 292 293impl AsRef<[u8]> for EmailSecretKey { 294 fn as_ref(&self) -> &[u8] { 295 &self.0 296 } 297} 298 299impl TryFrom<String> for ServiceKey { 300 type Error = anyhow::Error; 301 fn try_from(value: String) -> Result<Self, Self::Error> { 302 identify_key(&value) 303 .map(ServiceKey) 304 .map_err(|err| err.into()) 305 } 306} 307 308impl AsRef<KeyData> for ServiceKey { 309 fn as_ref(&self) -> &KeyData { 310 &self.0 311 } 312} 313 314impl TryFrom<String> for CertificateBundles { 315 type Error = anyhow::Error; 316 fn try_from(value: String) -> Result<Self, Self::Error> { 317 Ok(Self( 318 value 319 .split(';') 320 .filter_map(|s| { 321 if s.is_empty() { 322 None 323 } else { 324 Some(s.to_string()) 325 } 326 }) 327 .collect::<Vec<String>>(), 328 )) 329 } 330} 331 332impl AsRef<Vec<String>> for CertificateBundles { 333 fn as_ref(&self) -> &Vec<String> { 334 &self.0 335 } 336} 337 338impl TryFrom<String> for OAuthClientCredentialsKey { 339 type Error = anyhow::Error; 340 fn try_from(value: String) -> Result<Self, Self::Error> { 341 if value.is_empty() { 342 return Err(ConfigError::EmptySigningKeys.into()); 343 } 344 345 identify_key(&value) 346 .map(OAuthClientCredentialsKey) 347 .map_err(|err| err.into()) 348 } 349} 350 351impl AsRef<KeyData> for OAuthClientCredentialsKey { 352 fn as_ref(&self) -> &KeyData { 353 &self.0 354 } 355} 356 357impl AsRef<Vec<String>> for AdminDIDs { 358 fn as_ref(&self) -> &Vec<String> { 359 &self.0 360 } 361} 362 363impl TryFrom<String> for AdminDIDs { 364 type Error = anyhow::Error; 365 fn try_from(value: String) -> Result<Self, Self::Error> { 366 // Allow empty value for no admins 367 if value.is_empty() { 368 return Ok(Self(Vec::new())); 369 } 370 371 let admin_dids = value 372 .split(',') 373 .map(|s| s.trim().to_string()) 374 .filter(|s| !s.is_empty()) 375 .collect::<Vec<String>>(); 376 377 Ok(Self(admin_dids)) 378 } 379} 380 381impl AsRef<Vec<std::net::IpAddr>> for DnsNameservers { 382 fn as_ref(&self) -> &Vec<std::net::IpAddr> { 383 &self.0 384 } 385} 386 387impl TryFrom<String> for DnsNameservers { 388 type Error = anyhow::Error; 389 fn try_from(value: String) -> Result<Self, Self::Error> { 390 // Allow empty value for default DNS configuration 391 if value.is_empty() { 392 return Ok(Self(Vec::new())); 393 } 394 395 let nameservers = value 396 .split(',') 397 .map(|s| s.trim()) 398 .filter(|s| !s.is_empty()) 399 .map(|s| { 400 s.parse::<std::net::IpAddr>() 401 .map_err(|e| ConfigError::NameserverParsingFailed(s.to_string(), e)) 402 }) 403 .collect::<Result<Vec<std::net::IpAddr>, ConfigError>>()?; 404 405 Ok(Self(nameservers)) 406 } 407}