Skip to main content

webx_core/
config.rs

1//! Built-in configuration support: appsettings.json + AppOptions pattern.
2//!
3//! The framework automatically loads `appsettings.json` (merged with
4//! `appsettings.Development.json` in dev mode) and binds it to the
5//! built-in `AppOptions` struct.  Users customize options via
6//! `HostBuilder::configure(|app| app.useOptions(|o| { ... }))`.
7
8use crate::mode::AppMode;
9use serde::Deserialize;
10use std::path::Path;
11
12// ---------------------------------------------------------------------------
13// IAppOptions trait (for user-defined option types)
14// ---------------------------------------------------------------------------
15
16/// Application options — binds to a section of appsettings.json.
17///
18/// Users define their own structs implementing this trait,
19/// then call `AppConfig::bind()` to bind values.
20pub trait IAppOptions: for<'de> Deserialize<'de> + Default + Send + Sync + 'static {}
21
22impl<T> IAppOptions for T where T: for<'de> Deserialize<'de> + Default + Send + Sync + 'static {}
23
24// ---------------------------------------------------------------------------
25// Built-in option types (matching standard appsettings.json layout)
26// ---------------------------------------------------------------------------
27
28/// Top-level application section.
29#[derive(Debug, Clone, Deserialize)]
30pub struct AppSection {
31    /// Application display name.
32    #[serde(default, rename = "Name")]
33    pub name: String,
34    /// Listen addresses as full URLs (e.g., "http://0.0.0.0:5000").
35    /// ASP.NET Core compatible. Default: ["http://0.0.0.0:5000"].
36    #[serde(default = "default_urls", rename = "Urls")]
37    pub urls: Vec<String>,
38    /// Maximum request body size in bytes. Default: 10 MB.
39    #[serde(default = "default_max_body_size", rename = "MaxBodySize")]
40    pub max_body_size: usize,
41    /// Maximum concurrent connections. Default: 10000.
42    #[serde(default = "default_max_connections", rename = "MaxConnections")]
43    pub max_connections: usize,
44}
45
46impl Default for AppSection {
47    fn default() -> Self {
48        Self {
49            name: String::new(),
50            urls: default_urls(),
51            max_body_size: default_max_body_size(),
52            max_connections: default_max_connections(),
53        }
54    }
55}
56
57fn default_urls() -> Vec<String> {
58    vec!["http://0.0.0.0:5000".to_string()]
59}
60
61fn default_max_body_size() -> usize {
62    10 * 1024 * 1024 // 10 MB
63}
64
65fn default_max_connections() -> usize {
66    10_000
67}
68
69/// JWT authentication section.
70#[derive(Debug, Clone, Deserialize, Default)]
71pub struct JwtSection {
72    /// HMAC secret for signing/verifying JWT tokens.
73    #[serde(default, rename = "Secret")]
74    pub secret: String,
75}
76
77/// CORS (Cross-Origin Resource Sharing) section.
78///
79/// JSON keys use PascalCase (`Origins`, `Methods`, …) to match appsettings
80/// and ASP.NET Core conventions — same as `App` / `Jwt` / `RateLimit`.
81#[derive(Debug, Clone, Deserialize)]
82#[serde(rename_all = "PascalCase")]
83pub struct CorsSection {
84    /// Allowed origins. Default: ["*"].
85    #[serde(default = "default_origins")]
86    pub origins: Vec<String>,
87    /// Allowed methods. Default: GET, POST, PUT, DELETE, PATCH, OPTIONS.
88    #[serde(default = "default_cors_methods")]
89    pub methods: Vec<String>,
90    /// Allowed headers. Default: Content-Type, Authorization.
91    #[serde(default = "default_cors_headers")]
92    pub headers: Vec<String>,
93    /// Response headers exposed to the client. Default: empty.
94    #[serde(default)]
95    pub expose_headers: Vec<String>,
96    /// Allow credentials. Default: false.
97    #[serde(default)]
98    pub allow_credentials: bool,
99    /// Preflight cache max-age in seconds. Default: 86400.
100    #[serde(default = "default_max_age")]
101    pub max_age: u32,
102}
103
104impl Default for CorsSection {
105    fn default() -> Self {
106        Self {
107            origins: default_origins(),
108            methods: default_cors_methods(),
109            headers: default_cors_headers(),
110            expose_headers: Vec::new(),
111            allow_credentials: false,
112            max_age: default_max_age(),
113        }
114    }
115}
116
117fn default_origins() -> Vec<String> {
118    vec!["*".to_string()]
119}
120
121fn default_cors_methods() -> Vec<String> {
122    vec![
123        "GET".to_string(),
124        "POST".to_string(),
125        "PUT".to_string(),
126        "DELETE".to_string(),
127        "PATCH".to_string(),
128        "OPTIONS".to_string(),
129    ]
130}
131
132fn default_cors_headers() -> Vec<String> {
133    vec!["Content-Type".to_string(), "Authorization".to_string()]
134}
135
136fn default_max_age() -> u32 {
137    86400
138}
139
140/// Culture / localization section.
141///
142/// The default UI culture is a host-wide setting: endpoint names, OpenAPI, and
143/// permission synchronization use it unless a caller explicitly passes another
144/// culture. Request-header negotiation is deliberately not part of this:
145/// administrative output must not change with the caller's browser language.
146#[derive(Debug, Clone, Deserialize)]
147pub struct CultureSection {
148    /// Default BCP-47 culture, e.g. `"zh-CN"` or `"en"`. Default: `"en"`.
149    #[serde(default = "default_culture", rename = "Default")]
150    pub default: String,
151}
152
153impl Default for CultureSection {
154    fn default() -> Self {
155        Self {
156            default: default_culture(),
157        }
158    }
159}
160
161fn default_culture() -> String {
162    "en".to_string()
163}
164
165/// Per-IP rate limiting (token bucket).
166#[derive(Debug, Clone, Deserialize)]
167pub struct RateLimitSection {
168    /// Enable rate-limit middleware. Default: false (enable in Production appsettings).
169    #[serde(default, rename = "Enabled")]
170    pub enabled: bool,
171    /// Sustained requests per second per client IP.
172    #[serde(default = "default_rate_limit_rps", rename = "RequestsPerSecond")]
173    pub requests_per_second: f64,
174    /// Burst capacity before throttling.
175    #[serde(default = "default_rate_limit_burst", rename = "BurstSize")]
176    pub burst_size: u32,
177    /// Maximum distinct client IPs tracked (LRU eviction when exceeded).
178    #[serde(default = "default_rate_limit_max_ips", rename = "MaxTrackedIps")]
179    pub max_tracked_ips: usize,
180    /// Trust X-Forwarded-For / X-Real-IP headers for client IP extraction.
181    /// Only enable when behind a trusted reverse proxy. Default: false.
182    #[serde(default, rename = "TrustProxy")]
183    pub trust_proxy: bool,
184    /// CIDR ranges of the trusted proxies. When `TrustProxy` is enabled, the
185    /// forwarded headers are only consulted if the direct peer address belongs
186    /// to one of these ranges; the client IP is then the right-most address in
187    /// `X-Forwarded-For` that is not itself a trusted proxy.
188    ///
189    /// An empty list with `TrustProxy = true` is treated as "trust nothing":
190    /// the headers are ignored (a client can otherwise rotate
191    /// `X-Forwarded-For` to bypass the limiter).
192    #[serde(default, rename = "TrustedProxies")]
193    pub trusted_proxies: Vec<String>,
194}
195
196impl Default for RateLimitSection {
197    fn default() -> Self {
198        Self {
199            enabled: false,
200            requests_per_second: default_rate_limit_rps(),
201            burst_size: default_rate_limit_burst(),
202            max_tracked_ips: default_rate_limit_max_ips(),
203            trust_proxy: false,
204            trusted_proxies: Vec::new(),
205        }
206    }
207}
208
209fn default_rate_limit_rps() -> f64 {
210    100.0
211}
212
213fn default_rate_limit_burst() -> u32 {
214    200
215}
216
217fn default_rate_limit_max_ips() -> usize {
218    10_000
219}
220
221/// HTTP request metrics (`GET /metrics` Prometheus text when enabled).
222#[derive(Debug, Clone, Deserialize, Default)]
223pub struct MetricsSection {
224    #[serde(default, rename = "Enabled")]
225    pub enabled: bool,
226}
227
228/// TLS (Transport Layer Security) section.
229///
230/// TLS is activated automatically when the `App.Urls` array
231/// contains one or more `https://` entries. The certificate
232/// and key paths are read from this section.
233#[derive(Debug, Clone, Deserialize, Default)]
234pub struct TlsSection {
235    /// Path to TLS certificate PEM file.
236    #[serde(default, rename = "CertPath")]
237    pub cert_path: String,
238    /// Path to TLS private key PEM file.
239    #[serde(default, rename = "KeyPath")]
240    pub key_path: String,
241}
242
243/// `multipart/form-data` upload settings.
244///
245/// These are deliberately separate from `App.MaxBodySize`: a JSON endpoint
246/// should stay tightly bounded while an upload endpoint legitimately accepts
247/// far more. A request is measured against `MaxRequestSize` when it declares
248/// `Content-Type: multipart/form-data`, and against `App.MaxBodySize`
249/// otherwise.
250#[derive(Debug, Clone, Deserialize)]
251pub struct FormSection {
252    /// Largest accepted multipart request body, in bytes. Default: 256 MiB.
253    #[serde(default = "default_max_request_size", rename = "MaxRequestSize")]
254    pub max_request_size: usize,
255    /// Largest accepted individual file part, in bytes. Default: 128 MiB.
256    #[serde(default = "default_max_file_size", rename = "MaxFileSize")]
257    pub max_file_size: u64,
258    /// Largest accepted individual text field, in bytes. Default: 1 MiB.
259    #[serde(default = "default_max_field_size", rename = "MaxFieldSize")]
260    pub max_field_size: usize,
261    /// Bytes buffered in memory per file before spooling to disk. Default: 1 MiB.
262    ///
263    /// Set to `0` to spool every upload, or very high to keep everything in
264    /// memory.
265    #[serde(default = "default_memory_threshold", rename = "MemoryThreshold")]
266    pub memory_threshold: usize,
267    /// Directory for spooled uploads. Default: `webx-uploads` under the system
268    /// temporary directory.
269    #[serde(default, rename = "TempDir")]
270    pub temp_dir: Option<String>,
271}
272
273impl Default for FormSection {
274    fn default() -> Self {
275        Self {
276            max_request_size: default_max_request_size(),
277            max_file_size: default_max_file_size(),
278            max_field_size: default_max_field_size(),
279            memory_threshold: default_memory_threshold(),
280            temp_dir: None,
281        }
282    }
283}
284
285fn default_max_request_size() -> usize {
286    256 * 1024 * 1024
287}
288
289fn default_max_file_size() -> u64 {
290    128 * 1024 * 1024
291}
292
293fn default_max_field_size() -> usize {
294    1024 * 1024
295}
296
297fn default_memory_threshold() -> usize {
298    1024 * 1024
299}
300
301/// Standard application options loaded from appsettings.json.
302///
303/// Bound automatically by the framework.  Access via `host.options()`
304/// or customize via `app.useOptions(|o| { ... })`.
305#[derive(Debug, Clone, Deserialize, Default)]
306pub struct AppOptions {
307    /// Application settings.
308    #[serde(default, rename = "App")]
309    pub app: AppSection,
310    /// JWT authentication settings.
311    #[serde(default, rename = "Jwt")]
312    pub jwt: JwtSection,
313    /// CORS settings.
314    #[serde(default, rename = "Cors")]
315    pub cors: CorsSection,
316    /// TLS settings.
317    #[serde(default, rename = "Tls")]
318    pub tls: TlsSection,
319    /// Rate limiting settings.
320    #[serde(default, rename = "RateLimit")]
321    pub rate_limit: RateLimitSection,
322    /// Prometheus-style metrics endpoint settings.
323    #[serde(default, rename = "Metrics")]
324    pub metrics: MetricsSection,
325    /// `multipart/form-data` upload settings.
326    #[serde(default, rename = "Form")]
327    pub form: FormSection,
328    /// Default culture for endpoint text and OpenAPI.
329    #[serde(default, rename = "Culture")]
330    pub culture: CultureSection,
331}
332
333// ---------------------------------------------------------------------------
334// Config loading helpers
335// ---------------------------------------------------------------------------
336
337/// Load the merged appsettings JSON (base + environment overlay + env overrides).
338///
339/// 按运行模式自动合并对应的 `appsettings.{Mode}.json` overlay:
340/// - `Development` → `appsettings.Development.json`
341/// - `Production`  → `appsettings.Production.json`
342///
343/// Environment variables prefixed with `APP__` override the corresponding JSON values.
344/// For example, `APP__App__Address=0.0.0.0:8080` overrides `{"App": {"Address": "..."}}`.
345pub fn load_appsettings(mode: AppMode) -> Option<serde_json::Value> {
346    let mut base = read_json_file("appsettings.json")?;
347
348    let overlay_name = mode.overlay_filename();
349    if let Some(overlay) = read_json_file(overlay_name) {
350        merge_json(&mut base, overlay);
351    }
352
353    // Well-known env vars (JWT_SECRET) — applied before APP__ so APP__Jwt__Secret wins.
354    apply_well_known_env_overrides(&mut base);
355
356    // Apply environment variable overrides (APP__Section__Key pattern)
357    apply_env_overrides(&mut base);
358
359    Some(base)
360}
361
362/// Apply conventional environment variables that map to config sections.
363///
364/// `JWT_SECRET` → `Jwt.Secret` (overridden by `APP__Jwt__Secret` if set).
365fn apply_well_known_env_overrides(config: &mut serde_json::Value) {
366    if let Ok(secret) = std::env::var("JWT_SECRET") {
367        if !secret.is_empty() {
368            set_json_value(config, &["Jwt", "Secret"], &secret);
369        }
370    }
371}
372
373/// Apply environment variable overrides following the `APP__Section__Key` pattern.
374fn apply_env_overrides(config: &mut serde_json::Value) {
375    for (key, value) in std::env::vars() {
376        if let Some(path) = key.strip_prefix("APP__") {
377            // Split by double underscore to get path segments
378            let segments: Vec<&str> = path.split("__").collect();
379            if segments.is_empty() {
380                continue;
381            }
382            set_json_value(config, &segments, &value);
383        }
384    }
385}
386
387/// Set a value in a JSON object following the given path segments.
388fn set_json_value(obj: &mut serde_json::Value, segments: &[&str], value: &str) {
389    if segments.is_empty() {
390        return;
391    }
392
393    let key = segments[0];
394
395    if let serde_json::Value::Object(map) = obj {
396        if segments.len() == 1 {
397            // Leaf: set the value, attempting to parse as JSON first
398            let parsed =
399                serde_json::from_str(value).unwrap_or(serde_json::Value::String(value.to_string()));
400            map.insert(key.to_string(), parsed);
401        } else if let Some(child) = map.get_mut(key) {
402            // Recurse into child
403            set_json_value(child, &segments[1..], value);
404        } else {
405            // Create intermediate objects as needed
406            let mut child = serde_json::json!({});
407            set_json_value(&mut child, &segments[1..], value);
408            map.insert(key.to_string(), child);
409        }
410    }
411}
412
413/// Bind a section of the config JSON to a deserializable type.
414pub fn bind_config<T: for<'de> Deserialize<'de> + Default>(
415    config: &serde_json::Value,
416    section: &str,
417) -> T {
418    if section.is_empty() || section == "." {
419        serde_json::from_value(config.clone()).unwrap_or_else(|e| {
420            tracing::warn!(
421                "[Config] Failed to bind root config to {}: {}, using defaults",
422                std::any::type_name::<T>(),
423                e
424            );
425            T::default()
426        })
427    } else {
428        config
429            .get(section)
430            .map(|v| {
431                serde_json::from_value(v.clone()).unwrap_or_else(|e| {
432                    tracing::warn!(
433                        "[Config] Failed to bind section '{}' to {}: {}, using defaults",
434                        section,
435                        std::any::type_name::<T>(),
436                        e
437                    );
438                    T::default()
439                })
440            })
441            .unwrap_or_default()
442    }
443}
444
445/// Bind the entire config JSON to a type (for root-level deserialization).
446pub fn bind_root<T: for<'de> Deserialize<'de> + Default>(config: &serde_json::Value) -> T {
447    serde_json::from_value(config.clone()).unwrap_or_else(|e| {
448        tracing::warn!(
449            "[Config] Failed to bind root config to {}: {}, using defaults",
450            std::any::type_name::<T>(),
451            e
452        );
453        T::default()
454    })
455}
456
457// ---------------------------------------------------------------------------
458// Internal helpers
459// ---------------------------------------------------------------------------
460
461fn read_json_file(path: impl AsRef<Path>) -> Option<serde_json::Value> {
462    let path = path.as_ref();
463
464    /// Try to read and parse a JSON file at the given path.
465    fn try_read(path: &Path) -> Option<serde_json::Value> {
466        let content = std::fs::read_to_string(path).ok()?;
467        serde_json::from_str(&content).ok()
468    }
469
470    // 1. 应用基准目录(exe 同级 / cwd / 上溯统一由 app_base 处理)。
471    //    部署与开发期均能定位到 appsettings.json 所在目录。
472    let base = crate::paths::app_base();
473    let candidate = base.join(path);
474    if let Some(value) = try_read(&candidate) {
475        return Some(value);
476    }
477
478    // 2. Try as-is (relative to current working directory) — 兜底
479    if let Some(value) = try_read(path) {
480        return Some(value);
481    }
482
483    None
484}
485
486fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
487    match (base, overlay) {
488        (serde_json::Value::Object(a), serde_json::Value::Object(b)) => {
489            for (k, v) in b {
490                merge_json(a.entry(k).or_insert(serde_json::Value::Null), v);
491            }
492        }
493        (a, b) => *a = b,
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn config_env_jwt_and_app_override_precedence() {
503        std::env::set_var("JWT_SECRET", "env-jwt-secret-that-is-long-enough-32");
504        let mut config = serde_json::json!({ "Jwt": { "Secret": "from-file" } });
505        apply_well_known_env_overrides(&mut config);
506        assert_eq!(
507            config["Jwt"]["Secret"],
508            "env-jwt-secret-that-is-long-enough-32"
509        );
510
511        std::env::set_var(
512            "APP__Jwt__Secret",
513            "from-app-override-secret-32chars-minimum",
514        );
515        apply_env_overrides(&mut config);
516        std::env::remove_var("JWT_SECRET");
517        std::env::remove_var("APP__Jwt__Secret");
518        assert_eq!(
519            config["Jwt"]["Secret"],
520            "from-app-override-secret-32chars-minimum"
521        );
522    }
523
524    #[test]
525    fn cors_section_binds_pascal_case_from_appsettings() {
526        // Regression: without rename_all=PascalCase, Origins fell back to ["*"]
527        // and Production fail-fast panicked even when Production overlay set explicit origins.
528        let json = serde_json::json!({
529            "Cors": {
530                "Origins": ["https://www.lusida.net", "https://lusida.net"],
531                "Methods": ["GET", "POST"],
532                "Headers": ["Content-Type", "Authorization"],
533                "AllowCredentials": true,
534                "MaxAge": 3600
535            }
536        });
537        let opts: AppOptions = bind_root(&json);
538        assert_eq!(
539            opts.cors.origins,
540            vec![
541                "https://www.lusida.net".to_string(),
542                "https://lusida.net".to_string()
543            ]
544        );
545        assert_eq!(
546            opts.cors.methods,
547            vec!["GET".to_string(), "POST".to_string()]
548        );
549        assert!(opts.cors.allow_credentials);
550        assert_eq!(opts.cors.max_age, 3600);
551        assert!(!opts.cors.origins.iter().any(|o| o == "*"));
552    }
553
554    #[test]
555    fn culture_section_binds_default_and_falls_back_to_en() {
556        let json = serde_json::json!({ "Culture": { "Default": "zh-CN" } });
557        let opts: AppOptions = bind_root(&json);
558        assert_eq!(opts.culture.default, "zh-CN");
559
560        let opts: AppOptions = bind_root(&serde_json::json!({}));
561        assert_eq!(opts.culture.default, "en");
562    }
563
564    #[test]
565    fn production_overlay_merge_keeps_explicit_cors_origins() {
566        let mut base = serde_json::json!({
567            "App": { "Urls": ["http://localhost:5000"] },
568            "Cors": { "Origins": ["*"] },
569            "Jwt": { "Secret": "docbit-dev-secret-change-in-production" }
570        });
571        let overlay = serde_json::json!({
572            "App": { "Urls": ["http://0.0.0.0:8100"] },
573            "Cors": {
574                "Origins": [
575                    "https://www.lusida.net",
576                    "https://lusida.net"
577                ]
578            },
579            "Jwt": { "Secret": "" }
580        });
581        merge_json(&mut base, overlay);
582        let opts: AppOptions = bind_root(&base);
583        assert_eq!(opts.app.urls, vec!["http://0.0.0.0:8100".to_string()]);
584        assert_eq!(
585            opts.cors.origins,
586            vec![
587                "https://www.lusida.net".to_string(),
588                "https://lusida.net".to_string()
589            ]
590        );
591        assert!(opts.jwt.secret.is_empty());
592    }
593}