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