//! Configuration from the environment / `.env` file //! (`/opt/thailand-water-monitor/.env`), which the Python training tools //! (src/config.py) read too. Names and defaults are the ones the Python app //! used. use anyhow::{Context, Result, bail}; use std::env; use std::path::PathBuf; pub const RID_API_URL: &str = "https://hyd-app-db.rid.go.th/webservice/getGroupHourlyWaterLevelReportAllHL.ashx"; pub const HII_API_BASE: &str = "https://api-v3.thaiwater.net/api/v1/thaiwater30/public"; pub const RID_DAMS_URL: &str = "https://app.rid.go.th/reservoir/api/dams"; pub const OPENMETEO_URL: &str = "https://api.open-meteo.com/v1/forecast"; /// The RID endpoint serves the same JSON to any client; keep the UA the /// Python collector has used for years. pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \ (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"; #[derive(Clone, Debug)] pub struct Config { pub database_url: String, pub rid_api_url: String, pub hii_api_base: String, pub dams_url: String, pub openmeteo_url: String, pub hii_enabled: bool, pub dams_enabled: bool, pub rain_enabled: bool, pub hii_basin_code: i64, /// RID is polled this many minutes past each hour. pub poll_offset_minutes: u32, /// While the current hour is not published yet, retry this often. pub retry_minutes: u32, /// Days of RID holes re-fetched at startup (0 = off). pub gap_fill_days_on_start: u32, pub station_config_path: PathBuf, /// Status JSON for /scraping/status when the website runs in a separate /// process (`ping-collector serve`). pub status_file: Option, /// FORECAST_IN_COLLECTOR: forecast and notify (off: collect only). pub forecast_enabled: bool, /// Exported bundles (`flood_.json.gz`, src/ml/export.py). pub models_dir: PathBuf, /// Where to POST ntfy messages; `None` (NTFY_SERVER unset) = off. pub ntfy_publish_url: Option, pub ntfy_token: Option, pub ntfy_topic_prefix: String, /// Dashboard link in notifications. pub public_url: String, /// Report RID lagging this long while ThaiWater is current (0 = never). pub rid_stale_alert_hours: f64, /// FEED_NIGHT_UNTIL_HOUR: from 21:00 until this hour a quiet river's feed /// is not reported stale (RID publishes nothing overnight); 0 = off. pub feed_night_until_hour: u32, /// Public ntfy URL (what subscribers use), shown by /api/notifications. pub ntfy_server: Option, /// MATRIX_*: the notifications also go to this room (off when unset). pub matrix: Option, pub web: WebConfig, } #[derive(Clone, Debug)] pub struct MatrixConfig { pub homeserver: String, pub access_token: String, pub room_id: String, } /// The website and read API (setting names as the retired Python app had them). #[derive(Clone, Debug)] pub struct WebConfig { /// WEB_LISTEN: `run` also serves the website on this address /// (e.g. 0.0.0.0:8000); unset = collection only. pub listen: Option, /// Dashboard, robots/llms/sitemap and /static files. pub static_dir: PathBuf, /// X-API-Key for the station editing, /config and /scrape/trigger; /// unset = those endpoints are disabled. pub admin_api_key: Option, pub cors_allow_origins: Vec, pub umami_api_url: String, /// Empty = no server-side analytics. pub umami_website_id: String, pub umami_track_api: bool, pub hii_cache_ttl_s: u64, pub latest_cache_ttl_s: u64, pub health_cache_ttl_s: u64, /// A stored forecast older than this is served marked `X-Data-Stale`. pub forecast_max_age_s: u64, pub thaiwater_api_key: Option, pub request_timeout_s: u64, } /// Variable lookup (the process environment, or a map in tests). struct Vars<'a>(&'a dyn Fn(&str) -> Option); impl Vars<'_> { fn var(&self, name: &str) -> Option { (self.0)(name) .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) } /// `os.getenv(name, default)`: a variable set to "" stays "". fn raw_var(&self, name: &str, default: &str) -> String { (self.0)(name).map_or_else(|| default.to_string(), |v| v.trim().to_string()) } fn flag(&self, name: &str, default: bool) -> bool { self.var(name).map_or(default, |v| { matches!(v.to_lowercase().as_str(), "1" | "true" | "yes") }) } fn number(&self, name: &str, default: T) -> Result where T::Err: std::fmt::Display, { match self.var(name) { None => Ok(default), Some(v) => v.parse().map_err(|e| anyhow::anyhow!("{name}={v:?}: {e}")), } } } /// Percent-encode everything outside RFC 3986 "unreserved" (as Python's /// `urllib.parse.quote(password, safe="")`). fn encode_userinfo(s: &str) -> String { s.bytes() .map(|b| match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { (b as char).to_string() } _ => format!("%{b:02X}"), }) .collect() } /// SQLAlchemy URLs carry a driver suffix (`postgresql+psycopg2://`) that /// sqlx does not understand. pub fn normalize_database_url(url: &str) -> String { match url.split_once("://") { Some((scheme, rest)) if scheme.starts_with("postgresql") || scheme.starts_with("postgres") => { format!("postgres://{rest}") } _ => url.to_string(), } } impl Config { pub fn from_env() -> Result { Self::from_vars(&|name| env::var(name).ok()) } pub fn from_vars(get: &dyn Fn(&str) -> Option) -> Result { let v = Vars(get); let database_url = match v.var("POSTGRES_CONNECTION_STRING") { Some(url) => normalize_database_url(&url), None => { let password = v .var("POSTGRES_PASSWORD") .context("POSTGRES_CONNECTION_STRING or POSTGRES_PASSWORD must be set")?; format!( "postgres://{}:{}@{}:{}/{}", encode_userinfo(&v.var("POSTGRES_USER").unwrap_or_else(|| "postgres".into())), encode_userinfo(&password), v.var("POSTGRES_HOST").unwrap_or_else(|| "localhost".into()), v.var("POSTGRES_PORT").unwrap_or_else(|| "5432".into()), v.var("POSTGRES_DB") .unwrap_or_else(|| "water_monitoring".into()), ) } }; if let Some(db_type) = v.var("DB_TYPE") && db_type != "postgresql" { bail!("ping-collector supports PostgreSQL only (DB_TYPE={db_type})"); } let cfg = Config { database_url, rid_api_url: v.var("RID_API_URL").unwrap_or_else(|| RID_API_URL.into()), hii_api_base: v.var("HII_API_BASE").unwrap_or_else(|| HII_API_BASE.into()), dams_url: v.var("RID_DAMS_URL").unwrap_or_else(|| RID_DAMS_URL.into()), openmeteo_url: v .var("OPENMETEO_URL") .unwrap_or_else(|| OPENMETEO_URL.into()), hii_enabled: v.flag("ENABLE_HII_COLLECTION", true), dams_enabled: v.flag("ENABLE_RESERVOIR_COLLECTION", true), rain_enabled: v.flag("ENABLE_RAIN_COLLECTION", true), hii_basin_code: v.number("HII_BASIN_CODE", 6)?, poll_offset_minutes: v.number("RID_POLL_OFFSET_MINUTES", 5)?, retry_minutes: v.number("RID_RETRY_MINUTES", 10)?, gap_fill_days_on_start: v.number("GAP_FILL_DAYS_ON_START", 14)?, station_config_path: v .var("STATION_CONFIG_PATH") .unwrap_or_else(|| "stations.json".into()) .into(), status_file: v.var("SCRAPING_STATUS_FILE").map(PathBuf::from), forecast_enabled: v.flag("FORECAST_IN_COLLECTOR", false), models_dir: v .var("MODELS_DIR") .unwrap_or_else(|| "models".into()) .into(), // as src/config.py: off unless NTFY_SERVER is set ntfy_publish_url: v .var("NTFY_SERVER") .map(|server| v.var("NTFY_PUBLISH_URL").unwrap_or(server)), ntfy_token: v.var("NTFY_TOKEN"), ntfy_topic_prefix: v.var("NTFY_TOPIC_PREFIX").unwrap_or_else(|| "ping".into()), public_url: v .var("PUBLIC_URL") .unwrap_or_else(|| "https://water.buildfor.life/".into()), rid_stale_alert_hours: v.number("RID_STALE_ALERT_HOURS", 18.0)?, feed_night_until_hour: match v.number("FEED_NIGHT_UNTIL_HOUR", 9u32)? { h if h < 21 => h, h => bail!("FEED_NIGHT_UNTIL_HOUR must be 0 (off) to 20, got {h}"), }, ntfy_server: v.var("NTFY_SERVER"), matrix: match (v.var("MATRIX_ACCESS_TOKEN"), v.var("MATRIX_ROOM_ID")) { (Some(access_token), Some(room_id)) => Some(MatrixConfig { homeserver: v .var("MATRIX_HOMESERVER") .unwrap_or_else(|| "https://matrix.org".into()), access_token, room_id, }), _ => None, }, web: WebConfig { listen: v.var("WEB_LISTEN"), static_dir: v .var("STATIC_DIR") .unwrap_or_else(|| "src/static".into()) .into(), admin_api_key: v.var("ADMIN_API_KEY"), cors_allow_origins: v .raw_var("CORS_ALLOW_ORIGINS", "") .split(',') .map(str::trim) .filter(|o| !o.is_empty()) .map(String::from) .collect(), umami_api_url: v .var("UMAMI_API_URL") .unwrap_or_else(|| "https://stats.buildfor.life/api/send".into()), umami_website_id: v .raw_var("UMAMI_WEBSITE_ID", "00b2be73-8f5f-4400-9029-3be852eb08f7"), umami_track_api: v.flag("UMAMI_TRACK_API", true), hii_cache_ttl_s: v.number("HII_CACHE_TTL_SECONDS", 120)?, latest_cache_ttl_s: v.number("LATEST_CACHE_TTL_SECONDS", 45)?, health_cache_ttl_s: v.number("HEALTH_CACHE_TTL_SECONDS", 30)?, forecast_max_age_s: v.number("FORECAST_MAX_AGE_SECONDS", 7200)?, thaiwater_api_key: v.var("THAIWATER_API_KEY"), request_timeout_s: v.number("REQUEST_TIMEOUT", 30)?, }, }; if cfg.poll_offset_minutes >= 60 { bail!("RID_POLL_OFFSET_MINUTES must be between 0 and 59"); } if cfg.retry_minutes == 0 { bail!("RID_RETRY_MINUTES must be positive"); } Ok(cfg) } /// The URL with the password masked, for logs. pub fn redacted_database_url(&self) -> String { match (self.database_url.find("://"), self.database_url.rfind('@')) { (Some(s), Some(at)) if at > s => { let creds = &self.database_url[s + 3..at]; let user = creds.split(':').next().unwrap_or(""); format!( "{}{user}:***{}", &self.database_url[..s + 3], &self.database_url[at..] ) } _ => self.database_url.clone(), } } } #[cfg(test)] mod tests { use super::*; #[test] fn sqlalchemy_urls_are_normalized() { assert_eq!( normalize_database_url("postgresql+psycopg2://u:p@h:5432/db"), "postgres://u:p@h:5432/db" ); assert_eq!( normalize_database_url("postgresql://u@h/db"), "postgres://u@h/db" ); } #[test] fn passwords_are_percent_encoded() { assert_eq!(encode_userinfo("p@ss:w/rd"), "p%40ss%3Aw%2Frd"); } }