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