1#![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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
36pub struct GetProfileResponse {
37 pub user: PublicUser,
39 pub admin_env_active: bool,
41}
42
43#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct UpdateProfileResponse {
46 pub success: bool,
48 pub message: String,
50 pub user: Option<PublicUser>,
52}
53
54#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56pub struct ChangePasswordResponse {
57 pub success: bool,
59 pub message: String,
61}
62
63#[cfg(feature = "server")]
64fn 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")]
77fn 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#[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#[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 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 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 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#[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 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 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(¤t_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 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 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 let fifty = "名".repeat(50);
314 assert_eq!(normalize_display_name(Some(fifty.clone())), Ok(Some(fifty)));
315 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 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 let over = format!("/uploads/{}", "a".repeat(512));
338 assert!(normalize_avatar_url(Some(over)).is_err());
339 }
340}