riley_cms_core/
config.rs

1//! Configuration parsing and resolution for riley_cms
2
3use crate::error::{Error, Result};
4use serde::Deserialize;
5use std::path::{Path, PathBuf};
6
7/// Full configuration for riley_cms
8#[derive(Debug, Clone, Deserialize)]
9pub struct RileyCmsConfig {
10    pub content: ContentConfig,
11    pub storage: StorageConfig,
12    pub server: Option<ServerConfig>,
13    pub git: Option<GitConfig>,
14    pub webhooks: Option<WebhooksConfig>,
15    pub auth: Option<AuthConfig>,
16}
17
18/// Git configuration
19#[derive(Debug, Clone, Deserialize)]
20pub struct GitConfig {
21    /// Explicit path to git-http-backend binary (optional, auto-discovered if not set)
22    pub backend_path: Option<PathBuf>,
23    /// Maximum request body size for git operations in bytes. Default: 100MB.
24    #[serde(default = "default_git_max_body_size")]
25    pub max_body_size: u64,
26    /// Timeout for git-http-backend CGI process in seconds. Default: 300 (5 minutes).
27    #[serde(default = "default_git_cgi_timeout_secs")]
28    pub cgi_timeout_secs: u64,
29}
30
31fn default_git_max_body_size() -> u64 {
32    100 * 1024 * 1024 // 100 MB
33}
34
35fn default_git_cgi_timeout_secs() -> u64 {
36    300 // 5 minutes
37}
38
39/// Content repository configuration
40#[derive(Debug, Clone, Deserialize)]
41pub struct ContentConfig {
42    pub repo_path: PathBuf,
43    #[serde(default = "default_content_dir")]
44    pub content_dir: String,
45    /// Maximum size in bytes for any single content file (config.toml, content.mdx, series.toml).
46    /// Files exceeding this limit are skipped with a warning. Default: 5MB.
47    #[serde(default = "default_max_content_file_size")]
48    pub max_content_file_size: u64,
49    /// Maximum total size in bytes for all content loaded into memory.
50    /// If exceeded during loading, remaining content is skipped with a warning.
51    /// Default: 100MB.
52    #[serde(default = "default_max_total_content_size")]
53    pub max_total_content_size: u64,
54}
55
56fn default_content_dir() -> String {
57    "content".to_string()
58}
59
60fn default_max_content_file_size() -> u64 {
61    5 * 1024 * 1024 // 5 MB
62}
63
64fn default_max_total_content_size() -> u64 {
65    100 * 1024 * 1024 // 100 MB
66}
67
68/// Storage backend configuration
69#[derive(Debug, Clone, Deserialize)]
70pub struct StorageConfig {
71    #[serde(default = "default_backend")]
72    pub backend: String,
73    pub bucket: String,
74    #[serde(default = "default_region")]
75    pub region: String,
76    pub endpoint: Option<String>,
77    pub public_url_base: String,
78}
79
80fn default_backend() -> String {
81    "s3".to_string()
82}
83
84fn default_region() -> String {
85    "auto".to_string()
86}
87
88/// Server configuration
89#[derive(Debug, Clone, Deserialize)]
90pub struct ServerConfig {
91    #[serde(default = "default_host")]
92    pub host: String,
93    #[serde(default = "default_port")]
94    pub port: u16,
95    #[serde(default)]
96    pub cors_origins: Vec<String>,
97    #[serde(default = "default_cache_max_age")]
98    pub cache_max_age: u32,
99    #[serde(default = "default_cache_stale_while_revalidate")]
100    pub cache_stale_while_revalidate: u32,
101    /// When true, extract client IP from X-Forwarded-For/X-Real-IP headers
102    /// for rate limiting. Only enable when deployed behind a trusted reverse proxy.
103    /// When false (default), uses the TCP peer address directly.
104    #[serde(default)]
105    pub behind_proxy: bool,
106}
107
108fn default_host() -> String {
109    "0.0.0.0".to_string()
110}
111
112fn default_port() -> u16 {
113    8080
114}
115
116fn default_cache_max_age() -> u32 {
117    60
118}
119
120fn default_cache_stale_while_revalidate() -> u32 {
121    300
122}
123
124impl Default for ServerConfig {
125    fn default() -> Self {
126        Self {
127            host: default_host(),
128            port: default_port(),
129            cors_origins: vec![],
130            cache_max_age: default_cache_max_age(),
131            cache_stale_while_revalidate: default_cache_stale_while_revalidate(),
132            behind_proxy: false,
133        }
134    }
135}
136
137/// Webhook configuration
138#[derive(Debug, Clone, Deserialize)]
139pub struct WebhooksConfig {
140    #[serde(default)]
141    pub on_content_update: Vec<String>,
142    /// HMAC-SHA256 secret for signing webhook payloads.
143    /// When set, each webhook request includes an `X-Riley-Cms-Signature` header
144    /// with the hex-encoded HMAC-SHA256 of the request body.
145    /// Supports `"env:VAR_NAME"` syntax.
146    pub secret: Option<ConfigValue>,
147}
148
149/// Authentication configuration
150#[derive(Debug, Clone, Deserialize)]
151pub struct AuthConfig {
152    pub git_token: Option<ConfigValue>,
153    pub api_token: Option<ConfigValue>,
154}
155
156/// A config value that can be a literal or env var reference
157#[derive(Debug, Clone, Deserialize)]
158#[serde(untagged)]
159pub enum ConfigValue {
160    Literal(String),
161}
162
163impl ConfigValue {
164    /// Resolve the value, reading from env if it starts with "env:"
165    pub fn resolve(&self) -> Result<String> {
166        match self {
167            ConfigValue::Literal(s) => {
168                if let Some(var_name) = s.strip_prefix("env:") {
169                    std::env::var(var_name).map_err(|_| {
170                        Error::Config(format!("Environment variable {} not set", var_name))
171                    })
172                } else {
173                    Ok(s.clone())
174                }
175            }
176        }
177    }
178}
179
180/// Wrapper for loading config from file
181pub struct Config;
182
183impl Config {
184    /// Load config from a specific path
185    pub fn from_path(path: &Path) -> Result<RileyCmsConfig> {
186        let content = std::fs::read_to_string(path)?;
187        toml::from_str(&content).map_err(|e| Error::ConfigParse {
188            path: path.to_path_buf(),
189            source: e,
190        })
191    }
192}
193
194/// Resolve config file path using the resolution order:
195/// 1. Explicit path if provided
196/// 2. RILEY_CMS_CONFIG env var
197/// 3. riley_cms.toml in current directory
198/// 4. Walk up ancestors looking for riley_cms.toml
199/// 5. ~/.config/riley_cms/config.toml (user default)
200/// 6. /etc/riley_cms/config.toml (system default)
201pub fn resolve_config(explicit_path: Option<&Path>) -> Result<RileyCmsConfig> {
202    let mut searched = Vec::new();
203
204    // 1. Explicit path
205    if let Some(path) = explicit_path {
206        if path.exists() {
207            return Config::from_path(path);
208        }
209        searched.push(path.to_path_buf());
210    }
211
212    // 2. RILEY_CMS_CONFIG env var
213    if let Ok(env_path) = std::env::var("RILEY_CMS_CONFIG") {
214        let path = PathBuf::from(&env_path);
215        if path.exists() {
216            return Config::from_path(&path);
217        }
218        searched.push(path);
219    }
220
221    // 3 & 4. Current directory and ancestors
222    if let Ok(cwd) = std::env::current_dir() {
223        let mut dir = Some(cwd.as_path());
224        while let Some(d) = dir {
225            let config_path = d.join("riley_cms.toml");
226            if config_path.exists() {
227                return Config::from_path(&config_path);
228            }
229            searched.push(config_path);
230            dir = d.parent();
231        }
232    }
233
234    // 5. User default (~/.config/riley_cms/config.toml)
235    if let Some(config_dir) = dirs::config_dir() {
236        let user_config = config_dir.join("riley_cms").join("config.toml");
237        if user_config.exists() {
238            return Config::from_path(&user_config);
239        }
240        searched.push(user_config);
241    }
242
243    // 6. System default (/etc/riley_cms/config.toml)
244    let system_config = PathBuf::from("/etc/riley_cms/config.toml");
245    if system_config.exists() {
246        return Config::from_path(&system_config);
247    }
248    searched.push(system_config);
249
250    Err(Error::ConfigNotFound { searched })
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use tempfile::TempDir;
257
258    #[test]
259    fn test_config_value_literal() {
260        let val = ConfigValue::Literal("test".to_string());
261        assert_eq!(val.resolve().unwrap(), "test");
262    }
263
264    #[test]
265    fn test_config_value_env() {
266        temp_env::with_var("TEST_RILEY_VAR", Some("from_env"), || {
267            let val = ConfigValue::Literal("env:TEST_RILEY_VAR".to_string());
268            assert_eq!(val.resolve().unwrap(), "from_env");
269        });
270    }
271
272    #[test]
273    fn test_config_value_env_missing() {
274        let val = ConfigValue::Literal("env:NONEXISTENT_RILEY_VAR_12345".to_string());
275        assert!(val.resolve().is_err());
276    }
277
278    #[test]
279    fn test_parse_minimal_config() {
280        let toml = r#"
281[content]
282repo_path = "/data/repo"
283
284[storage]
285bucket = "my-bucket"
286public_url_base = "https://assets.example.com"
287"#;
288        let config: RileyCmsConfig = toml::from_str(toml).unwrap();
289        assert_eq!(config.content.repo_path, PathBuf::from("/data/repo"));
290        assert_eq!(config.content.content_dir, "content"); // default
291        assert_eq!(config.storage.bucket, "my-bucket");
292        assert_eq!(config.storage.backend, "s3"); // default
293        assert_eq!(config.storage.region, "auto"); // default
294        assert!(config.server.is_none());
295        assert!(config.webhooks.is_none());
296        assert!(config.auth.is_none());
297    }
298
299    #[test]
300    fn test_parse_full_config() {
301        let toml = r#"
302[content]
303repo_path = "/data/repo"
304content_dir = "posts"
305
306[storage]
307backend = "s3"
308bucket = "my-bucket"
309region = "us-east-1"
310endpoint = "https://s3.amazonaws.com"
311public_url_base = "https://assets.example.com"
312
313[server]
314host = "127.0.0.1"
315port = 3000
316cors_origins = ["https://example.com", "https://dev.example.com"]
317cache_max_age = 120
318cache_stale_while_revalidate = 600
319
320[webhooks]
321on_content_update = ["https://example.com/webhook"]
322
323[auth]
324git_token = "secret123"
325api_token = "env:API_TOKEN"
326"#;
327        let config: RileyCmsConfig = toml::from_str(toml).unwrap();
328        assert_eq!(config.content.content_dir, "posts");
329        assert_eq!(config.storage.region, "us-east-1");
330        assert_eq!(
331            config.storage.endpoint,
332            Some("https://s3.amazonaws.com".to_string())
333        );
334
335        let server = config.server.unwrap();
336        assert_eq!(server.host, "127.0.0.1");
337        assert_eq!(server.port, 3000);
338        assert_eq!(server.cors_origins.len(), 2);
339        assert_eq!(server.cache_max_age, 120);
340
341        let webhooks = config.webhooks.unwrap();
342        assert_eq!(webhooks.on_content_update.len(), 1);
343
344        let auth = config.auth.unwrap();
345        assert!(auth.git_token.is_some());
346        assert!(auth.api_token.is_some());
347    }
348
349    #[test]
350    fn test_server_config_defaults() {
351        let server = ServerConfig::default();
352        assert_eq!(server.host, "0.0.0.0");
353        assert_eq!(server.port, 8080);
354        assert!(server.cors_origins.is_empty());
355        assert_eq!(server.cache_max_age, 60);
356        assert_eq!(server.cache_stale_while_revalidate, 300);
357    }
358
359    #[test]
360    fn test_load_config_from_file() {
361        let temp_dir = TempDir::new().unwrap();
362        let config_path = temp_dir.path().join("riley_cms.toml");
363        std::fs::write(
364            &config_path,
365            r#"
366[content]
367repo_path = "/test"
368
369[storage]
370bucket = "test-bucket"
371public_url_base = "https://test.com"
372"#,
373        )
374        .unwrap();
375
376        let config = Config::from_path(&config_path).unwrap();
377        assert_eq!(config.content.repo_path, PathBuf::from("/test"));
378    }
379
380    #[test]
381    fn test_load_config_invalid_toml() {
382        let temp_dir = TempDir::new().unwrap();
383        let config_path = temp_dir.path().join("invalid.toml");
384        std::fs::write(&config_path, "this is not valid toml {{{").unwrap();
385
386        let result = Config::from_path(&config_path);
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn test_load_config_missing_required_field() {
392        let temp_dir = TempDir::new().unwrap();
393        let config_path = temp_dir.path().join("incomplete.toml");
394        std::fs::write(
395            &config_path,
396            r#"
397[content]
398repo_path = "/test"
399# Missing [storage] section
400"#,
401        )
402        .unwrap();
403
404        let result = Config::from_path(&config_path);
405        assert!(result.is_err());
406    }
407}