diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fbbc26c..956466dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - hosted behind an ssl-terminating reverse proxy - New docker image variant: `lovasoa/sqlpage:latest-duckdb`, `lovasoa/sqlpage:main-duckdb` with preconfigured duckdb odbc drivers. - New config option: `cache_stale_duration_ms` to control the duration for which cached sql files are considered fresh. +- Add support for standard PostgreSQL environment variables (`PGHOST`, `PGUSER`, `PGPASSWORD`, etc.) to configure the database connection. ## 0.41.0 (2025-12-28) diff --git a/Cargo.lock b/Cargo.lock index fb4c5879..2f8aa6b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -390,7 +390,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -401,7 +401,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1455,7 +1455,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1638,7 +1638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1974,7 +1974,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3021,7 +3021,7 @@ dependencies = [ "atoi", "log", "odbc-sys 0.27.4", - "thiserror 2.0.17", + "thiserror 2.0.18", "widestring", "winit", ] @@ -3095,9 +3095,9 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "option-ext" @@ -3562,7 +3562,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3722,7 +3722,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3735,7 +3735,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3774,7 +3774,7 @@ dependencies = [ "rcgen", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "webpki-roots 1.0.5", "x509-parser", ] @@ -4304,7 +4304,7 @@ dependencies = [ "smallvec", "sqlx-rt-oldapi", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio-stream", "tokio-util", "url", @@ -4415,7 +4415,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4429,11 +4429,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -4449,9 +4449,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -4985,7 +4985,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5065,15 +5065,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -5500,9 +5491,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.14" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" [[package]] name = "zstd" diff --git a/configuration.md b/configuration.md index 77e6b322..397e4c98 100644 --- a/configuration.md +++ b/configuration.md @@ -112,6 +112,8 @@ If the `database_password` configuration parameter is set, it will override any It does not need to be percent-encoded. This allows you to keep the password separate from the connection string, which can be useful for security purposes, especially when storing configurations in version control systems. +Standard [PostgreSQL environment variables](https://www.postgresql.org/docs/current/libpq-envars.html) are also supported. + ### OpenID Connect (OIDC) Authentication OpenID Connect (OIDC) is a secure way to let users log in to your SQLPage application using their existing accounts from popular services. When OIDC is configured, you can control which parts of your application require authentication using the `oidc_protected_paths` option. By default, all pages are protected. You can specify a list of URL prefixes to protect specific areas, allowing you to have a mix of public and private pages. diff --git a/src/app_config.rs b/src/app_config.rs index 5ebcab63..f7856818 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -62,11 +62,18 @@ impl AppConfig { } if config.database_url.is_empty() { - log::debug!( - "Creating default database in {}", - config.configuration_directory.display() - ); - config.database_url = create_default_database(&config.configuration_directory); + // If the database URL is empty, we check if the user has set the PGDATABASE environment variable. + // If they have, we assume they want to use the postgres environment variables to connect to the database. + // We set the database URL to a special value that will be recognized by the database connection code. + if std::env::var("PGDATABASE").is_ok() || std::env::var("PGHOST").is_ok() { + config.database_url = "postgres://:env:".to_string(); + } else { + log::debug!( + "Creating default database in {}", + config.configuration_directory.display() + ); + config.database_url = create_default_database(&config.configuration_directory); + } } config @@ -785,6 +792,46 @@ mod test { std::fs::remove_dir_all(&temp_dir).unwrap(); } + #[test] + fn test_postgres_env_vars() { + let _lock = ENV_LOCK + .lock() + .expect("Another test panicked while holding the lock"); + + // Test 1: No config, only env vars + env::set_var("PGHOST", "localhost"); + env::set_var("PGUSER", "myuser"); + env::set_var("PGDATABASE", "mydb"); + env::set_var("PGPASSWORD", "mypass"); + + // We need to clear other env vars that might interfere + env::remove_var("SQLPAGE_DATABASE_URL"); + env::remove_var("DATABASE_URL"); + + let cli = Cli { + web_root: None, + config_dir: None, + config_file: None, + command: None, + }; + + let config = AppConfig::from_cli(&cli).unwrap(); + + assert_eq!(config.database_url, "postgres://:env:"); + + // Test 2: Config overrides env vars + env::set_var("SQLPAGE_DATABASE_URL", "sqlite://:memory:"); + let config = AppConfig::from_cli(&cli).unwrap(); + assert_eq!(config.database_url, "sqlite://:memory:"); + + // Cleanup + env::remove_var("PGHOST"); + env::remove_var("PGUSER"); + env::remove_var("PGDATABASE"); + env::remove_var("PGPASSWORD"); + env::remove_var("SQLPAGE_DATABASE_URL"); + } + #[test] fn test_default_values() { let _lock = ENV_LOCK diff --git a/src/webserver/database/connect.rs b/src/webserver/database/connect.rs index 4ece367a..937c5af7 100644 --- a/src/webserver/database/connect.rs +++ b/src/webserver/database/connect.rs @@ -9,6 +9,7 @@ use crate::{ use anyhow::Context; use futures_util::future::BoxFuture; use sqlx::odbc::OdbcConnectOptions; +use sqlx::postgres::PgConnectOptions; use sqlx::{ any::{Any, AnyConnectOptions, AnyKind}, pool::PoolOptions, @@ -19,9 +20,21 @@ use sqlx::{ impl Database { pub async fn init(config: &AppConfig) -> anyhow::Result { let database_url = &config.database_url; - let mut connect_options: AnyConnectOptions = database_url - .parse() - .with_context(|| format!("\"{database_url}\" is not a valid database URL. Please change the \"database_url\" option in the configuration file."))?; + let mut connect_options: AnyConnectOptions = if database_url == "postgres://:env:" { + PgConnectOptions::new().into() + } else { + let mut options: AnyConnectOptions = database_url + .parse() + .with_context(|| format!("\"{database_url}\" is not a valid database URL. Please change the \"database_url\" option in the configuration file."))?; + if options.kind() == AnyKind::Postgres + && !url_has_password(database_url) + { + if let Ok(password) = std::env::var("PGPASSWORD") { + set_database_password(&mut options, &password); + } + } + options + }; if let Some(password) = &config.database_password { set_database_password(&mut connect_options, password); } @@ -77,8 +90,8 @@ impl Database { } else { // Different databases have a different number of max concurrent connections allowed by default match kind { - AnyKind::Postgres | AnyKind::Odbc => 50, // Default to PostgreSQL-like limits for Generic - AnyKind::MySql => 75, +// AnyKind::Postgres => 50, // Default to PostgreSQL-like limits for Generic +// AnyKind::MySql => 75, AnyKind::Sqlite => { if config.database_url.contains(":memory:") { 128 @@ -249,6 +262,16 @@ fn set_custom_connect_options_odbc(odbc_options: &mut OdbcConnectOptions, config odbc_options.max_column_size(None); } +fn url_has_password(url: &str) -> bool { + if let Some(rest) = url.strip_prefix("postgres://").or_else(|| url.strip_prefix("postgresql://")) { + if let Some(at_index) = rest.find('@') { + let user_info = &rest[..at_index]; + return user_info.contains(':'); + } + } + false +} + fn set_database_password(options: &mut AnyConnectOptions, password: &str) { if let Some(opts) = options.as_postgres_mut() { *opts = take(opts).password(password); @@ -266,3 +289,17 @@ fn set_database_password(options: &mut AnyConnectOptions, password: &str) { unreachable!("Unsupported database type"); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_has_password() { + assert!(url_has_password("postgres://user:pass@host/db")); + assert!(url_has_password("postgresql://user:pass@host/db")); + assert!(!url_has_password("postgres://user@host/db")); + assert!(!url_has_password("postgres://host/db")); + assert!(!url_has_password("mysql://user:pass@host/db")); + } +}