headless_lms_utils/
lib.rs

1//! Commonly used utils.
2
3pub mod cache;
4pub mod document_schema_processor;
5pub mod email_processor;
6pub mod error;
7pub mod file_store;
8pub mod folder_checksum;
9pub mod futures;
10pub mod http;
11pub mod icu4x;
12pub mod ip_to_country;
13pub mod language_tag_to_name;
14pub mod merge_edits;
15pub mod numbers;
16pub mod page_visit_hasher;
17pub mod pagination;
18pub mod prelude;
19pub mod strings;
20pub mod tmc;
21pub mod url_encoding;
22pub mod url_to_oembed_endpoint;
23
24#[macro_use]
25extern crate tracing;
26
27use anyhow::Context;
28use secrecy::{ExposeSecret, SecretBox, SecretString};
29use std::sync::Arc;
30use std::{env, str::FromStr};
31use url::Url;
32
33#[derive(Clone)]
34pub struct ApplicationConfiguration {
35    pub base_url: String,
36    pub test_mode: bool,
37    pub test_chatbot: bool,
38    pub development_uuid_login: bool,
39    pub azure_configuration: Option<AzureConfiguration>,
40    pub tmc_account_creation_origin: Option<String>,
41    pub tmc_admin_access_token: SecretString,
42    pub oauth_server_configuration: OAuthServerConfiguration,
43}
44
45impl ApplicationConfiguration {
46    /// Attempts to create an ApplicationConfiguration from environment variables.
47    pub fn try_from_env() -> anyhow::Result<Self> {
48        let base_url = env::var("BASE_URL").context("BASE_URL must be defined")?;
49        let test_mode = env::var("TEST_MODE").is_ok();
50        let development_uuid_login = env::var("DEVELOPMENT_UUID_LOGIN").is_ok();
51        let test_chatbot = test_mode
52            && (env::var("USE_MOCK_AZURE_CONFIGURATION").is_ok_and(|v| v.as_str() != "false")
53                || env::var("AZURE_CHATBOT_API_KEY").is_err());
54
55        let azure_configuration = if test_chatbot {
56            AzureConfiguration::mock_conf()?
57        } else {
58            AzureConfiguration::try_from_env()?
59        };
60
61        let tmc_account_creation_origin = Some(
62            env::var("TMC_ACCOUNT_CREATION_ORIGIN")
63                .context("TMC_ACCOUNT_CREATION_ORIGIN must be defined")?,
64        );
65
66        let tmc_admin_access_token = SecretString::new(
67            std::env::var("TMC_ACCESS_TOKEN")
68                .unwrap_or_else(|_| {
69                    if test_mode {
70                        "mock-access-token".to_string()
71                    } else {
72                        panic!("TMC_ACCESS_TOKEN must be defined in production")
73                    }
74                })
75                .into(),
76        );
77        let oauth_server_configuration = OAuthServerConfiguration::try_from_env()
78            .context("Failed to load OAuth server configuration")?;
79
80        Ok(Self {
81            base_url,
82            test_mode,
83            test_chatbot,
84            development_uuid_login,
85            azure_configuration,
86            tmc_account_creation_origin,
87            tmc_admin_access_token,
88            oauth_server_configuration,
89        })
90    }
91}
92
93#[derive(Clone, PartialEq)]
94pub struct AzureChatbotConfiguration {
95    pub api_key: String,
96    pub api_endpoint: Url,
97}
98
99impl AzureChatbotConfiguration {
100    /// Attempts to create an AzureChatbotConfiguration from environment variables.
101    /// Returns `Ok(Some(AzureChatbotConfiguration))` if both environment variables are set.
102    /// Returns `Ok(None)` if no environment variables are set for chatbot.
103    /// Returns an error if set environment variables fail to parse.
104    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
105        let api_key = env::var("AZURE_CHATBOT_API_KEY").ok();
106        let api_endpoint_str = env::var("AZURE_CHATBOT_API_ENDPOINT").ok();
107
108        if let (Some(api_key), Some(api_endpoint_str)) = (api_key, api_endpoint_str) {
109            let api_endpoint = Url::parse(&api_endpoint_str)
110                .context("Invalid URL in AZURE_CHATBOT_API_ENDPOINT")?;
111            Ok(Some(AzureChatbotConfiguration {
112                api_key,
113                api_endpoint,
114            }))
115        } else {
116            Ok(None)
117        }
118    }
119}
120
121#[derive(Clone, PartialEq)]
122pub struct AzureSearchConfiguration {
123    pub vectorizer_resource_uri: String,
124    pub vectorizer_deployment_id: String,
125    pub vectorizer_api_key: String,
126    pub vectorizer_model_name: String,
127    pub search_endpoint: Url,
128    pub search_api_key: String,
129}
130
131impl AzureSearchConfiguration {
132    /// Attempts to create an AzureSearchConfiguration from environment variables.
133    /// Returns `Ok(Some(AzureSearchConfiguration))` if all related environment variables are set.
134    /// Returns `Ok(None)` if no environment variables are set for search and vectorizer.
135    /// Returns an error if set environment variables fail to parse.
136    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
137        let vectorizer_resource_uri = env::var("AZURE_VECTORIZER_RESOURCE_URI").ok();
138        let vectorizer_deployment_id = env::var("AZURE_VECTORIZER_DEPLOYMENT_ID").ok();
139        let vectorizer_api_key = env::var("AZURE_VECTORIZER_API_KEY").ok();
140        let vectorizer_model_name = env::var("AZURE_VECTORIZER_MODEL_NAME").ok();
141        let search_endpoint_str = env::var("AZURE_SEARCH_ENDPOINT").ok();
142        let search_api_key = env::var("AZURE_SEARCH_API_KEY").ok();
143
144        if let (
145            Some(vectorizer_resource_uri),
146            Some(vectorizer_deployment_id),
147            Some(vectorizer_api_key),
148            Some(vectorizer_model_name),
149            Some(search_endpoint_str),
150            Some(search_api_key),
151        ) = (
152            vectorizer_resource_uri,
153            vectorizer_deployment_id,
154            vectorizer_api_key,
155            vectorizer_model_name,
156            search_endpoint_str,
157            search_api_key,
158        ) {
159            let search_endpoint =
160                Url::parse(&search_endpoint_str).context("Invalid URL in AZURE_SEARCH_ENDPOINT")?;
161            Ok(Some(AzureSearchConfiguration {
162                vectorizer_resource_uri,
163                vectorizer_deployment_id,
164                vectorizer_api_key,
165                vectorizer_model_name,
166                search_endpoint,
167                search_api_key,
168            }))
169        } else {
170            Ok(None)
171        }
172    }
173}
174
175#[derive(Clone, PartialEq)]
176pub struct AzureBlobStorageConfiguration {
177    pub storage_account: String,
178    pub access_key: String,
179}
180
181impl AzureBlobStorageConfiguration {
182    /// Attempts to create an AzureBlobStorageConfiguration from environment variables.
183    /// Returns `Ok(Some(AzureBlobStorageConfiguration))` if both environment variables are set.
184    /// Returns `Ok(None)` if no environment variables are set for blob storage.
185    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
186        let storage_account = env::var("AZURE_BLOB_STORAGE_ACCOUNT").ok();
187        let access_key = env::var("AZURE_BLOB_STORAGE_ACCESS_KEY").ok();
188
189        if let (Some(storage_account), Some(access_key)) = (storage_account, access_key) {
190            Ok(Some(AzureBlobStorageConfiguration {
191                storage_account,
192                access_key,
193            }))
194        } else {
195            Ok(None)
196        }
197    }
198
199    pub fn connection_string(&self) -> anyhow::Result<String> {
200        Ok(format!(
201            "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
202            self.storage_account, self.access_key
203        ))
204    }
205}
206
207#[derive(Clone, PartialEq)]
208pub struct AzureConfiguration {
209    pub chatbot_config: Option<AzureChatbotConfiguration>,
210    pub search_config: Option<AzureSearchConfiguration>,
211    pub blob_storage_config: Option<AzureBlobStorageConfiguration>,
212}
213
214impl AzureConfiguration {
215    /// Attempts to create an AzureConfiguration by calling the individual try_from_env functions.
216    /// Returns `Ok(Some(AzureConfiguration))` if any of the configurations are set.
217    /// Returns `Ok(None)` if no relevant environment variables are set.
218    pub fn try_from_env() -> anyhow::Result<Option<Self>> {
219        let chatbot = AzureChatbotConfiguration::try_from_env()?;
220        let search_config = AzureSearchConfiguration::try_from_env()?;
221        let blob_storage_config = AzureBlobStorageConfiguration::try_from_env()?;
222
223        if chatbot.is_some() || search_config.is_some() || blob_storage_config.is_some() {
224            Ok(Some(AzureConfiguration {
225                chatbot_config: chatbot,
226                search_config,
227                blob_storage_config,
228            }))
229        } else {
230            Ok(None)
231        }
232    }
233
234    /// Creates an AzureConfiguration with empty and mock values to be used in testing and dev
235    /// environments when Azure access is not needed. Enables the azure chatbot functionality to be
236    /// mocked with the api_endpoint from our application.
237    /// Returns `Ok(Some(AzureConfiguration))`
238    pub fn mock_conf() -> anyhow::Result<Option<Self>> {
239        let base_url = env::var("BASE_URL").context("BASE_URL must be defined")?;
240        let chatbot_config = Some(AzureChatbotConfiguration {
241            api_key: "".to_string(),
242            api_endpoint: Url::parse(&base_url)?.join("/api/v0/mock-azure/test/")?,
243        });
244        let search_config = Some(AzureSearchConfiguration {
245            vectorizer_resource_uri: "".to_string(),
246            vectorizer_deployment_id: "".to_string(),
247            vectorizer_api_key: "".to_string(),
248            vectorizer_model_name: "".to_string(),
249            search_api_key: "".to_string(),
250            search_endpoint: Url::from_str("https://example.com/does-not-exist/")?,
251        });
252        let blob_storage_config = Some(AzureBlobStorageConfiguration {
253            storage_account: "".to_string(),
254            access_key: "".to_string(),
255        });
256
257        Ok(Some(AzureConfiguration {
258            chatbot_config,
259            search_config,
260            blob_storage_config,
261        }))
262    }
263}
264
265#[derive(Clone)]
266pub struct OAuthServerConfiguration {
267    pub rsa_public_key: String,
268    pub rsa_private_key: String,
269    /// Secret key for HMAC-SHA-256 hashing of OAuth tokens (access tokens, refresh tokens, auth codes).
270    pub oauth_token_hmac_key: String,
271    /// Secret key for signing DPoP nonces (HMAC).
272    pub dpop_nonce_key: Arc<SecretBox<String>>,
273}
274
275impl PartialEq for OAuthServerConfiguration {
276    fn eq(&self, other: &Self) -> bool {
277        self.rsa_public_key == other.rsa_public_key
278            && self.rsa_private_key == other.rsa_private_key
279            && self.oauth_token_hmac_key == other.oauth_token_hmac_key
280            && self.dpop_nonce_key.expose_secret() == other.dpop_nonce_key.expose_secret()
281    }
282}
283
284impl OAuthServerConfiguration {
285    /// Attempts to create an OAuthServerConfiguration.
286    /// Return `Ok(Some(OAuthConfiguration))` if all configurations are set.
287    /// Return `Err` if any is not set.
288    pub fn try_from_env() -> anyhow::Result<Self> {
289        let rsa_public_key =
290            env::var("OAUTH_RSA_PUBLIC_PEM").context("OAUTH_RSA_PUBLIC_KEY must be defined")?;
291        let rsa_private_key =
292            env::var("OAUTH_RSA_PRIVATE_PEM").context("OAUTH_RSA_PRIVATE_KEY must be defined")?;
293        let oauth_token_hmac_key =
294            env::var("OAUTH_TOKEN_HMAC_KEY").context("OAUTH_TOKEN_HMAC_KEY must be defined")?;
295        let dpop_nonce_key = Arc::new(SecretBox::new(Box::new(
296            env::var("OAUTH_DPOP_NONCE_KEY").context("OAUTH_DPOP_NONCE_KEY must be defined")?,
297        )));
298
299        Ok(Self {
300            rsa_public_key,
301            rsa_private_key,
302            oauth_token_hmac_key,
303            dpop_nonce_key,
304        })
305    }
306}