Skip to main content

yggdrasil/api/
profile.rs

1//! 个人信息(当前登录账号的资料与密码)的 Dioxus 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//!
10//! 资料字段(display_name/avatar_url/email)缓存在 SESSION_CACHE 的
11//! SessionUser 里,故写操作成功后必须同步刷新当前 token 的缓存项,
12//! 否则 `get_current_user` 会持续返回旧值直到缓存 TTL 过期。
13
14#![allow(clippy::unused_unit, deprecated)]
15
16use dioxus::prelude::*;
17
18#[cfg(feature = "server")]
19use crate::api::auth::{
20    admin_env_active, get_current_admin_user, validate_email, validate_password,
21};
22#[cfg(feature = "server")]
23use crate::api::error::AppError;
24#[cfg(feature = "server")]
25use crate::auth::session::get_session_from_ctx;
26#[cfg(feature = "server")]
27use crate::auth::{password, session};
28#[cfg(feature = "server")]
29use crate::db::pool::get_conn;
30use crate::models::user::PublicUser;
31#[cfg(feature = "server")]
32use crate::models::user::SessionUser;
33
34/// 个人信息查询响应。
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
36pub struct GetProfileResponse {
37    /// 当前登录用户的公开信息。
38    pub user: PublicUser,
39    /// `ADMIN_*` 环境变量是否激活(激活时邮箱/密码重启后以 env 为准)。
40    pub admin_env_active: bool,
41}
42
43/// 资料更新响应。
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct UpdateProfileResponse {
46    /// 操作是否成功。
47    pub success: bool,
48    /// 提示信息。
49    pub message: String,
50    /// 更新后的用户公开信息(成功时返回,供客户端刷新全局上下文)。
51    pub user: Option<PublicUser>,
52}
53
54/// 修改密码响应。
55#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56pub struct ChangePasswordResponse {
57    /// 操作是否成功。
58    pub success: bool,
59    /// 提示信息。
60    pub message: String,
61}
62
63#[cfg(feature = "server")]
64/// 归一化显示名称:trim 后为空 → None;超过 50 字符报错。
65fn normalize_display_name(input: Option<String>) -> Result<Option<String>, String> {
66    let trimmed = input.unwrap_or_default().trim().to_string();
67    if trimmed.is_empty() {
68        return Ok(None);
69    }
70    if trimmed.chars().count() > 50 {
71        return Err("显示名称长度不能超过 50 个字符".to_string());
72    }
73    Ok(Some(trimmed))
74}
75
76#[cfg(feature = "server")]
77/// 归一化头像 URL:trim 后为空 → None;否则必须为 http(s) 链接或
78/// 安全的 `/uploads/` 素材路径(与友链头像同一规则),上限 512 字符。
79fn normalize_avatar_url(input: Option<String>) -> Result<Option<String>, String> {
80    let trimmed = input.unwrap_or_default().trim().to_string();
81    if trimmed.is_empty() {
82        return Ok(None);
83    }
84    let is_http_url = trimmed.starts_with("http://") || trimmed.starts_with("https://");
85    let is_uploads_path = trimmed.starts_with("/uploads/");
86    if !is_http_url && !is_uploads_path {
87        return Err("头像必须为 http(s) 链接或 /uploads/ 素材路径".to_string());
88    }
89    if trimmed.chars().count() > 512 {
90        return Err("头像 URL 过长(上限 512 字符)".to_string());
91    }
92    Ok(Some(trimmed))
93}
94
95/// 获取当前登录用户的资料。
96///
97/// Dioxus server function,注册在 `/api` 路径下。
98#[server(GetProfile, "/api")]
99pub async fn get_profile() -> Result<GetProfileResponse, ServerFnError> {
100    #[cfg(feature = "server")]
101    {
102        let user = get_current_admin_user().await?;
103        Ok(GetProfileResponse {
104            admin_env_active: admin_env_active(),
105            user: user.into(),
106        })
107    }
108    #[cfg(not(feature = "server"))]
109    unreachable!()
110}
111
112/// 更新当前登录用户的资料(邮箱 / 显示名称 / 头像)。
113///
114/// 校验失败或邮箱被占用返回 `Ok(UpdateProfileResponse{success:false,...})`,
115/// 与注册/登录的「业务拒绝走 Ok」约定一致。
116/// Dioxus server function,注册在 `/api` 路径下。
117#[server(UpdateProfile, "/api")]
118pub async fn update_profile(
119    email: String,
120    display_name: Option<String>,
121    avatar_url: Option<String>,
122) -> Result<UpdateProfileResponse, ServerFnError> {
123    #[cfg(feature = "server")]
124    {
125        let fail = |message: String| UpdateProfileResponse {
126            success: false,
127            message,
128            user: None,
129        };
130
131        let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
132        let user = get_current_admin_user().await?;
133
134        if let Err(e) = validate_email(&email) {
135            return Ok(fail(e));
136        }
137        let display_name = match normalize_display_name(display_name) {
138            Ok(v) => v,
139            Err(e) => return Ok(fail(e)),
140        };
141        let avatar_url = match normalize_avatar_url(avatar_url) {
142            Ok(v) => v,
143            Err(e) => return Ok(fail(e)),
144        };
145
146        let client = get_conn().await.map_err(AppError::db_conn)?;
147
148        // 邮箱唯一性预检(DB 唯一索引仍是最终兜底)。
149        let email_taken: bool = client
150            .query_one(
151                "SELECT EXISTS (SELECT 1 FROM users WHERE email = $1 AND id <> $2)",
152                &[&email, &user.id],
153            )
154            .await
155            .map_err(AppError::query)?
156            .get(0);
157        if email_taken {
158            return Ok(fail("邮箱已被占用".to_string()));
159        }
160
161        client
162            .execute(
163                "UPDATE users SET email = $2, display_name = $3, avatar_url = $4 WHERE id = $1",
164                &[&user.id, &email, &display_name, &avatar_url],
165            )
166            .await
167            .map_err(AppError::query)?;
168
169        // 资料字段缓存在 SESSION_CACHE 的 SessionUser 里,同步刷新当前 token
170        // 的缓存项,避免 get_current_user 在缓存 TTL 内返回旧值。同用户的其他
171        // 会话(其他设备)在 TTL 内可能持有旧展示字段——纯展示数据,可接受。
172        let updated = SessionUser {
173            email,
174            display_name,
175            avatar_url,
176            ..user
177        };
178        crate::cache::set_session_user(&session::hash_token(&token), updated.clone()).await;
179
180        // 评论列表经 LEFT JOIN users 实时解析显示名/头像:资料变更后失效全部
181        // 评论缓存,否则旧显示名/头像会残留到缓存 TTL 过期。
182        crate::cache::invalidate_all_comments();
183
184        Ok(UpdateProfileResponse {
185            success: true,
186            message: "资料已保存".to_string(),
187            user: Some(updated.into()),
188        })
189    }
190    #[cfg(not(feature = "server"))]
191    unreachable!()
192}
193
194/// 修改当前登录用户的密码。
195///
196/// 校验当前密码 → 更新哈希并 bump `session_generation`(其他设备会话全部
197/// 失效)→ 删除该用户除当前 token 外的所有会话 → 刷新当前 token 的会话缓存
198/// (写入新世代号),本端保持登录。Argon2 计算走 `spawn_blocking`。
199/// Dioxus server function,注册在 `/api` 路径下。
200#[server(ChangePassword, "/api")]
201pub async fn change_password(
202    current_password: String,
203    new_password: String,
204) -> Result<ChangePasswordResponse, ServerFnError> {
205    #[cfg(feature = "server")]
206    {
207        // Argon2 校验是高价操作,与登录/注册同挂严格限流。
208        if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
209            let headers = ctx.parts_mut().headers.clone();
210            let ip = crate::api::rate_limit::get_client_ip(&headers).await;
211            if let Err(msg) = crate::api::rate_limit::check_strict_limit(&ip) {
212                return Ok(ChangePasswordResponse {
213                    success: false,
214                    message: msg,
215                });
216            }
217        }
218
219        let fail = |message: &str| ChangePasswordResponse {
220            success: false,
221            message: message.to_string(),
222        };
223
224        let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
225        let user = get_current_admin_user().await?;
226
227        if let Err(e) = validate_password(&new_password) {
228            return Ok(fail(&e));
229        }
230        if current_password == new_password {
231            return Ok(fail("新密码不能与当前密码相同"));
232        }
233
234        let mut client = get_conn().await.map_err(AppError::db_conn)?;
235
236        let password_hash: String = client
237            .query_one("SELECT password_hash FROM users WHERE id = $1", &[&user.id])
238            .await
239            .map_err(AppError::query)?
240            .get(0);
241
242        // Argon2 是 memory-hard 计算,必须在 spawn_blocking 中执行。
243        let current_for_verify = current_password.clone();
244        let hash_for_verify = password_hash.clone();
245        let valid = tokio::task::spawn_blocking(move || {
246            password::verify_password(&current_for_verify, &hash_for_verify)
247        })
248        .await
249        .map_err(|_| AppError::Internal("密码处理任务失败"))?
250        .map_err(|_| AppError::Internal("密码处理失败"))?;
251        if !valid {
252            return Ok(fail("当前密码不正确"));
253        }
254
255        let new_for_hash = new_password.clone();
256        let new_hash = tokio::task::spawn_blocking(move || password::hash_password(&new_for_hash))
257            .await
258            .map_err(|_| AppError::Internal("密码处理任务失败"))?
259            .map_err(|_| AppError::Internal("密码处理失败"))?;
260
261        let token_hash = session::hash_token(&token);
262
263        // 事务内:更新哈希 + bump 世代号 + 删除其他会话。当前会话行保留,
264        // 靠下方缓存刷新写入新世代号维持有效。
265        let tx = client.transaction().await.map_err(AppError::query)?;
266        let new_generation: i32 = tx
267            .query_one(
268                "UPDATE users SET password_hash = $2, session_generation = session_generation + 1 \
269                 WHERE id = $1 RETURNING session_generation",
270                &[&user.id, &new_hash],
271            )
272            .await
273            .map_err(AppError::query)?
274            .get(0);
275        tx.execute(
276            "DELETE FROM sessions WHERE user_id = $1 AND token_hash <> $2",
277            &[&user.id, &token_hash],
278        )
279        .await
280        .map_err(AppError::query)?;
281        tx.commit().await.map_err(AppError::query)?;
282
283        // get_user_by_token 每次命中缓存都会回查 users.session_generation,
284        // 必须把当前 token 的缓存项刷到新世代号,否则本端会被视为已登出。
285        let refreshed = SessionUser {
286            session_generation: new_generation,
287            ..user
288        };
289        crate::cache::set_session_user(&token_hash, refreshed).await;
290
291        Ok(ChangePasswordResponse {
292            success: true,
293            message: "密码已修改,其他设备已退出登录".to_string(),
294        })
295    }
296    #[cfg(not(feature = "server"))]
297    unreachable!()
298}
299
300#[cfg(all(test, feature = "server"))]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn display_name_normalization() {
306        assert_eq!(normalize_display_name(None), Ok(None));
307        assert_eq!(normalize_display_name(Some("   ".to_string())), Ok(None));
308        assert_eq!(
309            normalize_display_name(Some("  Alice ".to_string())),
310            Ok(Some("Alice".to_string()))
311        );
312        // 恰好 50 字符(含多字节)通过。
313        let fifty = "名".repeat(50);
314        assert_eq!(normalize_display_name(Some(fifty.clone())), Ok(Some(fifty)));
315        // 51 字符拒绝。
316        let over = "a".repeat(51);
317        assert!(normalize_display_name(Some(over)).is_err());
318    }
319
320    #[test]
321    fn avatar_url_normalization() {
322        assert_eq!(normalize_avatar_url(None), Ok(None));
323        assert_eq!(normalize_avatar_url(Some("  ".to_string())), Ok(None));
324        assert_eq!(
325            normalize_avatar_url(Some("/uploads/2026/08/a.webp".to_string())),
326            Ok(Some("/uploads/2026/08/a.webp".to_string()))
327        );
328        assert_eq!(
329            normalize_avatar_url(Some("https://example.com/a.png".to_string())),
330            Ok(Some("https://example.com/a.png".to_string()))
331        );
332        // 非 http(s)/非 /uploads/ 一律拒绝(含 javascript: 等伪协议)。
333        assert!(normalize_avatar_url(Some("javascript:alert(1)".to_string())).is_err());
334        assert!(normalize_avatar_url(Some("ftp://example.com/a".to_string())).is_err());
335        assert!(normalize_avatar_url(Some("/etc/passwd".to_string())).is_err());
336        // 超长拒绝。
337        let over = format!("/uploads/{}", "a".repeat(512));
338        assert!(normalize_avatar_url(Some(over)).is_err());
339    }
340}