torrust_index/config/
mod.rs

1//! Configuration for the application.
2pub 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
56/// Configuration version
57const VERSION_2: &str = "2.0.0";
58
59/// Prefix for env vars that overwrite configuration options.
60const CONFIG_OVERRIDE_PREFIX: &str = "TORRUST_INDEX_CONFIG_OVERRIDE_";
61
62/// Path separator in env var names for nested values in configuration.
63const CONFIG_OVERRIDE_SEPARATOR: &str = "__";
64
65/// The whole `index.toml` file content. It has priority over the config file.
66/// Even if the file is not on the default path.
67pub const ENV_VAR_CONFIG_TOML: &str = "TORRUST_INDEX_CONFIG_TOML";
68
69/// The `index.toml` file location.
70pub const ENV_VAR_CONFIG_TOML_PATH: &str = "TORRUST_INDEX_CONFIG_TOML_PATH";
71
72pub const LATEST_VERSION: &str = "2.0.0";
73
74/// Info about the configuration specification.
75#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)]
76#[display(fmt = "Metadata(app: {app}, purpose: {purpose}, schema_version: {schema_version})")]
77pub struct Metadata {
78    /// The application this configuration is valid for.
79    #[serde(default = "Metadata::default_app")]
80    app: App,
81
82    /// The purpose of this parsed file.
83    #[serde(default = "Metadata::default_purpose")]
84    purpose: Purpose,
85
86    /// The schema version for the configuration.
87    #[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/// The configuration version.
129#[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/// Information required for loading config
163#[derive(Debug, Default, Clone)]
164pub struct Info {
165    config_toml: Option<String>,
166    config_toml_path: String,
167}
168
169impl Info {
170    /// Build configuration Info.
171    ///
172    /// # Errors
173    ///
174    /// Will return `Err` if unable to obtain a configuration.
175    ///
176    #[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/// Errors that can occur when loading the configuration.
212#[derive(Error, Debug)]
213pub enum Error {
214    /// Unable to load the configuration from the environment variable.
215    /// This error only occurs if there is no configuration file and the
216    /// `TORRUST_INDEX_CONFIG_TOML` environment variable is not set.
217    #[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    /// Unable to load the configuration from the configuration file.
228    #[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
252/// Port number representing that the OS will choose one randomly from the available ports.
253///
254/// It's the port number `0`
255pub const FREE_PORT: u16 = 0;
256
257#[serde_as]
258#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default)]
259pub struct Tsl {
260    /// Path to the SSL certificate file.
261    #[serde_as(as = "NoneAsEmptyString")]
262    #[serde(default = "Tsl::default_ssl_cert_path")]
263    pub ssl_cert_path: Option<Utf8PathBuf>,
264    /// Path to the SSL key file.
265    #[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/// The configuration service.
283#[derive(Debug)]
284pub struct Configuration {
285    /// The state of the configuration.
286    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    /// Loads the configuration from the `Info` struct.
299    ///
300    /// # Errors
301    ///
302    /// Will return `Err` if the environment variable does not exist or has a bad configuration.
303    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    /// Loads the settings from the `Info` struct. The whole
312    /// configuration in toml format is included in the `info.index_toml` string.
313    ///
314    /// Configuration provided via env var has priority over config file path.
315    ///
316    /// # Errors
317    ///
318    /// Will return `Err` if the environment variable does not exist or has a bad configuration.
319    pub fn load_settings(info: &Info) -> Result<Settings, Error> {
320        // Load configuration provided by the user, prioritizing env vars
321        let figment = if let Some(config_toml) = &info.config_toml {
322            // Config in env var has priority over config file path
323            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        // Make sure user has provided the mandatory options.
330        Self::check_mandatory_options(&figment)?;
331
332        // Fill missing options with default values.
333        let figment = figment.join(Serialized::defaults(Settings::default()));
334
335        // Build final configuration.
336        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    /// Some configuration options are mandatory. The tracker will panic if
348    /// the user doesn't provide an explicit value for them from one of the
349    /// configuration sources: TOML or ENV VARS.
350    ///
351    /// # Errors
352    ///
353    /// Will return an error if a mandatory configuration option is only
354    /// obtained by default value (code), meaning the user hasn't overridden it.
355    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        // Get the path to the current Cargo.toml directory
405        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR environment variable not set");
406
407        // Construct the path to the default configuration file relative to the Cargo.toml directory
408        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    /// Build settings from default configuration fixture in TOML.
420    ///
421    /// We just want to load that file without overriding with env var or other
422    /// configuration loading behavior.
423    #[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}