Skip to main content

yggdrasil/api/
profile.rs

1//! 个人信息(当前登录账号的资料与密码)的 Leptos server function。
2//!
3//! 支撑 `/admin/profile` 页面:
4//! - `get_profile`:读取当前用户公开信息 + `ADMIN_*` 环境变量是否激活
5//!   (激活时邮箱/密码会在重启后被 env 覆盖,页面据此显示提示横幅);
6//! - `update_profile`:修改邮箱 / 显示名称 / 头像(username 为登录凭据,只读);
7//! - `change_password`:校验当前密码后改密,bump `session_generation` 使
8//!   其他设备会话全部失效,同时刷新当前 token 的会话缓存保留本端登录;
9//!   可选同时吊销本人全部 MCP 令牌(令牌是独立凭据,默认不随改密失效)。
10//!
11//! 资料字段(display_name/avatar_url/email)缓存在 SESSION_CACHE 的
12//! SessionUser 里,故写操作成功后必须同步刷新当前 token 的缓存项,
13//! 否则 `get_current_user` 会持续返回旧值直到缓存 TTL 过期。
14
15#![allow(clippy::unused_unit, deprecated)]
16
17use crate::api::error::ApiError;
18use leptos::prelude::*;
19
20#[cfg(feature = "server")]
21use crate::api::auth::{
22    admin_env_active, get_current_admin_user, validate_email, validate_password,
23};
24#[cfg(feature = "server")]
25use crate::api::error::AppError;
26#[cfg(feature = "server")]
27use crate::auth::session::get_session_from_ctx;
28#[cfg(feature = "server")]
29use crate::auth::{password, session};
30#[cfg(feature = "server")]
31use crate::db::pool::get_conn;
32use crate::models::user::PublicUser;
33#[cfg(feature = "server")]
34use crate::models::user::SessionUser;
35
36/// 个人信息查询响应。
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38pub struct GetProfileResponse {
39    /// 当前登录用户的公开信息。
40    pub user: PublicUser,
41    /// `ADMIN_*` 环境变量是否激活(激活时邮箱/密码重启后以 env 为准)。
42    pub admin_env_active: bool,
43}
44
45/// 资料更新响应。
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47pub struct UpdateProfileResponse {
48    /// 操作是否成功。
49    pub success: bool,
50    /// 提示信息。
51    pub message: String,
52    /// 更新后的用户公开信息(成功时返回,供客户端刷新全局上下文)。
53    pub user: Option<PublicUser>,
54}
55
56/// 修改密码响应。
57#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
58pub struct ChangePasswordResponse {
59    /// 操作是否成功。
60    pub success: bool,
61    /// 提示信息。
62    pub message: String,
63}
64
65#[cfg(feature = "server")]
66/// 归一化显示名称:trim 后为空 → None;超过 50 字符报错。
67fn normalize_display_name(input: Option<String>) -> Result<Option<String>, String> {
68    let trimmed = input.unwrap_or_default().trim().to_string();
69    if trimmed.is_empty() {
70        return Ok(None);
71    }
72    if trimmed.chars().count() > 50 {
73        return Err("显示名称长度不能超过 50 个字符".to_string());
74    }
75    Ok(Some(trimmed))
76}
77
78#[cfg(feature = "server")]
79/// 归一化头像 URL:trim 后为空 → None;否则必须为 http(s) 链接或
80/// 安全的 `/uploads/` 素材路径(与友链头像共用 [`crate::api::friends::is_local_asset_url`]),
81/// 上限 512 字符。
82fn normalize_avatar_url(input: Option<String>) -> Result<Option<String>, String> {
83    use crate::api::friends::is_local_asset_url;
84
85    let trimmed = input.unwrap_or_default().trim().to_string();
86    if trimmed.is_empty() {
87        return Ok(None);
88    }
89    let is_http_url = trimmed.starts_with("http://") || trimmed.starts_with("https://");
90    if !is_http_url && !is_local_asset_url(&trimmed) {
91        return Err("头像必须为 http(s) 链接或 /uploads/ 素材路径".to_string());
92    }
93    if trimmed.chars().count() > 512 {
94        return Err("头像 URL 过长(上限 512 字符)".to_string());
95    }
96    Ok(Some(trimmed))
97}
98
99/// 获取当前登录用户的资料。
100///
101/// Leptos server function,注册在 `/api` 路径下。
102#[server(GetProfile, endpoint = "get_profile", input = leptos::server_fn::codec::Json)]
103pub async fn get_profile() -> Result<GetProfileResponse, ApiError> {
104    #[cfg(feature = "server")]
105    {
106        let user = get_current_admin_user().await?;
107        Ok(GetProfileResponse {
108            admin_env_active: admin_env_active(),
109            user: user.into(),
110        })
111    }
112    #[cfg(not(feature = "server"))]
113    unreachable!()
114}
115
116/// 更新当前登录用户的资料(邮箱 / 显示名称 / 头像)。
117///
118/// 校验失败或邮箱被占用返回 `Ok(UpdateProfileResponse{success:false,...})`,
119/// 与注册/登录的「业务拒绝走 Ok」约定一致。
120/// Leptos server function,注册在 `/api` 路径下。
121#[server(UpdateProfile, endpoint = "update_profile", input = leptos::server_fn::codec::Json)]
122pub async fn update_profile(
123    email: String,
124    display_name: Option<String>,
125    avatar_url: Option<String>,
126) -> Result<UpdateProfileResponse, ApiError> {
127    #[cfg(feature = "server")]
128    {
129        let fail = |message: String| UpdateProfileResponse {
130            success: false,
131            message,
132            user: None,
133        };
134
135        let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
136        let user = get_current_admin_user().await?;
137
138        if let Err(e) = validate_email(&email) {
139            return Ok(fail(e));
140        }
141        let display_name = match normalize_display_name(display_name) {
142            Ok(v) => v,
143            Err(e) => return Ok(fail(e)),
144        };
145        let avatar_url = match normalize_avatar_url(avatar_url) {
146            Ok(v) => v,
147            Err(e) => return Ok(fail(e)),
148        };
149
150        let mut client = get_conn().await.map_err(AppError::db_conn)?;
151
152        // 邮箱唯一性预检(DB 唯一索引仍是最终兜底)。
153        let email_taken: bool = client
154            .query_one(
155                "SELECT EXISTS (SELECT 1 FROM users WHERE email = $1 AND id <> $2)",
156                &[&email, &user.id],
157            )
158            .await
159            .map_err(AppError::query)?
160            .get(0);
161        if email_taken {
162            return Ok(fail("邮箱已被占用".to_string()));
163        }
164
165        // 资料与头像引用在同一事务写入;头像素材先加锁核对,防止与素材删除竞态。
166        let tx = client.transaction().await.map_err(AppError::tx)?;
167        let current: Option<String> = tx
168            .query_one(
169                "SELECT avatar_url FROM users WHERE id = $1 FOR UPDATE",
170                &[&user.id],
171            )
172            .await
173            .map_err(AppError::query)?
174            .get(0);
175        let paths: Vec<String> = crate::api::assets::refs::avatar_asset_path(avatar_url.as_deref())
176            .into_iter()
177            .collect();
178        let locked = crate::api::assets::refs::lock_assets_by_path(&tx, &paths).await?;
179        // 未改动的旧头像即使已失效也照常保存其余资料,只是不建引用行。
180        if locked.missing && avatar_url != current {
181            return Ok(fail(
182                crate::api::assets::refs::MISSING_AVATAR_ASSET.to_string(),
183            ));
184        }
185        tx.execute(
186            "UPDATE users SET email = $2, display_name = $3, avatar_url = $4 WHERE id = $1",
187            &[&user.id, &email, &display_name, &avatar_url],
188        )
189        .await
190        .map_err(AppError::tx)?;
191        crate::api::assets::refs::sync_user_avatar_ref(&tx, user.id, locked.ids.first().copied())
192            .await?;
193        tx.commit().await.map_err(AppError::tx)?;
194
195        // 资料字段缓存在 SESSION_CACHE 的 SessionUser 里,同步刷新当前 token
196        // 的缓存项,避免 get_current_user 在缓存 TTL 内返回旧值。同用户的其他
197        // 会话(其他设备)在 TTL 内可能持有旧展示字段——纯展示数据,可接受。
198        let updated = SessionUser {
199            email,
200            display_name,
201            avatar_url,
202            ..user
203        };
204        crate::cache::set_session_user(&session::hash_token(&token), updated.clone()).await;
205
206        // 评论列表经 LEFT JOIN users 实时解析显示名/头像:资料变更后失效全部
207        // 评论缓存,否则旧显示名/头像会残留到缓存 TTL 过期。
208        crate::cache::invalidate_all_comments();
209
210        Ok(UpdateProfileResponse {
211            success: true,
212            message: "资料已保存".to_string(),
213            user: Some(updated.into()),
214        })
215    }
216    #[cfg(not(feature = "server"))]
217    unreachable!()
218}
219
220/// 修改当前登录用户的密码。
221///
222/// 校验当前密码 → 更新哈希并 bump `session_generation`(其他设备会话全部
223/// 失效)→ 删除该用户除当前 token 外的所有会话 → 刷新当前 token 的会话缓存
224/// (写入新世代号),本端保持登录。Argon2 计算走 `spawn_blocking`。
225///
226/// MCP 令牌与会话不同:它是有独立过期与吊销的长期凭据,默认不随改密失效,
227/// 避免每次改密都打断 AI 客户端。`revoke_mcp_tokens` 为真时在同一事务内
228/// 吊销本人全部未吊销令牌,用于怀疑凭据泄露(泄露期间可能被签出令牌)的场景。
229/// Leptos server function,注册在 `/api` 路径下。
230#[server(ChangePassword, endpoint = "change_password", input = leptos::server_fn::codec::Json)]
231pub async fn change_password(
232    current_password: String,
233    new_password: String,
234    revoke_mcp_tokens: bool,
235) -> Result<ChangePasswordResponse, ApiError> {
236    #[cfg(feature = "server")]
237    {
238        // Argon2 校验是高价操作,与登录/注册同挂严格限流。
239        if let Some(headers) = crate::request_ctx::request_headers() {
240            let ip = crate::api::rate_limit::get_client_ip(&headers).await;
241            if let Err(msg) = crate::api::rate_limit::check_strict_limit(&ip) {
242                return Ok(ChangePasswordResponse {
243                    success: false,
244                    message: msg,
245                });
246            }
247        }
248
249        let fail = |message: &str| ChangePasswordResponse {
250            success: false,
251            message: message.to_string(),
252        };
253
254        let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
255        let user = get_current_admin_user().await?;
256
257        if let Err(e) = validate_password(&new_password) {
258            return Ok(fail(&e));
259        }
260        if current_password == new_password {
261            return Ok(fail("新密码不能与当前密码相同"));
262        }
263
264        let mut client = get_conn().await.map_err(AppError::db_conn)?;
265
266        let password_hash: String = client
267            .query_one("SELECT password_hash FROM users WHERE id = $1", &[&user.id])
268            .await
269            .map_err(AppError::query)?
270            .get(0);
271
272        // Argon2 是 memory-hard 计算,必须在 spawn_blocking 中执行。
273        let current_for_verify = current_password.clone();
274        let hash_for_verify = password_hash.clone();
275        let valid = tokio::task::spawn_blocking(move || {
276            password::verify_password(&current_for_verify, &hash_for_verify)
277        })
278        .await
279        .map_err(|_| AppError::Internal("密码处理任务失败"))?
280        .map_err(|_| AppError::Internal("密码处理失败"))?;
281        if !valid {
282            return Ok(fail("当前密码不正确"));
283        }
284
285        let new_for_hash = new_password.clone();
286        let new_hash = tokio::task::spawn_blocking(move || password::hash_password(&new_for_hash))
287            .await
288            .map_err(|_| AppError::Internal("密码处理任务失败"))?
289            .map_err(|_| AppError::Internal("密码处理失败"))?;
290
291        let token_hash = session::hash_token(&token);
292
293        // 事务内:更新哈希 + bump 世代号 + 删除其他会话。当前会话行保留,
294        // 靠下方缓存刷新写入新世代号维持有效。WHERE 复核已验证的旧哈希:
295        // 校验到提交之间若有并发改密,旧密码不得覆盖新凭据。
296        let tx = client.transaction().await.map_err(AppError::query)?;
297        let Some(row) = tx
298            .query_opt(
299                "UPDATE users SET password_hash = $2, session_generation = session_generation + 1 \
300                 WHERE id = $1 AND password_hash = $3 RETURNING session_generation",
301                &[&user.id, &new_hash, &password_hash],
302            )
303            .await
304            .map_err(AppError::query)?
305        else {
306            return Ok(fail("当前密码不正确"));
307        };
308        let new_generation: i32 = row.get(0);
309        // 当前会话的世代快照同步到新值,其余会话直接删除。会话行已被并发
310        // 改密删除时整体回滚,避免下方缓存刷新复活已吊销的 token。
311        let kept = tx
312            .execute(
313                "UPDATE sessions SET generation = $3 WHERE user_id = $1 AND token_hash = $2",
314                &[&user.id, &token_hash, &new_generation],
315            )
316            .await
317            .map_err(AppError::query)?;
318        if kept == 0 {
319            return Err(AppError::Unauthorized("未登录").into());
320        }
321        tx.execute(
322            "DELETE FROM sessions WHERE user_id = $1 AND token_hash <> $2",
323            &[&user.id, &token_hash],
324        )
325        .await
326        .map_err(AppError::query)?;
327        // MCP 鉴权每次请求都查库(resolve_principal 无缓存),提交即生效。
328        let revoked = if revoke_mcp_tokens {
329            tx.execute(
330                "UPDATE mcp_tokens SET revoked_at = NOW() \
331                 WHERE user_id = $1 AND revoked_at IS NULL",
332                &[&user.id],
333            )
334            .await
335            .map_err(AppError::query)?
336        } else {
337            0
338        };
339        tx.commit().await.map_err(AppError::query)?;
340
341        // get_user_by_token 每次命中缓存都会回查 users.session_generation,
342        // 必须把当前 token 的缓存项刷到新世代号,否则本端会被视为已登出。
343        let refreshed = SessionUser {
344            session_generation: new_generation,
345            ..user
346        };
347        crate::cache::set_session_user(&token_hash, refreshed).await;
348
349        let message = if revoke_mcp_tokens {
350            format!("密码已修改,其他设备已退出登录,已吊销 {revoked} 个 MCP 令牌")
351        } else {
352            "密码已修改,其他设备已退出登录".to_string()
353        };
354        Ok(ChangePasswordResponse {
355            success: true,
356            message,
357        })
358    }
359    #[cfg(not(feature = "server"))]
360    unreachable!()
361}
362
363#[cfg(all(test, feature = "server"))]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn display_name_normalization() {
369        assert_eq!(normalize_display_name(None), Ok(None));
370        assert_eq!(normalize_display_name(Some("   ".to_string())), Ok(None));
371        assert_eq!(
372            normalize_display_name(Some("  Alice ".to_string())),
373            Ok(Some("Alice".to_string()))
374        );
375        // 恰好 50 字符(含多字节)通过。
376        let fifty = "名".repeat(50);
377        assert_eq!(normalize_display_name(Some(fifty.clone())), Ok(Some(fifty)));
378        // 51 字符拒绝。
379        let over = "a".repeat(51);
380        assert!(normalize_display_name(Some(over)).is_err());
381    }
382
383    #[test]
384    fn avatar_url_normalization() {
385        assert_eq!(normalize_avatar_url(None), Ok(None));
386        assert_eq!(normalize_avatar_url(Some("  ".to_string())), Ok(None));
387        assert_eq!(
388            normalize_avatar_url(Some("/uploads/2026/08/a.webp".to_string())),
389            Ok(Some("/uploads/2026/08/a.webp".to_string()))
390        );
391        assert_eq!(
392            normalize_avatar_url(Some("https://example.com/a.png".to_string())),
393            Ok(Some("https://example.com/a.png".to_string()))
394        );
395        // 非 http(s)/非 /uploads/ 一律拒绝(含 javascript: 等伪协议)。
396        assert!(normalize_avatar_url(Some("javascript:alert(1)".to_string())).is_err());
397        assert!(normalize_avatar_url(Some("ftp://example.com/a".to_string())).is_err());
398        assert!(normalize_avatar_url(Some("/etc/passwd".to_string())).is_err());
399        // 超长拒绝。
400        let over = format!("/uploads/{}", "a".repeat(512));
401        assert!(normalize_avatar_url(Some(over)).is_err());
402    }
403
404    #[test]
405    fn avatar_url_rejects_unsafe_uploads_paths() {
406        for bad in [
407            "/uploads/../../x",
408            "/uploads/",
409            "/uploads//x",
410            "/uploads/a\0b",
411        ] {
412            assert!(
413                normalize_avatar_url(Some(bad.to_string())).is_err(),
414                "{bad:?} 应被拒绝"
415            );
416        }
417        assert_eq!(
418            normalize_avatar_url(Some(" /uploads/2026/08/a.webp ".to_string())),
419            Ok(Some("/uploads/2026/08/a.webp".to_string()))
420        );
421    }
422}