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/// 将数据库行转换为前端展示的公开评论结构。
26#[cfg(feature = "server")]
27pub fn row_to_public_comment(row: &tokio_postgres::Row) -> PublicComment {
28    let email: String = row.get("author_email");
29    let created_at_dt: chrono::DateTime<chrono::Utc> = row.get("created_at");
30    let created_at_iso = created_at_dt.to_rfc3339();
31    let created_at_relative = format_relative_time(created_at_dt);
32
33    PublicComment {
34        id: row.get("id"),
35        parent_id: row.get("parent_id"),
36        depth: row.get("depth"),
37        author_name: row.get("author_name"),
38        author_url: row.get("author_url"),
39        avatar_url: gravatar_url(&email),
40        content_html: row.get("content_html"),
41        created_at: created_at_relative,
42        created_at_iso,
43    }
44}
45
46/// 将数据库行转换为后台管理使用的评论结构。
47#[cfg(feature = "server")]
48pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment {
49    let status_str: String = row.get("status");
50    let email: String = row.get("author_email");
51
52    AdminComment {
53        id: row.get("id"),
54        post_id: row.get("post_id"),
55        post_title: row.get("post_title"),
56        post_slug: row.get("post_slug"),
57        parent_id: row.get("parent_id"),
58        depth: row.get("depth"),
59        author_name: row.get("author_name"),
60        author_email: email.clone(),
61        author_url: row.get("author_url"),
62        avatar_url: gravatar_url(&email),
63        content_md: row.get("content_md"),
64        status: CommentStatus::from_str(&status_str),
65        created_at: row.get("created_at"),
66    }
67}
68
69/// 将 UTC 时间格式化为相对时间(刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。
70///
71/// 分档规则与前端 `crate::utils::time::relative_label_from_millis` 完全一致,
72/// 通过共享分档函数保证服务端预渲染与前端实时计算的口径统一。
73#[cfg(feature = "server")]
74pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
75    let now = chrono::Utc::now();
76    let delta_millis = now.signed_duration_since(dt).num_milliseconds();
77    let iso = dt.to_rfc3339();
78    crate::utils::time::relative_label_from_millis(delta_millis, &iso).0
79}
80
81/// 校验评论作者昵称:非空且不超过 50 字符。
82#[cfg(feature = "server")]
83pub fn validate_comment_name(name: &str) -> Result<(), String> {
84    let trimmed = name.trim();
85    if trimmed.is_empty() {
86        return Err("请输入昵称".to_string());
87    }
88    if trimmed.len() > 50 {
89        return Err("昵称长度不能超过 50 个字符".to_string());
90    }
91    Ok(())
92}
93
94/// 校验评论作者邮箱格式。
95#[cfg(feature = "server")]
96pub fn validate_comment_email(email: &str) -> Result<(), String> {
97    if !crate::utils::server::EMAIL_REGEX.is_match(email.trim()) {
98        return Err("邮箱格式不正确".to_string());
99    }
100    Ok(())
101}
102
103/// 校验评论作者网址:为空时允许,非空时必须以 http:// 或 https:// 开头、
104/// 不含 HTML 特殊字符与空白,且不超过 200 字符。
105#[cfg(feature = "server")]
106pub fn validate_comment_url(url: &str) -> Result<(), String> {
107    let trimmed = url.trim();
108    if trimmed.is_empty() {
109        return Ok(());
110    }
111    let lower = trimmed.to_ascii_lowercase();
112    if !lower.starts_with("http://") && !lower.starts_with("https://") {
113        return Err("网址必须以 http:// 或 https:// 开头".to_string());
114    }
115    if trimmed.len() > 200 {
116        return Err("网址长度不能超过 200 个字符".to_string());
117    }
118    if trimmed
119        .chars()
120        .any(|c| matches!(c, '<' | '>' | '"' | '\'' | '&' | ' ' | '\t' | '\n' | '\r'))
121    {
122        return Err("网址包含非法字符".to_string());
123    }
124    Ok(())
125}
126
127/// 校验评论内容:非空且不超过 10000 字符。
128#[cfg(feature = "server")]
129pub fn validate_comment_content(content: &str) -> Result<(), String> {
130    let trimmed = content.trim();
131    if trimmed.is_empty() {
132        return Err("请输入评论内容".to_string());
133    }
134    if trimmed.len() > 10000 {
135        return Err("评论内容不能超过 10000 个字符".to_string());
136    }
137    Ok(())
138}
139
140/// 校验蜜罐字段:正常用户蜜罐为空,机器人可能填入任意内容。
141///
142/// 为空视为通过;一旦被填入内容即判定为机器人提交并拒绝。
143/// 这是禁用 JS / 无视前端校验的机器人也能在服务端被拦下的防线。
144#[cfg(feature = "server")]
145pub fn validate_comment_honeypot(value: &str) -> Result<(), String> {
146    if value.is_empty() {
147        Ok(())
148    } else {
149        Err("评论提交异常".to_string())
150    }
151}
152
153/// 计算评论内容哈希,用于检测短时间内的重复提交。
154#[cfg(feature = "server")]
155pub fn compute_content_hash(
156    post_id: i32,
157    parent_id: Option<i64>,
158    name: &str,
159    content: &str,
160) -> String {
161    use sha2::Digest;
162    let input = format!(
163        "{}:{}:{}:{}",
164        post_id,
165        parent_id.map(|id| id.to_string()).unwrap_or_default(),
166        name.trim(),
167        content.trim()
168    );
169    let hash = sha2::Sha256::digest(input.as_bytes());
170    hex::encode(hash)
171}
172
173#[cfg(all(test, feature = "server"))]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn md5_hash_known_value() {
179        assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592");
180    }
181
182    #[test]
183    fn md5_hash_empty() {
184        assert_eq!(md5_hash(""), "d41d8cd98f00b204e9800998ecf8427e");
185    }
186
187    #[test]
188    fn gravatar_url_format() {
189        let url = gravatar_url("test@example.com");
190        assert!(url.starts_with("https://cravatar.cn/avatar/"));
191        assert!(url.contains("?d=mp&s=80"));
192    }
193
194    #[test]
195    fn gravatar_url_normalizes_email() {
196        let url1 = gravatar_url("Test@Example.com");
197        let url2 = gravatar_url("test@example.com");
198        assert_eq!(url1, url2);
199    }
200
201    #[test]
202    fn gravatar_url_trims_whitespace() {
203        let url1 = gravatar_url(" test@example.com ");
204        let url2 = gravatar_url("test@example.com");
205        assert_eq!(url1, url2);
206    }
207
208    #[test]
209    fn format_relative_time_just_now() {
210        let now = chrono::Utc::now();
211        assert_eq!(format_relative_time(now), "刚刚");
212    }
213
214    #[test]
215    fn format_relative_time_minutes() {
216        let dt = chrono::Utc::now() - chrono::Duration::minutes(5);
217        assert_eq!(format_relative_time(dt), "5 分钟前");
218    }
219
220    #[test]
221    fn format_relative_time_hours() {
222        let dt = chrono::Utc::now() - chrono::Duration::hours(3);
223        assert_eq!(format_relative_time(dt), "3 小时前");
224    }
225
226    #[test]
227    fn format_relative_time_days() {
228        let dt = chrono::Utc::now() - chrono::Duration::days(7);
229        assert_eq!(format_relative_time(dt), "7 天前");
230    }
231
232    #[test]
233    fn format_relative_time_one_minute() {
234        let dt = chrono::Utc::now() - chrono::Duration::minutes(1);
235        assert_eq!(format_relative_time(dt), "1 分钟前");
236    }
237
238    #[test]
239    fn format_relative_time_one_hour() {
240        let dt = chrono::Utc::now() - chrono::Duration::hours(1);
241        assert_eq!(format_relative_time(dt), "1 小时前");
242    }
243
244    #[test]
245    fn format_relative_time_one_day() {
246        let dt = chrono::Utc::now() - chrono::Duration::days(1);
247        assert_eq!(format_relative_time(dt), "1 天前");
248    }
249
250    #[test]
251    fn format_relative_time_old_date() {
252        let dt = chrono::Utc::now() - chrono::Duration::days(60);
253        let result = format_relative_time(dt);
254        assert!(result.contains('-'));
255        assert_eq!(result.len(), 10);
256    }
257
258    #[test]
259    fn validate_comment_name_valid() {
260        assert!(validate_comment_name("Alice").is_ok());
261        assert!(validate_comment_name("张三").is_ok());
262    }
263
264    #[test]
265    fn validate_comment_name_empty() {
266        assert!(validate_comment_name("").is_err());
267        assert!(validate_comment_name("   ").is_err());
268    }
269
270    #[test]
271    fn validate_comment_name_too_long() {
272        assert!(validate_comment_name(&"a".repeat(51)).is_err());
273    }
274
275    #[test]
276    fn validate_comment_name_max_length() {
277        assert!(validate_comment_name(&"a".repeat(50)).is_ok());
278    }
279
280    #[test]
281    fn validate_comment_email_valid() {
282        assert!(validate_comment_email("user@example.com").is_ok());
283        assert!(validate_comment_email("a.b+c@domain.co").is_ok());
284    }
285
286    #[test]
287    fn validate_comment_email_invalid() {
288        assert!(validate_comment_email("notanemail").is_err());
289        assert!(validate_comment_email("@domain.com").is_err());
290        assert!(validate_comment_email("user@").is_err());
291    }
292
293    #[test]
294    fn validate_comment_url_valid() {
295        assert!(validate_comment_url("http://example.com").is_ok());
296        assert!(validate_comment_url("https://example.com/path").is_ok());
297    }
298
299    #[test]
300    fn validate_comment_url_empty_is_ok() {
301        assert!(validate_comment_url("").is_ok());
302        assert!(validate_comment_url("   ").is_ok());
303    }
304
305    #[test]
306    fn validate_comment_url_invalid_scheme() {
307        assert!(validate_comment_url("ftp://example.com").is_err());
308        assert!(validate_comment_url("javascript:alert(1)").is_err());
309    }
310
311    #[test]
312    fn validate_comment_url_uppercase_scheme() {
313        assert!(validate_comment_url("HTTP://example.com").is_ok());
314        assert!(validate_comment_url("HTTPS://example.com").is_ok());
315        assert!(validate_comment_url("Http://example.com").is_ok());
316    }
317
318    #[test]
319    fn validate_comment_url_fragment() {
320        assert!(validate_comment_url("https://example.com#section").is_ok());
321    }
322
323    #[test]
324    fn validate_comment_url_relative_path_rejected() {
325        assert!(validate_comment_url("/path/to/page").is_err());
326        assert!(validate_comment_url("path/to/page").is_err());
327    }
328
329    #[test]
330    fn validate_comment_url_too_long() {
331        let long_url = format!("https://example.com/{}", "a".repeat(200));
332        assert!(validate_comment_url(&long_url).is_err());
333    }
334
335    #[test]
336    fn validate_comment_content_valid() {
337        assert!(validate_comment_content("Hello world").is_ok());
338    }
339
340    #[test]
341    fn validate_comment_content_empty() {
342        assert!(validate_comment_content("").is_err());
343        assert!(validate_comment_content("   ").is_err());
344    }
345
346    #[test]
347    fn validate_comment_content_too_long() {
348        assert!(validate_comment_content(&"a".repeat(10001)).is_err());
349    }
350
351    #[test]
352    fn validate_comment_content_max_length() {
353        assert!(validate_comment_content(&"a".repeat(10000)).is_ok());
354    }
355
356    #[test]
357    fn compute_content_hash_deterministic() {
358        let h1 = compute_content_hash(1, None, "Alice", "Hello");
359        let h2 = compute_content_hash(1, None, "Alice", "Hello");
360        assert_eq!(h1, h2);
361    }
362
363    #[test]
364    fn compute_content_hash_different_inputs() {
365        let h1 = compute_content_hash(1, None, "Alice", "Hello");
366        let h2 = compute_content_hash(2, None, "Alice", "Hello");
367        assert_ne!(h1, h2);
368    }
369
370    #[test]
371    fn compute_content_hash_trims_whitespace() {
372        let h1 = compute_content_hash(1, None, "Alice", "Hello");
373        let h2 = compute_content_hash(1, None, " Alice ", " Hello ");
374        assert_eq!(h1, h2);
375    }
376
377    #[test]
378    fn compute_content_hash_64_hex_chars() {
379        let h = compute_content_hash(1, None, "Alice", "Hello");
380        assert_eq!(h.len(), 64);
381        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
382    }
383
384    #[test]
385    fn validate_comment_honeypot_empty_is_ok() {
386        assert!(validate_comment_honeypot("").is_ok());
387    }
388
389    #[test]
390    fn validate_comment_honeypot_filled_is_err() {
391        assert!(validate_comment_honeypot("anything").is_err());
392        assert!(validate_comment_honeypot(" ").is_err());
393    }
394}