1pub 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 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 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 pub redis_url: SecretString,
75 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}
87
88impl ServerRuntimeConfig {
89 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 private_cookie_key: SecretString::new(
128 env::var("PRIVATE_COOKIE_KEY")
129 .context("PRIVATE_COOKIE_KEY must be defined")?
130 .into(),
131 ),
132 allow_no_https_for_development: ProgramConfig::bool_flag(
133 "ALLOW_NO_HTTPS_FOR_DEVELOPMENT",
134 ),
135 host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
136 port: env::var("PORT").unwrap_or_else(|_| "3001".to_string()),
137 file_store,
138 tmc_server_secret_for_communicating_to_secret_project: SecretString::new(
139 env::var("TMC_SERVER_SECRET_FOR_COMMUNICATING_TO_SECRET_PROJECT")
140 .context(
141 "TMC_SERVER_SECRET_FOR_COMMUNICATING_TO_SECRET_PROJECT must be defined",
142 )?
143 .into(),
144 ),
145 ratelimit_protection_safe_api_key: SecretString::new(
146 ratelimit_protection_safe_api_key.into(),
147 ),
148 pod_namespace: env::var("POD_NAMESPACE").unwrap_or_else(|_| "default".to_string()),
149 app_conf,
150 test_mode,
151 })
152 }
153}
154
155pub fn set_server_runtime_config(config: ServerRuntimeConfig) -> anyhow::Result<()> {
157 SERVER_RUNTIME_CONFIG.set(config).map_err(|_| {
158 anyhow::anyhow!(
159 "SERVER_RUNTIME_CONFIG was already initialized in set_server_runtime_config"
160 )
161 })
162}
163
164pub fn server_runtime_config() -> &'static ServerRuntimeConfig {
166 SERVER_RUNTIME_CONFIG
167 .get()
168 .expect("SERVER_RUNTIME_CONFIG has not been initialized; call set_server_runtime_config before request handling")
169}
170
171pub struct ServerConfigBuilder {
172 pub database_url: SecretString,
173 pub oauth_application_id: String,
174 pub oauth_secret: SecretString,
175 pub auth_url: Url,
176 pub token_url: Url,
177 pub icu4x_postcard_path: String,
178 pub file_store: Arc<dyn FileStore + Send + Sync>,
179 pub app_conf: ApplicationConfiguration,
180 pub redis_url: SecretString,
181 pub mock_suotar_redis_db_index: i64,
182 pub tmc_client: TmcClient,
183 pub sisu_client: SisuClient,
184}
185
186impl ServerConfigBuilder {
187 pub async fn from_runtime_config(runtime_config: &ServerRuntimeConfig) -> anyhow::Result<Self> {
188 Ok(Self {
189 database_url: runtime_config.database_url.clone(),
190 oauth_application_id: runtime_config.oauth_application_id.clone(),
191 oauth_secret: runtime_config.oauth_secret.clone(),
192 auth_url: "https://tmc.mooc.fi/oauth/authorize"
193 .parse()
194 .context("Failed to parse auth_url")?,
195 token_url: "https://tmc.mooc.fi/oauth/token"
196 .parse()
197 .context("Failed to parse token url")?,
198 icu4x_postcard_path: runtime_config.icu4x_postcard_path.clone(),
199 file_store: crate::setup_file_store(
200 &runtime_config.file_store,
201 &runtime_config.app_conf.base_url,
202 )
203 .await,
204 app_conf: runtime_config.app_conf.clone(),
205 redis_url: runtime_config.redis_url.clone(),
206 mock_suotar_redis_db_index: runtime_config.mock_suotar_redis_db_index,
207 tmc_client: TmcClient::new(
208 runtime_config.app_conf.tmc_admin_access_token.clone(),
209 runtime_config.ratelimit_protection_safe_api_key.clone(),
210 )?,
211 sisu_client: SisuClient::new(runtime_config.app_conf.base_url.clone())?,
212 })
213 }
214
215 pub async fn build(self) -> anyhow::Result<ServerConfig> {
216 let json_config = web::JsonConfig::default().limit(2_097_152).error_handler(
217 |err, _req| -> actix_web::Error {
218 info!("Bad request: {}", &err);
219 let body = format!("{{\"title\": \"Bad Request\", \"message\": \"{}\"}}", &err);
220 let response = HttpResponse::with_body(StatusCode::BAD_REQUEST, body.boxed());
222 InternalError::from_response(err, response).into()
223 },
224 );
225 let json_config = Data::new(json_config);
226
227 let payload_config = PayloadConfig::default().limit(2_097_152);
228 let payload_config = Data::new(payload_config);
229
230 let db_pool = PgPoolOptions::new()
231 .max_connections(15)
232 .min_connections(5)
233 .connect(self.database_url.expose_secret())
234 .await?;
235 crate::domain::internal_error_reporting::init_error_reporting(db_pool.clone());
236 let db_pool = Data::new(db_pool);
237
238 let oauth_client: OAuthClient = BasicClient::new(ClientId::new(self.oauth_application_id))
239 .set_client_secret(ClientSecret::new(
240 self.oauth_secret.expose_secret().to_string(),
241 ))
242 .set_auth_uri(AuthUrl::from_url(self.auth_url.clone()))
243 .set_token_uri(TokenUrl::from_url(self.token_url.clone()));
244 let oauth_client = Data::new(oauth_client);
245
246 let icu4x_blob = Icu4xBlob::new(&self.icu4x_postcard_path)?;
247 let icu4x_blob = Data::new(icu4x_blob);
248
249 let app_conf = Data::new(self.app_conf);
250
251 let ip_to_country_mapper = IpToCountryMapper::new(&app_conf)?;
252 let ip_to_country_mapper = Data::new(ip_to_country_mapper);
253
254 let cache = Cache::new(self.redis_url.expose_secret())?;
255 let cache = Data::new(cache);
256
257 let mock_suotar_store = if app_conf.test_suotar {
259 warn!(
260 "MOCK SUOTAR ENABLED - credit registrations are simulated and are NOT recorded in Sisu"
261 );
262 Some(Data::new(MockSuotarStore::new(
263 self.redis_url.expose_secret(),
264 self.mock_suotar_redis_db_index,
265 )?))
266 } else {
267 None
268 };
269
270 let jwt_key = Data::new(JwtKey::new(&app_conf.jwt_password)?);
271
272 let tmc_client = Data::new(self.tmc_client);
273
274 let sisu_client = Data::new(self.sisu_client);
275
276 let suotar_client = Data::new(SuotarClient::new(
278 &app_conf.suotar_configuration,
279 Arc::new(PgSuotarCallAudit::new(
280 db_pool.as_ref().clone(),
281 is_waiting_item,
282 )),
283 ));
284
285 let config = ServerConfig {
286 json_config,
287 db_pool,
288 oauth_client,
289 icu4x_blob,
290 ip_to_country_mapper,
291 file_store: self.file_store,
292 app_conf,
293 jwt_key,
294 cache,
295 payload_config,
296 tmc_client,
297 sisu_client,
298 suotar_client,
299 mock_suotar_store,
300 };
301 Ok(config)
302 }
303}
304
305#[derive(Clone)]
306pub struct ServerConfig {
307 pub payload_config: Data<PayloadConfig>,
308 pub json_config: Data<web::JsonConfig>,
309 pub db_pool: Data<PgPool>,
310 pub oauth_client: Data<OAuthClient>,
311 pub icu4x_blob: Data<Icu4xBlob>,
312 pub ip_to_country_mapper: Data<IpToCountryMapper>,
313 pub file_store: Arc<dyn FileStore + Send + Sync>,
314 pub app_conf: Data<ApplicationConfiguration>,
315 pub cache: Data<Cache>,
316 pub jwt_key: Data<JwtKey>,
317 pub tmc_client: Data<TmcClient>,
318 pub sisu_client: Data<SisuClient>,
319 pub suotar_client: Data<SuotarClient>,
320 pub mock_suotar_store: Option<Data<MockSuotarStore>>,
321}
322
323pub fn configure(config: &mut ServiceConfig, server_config: ServerConfig) {
325 let ServerConfig {
326 json_config,
327 db_pool,
328 oauth_client,
329 icu4x_blob,
330 ip_to_country_mapper,
331 file_store,
332 app_conf,
333 jwt_key,
334 cache,
335 payload_config,
336 tmc_client,
337 sisu_client,
338 suotar_client,
339 mock_suotar_store,
340 } = server_config;
341 let api_rate_limit_config = RateLimit::global_api_rate_limit_config(app_conf.test_mode);
342 let file_store = Data::from(file_store as Arc<dyn FileStore>);
345 if let Some(mock_suotar_store) = mock_suotar_store {
346 config.app_data(mock_suotar_store);
347 }
348 config
349 .app_data(payload_config)
350 .app_data(json_config)
351 .app_data(db_pool)
352 .app_data(oauth_client)
353 .app_data(icu4x_blob)
354 .app_data(ip_to_country_mapper)
355 .app_data(file_store)
356 .app_data(app_conf.clone())
357 .app_data(jwt_key)
358 .app_data(cache)
359 .app_data(tmc_client)
360 .app_data(sisu_client)
361 .app_data(suotar_client)
362 .service(
363 web::scope("/api/v0")
364 .wrap(RateLimit::new(api_rate_limit_config))
365 .wrap(RequestSpan)
366 .configure(|c| crate::controllers::configure_controllers(c, app_conf)),
367 );
368}