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