1pub mod v2;
3pub mod validator;
4
5use std::env;
6use std::sync::Arc;
7
8use camino::Utf8PathBuf;
9use derive_more::Display;
10use figment::providers::{Env, Format, Serialized, Toml};
11use figment::Figment;
12use serde::{Deserialize, Serialize};
13use serde_with::{serde_as, NoneAsEmptyString};
14use thiserror::Error;
15use tokio::sync::RwLock;
16use torrust_index_located_error::LocatedError;
17
18use crate::web::api::server::DynError;
19
20pub type Settings = v2::Settings;
21
22pub type Api = v2::api::Api;
23
24pub type Registration = v2::registration::Registration;
25pub type Email = v2::registration::Email;
26
27pub type Auth = v2::auth::Auth;
28pub type SecretKey = v2::auth::ClaimTokenPepper;
29pub type PasswordConstraints = v2::auth::PasswordConstraints;
30
31pub type Database = v2::database::Database;
32
33pub type ImageCache = v2::image_cache::ImageCache;
34
35pub type Mail = v2::mail::Mail;
36pub type Smtp = v2::mail::Smtp;
37pub type Credentials = v2::mail::Credentials;
38
39pub type Network = v2::net::Network;
40
41pub type TrackerStatisticsImporter = v2::tracker_statistics_importer::TrackerStatisticsImporter;
42
43pub type Tracker = v2::tracker::Tracker;
44pub type ApiToken = v2::tracker::ApiToken;
45
46pub type Logging = v2::logging::Logging;
47pub type Threshold = v2::logging::Threshold;
48
49pub type Website = v2::website::Website;
50pub type Demo = v2::website::Demo;
51pub type Terms = v2::website::Terms;
52pub type TermsPage = v2::website::TermsPage;
53pub type TermsUpload = v2::website::TermsUpload;
54pub type Markdown = v2::website::Markdown;
55
56const VERSION_2: &str = "2.0.0";
58
59const CONFIG_OVERRIDE_PREFIX: &str = "TORRUST_INDEX_CONFIG_OVERRIDE_";
61
62const CONFIG_OVERRIDE_SEPARATOR: &str = "__";
64
65pub const ENV_VAR_CONFIG_TOML: &str = "TORRUST_INDEX_CONFIG_TOML";
68
69pub const ENV_VAR_CONFIG_TOML_PATH: &str = "TORRUST_INDEX_CONFIG_TOML_PATH";
71
72pub const LATEST_VERSION: &str = "2.0.0";
73
74#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)]
76#[display(fmt = "Metadata(app: {app}, purpose: {purpose}, schema_version: {schema_version})")]
77pub struct Metadata {
78 #[serde(default = "Metadata::default_app")]
80 app: App,
81
82 #[serde(default = "Metadata::default_purpose")]
84 purpose: Purpose,
85
86 #[serde(default = "Metadata::default_schema_version")]
88 #[serde(flatten)]
89 schema_version: Version,
90}
91
92impl Default for Metadata {
93 fn default() -> Self {
94 Self {
95 app: Self::default_app(),
96 purpose: Self::default_purpose(),
97 schema_version: Self::default_schema_version(),
98 }
99 }
100}
101
102impl Metadata {
103 fn default_app() -> App {
104 App::TorrustIndex
105 }
106
107 fn default_purpose() -> Purpose {
108 Purpose::Configuration
109 }
110
111 fn default_schema_version() -> Version {
112 Version::latest()
113 }
114}
115
116#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)]
117#[serde(rename_all = "kebab-case")]
118pub enum App {
119 TorrustIndex,
120}
121
122#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)]
123#[serde(rename_all = "lowercase")]
124pub enum Purpose {
125 Configuration,
126}
127
128#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)]
130#[serde(rename_all = "lowercase")]
131pub struct Version {
132 #[serde(default = "Version::default_semver")]
133 schema_version: String,
134}
135
136impl Default for Version {
137 fn default() -> Self {
138 Self {
139 schema_version: Self::default_semver(),
140 }
141 }
142}
143
144impl Version {
145 fn new(semver: &str) -> Self {
146 Self {
147 schema_version: semver.to_owned(),
148 }
149 }
150
151 fn latest() -> Self {
152 Self {
153 schema_version: LATEST_VERSION.to_string(),
154 }
155 }
156
157 fn default_semver() -> String {
158 LATEST_VERSION.to_string()
159 }
160}
161
162#[derive(Debug, Default, Clone)]
164pub struct Info {
165 config_toml: Option<String>,
166 config_toml_path: String,
167}
168
169impl Info {
170 #[allow(clippy::needless_pass_by_value)]
177 pub fn new(default_config_toml_path: String) -> Result<Self, Error> {
178 let env_var_config_toml = ENV_VAR_CONFIG_TOML.to_string();
179 let env_var_config_toml_path = ENV_VAR_CONFIG_TOML_PATH.to_string();
180
181 let config_toml = if let Ok(config_toml) = env::var(env_var_config_toml) {
182 println!("Loading extra configuration from environment variable {config_toml} ...");
183 Some(config_toml)
184 } else {
185 None
186 };
187
188 let config_toml_path = if let Ok(config_toml_path) = env::var(env_var_config_toml_path) {
189 println!("Loading extra configuration from file: `{config_toml_path}` ...");
190 config_toml_path
191 } else {
192 println!("Loading extra configuration from default configuration file: `{default_config_toml_path}` ...");
193 default_config_toml_path
194 };
195
196 Ok(Self {
197 config_toml,
198 config_toml_path,
199 })
200 }
201
202 #[must_use]
203 pub fn from_toml(config_toml: &str) -> Self {
204 Self {
205 config_toml: Some(config_toml.to_owned()),
206 config_toml_path: String::new(),
207 }
208 }
209}
210
211#[derive(Error, Debug)]
213pub enum Error {
214 #[error("Unable to load from Environmental Variable: {source}")]
218 UnableToLoadFromEnvironmentVariable {
219 source: LocatedError<'static, dyn std::error::Error + Send + Sync>,
220 },
221
222 #[error("Unable to load from Config File: {source}")]
223 UnableToLoadFromConfigFile {
224 source: LocatedError<'static, dyn std::error::Error + Send + Sync>,
225 },
226
227 #[error("Failed processing the configuration: {source}")]
229 ConfigError {
230 source: LocatedError<'static, dyn std::error::Error + Send + Sync>,
231 },
232
233 #[error("The error for errors that can never happen.")]
234 Infallible,
235
236 #[error("Unsupported configuration version: {version}")]
237 UnsupportedVersion { version: Version },
238
239 #[error("Missing mandatory configuration option. Option path: {path}")]
240 MissingMandatoryOption { path: String },
241}
242
243impl From<figment::Error> for Error {
244 #[track_caller]
245 fn from(err: figment::Error) -> Self {
246 Self::ConfigError {
247 source: (Arc::new(err) as DynError).into(),
248 }
249 }
250}
251
252pub const FREE_PORT: u16 = 0;
256
257#[serde_as]
258#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default)]
259pub struct Tsl {
260 #[serde_as(as = "NoneAsEmptyString")]
262 #[serde(default = "Tsl::default_ssl_cert_path")]
263 pub ssl_cert_path: Option<Utf8PathBuf>,
264 #[serde_as(as = "NoneAsEmptyString")]
266 #[serde(default = "Tsl::default_ssl_key_path")]
267 pub ssl_key_path: Option<Utf8PathBuf>,
268}
269
270impl Tsl {
271 #[allow(clippy::unnecessary_wraps)]
272 fn default_ssl_cert_path() -> Option<Utf8PathBuf> {
273 Some(Utf8PathBuf::new())
274 }
275
276 #[allow(clippy::unnecessary_wraps)]
277 fn default_ssl_key_path() -> Option<Utf8PathBuf> {
278 Some(Utf8PathBuf::new())
279 }
280}
281
282#[derive(Debug)]
284pub struct Configuration {
285 pub settings: RwLock<Settings>,
287}
288
289impl Default for Configuration {
290 fn default() -> Configuration {
291 Configuration {
292 settings: RwLock::new(Settings::default()),
293 }
294 }
295}
296
297impl Configuration {
298 pub fn load(info: &Info) -> Result<Configuration, Error> {
304 let settings = Self::load_settings(info)?;
305
306 Ok(Configuration {
307 settings: RwLock::new(settings),
308 })
309 }
310
311 pub fn load_settings(info: &Info) -> Result<Settings, Error> {
320 let figment = if let Some(config_toml) = &info.config_toml {
322 Figment::from(Toml::string(config_toml)).merge(Env::prefixed(CONFIG_OVERRIDE_PREFIX).split(CONFIG_OVERRIDE_SEPARATOR))
324 } else {
325 Figment::from(Toml::file(&info.config_toml_path))
326 .merge(Env::prefixed(CONFIG_OVERRIDE_PREFIX).split(CONFIG_OVERRIDE_SEPARATOR))
327 };
328
329 Self::check_mandatory_options(&figment)?;
331
332 let figment = figment.join(Serialized::defaults(Settings::default()));
334
335 let settings: Settings = figment.extract()?;
337
338 if settings.metadata.schema_version != Version::new(VERSION_2) {
339 return Err(Error::UnsupportedVersion {
340 version: settings.metadata.schema_version,
341 });
342 }
343
344 Ok(settings)
345 }
346
347 fn check_mandatory_options(figment: &Figment) -> Result<(), Error> {
356 let mandatory_options = [
357 "auth.user_claim_token_pepper",
358 "logging.threshold",
359 "metadata.schema_version",
360 "tracker.token",
361 ];
362
363 for mandatory_option in mandatory_options {
364 figment
365 .find_value(mandatory_option)
366 .map_err(|_err| Error::MissingMandatoryOption {
367 path: mandatory_option.to_owned(),
368 })?;
369 }
370
371 Ok(())
372 }
373
374 pub async fn get_all(&self) -> Settings {
375 let settings_lock = self.settings.read().await;
376
377 settings_lock.clone()
378 }
379
380 pub async fn get_site_name(&self) -> String {
381 let settings_lock = self.settings.read().await;
382
383 settings_lock.website.name.clone()
384 }
385
386 pub async fn get_api_base_url(&self) -> Option<String> {
387 let settings_lock = self.settings.read().await;
388 settings_lock.net.base_url.as_ref().map(std::string::ToString::to_string)
389 }
390}
391
392#[cfg(test)]
393mod tests {
394
395 use url::Url;
396
397 use crate::config::{ApiToken, Configuration, Info, SecretKey, Settings};
398
399 #[cfg(test)]
400 fn default_config_toml() -> String {
401 use std::fs;
402 use std::path::PathBuf;
403
404 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR environment variable not set");
406
407 let mut path = PathBuf::from(manifest_dir);
409 path.push("tests/fixtures/default_configuration.toml");
410
411 let config = fs::read_to_string(path)
412 .expect("Could not read default configuration TOML file: tests/fixtures/default_configuration.toml");
413
414 config.lines().map(str::trim_start).collect::<Vec<&str>>().join("\n");
415
416 config
417 }
418
419 #[cfg(test)]
424 fn default_settings() -> Settings {
425 use figment::providers::{Format, Toml};
426 use figment::Figment;
427
428 let figment = Figment::from(Toml::string(&default_config_toml()));
429 let settings: Settings = figment.extract().expect("Invalid configuration");
430
431 settings
432 }
433
434 #[tokio::test]
435 async fn configuration_should_have_a_default_constructor() {
436 let settings = Configuration::default().get_all().await;
437
438 assert_eq!(settings, default_settings());
439 }
440
441 #[tokio::test]
442 async fn configuration_should_return_the_site_name() {
443 let configuration = Configuration::default();
444 assert_eq!(configuration.get_site_name().await, "Torrust".to_string());
445 }
446
447 #[tokio::test]
448 async fn configuration_should_return_the_api_base_url() {
449 let configuration = Configuration::default();
450 assert_eq!(configuration.get_api_base_url().await, None);
451
452 let mut settings_lock = configuration.settings.write().await;
453 settings_lock.net.base_url = Some(Url::parse("http://localhost").unwrap());
454 drop(settings_lock);
455
456 assert_eq!(configuration.get_api_base_url().await, Some("http://localhost/".to_string()));
457 }
458
459 #[tokio::test]
460 async fn configuration_could_be_loaded_from_a_toml_string() {
461 figment::Jail::expect_with(|jail| {
462 jail.create_dir("templates")?;
463 jail.create_file("templates/verify.html", "EMAIL TEMPLATE")?;
464
465 let info = Info {
466 config_toml: Some(default_config_toml()),
467 config_toml_path: String::new(),
468 };
469
470 let settings = Configuration::load_settings(&info).expect("Failed to load configuration from info");
471
472 assert_eq!(settings, Settings::default());
473
474 Ok(())
475 });
476 }
477
478 #[test]
479 fn configuration_should_use_the_default_values_when_only_the_mandatory_options_are_provided_by_the_user_via_toml_file() {
480 figment::Jail::expect_with(|jail| {
481 jail.create_file(
482 "index.toml",
483 r#"
484 [metadata]
485 schema_version = "2.0.0"
486
487 [logging]
488 threshold = "info"
489
490 [tracker]
491 token = "MyAccessToken"
492
493 [auth]
494 user_claim_token_pepper = "MaxVerstappenWC2021"
495 "#,
496 )?;
497
498 let info = Info {
499 config_toml: None,
500 config_toml_path: "index.toml".to_string(),
501 };
502
503 let settings = Configuration::load_settings(&info).expect("Could not load configuration from file");
504
505 assert_eq!(settings, Settings::default());
506
507 Ok(())
508 });
509 }
510
511 #[test]
512 fn configuration_should_use_the_default_values_when_only_the_mandatory_options_are_provided_by_the_user_via_toml_content() {
513 figment::Jail::expect_with(|_jail| {
514 let config_toml = r#"
515 [metadata]
516 schema_version = "2.0.0"
517
518 [logging]
519 threshold = "info"
520
521 [tracker]
522 token = "MyAccessToken"
523
524 [auth]
525 user_claim_token_pepper = "MaxVerstappenWC2021"
526 "#
527 .to_string();
528
529 let info = Info {
530 config_toml: Some(config_toml),
531 config_toml_path: String::new(),
532 };
533
534 let settings = Configuration::load_settings(&info).expect("Could not load configuration from file");
535
536 assert_eq!(settings, Settings::default());
537
538 Ok(())
539 });
540 }
541
542 #[tokio::test]
543 async fn configuration_should_allow_to_override_the_tracker_api_token_provided_in_the_toml_file() {
544 figment::Jail::expect_with(|jail| {
545 jail.create_dir("templates")?;
546 jail.create_file("templates/verify.html", "EMAIL TEMPLATE")?;
547
548 jail.set_env("TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN", "OVERRIDDEN API TOKEN");
549
550 let info = Info {
551 config_toml: Some(default_config_toml()),
552 config_toml_path: String::new(),
553 };
554
555 let settings = Configuration::load_settings(&info).expect("Could not load configuration from file");
556
557 assert_eq!(settings.tracker.token, ApiToken::new("OVERRIDDEN API TOKEN"));
558
559 Ok(())
560 });
561 }
562
563 #[tokio::test]
564 async fn configuration_should_allow_to_override_the_authentication_user_claim_token_pepper_provided_in_the_toml_file() {
565 figment::Jail::expect_with(|jail| {
566 jail.create_dir("templates")?;
567 jail.create_file("templates/verify.html", "EMAIL TEMPLATE")?;
568
569 jail.set_env(
570 "TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER",
571 "OVERRIDDEN AUTH SECRET KEY",
572 );
573
574 let info = Info {
575 config_toml: Some(default_config_toml()),
576 config_toml_path: String::new(),
577 };
578
579 let settings = Configuration::load_settings(&info).expect("Could not load configuration from file");
580
581 assert_eq!(
582 settings.auth.user_claim_token_pepper,
583 SecretKey::new("OVERRIDDEN AUTH SECRET KEY")
584 );
585
586 Ok(())
587 });
588 }
589
590 mod semantic_validation {
591 use url::Url;
592
593 use crate::config::validator::Validator;
594 use crate::config::Configuration;
595
596 #[tokio::test]
597 async fn udp_trackers_in_private_mode_are_not_supported() {
598 let configuration = Configuration::default();
599
600 let mut settings_lock = configuration.settings.write().await;
601 settings_lock.tracker.private = true;
602 settings_lock.tracker.url = Url::parse("udp://localhost:6969").unwrap();
603
604 assert!(settings_lock.validate().is_err());
605 }
606 }
607}