Skip to main content

yggdrasil/api/settings/
security.rs

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