use crate::search::TokenizerKind as SearchTokenizer; use crate::signups::Strategy as SignupStrategy; use crate::smtp::{SMTP_PORT, SUBMISSIONS_PORT, SUBMISSION_PORT}; use rocket::config::Limits; use rocket::Config as RocketConfig; use std::collections::HashSet; use std::env::{self, var}; #[cfg(feature = "s3")] use s3::{Bucket, Region, creds::Credentials}; #[cfg(not(test))] const DB_NAME: &str = "plume"; #[cfg(test)] const DB_NAME: &str = "plume_tests"; pub struct Config { pub base_url: String, pub database_url: String, pub db_name: &'static str, pub db_max_size: Option, pub db_min_idle: Option, pub signup: SignupStrategy, pub search_index: String, pub search_tokenizers: SearchTokenizerConfig, pub rocket: Result, pub logo: LogoConfig, pub default_theme: String, pub media_directory: String, pub mail: Option, pub ldap: Option, pub proxy: Option, pub s3: Option, } impl Config { pub fn proxy(&self) -> Option<&reqwest::Proxy> { self.proxy.as_ref().map(|p| &p.proxy) } } fn string_to_bool(val: &str, name: &str) -> bool { match val { "1" | "true" | "TRUE" => true, "0" | "false" | "FALSE" => false, _ => panic!("Invalid configuration: {} is not boolean", name), } } #[derive(Debug, Clone)] pub enum InvalidRocketConfig { Env, Address, SecretKey, } fn get_rocket_config() -> Result { let mut c = RocketConfig::active().map_err(|_| InvalidRocketConfig::Env)?; let address = var("ROCKET_ADDRESS").unwrap_or_else(|_| "localhost".to_owned()); let port = var("ROCKET_PORT") .ok() .map(|s| s.parse::().unwrap()) .unwrap_or(7878); let secret_key = var("ROCKET_SECRET_KEY").map_err(|_| InvalidRocketConfig::SecretKey)?; let form_size = var("FORM_SIZE") .unwrap_or_else(|_| "128".to_owned()) .parse::() .unwrap(); let activity_size = var("ACTIVITY_SIZE") .unwrap_or_else(|_| "1024".to_owned()) .parse::() .unwrap(); c.set_address(address) .map_err(|_| InvalidRocketConfig::Address)?; c.set_port(port); c.set_secret_key(secret_key) .map_err(|_| InvalidRocketConfig::SecretKey)?; c.set_limits( Limits::new() .limit("forms", form_size * 1024) .limit("json", activity_size * 1024), ); Ok(c) } pub struct LogoConfig { pub main: String, pub favicon: String, pub other: Vec, //url, size, type } #[derive(Serialize)] pub struct Icon { pub src: String, #[serde(skip_serializing_if = "Option::is_none")] pub sizes: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "type")] pub image_type: Option, } impl Icon { pub fn with_prefix(&self, prefix: &str) -> Icon { Icon { src: format!("{}/{}", prefix, self.src), sizes: self.sizes.clone(), image_type: self.image_type.clone(), } } } impl Default for LogoConfig { fn default() -> Self { let to_icon = |(src, sizes, image_type): &(&str, Option<&str>, Option<&str>)| Icon { src: str::to_owned(src), sizes: sizes.map(str::to_owned), image_type: image_type.map(str::to_owned), }; let icons = [ ( "icons/trwnh/feather/plumeFeather48.png", Some("48x48"), Some("image/png"), ), ( "icons/trwnh/feather/plumeFeather72.png", Some("72x72"), Some("image/png"), ), ( "icons/trwnh/feather/plumeFeather96.png", Some("96x96"), Some("image/png"), ), ( "icons/trwnh/feather/plumeFeather144.png", Some("144x144"), Some("image/png"), ), ( "icons/trwnh/feather/plumeFeather160.png", Some("160x160"), Some("image/png"), ), ( "icons/trwnh/feather/plumeFeather192.png", Some("192x192"), Some("image/png"), ), ( "icons/trwnh/feather/plumeFeather256.png", Some("256x256"), Some("image/png"), ), ( "icons/trwnh/feather/plumeFeather512.png", Some("512x512"), Some("image/png"), ), ("icons/trwnh/feather/plumeFeather.svg", None, None), ] .iter() .map(to_icon) .collect(); let custom_main = var("PLUME_LOGO").ok(); let custom_favicon = var("PLUME_LOGO_FAVICON") .ok() .or_else(|| custom_main.clone()); let other = if let Some(main) = custom_main.clone() { let ext = |path: &str| match path.rsplit_once('.').map(|x| x.1) { Some("png") => Some("image/png".to_owned()), Some("jpg") | Some("jpeg") => Some("image/jpeg".to_owned()), Some("svg") => Some("image/svg+xml".to_owned()), Some("webp") => Some("image/webp".to_owned()), _ => None, }; let mut custom_icons = env::vars() .filter_map(|(var, val)| { var.strip_prefix("PLUME_LOGO_") .map(|size| (size.to_owned(), val)) }) .filter_map(|(var, val)| var.parse::().ok().map(|var| (var, val))) .map(|(dim, src)| Icon { image_type: ext(&src), src, sizes: Some(format!("{}x{}", dim, dim)), }) .collect::>(); custom_icons.push(Icon { image_type: ext(&main), src: main, sizes: None, }); custom_icons } else { icons }; LogoConfig { main: custom_main .unwrap_or_else(|| "icons/trwnh/feather/plumeFeather256.png".to_owned()), favicon: custom_favicon.unwrap_or_else(|| { "icons/trwnh/feather-filled/plumeFeatherFilled64.png".to_owned() }), other, } } } pub struct SearchTokenizerConfig { pub tag_tokenizer: SearchTokenizer, pub content_tokenizer: SearchTokenizer, pub property_tokenizer: SearchTokenizer, } impl SearchTokenizerConfig { pub fn init() -> Self { use SearchTokenizer::*; match var("SEARCH_LANG").ok().as_deref() { Some("ja") => { #[cfg(not(feature = "search-lindera"))] panic!("You need build Plume with search-lindera feature, or execute it with SEARCH_TAG_TOKENIZER=ngram and SEARCH_CONTENT_TOKENIZER=ngram to enable Japanese search feature"); #[cfg(feature = "search-lindera")] Self { tag_tokenizer: Self::determine_tokenizer("SEARCH_TAG_TOKENIZER", Lindera), content_tokenizer: Self::determine_tokenizer( "SEARCH_CONTENT_TOKENIZER", Lindera, ), property_tokenizer: Ngram, } } _ => Self { tag_tokenizer: Self::determine_tokenizer("SEARCH_TAG_TOKENIZER", Whitespace), content_tokenizer: Self::determine_tokenizer("SEARCH_CONTENT_TOKENIZER", Simple), property_tokenizer: Ngram, }, } } fn determine_tokenizer(var_name: &str, default: SearchTokenizer) -> SearchTokenizer { use SearchTokenizer::*; match var(var_name).ok().as_deref() { Some("simple") => Simple, Some("ngram") => Ngram, Some("whitespace") => Whitespace, Some("lindera") => { #[cfg(not(feature = "search-lindera"))] panic!("You need build Plume with search-lindera feature to use Lindera tokenizer"); #[cfg(feature = "search-lindera")] Lindera } _ => default, } } } pub struct MailConfig { pub server: String, pub port: u16, pub helo_name: String, pub username: String, pub password: String, } fn get_mail_config() -> Option { Some(MailConfig { server: env::var("MAIL_SERVER").ok()?, port: env::var("MAIL_PORT").map_or(SUBMISSIONS_PORT, |port| match port.as_str() { "smtp" => SMTP_PORT, "submissions" => SUBMISSIONS_PORT, "submission" => SUBMISSION_PORT, number => number .parse() .expect(r#"MAIL_PORT must be "smtp", "submissions", "submission" or an integer."#), }), helo_name: env::var("MAIL_HELO_NAME").unwrap_or_else(|_| "localhost".to_owned()), username: env::var("MAIL_USER").ok()?, password: env::var("MAIL_PASSWORD").ok()?, }) } pub struct LdapConfig { pub addr: String, pub base_dn: String, pub tls: bool, pub user_name_attr: String, pub mail_attr: String, } fn get_ldap_config() -> Option { let addr = var("LDAP_ADDR").ok(); let base_dn = var("LDAP_BASE_DN").ok(); match (addr, base_dn) { (Some(addr), Some(base_dn)) => { let tls = var("LDAP_TLS").unwrap_or_else(|_| "false".to_owned()); let tls = string_to_bool(&tls, "LDAP_TLS"); let user_name_attr = var("LDAP_USER_NAME_ATTR").unwrap_or_else(|_| "cn".to_owned()); let mail_attr = var("LDAP_USER_MAIL_ATTR").unwrap_or_else(|_| "mail".to_owned()); Some(LdapConfig { addr, base_dn, tls, user_name_attr, mail_attr, }) } (None, None) => None, (_, _) => { panic!("Invalid LDAP configuration : both LDAP_ADDR and LDAP_BASE_DN must be set") } } } pub struct ProxyConfig { pub url: reqwest::Url, pub only_domains: Option>, pub proxy: reqwest::Proxy, } fn get_proxy_config() -> Option { let url: reqwest::Url = var("PROXY_URL").ok()?.parse().expect("Invalid PROXY_URL"); let proxy_url = url.clone(); let only_domains: Option> = var("PROXY_DOMAINS") .ok() .map(|ods| ods.split(',').map(str::to_owned).collect()); let proxy = if let Some(ref only_domains) = only_domains { let only_domains = only_domains.clone(); reqwest::Proxy::custom(move |url| { if let Some(domain) = url.domain() { if only_domains.contains(domain) || only_domains .iter() .any(|target| domain.ends_with(&format!(".{}", target))) { Some(proxy_url.clone()) } else { None } } else { None } }) } else { reqwest::Proxy::all(proxy_url).expect("Invalid PROXY_URL") }; Some(ProxyConfig { url, only_domains, proxy, }) } pub struct S3Config { pub bucket: String, pub access_key_id: String, pub access_key_secret: String, // region? If not set, default to us-east-1 pub region: String, // hostname for s3. If not set, default to $region.amazonaws.com pub hostname: String, // may be useful when using self hosted s3. Won't work with recent AWS buckets pub path_style: bool, // http or https pub protocol: String, // download directly from s3 to user, wihout going through Plume. Require public read on bucket pub direct_download: bool, // use this hostname for downloads, can be used with caching proxy in front of s3 (expected to // be reachable through https) pub alias: Option, } impl S3Config { #[cfg(feature = "s3")] pub fn get_bucket(&self) -> Bucket { let region = Region::Custom { region: self.region.clone(), endpoint: format!("{}://{}", self.protocol, self.hostname), }; let credentials = Credentials { access_key: Some(self.access_key_id.clone()), secret_key: Some(self.access_key_secret.clone()), security_token: None, session_token: None, expiration: None, }; let bucket = Bucket::new(&self.bucket, region, credentials).unwrap(); if self.path_style { bucket.with_path_style() } else { bucket } } } fn get_s3_config() -> Option { let bucket = var("S3_BUCKET").ok(); let access_key_id = var("AWS_ACCESS_KEY_ID").ok(); let access_key_secret = var("AWS_SECRET_ACCESS_KEY").ok(); if bucket.is_none() && access_key_id.is_none() && access_key_secret.is_none() { return None; } #[cfg(not(feature = "s3"))] panic!("S3 support is not enabled in this build"); #[cfg(feature = "s3")] { if bucket.is_none() || access_key_id.is_none() || access_key_secret.is_none() { panic!("Invalid S3 configuration: some required values are set, but not others"); } let bucket = bucket.unwrap(); let access_key_id = access_key_id.unwrap(); let access_key_secret = access_key_secret.unwrap(); let region = var("S3_REGION").unwrap_or_else(|_| "us-east-1".to_owned()); let hostname = var("S3_HOSTNAME").unwrap_or_else(|_| format!("{}.amazonaws.com", region)); let protocol = var("S3_PROTOCOL").unwrap_or_else(|_| "https".to_owned()); if protocol != "http" && protocol != "https" { panic!("Invalid S3 configuration: invalid protocol {}", protocol); } let path_style = var("S3_PATH_STYLE").unwrap_or_else(|_| "false".to_owned()); let path_style = string_to_bool(&path_style, "S3_PATH_STYLE"); let direct_download = var("S3_DIRECT_DOWNLOAD").unwrap_or_else(|_| "false".to_owned()); let direct_download = string_to_bool(&direct_download, "S3_DIRECT_DOWNLOAD"); let alias = var("S3_ALIAS_HOST").ok(); if direct_download && protocol == "http" && alias.is_none() { panic!("S3 direct download is disabled because bucket is accessed through plain HTTP. Use HTTPS or set an alias hostname (S3_ALIAS_HOST)."); } Some(S3Config { bucket, access_key_id, access_key_secret, region, hostname, protocol, path_style, direct_download, alias, }) } } lazy_static! { pub static ref CONFIG: Config = Config { base_url: var("BASE_URL").unwrap_or_else(|_| format!( "127.0.0.1:{}", var("ROCKET_PORT").unwrap_or_else(|_| "7878".to_owned()) )), db_name: DB_NAME, db_max_size: var("DB_MAX_SIZE").map_or(None, |s| Some( s.parse::() .expect("Couldn't parse DB_MAX_SIZE into u32") )), db_min_idle: var("DB_MIN_IDLE").map_or(None, |s| Some( s.parse::() .expect("Couldn't parse DB_MIN_IDLE into u32") )), signup: var("SIGNUP").map_or(SignupStrategy::default(), |s| s.parse().unwrap()), #[cfg(feature = "postgres")] database_url: var("DATABASE_URL") .unwrap_or_else(|_| format!("postgres://plume:plume@localhost/{}", DB_NAME)), #[cfg(feature = "sqlite")] database_url: var("DATABASE_URL").unwrap_or_else(|_| format!("{}.sqlite", DB_NAME)), search_index: var("SEARCH_INDEX").unwrap_or_else(|_| "search_index".to_owned()), search_tokenizers: SearchTokenizerConfig::init(), rocket: get_rocket_config(), logo: LogoConfig::default(), default_theme: var("DEFAULT_THEME").unwrap_or_else(|_| "default-light".to_owned()), media_directory: var("MEDIA_UPLOAD_DIRECTORY") .unwrap_or_else(|_| "static/media".to_owned()), mail: get_mail_config(), ldap: get_ldap_config(), proxy: get_proxy_config(), s3: get_s3_config(), }; }