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