Skip to main content

autopulse_service/settings/
app.rs

1use autopulse_utils::LogLevel;
2use serde::{Deserialize, Serialize};
3use std::net::IpAddr;
4
5/// Normalize `base_path` so `format!("{base}/ui/...")` is always well-formed:
6/// either `""` or `/<prefix>`, no trailing slash. Tests cover the corner cases.
7fn normalize_base_path<'de, D>(deserializer: D) -> Result<String, D::Error>
8where
9    D: serde::Deserializer<'de>,
10{
11    let raw = String::deserialize(deserializer)?;
12    let trimmed = raw.trim().trim_end_matches('/');
13    if trimmed.is_empty() {
14        return Ok(String::new());
15    }
16    Ok(if trimmed.starts_with('/') {
17        trimmed.to_string()
18    } else {
19        format!("/{trimmed}")
20    })
21}
22
23#[derive(Serialize, Deserialize, Clone)]
24#[serde(default)]
25pub struct App {
26    /// Hostname to bind to, (default: 0.0.0.0)
27    pub hostname: String,
28    /// Port to bind to (default: 2875)
29    pub port: u16,
30    /// Database URL (see [`AnyConnection`](autopulse_database::conn::AnyConnection))
31    pub database_url: String,
32    /// Log level (default: info) (trace, debug, info, warn, error)
33    pub log_level: LogLevel,
34    /// Whether to include api logging (default: false)
35    pub api_logging: bool,
36    /// Reverse-proxy base path (default: ""). UI routes are mounted under
37    /// this prefix server-side and generated links include it, so the
38    /// proxy should pass the prefix through verbatim (no strip-prefix).
39    /// Input is normalized: leading slash added if missing, trailing
40    /// slash stripped, `"/"` collapses to `""`.
41    #[serde(deserialize_with = "normalize_base_path")]
42    pub base_path: String,
43    /// Whether to set the `Secure` flag on the UI session cookie
44    /// (default: false). Enable when serving over HTTPS/TLS.
45    pub secure_cookies: bool,
46    /// Proxy IPs whose `X-Forwarded-For` we honor for the login throttle's
47    /// client identification. Empty (default) = trust nothing, use `peer_addr`.
48    pub trusted_proxies: Vec<IpAddr>,
49}
50
51impl Default for App {
52    fn default() -> Self {
53        Self {
54            hostname: "0.0.0.0".to_string(),
55            port: 2875,
56            database_url: autopulse_database::conn::DatabaseType::default().default_url(),
57            log_level: LogLevel::default(),
58            api_logging: false,
59            base_path: String::new(),
60            secure_cookies: false,
61            trusted_proxies: Vec::new(),
62        }
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::App;
69
70    fn base_path_of(json: &str) -> String {
71        let app: App = serde_json::from_str(json).expect("valid app json");
72        app.base_path
73    }
74
75    #[test]
76    fn base_path_empty_stays_empty() {
77        assert_eq!(base_path_of(r#"{"base_path": ""}"#), "");
78    }
79
80    #[test]
81    fn base_path_lone_slash_collapses_to_empty() {
82        assert_eq!(base_path_of(r#"{"base_path": "/"}"#), "");
83    }
84
85    #[test]
86    fn base_path_missing_leading_slash_gets_one() {
87        assert_eq!(base_path_of(r#"{"base_path": "autopulse"}"#), "/autopulse");
88    }
89
90    #[test]
91    fn base_path_trailing_slash_stripped() {
92        assert_eq!(
93            base_path_of(r#"{"base_path": "/autopulse/"}"#),
94            "/autopulse"
95        );
96    }
97
98    #[test]
99    fn base_path_already_normalized_unchanged() {
100        assert_eq!(base_path_of(r#"{"base_path": "/autopulse"}"#), "/autopulse");
101    }
102
103    #[test]
104    fn base_path_trims_whitespace_and_trailing_slashes() {
105        assert_eq!(
106            base_path_of(r#"{"base_path": "  /autopulse//  "}"#),
107            "/autopulse"
108        );
109    }
110
111    #[test]
112    fn base_path_default_is_empty() {
113        let app: App = serde_json::from_str("{}").expect("valid empty app json");
114        assert_eq!(app.base_path, "");
115    }
116}