The smokesignal.events web application
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}