Skip to main content

yggdrasil/api/settings/
security.rs

1// 与 posts 模块一致:Dioxus `#[server]` 宏触发 deprecated/unit/too_many_arguments
2// 提示,按项目惯例放行(限流/运行器等配置项天然参数多)。
3#![allow(clippy::unused_unit, deprecated, clippy::too_many_arguments)]
4
5use dioxus::prelude::*;
6
7#[cfg(feature = "server")]
8use crate::api::auth::get_current_admin_user;
9#[cfg(feature = "server")]
10use crate::api::error::AppError;
11#[cfg(feature = "server")]
12use crate::db::pool::get_conn;
13use crate::models::settings::SecuritySettings;
14// 仅 server 构建的函数体引用(WASM 端 server fn 体被宏剥离)。
15#[cfg(feature = "server")]
16use crate::cache::invalidate_security_settings;
17
18// ============================================================================
19// 安全配置(即时生效)
20// ============================================================================
21
22/// 启动时用 `APP_BASE_URL` / `COOKIE_SECURE` / `TRUSTED_PROXY_COUNT` /
23/// `MAX_SESSIONS_PER_USER` 环境变量播种安全配置。
24///
25/// 语义与 [`seed_backup_settings_from_env`][crate::api::settings::seed_backup_settings_from_env] 一致:仅当对应 settings 键**不存在**时
26/// 插入(首次部署),之后以「站点配置 → 安全」面板写入的 DB 值为准,重启不被
27/// env 覆盖。单个变量非法只告警跳过,不影响其他变量与启动。
28#[cfg(feature = "server")]
29pub(crate) async fn seed_security_settings_from_env(
30    client: &tokio_postgres::Client,
31) -> Result<(), AppError> {
32    let mut seeds: Vec<(&'static str, String)> = Vec::new();
33
34    if let Ok(v) = std::env::var("APP_BASE_URL") {
35        let url = crate::models::settings::SecuritySettings::normalize_app_base_url(&v);
36        if !url.is_empty() {
37            seeds.push(("security_app_base_url", url));
38        }
39    }
40    if let Ok(v) = std::env::var("COOKIE_SECURE") {
41        match v.trim().parse::<bool>() {
42            Ok(b) => seeds.push(("security_cookie_secure", b.to_string())),
43            Err(_) => tracing::warn!("COOKIE_SECURE={v:?} 非法(期望 true/false),跳过"),
44        }
45    }
46    if let Ok(v) = std::env::var("TRUSTED_PROXY_COUNT") {
47        match v.trim().parse::<u32>() {
48            Ok(n) => seeds.push((
49                "security_trusted_proxy_count",
50                crate::models::settings::SecuritySettings::clamp_trusted_proxy_count(n).to_string(),
51            )),
52            Err(_) => tracing::warn!("TRUSTED_PROXY_COUNT={v:?} 非法(期望非负整数),跳过"),
53        }
54    }
55    if let Ok(v) = std::env::var("MAX_SESSIONS_PER_USER") {
56        match v.trim().parse::<u32>() {
57            Ok(n) => seeds.push((
58                "security_max_sessions_per_user",
59                crate::models::settings::SecuritySettings::clamp_max_sessions(n).to_string(),
60            )),
61            Err(_) => tracing::warn!("MAX_SESSIONS_PER_USER={v:?} 非法(期望正整数),跳过"),
62        }
63    }
64
65    for (key, value) in seeds {
66        client
67            .execute(
68                "INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING",
69                &[&key, &value],
70            )
71            .await
72            .map_err(AppError::query)?;
73        tracing::info!("安全配置已从环境变量播种: {key}={value}(仅键缺失时生效)");
74    }
75    Ok(())
76}
77
78/// 从 settings 表读取安全配置(缺键回退默认值)。
79///
80/// 供面板 `get_security_settings` 与运行时读取点(CSRF / cookie / 真实 IP /
81/// 登录会话上限)共用:先查 moka 缓存,未命中查 DB 并回填。
82#[cfg(feature = "server")]
83pub(crate) async fn load_security_settings(
84    client: &tokio_postgres::Client,
85) -> Result<crate::models::settings::SecuritySettings, AppError> {
86    async fn read_key(
87        client: &tokio_postgres::Client,
88        key: &str,
89    ) -> Result<Option<String>, AppError> {
90        let row = client
91            .query_opt("SELECT value FROM settings WHERE key = $1", &[&key])
92            .await
93            .map_err(AppError::query)?;
94        Ok(row.map(|r| r.get::<_, String>("value")))
95    }
96
97    let app_base_url = read_key(client, "security_app_base_url")
98        .await?
99        .map(|v| crate::models::settings::SecuritySettings::normalize_app_base_url(&v))
100        .unwrap_or_default();
101    let cookie_secure = read_key(client, "security_cookie_secure")
102        .await?
103        .and_then(|v| v.parse().ok())
104        .unwrap_or(crate::models::settings::DEFAULT_COOKIE_SECURE);
105    let trusted_proxy_count = read_key(client, "security_trusted_proxy_count")
106        .await?
107        .and_then(|v| v.parse().ok())
108        .map(crate::models::settings::SecuritySettings::clamp_trusted_proxy_count)
109        .unwrap_or(crate::models::settings::DEFAULT_TRUSTED_PROXY_COUNT);
110    let max_sessions_per_user = read_key(client, "security_max_sessions_per_user")
111        .await?
112        .and_then(|v| v.parse().ok())
113        .map(crate::models::settings::SecuritySettings::clamp_max_sessions)
114        .unwrap_or(crate::models::settings::DEFAULT_MAX_SESSIONS_PER_USER);
115
116    Ok(crate::models::settings::SecuritySettings {
117        app_base_url,
118        cookie_secure,
119        trusted_proxy_count,
120        max_sessions_per_user,
121    })
122}
123
124/// 运行时读取安全配置:先查 moka 缓存,未命中查 DB 并回填,全失败回退默认值。
125///
126/// 供运行时读取点(CSRF / cookie / 真实 IP / 登录会话上限)调用,
127/// 替代原来的 `std::env::var` 每请求读取。
128#[cfg(feature = "server")]
129pub(crate) async fn runtime_security_settings() -> crate::models::settings::SecuritySettings {
130    if let Some(s) = crate::cache::get_security_settings().await {
131        return s;
132    }
133    let fallback = crate::models::settings::SecuritySettings::default();
134    // 无 DATABASE_URL 时(如单元测试环境)直接回退默认值,
135    // 避免触发 DB_POOL LazyLock 的防御性 panic。
136    if std::env::var("DATABASE_URL").is_err() {
137        return fallback;
138    }
139    match get_conn().await {
140        Ok(client) => match load_security_settings(&client).await {
141            Ok(s) => {
142                crate::cache::set_security_settings(s.clone()).await;
143                s
144            }
145            Err(e) => {
146                tracing::warn!("读取安全配置失败,回退默认值:{e:?}");
147                fallback
148            }
149        },
150        Err(e) => {
151            tracing::warn!("获取连接读取安全配置失败,回退默认值:{e:?}");
152            fallback
153        }
154    }
155}
156
157/// 读取安全配置(面板用)。
158#[server(GetSecuritySettings, "/api")]
159pub async fn get_security_settings() -> Result<SecuritySettings, ServerFnError> {
160    let _user = get_current_admin_user().await?;
161
162    #[cfg(feature = "server")]
163    {
164        let client = get_conn().await.map_err(AppError::db_conn)?;
165        let s = load_security_settings(&client)
166            .await
167            .map_err(ServerFnError::from)?;
168        crate::cache::set_security_settings(s.clone()).await;
169        Ok(s)
170    }
171
172    #[cfg(not(feature = "server"))]
173    {
174        Ok(SecuritySettings::default())
175    }
176}
177
178/// 更新安全配置。
179///
180/// 所有字段会被规范化/clamp 后写入。写入后失效 moka 缓存,数秒内全链路生效。
181#[server(UpdateSecuritySettings, "/api")]
182pub async fn update_security_settings(
183    app_base_url: String,
184    cookie_secure: bool,
185    trusted_proxy_count: u32,
186    max_sessions_per_user: u32,
187) -> Result<SecuritySettings, ServerFnError> {
188    let _user = get_current_admin_user().await?;
189
190    let app_base_url = SecuritySettings::normalize_app_base_url(&app_base_url);
191    let trusted_proxy_count = SecuritySettings::clamp_trusted_proxy_count(trusted_proxy_count);
192    let max_sessions_per_user = SecuritySettings::clamp_max_sessions(max_sessions_per_user);
193
194    #[cfg(feature = "server")]
195    {
196        let client = get_conn().await.map_err(AppError::db_conn)?;
197
198        for (key, value) in [
199            ("security_app_base_url", app_base_url.clone()),
200            ("security_cookie_secure", cookie_secure.to_string()),
201            (
202                "security_trusted_proxy_count",
203                trusted_proxy_count.to_string(),
204            ),
205            (
206                "security_max_sessions_per_user",
207                max_sessions_per_user.to_string(),
208            ),
209        ] {
210            client
211                .execute(
212                    "INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
213                     ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
214                    &[&key, &value],
215                )
216                .await
217                .map_err(AppError::query)?;
218        }
219
220        invalidate_security_settings();
221        tracing::info!(
222            "Security settings updated: app_base_url={:?}, cookie_secure={}, proxy_count={}, max_sessions={}",
223            app_base_url,
224            cookie_secure,
225            trusted_proxy_count,
226            max_sessions_per_user
227        );
228    }
229
230    Ok(SecuritySettings {
231        app_base_url,
232        cookie_secure,
233        trusted_proxy_count,
234        max_sessions_per_user,
235    })
236}