1#![allow(clippy::unused_unit, deprecated)]
6
7#[cfg(feature = "server")]
8use crate::models::comment::{AdminComment, CommentStatus, PublicComment};
9
10#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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 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 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 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 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 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}