Skip to main content

headless_lms_server/config/
mod.rs

1//! Functionality for configuring the server
2pub mod open_university_config;
3pub mod program_config;
4
5use crate::{
6    OAuthClient,
7    config::program_config::ProgramConfig,
8    domain::{
9        models_requests::JwtKey, rate_limit_middleware_builder::RateLimit,
10        request_span_middleware::RequestSpan,
11    },
12};
13use actix_http::{StatusCode, body::MessageBody};
14use actix_web::{
15    HttpResponse,
16    error::InternalError,
17    web::{self, Data, PayloadConfig, ServiceConfig},
18};
19use anyhow::Context;
20use headless_lms_base::config::ApplicationConfiguration;
21use headless_lms_utils::{
22    cache::Cache, file_store::FileStore, icu4x::Icu4xBlob, ip_to_country::IpToCountryMapper,
23    services::sisu::SisuClient, services::tmc::TmcClient,
24};
25use oauth2::{AuthUrl, ClientId, ClientSecret, TokenUrl, basic::BasicClient};
26use secrecy::{ExposeSecret, SecretString};
27use sqlx::{PgPool, postgres::PgPoolOptions};
28use std::{
29    env,
30    sync::{Arc, OnceLock},
31};
32use url::Url;
33
34static SERVER_RUNTIME_CONFIG: OnceLock<ServerRuntimeConfig> = OnceLock::new();
35
36#[derive(Clone)]
37pub struct FileStoreRuntimeConfig {
38    pub use_google_cloud_storage: bool,
39    pub google_cloud_storage_bucket_name: Option<String>,
40}
41
42#[derive(Clone)]
43pub struct ServerRuntimeConfig {
44    /// Database connection URL — contains credentials, so kept secret.
45    pub database_url: SecretString,
46    pub oauth_application_id: String,
47    pub oauth_secret: SecretString,
48    pub icu4x_postcard_path: String,
49    pub app_conf: ApplicationConfiguration,
50    /// Redis connection URL — may contain credentials, so kept secret.
51    pub redis_url: SecretString,
52    pub jwt_password: SecretString,
53    pub private_cookie_key: SecretString,
54    pub test_mode: bool,
55    pub allow_no_https_for_development: bool,
56    pub host: String,
57    pub port: String,
58    pub file_store: FileStoreRuntimeConfig,
59    pub tmc_server_secret_for_communicating_to_secret_project: SecretString,
60    pub ratelimit_protection_safe_api_key: SecretString,
61    pub pod_namespace: String,
62}
63
64impl ServerRuntimeConfig {
65    /// Loads runtime configuration from environment variables.
66    pub fn try_from_env() -> anyhow::Result<Self> {
67        let app_conf = ApplicationConfiguration::try_from_env()?;
68        let test_mode = app_conf.test_mode;
69        let file_store_use_google_cloud_storage =
70            ProgramConfig::bool_flag("FILE_STORE_USE_GOOGLE_CLOUD_STORAGE");
71        let google_cloud_storage_bucket_name = if file_store_use_google_cloud_storage {
72            Some(
73                env::var("GOOGLE_CLOUD_STORAGE_BUCKET_NAME")
74                    .context("GOOGLE_CLOUD_STORAGE_BUCKET_NAME must be defined when FILE_STORE_USE_GOOGLE_CLOUD_STORAGE is enabled")?,
75            )
76        } else {
77            None
78        };
79        let ratelimit_protection_safe_api_key = match env::var("RATELIMIT_PROTECTION_SAFE_API_KEY")
80        {
81            Ok(value) => value,
82            Err(_) if cfg!(debug_assertions) || test_mode => "mock-api-key".to_string(),
83            Err(_) => {
84                anyhow::bail!("RATELIMIT_PROTECTION_SAFE_API_KEY must be defined in production")
85            }
86        };
87
88        Ok(Self {
89            database_url: SecretString::new(
90                env::var("DATABASE_URL")
91                    .context("DATABASE_URL must be defined")?
92                    .into(),
93            ),
94            oauth_application_id: env::var("OAUTH_APPLICATION_ID")
95                .context("OAUTH_APPLICATION_ID must be defined")?,
96            oauth_secret: SecretString::new(
97                env::var("OAUTH_SECRET")
98                    .context("OAUTH_SECRET must be defined")?
99                    .into(),
100            ),
101            icu4x_postcard_path: env::var("ICU4X_POSTCARD_PATH")
102                .context("ICU4X_POSTCARD_PATH must be defined")?,
103            redis_url: SecretString::new(
104                env::var("REDIS_URL")
105                    .context("REDIS_URL must be defined")?
106                    .into(),
107            ),
108            jwt_password: SecretString::new(
109                env::var("JWT_PASSWORD")
110                    .context("JWT_PASSWORD must be defined")?
111                    .into(),
112            ),
113            private_cookie_key: SecretString::new(
114                env::var("PRIVATE_COOKIE_KEY")
115                    .context("PRIVATE_COOKIE_KEY must be defined")?
116                    .into(),
117            ),
118            allow_no_https_for_development: ProgramConfig::bool_flag(
119                "ALLOW_NO_HTTPS_FOR_DEVELOPMENT",
120            ),
121            host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
122            port: env::var("PORT").unwrap_or_else(|_| "3001".to_string()),
123            file_store: FileStoreRuntimeConfig {
124                use_google_cloud_storage: file_store_use_google_cloud_storage,
125                google_cloud_storage_bucket_name,
126            },
127            tmc_server_secret_for_communicating_to_secret_project: SecretString::new(
128                env::var("TMC_SERVER_SECRET_FOR_COMMUNICATING_TO_SECRET_PROJECT")
129                    .context(
130                        "TMC_SERVER_SECRET_FOR_COMMUNICATING_TO_SECRET_PROJECT must be defined",
131                    )?
132                    .into(),
133            ),
134            ratelimit_protection_safe_api_key: SecretString::new(
135                ratelimit_protection_safe_api_key.into(),
136            ),
137            pod_namespace: env::var("POD_NAMESPACE").unwrap_or_else(|_| "default".to_string()),
138            app_conf,
139            test_mode,
140        })
141    }
142}
143
144/// Sets global runtime configuration for request-path consumers.
145pub fn set_server_runtime_config(config: ServerRuntimeConfig) -> anyhow::Result<()> {
146    SERVER_RUNTIME_CONFIG.set(config).map_err(|_| {
147        anyhow::anyhow!(
148            "SERVER_RUNTIME_CONFIG was already initialized in set_server_runtime_config"
149        )
150    })
151}
152
153/// Returns global runtime configuration loaded during startup.
154pub fn server_runtime_config() -> &'static ServerRuntimeConfig {
155    SERVER_RUNTIME_CONFIG
156        .get()
157        .expect("SERVER_RUNTIME_CONFIG has not been initialized; call set_server_runtime_config before request handling")
158}
159
160pub struct ServerConfigBuilder {
161    pub database_url: SecretString,
162    pub oauth_application_id: String,
163    pub oauth_secret: SecretString,
164    pub auth_url: Url,
165    pub token_url: Url,
166    pub icu4x_postcard_path: String,
167    pub file_store: Arc<dyn FileStore + Send + Sync>,
168    pub app_conf: ApplicationConfiguration,
169    pub redis_url: SecretString,
170    pub jwt_password: SecretString,
171    pub tmc_client: TmcClient,
172    pub sisu_client: SisuClient,
173}
174
175impl ServerConfigBuilder {
176    pub async fn from_runtime_config(runtime_config: &ServerRuntimeConfig) -> anyhow::Result<Self> {
177        Ok(Self {
178            database_url: runtime_config.database_url.clone(),
179            oauth_application_id: runtime_config.oauth_application_id.clone(),
180            oauth_secret: runtime_config.oauth_secret.clone(),
181            auth_url: "https://tmc.mooc.fi/oauth/authorize"
182                .parse()
183                .context("Failed to parse auth_url")?,
184            token_url: "https://tmc.mooc.fi/oauth/token"
185                .parse()
186                .context("Failed to parse token url")?,
187            icu4x_postcard_path: runtime_config.icu4x_postcard_path.clone(),
188            file_store: crate::setup_file_store(
189                &runtime_config.file_store,
190                &runtime_config.app_conf.base_url,
191            )
192            .await,
193            app_conf: runtime_config.app_conf.clone(),
194            redis_url: runtime_config.redis_url.clone(),
195            jwt_password: runtime_config.jwt_password.clone(),
196            tmc_client: TmcClient::new(
197                runtime_config.app_conf.tmc_admin_access_token.clone(),
198                runtime_config.ratelimit_protection_safe_api_key.clone(),
199            )?,
200            sisu_client: SisuClient::new(runtime_config.app_conf.base_url.clone())?,
201        })
202    }
203
204    pub async fn build(self) -> anyhow::Result<ServerConfig> {
205        let json_config = web::JsonConfig::default().limit(2_097_152).error_handler(
206            |err, _req| -> actix_web::Error {
207                info!("Bad request: {}", &err);
208                let body = format!("{{\"title\": \"Bad Request\", \"message\": \"{}\"}}", &err);
209                // create custom error response
210                let response = HttpResponse::with_body(StatusCode::BAD_REQUEST, body.boxed());
211                InternalError::from_response(err, response).into()
212            },
213        );
214        let json_config = Data::new(json_config);
215
216        let payload_config = PayloadConfig::default().limit(2_097_152);
217        let payload_config = Data::new(payload_config);
218
219        let db_pool = PgPoolOptions::new()
220            .max_connections(15)
221            .min_connections(5)
222            .connect(self.database_url.expose_secret())
223            .await?;
224        crate::domain::internal_error_reporting::init_error_reporting(db_pool.clone());
225        let db_pool = Data::new(db_pool);
226
227        let oauth_client: OAuthClient = BasicClient::new(ClientId::new(self.oauth_application_id))
228            .set_client_secret(ClientSecret::new(
229                self.oauth_secret.expose_secret().to_string(),
230            ))
231            .set_auth_uri(AuthUrl::from_url(self.auth_url.clone()))
232            .set_token_uri(TokenUrl::from_url(self.token_url.clone()));
233        let oauth_client = Data::new(oauth_client);
234
235        let icu4x_blob = Icu4xBlob::new(&self.icu4x_postcard_path)?;
236        let icu4x_blob = Data::new(icu4x_blob);
237
238        let app_conf = Data::new(self.app_conf);
239
240        let ip_to_country_mapper = IpToCountryMapper::new(&app_conf)?;
241        let ip_to_country_mapper = Data::new(ip_to_country_mapper);
242
243        let cache = Cache::new(self.redis_url.expose_secret())?;
244        let cache = Data::new(cache);
245
246        let jwt_key = JwtKey::new(&self.jwt_password)?;
247        let jwt_key = Data::new(jwt_key);
248
249        let tmc_client = Data::new(self.tmc_client);
250
251        let sisu_client = Data::new(self.sisu_client);
252
253        let config = ServerConfig {
254            json_config,
255            db_pool,
256            oauth_client,
257            icu4x_blob,
258            ip_to_country_mapper,
259            file_store: self.file_store,
260            app_conf,
261            jwt_key,
262            cache,
263            payload_config,
264            tmc_client,
265            sisu_client,
266        };
267        Ok(config)
268    }
269}
270
271#[derive(Clone)]
272pub struct ServerConfig {
273    pub payload_config: Data<PayloadConfig>,
274    pub json_config: Data<web::JsonConfig>,
275    pub db_pool: Data<PgPool>,
276    pub oauth_client: Data<OAuthClient>,
277    pub icu4x_blob: Data<Icu4xBlob>,
278    pub ip_to_country_mapper: Data<IpToCountryMapper>,
279    pub file_store: Arc<dyn FileStore + Send + Sync>,
280    pub app_conf: Data<ApplicationConfiguration>,
281    pub cache: Data<Cache>,
282    pub jwt_key: Data<JwtKey>,
283    pub tmc_client: Data<TmcClient>,
284    pub sisu_client: Data<SisuClient>,
285}
286
287/// Common configuration that is used by both production and testing.
288pub fn configure(config: &mut ServiceConfig, server_config: ServerConfig) {
289    let ServerConfig {
290        json_config,
291        db_pool,
292        oauth_client,
293        icu4x_blob,
294        ip_to_country_mapper,
295        file_store,
296        app_conf,
297        jwt_key,
298        cache,
299        payload_config,
300        tmc_client,
301        sisu_client,
302    } = server_config;
303    let api_rate_limit_config = RateLimit::global_api_rate_limit_config(app_conf.test_mode);
304    // turns file_store from `dyn FileStore + Send + Sync` to `dyn FileStore` to match controllers
305    // Not using Data::new for file_store to avoid double wrapping it in a arc
306    let file_store = Data::from(file_store as Arc<dyn FileStore>);
307    config
308        .app_data(payload_config)
309        .app_data(json_config)
310        .app_data(db_pool)
311        .app_data(oauth_client)
312        .app_data(icu4x_blob)
313        .app_data(ip_to_country_mapper)
314        .app_data(file_store)
315        .app_data(app_conf.clone())
316        .app_data(jwt_key)
317        .app_data(cache)
318        .app_data(tmc_client)
319        .app_data(sisu_client)
320        .service(
321            web::scope("/api/v0")
322                .wrap(RateLimit::new(api_rate_limit_config))
323                .wrap(RequestSpan)
324                .configure(|c| crate::controllers::configure_controllers(c, app_conf)),
325        );
326}