1use crate::cli::arguments::{parse_cli, Cli};
2use crate::webserver::content_security_policy::ContentSecurityPolicyTemplate;
3use crate::webserver::routing::RoutingConfig;
4use anyhow::Context;
5use config::Config;
6use openidconnect::IssuerUrl;
7use percent_encoding::AsciiSet;
8use serde::de::Error;
9use serde::{Deserialize, Deserializer, Serialize};
10use std::net::{SocketAddr, ToSocketAddrs};
11use std::path::{Path, PathBuf};
12
13#[cfg(not(feature = "lambda-web"))]
14const DEFAULT_DATABASE_FILE: &str = "sqlpage.db";
15
16impl AppConfig {
17 pub fn from_cli(cli: &Cli) -> anyhow::Result<Self> {
18 let mut config = if let Some(config_file) = &cli.config_file {
19 if !config_file.is_file() {
20 return Err(anyhow::anyhow!(
21 "Configuration file does not exist: {:?}",
22 config_file
23 ));
24 }
25 log::debug!("Loading configuration from file: {}", config_file.display());
26 load_from_file(config_file)?
27 } else if let Some(config_dir) = &cli.config_dir {
28 log::debug!(
29 "Loading configuration from directory: {}",
30 config_dir.display()
31 );
32 load_from_directory(config_dir)?
33 } else {
34 log::debug!("Loading configuration from environment");
35 load_from_env()?
36 };
37 if let Some(web_root) = &cli.web_root {
38 log::debug!(
39 "Setting web root to value from the command line: {}",
40 web_root.display()
41 );
42 config.web_root.clone_from(web_root);
43 }
44 if let Some(config_dir) = &cli.config_dir {
45 config.configuration_directory.clone_from(config_dir);
46 }
47
48 config.configuration_directory = std::fs::canonicalize(&config.configuration_directory)
49 .unwrap_or_else(|_| config.configuration_directory.clone());
50
51 if !config.configuration_directory.exists() {
52 log::info!(
53 "Configuration directory does not exist, creating it: {}",
54 config.configuration_directory.display()
55 );
56 std::fs::create_dir_all(&config.configuration_directory).with_context(|| {
57 format!(
58 "Failed to create configuration directory in {}",
59 config.configuration_directory.display()
60 )
61 })?;
62 }
63
64 if config.database_url.is_empty() {
65 log::debug!(
66 "Creating default database in {}",
67 config.configuration_directory.display()
68 );
69 config.database_url = create_default_database(&config.configuration_directory);
70 }
71
72 config
73 .validate()
74 .context("The provided configuration is invalid")?;
75
76 log::debug!("Loaded configuration: {config:#?}");
77 log::info!(
78 "Configuration loaded from {}",
79 config.configuration_directory.display()
80 );
81
82 Ok(config)
83 }
84
85 fn validate(&self) -> anyhow::Result<()> {
86 if !self.web_root.is_dir() {
87 return Err(anyhow::anyhow!(
88 "Web root is not a valid directory: {:?}",
89 self.web_root
90 ));
91 }
92 if !self.configuration_directory.is_dir() {
93 return Err(anyhow::anyhow!(
94 "Configuration directory is not a valid directory: {:?}",
95 self.configuration_directory
96 ));
97 }
98 if self.database_connection_acquire_timeout_seconds <= 0.0 {
99 return Err(anyhow::anyhow!(
100 "Database connection acquire timeout must be positive"
101 ));
102 }
103 if let Some(max_connections) = self.max_database_pool_connections {
104 if max_connections == 0 {
105 return Err(anyhow::anyhow!(
106 "Maximum database pool connections must be greater than 0"
107 ));
108 }
109 }
110 if let Some(idle_timeout) = self.database_connection_idle_timeout_seconds {
111 if idle_timeout < 0.0 {
112 return Err(anyhow::anyhow!(
113 "Database connection idle timeout must be non-negative"
114 ));
115 }
116 }
117 if let Some(max_lifetime) = self.database_connection_max_lifetime_seconds {
118 if max_lifetime < 0.0 {
119 return Err(anyhow::anyhow!(
120 "Database connection max lifetime must be non-negative"
121 ));
122 }
123 }
124 anyhow::ensure!(self.max_pending_rows > 0, "max_pending_rows cannot be null");
125 Ok(())
126 }
127}
128
129pub fn load_config() -> anyhow::Result<AppConfig> {
130 let cli = parse_cli()?;
131 AppConfig::from_cli(&cli)
132}
133
134pub fn load_from_env() -> anyhow::Result<AppConfig> {
135 let config_dir = configuration_directory();
136 load_from_directory(&config_dir)
137 .with_context(|| format!("Unable to load configuration from {}", config_dir.display()))
138}
139
140#[derive(Debug, Deserialize, PartialEq, Clone)]
141#[allow(clippy::struct_excessive_bools)]
142pub struct AppConfig {
143 #[serde(default = "default_database_url")]
144 pub database_url: String,
145 #[serde(default)]
147 pub database_password: Option<String>,
148 pub max_database_pool_connections: Option<u32>,
149 pub database_connection_idle_timeout_seconds: Option<f64>,
150 pub database_connection_max_lifetime_seconds: Option<f64>,
151
152 #[serde(default)]
153 pub sqlite_extensions: Vec<String>,
154
155 #[serde(default, deserialize_with = "deserialize_socket_addr")]
156 pub listen_on: Option<SocketAddr>,
157 pub port: Option<u16>,
158 pub unix_socket: Option<PathBuf>,
159
160 #[serde(default = "default_database_connection_retries")]
164 pub database_connection_retries: u32,
165
166 #[serde(default = "default_database_connection_acquire_timeout_seconds")]
169 pub database_connection_acquire_timeout_seconds: f64,
170
171 #[serde(default = "default_web_root")]
173 pub web_root: PathBuf,
174
175 #[serde(default = "configuration_directory")]
177 pub configuration_directory: PathBuf,
178
179 #[serde(default)]
183 pub allow_exec: bool,
184
185 #[serde(default = "default_max_file_size")]
187 pub max_uploaded_file_size: usize,
188
189 pub oidc_issuer_url: Option<IssuerUrl>,
192 #[serde(default = "default_oidc_client_id")]
195 pub oidc_client_id: String,
196 pub oidc_client_secret: Option<String>,
199 #[serde(default = "default_oidc_scopes")]
202 pub oidc_scopes: String,
203
204 #[serde(default = "default_oidc_protected_paths")]
213 pub oidc_protected_paths: Vec<String>,
214
215 #[serde(default)]
224 pub oidc_public_paths: Vec<String>,
225
226 #[serde(default)]
232 pub oidc_additional_trusted_audiences: Option<Vec<String>>,
233
234 pub https_domain: Option<String>,
242
243 pub host: Option<String>,
246
247 pub https_certificate_email: Option<String>,
250
251 #[serde(default = "default_https_certificate_cache_dir")]
253 pub https_certificate_cache_dir: PathBuf,
254
255 #[serde(default = "default_https_acme_directory_url")]
257 pub https_acme_directory_url: String,
258
259 #[serde(default)]
262 pub environment: DevOrProd,
263
264 #[serde(
269 deserialize_with = "deserialize_site_prefix",
270 default = "default_site_prefix"
271 )]
272 pub site_prefix: String,
273
274 #[serde(default = "default_max_pending_rows")]
277 pub max_pending_rows: usize,
278
279 #[serde(default = "default_compress_responses")]
281 pub compress_responses: bool,
282
283 #[serde(default)]
288 pub content_security_policy: ContentSecurityPolicyTemplate,
289
290 #[serde(default = "default_system_root_ca_certificates")]
294 pub system_root_ca_certificates: bool,
295
296 #[serde(default = "default_max_recursion_depth")]
298 pub max_recursion_depth: u8,
299
300 #[serde(default = "default_markdown_allow_dangerous_html")]
301 pub markdown_allow_dangerous_html: bool,
302
303 #[serde(default = "default_markdown_allow_dangerous_protocol")]
304 pub markdown_allow_dangerous_protocol: bool,
305}
306
307impl AppConfig {
308 #[must_use]
309 pub fn listen_on(&self) -> SocketAddr {
310 let mut addr = self.listen_on.unwrap_or_else(|| {
311 if self.https_domain.is_some() {
312 SocketAddr::from(([0, 0, 0, 0], 443))
313 } else {
314 SocketAddr::from(([0, 0, 0, 0], 8080))
315 }
316 });
317 if let Some(port) = self.port {
318 addr.set_port(port);
319 }
320 addr
321 }
322}
323
324impl RoutingConfig for AppConfig {
325 fn prefix(&self) -> &str {
326 &self.site_prefix
327 }
328}
329
330fn configuration_directory() -> PathBuf {
333 let env_var_name = "CONFIGURATION_DIRECTORY";
334 for prefix in &["", "SQLPAGE_"] {
336 let var = format!("{prefix}{env_var_name}");
337 for t in [str::to_lowercase, str::to_uppercase] {
338 let dir = t(&var);
339 if let Ok(dir) = std::env::var(dir) {
340 return PathBuf::from(dir);
341 }
342 }
343 }
344 PathBuf::from("./sqlpage")
345}
346
347fn cannonicalize_if_possible(path: &std::path::Path) -> PathBuf {
348 path.canonicalize().unwrap_or_else(|_| path.to_owned())
349}
350
351pub fn load_from_directory(directory: &Path) -> anyhow::Result<AppConfig> {
354 let cannonical = cannonicalize_if_possible(directory);
355 log::debug!("Loading configuration from {}", cannonical.display());
356 let config_file = directory.join("sqlpage");
357 let mut app_config = load_from_file(&config_file)?;
358 app_config.configuration_directory = directory.into();
359 Ok(app_config)
360}
361
362pub fn load_from_file(config_file: &Path) -> anyhow::Result<AppConfig> {
364 log::debug!("Loading configuration from file: {}", config_file.display());
365 let config = Config::builder()
366 .add_source(config::File::from(config_file).required(false))
367 .add_source(env_config())
368 .add_source(env_config().prefix("SQLPAGE"))
369 .build()
370 .with_context(|| {
371 format!(
372 "Unable to build configuration loader for {}",
373 config_file.display()
374 )
375 })?;
376 log::trace!("Configuration sources: {:#?}", config.cache);
377 let app_config = config
378 .try_deserialize::<AppConfig>()
379 .context("Failed to load the configuration")?;
380 Ok(app_config)
381}
382
383fn env_config() -> config::Environment {
384 config::Environment::default()
385 .try_parsing(true)
386 .list_separator(" ")
387 .with_list_parse_key("sqlite_extensions")
388}
389
390fn deserialize_socket_addr<'de, D: Deserializer<'de>>(
391 deserializer: D,
392) -> Result<Option<SocketAddr>, D::Error> {
393 let host_str: Option<String> = Deserialize::deserialize(deserializer)?;
394 host_str
395 .map(|h| {
396 parse_socket_addr(&h).map_err(|e| {
397 D::Error::custom(anyhow::anyhow!("Failed to parse socket address {h:?}: {e}"))
398 })
399 })
400 .transpose()
401}
402
403fn deserialize_site_prefix<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
404 let prefix: String = Deserialize::deserialize(deserializer)?;
405 Ok(normalize_site_prefix(prefix.as_str()))
406}
407
408fn normalize_site_prefix(prefix: &str) -> String {
412 const TO_ENCODE: AsciiSet = percent_encoding::CONTROLS
413 .add(b' ')
414 .add(b'"')
415 .add(b'#')
416 .add(b'<')
417 .add(b'>')
418 .add(b'?');
419
420 let prefix = prefix.trim_start_matches('/').trim_end_matches('/');
421 if prefix.is_empty() {
422 return default_site_prefix();
423 }
424 let encoded_prefix = percent_encoding::percent_encode(prefix.as_bytes(), &TO_ENCODE);
425
426 let invalid_chars = ["%09", "%0A", "%0D"];
427
428 std::iter::once("/")
429 .chain(encoded_prefix.filter(|c| !invalid_chars.contains(c)))
430 .chain(std::iter::once("/"))
431 .collect::<String>()
432}
433
434#[test]
435fn test_normalize_site_prefix() {
436 assert_eq!(normalize_site_prefix(""), "/");
437 assert_eq!(normalize_site_prefix("/"), "/");
438 assert_eq!(normalize_site_prefix("a"), "/a/");
439 assert_eq!(normalize_site_prefix("a/"), "/a/");
440 assert_eq!(normalize_site_prefix("/a"), "/a/");
441 assert_eq!(normalize_site_prefix("a/b"), "/a/b/");
442 assert_eq!(normalize_site_prefix("a/b/"), "/a/b/");
443 assert_eq!(normalize_site_prefix("a/b/c"), "/a/b/c/");
444 assert_eq!(normalize_site_prefix("a b"), "/a%20b/");
445 assert_eq!(normalize_site_prefix("a b/c"), "/a%20b/c/");
446}
447
448fn default_site_prefix() -> String {
449 '/'.to_string()
450}
451
452fn parse_socket_addr(host_str: &str) -> anyhow::Result<SocketAddr> {
453 host_str
454 .to_socket_addrs()?
455 .next()
456 .with_context(|| format!("Resolving host '{host_str}'"))
457}
458
459#[cfg(test)]
460fn default_database_url() -> String {
461 "sqlite://:memory:?cache=shared".to_owned()
462}
463#[cfg(not(test))]
464fn default_database_url() -> String {
465 String::new()
468}
469
470fn create_default_database(configuration_directory: &Path) -> String {
471 let prefix = "sqlite://".to_owned();
472
473 #[cfg(not(feature = "lambda-web"))]
474 {
475 let config_dir = cannonicalize_if_possible(configuration_directory);
476 let old_default_db_path = PathBuf::from(DEFAULT_DATABASE_FILE);
477 let default_db_path = config_dir.join(DEFAULT_DATABASE_FILE);
478 if let Ok(true) = old_default_db_path.try_exists() {
479 log::warn!("Your sqlite database in {} is publicly accessible through your web server. Please move it to {}.", old_default_db_path.display(), default_db_path.display());
480 return prefix + old_default_db_path.to_str().unwrap();
481 } else if let Ok(true) = default_db_path.try_exists() {
482 log::debug!(
483 "Using the default database file in {}",
484 default_db_path.display()
485 );
486 return prefix + &encode_uri(&default_db_path);
487 }
488 if let Ok(tmp_file) = std::fs::File::create(&default_db_path) {
490 log::info!(
491 "No DATABASE_URL provided, {} is writable, creating a new database file.",
492 default_db_path.display()
493 );
494 drop(tmp_file);
495 std::fs::remove_file(&default_db_path).expect("removing temp file");
496 return prefix + &encode_uri(&default_db_path) + "?mode=rwc";
497 }
498 }
499
500 log::warn!("No DATABASE_URL provided, and {} is not writeable. Using a temporary in-memory SQLite database. All the data created will be lost when this server shuts down.", configuration_directory.display());
501 prefix + ":memory:?cache=shared"
502}
503
504#[cfg(any(test, not(feature = "lambda-web")))]
505fn encode_uri(path: &Path) -> std::borrow::Cow<'_, str> {
506 const ASCII_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
507 .remove(b'-')
508 .remove(b'_')
509 .remove(b'.')
510 .remove(b':')
511 .remove(b' ')
512 .remove(b'/');
513 let path_bytes = path.as_os_str().as_encoded_bytes();
514 percent_encoding::percent_encode(path_bytes, ASCII_SET).into()
515}
516
517fn default_database_connection_retries() -> u32 {
518 6
519}
520
521fn default_database_connection_acquire_timeout_seconds() -> f64 {
522 10.
523}
524
525fn default_web_root() -> PathBuf {
526 std::env::current_dir().unwrap_or_else(|e| {
527 log::error!("Unable to get current directory: {e}");
528 PathBuf::from(&std::path::Component::CurDir)
529 })
530}
531
532fn default_max_file_size() -> usize {
533 5 * 1024 * 1024
534}
535
536fn default_https_certificate_cache_dir() -> PathBuf {
537 default_web_root().join("sqlpage").join("https")
538}
539
540fn default_https_acme_directory_url() -> String {
541 "https://acme-v02.api.letsencrypt.org/directory".to_string()
542}
543
544fn default_max_pending_rows() -> usize {
547 256
548}
549
550fn default_compress_responses() -> bool {
551 true
552}
553
554fn default_system_root_ca_certificates() -> bool {
555 std::env::var("SSL_CERT_FILE").is_ok_and(|x| !x.is_empty())
556 || std::env::var("SSL_CERT_DIR").is_ok_and(|x| !x.is_empty())
557}
558
559fn default_max_recursion_depth() -> u8 {
560 10
561}
562
563fn default_markdown_allow_dangerous_html() -> bool {
564 false
565}
566
567fn default_markdown_allow_dangerous_protocol() -> bool {
568 false
569}
570
571fn default_oidc_client_id() -> String {
572 "sqlpage".to_string()
573}
574
575fn default_oidc_scopes() -> String {
576 "openid email profile".to_string()
577}
578
579fn default_oidc_protected_paths() -> Vec<String> {
580 vec!["/".to_string()]
581}
582
583#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, Copy, Eq, Default)]
584#[serde(rename_all = "lowercase")]
585pub enum DevOrProd {
586 #[default]
587 Development,
588 Production,
589}
590impl DevOrProd {
591 pub(crate) fn is_prod(self) -> bool {
592 self == DevOrProd::Production
593 }
594}
595
596#[must_use]
597pub fn test_database_url() -> String {
598 std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string())
599}
600
601#[cfg(test)]
602pub mod tests {
603 pub use super::test_database_url;
604 use super::AppConfig;
605
606 #[must_use]
607 pub fn test_config() -> AppConfig {
608 serde_json::from_str::<AppConfig>(
609 &serde_json::json!({
610 "database_url": test_database_url(),
611 "listen_on": "localhost:8080"
612 })
613 .to_string(),
614 )
615 .unwrap()
616 }
617}
618
619#[cfg(test)]
620mod test {
621 use super::*;
622 use std::env;
623 use std::sync::Mutex;
624
625 static ENV_LOCK: Mutex<()> = Mutex::new(());
626
627 #[test]
628 fn test_default_site_prefix() {
629 assert_eq!(default_site_prefix(), "/".to_string());
630 }
631
632 #[test]
633 fn test_encode_uri() {
634 assert_eq!(
635 encode_uri(Path::new("/hello world/xxx.db")),
636 "/hello world/xxx.db"
637 );
638 assert_eq!(encode_uri(Path::new("é")), "%C3%A9");
639 assert_eq!(encode_uri(Path::new("/a?b/c")), "/a%3Fb/c");
640 }
641
642 #[test]
643 fn test_normalize_site_prefix() {
644 assert_eq!(normalize_site_prefix(""), "/");
645 assert_eq!(normalize_site_prefix("/"), "/");
646 assert_eq!(normalize_site_prefix("a"), "/a/");
647 assert_eq!(normalize_site_prefix("a/"), "/a/");
648 assert_eq!(normalize_site_prefix("/a"), "/a/");
649 assert_eq!(normalize_site_prefix("a/b"), "/a/b/");
650 assert_eq!(normalize_site_prefix("a/b/"), "/a/b/");
651 assert_eq!(normalize_site_prefix("a/b/c"), "/a/b/c/");
652 assert_eq!(normalize_site_prefix("a b"), "/a%20b/");
653 assert_eq!(normalize_site_prefix("a b/c"), "/a%20b/c/");
654 assert_eq!(normalize_site_prefix("*-+/:;,?%\"'{"), "/*-+/:;,%3F%%22'{/");
655 assert_eq!(
656 normalize_site_prefix(
657 &(0..=0x7F).map(char::from).collect::<String>()
658 ),
659 "/%00%01%02%03%04%05%06%07%08%0B%0C%0E%0F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%20!%22%23$%&'()*+,-./0123456789:;%3C=%3E%3F@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~%7F/"
660 );
661 }
662
663 #[test]
664 fn test_sqlpage_prefixed_env_variable_parsing() {
665 let _lock = ENV_LOCK
666 .lock()
667 .expect("Another test panicked while holding the lock");
668 env::set_var("SQLPAGE_CONFIGURATION_DIRECTORY", "/path/to/config");
669
670 let config = load_from_env().unwrap();
671
672 assert_eq!(
673 config.configuration_directory,
674 PathBuf::from("/path/to/config"),
675 "Configuration directory should match the SQLPAGE_CONFIGURATION_DIRECTORY env var"
676 );
677
678 env::remove_var("SQLPAGE_CONFIGURATION_DIRECTORY");
679 }
680
681 #[test]
682 fn test_config_priority() {
683 let _lock = ENV_LOCK
684 .lock()
685 .expect("Another test panicked while holding the lock");
686 env::set_var("SQLPAGE_WEB_ROOT", "/");
687
688 let cli = Cli {
689 web_root: Some(PathBuf::from(".")),
690 config_dir: None,
691 config_file: None,
692 command: None,
693 };
694
695 let config = AppConfig::from_cli(&cli).unwrap();
696
697 assert_eq!(
698 config.web_root,
699 PathBuf::from("."),
700 "CLI argument should take precedence over environment variable"
701 );
702
703 env::remove_var("SQLPAGE_WEB_ROOT");
704 }
705
706 #[test]
707 fn test_config_file_priority() {
708 let _lock = ENV_LOCK
709 .lock()
710 .expect("Another test panicked while holding the lock");
711 let temp_dir = std::env::temp_dir().join("sqlpage_test");
712 std::fs::create_dir_all(&temp_dir).unwrap();
713 let config_file_path = temp_dir.join("sqlpage.json");
714 let config_web_dir = temp_dir.join("config/web");
715 let env_web_dir = temp_dir.join("env/web");
716 let cli_web_dir = temp_dir.join("cli/web");
717 std::fs::create_dir_all(&config_web_dir).unwrap();
718 std::fs::create_dir_all(&env_web_dir).unwrap();
719 std::fs::create_dir_all(&cli_web_dir).unwrap();
720
721 let config_content = serde_json::json!({
722 "web_root": config_web_dir.to_str().unwrap()
723 })
724 .to_string();
725 std::fs::write(&config_file_path, config_content).unwrap();
726
727 env::set_var("SQLPAGE_WEB_ROOT", env_web_dir.to_str().unwrap());
728
729 let cli = Cli {
730 web_root: None,
731 config_dir: None,
732 config_file: Some(config_file_path.clone()),
733 command: None,
734 };
735
736 let config = AppConfig::from_cli(&cli).unwrap();
737
738 assert_eq!(
739 config.web_root, env_web_dir,
740 "Environment variable should override config file"
741 );
742 assert_eq!(
743 config.configuration_directory,
744 cannonicalize_if_possible(&PathBuf::from("./sqlpage")),
745 "Configuration directory should be default when not overridden"
746 );
747
748 let cli_with_web_root = Cli {
749 web_root: Some(cli_web_dir.clone()),
750 config_dir: None,
751 config_file: Some(config_file_path),
752 command: None,
753 };
754
755 let config = AppConfig::from_cli(&cli_with_web_root).unwrap();
756 assert_eq!(
757 config.web_root, cli_web_dir,
758 "CLI argument should take precedence over environment variable and config file"
759 );
760 assert_eq!(
761 config.configuration_directory,
762 cannonicalize_if_possible(&PathBuf::from("./sqlpage")),
763 "Configuration directory should remain unchanged"
764 );
765
766 env::remove_var("SQLPAGE_WEB_ROOT");
767 std::fs::remove_dir_all(&temp_dir).unwrap();
768 }
769
770 #[test]
771 fn test_default_values() {
772 let _lock = ENV_LOCK
773 .lock()
774 .expect("Another test panicked while holding the lock");
775 env::remove_var("SQLPAGE_CONFIGURATION_DIRECTORY");
776 env::remove_var("SQLPAGE_WEB_ROOT");
777
778 let cli = Cli {
779 web_root: None,
780 config_dir: None,
781 config_file: None,
782 command: None,
783 };
784
785 let config = AppConfig::from_cli(&cli).unwrap();
786
787 assert_eq!(
788 config.web_root,
789 default_web_root(),
790 "Web root should default to current directory when not specified"
791 );
792 assert_eq!(
793 config.configuration_directory,
794 cannonicalize_if_possible(&PathBuf::from("./sqlpage")),
795 "Configuration directory should default to ./sqlpage when not specified"
796 );
797 }
798}