headless_lms_server/config/
mod.rs1pub 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, 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 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 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 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
144pub 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
153pub 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}
173
174impl ServerConfigBuilder {
175 pub async fn from_runtime_config(runtime_config: &ServerRuntimeConfig) -> anyhow::Result<Self> {
176 Ok(Self {
177 database_url: runtime_config.database_url.clone(),
178 oauth_application_id: runtime_config.oauth_application_id.clone(),
179 oauth_secret: runtime_config.oauth_secret.clone(),
180 auth_url: "https://tmc.mooc.fi/oauth/authorize"
181 .parse()
182 .context("Failed to parse auth_url")?,
183 token_url: "https://tmc.mooc.fi/oauth/token"
184 .parse()
185 .context("Failed to parse token url")?,
186 icu4x_postcard_path: runtime_config.icu4x_postcard_path.clone(),
187 file_store: crate::setup_file_store(
188 &runtime_config.file_store,
189 &runtime_config.app_conf.base_url,
190 )
191 .await,
192 app_conf: runtime_config.app_conf.clone(),
193 redis_url: runtime_config.redis_url.clone(),
194 jwt_password: runtime_config.jwt_password.clone(),
195 tmc_client: TmcClient::new(
196 runtime_config.app_conf.tmc_admin_access_token.clone(),
197 runtime_config.ratelimit_protection_safe_api_key.clone(),
198 )?,
199 })
200 }
201
202 pub async fn build(self) -> anyhow::Result<ServerConfig> {
203 let json_config = web::JsonConfig::default().limit(2_097_152).error_handler(
204 |err, _req| -> actix_web::Error {
205 info!("Bad request: {}", &err);
206 let body = format!("{{\"title\": \"Bad Request\", \"message\": \"{}\"}}", &err);
207 let response = HttpResponse::with_body(StatusCode::BAD_REQUEST, body.boxed());
209 InternalError::from_response(err, response).into()
210 },
211 );
212 let json_config = Data::new(json_config);
213
214 let payload_config = PayloadConfig::default().limit(2_097_152);
215 let payload_config = Data::new(payload_config);
216
217 let db_pool = PgPoolOptions::new()
218 .max_connections(15)
219 .min_connections(5)
220 .connect(self.database_url.expose_secret())
221 .await?;
222 crate::domain::internal_error_reporting::init_error_reporting(db_pool.clone());
223 let db_pool = Data::new(db_pool);
224
225 let oauth_client: OAuthClient = BasicClient::new(ClientId::new(self.oauth_application_id))
226 .set_client_secret(ClientSecret::new(
227 self.oauth_secret.expose_secret().to_string(),
228 ))
229 .set_auth_uri(AuthUrl::from_url(self.auth_url.clone()))
230 .set_token_uri(TokenUrl::from_url(self.token_url.clone()));
231 let oauth_client = Data::new(oauth_client);
232
233 let icu4x_blob = Icu4xBlob::new(&self.icu4x_postcard_path)?;
234 let icu4x_blob = Data::new(icu4x_blob);
235
236 let app_conf = Data::new(self.app_conf);
237
238 let ip_to_country_mapper = IpToCountryMapper::new(&app_conf)?;
239 let ip_to_country_mapper = Data::new(ip_to_country_mapper);
240
241 let cache = Cache::new(self.redis_url.expose_secret())?;
242 let cache = Data::new(cache);
243
244 let jwt_key = JwtKey::new(&self.jwt_password)?;
245 let jwt_key = Data::new(jwt_key);
246
247 let tmc_client = Data::new(self.tmc_client);
248
249 let config = ServerConfig {
250 json_config,
251 db_pool,
252 oauth_client,
253 icu4x_blob,
254 ip_to_country_mapper,
255 file_store: self.file_store,
256 app_conf,
257 jwt_key,
258 cache,
259 payload_config,
260 tmc_client,
261 };
262 Ok(config)
263 }
264}
265
266#[derive(Clone)]
267pub struct ServerConfig {
268 pub payload_config: Data<PayloadConfig>,
269 pub json_config: Data<web::JsonConfig>,
270 pub db_pool: Data<PgPool>,
271 pub oauth_client: Data<OAuthClient>,
272 pub icu4x_blob: Data<Icu4xBlob>,
273 pub ip_to_country_mapper: Data<IpToCountryMapper>,
274 pub file_store: Arc<dyn FileStore + Send + Sync>,
275 pub app_conf: Data<ApplicationConfiguration>,
276 pub cache: Data<Cache>,
277 pub jwt_key: Data<JwtKey>,
278 pub tmc_client: Data<TmcClient>,
279}
280
281pub fn configure(config: &mut ServiceConfig, server_config: ServerConfig) {
283 let ServerConfig {
284 json_config,
285 db_pool,
286 oauth_client,
287 icu4x_blob,
288 ip_to_country_mapper,
289 file_store,
290 app_conf,
291 jwt_key,
292 cache,
293 payload_config,
294 tmc_client,
295 } = server_config;
296 let api_rate_limit_config = RateLimit::global_api_rate_limit_config(app_conf.test_mode);
297 let file_store = Data::from(file_store as Arc<dyn FileStore>);
300 config
301 .app_data(payload_config)
302 .app_data(json_config)
303 .app_data(db_pool)
304 .app_data(oauth_client)
305 .app_data(icu4x_blob)
306 .app_data(ip_to_country_mapper)
307 .app_data(file_store)
308 .app_data(app_conf.clone())
309 .app_data(jwt_key)
310 .app_data(cache)
311 .app_data(tmc_client)
312 .service(
313 web::scope("/api/v0")
314 .wrap(RateLimit::new(api_rate_limit_config))
315 .wrap(RequestSpan)
316 .configure(|c| crate::controllers::configure_controllers(c, app_conf)),
317 );
318}