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