1#![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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38pub struct GetProfileResponse {
39 pub user: PublicUser,
41 pub admin_env_active: bool,
43}
44
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47pub struct UpdateProfileResponse {
48 pub success: bool,
50 pub message: String,
52 pub user: Option<PublicUser>,
54}
55
56#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
58pub struct ChangePasswordResponse {
59 pub success: bool,
61 pub message: String,
63}
64
65#[cfg(feature = "server")]
66fn 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")]
79fn 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#[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#[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 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 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 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 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 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#[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 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 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(¤t_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 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 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 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 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 let fifty = "名".repeat(50);
377 assert_eq!(normalize_display_name(Some(fifty.clone())), Ok(Some(fifty)));
378 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 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 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}