Skip to main content

yggdrasil/api/comments/
helpers.rs

1//! 评论模块的辅助函数:数据转换、校验、哈希与头像生成。
2//!
3//! 所有工具函数仅在 `feature = "server"` 启用的服务端构建中使用。
4
5#![allow(clippy::unused_unit, deprecated)]
6
7#[cfg(feature = "server")]
8use crate::models::comment::{AdminComment, CommentStatus, PublicComment};
9
10/// 计算字符串的 MD5 哈希,用于 Gravatar。
11#[cfg(feature = "server")]
12pub fn md5_hash(input: &str) -> String {
13    use md5::Digest;
14    let hash = md5::Md5::digest(input.as_bytes());
15    hex::encode(hash)
16}
17
18/// 根据邮箱生成 Cravatar(Gravatar 国内镜像)头像 URL。
19#[cfg(feature = "server")]
20pub fn gravatar_url(email: &str) -> String {
21    let hash = md5_hash(&email.trim().to_lowercase());
22    format!("https://cravatar.cn/avatar/{}?d=mp&s=80", hash)
23}
24
25/// 解析评论的对外展示身份:`(名称, 头像 URL, 是否作者)`。
26///
27/// `user_id` 非空(登录用户发表的评论)时,优先使用 JOIN users 得到的
28/// **实时**显示名与头像——改名/换头像后历史评论自动跟随;匿名评论
29/// (`user_id` 为 NULL)回退到评论行内快照名 + 邮箱 Gravatar。
30/// 返回的名称经 HTML 转义,与 `author_name` 列的存储口径一致。
31#[cfg(feature = "server")]
32pub fn resolve_author_display(
33    author_name: &str,
34    author_email: &str,
35    user_id: Option<i32>,
36    user_display_name: Option<&str>,
37    user_avatar_url: Option<&str>,
38) -> (String, String, bool) {
39    let is_author = user_id.is_some();
40    let name = user_display_name
41        .map(str::trim)
42        .filter(|s| !s.is_empty())
43        .map(crate::utils::html::escape_html)
44        .unwrap_or_else(|| author_name.to_string());
45    let avatar = user_avatar_url
46        .map(str::trim)
47        .filter(|s| !s.is_empty())
48        .map(str::to_string)
49        .unwrap_or_else(|| gravatar_url(author_email));
50    (name, avatar, is_author)
51}
52
53/// 将数据库行转换为前端展示的公开评论结构。
54///
55/// 调用方查询须 LEFT JOIN users 并提供 `user_id` / `user_display_name` /
56/// `user_avatar` 三列(匿名评论为 NULL)。
57#[cfg(feature = "server")]
58pub fn row_to_public_comment(row: &tokio_postgres::Row) -> PublicComment {
59    let email: String = row.get("author_email");
60    let created_at_dt: chrono::DateTime<chrono::Utc> = row.get("created_at");
61    let created_at_iso = created_at_dt.to_rfc3339();
62    let created_at_relative = format_relative_time(created_at_dt);
63
64    let snapshot_name: String = row.get("author_name");
65    let user_id: Option<i32> = row.get("user_id");
66    let user_display_name: Option<String> = row.get("user_display_name");
67    let user_avatar: Option<String> = row.get("user_avatar");
68    let (author_name, avatar_url, is_author) = resolve_author_display(
69        &snapshot_name,
70        &email,
71        user_id,
72        user_display_name.as_deref(),
73        user_avatar.as_deref(),
74    );
75
76    PublicComment {
77        id: row.get("id"),
78        parent_id: row.get("parent_id"),
79        depth: row.get("depth"),
80        author_name,
81        author_url: row.get("author_url"),
82        avatar_url,
83        is_author,
84        content_html: row.get("content_html"),
85        created_at: created_at_relative,
86        created_at_iso,
87    }
88}
89
90/// 将数据库行转换为后台管理使用的评论结构。
91///
92/// 与 [`row_to_public_comment`] 同一套身份解析:查询须 LEFT JOIN users
93/// 提供 `user_id` / `user_display_name` / `user_avatar` 三列。
94#[cfg(feature = "server")]
95pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment {
96    let status_str: String = row.get("status");
97    let email: String = row.get("author_email");
98
99    let snapshot_name: String = row.get("author_name");
100    let user_id: Option<i32> = row.get("user_id");
101    let user_display_name: Option<String> = row.get("user_display_name");
102    let user_avatar: Option<String> = row.get("user_avatar");
103    let (author_name, avatar_url, _) = resolve_author_display(
104        &snapshot_name,
105        &email,
106        user_id,
107        user_display_name.as_deref(),
108        user_avatar.as_deref(),
109    );
110
111    AdminComment {
112        id: row.get("id"),
113        post_id: row.get("post_id"),
114        post_title: row.get("post_title"),
115        post_slug: row.get("post_slug"),
116        parent_id: row.get("parent_id"),
117        reply_to: row.try_get("parent_author").ok().flatten(),
118        depth: row.get("depth"),
119        author_name,
120        author_email: email.clone(),
121        author_url: row.get("author_url"),
122        avatar_url,
123        content_md: row.get("content_md"),
124        status: CommentStatus::from_str(&status_str),
125        created_at: row.get("created_at"),
126    }
127}
128
129/// 将 UTC 时间格式化为相对时间(刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。
130///
131/// 分档规则与前端 `crate::utils::time::relative_label_from_millis` 完全一致,
132/// 通过共享分档函数保证服务端预渲染与前端实时计算的口径统一。
133#[cfg(feature = "server")]
134pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
135    let now = chrono::Utc::now();
136    let delta_millis = now.signed_duration_since(dt).num_milliseconds();
137    let iso = dt.to_rfc3339();
138    crate::utils::time::relative_label_from_millis(delta_millis, &iso).0
139}
140
141/// 校验评论作者昵称:非空且不超过 50 字符。
142#[cfg(feature = "server")]
143pub fn validate_comment_name(name: &str) -> Result<(), String> {
144    let trimmed = name.trim();
145    if trimmed.is_empty() {
146        return Err("请输入昵称".to_string());
147    }
148    if trimmed.chars().count() > 50 {
149        return Err("昵称长度不能超过 50 个字符".to_string());
150    }
151    Ok(())
152}
153
154/// 校验评论作者邮箱格式。
155#[cfg(feature = "server")]
156pub fn validate_comment_email(email: &str) -> Result<(), String> {
157    if !crate::utils::server::EMAIL_REGEX.is_match(email.trim()) {
158        return Err("邮箱格式不正确".to_string());
159    }
160    Ok(())
161}
162
163/// 校验评论作者网址:为空时允许,非空时必须以 http:// 或 https:// 开头、
164/// 不含 HTML 特殊字符与空白,且不超过 200 字符。
165#[cfg(feature = "server")]
166pub fn validate_comment_url(url: &str) -> Result<(), String> {
167    let trimmed = url.trim();
168    if trimmed.is_empty() {
169        return Ok(());
170    }
171    let lower = trimmed.to_ascii_lowercase();
172    if !lower.starts_with("http://") && !lower.starts_with("https://") {
173        return Err("网址必须以 http:// 或 https:// 开头".to_string());
174    }
175    if trimmed.len() > 200 {
176        return Err("网址长度不能超过 200 个字符".to_string());
177    }
178    if trimmed
179        .chars()
180        .any(|c| matches!(c, '<' | '>' | '"' | '\'' | '&' | ' ' | '\t' | '\n' | '\r'))
181    {
182        return Err("网址包含非法字符".to_string());
183    }
184    Ok(())
185}
186
187/// 校验评论内容:非空且不超过 10000 字符。
188#[cfg(feature = "server")]
189pub fn validate_comment_content(content: &str) -> Result<(), String> {
190    let trimmed = content.trim();
191    if trimmed.is_empty() {
192        return Err("请输入评论内容".to_string());
193    }
194    if trimmed.chars().count() > 10000 {
195        return Err("评论内容不能超过 10000 个字符".to_string());
196    }
197    Ok(())
198}
199
200/// 校验蜜罐字段:正常用户蜜罐为空,机器人可能填入任意内容。
201///
202/// 为空视为通过;一旦被填入内容即判定为机器人提交并拒绝。
203/// 这是禁用 JS / 无视前端校验的机器人也能在服务端被拦下的防线。
204#[cfg(feature = "server")]
205pub fn validate_comment_honeypot(value: &str) -> Result<(), String> {
206    if value.is_empty() {
207        Ok(())
208    } else {
209        Err("评论提交异常".to_string())
210    }
211}
212
213/// 计算评论内容哈希,用于检测短时间内的重复提交。
214#[cfg(feature = "server")]
215pub fn compute_content_hash(
216    post_id: i32,
217    parent_id: Option<i64>,
218    name: &str,
219    content: &str,
220) -> String {
221    let input = format!(
222        "{}:{}:{}:{}",
223        post_id,
224        parent_id.map(|id| id.to_string()).unwrap_or_default(),
225        name.trim(),
226        content.trim()
227    );
228    crate::utils::server::sha256_hex(&input)
229}
230
231#[cfg(all(test, feature = "server"))]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn md5_hash_known_value() {
237        assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592");
238    }
239
240    #[test]
241    fn md5_hash_empty() {
242        assert_eq!(md5_hash(""), "d41d8cd98f00b204e9800998ecf8427e");
243    }
244
245    #[test]
246    fn gravatar_url_format() {
247        let url = gravatar_url("test@example.com");
248        assert!(url.starts_with("https://cravatar.cn/avatar/"));
249        assert!(url.contains("?d=mp&s=80"));
250    }
251
252    #[test]
253    fn gravatar_url_normalizes_email() {
254        let url1 = gravatar_url("Test@Example.com");
255        let url2 = gravatar_url("test@example.com");
256        assert_eq!(url1, url2);
257    }
258
259    #[test]
260    fn gravatar_url_trims_whitespace() {
261        let url1 = gravatar_url(" test@example.com ");
262        let url2 = gravatar_url("test@example.com");
263        assert_eq!(url1, url2);
264    }
265
266    #[test]
267    fn resolve_author_display_anonymous_uses_snapshot_and_gravatar() {
268        let (name, avatar, is_author) =
269            resolve_author_display("Alice", "a@example.com", None, None, None);
270        assert_eq!(name, "Alice");
271        assert_eq!(avatar, gravatar_url("a@example.com"));
272        assert!(!is_author);
273    }
274
275    #[test]
276    fn resolve_author_display_user_with_profile_fields() {
277        let (name, avatar, is_author) = resolve_author_display(
278            "snapshot",
279            "a@example.com",
280            Some(1),
281            Some("站长"),
282            Some("/uploads/a.webp"),
283        );
284        assert_eq!(name, "站长");
285        assert_eq!(avatar, "/uploads/a.webp");
286        assert!(is_author);
287    }
288
289    #[test]
290    fn resolve_author_display_user_without_display_name_falls_back_to_snapshot() {
291        // 登录用户未设显示名/头像:名称回退到写入时的快照(username),头像回退 Gravatar。
292        let (name, avatar, is_author) =
293            resolve_author_display("admin", "a@example.com", Some(1), Some("  "), None);
294        assert_eq!(name, "admin");
295        assert_eq!(avatar, gravatar_url("a@example.com"));
296        assert!(is_author);
297    }
298
299    #[test]
300    fn resolve_author_display_escapes_live_display_name() {
301        // users.display_name 是未转义原始输入,与 author_name 列口径一致需转义。
302        let (name, _, _) =
303            resolve_author_display("snapshot", "a@example.com", Some(1), Some("<b>x</b>"), None);
304        assert!(!name.contains('<'));
305    }
306
307    #[test]
308    fn format_relative_time_just_now() {
309        let now = chrono::Utc::now();
310        assert_eq!(format_relative_time(now), "刚刚");
311    }
312
313    #[test]
314    fn format_relative_time_minutes() {
315        let dt = chrono::Utc::now() - chrono::Duration::minutes(5);
316        assert_eq!(format_relative_time(dt), "5 分钟前");
317    }
318
319    #[test]
320    fn format_relative_time_hours() {
321        let dt = chrono::Utc::now() - chrono::Duration::hours(3);
322        assert_eq!(format_relative_time(dt), "3 小时前");
323    }
324
325    #[test]
326    fn format_relative_time_days() {
327        let dt = chrono::Utc::now() - chrono::Duration::days(7);
328        assert_eq!(format_relative_time(dt), "7 天前");
329    }
330
331    #[test]
332    fn format_relative_time_one_minute() {
333        let dt = chrono::Utc::now() - chrono::Duration::minutes(1);
334        assert_eq!(format_relative_time(dt), "1 分钟前");
335    }
336
337    #[test]
338    fn format_relative_time_one_hour() {
339        let dt = chrono::Utc::now() - chrono::Duration::hours(1);
340        assert_eq!(format_relative_time(dt), "1 小时前");
341    }
342
343    #[test]
344    fn format_relative_time_one_day() {
345        let dt = chrono::Utc::now() - chrono::Duration::days(1);
346        assert_eq!(format_relative_time(dt), "1 天前");
347    }
348
349    #[test]
350    fn format_relative_time_old_date() {
351        let dt = chrono::Utc::now() - chrono::Duration::days(60);
352        let result = format_relative_time(dt);
353        assert!(result.contains('-'));
354        assert_eq!(result.len(), 10);
355    }
356
357    #[test]
358    fn validate_comment_name_valid() {
359        assert!(validate_comment_name("Alice").is_ok());
360        assert!(validate_comment_name("张三").is_ok());
361    }
362
363    #[test]
364    fn validate_comment_name_empty() {
365        assert!(validate_comment_name("").is_err());
366        assert!(validate_comment_name("   ").is_err());
367    }
368
369    #[test]
370    fn validate_comment_name_too_long() {
371        assert!(validate_comment_name(&"a".repeat(51)).is_err());
372    }
373
374    #[test]
375    fn validate_comment_name_max_length() {
376        assert!(validate_comment_name(&"a".repeat(50)).is_ok());
377    }
378
379    #[test]
380    fn validate_comment_name_counts_chinese_chars_not_bytes() {
381        // 20 个汉字 = 60 字节,按字符计应通过;51 个汉字超过 50 字符应拒绝。
382        assert!(validate_comment_name(&"昵".repeat(20)).is_ok());
383        assert!(validate_comment_name(&"昵".repeat(50)).is_ok());
384        assert!(validate_comment_name(&"昵".repeat(51)).is_err());
385    }
386
387    #[test]
388    fn validate_comment_email_valid() {
389        assert!(validate_comment_email("user@example.com").is_ok());
390        assert!(validate_comment_email("a.b+c@domain.co").is_ok());
391    }
392
393    #[test]
394    fn validate_comment_email_invalid() {
395        assert!(validate_comment_email("notanemail").is_err());
396        assert!(validate_comment_email("@domain.com").is_err());
397        assert!(validate_comment_email("user@").is_err());
398    }
399
400    #[test]
401    fn validate_comment_url_valid() {
402        assert!(validate_comment_url("http://example.com").is_ok());
403        assert!(validate_comment_url("https://example.com/path").is_ok());
404    }
405
406    #[test]
407    fn validate_comment_url_empty_is_ok() {
408        assert!(validate_comment_url("").is_ok());
409        assert!(validate_comment_url("   ").is_ok());
410    }
411
412    #[test]
413    fn validate_comment_url_invalid_scheme() {
414        assert!(validate_comment_url("ftp://example.com").is_err());
415        assert!(validate_comment_url("javascript:alert(1)").is_err());
416    }
417
418    #[test]
419    fn validate_comment_url_uppercase_scheme() {
420        assert!(validate_comment_url("HTTP://example.com").is_ok());
421        assert!(validate_comment_url("HTTPS://example.com").is_ok());
422        assert!(validate_comment_url("Http://example.com").is_ok());
423    }
424
425    #[test]
426    fn validate_comment_url_fragment() {
427        assert!(validate_comment_url("https://example.com#section").is_ok());
428    }
429
430    #[test]
431    fn validate_comment_url_relative_path_rejected() {
432        assert!(validate_comment_url("/path/to/page").is_err());
433        assert!(validate_comment_url("path/to/page").is_err());
434    }
435
436    #[test]
437    fn validate_comment_url_too_long() {
438        let long_url = format!("https://example.com/{}", "a".repeat(200));
439        assert!(validate_comment_url(&long_url).is_err());
440    }
441
442    #[test]
443    fn validate_comment_content_valid() {
444        assert!(validate_comment_content("Hello world").is_ok());
445    }
446
447    #[test]
448    fn validate_comment_content_empty() {
449        assert!(validate_comment_content("").is_err());
450        assert!(validate_comment_content("   ").is_err());
451    }
452
453    #[test]
454    fn validate_comment_content_too_long() {
455        assert!(validate_comment_content(&"a".repeat(10001)).is_err());
456    }
457
458    #[test]
459    fn validate_comment_content_counts_chars_not_bytes() {
460        // 10000 个汉字 = 30000 字节,按字符计恰好在上限内。
461        assert!(validate_comment_content(&"评".repeat(10000)).is_ok());
462        assert!(validate_comment_content(&"评".repeat(10001)).is_err());
463    }
464
465    #[test]
466    fn validate_comment_content_max_length() {
467        assert!(validate_comment_content(&"a".repeat(10000)).is_ok());
468    }
469
470    #[test]
471    fn compute_content_hash_deterministic() {
472        let h1 = compute_content_hash(1, None, "Alice", "Hello");
473        let h2 = compute_content_hash(1, None, "Alice", "Hello");
474        assert_eq!(h1, h2);
475    }
476
477    #[test]
478    fn compute_content_hash_different_inputs() {
479        let h1 = compute_content_hash(1, None, "Alice", "Hello");
480        let h2 = compute_content_hash(2, None, "Alice", "Hello");
481        assert_ne!(h1, h2);
482    }
483
484    #[test]
485    fn compute_content_hash_trims_whitespace() {
486        let h1 = compute_content_hash(1, None, "Alice", "Hello");
487        let h2 = compute_content_hash(1, None, " Alice ", " Hello ");
488        assert_eq!(h1, h2);
489    }
490
491    #[test]
492    fn compute_content_hash_64_hex_chars() {
493        let h = compute_content_hash(1, None, "Alice", "Hello");
494        assert_eq!(h.len(), 64);
495        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
496    }
497
498    #[test]
499    fn compute_content_hash_matches_stored_format() {
500        // 锁定存量格式:sha256("{post_id}:{parent_id}:{name}:{content}") 小写 hex。
501        assert_eq!(
502            compute_content_hash(7, Some(42), "Alice", "hello"),
503            "13ffdf7edd453c9ec3f29ae6a1b000019c506fbd6115919313b368675c6a3c30"
504        );
505    }
506
507    #[test]
508    fn validate_comment_honeypot_empty_is_ok() {
509        assert!(validate_comment_honeypot("").is_ok());
510    }
511
512    #[test]
513    fn validate_comment_honeypot_filled_is_err() {
514        assert!(validate_comment_honeypot("anything").is_err());
515        assert!(validate_comment_honeypot(" ").is_err());
516    }
517}